Repository navigation
feat(runtime): event-loop watchdog for dev and serve - #51
Merged
Merged
Conversation
One synchronous loop blocks the only thread serving requests, so every URL stops answering at once with nothing in the logs. A Worker now expects a heartbeat from the main thread; after 15s (DUNE_WATCHDOG_TIMEOUT_MS) of silence it writes a report to <site>/.dune/hangs/ (in-flight requests, plus a native stack sample on macOS) and SIGKILLs the process so a supervisor can restart it. DUNE_WATCHDOG=warn reports only, =off disables. SIGKILL rather than Deno.exit(): an exit requested from a Worker only ends the Worker, the spinning main thread carries on. A Worker also can't be unref'd, so serve's graceful-shutdown paths stop it explicitly. The main thread additionally logs a warning when its own timer ran over a second late. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This was referenced Oct 26, 2026
…imately busy Review of #51 and a test of my own turned up false-kill cases and a kill that could fail to happen: - A process suspended and resumed (laptop sleep, Ctrl-Z/SIGSTOP) was killed on resume: confirmed by SIGSTOPping a watched process for 4s. The stall clock is now monotonic (performance.now()), and a gap in the Worker's own ticks resets it instead of firing. - Startup gets its own, much longer limit (2 min) until the server calls ready(), so a big site's initial indexing can't put a supervised process into a kill/restart loop. - watchdogProgress() (rate-limited heartbeat for long synchronous loops) and watchdogGrace() (capped, auto-expiring, nestable busy window), exported as @dune/core/watchdog. - Under --inspect/--inspect-brk the default becomes warn. - The kill no longer depends on the report: writing it is raced against a timeout, and a failed Deno.kill is logged instead of silencing the watchdog. - Reports are pruned to the newest 20, and path segments over 24 characters are masked before they are written. Refs #42 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
zumbrunn
added a commit
that referenced
this pull request
Oct 27, 2026
* feat(runtime): event-loop watchdog for dev and serve One synchronous loop blocks the only thread serving requests, so every URL stops answering at once with nothing in the logs. A Worker now expects a heartbeat from the main thread; after 15s (DUNE_WATCHDOG_TIMEOUT_MS) of silence it writes a report to <site>/.dune/hangs/ (in-flight requests, plus a native stack sample on macOS) and SIGKILLs the process so a supervisor can restart it. DUNE_WATCHDOG=warn reports only, =off disables. SIGKILL rather than Deno.exit(): an exit requested from a Worker only ends the Worker, the spinning main thread carries on. A Worker also can't be unref'd, so serve's graceful-shutdown paths stop it explicitly. The main thread additionally logs a warning when its own timer ran over a second late. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * feat(runtime): instance registry written by dev and serve Each running instance records itself in ~/.dune/instances/<pid>.json (pid, process start time, mode, root, title, port, version) and removes the record on a graceful exit. Readers verify a record against the live process (alive and same start time, since pids are reused) and prune stale ones. Groundwork for `dune ps` to list instances from what each one says about itself instead of parsing command lines and lsof; nothing reads it yet. Refs #42 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(runtime): watchdog no longer kills what is merely paused or legitimately busy Review of #51 and a test of my own turned up false-kill cases and a kill that could fail to happen: - A process suspended and resumed (laptop sleep, Ctrl-Z/SIGSTOP) was killed on resume: confirmed by SIGSTOPping a watched process for 4s. The stall clock is now monotonic (performance.now()), and a gap in the Worker's own ticks resets it instead of firing. - Startup gets its own, much longer limit (2 min) until the server calls ready(), so a big site's initial indexing can't put a supervised process into a kill/restart loop. - watchdogProgress() (rate-limited heartbeat for long synchronous loops) and watchdogGrace() (capped, auto-expiring, nestable busy window), exported as @dune/core/watchdog. - Under --inspect/--inspect-brk the default becomes warn. - The kill no longer depends on the report: writing it is raced against a timeout, and a failed Deno.kill is logged instead of silencing the watchdog. - Reports are pruned to the newest 20, and path segments over 24 characters are masked before they are written. Refs #42 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
zumbrunn
added a commit
that referenced
this pull request
Oct 27, 2026
* feat(cli): dune ps finds the dune CLI and task wrappers, says "not listening", adds --stop Found while cleaning up a machine running ~45 dune processes: `dune ps` listed 19 of them as "port unknown — is lsof installed?" and missed the one server that was actually serving. - Matching: besides the generated `main.ts dev|serve`, match the dune CLI run directly (`cli.ts dev|serve` via the global shim or a checkout, `jsr:@dune/core@x/cli serve`) and `deno task dev|serve` wrappers. A bare `cli.ts` / `deno task` could be any project, so those are "weak" matches that count only when a config/site.yaml is found via --root, PWD or live cwd. - One instance per process tree: wrapper, re-exec child and esbuild workers are grouped under the topmost matching process, from one `ps -eo pid,ppid,...` snapshot (drops the pgrep dependency). `--root` on the command line is used to find the site, resolved against the process's cwd when relative. - Wording: `portStatus` distinguishes "listening", "not-listening" (lsof ran, found nothing: a hung or leftover process) and "unknown" (lsof can't be spawned). lsof exits 1 on no matches, so availability is checked by spawning it, not by its exit code. - `--stop`: SIGTERM to every pid in every listed instance tree, waits, reports survivors (exit code 1). Asks first; --yes skips the prompt; without a terminal and without --yes it refuses. Never signals this process or its ancestors, whatever the caller passes. It stops everything it lists, so it is documented as such. Tests: pure matching/--root parsing tables; real processes for the CLI-style invocation (absolute and relative --root), a not-listening process, a `deno task` tree reported once, and stopInstances really stopping a tree and refusing to signal itself; the command flow runs against injected fakes so no test can ever stop a real instance. Mutation-checked: disabling tree grouping and the weak-match filter each fail exactly their test. Verified on the real machine: the previously-missed iofc dev server is now listed with title, root, port and children, and `ps --stop` without --yes/TTY refuses and kills nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(cli): dune ps --idle, a selective stop that leaves live servers alone `dune ps --stop` is all-or-nothing, and on a shared machine that includes servers you want: while building --stop, a live dev server sat next to ~45 hung leftovers. --idle narrows both the listing and --stop to instances that are running but "not listening" (hung or leftover), so `dune ps --stop --idle` clears the debris and leaves anything serving alone. `dune ps --idle` alone is the preview. An instance whose port state is unknown (no lsof) is never assumed idle: with nothing provably idle, --idle says so and --stop does nothing (exit 1). When nothing is idle it says "No idle instances" rather than prompting. The confirmation names the idle count and the process count. Tests (fakes only, so nothing real can be stopped): the listing shows only idle instances and counts the live ones left out; --stop --idle hands the stopper only idle instances and not even a live one's child pids; it still refuses without --yes and a terminal; the nothing-idle and lsof-unavailable cases. Disabling the filter fails 4 of them, including the safety one. Also run for real on the machine: with a live and an idle fixture next to a live server from another session, `ps --stop --idle --yes` stopped only the idle fixture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): dune ps --stop escalates to SIGKILL after a grace period A process can ignore SIGTERM indefinitely. A `dune serve` closed its listener on the first SIGTERM and then stayed alive until it was sent a second one, so "SIGTERM, then report survivors" left exactly the idle processes this command exists to clear, and made a plain `--stop` on a live serve end in "still running". stopInstances() now sends SIGTERM, waits up to a 10 s grace period (for real servers draining in-flight requests), then SIGKILLs whatever is still alive and waits briefly for it to go. StopResult gains `killed` (the subset of `stopped` that needed the SIGKILL) so the escalation is never silent; `survivors` now means "alive even after SIGKILL" and is the only thing that makes the exit code non-zero. Still never signals this process or its ancestors. Tests: a real process that swallows SIGTERM is escalated (killed, stopped, no survivors, died by SIGKILL), a cooperative process is not SIGKILLed, and the command's output names the killed pids and says nothing about SIGKILL when there were none. Removing the escalation fails the stubborn-process test. Also reproduced the original situation for real: a serve that got one SIGTERM sat idle, `ps --idle` listed it, and `ps --stop --idle --yes` cleared it while a live server from another session stayed up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(runtime): event-loop watchdog for dev and serve One synchronous loop blocks the only thread serving requests, so every URL stops answering at once with nothing in the logs. A Worker now expects a heartbeat from the main thread; after 15s (DUNE_WATCHDOG_TIMEOUT_MS) of silence it writes a report to <site>/.dune/hangs/ (in-flight requests, plus a native stack sample on macOS) and SIGKILLs the process so a supervisor can restart it. DUNE_WATCHDOG=warn reports only, =off disables. SIGKILL rather than Deno.exit(): an exit requested from a Worker only ends the Worker, the spinning main thread carries on. A Worker also can't be unref'd, so serve's graceful-shutdown paths stop it explicitly. The main thread additionally logs a warning when its own timer ran over a second late. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * feat(runtime): instance registry written by dev and serve Each running instance records itself in ~/.dune/instances/<pid>.json (pid, process start time, mode, root, title, port, version) and removes the record on a graceful exit. Readers verify a record against the live process (alive and same start time, since pids are reused) and prune stale ones. Groundwork for `dune ps` to list instances from what each one says about itself instead of parsing command lines and lsof; nothing reads it yet. Refs #42 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * refactor(cli): stopping moves out of `dune ps` into `dune stop` `dune ps` is read-only again. Stopping is its own command with a required target (a pid, --port, --idle or --all), SIGTERM by default and SIGKILL only with --force, so stopping every server on a machine has to be asked for by name and a kill that can orphan a wrapper's child is opt-in. Code is split along the same line: instances.ts (discovery, shared), ps.ts (listing), stop.ts (selection, confirmation, signalling). Tests follow, with the shared fixtures in ps_helpers.ts. Refs #46 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * feat(cli): dune ps probes health and reads the instance registry Every listening instance gets a /health/live request (3 s timeout, any status counts); one that accepts the connection but never answers is shown as NOT RESPONDING, the signature of a blocked event loop that lsof can't see. Instances that registered themselves are listed even when their command line doesn't look like dune, and show their version. `dune stop --idle` also takes instances that are listening but not responding. Refs #42 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(runtime): watchdog no longer kills what is merely paused or legitimately busy Review of #51 and a test of my own turned up false-kill cases and a kill that could fail to happen: - A process suspended and resumed (laptop sleep, Ctrl-Z/SIGSTOP) was killed on resume: confirmed by SIGSTOPping a watched process for 4s. The stall clock is now monotonic (performance.now()), and a gap in the Worker's own ticks resets it instead of firing. - Startup gets its own, much longer limit (2 min) until the server calls ready(), so a big site's initial indexing can't put a supervised process into a kill/restart loop. - watchdogProgress() (rate-limited heartbeat for long synchronous loops) and watchdogGrace() (capped, auto-expiring, nestable busy window), exported as @dune/core/watchdog. - Under --inspect/--inspect-brk the default becomes warn. - The kill no longer depends on the report: writing it is raced against a timeout, and a failed Deno.kill is logged instead of silencing the watchdog. - Reports are pruned to the newest 20, and path segments over 24 characters are masked before they are written. Refs #42 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(cli): dune stop only acts on what it can verify Review of #37: - --all/--idle act only on confirmed instances (a config/site.yaml at the site root). A command line that merely looks like dune (any Deno project's `main.ts serve`, a path containing "dune") is still listed but has to be named by pid, with a warning. - Only the current user's processes are listed and signalled; a refused signal (EPERM) is reported as "not permitted", not as "ignored SIGTERM". - Every pid's start time is captured when the instance is found and re-checked right before each SIGTERM and SIGKILL, so a pid that exited and was reused while the confirmation prompt was open is never signalled. - --idle leaves out a not-listening instance younger than 2 minutes: it may still be starting (indexing a big site). - Help and docs say that the whole process tree is stopped. Refs #46 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(cli): stricter "confirmed", no --allow-sys, honest permission errors Second-pass review of #37: - "confirmed" now needs evidence from the command line itself (the directory of the main.ts it runs, --root, or the dune CLI's cwd), not merely that the process runs from inside a site directory, which any tool started from a site repo inherits. stop prints each instance's full command line before asking. - Our own uid is read from our own row in the process table instead of Deno.uid(), which needs --allow-sys; without it `dune ps` used to throw. - A missing --allow-run now says so (instead of "no instances" or "already gone"), and Deno.errors.NotCapable on a kill is reported separately from EPERM (another user's process) and from a pid that already exited. Refs #46 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * chore: drop test-run drift from deno.lock * docs(changelog): #53 entries on top of main's [Unreleased] --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #42
Problem
One synchronous loop blocks the only thread serving requests, so every URL stops answering at once, with nothing in the logs, until someone restarts the server. A blocked thread can't log, time out or shut down, so the check has to run elsewhere.
Change
src/runtime/watchdog.ts: a Worker expects a main-thread heartbeat every second. After 15 s of silence (DUNE_WATCHDOG_TIMEOUT_MS) it writes a report to<site>/.dune/hangs/(in-flight requests, with token-sized path segments masked; plus a native stack sample on macOS), logs the key lines to stderr, and SIGKILLs the process so a supervisor (systemdRestart=, thedune devwrapper exiting 137) sees a dead process. The newest 20 reports are kept.DUNE_WATCHDOG=exit(default) |warn(report, don't kill; chosen automatically under--inspect/--inspect-brk) |off.DUNE_WATCHDOG_STARTUP_TIMEOUT_MS) until the server callsready(), so a big site's initial indexing can't put a supervised process into a kill/restart loop.performance.now()), and a gap in the Worker's own ticks (laptop sleep, Ctrl-Z, SIGSTOP) resets it instead of firing. I confirmed the original version did kill a process after a 4 s SIGSTOP/SIGCONT; there is now a regression test.@dune/core/watchdogwithwatchdogProgress()(a rate-limited heartbeat for long synchronous loops; only protects loops that call it, never put it in an unbounded one) andwatchdogGrace(ms, reason)(a declared busy window: capped at 10 min, auto-expires, nests, logged). Dune's own loops needed neither: on a 4,474-page site, startup indexing and the search build never held the event loop for over a second.Deno.killis logged and the watchdog carries on in warn mode instead of going silent.dune devanddune serveright after the root is resolved; in-flight request tracking is one middleware increateDuneApp(no-op without a watchdog).Things found while building it
Deno.exit()from a Worker only ends the Worker; a spinning main thread kept running at ~97% CPU. Hence SIGKILL.serve's two graceful-shutdown paths callstop(); a test covers it.dune dev: the re-exec wrapper exits 137 and the esbuild service child exits on its own when its parent dies (no orphan). A test mirrorsreexec.ts.Deno.killon your own pid needs no--allow-run, so I could not produce the kill-refused branch; the guard is defensive and untested.Verification
deno lint src/;deno test -A --unstable-kv tests/runtime tests/cli: 390 passedtests/runtime/watchdog_test.ts(16 tests, real child processes): stuck loop SIGKILLed with a report naming the stuck request and masking token segments;warnleaves the process alone; sub-limit block logs lag only; exits on its own afterstop();off; SIGSTOP/SIGCONT pause survives; startup vs. ready limits; progress keeps a synchronous loop alive; grace protects, expires, and nests; inspector flips to warn; a hungsampledoesn't stop the kill (macOS); old reports pruned; wrapper exits 137 and a stdin-reading child doesn't outlive the kill.DUNE_WATCHDOG_TIMEOUT_MS=5000 dune dev: killed after 5 s withGET /hang/ (5s)in the log, report and sample written, wrapper exited, nothing left running.Not done
eu-stack/gdb).[Unreleased].🤖 Generated with Claude Code