Skip to content

sql: reject a transaction or reserved query that first runs after the handle closed - #43261

Open
robobun wants to merge 1 commit into
robobun/9774a575/sql-handle-unsafe-file-closed-guardfrom
robobun/a8c4e0d7/sql-lazy-query-closed-handle
Open

robobun wants to merge 1 commit into
robobun/9774a575/sql-handle-unsafe-file-closed-guardfrom
robobun/a8c4e0d7/sql-lazy-query-closed-handle

Conversation

@robobun

@robobun robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #43249. The base is that PR's branch. Only the last commit is new.

Problem

Fix

  • queryFromTransaction and unsafeQueryFromTransaction pass the TransactionState to the handler instead of its query set. The handler rejects with the adapter's connection closed error when the closed bit is set. A query that was already sent is not affected.
  • The handler checks closed only, not acceptQueries. close() clears acceptQueries before it sends ROLLBACK through the same handler, and close({ timeout }) drains pending queries in that window.
  • Behaviour change, decided here: return reserved\...`withoutawaitfrom ausing reservedblock or afinally { reserved.release() }block now rejects withERR_*_CONNECTION_CLOSED. The query first runs after release(), on a connection that can already belong to another caller. The docs always await inside the block. One new test covers both forms, and docs/runtime/sql.mdx` gets one paragraph.
  • Verified: test/js/sql/sql-pool-transaction-isolation.test.ts (three new tests per adapter on the postgres and mysql mock servers) and test/js/sql/sqlite-sql.test.ts (two new cases). All ten fail on the base without the src/ change. Also sql.test.ts, postgres-listen-notify.test.ts, sql-reserve-abort.test.ts, sql-close-pending-connection.test.ts.

Background

  • A Query (src/js/internal/sql/query.ts) is a promise subclass. It does nothing until then(), catch(), finally() or execute() runs its handler. await calls then().
  • A transaction handle (sql.begin(tx => ...)) and a reserved handle (sql.reserve()) share one TransactionState. Its connectionState bit field has acceptQueries, closed and released. Commit, rollback, close() and release() set closed.
  • pool.release() hands the connection back to the pool. With max: 1 the next sql.begin() gets the same connection, so a late statement runs inside that transaction.
Notes
  • expect(query).rejects hangs on a Query: the matcher resolves the value without the overridden then(), so the lazy query never runs. The tests use query.then(() => null, e => e.code) like the rest of the file.
  • The mock tests use unsafe() without parameters because the mock servers answer simple protocol queries only. The SQLite test covers the tagged template form.
  • sql.begin(async tx => tx\...`)andsql.begin(async tx => [tx`a`, tx`b`])still work:onTransactionConnectedawaits the callback result beforeCOMMIT`, so those queries run while the handle is open.
  • test/js/sql/sql-mysql.transactions.test.ts fails in this container with Access denied for user 'root'@'localhost' on main as well. It is a local service setup issue, not this change.
  • Repro from the issue, before: lazy resolved: [], rows: [{ v: "lazy" }]. After: lazy rejected: ERR_SQLITE_CONNECTION_CLOSED, rows: []. Same on a real PostgreSQL for the transaction and the reserved handle.
  • Self-reviewed: 5 concerns raised, 5 addressed (stack on sql: reject unsafe() and file() on a closed transaction or reserved handle #43249, link the issue, state the behaviour change in the commit and body, test the using and finally forms, add the docs line).

[human-review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 10 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/sql/sql-pool-transaction-isolation.test.ts test/js/sql/sqlite-sql.test.ts
bun test v1.4.3 (b52d51348)

test/js/sql/sqlite-sql.test.ts:
(pass) Connection & Initialization > common default connection strings > should parse common connection strings [121.80ms]
(pass) Connection & Initialization > should connect to in-memory SQLite database [17.96ms]
(pass) Connection & Initialization > should connect to file-based SQLite database [62.42ms]
(pass) Connection & Initialization > should handle connection with options object [152.58ms]
(pass) Connection & Initialization > onconnect and onclose callbacks are invoked for SQLite [27.39ms]
(pass) Connection & Initialization > onconnect receives Error when open fails (readonly non-existent) [37.32ms]
(pass) Connection & Initialization > should create database file if it doesn't exist [68.69ms]
(pass) Connection & Initialization > should work with relative paths [44.97ms]
(pass) Connection & Initialization > Environment Variable Handling > should use DATABASE_URL for SQLite when it's a SQLite
... (truncated)

release without fix: 26 FAILED
bun test v1.4.3-canary.1 (b52d51348)

test/js/sql/sqlite-sql.test.ts:
(pass) Connection & Initialization > common default connection strings > should parse common connection strings [1.62ms]
(pass) Connection & Initialization > should connect to in-memory SQLite database [0.24ms]
(pass) Connection & Initialization > should connect to file-based SQLite database [3.96ms]
(pass) Connection & Initialization > should handle connection with options object [3.05ms]
(pass) Connection & Initialization > onconnect and onclose callbacks are invoked for SQLite [0.57ms]
(pass) Connection & Initialization > onconnect receives Error when open fails (readonly non-existent) [1.44ms]
(pass) Connection & Initialization > should create database file if it doesn't exist [23.57ms]
(pass) Connection & Initialization > should work with relative paths [33.22ms]
(pass) Connection & Initialization > Environment Variable Handling > should use DATABASE_URL for SQLite when it's a SQLite URL [23.72ms]
(pass) Connection & Initialization > Environment Variable Handling > should handle DATABASE_URL with :memory: [0.66ms]
(pass) Connection & Initialization > Environment Variable Handling > should han
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/sql/sql-pool-transaction-isolation.test.ts test/js/sql/sqlite-sql.test.ts
bun test v1.4.3 (b52d51348)

test/js/sql/sqlite-sql.test.ts:
(pass) Connection & Initialization > common default connection strings > should parse common connection strings [71.96ms]
(pass) Connection & Initialization > should connect to in-memory SQLite database [10.78ms]
(pass) Connection & Initialization > should connect to file-based SQLite database [41.40ms]
(pass) Connection & Initialization > should handle connection with options object [90.65ms]
(pass) Connection & Initialization > onconnect and onclose callbacks are invoked for SQLite [16.92ms]
(pass) Connection & Initialization > onconnect receives Error when open fails (readonly non-existent) [25.87ms]
(pass) Connection & Initialization > should create database file if it doesn't exist [36.24ms]
(pass) Connection & Initialization > should work with relative paths [42.77ms]
(pass) Connection & Initialization > Environment Variable Handling > should use DATABASE_URL for SQLite when it's a SQLite U
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     041a2a6d4b
  features     lto, baseline

23 deps, 131 codegen, 1176 objects in 1199ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1250] gen bindgenv2
[2/1250] install /workspace/bun
bun install v1.4.3-canary.1 (b52d51348)

Checked 22 installs across 61 packages (no changes) [16.00ms]
[3/1250] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[4/1223] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (b52d51348)

Checked 1 install across 2 packages (no changes) [6.00ms]
[5/1223] fetch tinycc
[tinycc] up to date
[6/1222] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (b52d51348)

Checked 111 installs across 104 packages (no changes) [19.00ms]
[7/1222] gen node-fallbacks/react-refresh.js
Bundled 1 module in 11ms

  react-refresh.js  4.81 KB  (entry point)

[8/1222] gen ErrorCode+*.h
[9/1222] gen .bind.ts → GeneratedBindings.cpp
[10/1222] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[11/1222] ge
... (truncated)
diff hotspot
docs/runtime/sql.mdx                               |   2 +
 src/js/bun/sql.ts                                  |  31 +++--
 test/js/sql/sql-pool-transaction-isolation.test.ts | 138 +++++++++++++++++++++
 test/js/sql/sqlite-sql.test.ts                     |  37 ++++++
 4 files changed, 197 insertions(+), 11 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                reads  edits  tests
docs/runtime/sql.mdx                                    1      2     12
src/js/bun/sql.ts                                       4      1     12
test/js/sql/sql-pool-transaction-isolation.test.ts      2      2     12
test/js/sql/sqlite-sql.test.ts                          1      1      7

root cause · written by the author bot

A Query is lazy, but the handle's closed state was only read when the query was created, so queryFromTransactionHandler ran a query first awaited after COMMIT, ROLLBACK, or release() on the captured connection, which on SQLite autocommitted rolled-back work and on PostgreSQL and MySQL sent the statement to a pooled connection another caller might already own. The fix passes the TransactionState into the handler so it re-checks the closed bit when the query actually runs and rejects with the existing connectionClosedError() if the handle has since closed. This intentionally mak…

… handle closed

A Query is lazy. It runs when it is first awaited or when execute() is
called. The transaction and reserved handles read their closed state
only when the query is created. A query created while the handle was
open and first awaited after COMMIT, ROLLBACK or release() still ran on
the connection bound at creation. On SQLite the statement ran outside
the rolled back transaction. On PostgreSQL and MySQL it ran on the
pooled connection, inside the transaction of the next caller.

queryFromTransactionHandler now receives the TransactionState instead
of its query set and rejects with the adapter's connection closed error
when the closed bit is set.

Behaviour change: a query returned without await from a block that
releases the handle, such as `using reserved = await sql.reserve();
return reserved`...`` or `try { return reserved`...` } finally {
reserved.release() }`, first runs after release() and now rejects with
ERR_*_CONNECTION_CLOSED. The docs note that each query must be awaited
before the release.

Fixes #43245

@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.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/js/bun/sql.ts — Users who call await tx.close({ timeout }) while a query is in flight get their transaction COMMITTED instead of rolled back, on this branch as on the base. The drain branch at src/js/bun/sql.ts:800-804 resolves close() when the pending queries settle but never sends ROLLBACK and never sets closed. The callback then returns, needs_rollback is cleared, and COMMIT at src/js/bun/sql.ts:886 passes the check at :687 because closed is unset. Fix: the drain path must do what the no-timeout path at :810-814 does: send BEFORE_COMMIT/ROLLBACK and set closed before resolving; same for the reserved sibling at :528-532 which never closes the connection. [also at: src/js/bun/sql.ts:532 - Users who call await reserved.close({ timeout }) with a query in flight leak the pooled connection: it is never closed and never released, so with max: 1 every later query or reserve() waits forever.]

    Extended reasoning...

    The PR body justifies checking closed only with: close({ timeout }) drains pending queries in that window. The dismissing finders read the lines as unchanged but did not follow the drain path to its end. Trace: user runs sql.begin(async tx => { const p = txUPDATE ...; p.then(()=>{}); await tx.close({ timeout: 5 }); }). close() at :771 passes acceptsQueries, clears acceptQueries at :774. transactionQueries.size > 0 so :783 enters the timeout branch. Promise.all(pending_queries) at :800 waits for the UPDATE; when it settles, .finally clears the timer and resolves close(). No ROLLBACK is sent. state.connectionState still lacks closed. close() returns; the callback returns undefined. At :877 the await completes, :882 sets needs_rollback = false, :886 creates COMMIT through run_internal_transaction_sql; :687 sees closed unset so COMMIT is sent. The handler's new guard at :196 also sees closed unset, so it does not help. The UPDATE the user asked close() to discard is committed.…

    Verification: pre-existing (the base file at cb285a4 has byte-identical transaction_sql.close; this PR only reasons about the path in its description — "close({ timeout }) drains pending queries in that window" — and routes COMMIT through the handler it changes, it does not fix it). Trigger: await tx.close({ timeout: N }) inside a sql.begin callback while at least one transaction query is still in…

  • 🟣 src/js/bun/sql.ts — Users who start reserved.begin(cb) and let the reserved handle be released before it finishes still get that transaction's BEGIN, statements and COMMIT sent on a connection the pool has already handed to another caller. This is the exact cross-caller leak the PR guards against, unguarded through the nested-transaction path. release() at src/js/bun/sql.ts:544-557 marks only the reserved state closed and ignores reservedTransaction; the inner transaction at :617 has its own state, so the handler guard at :196 never fires for its queries. Fix: release() must refuse or defer while reservedTransaction is non-empty, or mark the inner states closed, as close({timeout}) already waits on them at :516.

    Extended reasoning...

    The dismissing finder marked release() untouched and stopped. Trace: { using reserved = await sql.reserve(); reserved.begin(async tx => { await txINSERT ...; await slowWork(); await txUPDATE ...; }); } The block exits synchronously, Symbol.dispose at :560 calls release(): :549 sets closed on the reserved state, :555 calls pool.release(pooledConnection). Meanwhile runReservedTransaction created a separate TransactionState at :617 with acceptQueries set; BEGIN at :875 was sent through run_internal_transaction_sql, whose :687 check reads the inner state. With max: 1 the next sql.begin() or plain sql... from another caller gets the same pooledConnection and runs inside the still-open BEGIN. When slowWork resolves the UPDATE goes through transaction_sql at :696 (inner state still accepts) and the handler guard at :196 sees the inner state not closed, so it runs on the other caller's connection; COMMIT at :886 then commits the other caller's statements too. close({timeout}) at :512-516 does wait on reservedTransaction, proving the author knows the inner transactions must finish before the handle…

    Verification: pre-existing — the base fails the same way by the same route; the PR's new guard does not reach this path. Trigger: reserved.begin(cb) is not awaited (e.g. return reserved.begin(async tx => {...}) from a using reserved block, or release() in finally) and the reserved handle is released before the transaction settles. Mechanism verified in /home/claude/bun/src/js/bun/sql.ts: -…

  • 🟣 src/js/bun/sql.ts — A process that calls tx.close({ timeout }) inside sql.begin without awaiting it and then returns from the callback crashes with an unhandled ERR_*_CONNECTION_CLOSED rejection once the timer fires, after the transaction already committed. The timer callback at src/js/bun/sql.ts:788-797 is an async function whose await run_internal_transaction_sql(ROLLBACK_COMMAND) at :795 rejects at :687 because the finally at :901 set closed first; nothing catches that rejection. Fix: the timer callback must skip the ROLLBACK when closed is already set and catch rejections from run_internal_transaction_sql, and the same guard belongs in the reserved timer at :517.

    Extended reasoning...

    Steps. sql.begin(async tx => { const q = tx\SELECT pg_sleep(10)`; q.then(...); tx.close({ timeout: 1 }); }). close() at :769 clears acceptQueries, size > 0, arms the timer at :788, returns the promise, which the user does not await. The callback returns, :877 resumes, :886 sends COMMIT (closed unset at :687 and at :196). COMMIT resolves, :901 sets closed, pool.release hands the connection back. One second later the timer fires. :789 cancels the pending queries (they never settle, the :809 finding). :795 calls run_internal_transaction_sql(ROLLBACK); :687 returns Promise.$reject(connectionClosedError()). The await` throws inside the async setTimeout callback; the returned promise has no handler. Bun's default unhandled-rejection behavior reports the error and exits the process with code 1. Base cb285a4 has the same lines, so the crash is pre-existing, but the dismissing finders only noted the diff does not change the lines; neither followed the rejection to the process exit. Population: any server that uses close({ timeout }) as a fire-and-forget abort inside a transaction.…

    Verification: pre-existing — the base commit has byte-identical code on this path (git show cb285a4:src/js/bun/sql.ts: the same setTimeout(async () => { ... await run_internal_transaction_sql(ROLLBACK_COMMAND); ... }) and the same if (closed) return Promise.$reject(pool.connectionClosedError()) in run_internal_transaction_sql); the diff only swaps the argument at :690 (state.queries -> state) and adds…

Comment thread src/js/bun/sql.ts
Comment thread src/js/bun/sql.ts
@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

On the review findings. All of them are on the base, and none of them is in the path this PR changes. Where they stand:

This PR stays on the one rule it adds: a handle query that first runs after closed is set rejects.

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.

1 participant