Skip to content

sql(postgres): make finish_request's per-class counter decrement idempotent - #34756

Merged
Jarred-Sumner merged 1 commit into
mainfrom
claude/farm/4acb9a94/pg-finish-request-underflow
Jul 21, 2026
Merged

sql(postgres): make finish_request's per-class counter decrement idempotent#34756
Jarred-Sumner merged 1 commit into
mainfrom
claude/farm/4acb9a94/pg-finish-request-underflow

Conversation

@robobun

@robobun robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

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.

multi-iteration probe on current main (debug+ASAN, no fault injection)

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.

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

…potent

PostgresSQLConnection::finish_request decremented nonpipelinable_requests
or pipelined_requests based on the request's flags at fire time, from
three call sites (the ReadyForQuery handler, the ErrorResponse handler,
and connection-close cleanup) with no per-request idempotence guard.
Under connection-failure timing the same request can be finished twice,
driving the u32 past zero: debug builds panic with 'attempt to subtract
with overflow'; release builds silently wrap to u32::MAX, after which
advance()'s 'nonpipelinable_requests > 0' guard returns early forever
and the connection stops dispatching queued queries.

Record a per-request 'counted' bit alongside the simple/pipelined flags,
set at the five sites that increment the counter and cleared by
finish_request when it consumes that contribution. The decrement now
no-ops once the bit is cleared. A debug_assert on the counter value and
saturating_sub keep the release path safe even if a new call site is
added without setting the bit.
@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:05 PM PT - Jul 19th, 2026

@robobun, your commit d96d396 has 1 failures in Build #75934 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34756

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

bun-34756 --bun

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0171ef82-cc05-4e32-9588-8033ff7de6b3

📥 Commits

Reviewing files that changed from the base of the PR and between 0a17ce6 and d96d396.

📒 Files selected for processing (4)
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • src/sql_jsc/postgres/PostgresSQLQuery.rs
  • test/js/sql/postgres-finish-request-underflow-fixture.ts
  • test/js/sql/postgres-finish-request-underflow.test.ts

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

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

The diff is ready; the included test is a regression guard, not a fail-before test. The underflow only reproduces under syscall fault injection on the client socket (roughly 3% of 400-iteration runs, per the internal fault-plan lane); a zero-fault server-driven reproduction does not exist on current main because #33989's status == Fail skip in the result handlers closed the only deterministic wire path, and the on_data dispatch loop bails on Status::Failed which closes the JS re-entrancy windows.

Honest multi-iteration probe on current main (debug+ASAN, 30-round #32004 workload against local Postgres, no fault injection): 0/5 attempt to subtract with overflow panics, 5/5 wedge from the separate #32006 JS-side pool bug (wedge persists with this fix applied, confirming it is unrelated). The same workload on a pre-#33989 debug build panics 3/3 at finish_request.

The PR body has the full stack trace and the server-sequence analysis. Needs a maintainer merge on the analysis.

@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, but this touches the Postgres connection state machine (per-request counter bookkeeping across five increment sites and three finish_request callers) and the test is explicitly a regression guard rather than a fail-before repro — worth a human look.

What was reviewed:

  • Verified all five nonpipelinable_requests/pipelined_requests increment sites now set counted = true; no other writers exist.
  • Checked that the early return on !counted in the Running/Binding/PartialResponse arm is behavior-preserving — every path reaching those states with simple/pipelined set also sets counted.
  • Confirmed the fixture's wire-frames helpers exist and the subprocess test drains stdout/stderr/exited concurrently and asserts on a combined object per harness conventions.
Extended reasoning...

Overview

The PR adds a counted: bool flag to PostgresSQLQuery::Flags and threads it through five counter-increment sites (two in PostgresSQLQuery::do_run, three in PostgresSQLConnection::advance). finish_request now gates its decrement on that flag, clears it, and replaces the raw - 1 with debug_assert!(n > 0) + saturating_sub(1). A new mock-server fixture drives one connection through the Z, E, and E+late-C+Z paths and asserts a follow-up query still dispatches.

Security risks

None. The change is internal bookkeeping on per-connection in-flight counters; no user input reaches the new code paths beyond what already flowed through them. saturating_sub degrades a hypothetical future accounting bug from a u32::MAX wrap (permanent stall) to a leaked-high counter, which is strictly safer.

Level of scrutiny

Medium-high. This is not a mechanical change: it modifies the Postgres connection state machine's request-accounting invariant across multiple call sites, and getting one increment site wrong would leak the counter high (stalling a pool connection). I grepped for every read/write of nonpipelinable_requests and pipelined_requests across src/sql_jsc/postgres/ and confirmed every increment is paired with counted = true in this diff, and confirmed the early return in the Running/Binding/PartialResponse arm cannot skip a decrement that the old code would have performed (any request in those states with simple or pipelined set necessarily came through an increment site that now also sets counted). Still, the PR's own analysis is intricate (fault-injection-only repro, interaction with #33989 and #32006) and a maintainer familiar with this state machine should confirm the design choice of per-request idempotence over, e.g., a terminal status transition.

Other factors

The author is transparent that the new test is a regression guard, not a fail-before test — the underlying race only reproduces under syscall fault injection and the deterministic server-driven path was already closed by #33989. REVIEW.md's "a test that passes both ways is worse than no test" is relevant here; the test does guard the new debug_assert and the follow-up-query-dispatches property, so it's not vacuous, but it doesn't prove the fix. The existing Postgres wire-frame suites are reported passing. Given the state-machine complexity and the non-fail-before test, I'm deferring rather than approving.

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Bun.SQL pool permanently corrupted when all pool connections are closed server-side #30947 - Pool permanently stops processing queries after server-side connection close — matches the "pool stall" caused by counter underflow to u32::MAX
  2. Bun.sql (Postgres): pool deadlocks at drain with mixed prepared read+write under pool oversubscription (concurrency >> max) #33985 - Pool deadlocks at drain with mixed prepared read+write under oversubscription — hang at Promise.allSettled consistent with stalled dispatch from wrapped counter
  3. "bun test" hangs up in timeout with PostgreSQL and expect->toThrow() #19130 - bun test hangs with PostgreSQL and expect->toThrow() — error path double-decrement could stall the pool, preventing sql.close() from completing

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

Fixes #30947
Fixes #33985
Fixes #19130

🤖 Generated with Claude Code

@Jarred-Sumner
Jarred-Sumner merged commit c3d184f into main Jul 21, 2026
80 of 82 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/farm/4acb9a94/pg-finish-request-underflow branch July 21, 2026 00:46
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