From 5f2722e164d8369a07543ecfa8bcecf472bb2ec1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:36:58 +0000 Subject: [PATCH 1/6] jsc: throw RangeError from JSON.parse when a string value cannot be allocated JSON.parse copies every string value longer than 16 chars into a fresh WTF::StringImpl via JSONAtomStringCache::makeJSString. The copy used the crash-or-succeed StringImplMalloc::malloc path, so a near-OOM process died inside fastCompactMalloc instead of throwing. Seen in Sentry as BUN-2Z94 (Windows), reproducible on Linux by parsing a ~200 MB quoted string under RLIMIT_AS with the rest of the address space filled. The fix lives in oven-sh/WebKit#317: the >16 char branch now goes through StringImpl::tryCreateUninitialized and a null result surfaces as throwOutOfMemoryError in LiteralParser::parsePrimitiveValue. This bumps WEBKIT_VERSION to that change and adds a Linux-only test that runs the fixture under ulimit -v (skipped under ASAN because the shadow reservation cannot coexist with RLIMIT_AS). --- scripts/build/deps/webkit.ts | 2 +- test/js/bun/util/json-parse-oom-fixture.js | 49 +++++++++++++++ test/js/bun/util/json-parse-oom.test.ts | 69 ++++++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 test/js/bun/util/json-parse-oom-fixture.js create mode 100644 test/js/bun/util/json-parse-oom.test.ts diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index e50f02454d23..26c40a470c07 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -8,7 +8,7 @@ // importing), every x64 at the nehalem floor (no separate -baseline variant), // typed-array constructor ClassInfo kept address-unique under LTO, and the // Windows ICU data table filtered + per-item zstd compressed. -export const WEBKIT_VERSION = "c9296e353e365ecf0de82f273bb0a88a3df465be"; +export const WEBKIT_VERSION = "autobuild-preview-pr-317-d9b06a28"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/test/js/bun/util/json-parse-oom-fixture.js b/test/js/bun/util/json-parse-oom-fixture.js new file mode 100644 index 000000000000..bd251c135329 --- /dev/null +++ b/test/js/bun/util/json-parse-oom-fixture.js @@ -0,0 +1,49 @@ +// Force JSON.parse to hit allocator failure for its string-value copy. +// Prepare a quoted JSON input of N bytes, then fill remaining address +// space with buffers >= N/4 so an N-byte allocation cannot succeed while +// smaller allocations (thread stacks, GC bookkeeping) still can. +// +// Outcomes, written to stdout: +// SETUP-FAIL the address-space limit was too tight to build the input +// INPUT-OK input built; JSON.parse is about to run +// PARSED JSON.parse succeeded (enough memory for the copy) +// CAUGHT:: JSON.parse threw +const N = Number(process.argv[2]); +let buf; +try { + buf = Buffer.alloc(N + 2, 0x78); +} catch { + process.stdout.write("SETUP-FAIL\n"); + process.exit(2); +} +buf[0] = 0x22; +buf[N + 1] = 0x22; +let input; +try { + input = buf.toString("latin1"); +} catch { + process.stdout.write("SETUP-FAIL\n"); + process.exit(2); +} +buf = null; + +const filler = []; +let chunk = N; +const floor = N >> 2; +while (chunk >= floor) { + try { + filler.push(Buffer.alloc(chunk)); + } catch { + chunk = chunk >> 1; + } +} +process.stdout.write("INPUT-OK\n"); + +try { + JSON.parse(input); + process.stdout.write("PARSED\n"); + process.exit(1); +} catch (e) { + process.stdout.write("CAUGHT:" + e.name + ":" + e.message + "\n"); + process.exit(0); +} diff --git a/test/js/bun/util/json-parse-oom.test.ts b/test/js/bun/util/json-parse-oom.test.ts new file mode 100644 index 000000000000..9fe964bfb85a --- /dev/null +++ b/test/js/bun/util/json-parse-oom.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isLinux } from "harness"; +import { join } from "node:path"; + +// JSON.parse copies every string value longer than 16 chars into a fresh +// StringImpl. That copy used the crash-or-succeed StringImplMalloc::malloc +// path, so a near-OOM process died with SIGILL inside fastCompactMalloc +// instead of throwing. The fix routes through StringImpl::tryCreateUninitialized +// and surfaces a RangeError, same as "x".repeat(tooLarge). +// +// RLIMIT_AS is the only portable way to make tryMalloc actually fail, and it +// cannot be combined with AddressSanitizer's 16 TB shadow reservation, so this +// test runs on non-ASAN Linux only. +test.skipIf(!isLinux || isASAN)( + "JSON.parse throws RangeError instead of crashing when a string value cannot be allocated", + async () => { + const fixture = join(import.meta.dir, "json-parse-oom-fixture.js"); + const limitKiB = 5 * 1024 * 1024; + // A single size/limit can miss the window on a given machine's address-space + // layout. Sweep a few sizes; every run that reaches INPUT-OK must either + // succeed or throw RangeError, never crash. + const sizes = [200, 300, 400, 500].map(mb => mb * 1024 * 1024); + let sawCaught = false; + let sawInputOK = false; + + for (const size of sizes) { + await using proc = Bun.spawn({ + cmd: [ + "/bin/sh", + "-c", + `ulimit -v ${limitKiB} && ulimit -c 0 && exec "$0" "$1" "$2"`, + bunExe(), + fixture, + String(size), + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const reachedParse = stdout.includes("INPUT-OK"); + if (!reachedParse) continue; + sawInputOK = true; + + // Once the input is built, JSON.parse must not kill the process. + expect({ size, stdout: stdout.trim(), stderr: stderr.trim(), exitCode, signal: proc.signalCode }).toMatchObject({ + size, + signal: null, + }); + expect([0, 1]).toContain(exitCode); + + if (stdout.includes("CAUGHT:")) { + expect(stdout).toContain("CAUGHT:RangeError:Out of memory"); + sawCaught = true; + } else { + expect(stdout).toContain("PARSED"); + } + } + + // The sweep has to actually reach JSON.parse at least once; otherwise the + // address-space cap was too tight and nothing was exercised. + expect(sawInputOK).toBe(true); + // And at least one of those runs must have taken the out-of-memory branch, + // otherwise the sweep never exercised the path this test is for. + expect(sawCaught).toBe(true); + }, + 30_000, +); From 4d147eb9751d805b6db9ad9201efff915a97325d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:42:52 +0000 Subject: [PATCH 2/6] test: cover array/object string values and tighten exit-code assertions --- test/js/bun/util/json-parse-oom-fixture.js | 20 +++++++++++----- test/js/bun/util/json-parse-oom.test.ts | 27 +++++++++++++++------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/test/js/bun/util/json-parse-oom-fixture.js b/test/js/bun/util/json-parse-oom-fixture.js index bd251c135329..de24113a0abf 100644 --- a/test/js/bun/util/json-parse-oom-fixture.js +++ b/test/js/bun/util/json-parse-oom-fixture.js @@ -1,7 +1,12 @@ // Force JSON.parse to hit allocator failure for its string-value copy. -// Prepare a quoted JSON input of N bytes, then fill remaining address -// space with buffers >= N/4 so an N-byte allocation cannot succeed while -// smaller allocations (thread stacks, GC bookkeeping) still can. +// Prepare a JSON input containing an N-byte string, then fill remaining +// address space with buffers >= N/4 so an N-byte allocation cannot succeed +// while smaller allocations (thread stacks, GC bookkeeping) still can. +// +// argv: +// shape "root" => "xxxx..." +// shape "array" => ["xxxx..."] +// shape "object" => {"k":"xxxx..."} // // Outcomes, written to stdout: // SETUP-FAIL the address-space limit was too tight to build the input @@ -9,15 +14,18 @@ // PARSED JSON.parse succeeded (enough memory for the copy) // CAUGHT:: JSON.parse threw const N = Number(process.argv[2]); +const shape = process.argv[3] || "root"; +const prefix = shape === "array" ? '["' : shape === "object" ? '{"k":"' : '"'; +const suffix = shape === "array" ? '"]' : shape === "object" ? '"}' : '"'; let buf; try { - buf = Buffer.alloc(N + 2, 0x78); + buf = Buffer.alloc(prefix.length + N + suffix.length, 0x78); } catch { process.stdout.write("SETUP-FAIL\n"); process.exit(2); } -buf[0] = 0x22; -buf[N + 1] = 0x22; +buf.write(prefix, 0, "latin1"); +buf.write(suffix, prefix.length + N, "latin1"); let input; try { input = buf.toString("latin1"); diff --git a/test/js/bun/util/json-parse-oom.test.ts b/test/js/bun/util/json-parse-oom.test.ts index 9fe964bfb85a..65559865ea64 100644 --- a/test/js/bun/util/json-parse-oom.test.ts +++ b/test/js/bun/util/json-parse-oom.test.ts @@ -16,22 +16,31 @@ test.skipIf(!isLinux || isASAN)( async () => { const fixture = join(import.meta.dir, "json-parse-oom-fixture.js"); const limitKiB = 5 * 1024 * 1024; - // A single size/limit can miss the window on a given machine's address-space - // layout. Sweep a few sizes; every run that reaches INPUT-OK must either - // succeed or throw RangeError, never crash. - const sizes = [200, 300, 400, 500].map(mb => mb * 1024 * 1024); + // The filler loop in the fixture exhausts address space down to N/4, so any + // size should hit the OOM path, but mimalloc's arena layout varies. Sweep a + // couple of sizes and every parsePrimitiveValue caller (top-level literal, + // array element, object property value); every run that reaches INPUT-OK + // must either succeed or throw RangeError, never crash. + const cases: Array<[shape: string, mb: number]> = [ + ["root", 200], + ["root", 400], + ["array", 300], + ["object", 300], + ]; let sawCaught = false; let sawInputOK = false; - for (const size of sizes) { + for (const [shape, mb] of cases) { + const size = mb * 1024 * 1024; await using proc = Bun.spawn({ cmd: [ "/bin/sh", "-c", - `ulimit -v ${limitKiB} && ulimit -c 0 && exec "$0" "$1" "$2"`, + `ulimit -v ${limitKiB} && ulimit -c 0 && exec "$0" "$1" "$2" "$3"`, bunExe(), fixture, String(size), + shape, ], env: bunEnv, stdout: "pipe", @@ -44,17 +53,19 @@ test.skipIf(!isLinux || isASAN)( sawInputOK = true; // Once the input is built, JSON.parse must not kill the process. - expect({ size, stdout: stdout.trim(), stderr: stderr.trim(), exitCode, signal: proc.signalCode }).toMatchObject({ + expect({ shape, size, stdout: stdout.trim(), stderr: stderr.trim(), exitCode, signal: proc.signalCode }).toMatchObject({ + shape, size, signal: null, }); - expect([0, 1]).toContain(exitCode); if (stdout.includes("CAUGHT:")) { expect(stdout).toContain("CAUGHT:RangeError:Out of memory"); + expect(exitCode).toBe(0); sawCaught = true; } else { expect(stdout).toContain("PARSED"); + expect(exitCode).toBe(1); } } From 20661c10636818928804fa142889813913185b38 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:44:53 +0000 Subject: [PATCH 3/6] [autofix.ci] apply automated fixes --- test/js/bun/util/json-parse-oom.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/js/bun/util/json-parse-oom.test.ts b/test/js/bun/util/json-parse-oom.test.ts index 65559865ea64..84771bfaf46f 100644 --- a/test/js/bun/util/json-parse-oom.test.ts +++ b/test/js/bun/util/json-parse-oom.test.ts @@ -53,7 +53,14 @@ test.skipIf(!isLinux || isASAN)( sawInputOK = true; // Once the input is built, JSON.parse must not kill the process. - expect({ shape, size, stdout: stdout.trim(), stderr: stderr.trim(), exitCode, signal: proc.signalCode }).toMatchObject({ + expect({ + shape, + size, + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode, + signal: proc.signalCode, + }).toMatchObject({ shape, size, signal: null, From d7704174382cc43b48b4de5ef2000e183f7fc316 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:50:38 +0000 Subject: [PATCH 4/6] test: run fixture cases concurrently, add reviver shape, drop per-test timeout allocUnsafe for the filler reserves address space without committing pages, so five children can run in parallel with low RSS. Skip on debug builds where the ~200 MB input construction alone takes seconds; the release Linux lanes are what this test targets. --- test/js/bun/util/json-parse-oom-fixture.js | 14 +++-- test/js/bun/util/json-parse-oom.test.ts | 73 +++++++++++----------- 2 files changed, 46 insertions(+), 41 deletions(-) diff --git a/test/js/bun/util/json-parse-oom-fixture.js b/test/js/bun/util/json-parse-oom-fixture.js index de24113a0abf..3636381e23e8 100644 --- a/test/js/bun/util/json-parse-oom-fixture.js +++ b/test/js/bun/util/json-parse-oom-fixture.js @@ -4,9 +4,10 @@ // while smaller allocations (thread stacks, GC bookkeeping) still can. // // argv: -// shape "root" => "xxxx..." -// shape "array" => ["xxxx..."] -// shape "object" => {"k":"xxxx..."} +// shape "root" => "xxxx..." +// shape "array" => ["xxxx..."] +// shape "object" => {"k":"xxxx..."} +// shape "reviver" => "xxxx..." parsed with JSON.parse(input, (k, v) => v) // // Outcomes, written to stdout: // SETUP-FAIL the address-space limit was too tight to build the input @@ -35,12 +36,14 @@ try { } buf = null; +// allocUnsafe reserves address space (which is what RLIMIT_AS bounds) without +// committing pages, so this loop is fast and its RSS cost is negligible. const filler = []; let chunk = N; const floor = N >> 2; while (chunk >= floor) { try { - filler.push(Buffer.alloc(chunk)); + filler.push(Buffer.allocUnsafe(chunk)); } catch { chunk = chunk >> 1; } @@ -48,7 +51,8 @@ while (chunk >= floor) { process.stdout.write("INPUT-OK\n"); try { - JSON.parse(input); + if (shape === "reviver") JSON.parse(input, (k, v) => v); + else JSON.parse(input); process.stdout.write("PARSED\n"); process.exit(1); } catch (e) { diff --git a/test/js/bun/util/json-parse-oom.test.ts b/test/js/bun/util/json-parse-oom.test.ts index 84771bfaf46f..0cdfb04451a9 100644 --- a/test/js/bun/util/json-parse-oom.test.ts +++ b/test/js/bun/util/json-parse-oom.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isLinux } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isLinux } from "harness"; import { join } from "node:path"; // JSON.parse copies every string value longer than 16 chars into a fresh @@ -9,9 +9,10 @@ import { join } from "node:path"; // and surfaces a RangeError, same as "x".repeat(tooLarge). // // RLIMIT_AS is the only portable way to make tryMalloc actually fail, and it -// cannot be combined with AddressSanitizer's 16 TB shadow reservation, so this -// test runs on non-ASAN Linux only. -test.skipIf(!isLinux || isASAN)( +// cannot be combined with AddressSanitizer's 16 TB shadow reservation. Debug +// builds take several seconds per fixture just to zero-fill and toString() the +// ~200 MB inputs, so this runs only on release Linux lanes. +test.skipIf(!isLinux || isASAN || isDebug)( "JSON.parse throws RangeError instead of crashing when a string value cannot be allocated", async () => { const fixture = join(import.meta.dir, "json-parse-oom-fixture.js"); @@ -19,48 +20,49 @@ test.skipIf(!isLinux || isASAN)( // The filler loop in the fixture exhausts address space down to N/4, so any // size should hit the OOM path, but mimalloc's arena layout varies. Sweep a // couple of sizes and every parsePrimitiveValue caller (top-level literal, - // array element, object property value); every run that reaches INPUT-OK - // must either succeed or throw RangeError, never crash. + // reviver-mode literal, array element, object property value); every run + // that reaches INPUT-OK must either succeed or throw RangeError, never + // crash. The fixture's filler uses allocUnsafe, which reserves address + // space without committing pages, so the children's combined RSS stays + // well under the host's memory and they can run concurrently. const cases: Array<[shape: string, mb: number]> = [ ["root", 200], ["root", 400], ["array", 300], ["object", 300], + ["reviver", 300], ]; - let sawCaught = false; - let sawInputOK = false; - for (const [shape, mb] of cases) { - const size = mb * 1024 * 1024; - await using proc = Bun.spawn({ - cmd: [ - "/bin/sh", - "-c", - `ulimit -v ${limitKiB} && ulimit -c 0 && exec "$0" "$1" "$2" "$3"`, - bunExe(), - fixture, - String(size), - shape, - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const results = await Promise.all( + cases.map(async ([shape, mb]) => { + const size = mb * 1024 * 1024; + await using proc = Bun.spawn({ + cmd: [ + "/bin/sh", + "-c", + `ulimit -v ${limitKiB} && ulimit -c 0 && exec "$0" "$1" "$2" "$3"`, + bunExe(), + fixture, + String(size), + shape, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { shape, size, stdout, stderr, exitCode, signal: proc.signalCode }; + }), + ); - const reachedParse = stdout.includes("INPUT-OK"); - if (!reachedParse) continue; + let sawCaught = false; + let sawInputOK = false; + for (const { shape, size, stdout, stderr, exitCode, signal } of results) { + if (!stdout.includes("INPUT-OK")) continue; sawInputOK = true; // Once the input is built, JSON.parse must not kill the process. - expect({ - shape, - size, - stdout: stdout.trim(), - stderr: stderr.trim(), - exitCode, - signal: proc.signalCode, - }).toMatchObject({ + expect({ shape, size, stdout: stdout.trim(), stderr: stderr.trim(), exitCode, signal }).toMatchObject({ shape, size, signal: null, @@ -83,5 +85,4 @@ test.skipIf(!isLinux || isASAN)( // otherwise the sweep never exercised the path this test is for. expect(sawCaught).toBe(true); }, - 30_000, ); From 4c0fd832bada6de12a95e82077c592bf5b2b4232 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:56:17 +0000 Subject: [PATCH 5/6] test: require every shape in the matrix to reach JSON.parse --- test/js/bun/util/json-parse-oom.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/js/bun/util/json-parse-oom.test.ts b/test/js/bun/util/json-parse-oom.test.ts index 0cdfb04451a9..8b138edd61f0 100644 --- a/test/js/bun/util/json-parse-oom.test.ts +++ b/test/js/bun/util/json-parse-oom.test.ts @@ -56,10 +56,10 @@ test.skipIf(!isLinux || isASAN || isDebug)( ); let sawCaught = false; - let sawInputOK = false; + const reachedShapes = new Set(); for (const { shape, size, stdout, stderr, exitCode, signal } of results) { if (!stdout.includes("INPUT-OK")) continue; - sawInputOK = true; + reachedShapes.add(shape); // Once the input is built, JSON.parse must not kill the process. expect({ shape, size, stdout: stdout.trim(), stderr: stderr.trim(), exitCode, signal }).toMatchObject({ @@ -78,9 +78,9 @@ test.skipIf(!isLinux || isASAN || isDebug)( } } - // The sweep has to actually reach JSON.parse at least once; otherwise the - // address-space cap was too tight and nothing was exercised. - expect(sawInputOK).toBe(true); + // Every shape must reach JSON.parse; a SETUP-FAIL means the address-space + // cap was too tight for that case and nothing was exercised there. + expect([...reachedShapes].sort()).toEqual([...new Set(cases.map(([s]) => s))].sort()); // And at least one of those runs must have taken the out-of-memory branch, // otherwise the sweep never exercised the path this test is for. expect(sawCaught).toBe(true); From 4b69972d32be2a4b603a304f393cd848be0dd1ae Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:06:38 +0000 Subject: [PATCH 6/6] build: bump WebKit to the tryMakeJSString rename (preview-pr-317-23fb575d) --- scripts/build/deps/webkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 26c40a470c07..0fbb2ef946a8 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -8,7 +8,7 @@ // importing), every x64 at the nehalem floor (no separate -baseline variant), // typed-array constructor ClassInfo kept address-unique under LTO, and the // Windows ICU data table filtered + per-item zstd compressed. -export const WEBKIT_VERSION = "autobuild-preview-pr-317-d9b06a28"; +export const WEBKIT_VERSION = "autobuild-preview-pr-317-23fb575d"; /** * WebKit (JavaScriptCore) — the JS engine.