diff --git a/CHANGELOG.md b/CHANGELOG.md index 673c3dd9..1d29636d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what --- +## [Unreleased] + +### Fixed + +- The event-loop watchdog no longer crashes the process at boot intermittently + (`Module not found "blob:null/…"`): it revoked its worker's blob URL right + after `new Worker()`, racing Deno's async module load. The URL is no longer + revoked, and a worker that fails to load now disables the watchdog with a log + line instead of exiting the process (#60). + +--- + ## [0.35.0] — 2026-10-27 ### Changed diff --git a/src/runtime/watchdog.ts b/src/runtime/watchdog.ts index 4c86edef..6aa4238d 100644 --- a/src/runtime/watchdog.ts +++ b/src/runtime/watchdog.ts @@ -346,8 +346,11 @@ export function startWatchdog(opts: WatchdogOptions): Watchdog { const url = URL.createObjectURL( new Blob([WORKER_SOURCE], { type: "application/javascript" }), ); + // Never revoke `url`: Deno loads a module worker's source asynchronously, + // so revoking right after construction races the load and kills the + // process at boot ("Module not found blob:null/..."). One small blob per + // process lifetime is harmless. const worker = new Worker(url, { type: "module" }); - URL.revokeObjectURL(url); activeWorker = worker; worker.postMessage({ type: "init", @@ -388,6 +391,13 @@ export function startWatchdog(opts: WatchdogOptions): Watchdog { // shutdown) must therefore call stop(); signal-driven and Deno.exit() // exits end the process regardless. 'unload' is a best-effort backstop. globalThis.addEventListener("unload", stop); + // A worker that fails to load or throws must not take the process down: an + // unhandled worker error exits it. Degrade to "no watchdog" instead. + worker.addEventListener("error", (e) => { + e.preventDefault(); + console.error(`[dune] watchdog: worker failed (${e.message}); watchdog disabled`); + stop(); + }); return { ready: () => { if (!stopped) worker.postMessage({ type: "ready" }); diff --git a/tests/runtime/watchdog_test.ts b/tests/runtime/watchdog_test.ts index dfabeee0..50dfe4e6 100644 --- a/tests/runtime/watchdog_test.ts +++ b/tests/runtime/watchdog_test.ts @@ -198,6 +198,34 @@ Deno.test( }, ); +Deno.test( + "watchdog: a worker that fails to load disables the watchdog instead of crashing the process", + noSanitize, + async () => { + await withRoot(async (root) => { + const script = join(root, "child.ts"); + await Deno.writeTextFile( + script, + `const Real = globalThis.Worker;\n` + + `globalThis.Worker = class extends Real {\n` + + ` constructor(_url, o) { super("data:application/javascript,throw new Error('boom')", o); }\n` + + `};\n` + + `const { startWatchdog } = await import(${JSON.stringify(WATCHDOG_URL)});\n` + + `startWatchdog({ root: ${JSON.stringify(root)}, mode: "exit" });\n` + + `await new Promise((r) => setTimeout(r, 1000));\n` + + `console.log("survived");\n`, + ); + const r = await finish( + new Deno.Command(Deno.execPath(), { args: ["run", "-A", script], stdout: "piped", stderr: "piped" }).spawn(), + ); + assert(!r.timedOut, "process lingered"); + assertEquals(r.code, 0, r.stderr); + assertStringIncludes(r.stdout, "survived"); + assertStringIncludes(r.stderr, "watchdog disabled"); + }); + }, +); + Deno.test( "watchdog (off): starts nothing and the process exits normally", noSanitize, @@ -466,3 +494,8 @@ Deno.test( }); }, ); + +Deno.test("watchdog: the worker blob URL is never revoked (revoking races the async module load)", async () => { + const src = await Deno.readTextFile(new URL("../../src/runtime/watchdog.ts", import.meta.url)); + assert(!/revokeObjectURL\s*\(/.test(src.replace(/\/\/.*$/gm, "")), "revokeObjectURL would race the worker load"); +});