From 4f6b927c5e80d1c322aa27620087f0bdf86ea782 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:19:22 +0000 Subject: [PATCH] test(worker_threads): stress terminate() during preload module resolution Covers oven-sh/WebKit#391 (JSModuleLoader::hostLoadImportedModule propagating a TerminationException from resolve() instead of caching it as a resolution failure), which is already in the WebKit pin on main. The child calibrates a warm worker's time-to-online and sweeps terminate() through the tail of startup against workers with explicit node: preloads; on the unfixed engine it aborts on ExceptionScope::assertNoException in continueDynamicImport. --- .../worker-terminate-during-preload.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 test/js/node/worker_threads/worker-terminate-during-preload.test.ts diff --git a/test/js/node/worker_threads/worker-terminate-during-preload.test.ts b/test/js/node/worker_threads/worker-terminate-during-preload.test.ts new file mode 100644 index 000000000000..a2872ec0d142 --- /dev/null +++ b/test/js/node/worker_threads/worker-terminate-during-preload.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isDebug } from "harness"; + +// Terminating a node:worker_threads Worker while its startup preloads are +// running can land the NeedTermination trap inside +// JSModuleLoader::hostLoadImportedModule's resolve() call. JSC used to treat +// the resulting TerminationException like an ordinary resolution error: it +// cached it in m_resolutionFailures, returned early from +// rejectWithCaughtException (TRY_CLEAR_EXCEPTION won't clear a termination), +// and entered continueDynamicImport with the termination still pending on the +// VM, where scope.assertNoException() aborts under +// ENABLE(EXCEPTION_SCOPE_VERIFICATION) (debug/ASAN builds). +// +// The window is the tail end of worker startup, so the child calibrates on a +// warm worker's time-to-'online' and sweeps terminate() through a band just +// below it. Explicit builtin preloads widen the cumulative resolve() window so +// the sweep lands in it regardless of host speed. +test( + "terminate() during worker preload does not abort in the module loader", + async () => { + const script = /* js */ ` + const { Worker } = require("node:worker_threads"); + const preload = [ + "node:events", "node:path", "node:util", "node:url", "node:buffer", + "node:stream", "node:os", "node:fs", "node:crypto", "node:assert", + "node:querystring", "node:string_decoder", "node:timers", "node:zlib", + ]; + async function timeToOnline() { + const t0 = Bun.nanoseconds(); + const w = new Worker("", { eval: true, preload }); + await new Promise((res, rej) => { + w.once("online", res); + w.once("error", rej); + w.once("exit", code => rej(new Error("worker exited before 'online' with code " + code))); + }); + const dt = Bun.nanoseconds() - t0; + await w.terminate(); + return dt; + } + (async () => { + await timeToOnline(); // cold; discard + const T = Math.min(await timeToOnline(), await timeToOnline()); + for (let f = 0.55; f <= 1.05; f += 0.02) { + const w = new Worker("", { eval: true, preload }); + const spinUntil = Bun.nanoseconds() + T * f; + while (Bun.nanoseconds() < spinUntil) {} + await w.terminate(); + } + console.log("ok"); + })().catch(e => { console.error(e); process.exit(1); }); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // stderr is kept in the assertion object so an abort's output appears in + // the failure diff, but not matched exactly (debug/ASAN builds may emit + // benign warnings on this lane). + expect({ stdout: stdout.trim(), exitCode, stderr }).toEqual({ + stdout: "ok", + exitCode: 0, + stderr: expect.any(String), + }); + }, + isDebug ? 240_000 : 30_000, +);