sql(postgres): release WTFStringImpl from json_stringify_fast in json/jsonb bind

Closed
RO
3mo ago
+187-97

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.rs write_bind, json/jsonb arm. One WTFStringImpl leaked per JSON parameter bound once ParameterDescription populated statement.parameters with oid 114/3802. This is the user-visible leak in #40102.
  • jest.rs format_label, %j/%o arm.
  • pretty_format.rs Tag::JSON arm.

Fix

Two layers:

  1. Wrap the three leaking sites in OwnedString (first two commits). This is the minimal fix that the leak test proves.
  2. Make the compiler enforce it (d7fb706). JSValue::json_stringify and json_stringify_fast now return JsResult<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 with into_inner(). On the C++ side the two entry points collapse into one that takes space as an EncodedJSValue; 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)

   Compiling bun_core v0.0.0 (/workspace/bun/src/bun_core)
   Compiling bun_errno v0.0.0 (/workspace/bun/src/errno)
   Compiling bun_ptr v0.0.0 (/workspace/bun/src/ptr)
   Compiling bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
   Compiling bun_safety v0.0.0 (/workspace/bun/src/safety)
   Compiling bun_base64 v0.0.0 (/workspace/bun/src/base64)
   Compiling bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
   Compiling bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
   Compiling 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