Skip to content

sql: keep the callback error as cause when ROLLBACK fails - #41817

Open
robobun wants to merge 2 commits into
mainfrom
robobun/bc46340c/sql-rollback-error-cause
Open

robobun wants to merge 2 commits into
mainfrom
robobun/bc46340c/sql-rollback-error-cause

Conversation

@robobun

@robobun robobun commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • When the callback of sql.begin() throws and the ROLLBACK then fails too, the rejection is the rollback failure alone (PostgresError: Connection closed, code: "ERR_POSTGRES_CONNECTION_CLOSED", cause: undefined, or the server's 25P01). The callback error is lost. Same for savepoint(), on PostgreSQL and MySQL.
  • The cause is the catch block of onTransactionConnected (src/js/bun/sql.ts:893). It rejects with the rollback error and drops the callback error. When the connection drops during ROLLBACK, onTransactionDisconnected rejects with the connection error even earlier.

Fix

  • The rollback failure stays the rejection (as today, and as in postgres.js). It now carries the callback error as cause. A working ROLLBACK still rejects with the callback error itself.
  • withCause puts cause on a copy of the error. A dropped connection rejects every query queued on it with one shared error object, so a mutation would leak cause into unrelated rejections.
  • A rollbacks counter makes onTransactionDisconnected leave the rejection to the code that awaits the ROLLBACK, which knows the callback error. A counter, because savepoints can run concurrently.
  • Verified: test/js/sql/sql-transaction-rollback-failure.test.ts (mock PostgreSQL and MySQL peers, 10 of 12 tests fail on bun 1.4.3). Also ran sql-pool-transaction-isolation, sqlite-sql, sql-reserve-abort. Self-reviewed: 1 concern raised (see Notes), 0 defects.

Background

  • sql.begin(fn) reserves a connection, sends BEGIN, runs fn, then COMMIT, or ROLLBACK when fn throws. onTransactionConnected is that runner for every adapter.
  • onTransactionDisconnected is the close handler the runner attaches to the connection. It rejects the in-flight queries and the begin() promise.
Notes
  • sql(sqlite): detect SQLite's automatic ROLLBACK inside sql.begin() #33749 touches the same catch block for SQLite's automatic rollback. It swallows the cleanup error and rejects with the callback error. That covers the server-error case but not the dropped-connection case, because the close handler rejects first. This PR keeps the current primary error and only adds cause, so code that matches on e.code sees no change. The two PRs conflict on a few lines. Either merge order is easy to resolve.
  • The copy in withCause is new Error(message, { cause }) with the original's prototype and own enumerable properties (name, code, errno, ...), so instanceof SQL.PostgresError and .code keep working.
  • Review concern, accepted as intended: if the connection drops while a ROLLBACK TO SAVEPOINT is in flight, begin() now rejects when the callback finishes instead of at once, because the savepoint error (with its cause) is rethrown into the callback first. A callback that swallows that error and then waits forever on non-SQL work keeps begin() pending. Any further tx query rejects at once with the connection error.
  • SQLite: after an automatic rollback (OR ROLLBACK), the ROLLBACK fails with "cannot rollback - no transaction is active". That error now carries the constraint error as cause. sql(sqlite): detect SQLite's automatic ROLLBACK inside sql.begin() #33749 avoids the failing ROLLBACK entirely for that case, which is still the better fix there.
  • Reported through an internal ledger entry with a scripted PostgreSQL peer (--drop-on ROLLBACK, --err-word ROLLBACK:25P01).

When the callback of sql.begin() or sql.savepoint() throws, the driver
sends ROLLBACK (or ROLLBACK TO SAVEPOINT). If that statement failed, or
the connection dropped under it, the transaction rejected with the
rollback failure alone and the callback error was lost. The rollback
failure stays the rejection and now carries the callback error as its
cause.
@coderabbitai

coderabbitai Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 13 days. After that, they cost $0.25 per reviewed file.

Or wait 57 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 9b454585-ab6c-450f-8783-a4a4de0d1a94

📥 Commits

Reviewing files that changed from the base of the PR and between 62838e1 and aa9035d.

📒 Files selected for processing (2)
  • src/js/bun/sql.ts
  • test/js/sql/sql-transaction-rollback-failure.test.ts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Sep 7, 2026
@robobun

robobun commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:55 PM PT - Sep 7th, 2026

❌ @Jarred-Sumner, your commit aa9035d has 7 failures in Build #112233 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 41817

That installs a local version of the PR into your bun-41817 executable, so you can run:

bun-41817 --bun

@robobun

robobun commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on bun 1.4.3 with the mock peers in test/js/sql/sql-transaction-rollback-failure.test.ts (a statement starting with ROLLBACK gets an ErrorResponse 25P01 / MySQL ERR 1305, or a socket.destroy()):

USE_SYSTEM_BUN=1 bun test test/js/sql/sql-transaction-rollback-failure.test.ts   # 10 fail, 2 pass
bun bd test test/js/sql/sql-transaction-rollback-failure.test.ts                 # 12 pass

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the outer transaction rollback path (src/js/bun/sql.ts:891-903) for a missing finally { state.rollbacks-- } — it is safe: that path terminates in reject() and the per-call state object is never reused, so the counter staying at 1 cannot affect a later disconnect.

Extended reasoning...

The change restructures two catch blocks and adds a counter-based guard in the disconnect handler. The savepoint path decrements rollbacks in a finally; the outer begin() path does not. I traced the outer path: on both branches (rollback succeeds → reject(err); rollback throws → reject(withCause(...))) the begin() promise settles and the enclosing finally releases the connection, after which state is unreachable and onTransactionDisconnected will not fire again for it — so the un-decremented counter is dead. The two inline findings (unsettled-promise hazard when a user callback swallows a savepoint disconnect error, and non-primordial Object.* / in usage in withCause) stand on their own and warrant human review; I had nothing further to add there.

Comment thread src/js/bun/sql.ts
}

if (err) {
if (err && this.rollbacks === 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Suppressing reject(err) while a savepoint rollback is in flight can leave sql.begin() unsettled: when the connection drops during ROLLBACK TO SAVEPOINT, the error is now thrown into the user's transaction callback instead of rejecting begin() directly, so a callback that catches its tx.savepoint(...) rejection and then awaits non-SQL work never lets the outer catch run — on the base branch begin() rejected immediately here. The PR notes this as accepted; it still violates "settle every pending promise slot". Fix: after state.rollbacks returns to 0 in the savepoint finally, if connectionState is closed reject the outer promise with the composed error so disconnect always settles begin().

Extended reasoning...

Path: user calls sql.begin(async tx => { try { await tx.savepoint(cb) } catch (e) { log(e); await someExternalEvent; } }). cb throws → run_internal_savepoint enters its catch, sets state.rollbacks = 1, awaits ROLLBACK TO SAVEPOINT. The server drops the socket → onTransactionDisconnected runs, marks connectionState |= closed, rejects every queued query, then hits line 270: err is truthy but this.rollbacks === 1, so reject(err) is skipped. The awaited rollback rejects, withCause wraps it, the savepoint finally decrements rollbacks to 0, and the wrapped error is thrown to the user's catch (e). The user swallows it and awaits something unrelated to tx (any further tx query would reject immediately because connectionState is closed, but non-SQL awaits do not). await callback(transaction_sql) at line 882 never resolves, so neither resolve nor reject is called and the finally at 906 never releases the pooled connection. On the base branch onTransactionDisconnected unconditionally called reject(err), so begin() settled immediately even if the…

Verification: nit — acknowledged in diff: the PR "Notes" section describes exactly this scenario ("A callback that swallows that error and then waits forever on non-SQL work keeps begin() pending"); the note's claims hold. The path is real and reachable: - src/js/bun/sql.ts:835-842 — when the savepoint callback throws, state.rollbacks++ is set before awaiting ROLLBACK TO SAVEPOINT. If the socket…

Comment thread src/js/bun/sql.ts
Comment on lines +48 to +51
if (!Error.isError(error) || "cause" in error) return error;
const copy = new Error(error.message, { cause });
Object.setPrototypeOf(copy, Object.getPrototypeOf(error));
return Object.assign(copy, error);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) withCause calls Object.setPrototypeOf/getPrototypeOf/assign (and checks "cause" in error) at call time; a user that monkeypatches Object.assign or sets Error.prototype.cause before their transaction callback throws will make the ROLLBACK error path throw or silently drop the callback error — base branch had no such dependency. Fix: follow the src/js/ primordial convention — capture Object.getPrototypeOf/setPrototypeOf/assign into module-level consts at load time (as node/vm.ts, _http_client.ts, etc. do) and test own-property presence via $getOwnPropertyDescriptor/hasOwn rather than the prototype-walking in operator.

Extended reasoning...

REVIEW.md requires builtins in src/js/ to be tamper-resistant with "globals captured at module load". withCause runs on the ROLLBACK-failed path with user code already on the stack. If user code has done Object.assign = () => { throw new Error() }, line 51 throws inside the outer catch at sql.ts:902 (return reject(withCause(...))): the exception escapes the catch, the finally releases the connection, and the thrown value propagates out of onTransactionConnected — the sql.begin() promise is never settled (base branch called reject(err) unconditionally). Separately, "cause" in error walks the prototype chain, so Error.prototype.cause = undefined makes every rollback error short-circuit at line 48 and return the shared connection-closed error unchanged, both dropping the callback error and mutating-by-omission defeating the whole purpose of the copy. Other src/js/ files that need these methods capture them at module scope (const ObjectSetPrototypeOf = Object.setPrototypeOf in node/vm.ts, internal/serialization_buffers.ts, const ObjectAssign = Object.assign…

Verification: nit — the tamper-resistance concern is real per REVIEW.md ("Built-in JS modules (src/js/) are hot-path code in a hostile environment... globals captured at module load") and widespread repo convention (src/js/internal/util/inspect.js:83-95, src/js/node/vm.ts:24-25, src/js/internal/assert/assertion_error.ts:10-12, etc. all do const ObjectAssign = Object.assign;), but the concrete…

Comment thread src/js/bun/sql.ts
Comment on lines +29 to +31
/// ROLLBACK statements in flight after a callback threw. While non-zero, a
/// disconnect leaves the rejection to the code awaiting the ROLLBACK, which
/// attaches the callback error as `cause`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/bun/sql.ts
Comment on lines +45 to +46
/// Returns `error` with `cause` attached. A dropped connection rejects every
/// waiter with one shared error object, so the cause goes on a copy.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants