Skip to content

sql: fix pool stall when sql.begin() runs concurrently with pooled queries - #32006

Closed
robobun wants to merge 1 commit into
mainfrom
farm/25526f47/sql-pool-stall
Closed

sql: fix pool stall when sql.begin() runs concurrently with pooled queries#32006
robobun wants to merge 1 commit into
mainfrom
farm/25526f47/sql-pool-stall

Conversation

@robobun

@robobun robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #32004

Repro

From the issue: a pool (max: 4) running sql.begin() transactions concurrently with plain parameterized sql.unsafe() queries intermittently stalls forever. pg_stat_activity shows every connection idle at ClientRead (one left idle in transaction, its COMMIT never sent), no lock waits, while several sql.unsafe() calls wait for a connection that never comes back. Reproduced on the released build: 4 hangs in 15 runs of the issue's script; 0 in 30 runs with the fix.

Cause

Two cooperating bugs. The second has since been fixed on main by #33627, so this PR now carries only the first:

  1. Pool handoff leaves the connection visible to the distributor (this PR). connect(reserved=true)'s direct path removes the connection from readyConnections, but the release() path that hands a drained connection to a waiting sql.begin() does not, and flushConcurrentQueries() only filtered preReserved (not reserved) connections. Pooled queries therefore kept getting distributed onto a connection a transaction owned, and executed inside that transaction.

  2. Native queue could write out of order (fixed by sql(postgres): gate enqueue-time Bind+Execute on queued requests being written #33627). The enqueue-time pipelining fast path wrote a prepared query's Bind+Execute while an earlier queued request was still unwritten; responses are matched FIFO, so the unwritten request (the transaction's COMMIT) stole the pipelined query's result, the transaction "committed" without COMMIT reaching the server (the idle in transaction connection), nonpipelinable_requests underflowed, and the connection wedged. Main now gates that fast path on pending_requests == 0; earlier revisions of this PR fixed it by removing the fast path, and that half was dropped in the rebase in favor of main's version.

Fix

src/js/internal/sql/shared.ts (the pool shared by the postgres and mysql adapters): release() removes the connection from readyConnections when handing it to a reserved waiter (mirroring connect()), and flushConcurrentQueries() skips reserved connections.

Verification

test/js/sql/postgres-pool-transaction-stall.test.ts has two tests against a scripted mock postgres server (no Docker/services). The first forces the issue's interleaving deterministically: a transaction acquires its connection through the release() handoff while pooled prepared queries are in flight. Without this fix it fails its wire-order assertion (pooled queries are written to the transaction's connection between BEGIN and COMMIT; before #33627 landed, the same fixture wedged the pool outright). With the fix, no pooled query touches the transaction's connection and everything completes. The second test asserts BUN_FEATURE_FLAG_DISABLE_SQL_AUTO_PIPELINING keeps at most one query in flight per connection, coverage main's #33627 tests do not include.

Also ran the issue's original repro in a loop against real Postgres 17: 30/30 clean with the fix (debug build), 4/15 hangs without it (released build at the time of the original investigation).

MySQL's native queue has a narrower variant of the ordering window; tracked in #32005. The pool fix here applies to the MySQL adapter too and removes the transaction-driven trigger there.


[review] gate passed · iteration 11 · 4 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/sql/postgres-pool-transaction-stall.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (0809bdd14)

test/js/sql/postgres-pool-transaction-stall.test.ts:
332 |     expect(txConn).toBeDefined();
333 |     const log = txConn!.log;
334 |     const beginIndex = log.indexOf("Q:BEGIN");
335 |     const commitIndex = log.indexOf("Q:COMMIT");
336 |     expect(commitIndex).toBeGreaterThan(beginIndex);
337 |     expect(log.slice(beginIndex + 1, commitIndex)).toEqual(["Q:select 641 as victim_q"]);
                                                         ^
error: expect(received).toEqual(expected)

  [
+   "B:select $1 ::int as hold_me",
+   "E",
    "Q:select 641 as victim_q",
+   "B:select $1 ::int as fast_q",
+   "E",
  ]

- Expected  - 0
+ Received  + 4

      at <anonymous> (/workspace/bun/test/js/sql/postgre
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/sql/postgres-pool-transaction-stall.test.ts:
killed 1 dangling process
(fail) pool does not stall when sql.begin() runs concurrently with pooled prepared queries [5000.43ms]
  ^ this test timed out after 5000ms.

# Unhandled error between tests
-------------------------------
304 |     expect({
305 |       steps: steps.join("\n"),
306 |       stderr: stderr.includes("WATCHDOG") ? "WATCHDOG" : "",
307 |       exitCode,
308 |       mockErrors: mock.errors,
309 |     }).toEqual({
             ^
error: expect(received).toEqual(expected)

  {
-   "exitCode": 0,
+   "exitCode": 143,
    "mockErrors": [],
    "stderr": "",
    "steps": 
  "STEP prepared
  STEP armed
  STEP p0 done
  STEP body gate
  STEP released
- STEP victim resolved
- STEP fast resolved
- STEP slow resolved
- STEP tx resolved
- STEP pool alive
- DONE"
+ STEP victim resolved"
  ,
  }

- Expected  - 7
+ Received  + 2

      at <anonymous> (/workspace/bun/test/js/sql/postgres-pool-transaction-stall.test.ts:309:8)
-------------------------------

(pass) pipelining feature flag keeps one query in flight per connection [33.84ms]

 1 pass
 1 fail
 1 error
 4 expect
... (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/mechgate.xml" test/js/sql/postgres-pool-transaction-stall.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (0809bdd14)

test/js/sql/postgres-pool-transaction-stall.test.ts:
(pass) pool does not stall when sql.begin() runs concurrently with pooled prepared queries [1911.97ms]
(pass) pipelining feature flag keeps one query in flight per connection [1480.52ms]

 2 pass
 0 fail
 7 expect() calls
Ran 2 tests across 1 file. [5.55s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     0809bdd14a
  features     (none)

22 deps, 105 codegen, 1168 objects in 848ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1231] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [10.00ms]
[2/1231] gen ErrorCode+*.h
[3/1231] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [6.00ms]
[4/1231] gen bindgenv2
[5/1231] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[6/1231] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [10.00ms]
[7/1231] fetch tinycc
[tinycc] up to date
[8/1230] fetch z
... (truncated)
diff hotspot
src/js/internal/sql/shared.ts                      |   7 +-
 test/js/sql/postgres-pool-pipeline-flag-fixture.ts |  56 +++
 .../sql/postgres-pool-transaction-stall-fixture.ts |  89 +++++
 .../js/sql/postgres-pool-transaction-stall.test.ts | 386 +++++++++++++++++++++
 4 files changed, 537 insertions(+), 1 deletion(-)

gate history · 1 passed · 0 rejected · iteration 11

evidence per changed file
file                                                    reads  edits  tests
src/js/internal/sql/shared.ts                               1      2     28
test/js/sql/postgres-pool-pipeline-flag-fixture.ts          0      1     26
test/js/sql/postgres-pool-transaction-stall-fixture.ts      1      2     27
test/js/sql/postgres-pool-transaction-stall.test.ts         5     11     26

@robobun

robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:02 PM PT - Jul 9th, 2026

@robobun, your commit 0809bdd has 3 failures in Build #71293 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32006

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

bun-32006 --bun

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. [Bug] MySQL pool size > 1 causes Bun to hang during sql execution #26235 - MySQL pool size > 1 hangs during transactions; the pool handoff bug leaves the handed-off connection in readyConnections, so flushConcurrentQueries() dispatches pooled queries onto the transaction connection, deadlocking it
  2. MySQL transaction is hanging/freezing on Windows. #25552 - MySQL transactions hang/freeze on Windows; same root cause as [Bug] MySQL pool size > 1 causes Bun to hang during sql execution #26235 where sequential sql.begin() calls with pool size > 1 stall because the connection isn't removed from readyConnections on handoff
  3. Bun.SQL pool permanently corrupted when all pool connections are closed server-side #30947 - SQL pool permanently corrupted when all pool connections are closed server-side; the readyConnections inconsistency from the pool handoff bug contributes to the stale/wrong entries causing connection must be a PostgresSQLConnection errors

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #26235
Fixes #25552
Fixes #30947

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR prevents Postgres pool deadlocks by deferring prepared-statement bind/execute to FIFO advancement, gating advance/flush until no backpressure and no pending prepare, and excluding reserved connections from concurrent flush selection. It adds fixtures and deterministic mock-server integration tests validating the fix and pipelining behavior.

Changes

PostgreSQL Pool Concurrency Fix

Layer / File(s) Summary
Connection pool reserved connection isolation
src/js/internal/sql/shared.ts
Connection pool now excludes both preReserved and reserved connections from concurrent flush selection, and release() removes handed-off connections from the ready set so reserved connections are not visible to concurrent flushers.
Postgres advance_and_flush ordering gate
src/sql_jsc/postgres/PostgresSQLConnection.rs
advance_and_flush() now requires no backpressure and no pending prepare (!HAS_BACKPRESSURE && !WAITING_TO_PREPARE) instead of IS_READY_FOR_QUERY; after advance() it schedules a deferred flush via register_auto_flusher() rather than immediate flush_data(). The advance() loop consolidates pipelining eligibility via can_pipeline().
Postgres query execution deferral
src/sql_jsc/postgres/PostgresSQLQuery.rs
Reused prepared statements in Prepared/Parsing/Pending states no longer immediately bind_and_execute; execution is deferred to the advance()/advance_and_flush() path to preserve FIFO ordering and avoid bypassing queued requests.
Auto-pipelining feature-flag fixture
test/js/sql/postgres-pool-pipeline-flag-fixture.ts
Fixture validates that disabling auto-pipelining keeps one in-flight query per connection by holding prepared-statement responses and ensuring subsequent queries remain queued.
Transaction stall reproduction fixture
test/js/sql/postgres-pool-transaction-stall-fixture.ts
Fixture reproduces the transaction stall by creating a single-connection pool, pre-preparing statements, arming control to hold responses, queuing a victim query behind a held bind inside a transaction, and running a fast pooled query to detect desynchronization.
Integration test mock server and validation
test/js/sql/postgres-pool-transaction-stall.test.ts
Scripted mock Postgres server implements the extended query protocol subset with gated bind responses. Integration tests spawn fixtures, assert ordered lifecycle markers and wire-order invariants, and validate pipelining counters when the feature flag is toggled.

Possibly related issues

Suggested reviewers

  • cirospaciari
  • alii
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the main change: fixing a SQL pool stall during concurrent sql.begin() and pooled queries.
Description check ✅ Passed The description covers what the PR does, why it was needed, and how it was verified, even though the headings differ from the template.

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

@robobun

robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Checked each of the three suggested issues against this branch before adding any Fixes lines:

Leaving the PR scoped to #32004.

@robobun

robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI analysis across the builds on this PR (the diff is green; every failure so far has been unrelated flake or infra):

  • 61454: the @angular/cli bunx breakage, since fixed on main (Skip bunx.test.ts in CI until the Node.js version bump #32042).
  • 61650: no test/build failures; 23 jobs expired waiting for CI agents. Retriggered once (re-roll spent).
  • 61700 / 62104 / 62118: assorted unrelated flakes, dominated by the streams-leak memory test (Flaky CI test: streams-leak.test.ts "native ReadableStream reuses the pull buffer across small reads" #32190) which fails identically on unmodified bun; plus darwin VM cancellation kills and agent expiries.
  • 63256: darwin-only R2/S3 and Next.js integration flakes.
  • 71293 (final; current head 0809bdd, the pool-only diff): 283 passed, 3 red, all unrelated:
    • windows 2019 x64: Bun.spawn > 'pipe' stdout if read after exit should not leak memory, an RSS-measurement test in the spawn stdio subsystem; this branch is rebased directly onto spawnSync: drain piped stdio to EOF after the direct child exits #33832 which just changed exactly that code, and this PR's two shared.ts hunks cannot affect spawn stdio.
    • darwin 14 x64: ServerWebSocket > binaryType > uint8array 10s timeout, a WebSocket flake.
    • alpine 3.23 x64: Failed to start service mysql_plain (via coordinator) (test/docker/index.ts:332): the MySQL Docker container never started, so the mysql suite timed out waiting for it; service-startup infra, not a pool hang.

No SQL-related test has failed on any lane in any of the eight builds; postgres-pool-transaction-stall.test.ts passes everywhere it runs. Both review bots are clean on the current head with zero unresolved threads.

Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs
Comment thread src/sql_jsc/postgres/PostgresSQLQuery.rs Outdated

@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: 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/sql_jsc/postgres/PostgresSQLConnection.rs`:
- Around line 1389-1400: The in-flight close branch calls fail(...) (which via
fail_with_js_value() → ref_and_close() adds an extra poll_ref.ref_()), but only
unrefs once, leaving a dangling ref and potentially pinning the event loop;
update this branch (the block guarded by vm().is_shutting_down() == false &&
status matching Connecting | SentStartupMessage) to use the pre-connected close
path that does not call the ref-adding code or otherwise fully balance refs:
either replace the fail(...) call with a close variant that does not invoke
ref_and_close/ref_ (e.g., a ref_and_close_no_ref or a direct pre-connected close
helper), or, if retaining fail(...), ensure you call poll_ref.with_mut(|r|
r.unref(self.vm_ctx())) one additional time to match the extra ref added by
ref_and_close; reference fail, fail_with_js_value, ref_and_close, and
poll_ref.ref_()/poll_ref.with_mut(|r| r.unref(...)) when making the change.

In `@test/js/sql/postgres-pool-transaction-stall.test.ts`:
- Around line 199-205: The pump(conn: Conn) function can leave conn.busy true if
handleFrame rejects; change the implementation to set conn.busy = true, then run
the frame-processing loop inside a try/finally so conn.busy is always reset to
false, and ensure any errors from handleFrame are not swallowed (remove or
change the surrounding .catch(() => {}) so the rejection is propagated/rewrapped
and causes the test to fail); specifically update pump and the related
data-event handler to rethrow or forward handleFrame errors instead of silencing
them so failures surface.
- Around line 282-311: The test currently sorts the entire milestones list
before comparison (building expect({ steps:
stdout.split(...).filter(...).sort().join("\n"), ...
}).toEqual({...}.sort().join("\n"))), which removes ordering guarantees;
instead, preserve the original sequence from stdout and assert the exact
ordering for all milestones except the one scheduler-dependent pair ("STEP
released" vs "STEP victim resolved"). Change the assertion to use the unsorted
steps (from stdout -> steps) and verify the full sequence matches the expected
milestones in order, but allow the two ambiguous markers by either asserting
that their relative order is one of the two permitted permutations or by
asserting their indices relative to other milestones (e.g., check indexOf("STEP
released") and indexOf("STEP victim resolved") are adjacent and between "STEP
body gate" and "STEP fast resolved"); keep checks for stderr and exitCode
unchanged.
🪄 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: baf3815b-3c26-4af3-b51c-b050f1d488b9

📥 Commits

Reviewing files that changed from the base of the PR and between 08dce70 and 7e2903c.

📒 Files selected for processing (6)
  • src/js/internal/sql/shared.ts
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • src/sql_jsc/postgres/PostgresSQLQuery.rs
  • test/js/sql/postgres-pool-pipeline-flag-fixture.ts
  • test/js/sql/postgres-pool-transaction-stall-fixture.ts
  • test/js/sql/postgres-pool-transaction-stall.test.ts

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

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

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/sql_jsc/postgres/PostgresSQLConnection.rs`:
- Around line 1389-1400: The in-flight close branch calls fail(...) (which via
fail_with_js_value() → ref_and_close() adds an extra poll_ref.ref_()), but only
unrefs once, leaving a dangling ref and potentially pinning the event loop;
update this branch (the block guarded by vm().is_shutting_down() == false &&
status matching Connecting | SentStartupMessage) to use the pre-connected close
path that does not call the ref-adding code or otherwise fully balance refs:
either replace the fail(...) call with a close variant that does not invoke
ref_and_close/ref_ (e.g., a ref_and_close_no_ref or a direct pre-connected close
helper), or, if retaining fail(...), ensure you call poll_ref.with_mut(|r|
r.unref(self.vm_ctx())) one additional time to match the extra ref added by
ref_and_close; reference fail, fail_with_js_value, ref_and_close, and
poll_ref.ref_()/poll_ref.with_mut(|r| r.unref(...)) when making the change.

In `@test/js/sql/postgres-pool-transaction-stall.test.ts`:
- Around line 199-205: The pump(conn: Conn) function can leave conn.busy true if
handleFrame rejects; change the implementation to set conn.busy = true, then run
the frame-processing loop inside a try/finally so conn.busy is always reset to
false, and ensure any errors from handleFrame are not swallowed (remove or
change the surrounding .catch(() => {}) so the rejection is propagated/rewrapped
and causes the test to fail); specifically update pump and the related
data-event handler to rethrow or forward handleFrame errors instead of silencing
them so failures surface.
- Around line 282-311: The test currently sorts the entire milestones list
before comparison (building expect({ steps:
stdout.split(...).filter(...).sort().join("\n"), ...
}).toEqual({...}.sort().join("\n"))), which removes ordering guarantees;
instead, preserve the original sequence from stdout and assert the exact
ordering for all milestones except the one scheduler-dependent pair ("STEP
released" vs "STEP victim resolved"). Change the assertion to use the unsorted
steps (from stdout -> steps) and verify the full sequence matches the expected
milestones in order, but allow the two ambiguous markers by either asserting
that their relative order is one of the two permitted permutations or by
asserting their indices relative to other milestones (e.g., check indexOf("STEP
released") and indexOf("STEP victim resolved") are adjacent and between "STEP
body gate" and "STEP fast resolved"); keep checks for stderr and exitCode
unchanged.
🪄 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: baf3815b-3c26-4af3-b51c-b050f1d488b9

📥 Commits

Reviewing files that changed from the base of the PR and between 08dce70 and 7e2903c.

📒 Files selected for processing (6)
  • src/js/internal/sql/shared.ts
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • src/sql_jsc/postgres/PostgresSQLQuery.rs
  • test/js/sql/postgres-pool-pipeline-flag-fixture.ts
  • test/js/sql/postgres-pool-transaction-stall-fixture.ts
  • test/js/sql/postgres-pool-transaction-stall.test.ts
🛑 Comments failed to post (3)
src/sql_jsc/postgres/PostgresSQLConnection.rs (1)

1389-1400: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Release balancing is incomplete on the in-flight close() branch.

Line 1395 calls fail(...), which goes through fail_with_js_value()ref_and_close() and takes an extra poll_ref.ref_() (Lines 1527-1532). Line 1399 only unrefs once, and this branch explicitly assumes no socket close callback will run, so the extra keepalive ref is left behind. This can pin the event loop after close().

Please use a pre-connected close path that does not add the extra poll_ref ref, or otherwise fully balance both refs in this no-callback path.

As per coding guidelines: “Every error/abort/timeout path actively completes the operation.”

🤖 Prompt for 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.

In `@src/sql_jsc/postgres/PostgresSQLConnection.rs` around lines 1389 - 1400, The
in-flight close branch calls fail(...) (which via fail_with_js_value() →
ref_and_close() adds an extra poll_ref.ref_()), but only unrefs once, leaving a
dangling ref and potentially pinning the event loop; update this branch (the
block guarded by vm().is_shutting_down() == false && status matching Connecting
| SentStartupMessage) to use the pre-connected close path that does not call the
ref-adding code or otherwise fully balance refs: either replace the fail(...)
call with a close variant that does not invoke ref_and_close/ref_ (e.g., a
ref_and_close_no_ref or a direct pre-connected close helper), or, if retaining
fail(...), ensure you call poll_ref.with_mut(|r| r.unref(self.vm_ctx())) one
additional time to match the extra ref added by ref_and_close; reference fail,
fail_with_js_value, ref_and_close, and poll_ref.ref_()/poll_ref.with_mut(|r|
r.unref(...)) when making the change.

Source: Coding guidelines

test/js/sql/postgres-pool-transaction-stall.test.ts (2)

199-205: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t let mock-server frame-handler failures silently wedge the connection.

If handleFrame() rejects once, conn.busy never flips back to false, and the .catch(() => {}) on Line 243 turns that into a silent permanent stall on every later data event for that connection. This makes protocol/parser bugs look like generic test hangs instead of actionable failures.

Suggested hardening
 async function pump(conn: Conn) {
   if (conn.busy) return;
   conn.busy = true;
-  while (conn.frames.length > 0) {
-    await handleFrame(conn, conn.frames.shift()!);
-  }
-  conn.busy = false;
+  try {
+    while (conn.frames.length > 0) {
+      await handleFrame(conn, conn.frames.shift()!);
+    }
+  } finally {
+    conn.busy = false;
+  }
 }
 ...
-      pump(conn).catch(() => {});
+      pump(conn).catch(err => {
+        socket.destroy(err instanceof Error ? err : new Error(String(err)));
+      });

As per coding guidelines, tests should “wire EVERY failure event … to reject” and stay hermetic instead of degrading failures into hangs.

Also applies to: 219-243

🤖 Prompt for 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.

In `@test/js/sql/postgres-pool-transaction-stall.test.ts` around lines 199 - 205,
The pump(conn: Conn) function can leave conn.busy true if handleFrame rejects;
change the implementation to set conn.busy = true, then run the frame-processing
loop inside a try/finally so conn.busy is always reset to false, and ensure any
errors from handleFrame are not swallowed (remove or change the surrounding
.catch(() => {}) so the rejection is propagated/rewrapped and causes the test to
fail); specifically update pump and the related data-event handler to rethrow or
forward handleFrame errors instead of silencing them so failures surface.

Source: Coding guidelines


282-311: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t sort away the ordering guarantees this regression is supposed to prove.

The comment says only "STEP released" vs "STEP victim resolved" is scheduler-dependent, but Lines 285-311 sort the entire milestone list before comparing. That lets unrelated reorderings pass unnoticed, which weakens the regression substantially.

Suggested tightening
-    expect({
-      steps: stdout
-        .split(/\r?\n/)
-        .filter(line => line.startsWith("STEP ") || line === "DONE")
-        .sort()
-        .join("\n"),
+    const steps = stdout
+      .split(/\r?\n/)
+      .filter(line => line.startsWith("STEP ") || line === "DONE");
+
+    const expectedA = [
+      "STEP prepared",
+      "STEP armed",
+      "STEP p0 done",
+      "STEP body gate",
+      "STEP released",
+      "STEP victim resolved",
+      "STEP fast resolved",
+      "STEP slow resolved",
+      "STEP tx resolved",
+      "STEP pool alive",
+      "DONE",
+    ];
+    const expectedB = [
+      "STEP prepared",
+      "STEP armed",
+      "STEP p0 done",
+      "STEP body gate",
+      "STEP victim resolved",
+      "STEP released",
+      "STEP fast resolved",
+      "STEP slow resolved",
+      "STEP tx resolved",
+      "STEP pool alive",
+      "DONE",
+    ];
+
+    expect({
+      steps,
       stderr: stderr.includes("WATCHDOG") ? "WATCHDOG" : "",
       exitCode,
     }).toEqual({
-      steps: [
-        "STEP prepared",
-        "STEP armed",
-        "STEP p0 done",
-        "STEP body gate",
-        "STEP released",
-        "STEP victim resolved",
-        "STEP fast resolved",
-        "STEP slow resolved",
-        "STEP tx resolved",
-        "STEP pool alive",
-        "DONE",
-      ]
-        .sort()
-        .join("\n"),
+      steps: expect.toSatisfy(
+        actual => JSON.stringify(actual) === JSON.stringify(expectedA) || JSON.stringify(actual) === JSON.stringify(expectedB),
+      ),
       stderr: "",
       exitCode: 0,
     });

As per coding guidelines, “Every assertion must be able to fail, and must assert the strongest invariant.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    const steps = stdout
      .split(/\r?\n/)
      .filter(line => line.startsWith("STEP ") || line === "DONE");

    const expectedA = [
      "STEP prepared",
      "STEP armed",
      "STEP p0 done",
      "STEP body gate",
      "STEP released",
      "STEP victim resolved",
      "STEP fast resolved",
      "STEP slow resolved",
      "STEP tx resolved",
      "STEP pool alive",
      "DONE",
    ];
    const expectedB = [
      "STEP prepared",
      "STEP armed",
      "STEP p0 done",
      "STEP body gate",
      "STEP victim resolved",
      "STEP released",
      "STEP fast resolved",
      "STEP slow resolved",
      "STEP tx resolved",
      "STEP pool alive",
      "DONE",
    ];

    expect({
      steps: steps.join("\n"),
      stderr: stderr.includes("WATCHDOG") ? "WATCHDOG" : "",
      exitCode,
    }).toEqual(
      expect.objectContaining({
        steps: expect.stringMatching(
          new RegExp(
            `^(${expectedA.join("|").replace(/\./g, "\\.")})$|^(${expectedB.join("|").replace(/\./g, "\\.")})$`,
          ),
        ),
        stderr: "",
        exitCode: 0,
      }),
    );
    const steps = stdout
      .split(/\r?\n/)
      .filter(line => line.startsWith("STEP ") || line === "DONE");
    const stepsStr = steps.join("\n");

    const expectedA = [
      "STEP prepared",
      "STEP armed",
      "STEP p0 done",
      "STEP body gate",
      "STEP released",
      "STEP victim resolved",
      "STEP fast resolved",
      "STEP slow resolved",
      "STEP tx resolved",
      "STEP pool alive",
      "DONE",
    ].join("\n");

    const expectedB = [
      "STEP prepared",
      "STEP armed",
      "STEP p0 done",
      "STEP body gate",
      "STEP victim resolved",
      "STEP released",
      "STEP fast resolved",
      "STEP slow resolved",
      "STEP tx resolved",
      "STEP pool alive",
      "DONE",
    ].join("\n");

    expect(stepsStr === expectedA || stepsStr === expectedB).toBe(true);
    expect(stderr.includes("WATCHDOG") ? "WATCHDOG" : "").toBe("");
    expect(exitCode).toBe(0);
🤖 Prompt for 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.

In `@test/js/sql/postgres-pool-transaction-stall.test.ts` around lines 282 - 311,
The test currently sorts the entire milestones list before comparison (building
expect({ steps: stdout.split(...).filter(...).sort().join("\n"), ...
}).toEqual({...}.sort().join("\n"))), which removes ordering guarantees;
instead, preserve the original sequence from stdout and assert the exact
ordering for all milestones except the one scheduler-dependent pair ("STEP
released" vs "STEP victim resolved"). Change the assertion to use the unsorted
steps (from stdout -> steps) and verify the full sequence matches the expected
milestones in order, but allow the two ambiguous markers by either asserting
that their relative order is one of the two permitted permutations or by
asserting their indices relative to other milestones (e.g., check indexOf("STEP
released") and indexOf("STEP victim resolved") are adjacent and between "STEP
body gate" and "STEP fast resolved"); keep checks for stderr and exitCode
unchanged.

Source: Coding guidelines

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

Re the three CodeRabbit findings (the inline threads failed to post, so answering here):

  1. PostgresSQLConnection.rs close-branch ref balancing: that block is from sql: make forced close() resolve while a pool connection is mid-handshake #32097 (5c02cc1040) and is not touched by this PR, but I probed the claim anyway. A script that drives a pool connection mid-handshake against a never-answering server and calls sql.close({ timeout: "0" }) prints the rejected query's ERR_POSTGRES_CONNECTION_CLOSED and then the process exits cleanly on its own. If ref_and_close()'s poll_ref.ref_() were left dangling on this path, the event loop would stay pinned and the probe would hang; it does not, so the refs balance in practice and there is nothing to fix here.

  2. Mock server pump() resilience: applied in cddacc5. pump() now resets busy in a finally, frame-handler failures destroy the socket (so the fixture fails fast instead of hanging), and both tests assert a mockErrors: [] field so any handler error surfaces with its message.

  3. Step-ordering assertion: applied in cddacc5. The tests now compare the exact step sequence; only the adjacent scheduler-dependent pair ("STEP released" / "STEP victim resolved") is normalized before the comparison instead of sorting the whole list.

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

Both follow-ups from my earlier pass (the can_pipeline() gate in the Running/Binding arm and the auto-flusher batching) are applied in 7e2903c and covered by the new pipeline-flag test; nothing further from me, but this reworks the native queue's write-ordering gates enough that a maintainer should sign off.

Extended reasoning...

Overview

The PR fixes a pool stall in Bun.SQL postgres when sql.begin() runs concurrently with pooled prepared queries. JS side (shared.ts): two small, clearly-correct lines — release() removes the connection from readyConnections before handing it to a reserved waiter, and flushConcurrentQueries() filters reserved as well as preReserved. Rust side: PostgresSQLQuery::do_run() no longer writes Bind+Execute inline for already-prepared statements and instead defers to advance(); advance_and_flush()'s gate widens from IS_READY_FOR_QUERY to !WAITING_TO_PREPARE and now schedules the deferred auto-flusher; the Running/Binding arm in advance() now early-returns on !can_pipeline(). Three new test files add a scripted mock-postgres server and two deterministic regression tests.

Security risks

None identified. No auth, crypto, permissions, or untrusted-input parsing is touched. The change affects client-side write ordering and pool bookkeeping; the worst failure mode is a hang or query running inside the wrong transaction (the bug being fixed), not an exposure.

Level of scrutiny

High. This is concurrency-sensitive wire-ordering logic in a production database driver. The advance_and_flush() gate change is a semantic widening (it now runs while pipelined requests are in flight), and removing the do_run() fast-path shifts every reused-prepared-statement execution onto the advance() state machine. The reasoning in the PR description and code comments is sound and I traced the new ordering invariants (FIFO write order, can_pipeline() subsuming the old WAITING_TO_PREPARE/nonpipelinable checks, auto-flusher restoring same-tick batching), but the interaction surface — simple queries, named vs unnamed prepared statements, the disable-pipelining flag, backpressure — is large enough that a maintainer familiar with this queue should review it rather than auto-approve.

Other factors

Since my earlier inline comments, 7e2903c applied both suggested fixes exactly as proposed and added a dedicated test asserting the feature flag keeps one query in flight (verified can_pipeline() at line 1595 does cover WAITING_TO_PREPARE + nonpipelinable_requests, so the consolidated gate at line 2296 is equivalent-or-stricter). cddacc5 hardened the mock-server assertions per CodeRabbit. No CODEOWNERS entry covers these paths. CI failures on the latest build are infra (musl LTO link, agent provisioning), not test failures. The bug-hunting pass on the current head found nothing.

@robobun
robobun force-pushed the farm/25526f47/sql-pool-stall branch from cddacc5 to e9dfcbb Compare June 18, 2026 00:42
@robobun

robobun commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main to resolve the conflict with #32464 (postgres: fix do_run error-path leaks).

The conflict was in PostgresSQLQuery.rs::do_run, in the reused-prepared-statement arm. #32464 refactored that function's per-exit cleanup into release_query_ref()/throw_write_error() closures and moved poll_ref.ref_() to after the requests.write_item enqueue. This PR removes the inline bind_and_execute fast path from that same arm (leaving the request Pending so advance() writes it in FIFO order). Resolution: kept this PR's deferred arm and #32464's closure-based cleanup and relocated poll_ref.ref_(); they compose cleanly since the deferred arm has no error-return site of its own. The advance_and_flush() / register_auto_flusher() / can_pipeline() gate changes in PostgresSQLConnection.rs applied without conflict.

Re-verified on the new base: cargo check clean, postgres-pool-transaction-stall.test.ts fails (stall times out) with src/ reverted to main and passes with the fix, and the neighboring mock-server suites (postgres-multi-statement-fields, sql-connect-error-reporting, postgres-binary-numeric) still pass.

@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 `@src/js/internal/sql/shared.ts`:
- Around line 201-207: Add a null/undefined guard for the non-array INSERT
helper path before dereferencing items[column]. After the closing brace of the
if ($isArray(items)) block, add an else clause or additional guard that checks
if items is null or undefined and throws the same SyntaxError with the message
"Cannot use null or undefined as an item in INSERT helper" to ensure consistency
and prevent raw TypeErrors when a single null/undefined item is passed instead
of an array.
🪄 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: 284d8331-89b9-4f2b-8585-32489c71be5c

📥 Commits

Reviewing files that changed from the base of the PR and between cddacc5 and e9dfcbb.

📒 Files selected for processing (3)
  • src/js/internal/sql/shared.ts
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • src/sql_jsc/postgres/PostgresSQLQuery.rs
💤 Files with no reviewable changes (2)
  • src/sql_jsc/postgres/PostgresSQLQuery.rs
  • src/sql_jsc/postgres/PostgresSQLConnection.rs

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

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

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 `@src/js/internal/sql/shared.ts`:
- Around line 201-207: Add a null/undefined guard for the non-array INSERT
helper path before dereferencing items[column]. After the closing brace of the
if ($isArray(items)) block, add an else clause or additional guard that checks
if items is null or undefined and throws the same SyntaxError with the message
"Cannot use null or undefined as an item in INSERT helper" to ensure consistency
and prevent raw TypeErrors when a single null/undefined item is passed instead
of an array.
🪄 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: 284d8331-89b9-4f2b-8585-32489c71be5c

📥 Commits

Reviewing files that changed from the base of the PR and between cddacc5 and e9dfcbb.

📒 Files selected for processing (3)
  • src/js/internal/sql/shared.ts
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • src/sql_jsc/postgres/PostgresSQLQuery.rs
💤 Files with no reviewable changes (2)
  • src/sql_jsc/postgres/PostgresSQLQuery.rs
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
🛑 Comments failed to post (1)
src/js/internal/sql/shared.ts (1)

201-207: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard the non-array INSERT helper item too.

Line 201 adds array-item null validation, but the single-item path still reaches Line 222 and dereferences items[column] when items == null, causing a raw TypeError instead of your intended helper SyntaxError.

Suggested fix
 function buildDefinedColumnsAndQuery<T>(
   columns: (keyof T)[],
   items: T | T[],
   escapeIdentifier: (name: string) => string,
 ): { definedColumns: (keyof T)[]; columnsSql: string } {
   const definedColumns: (keyof T)[] = [];
   let columnsSql = "(";
   const columnCount = columns.length;

   if ($isArray(items)) {
     for (let j = 0; j < items.length; j++) {
       if (items[j] == null) {
         throw new SyntaxError("Cannot use null or undefined as an item in INSERT helper");
       }
     }
+  } else if (items == null) {
+    throw new SyntaxError("Cannot use null or undefined as an item in INSERT helper");
   }
🤖 Prompt for 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.

In `@src/js/internal/sql/shared.ts` around lines 201 - 207, Add a null/undefined
guard for the non-array INSERT helper path before dereferencing items[column].
After the closing brace of the if ($isArray(items)) block, add an else clause or
additional guard that checks if items is null or undefined and throws the same
SyntaxError with the message "Cannot use null or undefined as an item in INSERT
helper" to ensure consistency and prevent raw TypeErrors when a single
null/undefined item is passed instead of an array.

@robobun

robobun commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Re the CodeRabbit finding on shared.ts:201-207 (guard the non-array INSERT-helper item): skipping it in this PR.

That code is in buildDefinedColumnsAndQuery, which this PR does not touch. git blame puts the array-item null guard on #32156 (4d79cf8) and the function itself on an earlier commit; this branch's only shared.ts changes are the two pool-bookkeeping hunks (flushConcurrentQueries reserved filter and the readyConnections.delete on the reserved handoff). CodeRabbit flags it because it reviews the full base-to-head diff, but the single-item items == null path is unrelated to the pool-stall fix.

The observation is fair as a standalone follow-up to #32156 (extend the same SyntaxError to the non-array path), but adding an unrelated helper-validation change to a focused concurrency bugfix would just widen the blast radius of this PR. Leaving it out.

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Additional symptom of the same release() / readyConnections bug this PR's shared.ts change fixes, with no server-side connection death or native-queue involvement needed:

const sql = new Bun.SQL({ url, max: 1 });
await sql.unsafe("SELECT 'warm'");
const busy = sql.unsafe("SELECT 'busy'").execute();  // only connection is now busy
const tx = sql.begin(async t => {                       // queued on reservedQueue
  await t.unsafe("SELECT 1");
  await Bun.sleep(200);
  throw new Error("app error");                         // -> ROLLBACK
});
await Bun.sleep(150);
const intruder = sql.unsafe("INSERT INTO audit_log VALUES ('x')").execute();
await Promise.allSettled([busy, tx, intruder]);

The intruder INSERT is dispatched onto the open transaction's connection (it is still in readyConnections, and flushConcurrentQueries only filters preReserved), resolves with INSERT 0 1, and is then destroyed by the transaction's ROLLBACK. Every promise resolves normally. The control case where begin() does not have to wait for a busy connection is clean, which isolates the defect to the queued hand-off in release().

The this.readyConnections.delete(connection) this PR already adds in release() fixes it.

A postgres/mysql pool could hand pooled queries to a connection a
sql.begin() transaction owned: release() gave a drained connection to a
waiting reserved acquirer without removing it from readyConnections
(unlike connect()'s reserved path), and flushConcurrentQueries() only
filtered preReserved connections. Pooled queries then executed inside
the transaction, and in combination with the native queue's
write-ordering bug (fixed separately in #33627) the pool wedged
permanently with every connection idle.

release() now removes the connection from readyConnections when
handing it to a reserved waiter, and flushConcurrentQueries() skips
reserved connections.

The regression test scripts a mock postgres server that holds one
query's response until a control query arrives, deterministically
forcing the interleaving; without the fix, pooled queries are written
to the transaction's connection between BEGIN and COMMIT. A second
test covers BUN_FEATURE_FLAG_DISABLE_SQL_AUTO_PIPELINING keeping one
query in flight per connection.

Fixes #32004
@robobun
robobun force-pushed the farm/25526f47/sql-pool-stall branch from e9dfcbb to 0809bdd Compare July 10, 2026 03:11
@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main again; this one changed the PR's scope, so summarizing here (the description is updated to match).

Main landed #33627, which fixes the native write-ordering half of this bug its own way: the enqueue-time Bind+Execute fast path is kept but gated on pending_requests == 0. That conflicted with this PR's version (removing the fast path and deferring to advance()). Resolution: dropped this PR's native half entirely in favor of main's, including the follow-up batching/can_pipeline() changes that only existed to support it. This PR is now just the pool fix in src/js/internal/sql/shared.ts (release() removes the connection from readyConnections on the reserved handoff; flushConcurrentQueries() skips reserved connections) plus the tests.

Re-verified on the new base: with shared.ts reverted to main, the stall test fails its wire-order assertion (pooled queries written to the transaction's connection between BEGIN and COMMIT; the permanent wedge itself is gone thanks to #33627, so the failure is now a deterministic assertion rather than a timeout). With the fix, both tests pass. The pipeline-flag test is kept since #33627's own tests don't cover BUN_FEATURE_FLAG_DISABLE_SQL_AUTO_PIPELINING, and it passes against main's gating.

For reviewers who saw earlier revisions: claude[bot]'s sign-off concerns about the native queue rework no longer apply, since that rework is gone. The remaining diff is the two-hunk pool fix both bots already reviewed without findings.

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

No issues found — the two shared.ts hunks are small and mirror the existing reserved path in connect() — but this changes SQL pool distribution in a way that affects transaction isolation for both postgres and mysql, so it's worth a human sign-off.

Extended reasoning...

Overview

The current diff is much narrower than earlier revisions: the native Rust changes to PostgresSQLConnection.rs/PostgresSQLQuery.rs were dropped after #33627 landed the ordering fix on main, leaving only two small hunks in src/js/internal/sql/shared.ts (the pool shared by postgres and mysql) plus ~530 lines of new test/fixture code with a scripted mock postgres server.

The source change: (1) flushConcurrentQueries() now filters out reserved connections in addition to preReserved, and (2) release() calls this.readyConnections.delete(connection) before handing a drained connection to a waiting reservedQueue entry — the exact same bookkeeping connect()'s direct reserved path already does. Both are clearly consistent with the existing invariant that reserved connections must not be visible to the concurrent query distributor.

Security risks

None in the traditional sense (no auth/crypto/untrusted input), but the bug being fixed has data-integrity implications: pooled queries could execute inside a transaction they don't belong to and be silently rolled back. That raises the stakes on getting the fix right rather than lowering them.

Level of scrutiny

Medium-high. The change is mechanically simple and obviously mirrors an established pattern, but it's in the core connection-pool distribution path shared across all SQL adapters, the failure mode is a concurrency/ordering bug (historically hard to reason about from a diff alone), and the PR's scope has shifted substantially since it was opened (Rust half dropped, one test now covers behavior owned by #33627 rather than this PR). A maintainer familiar with the pool state machine should confirm the final scope and the mock-server test approach.

Other factors

All prior review threads (my two inline comments on the since-dropped Rust changes, and the CodeRabbit findings) are resolved. The bug-hunting system found nothing on the current head. CI is reported green modulo tracked flakes. No CODEOWNERS entry covers this path.

@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Acknowledged, nothing to address from that pass. Final state for whoever picks this up: the diff is the two-hunk pool fix in src/js/internal/sql/shared.ts (shared by the postgres and mysql adapters) plus the mock-server tests; the native ordering half now lives in #33627 on main. Both review bots have gone over the current head with no findings, all earlier threads are resolved, and CI is running on 0809bdd with everything green so far. Ready for maintainer sign-off.

Jarred-Sumner pushed a commit that referenced this pull request Jul 21, 2026
…potent (#34756)

### What

`PostgresSQLConnection::finish_request` decremented
`nonpipelinable_requests` / `pipelined_requests` based on the request's
`simple` / `pipelined` flags at fire time, from three call sites: the
`ReadyForQuery` handler, the `ErrorResponse` handler, and
connection-close cleanup. None of them record that a given request's
contribution has been consumed, so under connection-failure timing the
same request can be finished twice and the `u32` counter goes past zero.

Debug builds panic:

```
panic: attempt to subtract with overflow
  PostgresSQLConnection::finish_request            PostgresSQLConnection.rs:1812   (nonpipelinable_requests.get() - 1)
  PostgresSQLConnection::on::<StackReader>         PostgresSQLConnection.rs:2535   (ReadyForQuery 'Z' arm)
  postgres_request::on_data
  PostgresSQLConnection::on_data
  uws on_data / us_internal_dispatch_ready_poll
```

Release builds silently wrap the counter to `u32::MAX`, after which
`advance()`'s `nonpipelinable_requests.get() > 0` guard returns early
forever and that pool connection stops dispatching queued queries. This
is one of the mechanisms behind #32004's permanent pool stall (the other
is the JS-side reserved-connection handoff, tracked in #32006).

### Fix

Add a per-request `counted` bit to the query flags. Each of the five
increment sites (two in the enqueue fast paths in `PostgresSQLQuery.rs`,
three in `advance()` in `PostgresSQLConnection.rs`) sets it alongside
the increment. `finish_request` only decrements when the bit is set and
clears it afterwards, so a second call is a no-op. The decrement also
gains a `debug_assert!(n > 0)` and uses `saturating_sub` so a future
call site that forgets to set the bit trips the assert in debug and
degrades to a counter leak (not a wrap) in release.

### Reproduction and fail-before

The underflow was caught by syscall fault injection on the client's
Postgres socket against a real loopback server: the panic above
reproduces at roughly 3% of 400-iteration runs under injection, always
with a `PostgresError: Connection closed` rejection printed in the same
tick immediately before the panic. I could not reduce it to a zero-fault
reproduction. The known server-driven path (ErrorResponse, then a late
CommandComplete flipping the failed request back to PartialResponse so
ReadyForQuery decrements again) was closed by #33989's `status == Fail`
skip in the result-message handlers, and the `on_data` dispatch loop
bails once `fail_with_js_value` has run, which rules out the
straightforward re-entrancy windows.

<details><summary>multi-iteration probe on current main (debug+ASAN, no
fault injection)</summary>

30-round #32004 workload (`sql.begin()` interleaved with pooled
parameterized queries, `max: 4`) against a local Postgres 17:

```
run 1: WATCHDOG wedge
run 2: WATCHDOG wedge
run 3: WATCHDOG wedge
run 4: WATCHDOG wedge
run 5: WATCHDOG wedge
```

The wedge persists with this fix applied (it is the #32006 JS-side pool
bug). No `attempt to subtract with overflow` panic in any of 5 runs,
with or without this fix, so the remaining trigger is fault-gated.

The same workload on a debug build that predates #33989 panics 3/3 at
`finish_request`, which is what #33989's commit message documented; the
server-driven path is what that PR closed, the fault-timed one is what
this PR closes.

</details>

A directed hostile-server probe (CommandComplete before the first
ReadyForQuery, to push a never-dispatched Pending query through
`on_result(false)` into PartialResponse) does not reach the decrement
either: the JS adapter parks queries until after startup and the
resulting `ERR_POSTGRES_EXPECTED_REQUEST` fails the connection before
`finish_request` runs. The decrement is on an internal per-request
counter, not a wire field, so there is no single server byte sequence
that reaches it twice.

Given the above, the race is not fail-before-provable without
instrumenting `src/` (the gate strips `src/` for fail-before).
`test/js/sql/postgres-finish-request-underflow.test.ts` is therefore a
regression guard rather than a fail-before test: it drives a single
connection through the `Z`, `E`, and `E`-then-late-`C`+`Z` paths
back-to-back against a scripted mock server and asserts a follow-up
query still dispatches afterwards. A leaked-high or wrapped counter
would stall that follow-up query until the fixture's watchdog fires, and
a violated invariant trips the new `debug_assert`.

### Verification

- `bun bd test test/js/sql/postgres-finish-request-underflow.test.ts`:
passes
- Existing `test/js/sql/` Postgres suites
(`postgres-error-then-datarow`, `postgres-simple-query-pipeline`,
`postgres-prepared-pipeline-reorder`, `postgres-split-prepare-reorder`,
`postgres-multi-statement-fields`,
`postgres-failed-connection-resurrection`, `postgres-datarow-overrun`,
`wire-frames`) still pass with the fix.
- `cargo clippy -p bun_sql_jsc`: clean
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

The reported hang in #32004 no longer reproduces on main after #34756 (idempotent finish_request counter decrement) and #35114 (Int32 frame-boundary enforcement): the issue's repro script ran 40/40 clean on df84f8d vs 2/10 hangs on a build predating both. #32004 is now closed.

If this PR's JS-side pool-handoff change addresses a case those two PRs don't cover, a separate repro would help; otherwise it may no longer be needed.

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #34756 and #35114.

@robobun robobun closed this Jul 24, 2026
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.

SQL: connection pool stalls under concurrent begin() + parameterized queries (idle connections never handed to pending queries)

1 participant