From c25fc43de2981a32851d15cee4388ca4847ba472 Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 18 Jul 2026 12:12:10 +0000 Subject: [PATCH 1/6] jsc: test that a concurrent DFG plan's OSR-entry snapshot does not root user objects The retaining root behind the test-gc-http-client N-1/N stall (and the earlier once()-nulling workarounds in node:events / internal/shared) is not the conservative stack scan: a GCDebugging heap snapshot taken when the test is stuck shows the surviving IncomingMessage/ClientRequest rooted with RootMarkReason::JITWorkList. DFG::Plan::m_mustHandleValues captures whatever locals were live in the frame that triggered loop OSR and marks them as roots for the life of the concurrent compile. oven-sh/WebKit#308 makes that snapshot weak: entries that nothing else marks are dropped in Plan::finalizeInGC, and every compiler phase that reads m_mustHandleValues already skips nullopt. The fixture drives enough independent functions to DFG at once via the http client/server path that several plans are queued when the first gc() runs, then asserts zero ClientRequest/IncomingMessage are JITWorkList-rooted in the debugging heap snapshot. --- test/js/bun/jsc/bun-jsc.test.ts | 28 ++++++++++++++++ test/js/bun/jsc/dfg-plan-gc-fixture.js | 45 ++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 test/js/bun/jsc/dfg-plan-gc-fixture.js diff --git a/test/js/bun/jsc/bun-jsc.test.ts b/test/js/bun/jsc/bun-jsc.test.ts index 13657cb8909c..498e57745df7 100644 --- a/test/js/bun/jsc/bun-jsc.test.ts +++ b/test/js/bun/jsc/bun-jsc.test.ts @@ -25,6 +25,7 @@ import { } from "bun:jsc"; import { describe, expect, it } from "bun:test"; import { bunEnv, bunExe, isBuildKite, isWindows } from "harness"; +import path from "node:path"; describe("bun:jsc", () => { function count() { @@ -556,3 +557,30 @@ it("deserialize applies the same nesting depth limit to arrays as to objects", a const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout, exitCode }).toEqual({ stdout: "rejected\n65\n", exitCode: 0 }); }); + +// Objects live in a frame at the moment a loop triggers DFG/FTL tier-up are +// captured into the compilation plan's m_mustHandleValues. Those were rooted +// as RootMarkReason::JITWorkList for the life of the concurrent compile, so a +// gc() issued while plans were queued reported fewer objects collected than the +// program had let go of (node's test-gc-http-client* hit this). The snapshot is +// now treated as weak; every DFG phase that reads it already handles nullopt. +it("gc() does not root user objects from a concurrent DFG plan's OSR-entry snapshot", async () => { + // Reproducing this needs several independent functions to request DFG at + // roughly the same time so most plans are still in the worklist at gc(). The + // http client/server path does that reliably (emit, nextTick drain, stream + // flow all tier up during the first burst of responses). The fixture reports + // how many ClientRequest/IncomingMessage instances the debugging heap + // snapshot attributes directly to the JIT worklist; that count must be zero. + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(import.meta.dir, "dfg-plan-gc-fixture.js")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + // The "alive" count is timing dependent (how many plans were queued at the + // first gc()), but no ClientRequest/IncomingMessage may be a JITWorkList root. + expect(stdout.trim()).toMatch(/^jitworklist-rooted=0 alive=\d+$/); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/jsc/dfg-plan-gc-fixture.js b/test/js/bun/jsc/dfg-plan-gc-fixture.js new file mode 100644 index 000000000000..772f38d183fb --- /dev/null +++ b/test/js/bun/jsc/dfg-plan-gc-fixture.js @@ -0,0 +1,45 @@ +// The http client/server path drives enough independent functions to DFG at +// once (emit, nextTick drain, stream flow) that several plans are still in the +// concurrent JIT worklist when the first gc() runs. Previously each plan's +// m_mustHandleValues rooted whatever request/response objects were live in the +// frame that triggered tier-up (RootMarkReason::JITWorkList). +"use strict"; +const http = require("http"); +const jsc = require("bun:jsc"); +const N = 32; +let done = 0; +const refs = []; +const server = http + .createServer((req, res) => { + res.writeHead(200); + res.end("ok"); + }) + .listen(0, "127.0.0.1", () => { + for (let i = 0; i < N; i++) { + const req = http.get({ hostname: "127.0.0.1", port: server.address().port }, res => { + res.resume(); + res.on("end", () => done++); + }); + refs.push(new WeakRef(req)); + } + }); +setImmediate(function check() { + if (done < N) return setImmediate(check); + Bun.gc(true); + // Count ClientRequest / IncomingMessage instances that the debugging heap + // snapshot attributes directly to the JIT worklist. + const snap = jsc.generateHeapSnapshotForDebugging(); + const NF = 7; + const RF = 3; + const { nodes, nodeClassNames, roots, labels } = snap; + const classOf = new Map(); + for (let i = 0; i < nodes.length; i += NF) classOf.set(nodes[i], nodeClassNames[nodes[i + 2]]); + let rooted = 0; + for (let i = 0; i < roots.length; i += RF) { + const cn = classOf.get(roots[i]); + if ((cn === "ClientRequest" || cn === "IncomingMessage") && labels[roots[i + 1]] === "JITWorkList") rooted++; + } + const alive = refs.filter(r => r.deref()).length; + console.log("jitworklist-rooted=" + rooted + " alive=" + alive); + server.close(); +}); From 74f674afa20f094bddaa28b2033db11424a2a582 Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 18 Jul 2026 13:07:35 +0000 Subject: [PATCH 2/6] bump WebKit to autobuild-preview-pr-308-06ecce07 and pick up the weak m_mustHandleValues change Also: - generateHeapSnapshotForDebugging: RELEASE_AND_RETURN around JSONParse so validateExceptionChecks does not assert on the simulated throw the fixture exercises on the x64-asan lane. - fixture: skip the heap snapshot when alive=0 (it is expensive under debug+ASAN and the count is trivially zero). - test: run the child with a single DFG/FTL compiler thread so plans queue rather than drain in parallel, and allow 30s on debug/ASAN builds (the 32 http requests alone take ~5s there). --- scripts/build/deps/webkit.ts | 2 +- src/jsc/modules/BunJSCModule.h | 2 +- test/js/bun/jsc/bun-jsc.test.ts | 12 ++++++++--- test/js/bun/jsc/dfg-plan-gc-fixture.js | 28 +++++++++++++++----------- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index b7ca822369da..48a95a35fcd4 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -7,7 +7,7 @@ // -lto variants built with ThinLTO (per-module summaries for cross-language // importing), and the Windows ICU data table filtered + per-item zstd // compressed (lazily decompressed via bun_icu_decompress.cpp). -export const WEBKIT_VERSION = "4895f45dfbd0d1226c4d41799887bc0ecb9f341b"; +export const WEBKIT_VERSION = "autobuild-preview-pr-308-06ecce07"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/src/jsc/modules/BunJSCModule.h b/src/jsc/modules/BunJSCModule.h index deb683bb80d7..4633af86f315 100644 --- a/src/jsc/modules/BunJSCModule.h +++ b/src/jsc/modules/BunJSCModule.h @@ -799,7 +799,7 @@ JSC_DEFINE_HOST_FUNCTION(functionGenerateHeapSnapshotForDebugging, } scope.releaseAssertNoException(); - return JSValue::encode(JSONParse(globalObject, WTF::move(jsonString))); + RELEASE_AND_RETURN(scope, JSValue::encode(JSONParse(globalObject, WTF::move(jsonString)))); } JSC_DEFINE_HOST_FUNCTION(functionSerialize, diff --git a/test/js/bun/jsc/bun-jsc.test.ts b/test/js/bun/jsc/bun-jsc.test.ts index 498e57745df7..1408c25725e5 100644 --- a/test/js/bun/jsc/bun-jsc.test.ts +++ b/test/js/bun/jsc/bun-jsc.test.ts @@ -24,7 +24,7 @@ import { totalCompileTime, } from "bun:jsc"; import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isBuildKite, isWindows } from "harness"; +import { bunEnv, bunExe, isASAN, isBuildKite, isDebug, isWindows } from "harness"; import path from "node:path"; describe("bun:jsc", () => { @@ -571,8 +571,14 @@ it("gc() does not root user objects from a concurrent DFG plan's OSR-entry snaps // flow all tier up during the first burst of responses). The fixture reports // how many ClientRequest/IncomingMessage instances the debugging heap // snapshot attributes directly to the JIT worklist; that count must be zero. + // One compiler thread so plans queue instead of draining in parallel. await using proc = Bun.spawn({ - cmd: [bunExe(), path.join(import.meta.dir, "dfg-plan-gc-fixture.js")], + cmd: [ + bunExe(), + "--jsc-numberOfDFGCompilerThreads=1", + "--jsc-numberOfFTLCompilerThreads=1", + path.join(import.meta.dir, "dfg-plan-gc-fixture.js"), + ], env: bunEnv, stdout: "pipe", stderr: "pipe", @@ -583,4 +589,4 @@ it("gc() does not root user objects from a concurrent DFG plan's OSR-entry snaps // first gc()), but no ClientRequest/IncomingMessage may be a JITWorkList root. expect(stdout.trim()).toMatch(/^jitworklist-rooted=0 alive=\d+$/); expect(exitCode).toBe(0); -}); +}, isDebug || isASAN ? 30_000 : undefined); diff --git a/test/js/bun/jsc/dfg-plan-gc-fixture.js b/test/js/bun/jsc/dfg-plan-gc-fixture.js index 772f38d183fb..129cbe0cf446 100644 --- a/test/js/bun/jsc/dfg-plan-gc-fixture.js +++ b/test/js/bun/jsc/dfg-plan-gc-fixture.js @@ -26,20 +26,24 @@ const server = http setImmediate(function check() { if (done < N) return setImmediate(check); Bun.gc(true); - // Count ClientRequest / IncomingMessage instances that the debugging heap - // snapshot attributes directly to the JIT worklist. - const snap = jsc.generateHeapSnapshotForDebugging(); - const NF = 7; - const RF = 3; - const { nodes, nodeClassNames, roots, labels } = snap; - const classOf = new Map(); - for (let i = 0; i < nodes.length; i += NF) classOf.set(nodes[i], nodeClassNames[nodes[i + 2]]); + const alive = refs.filter(r => r.deref()).length; + // If nothing survived there cannot be a JITWorkList-rooted instance either; + // skip the (expensive under ASAN) heap snapshot. let rooted = 0; - for (let i = 0; i < roots.length; i += RF) { - const cn = classOf.get(roots[i]); - if ((cn === "ClientRequest" || cn === "IncomingMessage") && labels[roots[i + 1]] === "JITWorkList") rooted++; + if (alive > 0) { + // Count ClientRequest / IncomingMessage instances that the debugging heap + // snapshot attributes directly to the JIT worklist. + const snap = jsc.generateHeapSnapshotForDebugging(); + const NF = 7; + const RF = 3; + const { nodes, nodeClassNames, roots, labels } = snap; + const classOf = new Map(); + for (let i = 0; i < nodes.length; i += NF) classOf.set(nodes[i], nodeClassNames[nodes[i + 2]]); + for (let i = 0; i < roots.length; i += RF) { + const cn = classOf.get(roots[i]); + if ((cn === "ClientRequest" || cn === "IncomingMessage") && labels[roots[i + 1]] === "JITWorkList") rooted++; + } } - const alive = refs.filter(r => r.deref()).length; console.log("jitworklist-rooted=" + rooted + " alive=" + alive); server.close(); }); From 333cc6ba9bd69de729eeb4b6068b31be659ca1d9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:09:43 +0000 Subject: [PATCH 3/6] [autofix.ci] apply automated fixes --- test/js/bun/jsc/bun-jsc.test.ts | 56 ++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/test/js/bun/jsc/bun-jsc.test.ts b/test/js/bun/jsc/bun-jsc.test.ts index 1408c25725e5..735f51fb60e5 100644 --- a/test/js/bun/jsc/bun-jsc.test.ts +++ b/test/js/bun/jsc/bun-jsc.test.ts @@ -564,29 +564,33 @@ it("deserialize applies the same nesting depth limit to arrays as to objects", a // gc() issued while plans were queued reported fewer objects collected than the // program had let go of (node's test-gc-http-client* hit this). The snapshot is // now treated as weak; every DFG phase that reads it already handles nullopt. -it("gc() does not root user objects from a concurrent DFG plan's OSR-entry snapshot", async () => { - // Reproducing this needs several independent functions to request DFG at - // roughly the same time so most plans are still in the worklist at gc(). The - // http client/server path does that reliably (emit, nextTick drain, stream - // flow all tier up during the first burst of responses). The fixture reports - // how many ClientRequest/IncomingMessage instances the debugging heap - // snapshot attributes directly to the JIT worklist; that count must be zero. - // One compiler thread so plans queue instead of draining in parallel. - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "--jsc-numberOfDFGCompilerThreads=1", - "--jsc-numberOfFTLCompilerThreads=1", - path.join(import.meta.dir, "dfg-plan-gc-fixture.js"), - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - // The "alive" count is timing dependent (how many plans were queued at the - // first gc()), but no ClientRequest/IncomingMessage may be a JITWorkList root. - expect(stdout.trim()).toMatch(/^jitworklist-rooted=0 alive=\d+$/); - expect(exitCode).toBe(0); -}, isDebug || isASAN ? 30_000 : undefined); +it( + "gc() does not root user objects from a concurrent DFG plan's OSR-entry snapshot", + async () => { + // Reproducing this needs several independent functions to request DFG at + // roughly the same time so most plans are still in the worklist at gc(). The + // http client/server path does that reliably (emit, nextTick drain, stream + // flow all tier up during the first burst of responses). The fixture reports + // how many ClientRequest/IncomingMessage instances the debugging heap + // snapshot attributes directly to the JIT worklist; that count must be zero. + // One compiler thread so plans queue instead of draining in parallel. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--jsc-numberOfDFGCompilerThreads=1", + "--jsc-numberOfFTLCompilerThreads=1", + path.join(import.meta.dir, "dfg-plan-gc-fixture.js"), + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + // The "alive" count is timing dependent (how many plans were queued at the + // first gc()), but no ClientRequest/IncomingMessage may be a JITWorkList root. + expect(stdout.trim()).toMatch(/^jitworklist-rooted=0 alive=\d+$/); + expect(exitCode).toBe(0); + }, + isDebug || isASAN ? 30_000 : undefined, +); From a9a337ace88514b516bd033e0898abe490ef0305 Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 18 Jul 2026 13:18:39 +0000 Subject: [PATCH 4/6] test: drop stderr-empty assertion per REVIEW.md subprocess-tests rule Matches the combined {stdout, exitCode} toEqual pattern the neighboring deserialize-* tests use; stderr is drained but not asserted so a benign ASAN/debug warning cannot mask the jitworklist-rooted=0 check. --- test/js/bun/jsc/bun-jsc.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/js/bun/jsc/bun-jsc.test.ts b/test/js/bun/jsc/bun-jsc.test.ts index 735f51fb60e5..cc36e076a60c 100644 --- a/test/js/bun/jsc/bun-jsc.test.ts +++ b/test/js/bun/jsc/bun-jsc.test.ts @@ -585,12 +585,13 @@ it( stdout: "pipe", stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); // The "alive" count is timing dependent (how many plans were queued at the // first gc()), but no ClientRequest/IncomingMessage may be a JITWorkList root. - expect(stdout.trim()).toMatch(/^jitworklist-rooted=0 alive=\d+$/); - expect(exitCode).toBe(0); + expect({ stdout: stdout.trim(), exitCode }).toEqual({ + stdout: expect.stringMatching(/^jitworklist-rooted=0 alive=\d+$/), + exitCode: 0, + }); }, isDebug || isASAN ? 30_000 : undefined, ); From c9a03e21d7bf60853872a1fe8692e014458eac97 Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 18 Jul 2026 13:34:04 +0000 Subject: [PATCH 5/6] test: set compiler-thread knobs via BUN_JSC_* env vars, not --jsc-* CLI flags Bun only reads JSC options from BUN_JSC_* env vars (ZigGlobalObject.cpp); unknown --long flags are silently skipped, so the fixture was running with the default 2 DFG threads and the 'one compiler thread so plans queue' precondition was never applied. With the env vars actually taking effect fail-before is 30/30 on the current release build. --- test/js/bun/jsc/bun-jsc.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/test/js/bun/jsc/bun-jsc.test.ts b/test/js/bun/jsc/bun-jsc.test.ts index cc36e076a60c..7d364fcecf19 100644 --- a/test/js/bun/jsc/bun-jsc.test.ts +++ b/test/js/bun/jsc/bun-jsc.test.ts @@ -575,13 +575,8 @@ it( // snapshot attributes directly to the JIT worklist; that count must be zero. // One compiler thread so plans queue instead of draining in parallel. await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "--jsc-numberOfDFGCompilerThreads=1", - "--jsc-numberOfFTLCompilerThreads=1", - path.join(import.meta.dir, "dfg-plan-gc-fixture.js"), - ], - env: bunEnv, + cmd: [bunExe(), path.join(import.meta.dir, "dfg-plan-gc-fixture.js")], + env: { ...bunEnv, BUN_JSC_numberOfDFGCompilerThreads: "1", BUN_JSC_numberOfFTLCompilerThreads: "1" }, stdout: "pipe", stderr: "pipe", }); From 20a81ca8f69c7089b86018ababfcea62abd58aca Mon Sep 17 00:00:00 2001 From: robobun Date: Sat, 18 Jul 2026 16:00:39 +0000 Subject: [PATCH 6/6] bump WebKit preview to pr-308-40ff52aa (jsEmpty guard in finalizeInGC) and trim comments oven-sh/WebKit#308 is now based on 4895f45d (the currently pinned commit) so the preview artifact contains only the DFGPlan change, and finalizeInGC skips jsEmpty() entries (a TDZ-sentinel or LLInt-zeroed temp at the OSR point satisfies isCell() but asCell() is null). --- scripts/build/deps/webkit.ts | 2 +- test/js/bun/jsc/bun-jsc.test.ts | 18 +++++------------- test/js/bun/jsc/dfg-plan-gc-fixture.js | 7 ++----- 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 48a95a35fcd4..3d81fccf1b72 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -7,7 +7,7 @@ // -lto variants built with ThinLTO (per-module summaries for cross-language // importing), and the Windows ICU data table filtered + per-item zstd // compressed (lazily decompressed via bun_icu_decompress.cpp). -export const WEBKIT_VERSION = "autobuild-preview-pr-308-06ecce07"; +export const WEBKIT_VERSION = "autobuild-preview-pr-308-40ff52aa"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/test/js/bun/jsc/bun-jsc.test.ts b/test/js/bun/jsc/bun-jsc.test.ts index 7d364fcecf19..ac961aa82cce 100644 --- a/test/js/bun/jsc/bun-jsc.test.ts +++ b/test/js/bun/jsc/bun-jsc.test.ts @@ -558,22 +558,14 @@ it("deserialize applies the same nesting depth limit to arrays as to objects", a expect({ stdout, exitCode }).toEqual({ stdout: "rejected\n65\n", exitCode: 0 }); }); -// Objects live in a frame at the moment a loop triggers DFG/FTL tier-up are -// captured into the compilation plan's m_mustHandleValues. Those were rooted -// as RootMarkReason::JITWorkList for the life of the concurrent compile, so a -// gc() issued while plans were queued reported fewer objects collected than the -// program had let go of (node's test-gc-http-client* hit this). The snapshot is -// now treated as weak; every DFG phase that reads it already handles nullopt. +// oven-sh/WebKit#308: DFG Plan::m_mustHandleValues is weak, so objects live in +// the OSR-triggering frame are not JITWorkList-rooted for the life of a queued +// concurrent compile. it( "gc() does not root user objects from a concurrent DFG plan's OSR-entry snapshot", async () => { - // Reproducing this needs several independent functions to request DFG at - // roughly the same time so most plans are still in the worklist at gc(). The - // http client/server path does that reliably (emit, nextTick drain, stream - // flow all tier up during the first burst of responses). The fixture reports - // how many ClientRequest/IncomingMessage instances the debugging heap - // snapshot attributes directly to the JIT worklist; that count must be zero. - // One compiler thread so plans queue instead of draining in parallel. + // http client/server drives enough functions to DFG at once that plans are + // still queued at gc(). One compiler thread so they queue instead of drain. await using proc = Bun.spawn({ cmd: [bunExe(), path.join(import.meta.dir, "dfg-plan-gc-fixture.js")], env: { ...bunEnv, BUN_JSC_numberOfDFGCompilerThreads: "1", BUN_JSC_numberOfFTLCompilerThreads: "1" }, diff --git a/test/js/bun/jsc/dfg-plan-gc-fixture.js b/test/js/bun/jsc/dfg-plan-gc-fixture.js index 129cbe0cf446..15fcb55f0d34 100644 --- a/test/js/bun/jsc/dfg-plan-gc-fixture.js +++ b/test/js/bun/jsc/dfg-plan-gc-fixture.js @@ -1,8 +1,5 @@ -// The http client/server path drives enough independent functions to DFG at -// once (emit, nextTick drain, stream flow) that several plans are still in the -// concurrent JIT worklist when the first gc() runs. Previously each plan's -// m_mustHandleValues rooted whatever request/response objects were live in the -// frame that triggered tier-up (RootMarkReason::JITWorkList). +// oven-sh/WebKit#308: after one gc(), no ClientRequest/IncomingMessage may be a +// RootMarkReason::JITWorkList root while DFG plans are queued. "use strict"; const http = require("http"); const jsc = require("bun:jsc");