postgres: fix do_run error-path leaks (poll_ref hang + query/statement refs) - #32464
Conversation
do_run takes a speculative this.ref_() before dispatching. The simple-query execute_query failure path correctly released it (release_statement + deref) before throwing, but three other error exits did not: - statements.get_or_put failure (allocator) - writer.write(SYNC) failure - final requests.write_item failure (extended-protocol) Each leaked the query ref (and the latter two also leaked the statement ref). The omissions were inherited from the Zig original. Same error is thrown at the same point; the query/statement just no longer leak. Extracted the cleanup into two closures applied at all 8 error-return sites for consistency. Net -29 lines. These are allocator-failure / write-failure paths only — not JS-observable on the success path. From #31664 (closed).
|
Updated 2:18 PM PT - Jun 17th, 2026
✅ @alii, your commit f2b76baf21d6c248fe522d398a44a480b3db0cd0 passed in 🧪 To try this PR locally: bunx bun-pr 32464That installs a local version of the PR into your bun-32464 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
Caution Review failedAn error occurred during the review process. Please try again later. 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 (1)
Walkthrough
PostgresSQLQuery::do_run cleanup refactor and keepalive fix
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@test/js/sql/sql-postgres-run-error-pollref-fixture.ts`:
- Line 34: The server initialization in this fixture uses a Promise that only
resolves on success but never rejects on failure, causing potential hangs.
Replace the Promise-based approach where server.listen is passed a resolve
callback with await once(server, "listening") instead, which automatically wires
up both the 'listening' success event and the 'error' event for proper error
handling and rejection.
🪄 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: 8f850b33-b098-491b-a958-cca0fbdc3f1e
📒 Files selected for processing (3)
src/sql_jsc/postgres/PostgresSQLQuery.rstest/js/sql/sql-postgres-run-error-pollref-fixture.tstest/js/sql/sql-postgres-run-error-pollref.test.ts
There was a problem hiding this comment.
I didn't find any issues, but this reorders poll_ref activation relative to buffered writes and touches unsafe intrusive-refcount cleanup across eight error exits — worth a human pass to confirm the new ordering and the added release_statement() calls are sound on every branch.
Extended reasoning...
Overview
This PR fixes an event-loop hang and several ref leaks in PostgresSQLQuery::do_run (src/sql_jsc/postgres/PostgresSQLQuery.rs). It moves connection.poll_ref.ref_() from the top of the function to after requests.write_item() succeeds on both the simple-query and prepared-statement branches, and factors the per-exit cleanup (release_statement() + Self::deref(this_ptr) + throw) into two closures applied at all eight error-return sites — three of which previously leaked. Net −74 lines in the Rust file. A new mock-server regression test (sql-postgres-run-error-pollref.test.ts + fixture) reproduces the hang.
Security risks
None apparent. No auth, crypto, permissions, or untrusted-input parsing is touched; the change is confined to error-path resource release and keepalive ordering inside the native query-dispatch path.
Level of scrutiny
Medium-high. While the diff is largely a deduplication refactor, it changes behavior in two non-mechanical ways: (1) poll_ref is now activated after data may already have been buffered into writer (e.g. bind_and_execute / prepare_and_query_with_signature succeed before write_item), relying on the fact that the actual flush happens later in flush_data_and_reset_timeout(); and (2) release_query_ref() now unconditionally calls release_statement() on paths where no statement was ever set (e.g. get_or_put failure) — harmless because release_statement() is a no-op on None, but it also runs on the final write_item OOM path where a statement was stored in connection.statements, so dropping the query's ref there is correct but the map's ref remains (pre-existing). These are unsafe-adjacent intrusive-refcount edits in production-critical DB code, and the KeepAlive flag-not-counter semantics are subtle enough that a second pair of eyes is warranted.
Other factors
The PR description is thorough, cites the matching pattern in JSMySQLQuery::do_run, and includes a targeted regression test plus passing cargo check/clippy and existing protocol tests. The bug-hunting system found nothing. I'm deferring rather than approving solely because refcount/keepalive reordering in native code with multiple unsafe blocks falls outside the "simple and obvious" bar for bot approval.
|
@robobun adopt |
|
Adopted and merged. Verified locally: system bun hung after the synchronous |
Supersedes #32426 and #32273 — combines both, plus the
release_statement()at the SYNC-failure site that #32273 missed.Repro (the user-visible part)
Cause
PostgresSQLQuery::do_rundid two pieces of speculative setup before validating its arguments:connection.poll_ref.ref_().KeepAliveis a two-state flag (src/io/keep_alive.rs), not a refcount, so when this query is the only in-flight work the call flips Inactive → Active. Every synchronous error return after that point left it stuck Active; nothing else on an idle connection touchespoll_refuntil the next server message, which never comes because nothing was written. The hang is masked whendo_runruns inside the connection'son_datamicrotask drain (whose epilogue re-derivespoll_reffrom the queue), so it only shows up for queries issued on a later turn — the normal case for any query after the first on a pooled connection.this.ref_(). The simple-queryexecute_queryfailure path correctly released it, but three other error exits did not:statements.get_or_putfailure,writer.write(SYNC)failure, and the finalrequests.write_itemfailure. The latter two also leaked the just-allocated statement ref.Fix
poll_ref.ref_()to afterconnection.requests.write_item(this_ptr)succeeds, on both the simple-query and prepared-statement branches. On every error path the keepalive is now untouched; on the success path the request is enqueued so the ref is balanced byon_data/update_ref()when the server responds. MatchesJSMySQLQuery::do_run, which never pre-refs.release_query_ref,throw_write_error) and apply them at all eight error-return sites — the three previously-leaking sites now release what they took, and the five existing sites lose their copy-pasted blocks. Net −74 inPostgresSQLQuery.rs.Verification
Regression test added to
sql-onconnect-onclose-throw.test.ts'sdescribeWithContainer("postgres", ...)block: a fixture connects to real Postgres, waits a tick sodo_runruns outside theon_datadrain, issues the boxed-Boolean query, prints the rejection and falls through. Without the fix the child hangs and the test times out; with the fix it printsrejected:ERR_INVALID_ARG_TYPEand exits 0.cargo check -p bun_sql_jscandcargo clippy --no-depsclean.postgres-multi-statement-fields.test.tsandsql-connect-error-reporting.test.ts(20 tests, both query-protocol paths) still pass.