Skip to content

sql(postgres): roll back write buffer when Bind encoding throws mid-message - #34732

Open
robobun wants to merge 3 commits into
mainfrom
farm/0313f824/postgres-bind-torn-frame
Open

sql(postgres): roll back write buffer when Bind encoding throws mid-message#34732
robobun wants to merge 3 commits into
mainfrom
farm/0313f824/postgres-bind-torn-frame

Conversation

@robobun

@robobun robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Repro

const sql = new Bun.SQL(url, { max: 1 });
const evil = { valueOf() { throw new RangeError("evil"); } };
await sql`select ${evil}::int4`.catch(e => e);   // rejects with the user's error
await sql`select 42::int4`;                      // ERR_POSTGRES_CONNECTION_CLOSED

Against a real PostgreSQL 15, the second (innocent) query rejects with ERR_POSTGRES_CONNECTION_CLOSED and the server log reads invalid message length. Against a byte-capturing mock server, the first bytes after the prepare round-trip are 42 00 00 00 00 00 50 73 65 6c 65 63 74 …: a Bind message declaring length 0 (below the 4-byte minimum), followed by the partial portal/statement name, then the next query's frames.

Cause

write_bind in src/sql_jsc/postgres/PostgresRequest.rs streams parameter values directly into connection.write_buffer, calling back into JS for each value (coerce::<i32>, to_number, BunString::from_js, json_stringify_fast, and the binding iterator's getters). The Bind length field is written as a zero placeholder and patched once every parameter has been encoded. Every JS callback can throw; the error is propagated via ?, but nothing truncates the already-appended bytes, so the torn B\0\0\0\0… prefix stays in the buffer and is flushed ahead of the next query's frames.

The MySQL path was already immune by construction: bind_and_execute_impl converts every parameter to a native Vec<Value> before touching the writer.

Fix

Add WriterContext::truncate and a NewWriter::atomically(|w| …) helper that snapshots offset(), runs the batch body, and truncates back to the snapshot on Err. The three batch entry points that reach write_bind are wrapped:

  • bind_and_execute
  • prepare_and_query_with_signature
  • parse_and_bind_and_execute

so every caller (4 sites in PostgresSQLConnection::advance, 2 in PostgresSQLQuery::run) gets the rollback without per-call-site mark/restore pairs, and any future caller of these functions is covered.

#34708 (int4 overflow) includes an equivalent rollback at each of the six call sites as a supporting change; this PR centralises it in the writer instead and adds a test that targets the throwing-valueOf/toString case specifically. Either PR merging first leaves the other a minor rebase.

Verification

test/js/sql/postgres-bind-throw-torn-frame.test.ts drives a mock backend that records every raw byte the client sends after startup, then:

  1. runs select ${evil}::int4 where evil.valueOf() / evil.toString() throws; asserts the query rejects with the user's error;
  2. runs a second select ${1}::int4 on the same max: 1 connection; asserts it resolves;
  3. walks the recorded bytes as Byte1-type + Int32-length frontend messages and asserts every declared length is ≥ 4 and exactly one Bind reached the wire.

On the unfixed build both tests fail with { tornAt: 104, head: "42 00 00 00 00 00 50 73 65 6c 65 63 74 20 24 31" } (the torn Bind followed by the prepared statement name); with the fix both pass.


[review] gate passed · iteration 0 · 5 files touched

fails on main (without fix)
ASAN without fix: 2 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-bind-throw-torn-frame.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 (0fe017f95)

test/js/sql/postgres-bind-throw-torn-frame.test.ts:
115 |     const rows: any = await db`select ${1}::int4 as v`;
116 | 
117 |     // Every byte sent after startup must be a well-formed frontend message;
118 |     // a torn Bind fails frameTypes() with head `42 00 00 00 00 …`.
119 |     const framed = frameTypes(received);
120 |     expect(framed).toEqual({
                         ^
error: expect(received).toEqual(expected)

  {
-   "types": ExpectArrayContaining {},
+   "head": "42 00 00 00 00 00 50 73 65 6c 65 63 74 20 24 31",
+   "tornAt": 104,
  }

- Expected  - 1
+ Received  + 2

      at run (/workspace/bun/test/js/sql/postgres-bind-throw-torn-frame.test.ts:120:20)
      at async <anonymous> (/w
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (0746a1428)

test/js/sql/postgres-bind-throw-torn-frame.test.ts:
(pass) postgres: a throwing valueOf() during Bind does not leave a torn frame on the wire [9.90ms]
(pass) postgres: a throwing toString() during Bind does not leave a torn frame on the wire [2.45ms]

 2 pass
 0 fail
 8 expect() calls
Ran 2 tests across 1 file. [208.00ms]
__F:0:S:0
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-bind-throw-torn-frame.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 (0fe017f95)

test/js/sql/postgres-bind-throw-torn-frame.test.ts:
(pass) postgres: a throwing valueOf() during Bind does not leave a torn frame on the wire [558.29ms]
(pass) postgres: a throwing toString() during Bind does not leave a torn frame on the wire [189.52ms]

 2 pass
 0 fail
 8 expect() calls
Ran 2 tests across 1 file. [3.46s]
__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) in 790ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
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: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�
... (truncated)
diff hotspot
src/sql/postgres/protocol/ArrayList.rs             |   3 +
 src/sql/postgres/protocol/NewWriter.rs             |  20 +++
 src/sql_jsc/postgres/PostgresRequest.rs            | 194 +++++++++++----------
 src/sql_jsc/postgres/PostgresSQLConnection.rs      |   7 +
 test/js/sql/postgres-bind-throw-torn-frame.test.ts | 156 +++++++++++++++++
 5 files changed, 286 insertions(+), 94 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                reads  edits  tests
src/sql/postgres/protocol/ArrayList.rs                  1      1      0
src/sql/postgres/protocol/NewWriter.rs                  1      3      0
src/sql_jsc/postgres/PostgresRequest.rs                 1      3      0
src/sql_jsc/postgres/PostgresSQLConnection.rs           2      1      0
test/js/sql/postgres-bind-throw-torn-frame.test.ts      0     12      0

…essage

write_bind serialises parameter values straight into the connection's
outgoing wire buffer, calling back into JS for each value (coerce /
toString / valueOf). The Bind message's Int32 length is written as a zero
placeholder and only patched once every parameter has been encoded. If a
parameter's valueOf()/toString() throws mid-loop, the query rejected with
the user's error but the torn 'B\0\0\0\0...' prefix stayed in the
buffer; the next query's frames were appended after it and flushed
together. A real PostgreSQL server answers that with 'invalid message
length' and drops the connection, so one buggy/hostile parameter poisoned
the pooled connection for every in-flight and subsequent query.

Add WriterContext::truncate and NewWriter::atomically(), which snapshots
the buffer offset, runs the batch writer, and truncates back on Err. The
three batch entry points (bind_and_execute, prepare_and_query_with_signature,
parse_and_bind_and_execute) are wrapped so every caller gets the rollback
without per-call-site bookkeeping. MySQL was already immune: it converts
every parameter before touching the writer.
@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced with a byte-capturing mock server: on the unfixed build the bytes after the prepare round-trip start 42 00 00 00 00 00 50 73 … (Bind declaring length 0, then the torn statement name). With NewWriter::atomically wrapping the three batch writers, the buffer is rolled back on throw and the second query's Bind is the first thing on the wire.

USE_SYSTEM_BUN=1 bun test test/js/sql/postgres-bind-throw-torn-frame.test.ts
  -> { tornAt: 104, head: "42 00 00 00 00 00 50 73 65 6c 65 63 74 20 24 31" }
bun bd test test/js/sql/postgres-bind-throw-torn-frame.test.ts
  -> 2 pass

Overlaps with the supporting rollback in #34708 (different primary bug, call-site approach); whichever merges first leaves the other a small rebase.

CI (build #75858, 0fe017f): the new test and every other test/js/sql/ file pass on all lanes. Remaining red is unrelated to this diff:

  • test/js/node/test/parallel/test-net-connect-memleak.js on Alpine x64/x64-baseline: pre-existing break on main (handoff already filed).
  • :darwin: 14 aarch64: Tart VM boot failed ("guest never up; number of VMs exceeds the system limit"), tests never ran.
  • Ten other tests are [flaky] (passed on retry).

Ready for review; needs a maintainer to merge past the unrelated red lanes.

@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:24 PM PT - Jul 19th, 2026

@robobun, your commit 0fe017f has 1 failures in Build #75858 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34732

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

bun-34732 --bun

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e09358c6-8fe3-40ef-b281-46308d249f2c

📥 Commits

Reviewing files that changed from the base of the PR and between 0746a14 and 0fe017f.

📒 Files selected for processing (1)
  • test/js/sql/postgres-bind-throw-torn-frame.test.ts

Walkthrough

Changes

The PostgreSQL writer now supports truncation and atomic rollback. Extended-query requests use atomic encoding for Parse, Bind, Execute, FLUSH, and SYNC messages. Tests cover throwing valueOf() and toString() coercions without leaving torn wire frames.

PostgreSQL atomic writes

Layer / File(s) Summary
Writer rollback primitives
src/sql/postgres/protocol/ArrayList.rs, src/sql/postgres/protocol/NewWriter.rs, src/sql_jsc/postgres/PostgresSQLConnection.rs
Writer contexts truncate buffered bytes, and NewWriter::atomically restores the original offset when encoding fails.
Atomic PostgreSQL request batches
src/sql_jsc/postgres/PostgresRequest.rs
Parse, Bind, Execute, FLUSH, and SYNC writes are grouped inside atomic writer closures.
Torn-frame regression coverage
test/js/sql/postgres-bind-throw-torn-frame.test.ts
Mock-server tests validate recovery after valueOf() and toString() throw during Bind serialization.

Possibly related PRs

  • oven-sh/bun#34708: Both changes roll back partially serialized PostgreSQL protocol data on Bind-related failures.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is detailed, but it does not use the required 'What does this PR do?' and 'How did you verify your code works?' sections. Reformat the body to match the template, adding the required headings and placing the fix summary and verification steps under them.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main fix: rolling back the write buffer when Bind encoding fails.
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.

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

@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/postgres-bind-throw-torn-frame.test.ts`:
- Around line 1-13: Trim the opening comment in the fault-injection test to the
established shared three-line banner, removing the extended write_bind mechanism
explanation. Apply the same cleanup to the additional commentary blocks around
the referenced test sections, preserving only the shared banner and leaving test
behavior 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: 02db2848-f051-4b9d-898b-55a7c8ffc1b3

📥 Commits

Reviewing files that changed from the base of the PR and between 99fc2f8 and 0746a14.

📒 Files selected for processing (5)
  • src/sql/postgres/protocol/ArrayList.rs
  • src/sql/postgres/protocol/NewWriter.rs
  • src/sql_jsc/postgres/PostgresRequest.rs
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • test/js/sql/postgres-bind-throw-torn-frame.test.ts

Comment thread test/js/sql/postgres-bind-throw-torn-frame.test.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. sql(postgres): reject out-of-range JS numbers bound to int4 parameters #34708 - Also adds write buffer rollback when JS parameter encoding throws during Bind message serialization

🤖 Generated with Claude Code

@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate, but there is overlap (already noted in the PR body). #34708's primary fix is rejecting out-of-range int4 values; it adds a mark/rollback pair at each of the six call sites as a supporting change so its new Overflow error doesn't poison the buffer. This PR's primary fix is the buffer rollback itself, done once in the writer via NewWriter::atomically so the three batch functions are self-contained and any future caller is covered, with a test that targets the throwing-valueOf/toString path directly. Whichever merges first leaves the other a small rebase.

Comment thread test/js/sql/postgres-bind-throw-torn-frame.test.ts Outdated
pgReadFrontendMessages returns the unconsumed tail so a frame split across
TCP segments survives; the mock was discarding it and clearing pending,
which would hang the test if a client batch straddled two data events.
@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Alternate approach on branch farm/04b9ba2b/pg-bind-encode-before-write: split write_bind into encode_bind_params (all JS, returns Vec<BoundValue> + format codes) and write_bind_encoded (pure writes, no JS), so the three batch entry points encode before their first write and no rollback is needed. Same test coverage (byte-capturing mock + real-server round-trips for int4/float8/text/json coercion paths). Either approach fixes the desync; leaving it to the reviewer which shape to land.

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

LGTM — the earlier pgReadFrontendMessages leftover-carry nit is addressed and the rollback logic checks out.

What was reviewed:

  • truncate is symmetric with offset() for both implementors (ArrayListCtxVec::truncate; connection Writerbyte_list.truncate(head + offset) matching OffsetByteList::len() = byte_list.len() - head).
  • All write_bind call paths route through one of the three wrapped batch functions; the closure wrap is indent-only on the success path.
  • Test now carries the pgReadFrontendMessages leftover across data events and accumulates received from the incoming chunk, matching the sibling mock backends.
Extended reasoning...

Overview

Adds WriterContext::truncate and a NewWriter::atomically snapshot/rollback helper, then wraps the three Postgres batch writers (bind_and_execute, prepare_and_query_with_signature, parse_and_bind_and_execute) so that when a JS parameter coercion throws mid-write_bind, the half-written B\\0\\0\\0\\0… frame is truncated out of the write buffer instead of being flushed ahead of the next query. A byte-capturing mock-backend test asserts the wire framing directly.

Security risks

None. This is error-path cleanup on the client's own outgoing buffer; no new parsing of untrusted server bytes, no auth/TLS/permission changes.

Level of scrutiny

Moderate. It touches the Postgres extended-query serialisation path, but the diff is structurally conservative: the three function bodies are wrapped in a closure with no logic changes on the success path; the new code runs only on Err. I verified:

  • The postgres WriterContext trait has exactly two implementors (ArrayListCtx, connection Writer), both updated. The MySQL and js_printer WriterContext are unrelated traits.
  • OffsetByteList::len() is byte_list.len() - head, so the connection Writer::truncate computing byte_list.truncate(head + offset) restores exactly the state offset() snapshotted (assuming head is unchanged across the batch, which it is — head only advances via consume() after a socket flush, not during message encoding).
  • write_bind is called only from the three wrapped functions, and those three cover all six external call sites (4 in PostgresSQLConnection::advance, 2 in PostgresSQLQuery::run).

Other factors

  • My prior inline nit (discarding the pgReadFrontendMessages leftover and unconditionally resetting pending) was fixed in 0fe017f; the mock now matches the sibling callers in postgres-datarow-overrun.test.ts and postgres-split-prepare-reorder-fixture.ts.
  • CodeRabbit's comment-length nit was addressed and marked resolved.
  • PR body has fail-before (tornAt: 104, head: "42 00 00 00 00 …") / pass-after evidence on both ASAN debug and release.
  • The noted overlap with #34708 is a merge-order coordination question, not a correctness concern for this change; whichever lands first leaves the other a small rebase, and this PR's centralised-in-the-writer approach is the cleaner of the two.

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.

1 participant