Skip to content

sql(postgres): ref poll_ref only after request is enqueued in do_run - #32273

Closed
robobun wants to merge 3 commits into
mainfrom
farm/944b7509/postgres-do-run-pollref-leak
Closed

sql(postgres): ref poll_ref only after request is enqueued in do_run#32273
robobun wants to merge 3 commits into
mainfrom
farm/944b7509/postgres-do-run-pollref-leak

Conversation

@robobun

@robobun robobun commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Repro

import { SQL } from "bun";
const sql = new SQL({ url: "postgres://...", max: 1, idleTimeout: 0, maxLifetime: 0 });
await sql.connect();
await new Promise(r => setImmediate(r));          // leave on_data's microtask drain
await sql`SELECT ${new Boolean(true)}`.catch(e => e);  // ERR_INVALID_ARG_TYPE
// process hangs here instead of exiting

new Boolean(true) is rejected by the Postgres binding type mapper inside Signature::generate, so PostgresSQLQuery::do_run returns an error before the request is ever enqueued.

Cause

do_run refed the connection's poll_ref KeepAlive up front, before validating its arguments:

connection.poll_ref.with_mut(|r| r.ref_(get_vm_ctx(AllocatorType::Js)));
let query = arguments[1];
if !query.is_object() { return Err(...); }

KeepAlive is a two-state flag (Active/Inactive), not a reference count (src/io/keep_alive.rs). When this query is the only in-flight work, the call flips Inactive -> Active, and every synchronous error return after that point (non-object target, Signature::generate failure, statements.get_or_put OOM, cached statement in Failed state, bind_and_execute/prepare_and_query_with_signature/write_query failure, requests.write_item OOM) leaves the poll_ref Active. Nothing else on an idle connection touches poll_ref until the next server message, which never comes because nothing was written, so the event loop stays pinned and the process never exits.

The hang is masked when do_run runs inside the connection's on_data microtask drain (the usual first-query path): on_data's epilogue re-derives poll_ref from the request queue afterwards. It shows up whenever the failing query is issued on a later turn, which is the normal case for any query after the first on a pooled connection.

Fix

Move the poll_ref.ref_() to after connection.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 by on_data/update_ref() when the server responds. This matches JSMySQLQuery::do_run, which never pre-refs.

While auditing the error arms, three of them (statements.get_or_put OOM, writer.write(SYNC) failure, and the prepared-branch requests.write_item OOM) were also missing the Self::deref(this_ptr) that every sibling arm performs to undo the speculative this.ref_() taken near the top of the function; the last of these also left the freshly allocated this.statement pinned. Those are swept up here as well so every error return now releases what it took.

Verification

test/js/sql/sql-postgres-run-error-pollref.test.ts spawns a fixture that connects to a mock Postgres server (AuthenticationOk + ReadyForQuery), waits a tick, issues the boxed-Boolean query, prints the rejection and falls through. Without the fix the child hangs and is killed by the 5s test timeout; with the fix it prints rejected:ERR_INVALID_ARG_TYPE and exits 0.

Also ran locally with the fix: postgres-multi-statement-fields.test.ts, sql-close-pending-connection.test.ts, sql-connect-error-reporting.test.ts, sql-prepare-false.test.ts (33 tests covering both simple and prepared query paths against mock servers), all pass.

PostgresSQLQuery::do_run refed the connection's poll_ref KeepAlive
before any validation. KeepAlive is a two-state flag, not a counter,
so when the query was the only in-flight work the call flipped
Inactive -> Active. Every synchronous error return after that point
(bad binding, Signature::generate failure, statements.get_or_put OOM,
cached-statement failure, bind_and_execute / write_query failure,
requests.write_item OOM, ...) undid the speculative self-ref but
never the poll_ref, leaving the event loop pinned and the process
hung until the connection closed.

Move the poll_ref.ref_() to after the request has been written into
connection.requests on both the simple-query and prepared-statement
success paths. On every error path the keepalive is now untouched.

The regression test connects to a mock server, lets the connection go
idle (poll_ref Inactive), then issues a query with a boxed Boolean
binding that is rejected synchronously by the Postgres type mapper in
Signature::generate. The fixture must print the rejection and exit on
its own; without the fix it hangs and is killed by the test timeout.
@robobun

robobun commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:17 AM PT - Jun 15th, 2026

@robobun, your commit 1718b23818e506f7609b2909736b6f13216896d7 passed in Build #62381! 🎉


🧪   To try this PR locally:

bunx bun-pr 32273

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

bun-32273 --bun

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

PostgresSQLQuery::do_run previously acquired the event-loop KeepAlive (poll_ref.ref_) before fallible operations, leaving it unreleased on synchronous failures. The fix removes that early acquisition and re-inserts it after successful request enqueue in both the simple and non-simple query branches. A mock Postgres TCP server fixture and a subprocess test are added to assert clean process exit when do_run fails synchronously.

Changes

poll_ref KeepAlive Fix and Regression Test

Layer / File(s) Summary
Defer KeepAlive past fallible paths in do_run
src/sql_jsc/postgres/PostgresSQLQuery.rs
Removes the early poll_ref.ref_() call at the top of do_run. Adds it after successful enqueue in the simple-query branch (post-OOM check) and after successful enqueue in the non-simple/enqueue branch, so the event-loop is never kept alive when a synchronous error occurs before either point.
Mock server fixture and subprocess test
test/js/sql/sql-postgres-run-error-pollref-fixture.ts, test/js/sql/sql-postgres-run-error-pollref.test.ts
Fixture implements a minimal Postgres auth/ready handshake over a mock TCP server, connects Bun.SQL, waits a tick, then executes a query with a boxed Boolean to force a synchronous do_run failure, logging rejected:<error code>. The test spawns the fixture as a subprocess and asserts stdout contains rejected:ERR_INVALID_ARG_TYPE, stderr is empty, exit code is 0, and signal is null.

Suggested reviewers

  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: moving the poll_ref.ref_() call to after request enqueueing in PostgresSQLQuery::do_run, which is the core fix for the event-loop hang issue.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request provides a comprehensive description covering the root cause, fix strategy, and verification approach that aligns well with the template requirements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

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

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.test.ts`:
- Line 27: In the expect() assertion on line 27, remove the hard-coded empty
stderr check that makes the test flaky. Instead of expecting both stdout and
stderr to match an exact object, only verify the stdout output contains the
expected error message "rejected:ERR_INVALID_ARG_TYPE\n" and remove the stderr
assertion entirely, allowing benign debug/sanitizer noise in stderr without
failing the test.
🪄 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: d27eb6f0-3c29-4a71-9a3a-c6f010658c25

📥 Commits

Reviewing files that changed from the base of the PR and between e0acad3 and 78ea1c4.

📒 Files selected for processing (3)
  • src/sql_jsc/postgres/PostgresSQLQuery.rs
  • test/js/sql/sql-postgres-run-error-pollref-fixture.ts
  • test/js/sql/sql-postgres-run-error-pollref.test.ts

Comment thread test/js/sql/sql-postgres-run-error-pollref.test.ts Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLQuery.rs
…rror arms

Three pre-existing error returns in the prepared-statement branch of
do_run skipped the Self::deref(this_ptr) cleanup that every sibling
arm performs: statements.get_or_put OOM, writer.write(SYNC) failure,
and the trailing requests.write_item OOM (which also left the freshly
allocated this.statement pinned). These are allocator-OOM paths so the
practical impact is small, but they are the same cleanup class as the
poll_ref leak and sit directly adjacent to it.

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

Thanks — 1718b23 addresses all three error arms I flagged. The change looks correct to me, but since it reorders keepalive/refcount lifecycle across a dozen unsafe error paths in do_run, it's worth a quick look from someone who owns this driver.

Extended reasoning...

Overview

This PR fixes an event-loop hang in PostgresSQLQuery::do_run: the connection's poll_ref KeepAlive (a two-state flag, not a counter) was being ref'd before any argument validation, so every synchronous error return left it stuck Active and pinned the process. The fix moves the poll_ref.ref_() call to after connection.requests.write_item() succeeds on both the simple-query and prepared-statement branches. While auditing, three error arms (statements.get_or_put OOM, writer.write(SYNC) failure, prepared-branch requests.write_item OOM) were also found to be missing the Self::deref(this_ptr) that every sibling arm performs to undo the speculative this.ref_(); these are swept up too. A new mock-server subprocess test asserts the process exits cleanly after a synchronous do_run failure.

Security risks

None. This is internal refcount/keepalive bookkeeping in the Postgres driver; no auth, input parsing, or privilege boundaries are touched.

Level of scrutiny

Medium-high. The change is conceptually simple (defer the ref until enqueue succeeds; release what you took on error), and the on_data epilogue at PostgresSQLConnection.rs:1048-1055 plus update_ref() confirm the PR description's analysis of how the leaked ref escapes correction. The three new Self::deref(this_ptr) additions are mechanical copies of the pattern on every adjacent error arm. But do_run is ~400 lines of manual intrusive refcounting with a dozen early returns, several unsafe blocks, and a raw *mut stashed in a long-lived FIFO — the kind of function where a misplaced cleanup is a UAF rather than a leak. The newly added release_statement() on the prepared-branch write_item OOM, for example, drops the query's ref on a statement that (in the named branch) is also held by connection.statements, which is correct but worth a second pair of eyes.

Other factors

My earlier inline comment about the three missing Self::deref calls was addressed exactly as suggested in 1718b23, and the coderabbit stderr-assertion nit was fixed in cfbbe89. The new regression test is well-constructed (mock server, explicit setImmediate to leave on_data's drain, await using for subprocess cleanup, exit-code asserted last). CI shows two musl build failures that look like unrelated LTO/infra flakes ("Linking two modules of different data layouts"), not code issues. coderabbit suggested cirospaciari as a domain reviewer, which seems right for this area.

@alii

alii commented Jun 17, 2026

Copy link
Copy Markdown
Member

Superseded by #32464 (combined with #32426's closure refactor, which also catches the missing release_statement() at the SYNC-failure site).

@alii alii closed this Jun 17, 2026
alii added a commit that referenced this pull request Jun 17, 2026
…t refs) (#32464)

Supersedes #32426 and #32273 — combines both, plus the
`release_statement()` at the SYNC-failure site that #32273 missed.

## Repro (the user-visible part)

```js
import { SQL } from "bun";
const sql = new SQL({ url: "postgres://...", max: 1, idleTimeout: 0, maxLifetime: 0 });
await sql.connect();
await new Promise(r => setImmediate(r));
await sql`SELECT ${new Boolean(true)}`.catch(e => e);  // ERR_INVALID_ARG_TYPE
// process hangs here instead of exiting
```

## Cause

`PostgresSQLQuery::do_run` did two pieces of speculative setup before
validating its arguments:

1. `connection.poll_ref.ref_()`. `KeepAlive` is 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 touches `poll_ref` until the next server message, which never
comes because nothing was written. The hang is masked when `do_run` runs
inside the connection's `on_data` microtask drain (whose epilogue
re-derives `poll_ref` from 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.
2. `this.ref_()`. The simple-query `execute_query` failure path
correctly released it, but three other error exits did not:
`statements.get_or_put` failure, `writer.write(SYNC)` failure, and the
final `requests.write_item` failure. The latter two also leaked the
just-allocated statement ref.

## Fix

- Move `poll_ref.ref_()` to after
`connection.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 by `on_data`/`update_ref()` when the server
responds. Matches `JSMySQLQuery::do_run`, which never pre-refs.
- Extract the per-exit cleanup into two closures (`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 in
`PostgresSQLQuery.rs`.

## Verification

Regression test added to `sql-onconnect-onclose-throw.test.ts`'s
`describeWithContainer("postgres", ...)` block: a fixture connects to
real Postgres, waits a tick so `do_run` runs outside the `on_data`
drain, 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 prints `rejected:ERR_INVALID_ARG_TYPE` and exits 0.

`cargo check -p bun_sql_jsc` and `cargo clippy --no-deps` clean.
`postgres-multi-statement-fields.test.ts` and
`sql-connect-error-reporting.test.ts` (20 tests, both query-protocol
paths) still pass.
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