Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/runtime/watchdog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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" });
Expand Down
33 changes: 33 additions & 0 deletions tests/runtime/watchdog_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
});
Loading