diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index e50f02454d23..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 = "c9296e353e365ecf0de82f273bb0a88a3df465be"; +export const WEBKIT_VERSION = "autobuild-preview-pr-317-23fb575d"; /** * 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..3636381e23e8 --- /dev/null +++ b/test/js/bun/util/json-parse-oom-fixture.js @@ -0,0 +1,61 @@ +// Force JSON.parse to hit allocator failure for its string-value copy. +// 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..."} +// 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 +// 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]); +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(prefix.length + N + suffix.length, 0x78); +} catch { + process.stdout.write("SETUP-FAIL\n"); + process.exit(2); +} +buf.write(prefix, 0, "latin1"); +buf.write(suffix, prefix.length + N, "latin1"); +let input; +try { + input = buf.toString("latin1"); +} catch { + process.stdout.write("SETUP-FAIL\n"); + process.exit(2); +} +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.allocUnsafe(chunk)); + } catch { + chunk = chunk >> 1; + } +} +process.stdout.write("INPUT-OK\n"); + +try { + if (shape === "reviver") JSON.parse(input, (k, v) => v); + else 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..8b138edd61f0 --- /dev/null +++ b/test/js/bun/util/json-parse-oom.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test"; +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 +// 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. 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"); + const limitKiB = 5 * 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, + // 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], + ]; + + 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 }; + }), + ); + + let sawCaught = false; + const reachedShapes = new Set(); + for (const { shape, size, stdout, stderr, exitCode, signal } of results) { + if (!stdout.includes("INPUT-OK")) continue; + 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({ + shape, + size, + signal: null, + }); + + 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); + } + } + + // 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); + }, +);