sql(postgres): stop dispatching messages once the connection has failed - #33016
Conversation
A backend message that fails the connection can share a TCP read with
messages that follow it. PostgresRequest::on_data's message loop kept
dispatching those trailing messages against the already-failed
connection. A ReadyForQuery in that position called
set_status(Status::Connected), which had no guard against leaving
Failed, so the dead connection was flipped back to Connected and its
idle timer was re-armed on a socket uSockets had already scheduled to
free. When that timer later fired, ref_and_close read the freed
us_socket_t:
AddressSanitizer: heap-use-after-free in us_socket_is_closed
PostgresSQLConnection::ref_and_close
PostgresSQLConnection::fail_with_js_value
PostgresSQLConnection::on_connection_timeout
The message loop now returns once the status is Failed, and set_status
refuses to transition out of Failed.
|
Updated 2:50 PM PT - Jun 28th, 2026
✅ @robobun, your commit dfd2a3cfcc0183da10167ff45565e16d27f69a2b passed in 🧪 To try this PR locally: bunx bun-pr 33016That installs a local version of the PR into your bun-33016 --bun |
|
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 (4)
WalkthroughAdds a terminal-state guard to ChangesPostgres Failed-Connection Resurrection Fix
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
LGTM — two narrow guards making Failed terminal, with an ASan-gated regression test.
Extended reasoning...
Overview
This PR adds two small defensive guards in the Postgres connection state machine to fix a heap-use-after-free:
PostgresRequest::on_data(src/sql_jsc/postgres/PostgresRequest.rs): the message-dispatch loop now bails out as soon asconnection.status == Failed, so trailing backend messages in the same TCP read are not dispatched against a connection thatfail()has already torn down.PostgresSQLConnection::set_status(src/sql_jsc/postgres/PostgresSQLConnection.rs): refuses to transition out ofFailed, enforcing the invariant every other consumer (timers,update_has_pending_activity,fail_with_js_value's idempotency check) already assumes.
A new ASan-gated test (postgres-failed-connection-resurrection.test.ts + fixture) reproduces the original crash with a mock backend that sends an unrecognized Authentication message followed by ReadyForQuery in a single write.
Security risks
None introduced. This is a memory-safety fix — it eliminates a use-after-free where a freed us_socket_t was dereferenced from a stale idle-timer callback. No auth, crypto, injection, or data-exposure surface is touched.
Level of scrutiny
Low-to-moderate. The production diff is ~10 lines: two early-returns that fire only when the connection is already in the terminal Failed state. fail_with_js_value sets Failed via a direct self.status.set(...) (not set_status), so the new guard cannot interfere with entering the failed state — it only prevents leaving it. There is no legitimate path that un-fails a connection, so the behavior change is strictly a narrowing of an already-invalid state transition.
Other factors
- No bugs surfaced by the bug-hunting system.
- No CODEOWNERS for these paths and no outstanding reviewer comments.
- The PR description is unusually thorough: full ASan trace, minimal repro, root-cause analysis, and cross-references to the related #32861 / #30950 fixes that close the same class of bug from the other end.
- The test reuses the existing
wire-frames.tshelpers and follows the established fault-injection pattern intest/js/sql/, gated to ASan because the failure mode is undetectable in release builds.
Given the minimal, conservative nature of the change and the clear regression coverage, I'm comfortable approving without further human review.
A backend message that fails the connection can share a TCP read with messages that follow it.
PostgresRequest::on_data's message loop had no bail-out oncefail()had run, so the trailing messages in that read kept being dispatched against the already-failed connection.Repro
A mock backend that answers the StartupMessage with one write carrying two messages:
Cause
The unrecognized
Authenticationtype callsfail(), which sets the status toFailed, closes the socket, and rejects the pending requests, but the message loop keeps going and dispatches theReadyForQueryfrom the same read. That callsset_status(Status::Connected), which has no guard against leavingFailed, so the dead connection is flipped back toConnectedand theon_dataepilogue re-arms its idle timer. uSockets frees a closedus_socket_tat the end of the event-loop iteration, so when the timer later fires,ref_and_closereads the freed socket:Fix
PostgresRequest::on_data: the message loop returns once the connection's status isFailed.fail()is terminal; nothing after it in the same read should be handled (aDataRow,CommandComplete, orErrorResponsein that position would be just as wrong as theReadyForQuery).PostgresSQLConnection::set_status: refuses to transition out ofFailed. The transition function owns that invariant; every other consumer ofStatus(the timer interval,update_has_pending_activity, the idempotency check infail_with_js_value) already assumesFailedis terminal.Verification
test/js/sql/postgres-failed-connection-resurrection.test.tsruns a fixture against the mock backend above and lets it outlive the idle-timer window. Without the fix the fixture dies with the ASan report above; with it the fixture exits 0. Gated to ASan builds because the bug is a read of freed memory, which release lanes do not detect.The postgres fault-injection and integration suites still pass locally (90 tests across
test/js/sql/postgres-*.test.ts,sql*.test.ts,tls-sql.test.ts).Related
on_close/on_connect_errorso nothing can dereference the freedus_socket_tregardless of how the stale read is reached. It removes the last step of this chain from the other end; this PR stops the failed connection from being resurrected at all.handleConnectedagainst the reverse ordering within one read (a legitimately queuedonconnectmicrotask arriving after a synchronousonclose).