From 08951d0aa60109cb9eba54f4cd924f2d1b1af0be Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Wed, 28 Oct 2026 15:29:51 +0100 Subject: [PATCH] fix(runtime): watchdog no longer races its worker's blob URL load (#60) Revoking the blob URL right after new Worker() could win against Deno's async module load and kill the process at boot. Keep the URL alive and degrade to 'watchdog disabled' if the worker errors. Fixes #60 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 12 ++++++++++++ src/runtime/watchdog.ts | 12 +++++++++++- tests/runtime/watchdog_test.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) 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"); +});