sql: reject tx.unsafe()/tx.file() on a settled transaction or released reserved handle#33751
sql: reject tx.unsafe()/tx.file() on a settled transaction or released reserved handle#33751robobun wants to merge 5 commits into
Conversation
…d reserved handle The tagged-template call, savepoint(), connect(), flush() and the runner's own internal statements on a transaction/reserved handle all check connectionState and reject with "Connection closed" once begin() has settled (or the reserved connection has been released). unsafe() and file() on the same handle did not, so a reference retained past the callback could still issue statements on a pooled connection the handle no longer owns. On a real pool that connection may already belong to another caller. Add the same closed/acceptQueries guard to unsafe() and file() on both the transaction and reserved handles. file() re-checks after the async read so a handle closed while reading still rejects.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughThis PR introduces a shared ChangesConnection/transaction closed-state guards
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 6:05 AM PT - Jul 8th, 2026
❌ @robobun, your commit 65e0e01 has some failures in 🧪 To try this PR locally: bunx bun-pr 33751That installs a local version of the PR into your bun-33751 --bun |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/js/bun/sql.ts`:
- Around line 280-308: The closed/acceptQueries check is duplicated across
reserved_sql.unsafe, reserved_sql.file, transaction_sql.unsafe,
transaction_sql.file, and the related reserved_sql/transaction_sql close paths,
so extract it into a small helper and reuse it everywhere. Add a named guard
helper near the existing pool/transaction state logic in src/js/bun/sql.ts, then
replace each inline state.connectionState check with that helper while
preserving the same Promise.$reject(pool.connectionClosedError()) behavior.
In `@test/js/sql/sql.test.ts`:
- Around line 4000-4042: The existing reserved/tx rejection tests only verify
`.file()` after the handle is already closed, so they miss the TOCTOU case the
fix in `reserved.file()` and `tx.file()` is meant to protect. Add a test that
starts `reserved.file(rel("select.sql"))` or `tx.file(rel("select.sql"))`, then
releases/settles the handle while `Bun.file(...).text()` is still pending, and
assert it rejects with `ERR_POSTGRES_CONNECTION_CLOSED`. Use the existing
`reserved`, `tx`, `outcome`, and `.file()` paths so the race in the async
read-and-recheck flow is actually exercised.
In `@test/js/sql/sqlite-sql.test.ts`:
- Around line 1148-1175: The sqlite transaction close test only covers the
pre-read guard for tx.file(), not the post-read re-check added in the .then()
path. Update the test around tx.file() in tx.unsafe() and tx.file() reject after
begin() settles so the leaked transaction remains open while file reading is in
flight, then gets closed before the promise resolves. Use the existing leaked
handle from sql.begin and the tx.file(join(dir, "q.sql")) call to assert the
second “Connection closed” rejection path is exercised, not just the initial
guard.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 29a2b4a5-e8da-4065-84f0-fabf520d6fae
📒 Files selected for processing (3)
src/js/bun/sql.tstest/js/sql/sql.test.tstest/js/sql/sqlite-sql.test.ts
sqlite-sql.test.ts has a pre-existing 10k-iteration test that times out under the debug+ASAN gate build; moving the new test out so the gate only runs the relevant coverage.
There was a problem hiding this comment.
LGTM — brings .unsafe()/.file() in line with the existing closed-handle guard on sibling entry points.
Extended reasoning...
Overview
This PR closes a consistency gap in src/js/bun/sql.ts: the tagged-template call, .savepoint(), .begin(), .close(), and .release() on transaction/reserved handles all check state.connectionState and reject once the handle is settled/released, but .unsafe() and .file() did not. A retained tx reference could therefore execute writes on a pooled connection it no longer owns. The fix adds the same guard to those four entry points (plus a defensive post-read re-check in .file()), and extracts the repeated predicate into a cannotAcceptQueries(state) helper applied at all 13 sites. Tests are added for both SQLite (new dedicated file per the test/js/sql/ subject-per-file convention) and Postgres (in sql.test.ts), covering both reserved and tx handles.
Security risks
None introduced — the change is strictly fail-closed where it was previously fail-open. The bug being fixed is itself a mild isolation concern (a stale handle could inject SQL into whatever the released pooled connection is doing next), so this is a net safety improvement. No new inputs, no auth/crypto/permissions surface.
Level of scrutiny
Low-to-moderate. The runtime change is a targeted bug fix that copies an existing, well-established guard to four sibling entry points that were missing it. I verified the cannotAcceptQueries helper is byte-for-byte identical to the inline predicate it replaces at the seven pre-existing sites, so the refactor portion is behavior-preserving. The new guards use the same rejection (pool.connectionClosedError()) as the tagged-template path.
Other factors
All three CodeRabbit review threads are resolved: the helper-extraction request was addressed in a99ad43, and the two TOCTOU-coverage nits were withdrawn after the author explained why deterministic mid-read race coverage would be inherently flaky. No CODEOWNERS match this path. The bug hunter found no issues. Test coverage is solid — it asserts all four entry points reject with the expected error and confirms no escaped rows land in the SQLite case.
|
CI on 65e0e01 is red on unrelated lanes; the diff itself is green.
Ready for review. |
Every other entry point on a transaction/reserved handle (the tagged template,
savepoint(),connect(),flush(),begin(), and the runner's own internal SQL) checksstate.connectionStateand rejects withConnection closedoncesql.begin()has settled or the reserved connection has been released.unsafe()andfile()on the same handle did not, so atxreference retained past the callback silently executes on a pooled connection the handle no longer owns.Reproduction
On a Postgres/MySQL pool the leaked statement reaches whatever the released connection is doing next, which may be another caller's open transaction.
Fix
Add the same
closed/acceptQueriesguard thattransaction_sql()already has totransaction_sql.unsafe,transaction_sql.file,reserved_sql.unsafe, andreserved_sql.file.file()re-checks after the async read so a handle closed while the file is being read still rejects.Verification
test/js/sql/sqlite-sql.test.tsgains a test that retainstxpastbegin(), asserts all four entry points now rejectConnection closed, and asserts no escaped rows landed.test/js/sql/sql.test.tsgains the same for a releasedreservedhandle and a settledtxon Postgres. Before the fix the sqlite test showsunsafe: "fulfilled"/file: "fulfilled"; after, all reject.