Repository navigation
Fix PostgreSQL StringBuilder assertion failure with empty error messages - #22558
Conversation
When PostgreSQL returns an error response with completely empty message fields, the ErrorResponse.toJS function would call StringBuilder.allocatedSlice() with b.len == 0, triggering an assertion failure in debug builds. This fix checks if b.len > 0 before calling allocatedSlice(), using an empty string when there's no content to avoid the assertion. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
|
Updated 5:56 PM PT - Sep 10th, 2025
❌ @autofix-ci[bot], your commit 902c7b0 has some failures in 🧪 To try this PR locally: bunx bun-pr 22558That installs a local version of the PR into your bun-22558 --bun |
WalkthroughAdds a safeguard in Postgres ErrorResponse.toJS to handle empty error messages without slicing past length, and introduces a regression test that simulates an empty Postgres error packet to ensure no crash under concurrent queries. Changes
Possibly related PRs
Suggested reviewers
Pre-merge checks (2 passed, 1 warning)❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing Touches
🧪 Generate unit tests
Comment |
There was a problem hiding this comment.
Actionable comments posted: 0
🧹 Nitpick comments (4)
test/regression/issue/postgres-stringbuilder-assertion-aggressive.test.ts (4)
6-11: Use socket.end() to avoid racy partial writesDestroying the socket immediately after write can drop bytes and introduce flakiness. End the socket gracefully so the tiny packet is flushed.
- socket.write(createPostgresPacket("E", Buffer.from("\0"))); // Terminator only - socket.destroy(); + socket.end(createPostgresPacket("E", Buffer.from("\0"))); // Terminator only, graceful FIN
32-38: Assert outcomes instead of a no-op assertionCapture results and assert every query errored (coerced to null) rather than expect(true).
- await Promise.all(promises); - await sql.close(); - server.close(); - - // If we get here without crashing, the test passes - expect(true).toBe(true); + const results = await Promise.all(promises); + await sql.close(); + await new Promise<void>(r => server.close(() => r())); + + // Ensure all queries errored (we coerced to null) + expect(results.every(r => r === null)).toBe(true);
34-34: Wait for server to close before finishing the testAwaiting close ensures clean teardown and reduces port/socket leaks in CI.
- server.close(); + await new Promise<void>(r => server.close(() => r()));
1-1: Consider encoding the tracking number in the filenameTo align with the repo’s regression test organization (“organize by issue number”), consider renaming to include the PR/issue identifier, e.g., test/regression/issue/22558-postgres-stringbuilder-assertion-aggressive.test.ts.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
src/sql/postgres/protocol/ErrorResponse.zig(1 hunks)test/regression/issue/postgres-stringbuilder-assertion-aggressive.test.ts(1 hunks)
🧰 Additional context used
📓 Path-based instructions (8)
test/**
📄 CodeRabbit inference engine (.cursor/rules/writing-tests.mdc)
Place all tests under the test/ directory
Files:
test/regression/issue/postgres-stringbuilder-assertion-aggressive.test.ts
test/**/*.{js,ts}
📄 CodeRabbit inference engine (.cursor/rules/writing-tests.mdc)
test/**/*.{js,ts}: Write tests in JavaScript or TypeScript using Bun’s Jest-style APIs (test, describe, expect) and run with bun test
Prefer data-driven tests (e.g., test.each) to reduce boilerplate
Use shared utilities from test/harness.ts where applicable
Files:
test/regression/issue/postgres-stringbuilder-assertion-aggressive.test.ts
test/**/*.test.ts
📄 CodeRabbit inference engine (test/CLAUDE.md)
test/**/*.test.ts: Name test files*.test.tsand usebun:test
Do not write flaky tests: never wait for arbitrary time; wait for conditions instead
Never hardcode port numbers in tests; useport: 0to get a random port
When spawning Bun in tests, usebunExe()andbunEnvfromharness
Preferasync/awaitin tests; for a single callback, usePromise.withResolvers()
Do not set explicit test timeouts; rely on Bun’s built-in timeouts
UsetempDir/tempDirWithFilesfromharnessfor temporary files and directories in tests
For large/repetitive strings in tests, preferBuffer.alloc(count, fill).toString()over"A".repeat(count)
Import common test utilities fromharness(e.g.,bunExe,bunEnv,tempDirWithFiles,tmpdirSync, platform checks, GC helpers)
In error tests, assert non-zero exit codes for failing processes and usetoThrowfor synchronous errors
Usedescribeblocks for grouping,describe.eachfor parameterized tests, snapshots withtoMatchSnapshot, and lifecycle hooks (beforeAll,beforeEach,afterEach); track resources for cleanup inafterEach
Useusing/await usingwith Bun resources (e.g., Bun.listen/connect/spawn/serve) to ensure cleanup in tests
Files:
test/regression/issue/postgres-stringbuilder-assertion-aggressive.test.ts
test/regression/issue/**
📄 CodeRabbit inference engine (test/CLAUDE.md)
Place regression tests under
test/regression/issue/and organize by issue number
Files:
test/regression/issue/postgres-stringbuilder-assertion-aggressive.test.ts
test/**/*.test.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
test/**/*.test.{ts,tsx}: Test files must be placed under test/ and end with .test.ts or .test.tsx
In tests, always use port: 0; do not hardcode ports or use custom random port functions
In tests, use normalizeBunSnapshot when asserting snapshots
Never write tests that merely assert absence of "panic" or "uncaught exception" in output
Avoid shell commands (e.g., find, grep) in tests; use Bun.Glob and built-ins instead
Prefer snapshot tests over exact stdout equality assertions
Files:
test/regression/issue/postgres-stringbuilder-assertion-aggressive.test.ts
**/*.{js,ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Format JavaScript/TypeScript files with Prettier (bun run prettier)
Files:
test/regression/issue/postgres-stringbuilder-assertion-aggressive.test.ts
src/**/*.zig
📄 CodeRabbit inference engine (.cursor/rules/building-bun.mdc)
Implement debug logs in Zig using
const log = bun.Output.scoped(.${SCOPE}, false);and invokinglog("...", .{})
Files:
src/sql/postgres/protocol/ErrorResponse.zig
**/*.zig
📄 CodeRabbit inference engine (.cursor/rules/javascriptcore-class.mdc)
**/*.zig: Declare the extern C symbol in Zig and export a Zig-friendly alias for use
Wrap the Bun____toJS extern in a Zig method that takes a JSGlobalObject and returns JSC.JSValue
**/*.zig: Format Zig files with zig-format (bun run zig-format)
In Zig, manage memory carefully with allocators and use defer for cleanup
Files:
src/sql/postgres/protocol/ErrorResponse.zig
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Format
🔇 Additional comments (2)
src/sql/postgres/protocol/ErrorResponse.zig (1)
135-137: Fix avoids allocatedSlice() assert when buffer is empty — LGTMThe b.len guard correctly prevents calling allocatedSlice() with cap == 0. This addresses the debug-assert crash without affecting release behavior.
test/regression/issue/postgres-stringbuilder-assertion-aggressive.test.ts (1)
16-20: Confirmed: SQL acceptsmaxandconnection_timeout(plus common aliases).
internal/sql/shared.ts normalizes options and readsoptions.maxandoptions.connectionTimeout/options.connection_timeout/options.connectTimeout/options.connect_timeout(see src/js/internal/sql/shared.ts:465–472). packages/bun-types/sql.d.ts declaresconnectionTimeout/connection_timeoutandmax(see packages/bun-types/sql.d.ts:225,304).
### Problem
- An assert-enabled build (debug, ASAN) aborts with `panic: assertion
failed: self.cap > 0` in `StringBuilder::writable()`. `bun install`
reaches it with a hosted-git shortcut that ends at the colon, like
`"overrides": { "zz": "github:" }`.
- `allocate()` stores a pointer also for a count of 0 bytes.
`writable()` and `allocated_slice()`
(`src/bun_core/string/StringBuilder.rs:201-226`) asserted `cap > 0` for
each builder with a pointer.
### Fix
- `writable()` asserts `len <= cap`, the precondition of its pointer
arithmetic. This removes the abort.
- `allocated_slice()` loses the same assert. The Postgres call site
drops its #22558 guard and reads `written_slice()`. No path on main
fails there.
- Verified: `test/cli/install/hosted-git-info/`. On a debug build of
main, 5 install tests and the guard of 13 parser rows fail (timeouts).
All 18 pass with the fix. Two unit tests cover both views (Miri).
- Self-reviewed: 2 concerns raised, 2 addressed.
### Background
- `StringBuilder` counts each slice, allocates one buffer, then appends
into it.
- `HostedGitInfo` mirrors npm's hosted-git-info. Both return `project:
""` for `github:`. The tests record that output, not a new contract.
- Considered a guard in `HostedGitInfo::copy_from`. It adds a release
branch and leaves the assert false. Bake `computeChunks` (#20998) and
Postgres `ErrorResponse` (#22558) hit it before.
### Downsides
- Assert-enabled builds lose the `cap > 0` check. A builder that counted
nothing and appends bytes still panics on the bounds check.
- Release: 0 bytes changed in 4 `StringBuilder` symbols and 3 callers.
Only `error_response_jsc::to_js` changes: 4412 -> 4400 B.
<details><summary>Notes</summary>
**Origin.** A fuzzer found the abort on an assert-enabled build. No
issue exists. Only linux x64 was run.
**Proof runs (debug build).**
- Source of main (a4f1429): 5 of 5 new install tests fail. The
`beforeAll` hook of the 13 parser rows fails too, so these rows do not
run. Each failure shows `timed out after 5000ms`, not an assertion diff.
The child that aborts needs 5 s or more to print its stack trace. The
late output has `panic: assertion failed: self.cap > 0`.
- This PR: 18 of 18 pass.
- The new tests pass on a release build of main, because a release build
has no assert. `USE_SYSTEM_BUN=1` cannot show the failure.
-
`test/regression/issue/postgres-stringbuilder-assertion-aggressive.test.ts`
passes on main and on this PR. It does not tell the two apart.
- No caller reaches `allocated_slice()` with a zero-capacity buffer now.
The Postgres call site was the one caller that could, and it reads
`written_slice()`. `allocated_slice()` makes a slice over the whole
capacity, and only the first `len` bytes are written.
- Two unit tests in `StringBuilder.rs` take an empty builder through
`writable()`, `allocated_slice()`, `append()`, `written_slice()` and
`move_to_slice()`. `cargo miri test -p bun_core --lib string_builder`
runs them: 2 pass. With one of the old asserts back, 2 fail. CI does not
run them. `cargo test -p bun_core` does not link, and the Miri job does
not list `bun_core`, because other tests of that crate call foreign
functions.
**Measurements.** Release builds of main (a4f1429) and of this PR,
same toolchain.
- release code, 4 StringBuilder symbols and 3 inlining callers: 0 bytes
changed, 0 instructions differ (objdump). The symbols are
`StringBuilder::allocate`, `append`, `append_count`, `add_concat`,
`HostedGitInfo::from_url`, `HTTPClient::clone_metadata` and
`bun_picohttp::Response::clone`.
- release .text: +0 B (readelf, 58,143,477 B on main and on the PR).
`error_response_jsc::to_js`: 4412 -> 4400 B. It is the only one of
78,610 text symbols that changes size.
- from_url instructions per call (`github:user/repo#main`): 2596 -> 2596
(gdb `stepi` count over the call, JIT off). valgrind is not installed in
the container.
- allocator calls per from_url call: malloc 7, zalloc 1, realloc 0, free
7 on PR (main: 7, 1, 0, 7).
- syscalls, `bun install` on the override fixture: file 67 -> 67, read
18 -> 18, write 3 -> 3, network 0 -> 0 (gdb `catch syscall`). strace is
not installed in the container.
- assert-enabled checks per view call: `allocated_slice` 1 -> 0,
`writable` 1 -> 1.
- `len <= cap` in `writable()`: 0 assertion failures in 8 existing test
files on the debug build. The files are the three in
`test/cli/install/hosted-git-info/`,
`test/cli/install/overrides.test.ts`,
`test/bundler/bundler_compile.test.ts`,
`test/js/web/fetch/fetch.test.ts`, `test/js/bun/spawn/spawn.test.ts` and
the Postgres test above. `fetch.test.ts` has the same 25 failures on
main and on the PR. On the PR run, 2 more tests of
`bundler_compile.test.ts` and 4 more tests of `spawn.test.ts` reached
the 5000 ms timeout. All 6 pass with a longer timeout. The host had a
load average above 250 during the runs.
**Callers.** `bun_core::StringBuilder` has 27 construction sites in 18
files: 19 direct and 8 through `HeaderBuilder::default()`. `writable()`
has 10 callers outside the builder. Each one indexes the returned slice
with bounds checks and adds to `len` only what it wrote. So `len <= cap`
holds for them.
**Sites left as they are.**
- `src/runtime/valkey_jsc/js_valkey.rs:657-675` and
`src/bundler/linker_context/computeChunks.rs:551-555` skip the builder
when there is nothing to build. They also skip work, so they stay.
- Open PR #43942 sizes a buffer above the exact sum because of this
assert. It can use the exact sum after this PR.
**Tests.**
- The install tests are in
`hosted-git-info/boundary-conditions.test.ts`, not in
`overrides.test.ts`. That file installs from the public npm registry,
and these tests are about the spec parser.
- The `dependency github:` test records what release bun does for that
spec today. It sends one `GET /repos//tarball/` and exits with code 1.
The test uses a local server through `GITHUB_API_URL`.
- The expected objects in `from-url.test.ts` are the output of
hosted-git-info 9.0.0 on node 26.3.0 for the same 13 inputs.
- The 13 parser rows call the parser in the test process. A `beforeAll`
hook runs `bun install` with the override `github:` in a child process
first. An abort in the parser fails the hook, and the rows do not run. A
child process that imports `bun:internal-for-testing` needs 2 s to 5 s
on a debug build under load. That is too near the 5000 ms default.
- The `bun.lock` test compares each stderr line that names the lockfile.
`bun install` prints `Ignoring lockfile` and continues when it cannot
load `bun.lock`.
- One `bun install` covers the override values of all five providers.
**Differences from hosted-git-info 9.0.0 that this PR does not change.**
- `github:user/repo%2Egit`: the reference returns project `repo`, bun
returns `repo.git`.
- `github:%`: the reference returns `undefined`, `hostedGitInfo.fromUrl`
in bun throws `Invalid Git URL: InvalidURL`.
- `github:/.git`: the reference returns project `""`, bun returns `git`.
`new URL("github:/.git").pathname` is `git` in bun. Open PR #39457
covers that.
</details>
<!-- robobun:evidence:begin -->
---
**[human-review]** gate passed · iteration 1 · 4 files touched
<details><summary>fails on main (without fix)</summary>
```console
ASAN without fix: 6 failed, 32 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/cli/install/hosted-git-info/boundary-conditions.test.ts test/cli/install/hosted-git-info/from-url.test.ts
bun test v1.4.3 (367d939)
test/cli/install/hosted-git-info/from-url.test.ts:
killed 1 dangling process
(fail) fromUrl > shortcuts with an empty user, project and committish > (unnamed) [5008.49ms]
^ a beforeEach/afterEach hook timed out for this test.
(pass) fromUrl > valid urls > bitbucket > parses bitbucket:foo/bar [11.72ms]
(pass) fromUrl > valid urls > bitbucket > parses bitbucket:foo/bar#branch [4.62ms]
(pass) fromUrl > valid urls > bitbucket > parses bitbucket:user@foo/bar [3.28ms]
(pass) fromUrl > valid urls > bitbucket > parses bitbucket:user@foo/bar#branch [1.22ms]
(pass) fromUrl > valid urls > bitbucket > parses bitbucket:user:password@foo/bar [1.26ms]
(pass) fromUrl > valid urls > bitbucket > parses bitbucket:user:password@foo/bar#branch [3.13ms]
(pass) fromUrl > valid urls > bitbucket > parses bitbucket::password@foo/bar [0.94ms]
(pass) fromUrl > valid urls > bitbucket > parses bitbucket::password@foo/bar#branc
... (truncated)
release without fix: 32 skipped
bun test v1.4.3-canary.1 (6780601)
test/cli/install/hosted-git-info/from-url.test.ts:
(pass) fromUrl > shortcuts with an empty user, project and committish > parses github: [2.39ms]
(pass) fromUrl > shortcuts with an empty user, project and committish > parses gitlab: [0.07ms]
(pass) fromUrl > shortcuts with an empty user, project and committish > parses bitbucket:
(pass) fromUrl > shortcuts with an empty user, project and committish > parses gist:
(pass) fromUrl > shortcuts with an empty user, project and committish > parses sourcehut: [1.02ms]
(pass) fromUrl > shortcuts with an empty user, project and committish > parses github:.git [0.01ms]
(pass) fromUrl > shortcuts with an empty user, project and committish > parses github:/
(pass) fromUrl > shortcuts with an empty user, project and committish > parses github:// [0.01ms]
(pass) fromUrl > shortcuts with an empty user, project and committish > parses github:#
(pass) fromUrl > shortcuts with an empty user, project and committish > parses github:?x
(pass) fromUrl > shortcuts with an empty user, project and committish > parses github:@
(pass) fromUrl > shortcuts with an empty user, project and committish > parses
... (truncated)
```
</details>
<details><summary>passes on PR (with fix)</summary>
```console
ASAN with fix: 32 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/cli/install/hosted-git-info/boundary-conditions.test.ts test/cli/install/hosted-git-info/from-url.test.ts
bun test v1.4.3 (367d939)
test/cli/install/hosted-git-info/from-url.test.ts:
(pass) fromUrl > shortcuts with an empty user, project and committish > parses github: [4.28ms]
(pass) fromUrl > shortcuts with an empty user, project and committish > parses gitlab: [1.19ms]
(pass) fromUrl > shortcuts with an empty user, project and committish > parses bitbucket: [0.91ms]
(pass) fromUrl > shortcuts with an empty user, project and committish > parses gist: [0.75ms]
(pass) fromUrl > shortcuts with an empty user, project and committish > parses sourcehut: [0.82ms]
(pass) fromUrl > shortcuts with an empty user, project and committish > parses github:.git [1.52ms]
(pass) fromUrl > shortcuts with an empty user, project and committish > parses github:/ [2.71ms]
(pass) fromUrl > shortcuts with an empty user, project and committish > parses github:// [0.98ms]
(pass) fromUrl > shortcuts with an empty user, project and committish > parses git
... (truncated)
release with fix: 32 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 6641ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/89] build.rs build_script_build
[2/89] rustc bun_platform
[3/89] rustc bun_core
[4/89] rustc bun_boringssl_sys
[5/89] rustc bun_safety
[6/89] rustc bun_cares_sys
[7/89] rustc bun_zlib_sys
[8/89] rustc bun_errno
[9/89] rustc bun_picohttp
[10/89] rustc bun_brotli
[11/89] rustc bun_zstd
[12/89] rustc bun_base64
[13/89] rustc bun_ptr
[14/89] rustc bun_output
[15/89] rustc bun_valkey
[16/89] rustc bun_clap
[17/89] rustc bun_lsquic_sys
[18/89] rustc bun_paths
[19/89] rustc bun_tcc_sys
[20/89] rustc bun_collections
[21/89] rustc bun_shell_parser
warning: `feature(generic_const_exprs)` is not supported with the next-generation trait solver
--> src/shell_parser/lib.rs:1:30
|
1 | #![feature(adt_const_params, generic_const_exprs, allocator_api)]
| ^^^^^^^^^^^^^^^^^^^
|
= note: `-Znext-solver=globally` is currently enabled by default for testing
= note: reverted the setting to `-Znext-solver=coherence` for this crate
= note: the currently stable trait s
... (truncated)
```
</details>
<details><summary>diff hotspot</summary>
```
src/bun_core/string/StringBuilder.rs | 30 +++++++-
.../postgres/protocol/error_response_jsc.rs | 8 +-
.../hosted-git-info/boundary-conditions.test.ts | 89 ++++++++++++++++++++++
test/cli/install/hosted-git-info/from-url.test.ts | 47 +++++++++++-
4 files changed, 164 insertions(+), 10 deletions(-)
```
</details>
**gate history** · 1 passed · 0 rejected · iteration 1
<details><summary>evidence per changed file</summary>
```
file reads edits tests
src/bun_core/string/StringBuilder.rs 2 1 35
src/sql_jsc/postgres/protocol/error_response_jsc.rs 2 2 27
…cli/install/hosted-git-info/boundary-conditions.test.ts 5 4 20
test/cli/install/hosted-git-info/from-url.test.ts 6 6 24
```
</details>
<!-- robobun:evidence:end -->
Summary
StringBuilder.allocatedSlice()to handle zero-length messagesThe Problem
When PostgreSQL sends an error response with completely empty message fields, the
ErrorResponse.toJSfunction would:b.capbut end up withb.len = 0(no actual content)b.allocatedSlice()[0..b.len]unconditionallyStringBuilder.allocatedSlice()that requirescap > 0This only affected debug builds since the assertion is compiled out in release builds.
The Fix
Check if
b.len > 0before callingallocatedSlice(). If there's no content, use an empty string instead.Test Plan
🤖 Generated with Claude Code