From 4336daf794e9831ec638a53f8dc6774b428e9772 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:07:57 +0000 Subject: [PATCH 1/4] Bump WebKit to fix a sampling profiler segfault walking a null entry frame The JSC sampling profiler could crash at 0xFFFFFFFFFFFFFFC8 when a sample landed inside a VM entry/exit transition: vm.topEntryFrame is null there while vm.entryScope is already set, and a walked frame whose caller slot read null made the stack walker dereference vmEntryRecord(nullptr). Seen in test-cpu-prof-dir-worker.js on the Windows 2019 CI runners. Picks up oven-sh/WebKit#395 (currently via its preview build; to be repinned to the merge commit) and adds a --cpu-prof test that drives the raced window: callbacks with huge declared parameter counts called from native spend most of their runtime in doVMEntry's argument pad loop, which runs before topEntryFrame is stored. --- scripts/build/deps/webkit.ts | 2 +- test/cli/run/cpu-prof.test.ts | 49 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index f3fd88db6d9..b3aac4fb63e 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "ddea71318fec9b923465c7c45ded8fa713ca3251"; +export const WEBKIT_VERSION = "autobuild-preview-pr-395-8ec8fb6a"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index 4d5fe51aea7..438e40e2ea0 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -444,4 +444,53 @@ describe.concurrent("--cpu-prof", () => { const mdContent = readFileSync(join(String(dir), mdFiles[0]), "utf-8"); expect(mdContent).toContain("# CPU Profile"); }); + + // The sampling profiler could segfault when a sample landed inside a VM + // entry/exit transition: vm.topEntryFrame is null there while vm.entryScope + // is already set, and a walked frame whose caller slot read null made the + // stack walker dereference vmEntryRecord(nullptr) (oven-sh/WebKit#395, seen + // as a crash at 0xFFFFFFFFFFFFFFC8 in test-cpu-prof-dir-worker.js on CI). + // The workload widens that window as far as JS can: a callback with a huge + // declared parameter count invoked from native spends most of its runtime in + // doVMEntry's argument pad loop, which runs before topEntryFrame is stored. + test("sampler survives VM entry churn from callbacks with huge parameter counts", async () => { + using dir = tempDir("cpu-prof-entry-churn", { + "churn.js": ` + const params = Array.from({ length: 2000 }, (_, i) => "p" + i).join(","); + const f = new Function(params, "c.n++;"); + globalThis.c = { n: 0 }; + const { port1, port2 } = new MessageChannel(); + port1.onmessage = f; + const deadline = performance.now() + 150; + function loop() { + if (performance.now() >= deadline) { + console.log("calls made:", c.n > 0); + port1.close(); + port2.close(); + return; + } + setImmediate(f); + Promise.resolve().then(f); + queueMicrotask(f); + process.nextTick(f); + port2.postMessage(1); + setImmediate(loop); + } + loop(); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--cpu-prof", "--cpu-prof-interval=50", "churn.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + expect(stdout).toContain("calls made: true"); + expect(exitCode).toBe(0); + }); }); From eef9903cb088d98126ef042d31318a18e4e3f4f0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:13:00 +0000 Subject: [PATCH 2/4] test: assert the profiler collected samples during the churn workload --- test/cli/run/cpu-prof.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index 438e40e2ea0..10fbe0d26c0 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -491,6 +491,14 @@ describe.concurrent("--cpu-prof", () => { const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); expect(stdout).toContain("calls made: true"); + + // The profiler must actually have sampled the churn, or the test exercised + // nothing. + const profileFile = readdirSync(String(dir)).find(file => file.endsWith(".cpuprofile")); + expect(profileFile).toBeDefined(); + const profile = JSON.parse(readFileSync(join(String(dir), profileFile!), "utf-8")); + expect(profile.samples.length).toBeGreaterThan(0); + expect(exitCode).toBe(0); }); }); From 5ff367664b394e502e377f4bb30cf48e97c5c9d5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:14:00 +0000 Subject: [PATCH 3/4] test: drop the nextTick leg and give the Windows sampler a longer window process.nextTick kept the tick queue non-empty, which drains the microtask jobs nested inside the tick-drain VM entry instead of as outermost entries, demoting two of the churn legs away from the raced state. And on Windows the sampler ticks at the ~15.6ms timer quantum while only in-entry-scope samples are recorded, so a 150ms window expects about one recorded sample; give Windows 1.5s so the samples assertion cannot flake to zero. --- test/cli/run/cpu-prof.test.ts | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index 10fbe0d26c0..1017a1c6566 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -445,14 +445,25 @@ describe.concurrent("--cpu-prof", () => { expect(mdContent).toContain("# CPU Profile"); }); - // The sampling profiler could segfault when a sample landed inside a VM - // entry/exit transition: vm.topEntryFrame is null there while vm.entryScope - // is already set, and a walked frame whose caller slot read null made the - // stack walker dereference vmEntryRecord(nullptr) (oven-sh/WebKit#395, seen - // as a crash at 0xFFFFFFFFFFFFFFC8 in test-cpu-prof-dir-worker.js on CI). - // The workload widens that window as far as JS can: a callback with a huge - // declared parameter count invoked from native spends most of its runtime in - // doVMEntry's argument pad loop, which runs before topEntryFrame is stored. + // Smoke test for the raced path behind oven-sh/WebKit#395: the sampling + // profiler could segfault when a sample landed inside a VM entry/exit + // transition (vm.topEntryFrame null while vm.entryScope is set; a walked + // frame whose caller slot read null made the walker dereference + // vmEntryRecord(nullptr), crashing at 0xFFFFFFFFFFFFFFC8 in + // test-cpu-prof-dir-worker.js on CI). The crash itself needs CI-runner + // timing; the deterministic regression test lives in WebKit + // (TestWebKitAPI). This workload widens the raced window as far as JS can: a + // callback with a huge declared parameter count invoked from native spends + // most of its runtime in doVMEntry's argument pad loop, which runs before + // topEntryFrame is stored. No process.nextTick leg on purpose: a non-empty + // tick queue makes the microtask jobs drain nested inside the tick-drain VM + // entry instead of as outermost entries, which is the raced state. + // + // Deadline: the Windows sampler effectively ticks at the ~15.6ms timer + // quantum (see the header comment), and only samples taken while + // vm.entryScope is set are recorded, which is a minority of this churn + // loop's wall time. 150ms is ~9 quantum ticks, around one expected recorded + // sample, so Windows gets 1.5s to keep the samples assertion reliable. test("sampler survives VM entry churn from callbacks with huge parameter counts", async () => { using dir = tempDir("cpu-prof-entry-churn", { "churn.js": ` @@ -461,7 +472,7 @@ describe.concurrent("--cpu-prof", () => { globalThis.c = { n: 0 }; const { port1, port2 } = new MessageChannel(); port1.onmessage = f; - const deadline = performance.now() + 150; + const deadline = performance.now() + ${isWindows ? 1500 : 150}; function loop() { if (performance.now() >= deadline) { console.log("calls made:", c.n > 0); @@ -472,7 +483,6 @@ describe.concurrent("--cpu-prof", () => { setImmediate(f); Promise.resolve().then(f); queueMicrotask(f); - process.nextTick(f); port2.postMessage(1); setImmediate(loop); } From 4d7472b64e5b953e044355d22f362a27c25a9768 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:19:02 +0000 Subject: [PATCH 4/4] test: trim the smoke test comment to the durable constraints --- test/cli/run/cpu-prof.test.ts | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index 1017a1c6566..e7b22d04d87 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -445,25 +445,22 @@ describe.concurrent("--cpu-prof", () => { expect(mdContent).toContain("# CPU Profile"); }); - // Smoke test for the raced path behind oven-sh/WebKit#395: the sampling - // profiler could segfault when a sample landed inside a VM entry/exit - // transition (vm.topEntryFrame null while vm.entryScope is set; a walked - // frame whose caller slot read null made the walker dereference - // vmEntryRecord(nullptr), crashing at 0xFFFFFFFFFFFFFFC8 in - // test-cpu-prof-dir-worker.js on CI). The crash itself needs CI-runner - // timing; the deterministic regression test lives in WebKit - // (TestWebKitAPI). This workload widens the raced window as far as JS can: a - // callback with a huge declared parameter count invoked from native spends - // most of its runtime in doVMEntry's argument pad loop, which runs before - // topEntryFrame is stored. No process.nextTick leg on purpose: a non-empty - // tick queue makes the microtask jobs drain nested inside the tick-drain VM - // entry instead of as outermost entries, which is the raced state. + // Smoke test for the raced sampler path fixed in oven-sh/WebKit#395 (the + // deterministic regression test lives there, in TestWebKitAPI): a sample + // landing inside a VM entry/exit transition used to crash the profiler's + // stack walker. A callback with a huge declared parameter count invoked + // from native spends most of its runtime in doVMEntry's argument pad loop, + // which runs before vm.topEntryFrame is stored, so this workload keeps the + // sampler crossing that window. No process.nextTick leg on purpose: a + // non-empty tick queue drains the microtask jobs nested inside the + // tick-drain VM entry instead of as outermost entries, and only outermost + // entries hit the raced state. // // Deadline: the Windows sampler effectively ticks at the ~15.6ms timer // quantum (see the header comment), and only samples taken while - // vm.entryScope is set are recorded, which is a minority of this churn - // loop's wall time. 150ms is ~9 quantum ticks, around one expected recorded - // sample, so Windows gets 1.5s to keep the samples assertion reliable. + // vm.entryScope is set are recorded, a minority of this loop's wall time; + // 150ms is ~9 ticks, about one expected recorded sample, so Windows gets + // 1.5s to keep the samples assertion reliable. test("sampler survives VM entry churn from callbacks with huge parameter counts", async () => { using dir = tempDir("cpu-prof-entry-churn", { "churn.js": `