sql(postgres): release WTFStringImpl from json_stringify_fast in json/jsonb bind
Repro
import { SQL } from "bun";
const sql = new SQL(process.env.DATABASE_URL);
const big = { data: "x".repeat(10000) };
for (let i = 0; i < 200; i++) await sql`SELECT ${big}::jsonb AS j`;
Bun.gc(true);
const rss0 = process.memoryUsage().rss;
for (let i = 0; i < 5000; i++) await sql`SELECT ${big}::jsonb AS j`;
Bun.gc(true);
console.log(((process.memoryUsage().rss - rss0) / 1048576).toFixed(2), "MiB");
// ~338 MiB on Rust HEAD, ~0 on Zig 1.3.1
Cause
JSC__JSValue__jsonStringify{,Fast} write a +1 WTFStringImpl ref into a BunString out-param. bun_core::String is #[derive(Copy)] with no Drop impl (src/bun_core/string/mod.rs), so every caller had to remember to wrap it in OwnedString. The Zig originals had defer str.deref().
Three Rust callers did not wrap, and two of them carried a comment claiming Drop would release it:
PostgresRequest.rswrite_bind, json/jsonb arm. OneWTFStringImplleaked per JSON parameter bound onceParameterDescriptionpopulatedstatement.parameterswith oid 114/3802. This is the user-visible leak in #40102.jest.rsformat_label,%j/%oarm.pretty_format.rsTag::JSONarm.
Fix
Two layers:
- Wrap the three leaking sites in
OwnedString(first two commits). This is the minimal fix that the leak test proves. - Make the compiler enforce it (d7fb706).
JSValue::json_stringifyandjson_stringify_fastnow returnJsResult<OwnedString>instead of taking&mut bun_core::String. A bare +1 no longer reaches any caller. The one site that legitimately hands the ref onward (Response::construct_json) disarms withinto_inner(). On the C++ side the two entry points collapse into one that takesspaceas anEncodedJSValue;jsUndefined()is what selects the FastStringifier.
Every former caller (MySQL, ConsoleObject %J, ipc, JSTranspiler, Response, the three above) is updated to the new signature.
Verification
test/js/sql/sql-postgres-json-bind-leak.test.ts runs a mock Postgres server that replies ParameterDescription([114]), then measures RSS across 300 binds of a 512 KiB JSON payload in a subprocess (ASAN quarantine disabled so freed memory is visible).
bun bd, src/ at main: deltaMiB 163.4 -> fail (threshold 50)
bun bd, with fix: pass
release bun (system): deltaMiB 169 -> fail
Also smoke-tested the other callers on the final commit: console.log("%j|%o"), Response.json(), Bun.Transpiler with tsconfig/macro objects, Bun.spawn IPC send(), and test/js/bun/test/jest-each.test.ts.
Fixes #40102.
[review] gate passed · iteration 2 · 12 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/sql-postgres-json-bind-leak.test.ts
bun test v1.4.1 (4448a2e21)
test/js/sql/sql-postgres-json-bind-leak.test.ts:
131 | expect(trimmedStdout).not.toBe("");
132 | const { deltaMiB } = JSON.parse(trimmedStdout);
133 | // With the leak, every one of the ~300 x 512 KiB stringified payloads is
134 | // retained (>= ~150 MiB). With the fix the WTFStringImpl is deref'd on scope
135 | // exit and growth stays in single digits.
136 | expect(deltaMiB).toBeLessThan(50);
^
error: expect(received).toBeLessThan(expected)
Expected: < 50
Received: 162.859375
at <anonymous> (/workspace/bun/test/js/sql/sql-postgres-json-bind-leak.test.ts:136:20)
(fail) Postgres: JSON.stringify result for json/jsonb bind parameter is not leaked [7182.91ms]
0 pass
1 fail
3 expect() calls
Ran 1 test across 1 file. [9.69s]
error: script "bd" exited with code 1
__F:1:S:0
release without fix: 1 FAILED
bun test v1.4.0-canary.1 (4448a2e21)
test/js/sql/sql-postgres-json-bind-leak.test.ts:
131 | expect(trimmedStdout).not.toBe("");
132 | const { deltaMiB } = JSON.parse(trimmedStdout);
133 | // With the leak, every one of the ~300 x 512 KiB stringified payloads is
134 | // retained (>= ~150 MiB). With the fix the WTFStringImpl is deref'd on scope
135 | // exit and growth stays in single digits.
136 | expect(deltaMiB).toBeLessThan(50);
^
error: expect(received).toBeLessThan(expected)
Expected: < 50
Received: 163.22265625
at <anonymous> (/workspace/bun/test/js/sql/sql-postgres-json-bind-leak.test.ts:136:20)
(fail) Postgres: JSON.stringify result for json/jsonb bind parameter is not leaked [342.26ms]
0 pass
1 fail
3 expect() calls
Ran 1 test across 1 file. [492.00ms]
__F:1: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/sql-postgres-json-bind-leak.test.ts
bun test v1.4.1 (4448a2e21)
test/js/sql/sql-postgres-json-bind-leak.test.ts:
(pass) Postgres: JSON.stringify result for json/jsonb bind parameter is not leaked [7003.88ms]
1 pass
0 fail
4 expect() calls
Ran 1 test across 1 file. [9.03s]
__F:0:S:0
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 647ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/22] gen generated_host_exports.rs
generated_host_exports.rs: 120 exports (host=5, lazy=10, generic=105, rust=0); 244 extern-C blocks audited
[2/22] gen cpp.rs (cppbind)
[2/22] cargo bun_runtime → libbun_runtime.a (--target x86_64-unknown-linux-gnu)
nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)
[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[92m Compiling[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
[1m[92m Compiling[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
[1m[92m Compiling[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
[1m[92m Compiling[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
[1m[92m Compiling[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
[1m[92m Compiling[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
[1m[92m Compiling[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
[1
... (truncated)
diff hotspot
src/jsc/ConsoleObject.rs | 11 +-
src/jsc/JSValue.rs | 41 +++----
src/jsc/bindings/bindings.cpp | 25 ++---
src/jsc/bindings/headers.h | 3 +-
src/runtime/api/JSTranspiler.rs | 20 ++--
src/runtime/ipc.rs | 10 +-
src/runtime/test_runner/jest.rs | 5 +-
src/runtime/test_runner/pretty_format.rs | 4 +-
src/runtime/webcore/Response.rs | 14 +--
src/sql_jsc/mysql/MySQLValue.rs | 6 +-
src/sql_jsc/postgres/PostgresRequest.rs | 7 +-
test/js/sql/sql-postgres-json-bind-leak.test.ts | 138 ++++++++++++++++++++++++
12 files changed, 187 insertions(+), 97 deletions(-)
gate history · 1 passed · 0 rejected · iteration 2
evidence per changed file
file reads edits tests
src/jsc/ConsoleObject.rs 0 0 6
src/jsc/JSValue.rs 0 0 6
src/jsc/bindings/bindings.cpp 0 0 6
src/jsc/bindings/headers.h 0 0 6
src/runtime/api/JSTranspiler.rs 0 0 6
src/runtime/ipc.rs 0 0 6
src/runtime/test_runner/jest.rs 1 2 6
src/runtime/test_runner/pretty_format.rs 1 1 7
src/runtime/webcore/Response.rs 0 0 6
src/sql_jsc/mysql/MySQLValue.rs 0 0 6
src/sql_jsc/postgres/PostgresRequest.rs 1 1 6
test/js/sql/sql-postgres-json-bind-leak.test.ts 1 3 6