From 0a72eaf52b884737e54aa17a2de7e29529752397 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 22 Oct 2026 09:28:16 +0200 Subject: [PATCH 01/12] feat(cli): dune ps finds the dune CLI and task wrappers, says "not listening", adds --stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 28 +++ README.md | 2 +- deno.json | 2 +- src/cli-impl.ts | 5 +- src/cli/args.ts | 2 + src/cli/help.ts | 5 +- src/cli/ps.ts | 522 +++++++++++++++++++++++++++++++------------ tests/cli/ps_test.ts | 318 +++++++++++++++++++++++++- 8 files changed, 730 insertions(+), 154 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b3e8fc..d44396f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,34 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what --- +## [0.34.15] — 2026-10-22 + +### Fixed + +- **`dune ps` missed servers started through the `dune` CLI itself.** It only + matched the generated entrypoint (`main.ts dev|serve`), so a dev server run + via the global shim or a checkout (`.../cli.ts dev`), via + `jsr:@dune/core/cli serve`, or through a `deno task dev|serve` wrapper never + showed up — on a real machine it listed 19 hung processes and missed the one + server that was actually serving. It now matches those forms too. A bare + `cli.ts` or `deno task` match (which could be any project) counts only when a + `config/site.yaml` is found via `--root`, `PWD` or the live cwd. +- **`dune ps` reported "port unknown — is `lsof` installed?" for processes that + simply weren't listening.** It now says **not listening** when `lsof` ran and + found no open port (typically a server started on a port something else + already held, which never exits), and "port unknown (lsof not available)" + only when `lsof` can't be run. +- A server's wrapper, re-exec child and esbuild workers are now reported as one + instance, found from a single process-table snapshot (no more `pgrep`), and + `--root` on the command line is used to find the site. + +### Added + +- **`dune ps --stop`** stops every instance it lists, with its child processes + (SIGTERM), and reports anything still alive. It asks first; `--yes` skips the + prompt, and with no terminal it refuses unless `--yes` is given. It never + signals itself or its parent processes. + ## [0.34.14] — 2026-10-21 ### Security diff --git a/README.md b/README.md index c8ddbd8a..1f5f6ae3 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ This snapshots `deno.json` at install time rather than reading it live, so it go | `dune new [dir] --verify` | Same, but also boot the site for real and check it serves a request | | `dune dev` | Dev server with hot-reload (watches content + themes) | | `dune dev:link` | Reinstall the global shim against this checkout (local dev on Dune itself) | -| `dune ps` | List dune dev/serve instances running on this machine | +| `dune ps` | List dune dev/serve instances running on this machine (`--stop` to stop them) | | `dune doctor` | Environment/runtime health checks — Deno version, dependency resolution, lockfile staleness | | `dune doctor --boot` | Also boot the site for real and check it serves a request | | `dune build` | Build content index, validate config | diff --git a/deno.json b/deno.json index da4f135c..63e14aa0 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@dune/core", - "version": "0.34.14", + "version": "0.34.15", "license": "MIT", "author": "zumbrunn (https://zumbrunn.com)", "minimumDependencyAge": { diff --git a/src/cli-impl.ts b/src/cli-impl.ts index 30afd52e..299c1225 100644 --- a/src/cli-impl.ts +++ b/src/cli-impl.ts @@ -194,7 +194,10 @@ export async function main(args: string[] = Deno.args) { break; case "ps": - await psCommand(); + await psCommand({ + stop: options.stop === true, + yes: options.yes === true, + }); break; case "validate": diff --git a/src/cli/args.ts b/src/cli/args.ts index 6de40b5a..0cc4c7d3 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -103,6 +103,8 @@ export function parseCliOptions(args: string[]): ParsedCliArgs { options.integrity = args[++i]; } else if (args[i] === "--readonly") { options.readonly = true; + } else if (args[i] === "--stop") { + options.stop = true; } else if (args[i] === "--boot") { options.boot = true; } else if (args[i] === "--verify") { diff --git a/src/cli/help.ts b/src/cli/help.ts index 2bb99631..5cd713c8 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -20,7 +20,10 @@ Commands: (for developing Dune itself — refreshes its frozen import-map snapshot after deno.json's imports change) ps List dune dev/serve instances currently running on - this machine (PID, site, port — macOS/Linux only) + this machine (PID, site, port or "not listening" — + macOS/Linux only) + ps --stop Stop every instance found, with its child processes + (asks first; --yes to skip the prompt) build Build content index and validate config build --static Generate a fully static site (SSG) serve Start production server (--frozen enforces deno.lock; diff --git a/src/cli/ps.ts b/src/cli/ps.ts index 09a019a0..4b3b0f90 100644 --- a/src/cli/ps.ts +++ b/src/cli/ps.ts @@ -1,21 +1,35 @@ /** - * dune ps — list dune dev/serve instances currently running on this machine. + * dune ps — list dune dev/serve instances currently running on this machine, + * and (with `--stop`) stop them. * - * Finds candidates by matching the process table against the generated - * entrypoint invocation shape (`deno run ... main.ts dev|serve` — the - * pattern every `dune new` scaffold's `deno task dev`/`serve` runs, see - * `entrypoint-template.ts`'s `ENTRYPOINT_TASKS`), confirms each match is a - * real dune site by checking `config/site.yaml` exists at its site root, - * and reports the port(s) it's *actually* listening on — read from that - * process's own open sockets via `lsof`, never assumed from a fixed list - * of "common" ports. + * Finds candidates by matching the process table against the invocation + * shapes dune servers actually run as: + * - the generated entrypoint: `deno run ... main.ts dev|serve` (what every + * `dune new` scaffold's `deno task dev`/`serve` runs, see + * `entrypoint-template.ts`'s `ENTRYPOINT_TASKS`); + * - the `dune` CLI itself: `deno run ... cli.ts dev|serve` (the global shim + * or a source checkout) and `jsr:@dune/core@x/cli serve`; + * - the `deno task dev|serve` wrapper around either of those. + * Each match is then confirmed as a real dune site by checking `config/site.yaml` + * at its root, and reported with the port(s) it is *actually* listening on — + * read from that process's own open sockets via `lsof`, never assumed from a + * fixed list of "common" ports. * - * Site-root detection prefers a process's `PWD` environment variable over - * its live cwd — `dev.ts` deliberately `Deno.chdir()`s to `@dune/core`'s - * own package root when running from a local checkout (for Fresh's - * esbuild import-map auto-detection), which would otherwise make a - * maintainer's own dev setup — running dune from source against a real - * site — misreport as "not a dune site" (see `getPwdEnv()`'s doc comment). + * A server is usually several processes (a `deno task` wrapper, the re-exec + * child that applies the site's import map, esbuild workers). They are + * reported as ONE instance, keyed by the topmost matching process, and + * `--stop` stops the whole tree. + * + * Site-root detection prefers an explicit `--root`, then the process's `PWD` + * environment variable, then its live cwd — `dev.ts` deliberately + * `Deno.chdir()`s to `@dune/core`'s own package root when running from a local + * checkout (for Fresh's esbuild import-map auto-detection), which would + * otherwise make a maintainer's own dev setup misreport as "not a dune site" + * (see `getPwdEnv()`'s doc comment). + * + * A process can match and still be listening on nothing — typically a server + * started on a port something else already held, which never exits. That is + * reported as "not listening", distinct from "port unknown" (no `lsof`). * * POSIX only (macOS/Linux) — both `ps` and `lsof` are used. Deliberately * process-table-scanning rather than a self-registered PID/state file: @@ -26,26 +40,36 @@ * @module */ -import { join } from "@std/path"; +import { isAbsolute, join, resolve } from "@std/path"; import { parseUserYaml } from "../security/safe-yaml.ts"; -/** A dune dev/serve-shaped process found running on this machine. */ +/** A dune dev/serve-shaped process (tree) found running on this machine. */ export interface DuneInstance { + /** The topmost matching process; what `--stop` and humans refer to. */ pid: number; + /** `pid` plus every descendant (re-exec child, esbuild workers, ...). */ + pids: number[]; mode: "dev" | "serve" | "unknown"; /** - * Absolute path to the site root, or null when neither `PWD` nor the - * live cwd could be confirmed as a real dune site (`config/site.yaml` - * missing from both) — still a real process worth reporting, just - * without a confirmed identity. + * Absolute path to the site root, or null when no candidate directory + * (`--root`, `PWD`, live cwd) held a `config/site.yaml` — still a real + * process worth reporting, just without a confirmed identity. */ root: string | null; /** `config/site.yaml`'s `title`, or null if unset/unreadable/`root` is null. */ title: string | null; - /** Ports this process is actually listening on, ascending. Empty if `lsof` couldn't tell us (e.g. not installed). */ + /** Ports this instance is actually listening on, ascending. */ ports: number[]; + /** + * `"listening"`: at least one port. `"not-listening"`: `lsof` ran and found + * none — a hung or leftover process. `"unknown"`: `lsof` isn't available, + * so we can't tell. + */ + portStatus: "listening" | "not-listening" | "unknown"; /** `ps`'s own elapsed-time string (e.g. "01:23:45" or "2-09:03:55"). */ elapsed: string | null; + /** The process's command line, for identifying unconfirmed instances. */ + command: string; } async function runCommand( @@ -69,36 +93,144 @@ async function runCommand( } } + +// ── Process table ──────────────────────────────────────────────────────────── + +interface ProcRow { + pid: number; + ppid: number; + elapsed: string; + command: string; +} + /** - * Every process on the machine matching the dune dev/serve invocation - * shape, with PID and elapsed time straight from `ps` (one call, so we - * don't pay a subprocess per candidate for this part). + * Snapshot of the whole process table with parent links, so a server's + * wrapper/child/worker processes can be grouped without further spawns. * * `COLUMNS=4096` works around BSD `ps` (macOS) truncating the `command` * column to terminal width by default — without it, a long `deno run -A * --watch=main.ts main.ts dev` invocation from a deeply-nested path can get - * cut off before the trailing `dev`/`serve` token this match depends on. + * cut off before the trailing `dev`/`serve` token the match depends on. */ -async function findCandidates(): Promise< - { pid: number; elapsed: string; command: string }[] -> { - const out = await runCommand("ps", ["-eo", "pid=,etime=,command="], { +async function readProcessTable(): Promise { + const out = await runCommand("ps", ["-eo", "pid=,ppid=,etime=,command="], { COLUMNS: "4096", }); if (!out) return []; + const rows: ProcRow[] = []; + for (const line of out.split("\n")) { + const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/); + if (!m) continue; + rows.push({ pid: parseInt(m[1], 10), ppid: parseInt(m[2], 10), elapsed: m[3], command: m[4] }); + } + return rows; +} + +/** How a command line matched: `strong` is unambiguous, `weak` needs a confirmed site. */ +export interface CommandMatch { + mode: "dev" | "serve"; + strength: "strong" | "weak"; +} + +const DENO_EXE = /^(?:\S*\/)?deno\s/; + +/** + * Does this command line look like a dune dev/serve server? + * + * - `main.ts dev|serve` — the generated site entrypoint. Strong. + * - `cli.ts dev|serve` / `.../cli dev|serve` — the dune CLI run directly: the + * global shim, a source checkout, or `jsr:@dune/core/cli`. Strong when + * "dune" appears in the command (path, `@dune/core` specifier, + * `.dune-cli-config`); otherwise (a relative `src/cli.ts dev`) it could be + * any tool's CLI, so Weak. + * - `deno task dev|serve` — only a wrapper around one of the above, and + * generic enough to be any project's task, so Weak. + * + * A weak match counts only when its site root is confirmed (`config/site.yaml`). + */ +export function matchDuneCommand(command: string): CommandMatch | null { + if (!DENO_EXE.test(command)) return null; + + const entry = command.match(/\bmain\.ts\s+(dev|serve)\b/); + if (entry) return { mode: entry[1] as "dev" | "serve", strength: "strong" }; + + const cli = command.match(/(?:^|[\s/])cli(?:\.ts)?\s+(dev|serve)\b/); + if (cli) { + return { mode: cli[1] as "dev" | "serve", strength: /dune/i.test(command) ? "strong" : "weak" }; + } + + const task = command.match(/\bdeno\s+task\s+(dev|serve)\b/); + if (task) return { mode: task[1] as "dev" | "serve", strength: "weak" }; + + return null; +} + +/** `--root ` / `--root=` from a command line, or null. */ +export function parseRootArg(command: string): string | null { + const m = command.match(/--root(?:=|\s+)("[^"]+"|'[^']+'|\S+)/); + return m ? m[1].replace(/^["']|["']$/g, "") : null; +} + +/** All descendant pids of `pid`, from an in-memory process table. */ +function descendantsOf(pid: number, children: Map): number[] { + const out: number[] = []; + const seen = new Set([pid]); + const queue = [pid]; + while (queue.length > 0) { + for (const child of children.get(queue.shift()!) ?? []) { + if (seen.has(child)) continue; + seen.add(child); + out.push(child); + queue.push(child); + } + } + return out; +} - const results: { pid: number; elapsed: string; command: string }[] = []; +// ── lsof ───────────────────────────────────────────────────────────────────── + +let lsofAvailableCache: boolean | undefined; + +/** Whether `lsof` can be spawned at all (its exit code is irrelevant: it exits 1 on "no matches"). */ +async function lsofAvailable(): Promise { + if (lsofAvailableCache !== undefined) return lsofAvailableCache; + try { + await new Deno.Command("lsof", { args: ["-v"], stdout: "null", stderr: "null" }).output(); + lsofAvailableCache = true; + } catch { + lsofAvailableCache = false; + } + return lsofAvailableCache; +} + +/** + * Ports any of `pids` is actually bound to and listening on (TCP), ascending. + * `-P` forces numeric port output — without it, `lsof` resolves a port to its + * `/etc/services` name when one happens to be registered (port 3000 prints as + * `hbci` on macOS, not `3000`), which would silently break the numeric parse + * below for exactly the port `dune new` defaults to. + */ +async function getListeningPorts(pids: number[]): Promise { + const out = await runCommand("lsof", [ + "-a", + "-p", + pids.join(","), + "-iTCP", + "-sTCP:LISTEN", + "-P", + "-Fn", + ]); + if (!out) return []; + const ports = new Set(); for (const line of out.split("\n")) { - const match = line.match(/^\s*(\d+)\s+(\S+)\s+(.*)$/); - if (!match) continue; - const [, pidStr, elapsed, command] = match; - if (!command.includes("deno")) continue; - if (!/\bmain\.ts\s+(dev|serve)\b/.test(command)) continue; - results.push({ pid: parseInt(pidStr, 10), elapsed, command }); + if (!line.startsWith("n")) continue; + const match = line.match(/:(\d+)$/); + if (match) ports.add(parseInt(match[1], 10)); } - return results; + return [...ports].sort((a, b) => a - b); } + /** * Extract one env var's value from `ps eww`'s raw dump. There's no * delimiter between the command and its environment in that output (both @@ -152,67 +284,6 @@ async function getLiveCwd(pid: number): Promise { return null; } -/** - * Every descendant PID of `pid` (children, grandchildren, ...), via - * repeated `pgrep -P`. Needed because `dune dev`/`serve`'s *matched* - * process is not always the one holding the listening socket: when a site - * resolves `@dune/core` from JSR (the common case — anything that isn't a - * maintainer's own local-checkout dev setup), `maybeReexecWithSiteConfig()` - * re-execs into a **child** process (to apply the site's merged - * `deno.json`/import map) that actually calls `bootstrap()` and binds the - * port — the original, outer process (the one whose command line matches - * `main.ts dev|serve`) is a thin wrapper that doesn't hold the socket at - * all. Checking descendants too is what makes port lookup work for both - * that case and the local-checkout case (no re-exec, the matched PID - * *is* the listener) uniformly. - */ -async function getDescendantPids(pid: number): Promise { - const all: number[] = []; - let frontier = [pid]; - // Bounded depth, not because deep re-exec chains are expected, but so a - // pathological/cyclic ps report can't spin this into an infinite loop. - for (let depth = 0; depth < 5 && frontier.length > 0; depth++) { - const out = await runCommand("pgrep", ["-P", frontier.join(",")]); - if (!out) break; - const children = out.split("\n").map((s) => parseInt(s.trim(), 10)).filter((n) => - !isNaN(n) - ); - if (children.length === 0) break; - all.push(...children); - frontier = children; - } - return all; -} - -/** - * Ports this process — or any of its descendants (see `getDescendantPids`) - * — is actually bound to and listening on (TCP), ascending. `-P` forces - * numeric port output — without it, `lsof` resolves a port to its - * `/etc/services` name when one happens to be registered (port 3000 - * prints as `hbci` on macOS, not `3000`), which would silently break the - * numeric parse below for exactly the port `dune new` defaults to. - */ -async function getListeningPorts(pid: number): Promise { - const descendants = await getDescendantPids(pid); - const pids = [pid, ...descendants]; - const out = await runCommand("lsof", [ - "-a", - "-p", - pids.join(","), - "-iTCP", - "-sTCP:LISTEN", - "-P", - "-Fn", - ]); - if (!out) return []; - const ports = new Set(); - for (const line of out.split("\n")) { - if (!line.startsWith("n")) continue; - const match = line.match(/:(\d+)$/); - if (match) ports.add(parseInt(match[1], 10)); - } - return [...ports].sort((a, b) => a - b); -} /** `config/site.yaml`'s `title` field, or null if the file is missing/unreadable/has no title. */ async function readSiteTitle(root: string): Promise { @@ -229,26 +300,14 @@ async function readSiteTitle(root: string): Promise { } } -function modeFromCommand(command: string): DuneInstance["mode"] { - if (/\bmain\.ts\s+dev\b/.test(command)) return "dev"; - if (/\bmain\.ts\s+serve\b/.test(command)) return "serve"; - return "unknown"; -} /** - * Confirm `dir` (or, failing that, `fallbackDir`) is a real dune site — - * `config/site.yaml` exists there — and return whichever one actually is. - * Tries `dir` (normally the `PWD`-derived root) first since it's right for - * the common `Deno.chdir()` case `getPwdEnv()`'s doc comment describes; - * falls back to the live cwd for anything `PWD` got wrong. Returns null - * when neither pans out — the caller still reports the process, just - * without a confirmed site identity, rather than silently dropping it. + * First directory among `dirs` that holds a `config/site.yaml`, or null. + * `--root` first (explicit), then `PWD` (right for the `Deno.chdir()` case + * `getPwdEnv()` describes), then the live cwd. */ -async function confirmSiteRoot( - dir: string | null, - fallbackDir: string | null, -): Promise { - for (const candidate of [dir, fallbackDir]) { +async function confirmSiteRoot(dirs: (string | null)[]): Promise { + for (const candidate of dirs) { if (!candidate) continue; try { await Deno.stat(join(candidate, "config", "site.yaml")); @@ -259,65 +318,230 @@ async function confirmSiteRoot( } /** - * Find every dune dev/serve-shaped process on this machine. + * Find every dune dev/serve instance on this machine. * - * A process-table match is reported unconditionally — even when neither - * `PWD` nor the live cwd turns out to hold a `config/site.yaml`, since - * that's still useful signal (PID, port, uptime) rather than a false - * negative — but `root`/`title` are null in that case, and the caller - * should say so plainly rather than guessing. + * A process-table match is reported unconditionally — even when no candidate + * directory holds a `config/site.yaml`, since that's still useful signal (PID, + * port, uptime, command) rather than a false negative — but `root`/`title` + * are null in that case, and the caller should say so plainly. */ export async function findDuneInstances(): Promise { - const candidates = await findCandidates(); - const instances: DuneInstance[] = []; + const table = await readProcessTable(); + const byPid = new Map(table.map((r) => [r.pid, r])); + const children = new Map(); + for (const r of table) children.set(r.ppid, [...(children.get(r.ppid) ?? []), r.pid]); + + // 1. Match command lines; resolve each match's site root. + interface Cand extends ProcRow { + match: CommandMatch; + root: string | null; + } + const cands: Cand[] = []; + for (const row of table) { + const match = matchDuneCommand(row.command); + if (!match) continue; + const [pwdRoot, liveCwd] = await Promise.all([getPwdEnv(row.pid), getLiveCwd(row.pid)]); + const rootArg = parseRootArg(row.command); + const base = pwdRoot ?? liveCwd; + const explicit = rootArg ? (isAbsolute(rootArg) ? rootArg : base ? resolve(base, rootArg) : null) : null; + const root = await confirmSiteRoot([explicit, pwdRoot, liveCwd]); + cands.push({ ...row, match, root }); + } + + // 2. A weak match (`deno task ...`) counts only for a confirmed dune site. + const kept = cands.filter((c) => c.match.strength === "strong" || c.root !== null); + const keptPids = new Set(kept.map((c) => c.pid)); - for (const { pid, elapsed, command } of candidates) { - const [pwdRoot, liveCwd] = await Promise.all([getPwdEnv(pid), getLiveCwd(pid)]); - const root = await confirmSiteRoot(pwdRoot, liveCwd); + // 3. One instance per process tree: drop any match that has a matching ancestor. + const hasKeptAncestor = (pid: number): boolean => { + let cur = byPid.get(pid)?.ppid; + for (let i = 0; cur && cur > 1 && i < 32; i++) { + if (keptPids.has(cur)) return true; + cur = byPid.get(cur)?.ppid; + } + return false; + }; + const tops = kept.filter((c) => !hasKeptAncestor(c.pid)); + // 4. Build the instances. + const haveLsof = await lsofAvailable(); + const instances: DuneInstance[] = []; + for (const c of tops) { + const pids = [c.pid, ...descendantsOf(c.pid, children)]; + // A tree's site root can sit on any member (e.g. the `--root` is on the child). + let root = c.root; + if (!root) { + for (const pid of pids.slice(1)) { + const row = byPid.get(pid); + if (!row) continue; + const [pwd, cwd] = await Promise.all([getPwdEnv(pid), getLiveCwd(pid)]); + const arg = parseRootArg(row.command); + const base = pwd ?? cwd; + const explicit = arg ? (isAbsolute(arg) ? arg : base ? resolve(base, arg) : null) : null; + root = await confirmSiteRoot([explicit, pwd, cwd]); + if (root) break; + } + } const [title, ports] = await Promise.all([ root ? readSiteTitle(root) : Promise.resolve(null), - getListeningPorts(pid), + haveLsof ? getListeningPorts(pids) : Promise.resolve([] as number[]), ]); - instances.push({ - pid, - mode: modeFromCommand(command), + pid: c.pid, + pids, + mode: c.match.mode, root, title, ports, - elapsed, + portStatus: !haveLsof ? "unknown" : ports.length > 0 ? "listening" : "not-listening", + elapsed: c.elapsed, + command: c.command, }); } + return instances.sort((a, b) => a.pid - b.pid); +} + +// ── Stopping ───────────────────────────────────────────────────────────────── - return instances; +/** This process and its ancestors: never a stop target, whatever the caller passes. */ +async function selfAndAncestors(): Promise> { + const table = await readProcessTable(); + const byPid = new Map(table.map((r) => [r.pid, r])); + const protectedPids = new Set([Deno.pid]); + let cur = byPid.get(Deno.pid)?.ppid; + for (let i = 0; cur && cur > 1 && i < 64; i++) { + protectedPids.add(cur); + cur = byPid.get(cur)?.ppid; + } + return protectedPids; +} + +export interface StopResult { + /** Pids that were signalled and are gone. */ + stopped: number[]; + /** Pids still alive after the timeout (SIGTERM was ignored or is still shutting down). */ + survivors: number[]; + /** Pids deliberately not signalled (this process and its ancestors). */ + skipped: number[]; +} + +async function alivePids(pids: number[]): Promise { + if (pids.length === 0) return []; + const out = await runCommand("ps", ["-o", "pid=", "-p", pids.join(",")]); + if (!out) return []; + const alive = new Set(out.split("\n").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n))); + return pids.filter((p) => alive.has(p)); +} + +/** + * Stop whole instance trees with SIGTERM, then wait for them to exit. + * Never signals this process or its ancestors (so a `dune ps --stop` run from + * inside a shell/agent can't take out its own parents). + */ +export async function stopInstances( + instances: DuneInstance[], + opts: { timeoutMs?: number } = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? 5000; + const protectedPids = await selfAndAncestors(); + const targets = [...new Set(instances.flatMap((i) => i.pids))]; + const skipped = targets.filter((p) => protectedPids.has(p)); + const toSignal = targets.filter((p) => !protectedPids.has(p)); + + for (const pid of toSignal) { + try { + Deno.kill(pid, "SIGTERM"); + } catch { /* already gone */ } + } + + const deadline = Date.now() + timeoutMs; + let remaining = await alivePids(toSignal); + while (remaining.length > 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 150)); + remaining = await alivePids(remaining); + } + const gone = new Set(toSignal.filter((p) => !remaining.includes(p))); + return { stopped: toSignal.filter((p) => gone.has(p)), survivors: remaining, skipped }; +} + +// ── Command ────────────────────────────────────────────────────────────────── + +export interface PsOptions { + /** Stop every instance found (asks first unless `yes`). */ + stop?: boolean; + /** Skip the confirmation prompt for `stop`. */ + yes?: boolean; +} + +/** Injection points so the command's flow is testable without touching real processes. */ +export interface PsDeps { + find?: () => Promise; + stop?: typeof stopInstances; + isInteractive?: () => boolean; + confirm?: (message: string) => Promise; + log?: (line: string) => void; } -export async function psCommand(): Promise { +function describePort(inst: DuneInstance): string { + if (inst.portStatus === "listening") return inst.ports.map((p) => `http://localhost:${p}`).join(", "); + if (inst.portStatus === "not-listening") return "not listening (no open port; likely a hung or leftover process)"; + return "port unknown (lsof not available)"; +} + +export async function psCommand(opts: PsOptions = {}, deps: PsDeps = {}): Promise { + const log = deps.log ?? ((l: string) => console.log(l)); if (Deno.build.os === "windows") { - console.log( - " ⚠ `dune ps` needs `ps`/`lsof` and isn't supported on Windows yet.", - ); + log(" ⚠ `dune ps` needs `ps`/`lsof` and isn't supported on Windows yet."); return; } - console.log("🏜️ Dune — scanning for running instances...\n"); - const instances = await findDuneInstances(); + log("🏜️ Dune — scanning for running instances...\n"); + const instances = await (deps.find ?? findDuneInstances)(); if (instances.length === 0) { - console.log(" No dune dev/serve instances found running locally."); + log(" No dune dev/serve instances found running locally."); return; } for (const inst of instances) { - const portsStr = inst.ports.length > 0 - ? inst.ports.map((p) => `http://localhost:${p}`).join(", ") - : "(port unknown — is `lsof` installed?)"; - console.log(` PID ${inst.pid} ${inst.mode.padEnd(5)} up ${inst.elapsed ?? "?"}`); - console.log(` ${inst.title ?? "(untitled)"}`); - console.log( - ` ${inst.root ?? "(site root unconfirmed — config/site.yaml not found via PWD or live cwd)"}`, - ); - console.log(` ${portsStr}\n`); + log(` PID ${inst.pid} ${inst.mode.padEnd(5)} up ${inst.elapsed ?? "?"}${inst.pids.length > 1 ? ` (+${inst.pids.length - 1} child process${inst.pids.length > 2 ? "es" : ""})` : ""}`); + log(` ${inst.title ?? "(untitled)"}`); + log(` ${inst.root ?? "(site root unconfirmed — no config/site.yaml via --root, PWD or live cwd)"}`); + log(` ${describePort(inst)}`); + log(` $ ${inst.command.length > 110 ? inst.command.slice(0, 107) + "..." : inst.command}\n`); + } + const serving = instances.filter((i) => i.portStatus === "listening").length; + const idle = instances.filter((i) => i.portStatus === "not-listening").length; + log(` ${instances.length} instance${instances.length === 1 ? "" : "s"}: ${serving} listening, ${idle} not listening\n`); + + if (!opts.stop) { + if (idle > 0) log(" Run `dune ps --stop` to stop them."); + return; + } + + const procCount = new Set(instances.flatMap((i) => i.pids)).size; + const question = `Stop ${instances.length} instance${instances.length === 1 ? "" : "s"} (${procCount} process${procCount === 1 ? "" : "es"})?`; + if (!opts.yes) { + const interactive = (deps.isInteractive ?? (() => Deno.stdin.isTerminal()))(); + if (!interactive) { + log(` Not stopping: ${question} needs confirmation, and this isn't a terminal. Re-run with --yes.`); + Deno.exitCode = 1; + return; + } + const ok = await (deps.confirm ?? ((m) => Promise.resolve(globalThis.confirm(m))))(question); + if (!ok) { + log(" Cancelled; nothing was stopped."); + return; + } + } + + const result = await (deps.stop ?? stopInstances)(instances); + log(` Stopped ${result.stopped.length} process${result.stopped.length === 1 ? "" : "es"}.`); + if (result.skipped.length > 0) { + log(` Left alone (this command's own process and its parents): ${result.skipped.join(", ")}`); + } + if (result.survivors.length > 0) { + log(` Still running after SIGTERM: ${result.survivors.join(", ")}. Try \`kill -9\` on those if they are really stuck.`); + Deno.exitCode = 1; } } diff --git a/tests/cli/ps_test.ts b/tests/cli/ps_test.ts index 2bdbcff3..09670d21 100644 --- a/tests/cli/ps_test.ts +++ b/tests/cli/ps_test.ts @@ -13,7 +13,14 @@ import { assertEquals, assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { join } from "@std/path"; -import { findDuneInstances } from "../../src/cli/ps.ts"; +import { + type DuneInstance, + findDuneInstances, + matchDuneCommand, + parseRootArg, + psCommand, + stopInstances, +} from "../../src/cli/ps.ts"; const BOOT_TIMEOUT_MS = 10_000; const POLL_INTERVAL_MS = 100; @@ -220,9 +227,318 @@ Deno.test( assertExists(found, `no instance found for pid ${pid}`); assertEquals(found.root, null); assertEquals(found.title, null); + assertEquals(found.portStatus, "not-listening"); } finally { if (child) await stop(child); await Deno.remove(root, { recursive: true }).catch(() => {}); } }, ); + +// ── command matching (pure) ────────────────────────────────────────────────── + +Deno.test("matchDuneCommand: the generated entrypoint, the dune CLI and jsr forms are strong matches", () => { + const strong: [string, "dev" | "serve"][] = [ + ["deno run -A main.ts dev", "dev"], + ["deno run -A --watch=main.ts main.ts dev --port 4700", "dev"], + ["/Users/x/.deno/bin/deno run -A main.ts serve --root . --port 8191", "serve"], + ["/Users/x/.deno/bin/deno run --allow-all --config /Users/x/.deno/bin/.dune/deno.json file:///Users/x/claude/dune/src/cli.ts dev", "dev"], + ["/Users/x/.deno/bin/deno run -A jsr:@dune/core@0.34.10/cli serve --env-file --port 3100", "serve"], + ["/Users/x/.deno/bin/deno run -A --config=/tmp/c.json --no-lock https://jsr.io/@dune/core/0.34.10/src/cli.ts serve --env-file", "serve"], + ]; + for (const [cmd, mode] of strong) { + assertEquals(matchDuneCommand(cmd), { mode, strength: "strong" }, cmd); + } +}); + +Deno.test("matchDuneCommand: `deno task dev|serve` and a bare `cli.ts dev|serve` are only weak matches (they could be any project)", () => { + assertEquals(matchDuneCommand("deno task serve --port 3100"), { mode: "serve", strength: "weak" }); + assertEquals(matchDuneCommand("/Users/x/.deno/bin/deno task dev"), { mode: "dev", strength: "weak" }); + assertEquals(matchDuneCommand("deno run -A src/cli.ts dev --root ../site"), { mode: "dev", strength: "weak" }); + assertEquals(matchDuneCommand("deno run -A other-tool/cli.ts serve"), { mode: "serve", strength: "weak" }); +}); + +Deno.test("matchDuneCommand: unrelated commands do not match", () => { + for ( + const cmd of [ + "deno run -A src/cli.ts ps", // dune, but not a server + "deno run -A src/cli.ts ps --stop", + "node server.js serve", + "/bin/zsh -c deno run -A main.ts dev", // a shell *containing* the text, not deno itself + "deno lint src/", + "deno test -A tests/", + ] + ) { + assertEquals(matchDuneCommand(cmd), null, cmd); + } +}); + +Deno.test("parseRootArg: --root in both spellings, quoted or not", () => { + assertEquals(parseRootArg("deno run cli.ts dev --root /a/b --port 3000"), "/a/b"); + assertEquals(parseRootArg("deno run cli.ts dev --root=/a/b"), "/a/b"); + assertEquals(parseRootArg('deno run cli.ts dev --root "/a b/c"'), "/a b/c"); + assertEquals(parseRootArg("deno run cli.ts dev --port 3000"), null); +}); + +// ── fixtures for the new real-process tests ────────────────────────────────── + +const SERVER = (port: number) => `Deno.serve({ port: ${port}, onListen: () => {} }, () => new Response("ok"));\n`; + +async function makeSite(prefix: string, title: string): Promise { + const dir = await Deno.makeTempDir({ prefix }); + await Deno.mkdir(join(dir, "config"), { recursive: true }); + await Deno.writeTextFile(join(dir, "config", "site.yaml"), `title: ${title}\n`); + return dir; +} + +function spawnDeno(args: string[], cwd: string): Deno.ChildProcess { + return new Deno.Command(Deno.execPath(), { + args, + cwd, + env: { ...Deno.env.toObject(), PWD: cwd }, + stdout: "null", + stderr: "null", + }).spawn(); +} + +const opts = { sanitizeOps: false, sanitizeResources: false }; + +Deno.test("findDuneInstances: the dune CLI run directly (`cli.ts dev --root `) is found, root from --root", opts, async () => { + // "dune" must appear in the command line for a bare `cli.ts dev` to count. + const toolDir = await Deno.makeTempDir({ prefix: "dune_test_ps_shim_tool_" }); + const site = await makeSite("dune_test_ps_shim_site_", "Shim Site"); + const port = 18_911; + let child: Deno.ChildProcess | undefined; + try { + await Deno.writeTextFile(join(toolDir, "cli.ts"), SERVER(port) + `await new Promise(() => {});\n`); + child = spawnDeno(["run", "-A", join(toolDir, "cli.ts"), "dev", "--root", site], toolDir); + const pid = child.pid; + await waitForPort(port); + + const found = (await findDuneInstances()).find((i) => i.pid === pid); + assertExists(found, `no instance for pid ${pid}`); + assertEquals(found.mode, "dev"); + assertEquals(found.root, site); // the explicit --root, as given + assertEquals(found.title, "Shim Site"); + assertEquals(found.ports, [port]); + assertEquals(found.portStatus, "listening"); + } finally { + if (child) await stop(child); + await Deno.remove(toolDir, { recursive: true }).catch(() => {}); + await Deno.remove(site, { recursive: true }).catch(() => {}); + } +}); + +Deno.test("findDuneInstances: a relative --root resolves against the process's own directory", opts, async () => { + const parent = await Deno.makeTempDir({ prefix: "dune_test_ps_relroot_" }); + const port = 18_912; + let child: Deno.ChildProcess | undefined; + try { + await Deno.mkdir(join(parent, "mysite", "config"), { recursive: true }); + await Deno.writeTextFile(join(parent, "mysite", "config", "site.yaml"), "title: Relative\n"); + await Deno.writeTextFile(join(parent, "cli.ts"), SERVER(port) + `await new Promise(() => {});\n`); + child = spawnDeno(["run", "-A", "cli.ts", "serve", "--root", "mysite"], parent); + const pid = child.pid; + await waitForPort(port); + + const found = (await findDuneInstances()).find((i) => i.pid === pid); + assertExists(found, `no instance for pid ${pid}`); + assertEquals(found.title, "Relative"); + assertEquals(found.root, join(parent, "mysite")); + } finally { + if (child) await stop(child); + await Deno.remove(parent, { recursive: true }).catch(() => {}); + } +}); + +Deno.test("findDuneInstances: a process that matches but holds no port is 'not-listening', with its root confirmed", opts, async () => { + const site = await makeSite("dune_test_ps_idle_", "Idle Site"); + let child: Deno.ChildProcess | undefined; + try { + await Deno.writeTextFile(join(site, "main.ts"), `setInterval(() => {}, 1000);\nawait new Promise(() => {});\n`); + child = spawnDeno(["run", "-A", "main.ts", "serve"], site); + const pid = child.pid; + await new Promise((r) => setTimeout(r, 600)); + + const found = (await findDuneInstances()).find((i) => i.pid === pid); + assertExists(found, `no instance for pid ${pid}`); + assertEquals(found.root, site); + assertEquals(found.title, "Idle Site"); + assertEquals(found.ports, []); + assertEquals(found.portStatus, "not-listening"); + } finally { + if (child) await stop(child); + await Deno.remove(site, { recursive: true }).catch(() => {}); + } +}); + +/** A `deno task serve` wrapper around `deno run -A main.ts serve`: two processes, one instance. */ +async function startTaskTree(port: number): Promise<{ site: string; child: Deno.ChildProcess }> { + const site = await makeSite("dune_test_ps_tree_", "Tree Site"); + await Deno.writeTextFile(join(site, "deno.json"), JSON.stringify({ tasks: { serve: "deno run -A main.ts serve" } })); + await Deno.writeTextFile(join(site, "main.ts"), SERVER(port) + `await new Promise(() => {});\n`); + const child = spawnDeno(["task", "serve"], site); + await waitForPort(port); + return { site, child }; +} + +Deno.test("findDuneInstances: a `deno task` wrapper and its child are ONE instance, reported at the wrapper", opts, async () => { + const port = 18_913; + const { site, child } = await startTaskTree(port); + try { + const instances = await findDuneInstances(); + const mine = instances.filter((i) => i.pids.includes(child.pid)); + assertEquals(mine.length, 1, "the tree must appear exactly once"); + const found = mine[0]; + assertEquals(found.pid, child.pid, "reported at the topmost process"); + assertEquals(found.pids.length >= 2, true, "includes the child process"); + assertEquals(found.ports, [port], "port found through the child"); + assertEquals(found.title, "Tree Site"); + // ...and the child is not listed again on its own. + assertEquals(instances.filter((i) => found.pids.slice(1).includes(i.pid)).length, 0); + } finally { + await stop(child); + await Deno.remove(site, { recursive: true }).catch(() => {}); + } +}); + +Deno.test("stopInstances: stops the whole tree and reports nothing left", opts, async () => { + const port = 18_914; + const { site, child } = await startTaskTree(port); + try { + const found = (await findDuneInstances()).find((i) => i.pids.includes(child.pid)); + assertExists(found); + const result = await stopInstances([found], { timeoutMs: 8000 }); + assertEquals(result.survivors, []); + assertEquals(result.skipped, []); + assertEquals(result.stopped.sort(), [...found.pids].sort()); + // Really gone: nothing answers any more. + let answered = true; + try { + await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(800) }); + } catch { + answered = false; + } + assertEquals(answered, false); + } finally { + await stop(child); + await Deno.remove(site, { recursive: true }).catch(() => {}); + } +}); + +function fakeInstance(overrides: Partial = {}): DuneInstance { + return { + pid: 99_990, + pids: [99_990], + mode: "serve", + root: "/x/site", + title: "Fake", + ports: [], + portStatus: "not-listening", + elapsed: "01:00", + command: "deno run -A main.ts serve", + ...overrides, + }; +} + +Deno.test("stopInstances: never signals this process, even if an instance lists it", async () => { + const result = await stopInstances([fakeInstance({ pids: [Deno.pid] })], { timeoutMs: 300 }); + assertEquals(result.skipped, [Deno.pid]); + assertEquals(result.stopped, []); + assertEquals(result.survivors, []); + // (If it had signalled itself, this assertion would never run.) +}); + +// ── psCommand flow (fakes only: these never touch real processes) ──────────── + +async function runPs( + instances: DuneInstance[], + opts: { stop?: boolean; yes?: boolean }, + deps: { interactive?: boolean; confirm?: boolean; survivors?: number[] } = {}, +) { + const lines: string[] = []; + const stopCalls: DuneInstance[][] = []; + const priorExit = Deno.exitCode; + Deno.exitCode = 0; + try { + await psCommand(opts, { + find: () => Promise.resolve(instances), + stop: (insts) => { + stopCalls.push(insts); + const all = insts.flatMap((i) => i.pids); + return Promise.resolve({ + stopped: all.filter((p) => !(deps.survivors ?? []).includes(p)), + survivors: deps.survivors ?? [], + skipped: [], + }); + }, + isInteractive: () => deps.interactive ?? false, + confirm: () => Promise.resolve(deps.confirm ?? false), + log: (l) => lines.push(l), + }); + return { text: lines.join("\n"), stopCalls, exitCode: Deno.exitCode }; + } finally { + Deno.exitCode = priorExit; + } +} + +Deno.test("psCommand: says 'not listening' for an idle process, a URL for a live one, 'lsof not available' only when unknown", async () => { + const { text } = await runPs([ + fakeInstance({ pid: 1, pids: [1], ports: [3000], portStatus: "listening" }), + fakeInstance({ pid: 2, pids: [2], ports: [], portStatus: "not-listening" }), + fakeInstance({ pid: 3, pids: [3], ports: [], portStatus: "unknown" }), + ], {}); + assertEquals(text.includes("http://localhost:3000"), true); + assertEquals(text.includes("not listening (no open port"), true); + assertEquals(text.includes("port unknown (lsof not available)"), true); + assertEquals(text.includes("is `lsof` installed?"), false, "the old misleading wording is gone"); + assertEquals(text.includes("3 instances: 1 listening, 1 not listening"), true); +}); + +Deno.test("psCommand: without --stop it never stops anything", async () => { + const { stopCalls } = await runPs([fakeInstance()], {}); + assertEquals(stopCalls.length, 0); +}); + +Deno.test("psCommand --stop: refuses without --yes when not interactive, and says how to proceed", async () => { + const { text, stopCalls, exitCode } = await runPs([fakeInstance()], { stop: true }, { interactive: false }); + assertEquals(stopCalls.length, 0); + assertEquals(exitCode, 1); + assertEquals(text.includes("Re-run with --yes"), true); +}); + +Deno.test("psCommand --stop: --yes stops without asking", async () => { + const { stopCalls, exitCode } = await runPs([fakeInstance(), fakeInstance({ pid: 5, pids: [5, 6] })], { stop: true, yes: true }); + assertEquals(stopCalls.length, 1); + assertEquals(stopCalls[0].length, 2); + assertEquals(exitCode, 0); +}); + +Deno.test("psCommand --stop: an interactive 'no' stops nothing, a 'yes' stops everything", async () => { + const no = await runPs([fakeInstance()], { stop: true }, { interactive: true, confirm: false }); + assertEquals(no.stopCalls.length, 0); + assertEquals(no.text.includes("Cancelled"), true); + const yes = await runPs([fakeInstance()], { stop: true }, { interactive: true, confirm: true }); + assertEquals(yes.stopCalls.length, 1); +}); + +Deno.test("psCommand --stop: survivors are reported and make the exit code non-zero", async () => { + const { text, exitCode } = await runPs([fakeInstance({ pids: [7, 8] })], { stop: true, yes: true }, { survivors: [8] }); + assertEquals(text.includes("Still running after SIGTERM: 8"), true); + assertEquals(exitCode, 1); +}); + +Deno.test("findDuneInstances: a bare `cli.ts serve` with NO site anywhere is not reported (weak matches need a confirmed site)", opts, async () => { + const dir = await Deno.makeTempDir({ prefix: "ps_test_nosite_" }); // no "dune" in the command, no config/site.yaml + let child: Deno.ChildProcess | undefined; + try { + await Deno.writeTextFile(join(dir, "cli.ts"), `setInterval(() => {}, 1000);\nawait new Promise(() => {});\n`); + child = spawnDeno(["run", "-A", "cli.ts", "serve"], dir); + const pid = child.pid; + await new Promise((r) => setTimeout(r, 600)); + assertEquals((await findDuneInstances()).some((i) => i.pids.includes(pid)), false); + } finally { + if (child) await stop(child); + await Deno.remove(dir, { recursive: true }).catch(() => {}); + } +}); From 3d6c156eb92d1c12c13da7fc17376e28f2379a9e Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 22 Oct 2026 09:33:19 +0200 Subject: [PATCH 02/12] 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 --- CHANGELOG.md | 4 +++ README.md | 2 +- src/cli-impl.ts | 1 + src/cli/args.ts | 2 ++ src/cli/help.ts | 2 ++ src/cli/ps.ts | 42 ++++++++++++++++++++++++++------ tests/cli/ps_test.ts | 58 +++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 101 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d44396f2..befd55a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,10 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what (SIGTERM), and reports anything still alive. It asks first; `--yes` skips the prompt, and with no terminal it refuses unless `--yes` is given. It never signals itself or its parent processes. +- **`dune ps --idle`** narrows the listing to instances that are running but + not listening (hung or leftover processes), and `dune ps --stop --idle` + stops only those, so live servers on a shared machine are left alone. An + instance whose port state is unknown (no `lsof`) is never assumed idle. ## [0.34.14] — 2026-10-21 diff --git a/README.md b/README.md index 1f5f6ae3..22a3398b 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ This snapshots `deno.json` at install time rather than reading it live, so it go | `dune new [dir] --verify` | Same, but also boot the site for real and check it serves a request | | `dune dev` | Dev server with hot-reload (watches content + themes) | | `dune dev:link` | Reinstall the global shim against this checkout (local dev on Dune itself) | -| `dune ps` | List dune dev/serve instances running on this machine (`--stop` to stop them) | +| `dune ps` | List dune dev/serve instances running on this machine (`--stop` to stop them, `--idle` for only the hung ones) | | `dune doctor` | Environment/runtime health checks — Deno version, dependency resolution, lockfile staleness | | `dune doctor --boot` | Also boot the site for real and check it serves a request | | `dune build` | Build content index, validate config | diff --git a/src/cli-impl.ts b/src/cli-impl.ts index 299c1225..d5090f81 100644 --- a/src/cli-impl.ts +++ b/src/cli-impl.ts @@ -197,6 +197,7 @@ export async function main(args: string[] = Deno.args) { await psCommand({ stop: options.stop === true, yes: options.yes === true, + idle: options.idle === true, }); break; diff --git a/src/cli/args.ts b/src/cli/args.ts index 0cc4c7d3..77179787 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -105,6 +105,8 @@ export function parseCliOptions(args: string[]): ParsedCliArgs { options.readonly = true; } else if (args[i] === "--stop") { options.stop = true; + } else if (args[i] === "--idle") { + options.idle = true; } else if (args[i] === "--boot") { options.boot = true; } else if (args[i] === "--verify") { diff --git a/src/cli/help.ts b/src/cli/help.ts index 5cd713c8..ece66cf9 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -24,6 +24,8 @@ Commands: macOS/Linux only) ps --stop Stop every instance found, with its child processes (asks first; --yes to skip the prompt) + ps --idle Only the instances that are running but not listening + (hung or leftover); with --stop, stops just those build Build content index and validate config build --static Generate a fully static site (SSG) serve Start production server (--frozen enforces deno.lock; diff --git a/src/cli/ps.ts b/src/cli/ps.ts index 4b3b0f90..36403300 100644 --- a/src/cli/ps.ts +++ b/src/cli/ps.ts @@ -467,10 +467,17 @@ export async function stopInstances( // ── Command ────────────────────────────────────────────────────────────────── export interface PsOptions { - /** Stop every instance found (asks first unless `yes`). */ + /** Stop every instance shown (asks first unless `yes`). */ stop?: boolean; /** Skip the confirmation prompt for `stop`. */ yes?: boolean; + /** + * Only consider instances that are running but "not listening" — hung or + * leftover processes. Narrows both the listing and `stop`, so live servers + * are left alone. Instances whose port state is unknown (no `lsof`) are not + * assumed idle. + */ + idle?: boolean; } /** Injection points so the command's flow is testable without touching real processes. */ @@ -496,13 +503,28 @@ export async function psCommand(opts: PsOptions = {}, deps: PsDeps = {}): Promis } log("🏜️ Dune — scanning for running instances...\n"); - const instances = await (deps.find ?? findDuneInstances)(); + const all = await (deps.find ?? findDuneInstances)(); - if (instances.length === 0) { + if (all.length === 0) { log(" No dune dev/serve instances found running locally."); return; } + const serving = all.filter((i) => i.portStatus === "listening").length; + const idle = all.filter((i) => i.portStatus === "not-listening").length; + const unknown = all.length - serving - idle; + + if (opts.idle && unknown > 0 && idle === 0) { + log(" Can't tell which instances are idle: `lsof` isn't available, so no instance can be shown as not listening."); + if (opts.stop) Deno.exitCode = 1; + return; + } + const instances = opts.idle ? all.filter((i) => i.portStatus === "not-listening") : all; + if (instances.length === 0) { + log(` No idle instances: all ${all.length} running instance${all.length === 1 ? " is" : "s are"} listening.`); + return; + } + for (const inst of instances) { log(` PID ${inst.pid} ${inst.mode.padEnd(5)} up ${inst.elapsed ?? "?"}${inst.pids.length > 1 ? ` (+${inst.pids.length - 1} child process${inst.pids.length > 2 ? "es" : ""})` : ""}`); log(` ${inst.title ?? "(untitled)"}`); @@ -510,17 +532,21 @@ export async function psCommand(opts: PsOptions = {}, deps: PsDeps = {}): Promis log(` ${describePort(inst)}`); log(` $ ${inst.command.length > 110 ? inst.command.slice(0, 107) + "..." : inst.command}\n`); } - const serving = instances.filter((i) => i.portStatus === "listening").length; - const idle = instances.filter((i) => i.portStatus === "not-listening").length; - log(` ${instances.length} instance${instances.length === 1 ? "" : "s"}: ${serving} listening, ${idle} not listening\n`); + if (opts.idle) { + log(` ${instances.length} idle instance${instances.length === 1 ? "" : "s"} shown; ${serving} listening instance${serving === 1 ? "" : "s"} left out\n`); + } else { + log(` ${all.length} instance${all.length === 1 ? "" : "s"}: ${serving} listening, ${idle} not listening\n`); + } if (!opts.stop) { - if (idle > 0) log(" Run `dune ps --stop` to stop them."); + if (idle > 0 && !opts.idle) { + log(" `dune ps --stop --idle` stops only the not-listening ones; `dune ps --stop` stops everything listed."); + } return; } const procCount = new Set(instances.flatMap((i) => i.pids)).size; - const question = `Stop ${instances.length} instance${instances.length === 1 ? "" : "s"} (${procCount} process${procCount === 1 ? "" : "es"})?`; + const question = `Stop ${instances.length} ${opts.idle ? "idle " : ""}instance${instances.length === 1 ? "" : "s"} (${procCount} process${procCount === 1 ? "" : "es"})?`; if (!opts.yes) { const interactive = (deps.isInteractive ?? (() => Deno.stdin.isTerminal()))(); if (!interactive) { diff --git a/tests/cli/ps_test.ts b/tests/cli/ps_test.ts index 09670d21..ae29c456 100644 --- a/tests/cli/ps_test.ts +++ b/tests/cli/ps_test.ts @@ -453,7 +453,7 @@ Deno.test("stopInstances: never signals this process, even if an instance lists async function runPs( instances: DuneInstance[], - opts: { stop?: boolean; yes?: boolean }, + opts: { stop?: boolean; yes?: boolean; idle?: boolean }, deps: { interactive?: boolean; confirm?: boolean; survivors?: number[] } = {}, ) { const lines: string[] = []; @@ -542,3 +542,59 @@ Deno.test("findDuneInstances: a bare `cli.ts serve` with NO site anywhere is not await Deno.remove(dir, { recursive: true }).catch(() => {}); } }); + +// ── --idle: the selective form ─────────────────────────────────────────────── + +const LIVE = () => fakeInstance({ pid: 10, pids: [10, 11], ports: [3000], portStatus: "listening", title: "Live Site" }); +const IDLE_A = () => fakeInstance({ pid: 20, pids: [20], title: "Idle A" }); +const IDLE_B = () => fakeInstance({ pid: 30, pids: [30, 31, 32], title: "Idle B" }); + +Deno.test("psCommand --idle: lists only the not-listening instances and says how many live ones it left out", async () => { + const { text, stopCalls } = await runPs([LIVE(), IDLE_A(), IDLE_B()], { idle: true }); + assertEquals(text.includes("Idle A") && text.includes("Idle B"), true); + assertEquals(text.includes("Live Site"), false, "a listening instance is not shown"); + assertEquals(text.includes("2 idle instances shown; 1 listening instance left out"), true); + assertEquals(stopCalls.length, 0, "--idle alone never stops anything"); +}); + +Deno.test("psCommand --stop --idle: stops ONLY the idle instances, never a listening one", async () => { + const { stopCalls, exitCode } = await runPs([LIVE(), IDLE_A(), IDLE_B()], { stop: true, idle: true, yes: true }); + assertEquals(stopCalls.length, 1); + assertEquals(stopCalls[0].map((i) => i.pid).sort(), [20, 30]); + assertEquals(stopCalls[0].flatMap((i) => i.pids).includes(10), false); + assertEquals(stopCalls[0].flatMap((i) => i.pids).includes(11), false, "not even the live one's child processes"); + assertEquals(exitCode, 0); +}); + +Deno.test("psCommand --stop --idle: the confirmation names the idle count and process count", async () => { + const { text } = await runPs([LIVE(), IDLE_A(), IDLE_B()], { stop: true, idle: true }, { interactive: false }); + assertEquals(text.includes("Stop 2 idle instances (4 processes)?"), true, text); +}); + +Deno.test("psCommand --stop --idle: without --yes and without a terminal it still refuses", async () => { + const { stopCalls, exitCode } = await runPs([LIVE(), IDLE_A()], { stop: true, idle: true }, { interactive: false }); + assertEquals(stopCalls.length, 0); + assertEquals(exitCode, 1); +}); + +Deno.test("psCommand --stop --idle: with nothing idle it stops nothing and says so", async () => { + const { text, stopCalls, exitCode } = await runPs([LIVE()], { stop: true, idle: true, yes: true }); + assertEquals(stopCalls.length, 0); + assertEquals(text.includes("No idle instances: all 1 running instance is listening."), true); + assertEquals(exitCode, 0); +}); + +Deno.test("psCommand --idle: when lsof is unavailable nothing is assumed idle, and --stop does not proceed", async () => { + const unknown = fakeInstance({ pid: 40, pids: [40], portStatus: "unknown" }); + const { text, stopCalls, exitCode } = await runPs([unknown], { stop: true, idle: true, yes: true }); + assertEquals(stopCalls.length, 0, "an instance of unknown state must not be stopped as 'idle'"); + assertEquals(text.includes("Can't tell which instances are idle"), true); + assertEquals(exitCode, 1); +}); + +Deno.test("psCommand: plain listing points at --stop --idle when idle instances exist", async () => { + const { text } = await runPs([LIVE(), IDLE_A()], {}); + assertEquals(text.includes("`dune ps --stop --idle` stops only the not-listening ones"), true); + const none = await runPs([LIVE()], {}); + assertEquals(none.text.includes("--stop --idle"), false, "no hint when there is nothing idle"); +}); From 5a014f39890c632a1213f5579d02c62045de8cd8 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 22 Oct 2026 10:45:42 +0200 Subject: [PATCH 03/12] 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 --- CHANGELOG.md | 10 +++++-- src/cli/ps.ts | 66 ++++++++++++++++++++++++++++++++------------ tests/cli/ps_test.ts | 59 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 111 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index befd55a9..45d8a0e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,9 +32,13 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what ### Added -- **`dune ps --stop`** stops every instance it lists, with its child processes - (SIGTERM), and reports anything still alive. It asks first; `--yes` skips the - prompt, and with no terminal it refuses unless `--yes` is given. It never +- **`dune ps --stop`** stops every instance it lists, with its child processes: + SIGTERM, a 10-second grace period for a real server to drain, then SIGKILL + for anything still alive, naming the pids that needed it. (The escalation + matters: a `dune serve` used to close its listener on the first SIGTERM and + then stay alive until sent a second one, so plain SIGTERM left exactly the + idle processes this command exists to clear.) It asks first; `--yes` skips + the prompt, and with no terminal it refuses unless `--yes` is given. It never signals itself or its parent processes. - **`dune ps --idle`** narrows the listing to instances that are running but not listening (hung or leftover processes), and `dune ps --stop --idle` diff --git a/src/cli/ps.ts b/src/cli/ps.ts index 36403300..79031271 100644 --- a/src/cli/ps.ts +++ b/src/cli/ps.ts @@ -417,9 +417,11 @@ async function selfAndAncestors(): Promise> { } export interface StopResult { - /** Pids that were signalled and are gone. */ + /** Pids that were signalled and are gone (including those that needed SIGKILL). */ stopped: number[]; - /** Pids still alive after the timeout (SIGTERM was ignored or is still shutting down). */ + /** The subset of `stopped` that ignored SIGTERM for the whole grace period and was SIGKILLed. */ + killed: number[]; + /** Pids still alive even after SIGKILL (rare: uninterruptible sleep, or not ours to signal). */ survivors: number[]; /** Pids deliberately not signalled (this process and its ancestors). */ skipped: number[]; @@ -433,35 +435,60 @@ async function alivePids(pids: number[]): Promise { return pids.filter((p) => alive.has(p)); } +async function waitUntilGone(pids: number[], timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let remaining = await alivePids(pids); + while (remaining.length > 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 150)); + remaining = await alivePids(remaining); + } + return remaining; +} + +function signalAll(pids: number[], signal: Deno.Signal): void { + for (const pid of pids) { + try { + Deno.kill(pid, signal); + } catch { /* already gone */ } + } +} + /** - * Stop whole instance trees with SIGTERM, then wait for them to exit. + * Stop whole instance trees: SIGTERM, wait up to `timeoutMs` for a graceful + * exit, then SIGKILL whatever is still alive. + * + * The escalation exists because a process can ignore SIGTERM indefinitely: a + * `dune serve` (before the explicit exit after shutdown) closed its listener + * on the first SIGTERM and then stayed alive until sent a second one, so + * "SIGTERM and report survivors" left exactly the idle processes this command + * exists to clear. The grace period is for real servers draining in-flight + * requests; `killed` reports who needed the SIGKILL so it is never silent. + * * Never signals this process or its ancestors (so a `dune ps --stop` run from * inside a shell/agent can't take out its own parents). */ export async function stopInstances( instances: DuneInstance[], - opts: { timeoutMs?: number } = {}, + opts: { timeoutMs?: number; killTimeoutMs?: number } = {}, ): Promise { - const timeoutMs = opts.timeoutMs ?? 5000; + const timeoutMs = opts.timeoutMs ?? 10_000; + const killTimeoutMs = opts.killTimeoutMs ?? 2_000; const protectedPids = await selfAndAncestors(); const targets = [...new Set(instances.flatMap((i) => i.pids))]; const skipped = targets.filter((p) => protectedPids.has(p)); const toSignal = targets.filter((p) => !protectedPids.has(p)); - for (const pid of toSignal) { - try { - Deno.kill(pid, "SIGTERM"); - } catch { /* already gone */ } - } + signalAll(toSignal, "SIGTERM"); + const afterTerm = await waitUntilGone(toSignal, timeoutMs); - const deadline = Date.now() + timeoutMs; - let remaining = await alivePids(toSignal); - while (remaining.length > 0 && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 150)); - remaining = await alivePids(remaining); + let survivors: number[] = []; + if (afterTerm.length > 0) { + signalAll(afterTerm, "SIGKILL"); + survivors = await waitUntilGone(afterTerm, killTimeoutMs); } - const gone = new Set(toSignal.filter((p) => !remaining.includes(p))); - return { stopped: toSignal.filter((p) => gone.has(p)), survivors: remaining, skipped }; + const killed = afterTerm.filter((p) => !survivors.includes(p)); + const gone = new Set(toSignal.filter((p) => !survivors.includes(p))); + return { stopped: toSignal.filter((p) => gone.has(p)), killed, survivors, skipped }; } // ── Command ────────────────────────────────────────────────────────────────── @@ -566,8 +593,11 @@ export async function psCommand(opts: PsOptions = {}, deps: PsDeps = {}): Promis if (result.skipped.length > 0) { log(` Left alone (this command's own process and its parents): ${result.skipped.join(", ")}`); } + if (result.killed.length > 0) { + log(` ${result.killed.length} did not exit on SIGTERM and were killed (SIGKILL): ${result.killed.join(", ")}`); + } if (result.survivors.length > 0) { - log(` Still running after SIGTERM: ${result.survivors.join(", ")}. Try \`kill -9\` on those if they are really stuck.`); + log(` Could not stop (still running even after SIGKILL): ${result.survivors.join(", ")}`); Deno.exitCode = 1; } } diff --git a/tests/cli/ps_test.ts b/tests/cli/ps_test.ts index ae29c456..9a920739 100644 --- a/tests/cli/ps_test.ts +++ b/tests/cli/ps_test.ts @@ -411,6 +411,7 @@ Deno.test("stopInstances: stops the whole tree and reports nothing left", opts, const result = await stopInstances([found], { timeoutMs: 8000 }); assertEquals(result.survivors, []); assertEquals(result.skipped, []); + assertEquals(result.killed, [], "a process that exits on SIGTERM must not be SIGKILLed"); assertEquals(result.stopped.sort(), [...found.pids].sort()); // Really gone: nothing answers any more. let answered = true; @@ -426,6 +427,45 @@ Deno.test("stopInstances: stops the whole tree and reports nothing left", opts, } }); +Deno.test("stopInstances: a process that ignores SIGTERM is escalated to SIGKILL, and reported as killed", opts, async () => { + const dir = await Deno.makeTempDir({ prefix: "ps_test_stubborn_" }); + let child: Deno.ChildProcess | undefined; + try { + // Swallows SIGTERM, the way a server stuck in its own shutdown does. + await Deno.writeTextFile( + join(dir, "stubborn.ts"), + `Deno.addSignalListener("SIGTERM", () => {});\nconsole.log("ready");\nsetInterval(() => {}, 1000);\n`, + ); + child = new Deno.Command(Deno.execPath(), { + args: ["run", "-A", "stubborn.ts"], + cwd: dir, + stdout: "piped", + stderr: "null", + }).spawn(); + const pid = child.pid; + // Wait until the signal handler is installed, or SIGTERM would just kill it. + const reader = child.stdout.getReader(); + const first = await reader.read(); + assertEquals(new TextDecoder().decode(first.value).includes("ready"), true); + reader.releaseLock(); + + const result = await stopInstances([fakeInstance({ pid, pids: [pid] })], { timeoutMs: 600, killTimeoutMs: 5000 }); + assertEquals(result.killed, [pid], "had to be SIGKILLed"); + assertEquals(result.stopped, [pid]); + assertEquals(result.survivors, []); + assertEquals((await child.status).signal, "SIGKILL"); + child = undefined; + } finally { + if (child) { + try { + child.kill("SIGKILL"); + } catch { /* already gone */ } + await child.status.catch(() => {}); + } + await Deno.remove(dir, { recursive: true }).catch(() => {}); + } +}); + function fakeInstance(overrides: Partial = {}): DuneInstance { return { pid: 99_990, @@ -445,6 +485,7 @@ Deno.test("stopInstances: never signals this process, even if an instance lists const result = await stopInstances([fakeInstance({ pids: [Deno.pid] })], { timeoutMs: 300 }); assertEquals(result.skipped, [Deno.pid]); assertEquals(result.stopped, []); + assertEquals(result.killed, []); assertEquals(result.survivors, []); // (If it had signalled itself, this assertion would never run.) }); @@ -454,7 +495,7 @@ Deno.test("stopInstances: never signals this process, even if an instance lists async function runPs( instances: DuneInstance[], opts: { stop?: boolean; yes?: boolean; idle?: boolean }, - deps: { interactive?: boolean; confirm?: boolean; survivors?: number[] } = {}, + deps: { interactive?: boolean; confirm?: boolean; survivors?: number[]; killed?: number[] } = {}, ) { const lines: string[] = []; const stopCalls: DuneInstance[][] = []; @@ -468,6 +509,7 @@ async function runPs( const all = insts.flatMap((i) => i.pids); return Promise.resolve({ stopped: all.filter((p) => !(deps.survivors ?? []).includes(p)), + killed: deps.killed ?? [], survivors: deps.survivors ?? [], skipped: [], }); @@ -522,12 +564,23 @@ Deno.test("psCommand --stop: an interactive 'no' stops nothing, a 'yes' stops ev assertEquals(yes.stopCalls.length, 1); }); -Deno.test("psCommand --stop: survivors are reported and make the exit code non-zero", async () => { +Deno.test("psCommand --stop: survivors (alive even after SIGKILL) are reported and make the exit code non-zero", async () => { const { text, exitCode } = await runPs([fakeInstance({ pids: [7, 8] })], { stop: true, yes: true }, { survivors: [8] }); - assertEquals(text.includes("Still running after SIGTERM: 8"), true); + assertEquals(text.includes("Could not stop (still running even after SIGKILL): 8"), true); assertEquals(exitCode, 1); }); +Deno.test("psCommand --stop: processes that needed SIGKILL are named, and that alone is not a failure", async () => { + const { text, exitCode } = await runPs([fakeInstance({ pids: [7, 8] })], { stop: true, yes: true }, { killed: [8] }); + assertEquals(text.includes("1 did not exit on SIGTERM and were killed (SIGKILL): 8"), true); + assertEquals(exitCode, 0); +}); + +Deno.test("psCommand --stop: says nothing about SIGKILL when everything exited gracefully", async () => { + const { text } = await runPs([fakeInstance()], { stop: true, yes: true }); + assertEquals(text.includes("SIGKILL"), false); +}); + Deno.test("findDuneInstances: a bare `cli.ts serve` with NO site anywhere is not reported (weak matches need a confirmed site)", opts, async () => { const dir = await Deno.makeTempDir({ prefix: "ps_test_nosite_" }); // no "dune" in the command, no config/site.yaml let child: Deno.ChildProcess | undefined; From 02433047862b621686ad5179f51eb5ff1b7170ee Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Mon, 26 Oct 2026 15:18:46 +0100 Subject: [PATCH 04/12] 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 /.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 --- CHANGELOG.md | 22 ++++ src/cli/dev.ts | 6 + src/cli/serve.ts | 7 + src/runtime/server.ts | 12 ++ src/runtime/watchdog.ts | 226 +++++++++++++++++++++++++++++++++ tests/runtime/watchdog_test.ts | 190 +++++++++++++++++++++++++++ 6 files changed, 463 insertions(+) create mode 100644 src/runtime/watchdog.ts create mode 100644 tests/runtime/watchdog_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b3e8fc..c58facfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,28 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what --- +## [Unreleased] + +### Added + +- **Event-loop watchdog: a server whose main thread gets stuck no longer hangs + silently until someone restarts it.** One synchronous loop (catastrophic + regex backtracking, a conversion that never terminates) blocks the single + thread that serves every request, so every URL stops answering at once, with + nothing in the logs. `dune dev` and `dune serve` now run a small watchdog + Worker that expects a heartbeat from the main thread every second. After 15 + seconds of silence (`DUNE_WATCHDOG_TIMEOUT_MS`) it writes a report to + `/.dune/hangs/` naming the requests that were in flight (plus a native + stack sample on macOS), logs the same lines to stderr, and SIGKILLs the + process so a supervisor (systemd `Restart=`, the `dune dev` wrapper exiting + non-zero) sees a dead process instead of a zombie. `DUNE_WATCHDOG=warn` + reports without killing; `DUNE_WATCHDOG=off` disables it. The process is + killed with SIGKILL rather than `Deno.exit()` because an exit requested from a + Worker only ends the Worker — the spinning main thread carries on. +- The main thread also logs `[dune] event loop was blocked for ~N.Ns` whenever + it was held for over a second, which shows a creeping problem before it + becomes a hang. + ## [0.34.14] — 2026-10-21 ### Security diff --git a/src/cli/dev.ts b/src/cli/dev.ts index cd8e23f4..e97bd636 100644 --- a/src/cli/dev.ts +++ b/src/cli/dev.ts @@ -22,6 +22,7 @@ import { materializeRemoteIslands } from "./remote-islands.ts"; import { checkLockfileStaleness } from "./lockfile.ts"; import { resolveContentDirPath } from "../content/content-root.ts"; import { registerEsbuildWorkerCleanup } from "./esbuild-cleanup.ts"; +import { startWatchdog } from "../runtime/watchdog.ts"; export interface DevOptions { port?: number; @@ -36,6 +37,11 @@ export async function devCommand(root: string, options: DevOptions = {}) { // invalidate any relative paths computed against the original cwd. root = resolve(root); + // A server whose event loop is stuck can't answer anything, log anything or + // be stopped gracefully; see runtime/watchdog.ts. Dev has no graceful-exit + // path to hook (Ctrl-C / --watch restarts end the process), so no stop(). + startWatchdog({ root, label: "dune dev" }); + registerEsbuildWorkerCleanup(); // ── Lockfile staleness check (advisory in dev) ─────────────────────────── diff --git a/src/cli/serve.ts b/src/cli/serve.ts index 05ae1691..4aba5861 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -32,6 +32,7 @@ import type { Scheduler, ScheduledAction } from "../workflow/mod.ts"; import { resolveContentDirPath } from "../content/content-root.ts"; import { createEmailClient, createEmailProvider } from "../email/mod.ts"; import { checkLockfileStaleness } from "./lockfile.ts"; +import { startWatchdog } from "../runtime/watchdog.ts"; export interface ServeOptions { port?: number; @@ -106,6 +107,10 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { // path and we Deno.chdir() later, which would invalidate relative paths. root = resolve(root); + // See runtime/watchdog.ts. Its Worker can't be unref'd, so both graceful- + // shutdown paths below stop it explicitly or the process would never exit. + const watchdog = startWatchdog({ root, label: "dune serve" }); + // ── Lockfile staleness check ───────────────────────────────────────────── if (await checkLockfileStaleness(root)) { if (frozen) { @@ -173,6 +178,7 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { await server.finished; await drainInFlight(() => inFlight, drainDeadlineMs); cleanup(); + watchdog.stop(); console.log("[dune] shutdown complete"); return; } @@ -387,5 +393,6 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { // flush step is required here. cleanup(); + watchdog.stop(); console.log("[dune] shutdown complete"); } diff --git a/src/runtime/server.ts b/src/runtime/server.ts index 99764266..cb9343af 100644 --- a/src/runtime/server.ts +++ b/src/runtime/server.ts @@ -19,6 +19,7 @@ import { mountPlugins } from "../plugins/loader.ts"; import { mountDuneAuth } from "../auth/mount.ts"; import { getUser, USER_HEADER } from "../auth/types.ts"; import { isAdminPath, withSecurityHeaders } from "../cli/serve-utils.ts"; +import { trackRequest } from "./watchdog.ts"; import { duneRoutes } from "../routing/routes.ts"; import { buildPluginClientBundles } from "../cli/client-bundles.ts"; import { createApiHandler } from "../api/handlers.ts"; @@ -346,6 +347,17 @@ export async function createDuneApp( return fc.next(); }); + // 0a. Tell the event-loop watchdog which requests are in flight, so a hang + // report can name the one that never finished. No-op without a watchdog. + app.use(async (fc) => { + const done = trackRequest(fc.req.method, fc.url.pathname); + try { + return await fc.next(); + } finally { + done(); + } + }); + // 0b. Standard security headers on EVERY response, whichever route family // produced it — plugin publicRoutes, plugin mount() routes, /api/*, /health, // sitemap and feeds, static files. They used to be applied by wrapping diff --git a/src/runtime/watchdog.ts b/src/runtime/watchdog.ts new file mode 100644 index 00000000..d149a0a2 --- /dev/null +++ b/src/runtime/watchdog.ts @@ -0,0 +1,226 @@ +/** + * Event-loop watchdog. + * + * A synchronous loop (catastrophic regex backtracking, a conversion that never + * terminates) blocks the one thread that serves every request, so one bad + * request takes the whole site down — silently, with nothing in the logs, and + * only a restart clears it. A blocked thread can't rescue itself, so the check + * runs on a separate one: the main thread posts a heartbeat to a Worker every + * second, and the Worker acts when the heartbeats stop. + * + * What it does on a stall (`DUNE_WATCHDOG`): + * - `exit` (default): write a report (plus a native stack sample where + * `sample` exists) to `/.dune/hangs/`, then SIGKILL the process so a + * supervisor (systemd `Restart=`, the `dune dev` wrapper's exit) sees a + * dead process instead of a zombie. SIGKILL, not `Deno.exit()`: an exit + * requested from a worker only ends the worker; the spinning main thread + * carries on. + * - `warn`: same report, no kill. + * - `off`: nothing starts. + * + * The main thread also notices its own timer drift and logs a one-line warning + * when the loop was held for over a second, which makes a creeping problem + * visible before it becomes a hang. + * + * @module + */ + +export type WatchdogMode = "exit" | "warn" | "off"; + +export interface WatchdogOptions { + /** Site root; reports go to `/.dune/hangs/`. */ + root: string; + /** What to do on a stall. Default: `DUNE_WATCHDOG` env, else `exit`. */ + mode?: WatchdogMode; + /** Heartbeat silence that counts as a stall. Default: `DUNE_WATCHDOG_TIMEOUT_MS` env, else 15000. */ + stallMs?: number; + /** Heartbeat period. Default 1000; tests shorten it. */ + heartbeatMs?: number; + /** Event-loop delay (over the expected tick) that logs a warning. Default 1000. */ + lagWarnMs?: number; + /** Shown in the report and log line, e.g. `dev` / `serve`. */ + label?: string; +} + +export interface Watchdog { + stop(): void; +} + +// The running watchdog's Worker, so request middleware can report to it. +let activeWorker: Worker | null = null; +let nextRequestId = 0; + +/** + * Record a request as in flight; call the returned function when it + * finishes. A stall report then lists what was running, which is usually + * the answer to "what hung?" — the native stack sample has no JS frames. A + * no-op when no watchdog is running. + */ +export function trackRequest(method: string, path: string): () => void { + const worker = activeWorker; + if (!worker) return () => {}; + const id = ++nextRequestId; + worker.postMessage({ type: "req+", id, desc: `${method} ${path}` }); + return () => worker.postMessage({ type: "req-", id }); +} + +const DEFAULT_STALL_MS = 15_000; +const DEFAULT_HEARTBEAT_MS = 1_000; +const DEFAULT_LAG_WARN_MS = 1_000; + +/** Parse `DUNE_WATCHDOG`; anything unrecognised falls back to the default. */ +export function parseWatchdogMode(value: string | undefined): WatchdogMode { + const v = value?.trim().toLowerCase(); + return v === "off" || v === "warn" || v === "exit" ? v : "exit"; +} + +function parseStallMs(value: string | undefined): number { + const n = Number(value); + return Number.isFinite(n) && n >= 1000 ? n : DEFAULT_STALL_MS; +} + +// Runs in the Worker. Kept as a string so it works from any module origin +// (jsr:/https: included), where a relative worker URL would not. +const WORKER_SOURCE = ` +let cfg = null; +let last = Date.now(); +let fired = false; +const inflight = new Map(); // id -> { desc, since } +const pad = (n) => String(n).padStart(2, "0"); + +async function report(silentMs) { + const now = new Date(); + const stamp = now.getFullYear() + pad(now.getMonth() + 1) + pad(now.getDate()) + + "-" + pad(now.getHours()) + pad(now.getMinutes()) + pad(now.getSeconds()); + const dir = cfg.root + "/.dune/hangs"; + const base = dir + "/hang-" + stamp + "-" + cfg.pid; + const lines = [ + "Dune event-loop watchdog report", + "time: " + now.toISOString(), + "pid: " + cfg.pid, + "command: " + cfg.label, + "root: " + cfg.root, + "deno: " + Deno.version.deno, + "uptime: " + Math.round((Date.now() - cfg.startedAt) / 1000) + "s", + "stalled: main thread silent for " + Math.round(silentMs / 1000) + "s (limit " + + Math.round(cfg.stallMs / 1000) + "s)", + "action: " + (cfg.mode === "exit" ? "SIGKILL" : "none (DUNE_WATCHDOG=warn)"), + ]; + const now2 = Date.now(); + const pending = [...inflight.values()].sort((a, b) => a.since - b.since); + lines.push("in-flight requests: " + pending.length); + for (const r of pending.slice(0, 20)) { + lines.push(" " + r.desc + " (" + Math.round((now2 - r.since) / 1000) + "s)"); + } + let sampled = false; + try { + await Deno.mkdir(dir, { recursive: true }); + if (Deno.build.os === "darwin") { + // 'sample' ships with macOS; it reads the stuck thread's native stack. + const out = await new Deno.Command("sample", { + args: [String(cfg.pid), "1", "-file", base + ".sample.txt"], + stdout: "null", + stderr: "null", + }).output(); + sampled = out.success; + } + if (sampled) lines.push("sample: " + base + ".sample.txt"); + else lines.push("sample: unavailable on this platform; run 'eu-stack -p " + cfg.pid + "' or 'gdb -p " + cfg.pid + "' next time before killing"); + await Deno.writeTextFile(base + ".txt", lines.join("\\n") + "\\n"); + lines.push("report: " + base + ".txt"); + } catch (err) { + lines.push("report: could not be written (" + err + ")"); + } + return lines; +} + +self.onmessage = async (e) => { + const msg = e.data; + if (msg.type === "init") { + cfg = msg; + last = Date.now(); + setInterval(async () => { + const silent = Date.now() - last; + if (fired || silent < cfg.stallMs) return; + fired = true; + console.error("[dune] watchdog: the main thread has not responded for " + + Math.round(silent / 1000) + "s (event loop blocked)"); + const lines = await report(silent); + for (const l of lines.filter((l) => /^(in-flight| [A-Z]+ |sample|report)/.test(l))) { + console.error("[dune] watchdog: " + l.trim()); + } + if (cfg.mode === "exit") { + console.error("[dune] watchdog: killing pid " + cfg.pid + " so it can be restarted"); + Deno.kill(cfg.pid, "SIGKILL"); + } else { + fired = false; // 'warn': keep watching, and report again if it stays stuck + last = Date.now(); + } + }, 500); + } else if (msg.type === "beat") { + last = Date.now(); + } else if (msg.type === "req+") { + inflight.set(msg.id, { desc: msg.desc, since: Date.now() }); + } else if (msg.type === "req-") { + inflight.delete(msg.id); + } +}; +`; + +/** + * Start the watchdog for this process. Returns a handle whose `stop()` ends + * it; a no-op handle when the mode is `off`. + */ +export function startWatchdog(opts: WatchdogOptions): Watchdog { + const mode = opts.mode ?? parseWatchdogMode(Deno.env.get("DUNE_WATCHDOG")); + if (mode === "off") return { stop() {} }; + + const stallMs = opts.stallMs ?? parseStallMs(Deno.env.get("DUNE_WATCHDOG_TIMEOUT_MS")); + const heartbeatMs = opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS; + const lagWarnMs = opts.lagWarnMs ?? DEFAULT_LAG_WARN_MS; + + const url = URL.createObjectURL( + new Blob([WORKER_SOURCE], { type: "application/javascript" }), + ); + const worker = new Worker(url, { type: "module" }); + URL.revokeObjectURL(url); + activeWorker = worker; + worker.postMessage({ + type: "init", + mode, + stallMs, + root: opts.root, + pid: Deno.pid, + label: opts.label ?? "dune", + startedAt: Date.now(), + }); + + let expected = Date.now() + heartbeatMs; + const timer = setInterval(() => { + const now = Date.now(); + const lag = now - expected; + expected = now + heartbeatMs; + worker.postMessage({ type: "beat" }); + if (lag >= lagWarnMs) { + console.warn(`[dune] event loop was blocked for ~${(lag / 1000).toFixed(1)}s`); + } + }, heartbeatMs); + + // The heartbeat must not be what keeps a finished process alive. + Deno.unrefTimer(timer); + + let stopped = false; + const stop = () => { + if (stopped) return; + stopped = true; + if (activeWorker === worker) activeWorker = null; + clearInterval(timer); + worker.terminate(); + }; + // Unlike a timer, a Worker cannot be unref'd: it keeps the process alive + // until terminated. Every normal exit path (`dune serve`'s graceful + // 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); + return { stop }; +} diff --git a/tests/runtime/watchdog_test.ts b/tests/runtime/watchdog_test.ts new file mode 100644 index 00000000..e69471ed --- /dev/null +++ b/tests/runtime/watchdog_test.ts @@ -0,0 +1,190 @@ +/** + * Tests for the event-loop watchdog (src/runtime/watchdog.ts). + * + * The behaviour under test is a process killing itself while its main thread + * is stuck in a loop, so every case runs a real child `deno` process and + * inspects how it ended. + */ + +import { assert, assertEquals, assertStringIncludes } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { join } from "@std/path"; +import { parseWatchdogMode } from "../../src/runtime/watchdog.ts"; + +const WATCHDOG_URL = new URL("../../src/runtime/watchdog.ts", import.meta.url).href; +const CHILD_DEADLINE_MS = 20_000; + +interface ChildResult { + code: number; + signal: Deno.Signal | null; + stdout: string; + stderr: string; + timedOut: boolean; +} + +/** Run `body` as a module that has already started a watchdog configured by `opts`. */ +async function runChild(root: string, opts: string, body: string): Promise { + const script = join(root, "child.ts"); + await Deno.writeTextFile( + script, + `import { startWatchdog } from ${JSON.stringify(WATCHDOG_URL)};\n` + + `const wd = startWatchdog({ root: ${JSON.stringify(root)}, ${opts} });\n${body}\n`, + ); + const child = new Deno.Command(Deno.execPath(), { + args: ["run", "-A", script], + stdout: "piped", + stderr: "piped", + }).spawn(); + let timedOut = false; + const killer = setTimeout(() => { + timedOut = true; + try { + child.kill("SIGKILL"); + } catch { /* already gone */ } + }, CHILD_DEADLINE_MS); + const out = await child.output(); + clearTimeout(killer); + const dec = new TextDecoder(); + return { + code: out.code, + signal: out.signal, + stdout: dec.decode(out.stdout), + stderr: dec.decode(out.stderr), + timedOut, + }; +} + +async function withRoot(fn: (root: string) => Promise): Promise { + const root = await Deno.makeTempDir({ prefix: "dune_test_watchdog_" }); + try { + await fn(root); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +async function reports(root: string): Promise { + try { + const names: string[] = []; + for await (const e of Deno.readDir(join(root, ".dune", "hangs"))) names.push(e.name); + return names.sort(); + } catch { + return []; + } +} + +Deno.test("parseWatchdogMode: accepts the three modes, defaults anything else to exit", () => { + assertEquals(parseWatchdogMode("off"), "off"); + assertEquals(parseWatchdogMode("WARN"), "warn"); + assertEquals(parseWatchdogMode(" exit "), "exit"); + assertEquals(parseWatchdogMode(undefined), "exit"); + assertEquals(parseWatchdogMode("nonsense"), "exit"); +}); + +Deno.test( + "watchdog (exit): a main thread stuck in a loop is SIGKILLed, with a report written first", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withRoot(async (root) => { + const r = await runChild( + root, + `mode: "exit", stallMs: 1500, heartbeatMs: 100`, + `import { trackRequest } from ${JSON.stringify(WATCHDOG_URL)};\n` + + `trackRequest("GET", "/fine")();\n` + + `trackRequest("GET", "/stuck");\n` + + `setTimeout(() => { console.log("spinning"); while (true) {} }, 300);\n` + + `Deno.serve({ port: 0, onListen: () => {} }, () => new Response("ok"));`, + ); + assert(!r.timedOut, "child was still running at the deadline — the watchdog did not fire"); + assertEquals(r.signal, "SIGKILL"); + assertStringIncludes(r.stderr, "event loop blocked"); + const files = await reports(root); + assert(files.some((f) => /^hang-.*\.txt$/.test(f) && !f.endsWith(".sample.txt")), `no report in ${files}`); + const report = await Deno.readTextFile( + join(root, ".dune", "hangs", files.find((f) => !f.endsWith(".sample.txt"))!), + ); + assertStringIncludes(report, "main thread silent for"); + assertStringIncludes(report, "action: SIGKILL"); + assertStringIncludes(report, "GET /stuck"); + assert(!report.includes("GET /fine"), "a finished request must not be listed"); + }); + }, +); + +Deno.test( + "watchdog (warn): reports a stall but leaves the process alone", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withRoot(async (root) => { + // Block for ~2.5s (past the 1.5s limit), then carry on and exit normally. + const r = await runChild( + root, + `mode: "warn", stallMs: 1500, heartbeatMs: 100`, + `setTimeout(async () => {\n` + + ` const t = Date.now(); while (Date.now() - t < 2500) {}\n` + + ` // Let the report (and macOS 'sample') finish before stopping.\n` + + ` for (let i = 0; i < 100; i++) {\n` + + ` try { for await (const e of Deno.readDir(${JSON.stringify(root)} + "/.dune/hangs")) if (e.name.endsWith(".txt") && !e.name.endsWith(".sample.txt")) { wd.stop(); Deno.exit(0); } } catch {}\n` + + ` await new Promise((r) => setTimeout(r, 100));\n` + + ` }\n` + + ` wd.stop();\n` + + `}, 300);`, + ); + assert(!r.timedOut); + assertEquals(r.code, 0); + assertEquals(r.signal, null); + assertStringIncludes(r.stderr, "event loop blocked"); + assert((await reports(root)).length > 0, "expected a report file"); + }); + }, +); + +Deno.test( + "watchdog: a briefly blocked loop under the limit is not reported, but the lag is logged", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withRoot(async (root) => { + const r = await runChild( + root, + `mode: "exit", stallMs: 5000, heartbeatMs: 100, lagWarnMs: 500`, + `setTimeout(() => { const t = Date.now(); while (Date.now() - t < 900) {} }, 300);\n` + + `setTimeout(() => wd.stop(), 1500);`, + ); + assert(!r.timedOut); + assertEquals(r.code, 0); + assertStringIncludes(r.stderr, "event loop was blocked for"); + assertEquals((await reports(root)).length, 0); + }); + }, +); + +Deno.test( + "watchdog: after stop(), a finished process exits on its own", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withRoot(async (root) => { + // A Worker can't be unref'd, so stop() is the contract: once it has + // been called (as `dune serve`'s shutdown does) nothing of the + // watchdog may hold the process open. + const r = await runChild( + root, + `mode: "exit", heartbeatMs: 100`, + `setTimeout(() => wd.stop(), 300);`, + ); + assert(!r.timedOut, "process lingered after its work was done"); + assertEquals(r.code, 0); + }); + }, +); + +Deno.test( + "watchdog (off): starts nothing and the process exits normally", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withRoot(async (root) => { + const r = await runChild(root, `mode: "off"`, `console.log("done");`); + assert(!r.timedOut); + assertEquals(r.code, 0); + assertStringIncludes(r.stdout, "done"); + }); + }, +); From e7c5cb909ec2a4c6b20a7ad213b55e1bf9439b6f Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Mon, 26 Oct 2026 15:41:29 +0100 Subject: [PATCH 05/12] feat(runtime): instance registry written by dev and serve Each running instance records itself in ~/.dune/instances/.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 --- CHANGELOG.md | 10 ++ src/cli/dev.ts | 9 ++ src/cli/serve.ts | 14 ++ src/runtime/instance-registry.ts | 170 ++++++++++++++++++++++++ tests/runtime/instance_registry_test.ts | 165 +++++++++++++++++++++++ 5 files changed, 368 insertions(+) create mode 100644 src/runtime/instance-registry.ts create mode 100644 tests/runtime/instance_registry_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c58facfb..ecf3b2cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what ### Added +- **Running instances now register themselves.** `dune dev` and `dune serve` + write a small record (pid, process start time, mode, site root, title, port, + `@dune/core` version) to `~/.dune/instances/.json` + (`DUNE_REGISTRY_DIR` overrides the directory) and remove it on a graceful + exit. A kill or crash leaves the file behind, so readers confirm the pid is + alive *and* has the recorded start time (pids get reused) and delete the ones + that fail. This is groundwork for `dune ps` to list instances from what each + one says about itself rather than guessing from command lines and `lsof`; + nothing reads it yet, and a registry that can't be written never stops a + server from starting. Unix only. - **Event-loop watchdog: a server whose main thread gets stuck no longer hangs silently until someone restarts it.** One synchronous loop (catastrophic regex backtracking, a conversion that never terminates) blocks the single diff --git a/src/cli/dev.ts b/src/cli/dev.ts index e97bd636..fca48e21 100644 --- a/src/cli/dev.ts +++ b/src/cli/dev.ts @@ -23,6 +23,8 @@ import { checkLockfileStaleness } from "./lockfile.ts"; import { resolveContentDirPath } from "../content/content-root.ts"; import { registerEsbuildWorkerCleanup } from "./esbuild-cleanup.ts"; import { startWatchdog } from "../runtime/watchdog.ts"; +import { registerInstance } from "../runtime/instance-registry.ts"; +import { CORE_VERSION } from "../plugins/mod.ts"; export interface DevOptions { port?: number; @@ -65,6 +67,7 @@ export async function devCommand(root: string, options: DevOptions = {}) { const { MultisiteManager } = await import("../multisite/mod.ts"); const manager = new MultisiteManager(); await manager.init(root, { port, debug, dev: true }); + await registerInstance({ mode: "dev", root, title: null, port, version: CORE_VERSION }); console.log(`\n 🌐 http://localhost:${port} (${manager.siteCount()} sites)\n`); Deno.serve({ port, handler: (req, info) => manager.handle(req, info) }); return; @@ -75,6 +78,12 @@ export async function devCommand(root: string, options: DevOptions = {}) { const ctx = await bootstrap(root, { debug, buildSearch: true }); const { engine, collections, taxonomy, search, config, pluginPublicRoutes, hooks } = ctx; + // For `dune ps` (runtime/instance-registry.ts). Dev ends via signals or a + // --watch restart rather than a graceful path, so the record is left to the + // unload handler and, failing that, to readers pruning it. + await registerInstance({ + mode: "dev", root, title: config.site.title ?? null, port, version: CORE_VERSION, + }); const adminPrefix = config.admin?.path ?? "/admin"; console.log(` 📄 ${engine.pages.length} pages indexed`); diff --git a/src/cli/serve.ts b/src/cli/serve.ts index 4aba5861..60ac0785 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -33,6 +33,8 @@ import { resolveContentDirPath } from "../content/content-root.ts"; import { createEmailClient, createEmailProvider } from "../email/mod.ts"; import { checkLockfileStaleness } from "./lockfile.ts"; import { startWatchdog } from "../runtime/watchdog.ts"; +import { type InstanceHandle, registerInstance } from "../runtime/instance-registry.ts"; +import { CORE_VERSION } from "../plugins/mod.ts"; export interface ServeOptions { port?: number; @@ -111,6 +113,10 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { // shutdown paths below stop it explicitly or the process would never exit. const watchdog = startWatchdog({ root, label: "dune serve" }); + // Tell `dune ps` about this process (runtime/instance-registry.ts). Removed + // again on graceful shutdown; a kill leaves a stale file that readers prune. + let instance: InstanceHandle | undefined; + // ── Lockfile staleness check ───────────────────────────────────────────── if (await checkLockfileStaleness(root)) { if (frozen) { @@ -142,6 +148,9 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { const { MultisiteManager } = await import("../multisite/mod.ts"); const manager = new MultisiteManager(); await manager.init(root, { port, debug, dev: false }); + instance = await registerInstance({ + mode: "serve", root, title: null, port, version: CORE_VERSION, + }); console.log(` 🌐 http://localhost:${port} (${manager.siteCount()} sites)\n`); // ── Graceful shutdown (multi-site) ────────────────────────────────────── @@ -178,6 +187,7 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { await server.finished; await drainInFlight(() => inFlight, drainDeadlineMs); cleanup(); + instance?.unregister(); watchdog.stop(); console.log("[dune] shutdown complete"); return; @@ -188,6 +198,9 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { const ctx = await bootstrap(root, { debug, buildSearch: true }); const { engine, config, pluginPublicRoutes, storage, hooks } = ctx; + instance = await registerInstance({ + mode: "serve", root, title: config.site.title ?? null, port, version: CORE_VERSION, + }); const adminPrefix = config.admin?.path ?? "/admin"; // Expose the configured runtimeDir to ConsoleEmailProvider so its dev-email @@ -393,6 +406,7 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { // flush step is required here. cleanup(); + instance?.unregister(); watchdog.stop(); console.log("[dune] shutdown complete"); } diff --git a/src/runtime/instance-registry.ts b/src/runtime/instance-registry.ts new file mode 100644 index 00000000..1548475b --- /dev/null +++ b/src/runtime/instance-registry.ts @@ -0,0 +1,170 @@ +/** + * Instance registry. + * + * `dune dev` / `dune serve` record themselves in a per-user directory so tools + * (`dune ps`) can list running instances from what each one says about + * itself instead of guessing from process command lines and `lsof`, which + * misses processes started in unexpected ways and says nothing about whether + * one is healthy. + * + * One JSON file per process: `/.json`. A process removes + * its file on a graceful exit, but a SIGKILL, crash or default signal + * termination leaves it behind, so every reader verifies a record against the + * live process (pid exists *and* has the recorded start time, since pids are + * reused) and prunes the ones that fail. + * + * The directory is `~/.dune/instances`, overridable with `DUNE_REGISTRY_DIR` + * (tests; unusual setups). Nothing here may stop a server from starting: every + * failure is swallowed. Unix only (like `dune ps`): where the process start time + * can't be read there is nothing to verify a record against, so nothing is + * written. + * + * @module + */ + +import { join } from "@std/path"; + +export interface InstanceRecord { + pid: number; + /** The OS-reported start time of the process (`ps -o lstart=`). */ + processStart: string; + mode: "dev" | "serve"; + /** Absolute site root. */ + root: string; + title: string | null; + /** The port the server was asked to listen on. */ + port: number | null; + /** `@dune/core` version running. */ + version: string; + /** ISO timestamp of registration. */ + registeredAt: string; +} + +export type InstanceInit = Pick; + +export interface InstanceHandle { + /** Remove this process's record. Safe to call more than once. */ + unregister(): void; +} + +/** Where records live. */ +export function registryDir(): string { + const override = Deno.env.get("DUNE_REGISTRY_DIR"); + if (override) return override; + const home = Deno.env.get("HOME") ?? Deno.env.get("USERPROFILE") ?? ""; + return join(home, ".dune", "instances"); +} + +/** + * The OS-reported start time of `pid`, or null when it can't be read (not a + * Unix `ps`, process gone). Compared as an opaque string: only equality with + * an earlier reading of the same process matters. + */ +export async function readProcessStart(pid: number): Promise { + if (Deno.build.os === "windows") return null; + try { + const out = await new Deno.Command("ps", { + args: ["-o", "lstart=", "-p", String(pid)], + stdout: "piped", + stderr: "null", + }).output(); + if (!out.success) return null; + const text = new TextDecoder().decode(out.stdout).trim(); + return text || null; + } catch { + return null; + } +} + +function fileFor(dir: string, pid: number): string { + return join(dir, `${pid}.json`); +} + +/** + * Record this process. Never throws: a registry that can't be written must not + * keep a site from starting. The record is written atomically (temp file + + * rename) so a reader never sees half a file. + */ +export async function registerInstance(init: InstanceInit): Promise { + const dir = registryDir(); + const file = fileFor(dir, Deno.pid); + let removed = false; + const unregister = () => { + if (removed) return; + removed = true; + try { + Deno.removeSync(file); + } catch { /* already gone, or never written */ } + }; + + try { + const processStart = await readProcessStart(Deno.pid); + if (processStart === null) { + removed = true; + return { unregister }; + } + const record: InstanceRecord = { + pid: Deno.pid, + processStart, + ...init, + registeredAt: new Date().toISOString(), + }; + await Deno.mkdir(dir, { recursive: true }); + const tmp = `${file}.${crypto.randomUUID()}.tmp`; + await Deno.writeTextFile(tmp, JSON.stringify(record, null, 2) + "\n"); + await Deno.rename(tmp, file); + // A graceful or natural exit cleans up; anything else leaves a stale file + // that readers prune. + globalThis.addEventListener("unload", unregister); + } catch { + removed = true; // nothing to clean up + } + return { unregister }; +} + +function isRecord(value: unknown): value is InstanceRecord { + if (typeof value !== "object" || value === null) return false; + const r = value as Record; + return typeof r.pid === "number" && typeof r.root === "string" && + typeof r.processStart === "string" && + (r.mode === "dev" || r.mode === "serve") && typeof r.version === "string"; +} + +/** + * Registered instances that are still running. Records for dead processes, or + * whose pid now belongs to a different process, are deleted as they are found. + */ +export async function listRegisteredInstances(): Promise { + const dir = registryDir(); + const live: InstanceRecord[] = []; + let entries: Deno.DirEntry[]; + try { + entries = []; + for await (const e of Deno.readDir(dir)) entries.push(e); + } catch { + return live; // no registry yet + } + + for (const entry of entries) { + if (!entry.isFile || !/^\d+\.json$/.test(entry.name)) continue; + const file = join(dir, entry.name); + let record: InstanceRecord | null = null; + try { + const parsed: unknown = JSON.parse(await Deno.readTextFile(file)); + if (isRecord(parsed) && `${parsed.pid}.json` === entry.name) record = parsed; + } catch { /* unreadable or half-written: treated as stale below */ } + + // Alive AND the same process: pids are reused, so a live pid with a + // different start time is someone else's process and this record is stale. + const valid = record !== null && + (await readProcessStart(record.pid)) === record.processStart; + if (valid && record) { + live.push(record); + } else { + try { + await Deno.remove(file); + } catch { /* raced with its owner or another reader */ } + } + } + return live.sort((a, b) => a.pid - b.pid); +} diff --git a/tests/runtime/instance_registry_test.ts b/tests/runtime/instance_registry_test.ts new file mode 100644 index 00000000..a69d1ff7 --- /dev/null +++ b/tests/runtime/instance_registry_test.ts @@ -0,0 +1,165 @@ +/** + * Tests for the instance registry (src/runtime/instance-registry.ts). + * Every test points DUNE_REGISTRY_DIR at a temp directory, so nothing here + * touches the real ~/.dune. + */ + +import { assert, assertEquals, assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { join } from "@std/path"; +import { + listRegisteredInstances, + readProcessStart, + registerInstance, + registryDir, +} from "../../src/runtime/instance-registry.ts"; + +const unixOnly = { ignore: Deno.build.os === "windows" }; + +async function withRegistry(fn: (dir: string) => Promise): Promise { + const dir = await Deno.makeTempDir({ prefix: "dune_test_registry_" }); + const prev = Deno.env.get("DUNE_REGISTRY_DIR"); + Deno.env.set("DUNE_REGISTRY_DIR", join(dir, "instances")); + try { + await fn(join(dir, "instances")); + } finally { + if (prev === undefined) Deno.env.delete("DUNE_REGISTRY_DIR"); + else Deno.env.set("DUNE_REGISTRY_DIR", prev); + await Deno.remove(dir, { recursive: true }).catch(() => {}); + } +} + +const INIT = { + mode: "serve" as const, + root: "/sites/example", + title: "Example", + port: 3123, + version: "9.9.9", +}; + +Deno.test("registryDir: honours DUNE_REGISTRY_DIR, defaults under ~/.dune", () => { + const prev = Deno.env.get("DUNE_REGISTRY_DIR"); + try { + Deno.env.set("DUNE_REGISTRY_DIR", "/somewhere/else"); + assertEquals(registryDir(), "/somewhere/else"); + Deno.env.delete("DUNE_REGISTRY_DIR"); + assert(registryDir().endsWith(join(".dune", "instances"))); + } finally { + if (prev !== undefined) Deno.env.set("DUNE_REGISTRY_DIR", prev); + } +}); + +Deno.test("register: writes a record for this process, listed back with its fields", unixOnly, async () => { + await withRegistry(async (dir) => { + const handle = await registerInstance(INIT); + const names: string[] = []; + for await (const e of Deno.readDir(dir)) names.push(e.name); + assertEquals(names, [`${Deno.pid}.json`], "exactly the record, no leftover temp file"); + + const list = await listRegisteredInstances(); + assertEquals(list.length, 1); + const r = list[0]; + assertEquals(r.pid, Deno.pid); + assertEquals(r.mode, "serve"); + assertEquals(r.root, "/sites/example"); + assertEquals(r.title, "Example"); + assertEquals(r.port, 3123); + assertEquals(r.version, "9.9.9"); + assertExists(r.processStart); + handle.unregister(); + }); +}); + +Deno.test("unregister: removes the record, and is safe to repeat", unixOnly, async () => { + await withRegistry(async () => { + const handle = await registerInstance(INIT); + handle.unregister(); + handle.unregister(); + assertEquals(await listRegisteredInstances(), []); + }); +}); + +Deno.test("list: a record whose process is gone is dropped and its file pruned", unixOnly, async () => { + await withRegistry(async (dir) => { + // A real process that has definitely exited. + const child = new Deno.Command(Deno.execPath(), { args: ["eval", "1"], stdout: "null", stderr: "null" }) + .spawn(); + const pid = child.pid; + const start = await readProcessStart(pid) ?? "Thu Jan 1 00:00:00 1970"; + await child.status; + await Deno.mkdir(dir, { recursive: true }); + await Deno.writeTextFile( + join(dir, `${pid}.json`), + JSON.stringify({ pid, processStart: start, ...INIT, registeredAt: new Date().toISOString() }), + ); + assertEquals(await listRegisteredInstances(), []); + assertEquals([...Deno.readDirSync(dir)].length, 0, "stale file should be pruned"); + }); +}); + +Deno.test("list: a live pid with a different start time (pid reuse) is treated as stale", unixOnly, async () => { + await withRegistry(async (dir) => { + await Deno.mkdir(dir, { recursive: true }); + await Deno.writeTextFile( + join(dir, `${Deno.pid}.json`), + JSON.stringify({ + pid: Deno.pid, + processStart: "Mon Jan 1 00:00:00 2001", // not when this process started + ...INIT, + registeredAt: new Date().toISOString(), + }), + ); + assertEquals(await listRegisteredInstances(), []); + assertEquals([...Deno.readDirSync(dir)].length, 0); + }); +}); + +Deno.test("list: garbage, mismatched names and non-record files never throw and are not listed", unixOnly, async () => { + await withRegistry(async (dir) => { + await Deno.mkdir(dir, { recursive: true }); + await Deno.writeTextFile(join(dir, "123.json"), "{ not json"); + await Deno.writeTextFile(join(dir, "124.json"), JSON.stringify({ pid: 999, nope: true })); + await Deno.writeTextFile(join(dir, "notes.txt"), "keep me"); + assertEquals(await listRegisteredInstances(), []); + const left = [...Deno.readDirSync(dir)].map((e) => e.name); + assertEquals(left, ["notes.txt"], "unrelated files are left alone"); + }); +}); + +Deno.test("list: no registry directory yet is an empty list, not an error", async () => { + await withRegistry(async () => { + assertEquals(await listRegisteredInstances(), []); + }); +}); + +Deno.test("register: an unwritable registry location doesn't throw", unixOnly, async () => { + const prev = Deno.env.get("DUNE_REGISTRY_DIR"); + try { + // A path under a regular file can't become a directory. + const file = await Deno.makeTempFile(); + Deno.env.set("DUNE_REGISTRY_DIR", join(file, "instances")); + const handle = await registerInstance(INIT); + handle.unregister(); + await Deno.remove(file); + } finally { + if (prev === undefined) Deno.env.delete("DUNE_REGISTRY_DIR"); + else Deno.env.set("DUNE_REGISTRY_DIR", prev); + } +}); + +Deno.test("register: a process exiting normally leaves no record behind", unixOnly, async () => { + await withRegistry(async (dir) => { + const url = new URL("../../src/runtime/instance-registry.ts", import.meta.url).href; + const out = await new Deno.Command(Deno.execPath(), { + args: [ + "eval", + `import { registerInstance } from ${JSON.stringify(url)};` + + `await registerInstance({ mode: "dev", root: "/x", title: null, port: null, version: "0" });`, + ], + env: { DUNE_REGISTRY_DIR: dir }, + stdout: "null", + stderr: "piped", + }).output(); + assert(out.success, new TextDecoder().decode(out.stderr)); + assertEquals([...Deno.readDirSync(dir)].length, 0, "unload handler should have removed the record"); + }); +}); From 6f14bf30c8dbdd881e86a2bd15b0bf3020613be0 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Mon, 26 Oct 2026 17:30:32 +0100 Subject: [PATCH 06/12] 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 --- CHANGELOG.md | 24 +- README.md | 3 +- src/cli-impl.ts | 15 +- src/cli/args.ts | 4 +- src/cli/help.ts | 12 +- src/cli/instances.ts | 402 +++++++++++++++++++++++++++ src/cli/ps.ts | 594 ++-------------------------------------- src/cli/reexec.ts | 2 +- src/cli/stop.ts | 266 ++++++++++++++++++ tests/cli/ps_helpers.ts | 82 ++++++ tests/cli/ps_test.ts | 325 +++------------------- tests/cli/stop_test.ts | 328 ++++++++++++++++++++++ 12 files changed, 1174 insertions(+), 883 deletions(-) create mode 100644 src/cli/instances.ts create mode 100644 src/cli/stop.ts create mode 100644 tests/cli/ps_helpers.ts create mode 100644 tests/cli/stop_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 45d8a0e8..eb8b7371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,18 +32,18 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what ### Added -- **`dune ps --stop`** stops every instance it lists, with its child processes: - SIGTERM, a 10-second grace period for a real server to drain, then SIGKILL - for anything still alive, naming the pids that needed it. (The escalation - matters: a `dune serve` used to close its listener on the first SIGTERM and - then stay alive until sent a second one, so plain SIGTERM left exactly the - idle processes this command exists to clear.) It asks first; `--yes` skips - the prompt, and with no terminal it refuses unless `--yes` is given. It never - signals itself or its parent processes. -- **`dune ps --idle`** narrows the listing to instances that are running but - not listening (hung or leftover processes), and `dune ps --stop --idle` - stops only those, so live servers on a shared machine are left alone. An - instance whose port state is unknown (no `lsof`) is never assumed idle. +- **`dune stop`** stops dune instances, with their child processes. A target + is required, so stopping every server on a machine always has to be asked + for by name: `dune stop ` (any process of an instance), `dune stop + --port `, `dune stop --idle` (only instances that are running but not + listening — hung or leftover processes; an instance whose port state is + unknown, with no `lsof`, is never assumed idle) or `dune stop --all`. It + sends SIGTERM and waits up to 10 seconds for a server to drain; a process + that ignores it is reported, and only `--force` escalates to SIGKILL. It + asks first (`--yes` skips the prompt; with no terminal it refuses unless + `--yes` is given), never signals itself or its parent processes, and exits 2 + for a usage error, 1 when a named pid/port matches nothing or something would + not stop, and 0 otherwise. `dune ps` stays read-only and now points at it. ## [0.34.14] — 2026-10-21 diff --git a/README.md b/README.md index 22a3398b..d88398cd 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ This snapshots `deno.json` at install time rather than reading it live, so it go | `dune new [dir] --verify` | Same, but also boot the site for real and check it serves a request | | `dune dev` | Dev server with hot-reload (watches content + themes) | | `dune dev:link` | Reinstall the global shim against this checkout (local dev on Dune itself) | -| `dune ps` | List dune dev/serve instances running on this machine (`--stop` to stop them, `--idle` for only the hung ones) | +| `dune ps` | List dune dev/serve instances running on this machine | +| `dune stop` | Stop instances: ``, `--port `, `--idle` (only the hung/leftover ones) or `--all` (asks first; `--force` SIGKILLs what ignores SIGTERM) | | `dune doctor` | Environment/runtime health checks — Deno version, dependency resolution, lockfile staleness | | `dune doctor --boot` | Also boot the site for real and check it serves a request | | `dune build` | Build content index, validate config | diff --git a/src/cli-impl.ts b/src/cli-impl.ts index d5090f81..34fbb8f3 100644 --- a/src/cli-impl.ts +++ b/src/cli-impl.ts @@ -6,6 +6,7 @@ * dune dev — Start dev server with file watching * dune dev:link — Reinstall the global shim against this checkout * dune ps — List dune dev/serve instances running on this machine + * dune stop |--port|--idle|--all — Stop them (asks first) * dune build — Build content index + validate config * dune build --static — Generate a fully static site (SSG) * dune serve — Start production server @@ -49,6 +50,7 @@ import { devCommand } from "./cli/dev.ts"; import { devLinkCommand } from "./cli/dev-link.ts"; import { psCommand } from "./cli/ps.ts"; +import { stopCommand } from "./cli/stop.ts"; import { computeLockPolicy, parseRootArg } from "./cli/lock-policy.ts"; import { loadEnvFile, parseEnvFileArg } from "./cli/env-file.ts"; import { serveCommand } from "./cli/serve.ts"; @@ -194,10 +196,17 @@ export async function main(args: string[] = Deno.args) { break; case "ps": - await psCommand({ - stop: options.stop === true, - yes: options.yes === true, + await psCommand(); + break; + + case "stop": + await stopCommand({ + pid: typeof options.positional === "string" ? options.positional : undefined, + port: typeof options.port === "string" ? options.port : undefined, idle: options.idle === true, + all: options.all === true, + force: options.force === true, + yes: options.yes === true, }); break; diff --git a/src/cli/args.ts b/src/cli/args.ts index 77179787..51213155 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -103,10 +103,10 @@ export function parseCliOptions(args: string[]): ParsedCliArgs { options.integrity = args[++i]; } else if (args[i] === "--readonly") { options.readonly = true; - } else if (args[i] === "--stop") { - options.stop = true; } else if (args[i] === "--idle") { options.idle = true; + } else if (args[i] === "--all") { + options.all = true; } else if (args[i] === "--boot") { options.boot = true; } else if (args[i] === "--verify") { diff --git a/src/cli/help.ts b/src/cli/help.ts index ece66cf9..c04ccf59 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -22,10 +22,14 @@ Commands: ps List dune dev/serve instances currently running on this machine (PID, site, port or "not listening" — macOS/Linux only) - ps --stop Stop every instance found, with its child processes - (asks first; --yes to skip the prompt) - ps --idle Only the instances that are running but not listening - (hung or leftover); with --stop, stops just those + stop Stop a dune instance (and its child processes) — any + PID shown by \`dune ps\`. SIGTERM; asks first + stop --port Stop the instance listening on that port + stop --idle Stop only instances that are running but not listening + (hung or leftover) + stop --all Stop every instance \`dune ps\` lists + (--force SIGKILLs processes that ignore SIGTERM; + --yes skips the prompt) build Build content index and validate config build --static Generate a fully static site (SSG) serve Start production server (--frozen enforces deno.lock; diff --git a/src/cli/instances.ts b/src/cli/instances.ts new file mode 100644 index 00000000..820eac65 --- /dev/null +++ b/src/cli/instances.ts @@ -0,0 +1,402 @@ +/** + * Discovery of dune dev/serve instances running on this machine: what `dune ps` + * lists and `dune stop` acts on. + * + * Finds candidates by matching the process table against the invocation + * shapes dune servers actually run as: + * - the generated entrypoint: `deno run ... main.ts dev|serve` (what every + * `dune new` scaffold's `deno task dev`/`serve` runs, see + * `entrypoint-template.ts`'s `ENTRYPOINT_TASKS`); + * - the `dune` CLI itself: `deno run ... cli.ts dev|serve` (the global shim + * or a source checkout) and `jsr:@dune/core@x/cli serve`; + * - the `deno task dev|serve` wrapper around either of those. + * Each match is then confirmed as a real dune site by checking `config/site.yaml` + * at its root, and reported with the port(s) it is *actually* listening on — + * read from that process's own open sockets via `lsof`, never assumed from a + * fixed list of "common" ports. + * + * A server is usually several processes (a `deno task` wrapper, the re-exec + * child that applies the site's import map, esbuild workers). They are + * reported as ONE instance, keyed by the topmost matching process, and + * `dune stop` stops the whole tree. + * + * Site-root detection prefers an explicit `--root`, then the process's `PWD` + * environment variable, then its live cwd — `dev.ts` deliberately + * `Deno.chdir()`s to `@dune/core`'s own package root when running from a local + * checkout (for Fresh's esbuild import-map auto-detection), which would + * otherwise make a maintainer's own dev setup misreport as "not a dune site" + * (see `getPwdEnv()`'s doc comment). + * + * A process can match and still be listening on nothing — typically a server + * started on a port something else already held, which never exits. That is + * reported as "not listening", distinct from "port unknown" (no `lsof`). + * + * POSIX only (macOS/Linux) — both `ps` and `lsof` are used. Deliberately + * process-table-scanning rather than a self-registered PID/state file: + * that would need `dev.ts`/`serve.ts` to register on startup, so it could + * only ever find instances started after this feature shipped. Scanning + * works retroactively on anything already running. + * + * @module + */ + +import { isAbsolute, join, resolve } from "@std/path"; +import { parseUserYaml } from "../security/safe-yaml.ts"; + +/** A dune dev/serve-shaped process (tree) found running on this machine. */ +export interface DuneInstance { + /** The topmost matching process; what `dune stop ` and humans refer to. */ + pid: number; + /** `pid` plus every descendant (re-exec child, esbuild workers, ...). */ + pids: number[]; + mode: "dev" | "serve" | "unknown"; + /** + * Absolute path to the site root, or null when no candidate directory + * (`--root`, `PWD`, live cwd) held a `config/site.yaml` — still a real + * process worth reporting, just without a confirmed identity. + */ + root: string | null; + /** `config/site.yaml`'s `title`, or null if unset/unreadable/`root` is null. */ + title: string | null; + /** Ports this instance is actually listening on, ascending. */ + ports: number[]; + /** + * `"listening"`: at least one port. `"not-listening"`: `lsof` ran and found + * none — a hung or leftover process. `"unknown"`: `lsof` isn't available, + * so we can't tell. + */ + portStatus: "listening" | "not-listening" | "unknown"; + /** `ps`'s own elapsed-time string (e.g. "01:23:45" or "2-09:03:55"). */ + elapsed: string | null; + /** The process's command line, for identifying unconfirmed instances. */ + command: string; +} + +export async function runCommand( + cmd: string, + args: string[], + env?: Record, +): Promise { + try { + const command = new Deno.Command(cmd, { + args, + stdout: "piped", + stderr: "null", + env, + }); + const { code, stdout } = await command.output(); + if (code !== 0) return null; + return new TextDecoder().decode(stdout); + } catch { + // Binary not found, no permission, etc. — treat as "can't tell", not fatal. + return null; + } +} + + +// ── Process table ──────────────────────────────────────────────────────────── + +export interface ProcRow { + pid: number; + ppid: number; + elapsed: string; + command: string; +} + +/** + * Snapshot of the whole process table with parent links, so a server's + * wrapper/child/worker processes can be grouped without further spawns. + * + * `COLUMNS=4096` works around BSD `ps` (macOS) truncating the `command` + * column to terminal width by default — without it, a long `deno run -A + * --watch=main.ts main.ts dev` invocation from a deeply-nested path can get + * cut off before the trailing `dev`/`serve` token the match depends on. + */ +export async function readProcessTable(): Promise { + const out = await runCommand("ps", ["-eo", "pid=,ppid=,etime=,command="], { + COLUMNS: "4096", + }); + if (!out) return []; + const rows: ProcRow[] = []; + for (const line of out.split("\n")) { + const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/); + if (!m) continue; + rows.push({ pid: parseInt(m[1], 10), ppid: parseInt(m[2], 10), elapsed: m[3], command: m[4] }); + } + return rows; +} + +/** How a command line matched: `strong` is unambiguous, `weak` needs a confirmed site. */ +export interface CommandMatch { + mode: "dev" | "serve"; + strength: "strong" | "weak"; +} + +const DENO_EXE = /^(?:\S*\/)?deno\s/; + +/** + * Does this command line look like a dune dev/serve server? + * + * - `main.ts dev|serve` — the generated site entrypoint. Strong. + * - `cli.ts dev|serve` / `.../cli dev|serve` — the dune CLI run directly: the + * global shim, a source checkout, or `jsr:@dune/core/cli`. Strong when + * "dune" appears in the command (path, `@dune/core` specifier, + * `.dune-cli-config`); otherwise (a relative `src/cli.ts dev`) it could be + * any tool's CLI, so Weak. + * - `deno task dev|serve` — only a wrapper around one of the above, and + * generic enough to be any project's task, so Weak. + * + * A weak match counts only when its site root is confirmed (`config/site.yaml`). + */ +export function matchDuneCommand(command: string): CommandMatch | null { + if (!DENO_EXE.test(command)) return null; + + const entry = command.match(/\bmain\.ts\s+(dev|serve)\b/); + if (entry) return { mode: entry[1] as "dev" | "serve", strength: "strong" }; + + const cli = command.match(/(?:^|[\s/])cli(?:\.ts)?\s+(dev|serve)\b/); + if (cli) { + return { mode: cli[1] as "dev" | "serve", strength: /dune/i.test(command) ? "strong" : "weak" }; + } + + const task = command.match(/\bdeno\s+task\s+(dev|serve)\b/); + if (task) return { mode: task[1] as "dev" | "serve", strength: "weak" }; + + return null; +} + +/** `--root ` / `--root=` from a command line, or null. */ +export function parseRootArg(command: string): string | null { + const m = command.match(/--root(?:=|\s+)("[^"]+"|'[^']+'|\S+)/); + return m ? m[1].replace(/^["']|["']$/g, "") : null; +} + +/** All descendant pids of `pid`, from an in-memory process table. */ +function descendantsOf(pid: number, children: Map): number[] { + const out: number[] = []; + const seen = new Set([pid]); + const queue = [pid]; + while (queue.length > 0) { + for (const child of children.get(queue.shift()!) ?? []) { + if (seen.has(child)) continue; + seen.add(child); + out.push(child); + queue.push(child); + } + } + return out; +} + +// ── lsof ───────────────────────────────────────────────────────────────────── + +let lsofAvailableCache: boolean | undefined; + +/** Whether `lsof` can be spawned at all (its exit code is irrelevant: it exits 1 on "no matches"). */ +async function lsofAvailable(): Promise { + if (lsofAvailableCache !== undefined) return lsofAvailableCache; + try { + await new Deno.Command("lsof", { args: ["-v"], stdout: "null", stderr: "null" }).output(); + lsofAvailableCache = true; + } catch { + lsofAvailableCache = false; + } + return lsofAvailableCache; +} + +/** + * Ports any of `pids` is actually bound to and listening on (TCP), ascending. + * `-P` forces numeric port output — without it, `lsof` resolves a port to its + * `/etc/services` name when one happens to be registered (port 3000 prints as + * `hbci` on macOS, not `3000`), which would silently break the numeric parse + * below for exactly the port `dune new` defaults to. + */ +async function getListeningPorts(pids: number[]): Promise { + const out = await runCommand("lsof", [ + "-a", + "-p", + pids.join(","), + "-iTCP", + "-sTCP:LISTEN", + "-P", + "-Fn", + ]); + if (!out) return []; + const ports = new Set(); + for (const line of out.split("\n")) { + if (!line.startsWith("n")) continue; + const match = line.match(/:(\d+)$/); + if (match) ports.add(parseInt(match[1], 10)); + } + return [...ports].sort((a, b) => a - b); +} + + +/** + * Extract one env var's value from `ps eww`'s raw dump. There's no + * delimiter between the command and its environment in that output (both + * are one space-joined string), and some real values (e.g. `PATH`, on a + * machine with a space in some directory name) contain spaces themselves + * — so this is a heuristic, not a real parser: it stops at the next token + * that looks like `NAME=` (an all-caps/underscore key), which is right + * for the overwhelming majority of real values, `PWD` included. + */ +function extractEnvVar(psOutput: string, name: string): string | null { + const re = new RegExp(`\\s${name}=(.*?)(?:\\s[A-Z_][A-Z0-9_]*=|$)`); + const match = psOutput.match(re); + return match ? match[1] : null; +} + +/** + * The process's `PWD` environment variable, as captured at launch — a + * snapshot from `ps eww`, not a live query. Preferred over the live cwd + * (`getLiveCwd()`) as the site-root signal: `dev.ts` deliberately + * `Deno.chdir()`s to `@dune/core`'s own package root when running from a + * local `file://` checkout (so Fresh's esbuild import-map auto-detection + * finds dune's own `deno.json`) — a real, documented behavior, not a bug — + * which means the *live* cwd reports the wrong directory for exactly that + * case: a maintainer running dune from a local checkout against a real + * site, which is common on a dev machine that also develops dune itself. + * `PWD` isn't touched by anything the process does internally afterward, + * so it survives that chdir. Null when the process has no `PWD` at all + * (e.g. launched without a shell, by a process manager that doesn't + * export it) — `getLiveCwd()` is the fallback for that case. + */ +async function getPwdEnv(pid: number): Promise { + const envOut = await runCommand("ps", ["eww", "-p", String(pid), "-o", "command="]); + return envOut ? extractEnvVar(envOut, "PWD") : null; +} + +/** The process's actual, current (post-any-internal-chdir) working directory. */ +async function getLiveCwd(pid: number): Promise { + if (Deno.build.os === "linux") { + try { + return await Deno.readLink(`/proc/${pid}/cwd`); + } catch { + return null; + } + } + // macOS (and any other platform with lsof but no /proc). + const out = await runCommand("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]); + if (!out) return null; + for (const line of out.split("\n")) { + if (line.startsWith("n")) return line.slice(1); + } + return null; +} + + +/** `config/site.yaml`'s `title` field, or null if the file is missing/unreadable/has no title. */ +async function readSiteTitle(root: string): Promise { + try { + const text = await Deno.readTextFile(join(root, "config", "site.yaml")); + const parsed = parseUserYaml(text); + if (parsed && typeof parsed === "object" && "title" in parsed) { + const title = (parsed as Record).title; + if (typeof title === "string") return title; + } + return null; + } catch { + return null; + } +} + + +/** + * First directory among `dirs` that holds a `config/site.yaml`, or null. + * `--root` first (explicit), then `PWD` (right for the `Deno.chdir()` case + * `getPwdEnv()` describes), then the live cwd. + */ +async function confirmSiteRoot(dirs: (string | null)[]): Promise { + for (const candidate of dirs) { + if (!candidate) continue; + try { + await Deno.stat(join(candidate, "config", "site.yaml")); + return candidate; + } catch { /* try the next candidate */ } + } + return null; +} + +/** + * Find every dune dev/serve instance on this machine. + * + * A process-table match is reported unconditionally — even when no candidate + * directory holds a `config/site.yaml`, since that's still useful signal (PID, + * port, uptime, command) rather than a false negative — but `root`/`title` + * are null in that case, and the caller should say so plainly. + */ +export async function findDuneInstances(): Promise { + const table = await readProcessTable(); + const byPid = new Map(table.map((r) => [r.pid, r])); + const children = new Map(); + for (const r of table) children.set(r.ppid, [...(children.get(r.ppid) ?? []), r.pid]); + + // 1. Match command lines; resolve each match's site root. + interface Cand extends ProcRow { + match: CommandMatch; + root: string | null; + } + const cands: Cand[] = []; + for (const row of table) { + const match = matchDuneCommand(row.command); + if (!match) continue; + const [pwdRoot, liveCwd] = await Promise.all([getPwdEnv(row.pid), getLiveCwd(row.pid)]); + const rootArg = parseRootArg(row.command); + const base = pwdRoot ?? liveCwd; + const explicit = rootArg ? (isAbsolute(rootArg) ? rootArg : base ? resolve(base, rootArg) : null) : null; + const root = await confirmSiteRoot([explicit, pwdRoot, liveCwd]); + cands.push({ ...row, match, root }); + } + + // 2. A weak match (`deno task ...`) counts only for a confirmed dune site. + const kept = cands.filter((c) => c.match.strength === "strong" || c.root !== null); + const keptPids = new Set(kept.map((c) => c.pid)); + + // 3. One instance per process tree: drop any match that has a matching ancestor. + const hasKeptAncestor = (pid: number): boolean => { + let cur = byPid.get(pid)?.ppid; + for (let i = 0; cur && cur > 1 && i < 32; i++) { + if (keptPids.has(cur)) return true; + cur = byPid.get(cur)?.ppid; + } + return false; + }; + const tops = kept.filter((c) => !hasKeptAncestor(c.pid)); + + // 4. Build the instances. + const haveLsof = await lsofAvailable(); + const instances: DuneInstance[] = []; + for (const c of tops) { + const pids = [c.pid, ...descendantsOf(c.pid, children)]; + // A tree's site root can sit on any member (e.g. the `--root` is on the child). + let root = c.root; + if (!root) { + for (const pid of pids.slice(1)) { + const row = byPid.get(pid); + if (!row) continue; + const [pwd, cwd] = await Promise.all([getPwdEnv(pid), getLiveCwd(pid)]); + const arg = parseRootArg(row.command); + const base = pwd ?? cwd; + const explicit = arg ? (isAbsolute(arg) ? arg : base ? resolve(base, arg) : null) : null; + root = await confirmSiteRoot([explicit, pwd, cwd]); + if (root) break; + } + } + const [title, ports] = await Promise.all([ + root ? readSiteTitle(root) : Promise.resolve(null), + haveLsof ? getListeningPorts(pids) : Promise.resolve([] as number[]), + ]); + instances.push({ + pid: c.pid, + pids, + mode: c.match.mode, + root, + title, + ports, + portStatus: !haveLsof ? "unknown" : ports.length > 0 ? "listening" : "not-listening", + elapsed: c.elapsed, + command: c.command, + }); + } + return instances.sort((a, b) => a.pid - b.pid); +} diff --git a/src/cli/ps.ts b/src/cli/ps.ts index 79031271..e96b532a 100644 --- a/src/cli/ps.ts +++ b/src/cli/ps.ts @@ -1,528 +1,41 @@ /** - * dune ps — list dune dev/serve instances currently running on this machine, - * and (with `--stop`) stop them. + * dune ps — list dune dev/serve instances currently running on this machine. * - * Finds candidates by matching the process table against the invocation - * shapes dune servers actually run as: - * - the generated entrypoint: `deno run ... main.ts dev|serve` (what every - * `dune new` scaffold's `deno task dev`/`serve` runs, see - * `entrypoint-template.ts`'s `ENTRYPOINT_TASKS`); - * - the `dune` CLI itself: `deno run ... cli.ts dev|serve` (the global shim - * or a source checkout) and `jsr:@dune/core@x/cli serve`; - * - the `deno task dev|serve` wrapper around either of those. - * Each match is then confirmed as a real dune site by checking `config/site.yaml` - * at its root, and reported with the port(s) it is *actually* listening on — - * read from that process's own open sockets via `lsof`, never assumed from a - * fixed list of "common" ports. - * - * A server is usually several processes (a `deno task` wrapper, the re-exec - * child that applies the site's import map, esbuild workers). They are - * reported as ONE instance, keyed by the topmost matching process, and - * `--stop` stops the whole tree. - * - * Site-root detection prefers an explicit `--root`, then the process's `PWD` - * environment variable, then its live cwd — `dev.ts` deliberately - * `Deno.chdir()`s to `@dune/core`'s own package root when running from a local - * checkout (for Fresh's esbuild import-map auto-detection), which would - * otherwise make a maintainer's own dev setup misreport as "not a dune site" - * (see `getPwdEnv()`'s doc comment). - * - * A process can match and still be listening on nothing — typically a server - * started on a port something else already held, which never exits. That is - * reported as "not listening", distinct from "port unknown" (no `lsof`). - * - * POSIX only (macOS/Linux) — both `ps` and `lsof` are used. Deliberately - * process-table-scanning rather than a self-registered PID/state file: - * that would need `dev.ts`/`serve.ts` to register on startup, so it could - * only ever find instances started after this feature shipped. Scanning - * works retroactively on anything already running. + * Read-only: it never prompts and never signals anything. Stopping what it + * lists is `dune stop` (stop.ts). Discovery lives in instances.ts. * * @module */ -import { isAbsolute, join, resolve } from "@std/path"; -import { parseUserYaml } from "../security/safe-yaml.ts"; - -/** A dune dev/serve-shaped process (tree) found running on this machine. */ -export interface DuneInstance { - /** The topmost matching process; what `--stop` and humans refer to. */ - pid: number; - /** `pid` plus every descendant (re-exec child, esbuild workers, ...). */ - pids: number[]; - mode: "dev" | "serve" | "unknown"; - /** - * Absolute path to the site root, or null when no candidate directory - * (`--root`, `PWD`, live cwd) held a `config/site.yaml` — still a real - * process worth reporting, just without a confirmed identity. - */ - root: string | null; - /** `config/site.yaml`'s `title`, or null if unset/unreadable/`root` is null. */ - title: string | null; - /** Ports this instance is actually listening on, ascending. */ - ports: number[]; - /** - * `"listening"`: at least one port. `"not-listening"`: `lsof` ran and found - * none — a hung or leftover process. `"unknown"`: `lsof` isn't available, - * so we can't tell. - */ - portStatus: "listening" | "not-listening" | "unknown"; - /** `ps`'s own elapsed-time string (e.g. "01:23:45" or "2-09:03:55"). */ - elapsed: string | null; - /** The process's command line, for identifying unconfirmed instances. */ - command: string; -} - -async function runCommand( - cmd: string, - args: string[], - env?: Record, -): Promise { - try { - const command = new Deno.Command(cmd, { - args, - stdout: "piped", - stderr: "null", - env, - }); - const { code, stdout } = await command.output(); - if (code !== 0) return null; - return new TextDecoder().decode(stdout); - } catch { - // Binary not found, no permission, etc. — treat as "can't tell", not fatal. - return null; - } -} - - -// ── Process table ──────────────────────────────────────────────────────────── - -interface ProcRow { - pid: number; - ppid: number; - elapsed: string; - command: string; -} - -/** - * Snapshot of the whole process table with parent links, so a server's - * wrapper/child/worker processes can be grouped without further spawns. - * - * `COLUMNS=4096` works around BSD `ps` (macOS) truncating the `command` - * column to terminal width by default — without it, a long `deno run -A - * --watch=main.ts main.ts dev` invocation from a deeply-nested path can get - * cut off before the trailing `dev`/`serve` token the match depends on. - */ -async function readProcessTable(): Promise { - const out = await runCommand("ps", ["-eo", "pid=,ppid=,etime=,command="], { - COLUMNS: "4096", - }); - if (!out) return []; - const rows: ProcRow[] = []; - for (const line of out.split("\n")) { - const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/); - if (!m) continue; - rows.push({ pid: parseInt(m[1], 10), ppid: parseInt(m[2], 10), elapsed: m[3], command: m[4] }); - } - return rows; -} - -/** How a command line matched: `strong` is unambiguous, `weak` needs a confirmed site. */ -export interface CommandMatch { - mode: "dev" | "serve"; - strength: "strong" | "weak"; -} - -const DENO_EXE = /^(?:\S*\/)?deno\s/; - -/** - * Does this command line look like a dune dev/serve server? - * - * - `main.ts dev|serve` — the generated site entrypoint. Strong. - * - `cli.ts dev|serve` / `.../cli dev|serve` — the dune CLI run directly: the - * global shim, a source checkout, or `jsr:@dune/core/cli`. Strong when - * "dune" appears in the command (path, `@dune/core` specifier, - * `.dune-cli-config`); otherwise (a relative `src/cli.ts dev`) it could be - * any tool's CLI, so Weak. - * - `deno task dev|serve` — only a wrapper around one of the above, and - * generic enough to be any project's task, so Weak. - * - * A weak match counts only when its site root is confirmed (`config/site.yaml`). - */ -export function matchDuneCommand(command: string): CommandMatch | null { - if (!DENO_EXE.test(command)) return null; - - const entry = command.match(/\bmain\.ts\s+(dev|serve)\b/); - if (entry) return { mode: entry[1] as "dev" | "serve", strength: "strong" }; - - const cli = command.match(/(?:^|[\s/])cli(?:\.ts)?\s+(dev|serve)\b/); - if (cli) { - return { mode: cli[1] as "dev" | "serve", strength: /dune/i.test(command) ? "strong" : "weak" }; - } - - const task = command.match(/\bdeno\s+task\s+(dev|serve)\b/); - if (task) return { mode: task[1] as "dev" | "serve", strength: "weak" }; - - return null; -} - -/** `--root ` / `--root=` from a command line, or null. */ -export function parseRootArg(command: string): string | null { - const m = command.match(/--root(?:=|\s+)("[^"]+"|'[^']+'|\S+)/); - return m ? m[1].replace(/^["']|["']$/g, "") : null; -} - -/** All descendant pids of `pid`, from an in-memory process table. */ -function descendantsOf(pid: number, children: Map): number[] { - const out: number[] = []; - const seen = new Set([pid]); - const queue = [pid]; - while (queue.length > 0) { - for (const child of children.get(queue.shift()!) ?? []) { - if (seen.has(child)) continue; - seen.add(child); - out.push(child); - queue.push(child); - } - } - return out; -} - -// ── lsof ───────────────────────────────────────────────────────────────────── - -let lsofAvailableCache: boolean | undefined; - -/** Whether `lsof` can be spawned at all (its exit code is irrelevant: it exits 1 on "no matches"). */ -async function lsofAvailable(): Promise { - if (lsofAvailableCache !== undefined) return lsofAvailableCache; - try { - await new Deno.Command("lsof", { args: ["-v"], stdout: "null", stderr: "null" }).output(); - lsofAvailableCache = true; - } catch { - lsofAvailableCache = false; - } - return lsofAvailableCache; -} - -/** - * Ports any of `pids` is actually bound to and listening on (TCP), ascending. - * `-P` forces numeric port output — without it, `lsof` resolves a port to its - * `/etc/services` name when one happens to be registered (port 3000 prints as - * `hbci` on macOS, not `3000`), which would silently break the numeric parse - * below for exactly the port `dune new` defaults to. - */ -async function getListeningPorts(pids: number[]): Promise { - const out = await runCommand("lsof", [ - "-a", - "-p", - pids.join(","), - "-iTCP", - "-sTCP:LISTEN", - "-P", - "-Fn", - ]); - if (!out) return []; - const ports = new Set(); - for (const line of out.split("\n")) { - if (!line.startsWith("n")) continue; - const match = line.match(/:(\d+)$/); - if (match) ports.add(parseInt(match[1], 10)); - } - return [...ports].sort((a, b) => a - b); -} - - -/** - * Extract one env var's value from `ps eww`'s raw dump. There's no - * delimiter between the command and its environment in that output (both - * are one space-joined string), and some real values (e.g. `PATH`, on a - * machine with a space in some directory name) contain spaces themselves - * — so this is a heuristic, not a real parser: it stops at the next token - * that looks like `NAME=` (an all-caps/underscore key), which is right - * for the overwhelming majority of real values, `PWD` included. - */ -function extractEnvVar(psOutput: string, name: string): string | null { - const re = new RegExp(`\\s${name}=(.*?)(?:\\s[A-Z_][A-Z0-9_]*=|$)`); - const match = psOutput.match(re); - return match ? match[1] : null; -} - -/** - * The process's `PWD` environment variable, as captured at launch — a - * snapshot from `ps eww`, not a live query. Preferred over the live cwd - * (`getLiveCwd()`) as the site-root signal: `dev.ts` deliberately - * `Deno.chdir()`s to `@dune/core`'s own package root when running from a - * local `file://` checkout (so Fresh's esbuild import-map auto-detection - * finds dune's own `deno.json`) — a real, documented behavior, not a bug — - * which means the *live* cwd reports the wrong directory for exactly that - * case: a maintainer running dune from a local checkout against a real - * site, which is common on a dev machine that also develops dune itself. - * `PWD` isn't touched by anything the process does internally afterward, - * so it survives that chdir. Null when the process has no `PWD` at all - * (e.g. launched without a shell, by a process manager that doesn't - * export it) — `getLiveCwd()` is the fallback for that case. - */ -async function getPwdEnv(pid: number): Promise { - const envOut = await runCommand("ps", ["eww", "-p", String(pid), "-o", "command="]); - return envOut ? extractEnvVar(envOut, "PWD") : null; -} - -/** The process's actual, current (post-any-internal-chdir) working directory. */ -async function getLiveCwd(pid: number): Promise { - if (Deno.build.os === "linux") { - try { - return await Deno.readLink(`/proc/${pid}/cwd`); - } catch { - return null; - } - } - // macOS (and any other platform with lsof but no /proc). - const out = await runCommand("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]); - if (!out) return null; - for (const line of out.split("\n")) { - if (line.startsWith("n")) return line.slice(1); - } - return null; -} - - -/** `config/site.yaml`'s `title` field, or null if the file is missing/unreadable/has no title. */ -async function readSiteTitle(root: string): Promise { - try { - const text = await Deno.readTextFile(join(root, "config", "site.yaml")); - const parsed = parseUserYaml(text); - if (parsed && typeof parsed === "object" && "title" in parsed) { - const title = (parsed as Record).title; - if (typeof title === "string") return title; - } - return null; - } catch { - return null; - } -} - - -/** - * First directory among `dirs` that holds a `config/site.yaml`, or null. - * `--root` first (explicit), then `PWD` (right for the `Deno.chdir()` case - * `getPwdEnv()` describes), then the live cwd. - */ -async function confirmSiteRoot(dirs: (string | null)[]): Promise { - for (const candidate of dirs) { - if (!candidate) continue; - try { - await Deno.stat(join(candidate, "config", "site.yaml")); - return candidate; - } catch { /* try the next candidate */ } - } - return null; -} - -/** - * Find every dune dev/serve instance on this machine. - * - * A process-table match is reported unconditionally — even when no candidate - * directory holds a `config/site.yaml`, since that's still useful signal (PID, - * port, uptime, command) rather than a false negative — but `root`/`title` - * are null in that case, and the caller should say so plainly. - */ -export async function findDuneInstances(): Promise { - const table = await readProcessTable(); - const byPid = new Map(table.map((r) => [r.pid, r])); - const children = new Map(); - for (const r of table) children.set(r.ppid, [...(children.get(r.ppid) ?? []), r.pid]); - - // 1. Match command lines; resolve each match's site root. - interface Cand extends ProcRow { - match: CommandMatch; - root: string | null; - } - const cands: Cand[] = []; - for (const row of table) { - const match = matchDuneCommand(row.command); - if (!match) continue; - const [pwdRoot, liveCwd] = await Promise.all([getPwdEnv(row.pid), getLiveCwd(row.pid)]); - const rootArg = parseRootArg(row.command); - const base = pwdRoot ?? liveCwd; - const explicit = rootArg ? (isAbsolute(rootArg) ? rootArg : base ? resolve(base, rootArg) : null) : null; - const root = await confirmSiteRoot([explicit, pwdRoot, liveCwd]); - cands.push({ ...row, match, root }); - } - - // 2. A weak match (`deno task ...`) counts only for a confirmed dune site. - const kept = cands.filter((c) => c.match.strength === "strong" || c.root !== null); - const keptPids = new Set(kept.map((c) => c.pid)); - - // 3. One instance per process tree: drop any match that has a matching ancestor. - const hasKeptAncestor = (pid: number): boolean => { - let cur = byPid.get(pid)?.ppid; - for (let i = 0; cur && cur > 1 && i < 32; i++) { - if (keptPids.has(cur)) return true; - cur = byPid.get(cur)?.ppid; - } - return false; - }; - const tops = kept.filter((c) => !hasKeptAncestor(c.pid)); - - // 4. Build the instances. - const haveLsof = await lsofAvailable(); - const instances: DuneInstance[] = []; - for (const c of tops) { - const pids = [c.pid, ...descendantsOf(c.pid, children)]; - // A tree's site root can sit on any member (e.g. the `--root` is on the child). - let root = c.root; - if (!root) { - for (const pid of pids.slice(1)) { - const row = byPid.get(pid); - if (!row) continue; - const [pwd, cwd] = await Promise.all([getPwdEnv(pid), getLiveCwd(pid)]); - const arg = parseRootArg(row.command); - const base = pwd ?? cwd; - const explicit = arg ? (isAbsolute(arg) ? arg : base ? resolve(base, arg) : null) : null; - root = await confirmSiteRoot([explicit, pwd, cwd]); - if (root) break; - } - } - const [title, ports] = await Promise.all([ - root ? readSiteTitle(root) : Promise.resolve(null), - haveLsof ? getListeningPorts(pids) : Promise.resolve([] as number[]), - ]); - instances.push({ - pid: c.pid, - pids, - mode: c.match.mode, - root, - title, - ports, - portStatus: !haveLsof ? "unknown" : ports.length > 0 ? "listening" : "not-listening", - elapsed: c.elapsed, - command: c.command, - }); - } - return instances.sort((a, b) => a.pid - b.pid); -} - -// ── Stopping ───────────────────────────────────────────────────────────────── - -/** This process and its ancestors: never a stop target, whatever the caller passes. */ -async function selfAndAncestors(): Promise> { - const table = await readProcessTable(); - const byPid = new Map(table.map((r) => [r.pid, r])); - const protectedPids = new Set([Deno.pid]); - let cur = byPid.get(Deno.pid)?.ppid; - for (let i = 0; cur && cur > 1 && i < 64; i++) { - protectedPids.add(cur); - cur = byPid.get(cur)?.ppid; - } - return protectedPids; -} - -export interface StopResult { - /** Pids that were signalled and are gone (including those that needed SIGKILL). */ - stopped: number[]; - /** The subset of `stopped` that ignored SIGTERM for the whole grace period and was SIGKILLed. */ - killed: number[]; - /** Pids still alive even after SIGKILL (rare: uninterruptible sleep, or not ours to signal). */ - survivors: number[]; - /** Pids deliberately not signalled (this process and its ancestors). */ - skipped: number[]; -} - -async function alivePids(pids: number[]): Promise { - if (pids.length === 0) return []; - const out = await runCommand("ps", ["-o", "pid=", "-p", pids.join(",")]); - if (!out) return []; - const alive = new Set(out.split("\n").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n))); - return pids.filter((p) => alive.has(p)); -} - -async function waitUntilGone(pids: number[], timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - let remaining = await alivePids(pids); - while (remaining.length > 0 && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 150)); - remaining = await alivePids(remaining); - } - return remaining; -} - -function signalAll(pids: number[], signal: Deno.Signal): void { - for (const pid of pids) { - try { - Deno.kill(pid, signal); - } catch { /* already gone */ } - } -} - -/** - * Stop whole instance trees: SIGTERM, wait up to `timeoutMs` for a graceful - * exit, then SIGKILL whatever is still alive. - * - * The escalation exists because a process can ignore SIGTERM indefinitely: a - * `dune serve` (before the explicit exit after shutdown) closed its listener - * on the first SIGTERM and then stayed alive until sent a second one, so - * "SIGTERM and report survivors" left exactly the idle processes this command - * exists to clear. The grace period is for real servers draining in-flight - * requests; `killed` reports who needed the SIGKILL so it is never silent. - * - * Never signals this process or its ancestors (so a `dune ps --stop` run from - * inside a shell/agent can't take out its own parents). - */ -export async function stopInstances( - instances: DuneInstance[], - opts: { timeoutMs?: number; killTimeoutMs?: number } = {}, -): Promise { - const timeoutMs = opts.timeoutMs ?? 10_000; - const killTimeoutMs = opts.killTimeoutMs ?? 2_000; - const protectedPids = await selfAndAncestors(); - const targets = [...new Set(instances.flatMap((i) => i.pids))]; - const skipped = targets.filter((p) => protectedPids.has(p)); - const toSignal = targets.filter((p) => !protectedPids.has(p)); - - signalAll(toSignal, "SIGTERM"); - const afterTerm = await waitUntilGone(toSignal, timeoutMs); - - let survivors: number[] = []; - if (afterTerm.length > 0) { - signalAll(afterTerm, "SIGKILL"); - survivors = await waitUntilGone(afterTerm, killTimeoutMs); - } - const killed = afterTerm.filter((p) => !survivors.includes(p)); - const gone = new Set(toSignal.filter((p) => !survivors.includes(p))); - return { stopped: toSignal.filter((p) => gone.has(p)), killed, survivors, skipped }; -} - -// ── Command ────────────────────────────────────────────────────────────────── - -export interface PsOptions { - /** Stop every instance shown (asks first unless `yes`). */ - stop?: boolean; - /** Skip the confirmation prompt for `stop`. */ - yes?: boolean; - /** - * Only consider instances that are running but "not listening" — hung or - * leftover processes. Narrows both the listing and `stop`, so live servers - * are left alone. Instances whose port state is unknown (no `lsof`) are not - * assumed idle. - */ - idle?: boolean; -} +import { type DuneInstance, findDuneInstances } from "./instances.ts"; -/** Injection points so the command's flow is testable without touching real processes. */ +/** Injection points so the command's output is testable without touching real processes. */ export interface PsDeps { find?: () => Promise; - stop?: typeof stopInstances; - isInteractive?: () => boolean; - confirm?: (message: string) => Promise; log?: (line: string) => void; } -function describePort(inst: DuneInstance): string { +export function describePort(inst: DuneInstance): string { if (inst.portStatus === "listening") return inst.ports.map((p) => `http://localhost:${p}`).join(", "); if (inst.portStatus === "not-listening") return "not listening (no open port; likely a hung or leftover process)"; return "port unknown (lsof not available)"; } -export async function psCommand(opts: PsOptions = {}, deps: PsDeps = {}): Promise { +/** The lines `dune ps` prints for one instance (shared with `dune stop`, which shows what it is about to stop). */ +export function formatInstance(inst: DuneInstance): string[] { + const extra = inst.pids.length > 1 + ? ` (+${inst.pids.length - 1} child process${inst.pids.length > 2 ? "es" : ""})` + : ""; + return [ + ` PID ${inst.pid} ${inst.mode.padEnd(5)} up ${inst.elapsed ?? "?"}${extra}`, + ` ${inst.title ?? "(untitled)"}`, + ` ${inst.root ?? "(site root unconfirmed — no config/site.yaml via --root, PWD or live cwd)"}`, + ` ${describePort(inst)}`, + ` $ ${inst.command.length > 110 ? inst.command.slice(0, 107) + "..." : inst.command}\n`, + ]; +} + +export async function psCommand(deps: PsDeps = {}): Promise { const log = deps.log ?? ((l: string) => console.log(l)); if (Deno.build.os === "windows") { log(" ⚠ `dune ps` needs `ps`/`lsof` and isn't supported on Windows yet."); @@ -537,67 +50,12 @@ export async function psCommand(opts: PsOptions = {}, deps: PsDeps = {}): Promis return; } + for (const inst of all) for (const line of formatInstance(inst)) log(line); + const serving = all.filter((i) => i.portStatus === "listening").length; const idle = all.filter((i) => i.portStatus === "not-listening").length; - const unknown = all.length - serving - idle; - - if (opts.idle && unknown > 0 && idle === 0) { - log(" Can't tell which instances are idle: `lsof` isn't available, so no instance can be shown as not listening."); - if (opts.stop) Deno.exitCode = 1; - return; - } - const instances = opts.idle ? all.filter((i) => i.portStatus === "not-listening") : all; - if (instances.length === 0) { - log(` No idle instances: all ${all.length} running instance${all.length === 1 ? " is" : "s are"} listening.`); - return; - } - - for (const inst of instances) { - log(` PID ${inst.pid} ${inst.mode.padEnd(5)} up ${inst.elapsed ?? "?"}${inst.pids.length > 1 ? ` (+${inst.pids.length - 1} child process${inst.pids.length > 2 ? "es" : ""})` : ""}`); - log(` ${inst.title ?? "(untitled)"}`); - log(` ${inst.root ?? "(site root unconfirmed — no config/site.yaml via --root, PWD or live cwd)"}`); - log(` ${describePort(inst)}`); - log(` $ ${inst.command.length > 110 ? inst.command.slice(0, 107) + "..." : inst.command}\n`); - } - if (opts.idle) { - log(` ${instances.length} idle instance${instances.length === 1 ? "" : "s"} shown; ${serving} listening instance${serving === 1 ? "" : "s"} left out\n`); - } else { - log(` ${all.length} instance${all.length === 1 ? "" : "s"}: ${serving} listening, ${idle} not listening\n`); - } - - if (!opts.stop) { - if (idle > 0 && !opts.idle) { - log(" `dune ps --stop --idle` stops only the not-listening ones; `dune ps --stop` stops everything listed."); - } - return; - } - - const procCount = new Set(instances.flatMap((i) => i.pids)).size; - const question = `Stop ${instances.length} ${opts.idle ? "idle " : ""}instance${instances.length === 1 ? "" : "s"} (${procCount} process${procCount === 1 ? "" : "es"})?`; - if (!opts.yes) { - const interactive = (deps.isInteractive ?? (() => Deno.stdin.isTerminal()))(); - if (!interactive) { - log(` Not stopping: ${question} needs confirmation, and this isn't a terminal. Re-run with --yes.`); - Deno.exitCode = 1; - return; - } - const ok = await (deps.confirm ?? ((m) => Promise.resolve(globalThis.confirm(m))))(question); - if (!ok) { - log(" Cancelled; nothing was stopped."); - return; - } - } - - const result = await (deps.stop ?? stopInstances)(instances); - log(` Stopped ${result.stopped.length} process${result.stopped.length === 1 ? "" : "es"}.`); - if (result.skipped.length > 0) { - log(` Left alone (this command's own process and its parents): ${result.skipped.join(", ")}`); - } - if (result.killed.length > 0) { - log(` ${result.killed.length} did not exit on SIGTERM and were killed (SIGKILL): ${result.killed.join(", ")}`); - } - if (result.survivors.length > 0) { - log(` Could not stop (still running even after SIGKILL): ${result.survivors.join(", ")}`); - Deno.exitCode = 1; + log(` ${all.length} instance${all.length === 1 ? "" : "s"}: ${serving} listening, ${idle} not listening\n`); + if (idle > 0) { + log(" `dune stop --idle` stops only the not-listening ones; `dune stop ` stops one instance."); } } diff --git a/src/cli/reexec.ts b/src/cli/reexec.ts index 65f2e963..93d6607c 100644 --- a/src/cli/reexec.ts +++ b/src/cli/reexec.ts @@ -39,7 +39,7 @@ export async function maybeReexecWithSiteConfig( if ( !Deno.env.get("DUNE_CONFIG_APPLIED") && command !== "new" && command !== "lockfile:check" && command !== "lockfile:sync" && - command !== "ps" && + command !== "ps" && command !== "stop" && !import.meta.url.startsWith("file://") ) { // We're about to lock into running the published @dune/core package for diff --git a/src/cli/stop.ts b/src/cli/stop.ts new file mode 100644 index 00000000..4107df00 --- /dev/null +++ b/src/cli/stop.ts @@ -0,0 +1,266 @@ +/** + * dune stop — stop dune dev/serve instances found by `dune ps`. + * + * A target is required: a PID (any process of an instance), `--port`, `--idle` + * (only instances that are running but not listening) or `--all`. A bare + * `dune stop` does nothing but print usage, so stopping every server on a + * machine always has to be asked for by name. + * + * SIGTERM first, so a server can drain. A process that ignores it is reported, + * and only `--force` escalates to SIGKILL (which can orphan a wrapper's child, + * so it is not the default). Whole process trees are stopped, and this process + * and its ancestors are never signalled. + * + * @module + */ + +import { type DuneInstance, findDuneInstances, readProcessTable, runCommand } from "./instances.ts"; +import { formatInstance } from "./ps.ts"; + +// ── Signalling ─────────────────────────────────────────────────────────────── + +/** This process and its ancestors: never a stop target, whatever the caller passes. */ +async function selfAndAncestors(): Promise> { + const table = await readProcessTable(); + const byPid = new Map(table.map((r) => [r.pid, r])); + const protectedPids = new Set([Deno.pid]); + let cur = byPid.get(Deno.pid)?.ppid; + for (let i = 0; cur && cur > 1 && i < 64; i++) { + protectedPids.add(cur); + cur = byPid.get(cur)?.ppid; + } + return protectedPids; +} + +export interface StopResult { + /** Pids that were signalled and are gone (including those that needed SIGKILL). */ + stopped: number[]; + /** The subset of `stopped` that ignored SIGTERM for the whole grace period and was SIGKILLed (`force` only). */ + killed: number[]; + /** Pids still alive: they ignored SIGTERM (without `force`), or survived SIGKILL too. */ + survivors: number[]; + /** Pids deliberately not signalled (this process and its ancestors). */ + skipped: number[]; +} + +async function alivePids(pids: number[]): Promise { + if (pids.length === 0) return []; + const out = await runCommand("ps", ["-o", "pid=", "-p", pids.join(",")]); + if (!out) return []; + const alive = new Set(out.split("\n").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n))); + return pids.filter((p) => alive.has(p)); +} + +async function waitUntilGone(pids: number[], timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let remaining = await alivePids(pids); + while (remaining.length > 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 150)); + remaining = await alivePids(remaining); + } + return remaining; +} + +function signalAll(pids: number[], signal: Deno.Signal): void { + for (const pid of pids) { + try { + Deno.kill(pid, signal); + } catch { /* already gone */ } + } +} + +/** + * Stop whole instance trees: SIGTERM, then wait up to `timeoutMs` for a + * graceful exit. With `force`, whatever is still alive after that gets SIGKILL + * (reported in `killed`); without it, those pids are returned as `survivors`. + */ +export async function stopInstances( + instances: DuneInstance[], + opts: { force?: boolean; timeoutMs?: number; killTimeoutMs?: number } = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? 10_000; + const killTimeoutMs = opts.killTimeoutMs ?? 2_000; + const protectedPids = await selfAndAncestors(); + const targets = [...new Set(instances.flatMap((i) => i.pids))]; + const skipped = targets.filter((p) => protectedPids.has(p)); + const toSignal = targets.filter((p) => !protectedPids.has(p)); + + signalAll(toSignal, "SIGTERM"); + const afterTerm = await waitUntilGone(toSignal, timeoutMs); + + let survivors = afterTerm; + let killed: number[] = []; + if (opts.force && afterTerm.length > 0) { + signalAll(afterTerm, "SIGKILL"); + survivors = await waitUntilGone(afterTerm, killTimeoutMs); + killed = afterTerm.filter((p) => !survivors.includes(p)); + } + const gone = new Set(toSignal.filter((p) => !survivors.includes(p))); + return { stopped: toSignal.filter((p) => gone.has(p)), killed, survivors, skipped }; +} + +// ── Choosing what to stop ──────────────────────────────────────────────────── + +export interface StopOptions { + /** A PID: matches an instance by its topmost process or any of its children. */ + pid?: string; + /** The instance listening on this port. */ + port?: string; + /** Only instances that are running but not listening (hung or leftover). */ + idle?: boolean; + /** Every instance `dune ps` lists. */ + all?: boolean; + /** Escalate to SIGKILL for processes that ignore SIGTERM. */ + force?: boolean; + /** Skip the confirmation prompt. */ + yes?: boolean; +} + +export const STOP_USAGE = `Usage: dune stop | --port | --idle | --all [--force] [--yes] + + the instance this process belongs to (see \`dune ps\`) + --port the instance listening on that port + --idle only instances that are running but not listening + --all every instance \`dune ps\` lists + --force SIGKILL processes that ignore SIGTERM (default: report them) + --yes, -y don't ask for confirmation`; + +export type Selection = + | { ok: true; instances: DuneInstance[]; label: string } + | { ok: false; message: string; exitCode: number }; + +function parseNumber(value: string, what: string, max: number): number | string { + const n = Number(value); + return Number.isInteger(n) && n > 0 && n <= max ? n : `"${value}" is not a valid ${what}.`; +} + +/** Pure: pick the instances `opts` asks for out of `all`, or explain why none. */ +export function selectInstances(all: DuneInstance[], opts: StopOptions): Selection { + const chosen = [opts.pid !== undefined, opts.port !== undefined, opts.idle === true, opts.all === true] + .filter(Boolean).length; + if (chosen === 0) return { ok: false, message: STOP_USAGE, exitCode: 2 }; + if (chosen > 1) { + return { ok: false, message: `Choose one target: a pid, --port, --idle or --all.\n\n${STOP_USAGE}`, exitCode: 2 }; + } + + if (opts.pid !== undefined) { + const pid = parseNumber(opts.pid, "pid", 4_194_304); + if (typeof pid === "string") return { ok: false, message: pid, exitCode: 2 }; + const found = all.filter((i) => i.pids.includes(pid)); + if (found.length === 0) { + return { ok: false, message: `No dune instance has a process with pid ${pid}. \`dune ps\` lists them.`, exitCode: 1 }; + } + return { ok: true, instances: found, label: "" }; + } + + if (opts.port !== undefined) { + const port = parseNumber(opts.port, "port", 65_535); + if (typeof port === "string") return { ok: false, message: port, exitCode: 2 }; + const found = all.filter((i) => i.ports.includes(port)); + if (found.length === 0) { + return { ok: false, message: `No dune instance is listening on port ${port}. \`dune ps\` lists them.`, exitCode: 1 }; + } + return { ok: true, instances: found, label: "" }; + } + + if (opts.idle) { + const idle = all.filter((i) => i.portStatus === "not-listening"); + const unknown = all.filter((i) => i.portStatus === "unknown").length; + if (idle.length === 0 && unknown > 0) { + return { + ok: false, + message: "Can't tell which instances are idle: `lsof` isn't available, so no instance can be shown as not listening.", + exitCode: 1, + }; + } + if (idle.length === 0) { + return { + ok: false, + message: `No idle instances: all ${all.length} running instance${all.length === 1 ? " is" : "s are"} listening.`, + exitCode: 0, + }; + } + return { ok: true, instances: idle, label: "idle " }; + } + + return { ok: true, instances: all, label: "" }; +} + +// ── Command ────────────────────────────────────────────────────────────────── + +/** Injection points so the command's flow is testable without touching real processes. */ +export interface StopDeps { + find?: () => Promise; + stop?: typeof stopInstances; + isInteractive?: () => boolean; + confirm?: (message: string) => Promise; + log?: (line: string) => void; +} + +export async function stopCommand(opts: StopOptions = {}, deps: StopDeps = {}): Promise { + const log = deps.log ?? ((l: string) => console.log(l)); + if (Deno.build.os === "windows") { + log(" ⚠ `dune stop` needs `ps`/`lsof` and isn't supported on Windows yet."); + return; + } + + // Validate the request before scanning anything. + const preliminary = selectInstances([], opts); + if (!preliminary.ok && preliminary.exitCode === 2) { + log(preliminary.message); + Deno.exitCode = 2; + return; + } + + const all = await (deps.find ?? findDuneInstances)(); + if (all.length === 0) { + log(" No dune dev/serve instances found running locally."); + // Naming a pid or port that isn't there is a failure; --idle/--all with nothing to do is not. + if (opts.pid !== undefined || opts.port !== undefined) Deno.exitCode = 1; + return; + } + + const selection = selectInstances(all, opts); + if (!selection.ok) { + log(` ${selection.message}`); + Deno.exitCode = selection.exitCode; + return; + } + + const { instances, label } = selection; + log(""); + for (const inst of instances) for (const line of formatInstance(inst)) log(line); + + const procCount = new Set(instances.flatMap((i) => i.pids)).size; + const question = `Stop ${instances.length} ${label}instance${instances.length === 1 ? "" : "s"} (${procCount} process${procCount === 1 ? "" : "es"})?`; + if (!opts.yes) { + const interactive = (deps.isInteractive ?? (() => Deno.stdin.isTerminal()))(); + if (!interactive) { + log(` Not stopping: ${question} needs confirmation, and this isn't a terminal. Re-run with --yes.`); + Deno.exitCode = 1; + return; + } + const ok = await (deps.confirm ?? ((m) => Promise.resolve(globalThis.confirm(m))))(question); + if (!ok) { + log(" Cancelled; nothing was stopped."); + return; + } + } + + const result = await (deps.stop ?? stopInstances)(instances, { force: opts.force === true }); + log(` Stopped ${result.stopped.length} process${result.stopped.length === 1 ? "" : "es"}.`); + if (result.skipped.length > 0) { + log(` Left alone (this command's own process and its parents): ${result.skipped.join(", ")}`); + } + if (result.killed.length > 0) { + log(` ${result.killed.length} did not exit on SIGTERM and were killed (SIGKILL): ${result.killed.join(", ")}`); + } + if (result.survivors.length > 0) { + if (opts.force) { + log(` Could not stop (still running even after SIGKILL): ${result.survivors.join(", ")}`); + } else { + log(` Still running (ignored SIGTERM): ${result.survivors.join(", ")}\n Re-run with --force to SIGKILL them.`); + } + Deno.exitCode = 1; + } +} diff --git a/tests/cli/ps_helpers.ts b/tests/cli/ps_helpers.ts new file mode 100644 index 00000000..11052e6c --- /dev/null +++ b/tests/cli/ps_helpers.ts @@ -0,0 +1,82 @@ +/** + * Shared fixtures for the `dune ps` / `dune stop` tests: small real processes + * that look like dune servers to the discovery logic (a trivial `main.ts` + * beside a real `config/site.yaml`), plus a fake instance for flow tests. + * Not a test file itself. + */ + +import { join } from "@std/path"; +import type { DuneInstance } from "../../src/cli/instances.ts"; + +export const opts = { sanitizeOps: false, sanitizeResources: false }; + +const BOOT_TIMEOUT_MS = 10_000; +const POLL_INTERVAL_MS = 100; + +/** Poll until `port` accepts a connection, or give up at the timeout. */ +export async function waitForPort(port: number): Promise { + const deadline = Date.now() + BOOT_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(500) }); + return; + } catch { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + } + } + throw new Error(`nothing answered on port ${port} within ${BOOT_TIMEOUT_MS}ms`); +} + +export async function stop(child: Deno.ChildProcess): Promise { + try { + child.kill("SIGTERM"); + } catch { + // already exited + } + await child.status.catch(() => {}); +} + +export const SERVER = (port: number) => `Deno.serve({ port: ${port}, onListen: () => {} }, () => new Response("ok"));\n`; + +export async function makeSite(prefix: string, title: string): Promise { + const dir = await Deno.makeTempDir({ prefix }); + await Deno.mkdir(join(dir, "config"), { recursive: true }); + await Deno.writeTextFile(join(dir, "config", "site.yaml"), `title: ${title}\n`); + return dir; +} + +export function spawnDeno(args: string[], cwd: string): Deno.ChildProcess { + return new Deno.Command(Deno.execPath(), { + args, + cwd, + env: { ...Deno.env.toObject(), PWD: cwd }, + stdout: "null", + stderr: "null", + }).spawn(); +} + +/** A `deno task serve` wrapper around `deno run -A main.ts serve`: two processes, one instance. */ +export async function startTaskTree(port: number): Promise<{ site: string; child: Deno.ChildProcess }> { + const site = await makeSite("dune_test_ps_tree_", "Tree Site"); + await Deno.writeTextFile(join(site, "deno.json"), JSON.stringify({ tasks: { serve: "deno run -A main.ts serve" } })); + await Deno.writeTextFile(join(site, "main.ts"), SERVER(port) + `await new Promise(() => {});\n`); + const child = spawnDeno(["task", "serve"], site); + await waitForPort(port); + return { site, child }; +} + +export function fakeInstance(overrides: Partial = {}): DuneInstance { + return { + pid: 99_990, + pids: [99_990], + mode: "serve", + root: "/x/site", + title: "Fake", + ports: [], + portStatus: "not-listening", + elapsed: "01:00", + command: "deno run -A main.ts serve", + ...overrides, + }; +} + diff --git a/tests/cli/ps_test.ts b/tests/cli/ps_test.ts index 9a920739..f9b09bcc 100644 --- a/tests/cli/ps_test.ts +++ b/tests/cli/ps_test.ts @@ -1,14 +1,13 @@ /** - * Tests for `dune ps` (src/cli/ps.ts) — finding dune dev/serve instances - * already running on the machine. + * Tests for `dune ps` discovery (src/cli/instances.ts) and listing + * (src/cli/ps.ts). * * Deliberately doesn't spawn a real `dune dev`/`main.ts` process (that's * `live_boot_smoke_test.ts`'s job, and it's slow/needs the full bootstrap * stack) — these use a trivial fake entrypoint that only needs to *look* - * like the real thing to `dune ps`'s own detection logic: a `main.ts` - * invoked with `dev`/`serve` as the trailing arg, sitting next to a real - * `config/site.yaml`, optionally binding a real port. That's the actual - * surface `findDuneInstances()` operates on. + * like the real thing to the detection logic: a `main.ts` invoked with + * `dev`/`serve` as the trailing arg, sitting next to a real + * `config/site.yaml`, optionally binding a real port. */ import { assertEquals, assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; @@ -18,16 +17,13 @@ import { findDuneInstances, matchDuneCommand, parseRootArg, - psCommand, - stopInstances, -} from "../../src/cli/ps.ts"; - -const BOOT_TIMEOUT_MS = 10_000; -const POLL_INTERVAL_MS = 100; +} from "../../src/cli/instances.ts"; +import { psCommand } from "../../src/cli/ps.ts"; +import { fakeInstance, makeSite, opts, SERVER, spawnDeno, startTaskTree, stop, waitForPort } from "./ps_helpers.ts"; /** * On macOS, /tmp is a symlink to /private/tmp — Deno.makeTempDir() returns - * the unresolved path, but the site root ps.ts reports back (via PWD/lsof, + * the unresolved path, but the site root discovery reports back (via PWD/lsof, * both reflecting the kernel's resolved path) is fully resolved. Compare * both sides post-realpath so this symlink hop doesn't look like a bug. */ @@ -35,29 +31,6 @@ async function realpath(path: string): Promise { return await Deno.realPath(path); } -/** Poll until `port` accepts a connection, or give up at the timeout. */ -async function waitForPort(port: number): Promise { - const deadline = Date.now() + BOOT_TIMEOUT_MS; - while (Date.now() < deadline) { - try { - await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(500) }); - return; - } catch { - await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); - } - } - throw new Error(`nothing answered on port ${port} within ${BOOT_TIMEOUT_MS}ms`); -} - -async function stop(child: Deno.ChildProcess): Promise { - try { - child.kill("SIGTERM"); - } catch { - // already exited - } - await child.status.catch(() => {}); -} - Deno.test( "findDuneInstances: finds a dev-shaped process via its live cwd, reads title and port", { sanitizeOps: false, sanitizeResources: false }, @@ -280,29 +253,6 @@ Deno.test("parseRootArg: --root in both spellings, quoted or not", () => { assertEquals(parseRootArg("deno run cli.ts dev --port 3000"), null); }); -// ── fixtures for the new real-process tests ────────────────────────────────── - -const SERVER = (port: number) => `Deno.serve({ port: ${port}, onListen: () => {} }, () => new Response("ok"));\n`; - -async function makeSite(prefix: string, title: string): Promise { - const dir = await Deno.makeTempDir({ prefix }); - await Deno.mkdir(join(dir, "config"), { recursive: true }); - await Deno.writeTextFile(join(dir, "config", "site.yaml"), `title: ${title}\n`); - return dir; -} - -function spawnDeno(args: string[], cwd: string): Deno.ChildProcess { - return new Deno.Command(Deno.execPath(), { - args, - cwd, - env: { ...Deno.env.toObject(), PWD: cwd }, - stdout: "null", - stderr: "null", - }).spawn(); -} - -const opts = { sanitizeOps: false, sanitizeResources: false }; - Deno.test("findDuneInstances: the dune CLI run directly (`cli.ts dev --root `) is found, root from --root", opts, async () => { // "dune" must appear in the command line for a bare `cli.ts dev` to count. const toolDir = await Deno.makeTempDir({ prefix: "dune_test_ps_shim_tool_" }); @@ -372,16 +322,6 @@ Deno.test("findDuneInstances: a process that matches but holds no port is 'not-l } }); -/** A `deno task serve` wrapper around `deno run -A main.ts serve`: two processes, one instance. */ -async function startTaskTree(port: number): Promise<{ site: string; child: Deno.ChildProcess }> { - const site = await makeSite("dune_test_ps_tree_", "Tree Site"); - await Deno.writeTextFile(join(site, "deno.json"), JSON.stringify({ tasks: { serve: "deno run -A main.ts serve" } })); - await Deno.writeTextFile(join(site, "main.ts"), SERVER(port) + `await new Promise(() => {});\n`); - const child = spawnDeno(["task", "serve"], site); - await waitForPort(port); - return { site, child }; -} - Deno.test("findDuneInstances: a `deno task` wrapper and its child are ONE instance, reported at the wrapper", opts, async () => { const port = 18_913; const { site, child } = await startTaskTree(port); @@ -402,134 +342,38 @@ Deno.test("findDuneInstances: a `deno task` wrapper and its child are ONE instan } }); -Deno.test("stopInstances: stops the whole tree and reports nothing left", opts, async () => { - const port = 18_914; - const { site, child } = await startTaskTree(port); - try { - const found = (await findDuneInstances()).find((i) => i.pids.includes(child.pid)); - assertExists(found); - const result = await stopInstances([found], { timeoutMs: 8000 }); - assertEquals(result.survivors, []); - assertEquals(result.skipped, []); - assertEquals(result.killed, [], "a process that exits on SIGTERM must not be SIGKILLed"); - assertEquals(result.stopped.sort(), [...found.pids].sort()); - // Really gone: nothing answers any more. - let answered = true; - try { - await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(800) }); - } catch { - answered = false; - } - assertEquals(answered, false); - } finally { - await stop(child); - await Deno.remove(site, { recursive: true }).catch(() => {}); - } -}); - -Deno.test("stopInstances: a process that ignores SIGTERM is escalated to SIGKILL, and reported as killed", opts, async () => { - const dir = await Deno.makeTempDir({ prefix: "ps_test_stubborn_" }); +Deno.test("findDuneInstances: a bare `cli.ts serve` with NO site anywhere is not reported (weak matches need a confirmed site)", opts, async () => { + const dir = await Deno.makeTempDir({ prefix: "ps_test_nosite_" }); // no "dune" in the command, no config/site.yaml let child: Deno.ChildProcess | undefined; try { - // Swallows SIGTERM, the way a server stuck in its own shutdown does. - await Deno.writeTextFile( - join(dir, "stubborn.ts"), - `Deno.addSignalListener("SIGTERM", () => {});\nconsole.log("ready");\nsetInterval(() => {}, 1000);\n`, - ); - child = new Deno.Command(Deno.execPath(), { - args: ["run", "-A", "stubborn.ts"], - cwd: dir, - stdout: "piped", - stderr: "null", - }).spawn(); + await Deno.writeTextFile(join(dir, "cli.ts"), `setInterval(() => {}, 1000);\nawait new Promise(() => {});\n`); + child = spawnDeno(["run", "-A", "cli.ts", "serve"], dir); const pid = child.pid; - // Wait until the signal handler is installed, or SIGTERM would just kill it. - const reader = child.stdout.getReader(); - const first = await reader.read(); - assertEquals(new TextDecoder().decode(first.value).includes("ready"), true); - reader.releaseLock(); - - const result = await stopInstances([fakeInstance({ pid, pids: [pid] })], { timeoutMs: 600, killTimeoutMs: 5000 }); - assertEquals(result.killed, [pid], "had to be SIGKILLed"); - assertEquals(result.stopped, [pid]); - assertEquals(result.survivors, []); - assertEquals((await child.status).signal, "SIGKILL"); - child = undefined; + await new Promise((r) => setTimeout(r, 600)); + assertEquals((await findDuneInstances()).some((i) => i.pids.includes(pid)), false); } finally { - if (child) { - try { - child.kill("SIGKILL"); - } catch { /* already gone */ } - await child.status.catch(() => {}); - } + if (child) await stop(child); await Deno.remove(dir, { recursive: true }).catch(() => {}); } }); -function fakeInstance(overrides: Partial = {}): DuneInstance { - return { - pid: 99_990, - pids: [99_990], - mode: "serve", - root: "/x/site", - title: "Fake", - ports: [], - portStatus: "not-listening", - elapsed: "01:00", - command: "deno run -A main.ts serve", - ...overrides, - }; -} +// ── psCommand output (fakes only: never touches real processes) ────────────── -Deno.test("stopInstances: never signals this process, even if an instance lists it", async () => { - const result = await stopInstances([fakeInstance({ pids: [Deno.pid] })], { timeoutMs: 300 }); - assertEquals(result.skipped, [Deno.pid]); - assertEquals(result.stopped, []); - assertEquals(result.killed, []); - assertEquals(result.survivors, []); - // (If it had signalled itself, this assertion would never run.) -}); - -// ── psCommand flow (fakes only: these never touch real processes) ──────────── - -async function runPs( - instances: DuneInstance[], - opts: { stop?: boolean; yes?: boolean; idle?: boolean }, - deps: { interactive?: boolean; confirm?: boolean; survivors?: number[]; killed?: number[] } = {}, -) { +async function runPs(instances: DuneInstance[]): Promise { const lines: string[] = []; - const stopCalls: DuneInstance[][] = []; - const priorExit = Deno.exitCode; - Deno.exitCode = 0; - try { - await psCommand(opts, { - find: () => Promise.resolve(instances), - stop: (insts) => { - stopCalls.push(insts); - const all = insts.flatMap((i) => i.pids); - return Promise.resolve({ - stopped: all.filter((p) => !(deps.survivors ?? []).includes(p)), - killed: deps.killed ?? [], - survivors: deps.survivors ?? [], - skipped: [], - }); - }, - isInteractive: () => deps.interactive ?? false, - confirm: () => Promise.resolve(deps.confirm ?? false), - log: (l) => lines.push(l), - }); - return { text: lines.join("\n"), stopCalls, exitCode: Deno.exitCode }; - } finally { - Deno.exitCode = priorExit; - } + await psCommand({ find: () => Promise.resolve(instances), log: (l) => lines.push(l) }); + return lines.join("\n"); } +const LIVE = () => fakeInstance({ pid: 10, pids: [10, 11], ports: [3000], portStatus: "listening", title: "Live Site" }); +const IDLE = () => fakeInstance({ pid: 20, pids: [20], title: "Idle A" }); + Deno.test("psCommand: says 'not listening' for an idle process, a URL for a live one, 'lsof not available' only when unknown", async () => { - const { text } = await runPs([ + const text = await runPs([ fakeInstance({ pid: 1, pids: [1], ports: [3000], portStatus: "listening" }), fakeInstance({ pid: 2, pids: [2], ports: [], portStatus: "not-listening" }), fakeInstance({ pid: 3, pids: [3], ports: [], portStatus: "unknown" }), - ], {}); + ]); assertEquals(text.includes("http://localhost:3000"), true); assertEquals(text.includes("not listening (no open port"), true); assertEquals(text.includes("port unknown (lsof not available)"), true); @@ -537,117 +381,14 @@ Deno.test("psCommand: says 'not listening' for an idle process, a URL for a live assertEquals(text.includes("3 instances: 1 listening, 1 not listening"), true); }); -Deno.test("psCommand: without --stop it never stops anything", async () => { - const { stopCalls } = await runPs([fakeInstance()], {}); - assertEquals(stopCalls.length, 0); -}); - -Deno.test("psCommand --stop: refuses without --yes when not interactive, and says how to proceed", async () => { - const { text, stopCalls, exitCode } = await runPs([fakeInstance()], { stop: true }, { interactive: false }); - assertEquals(stopCalls.length, 0); - assertEquals(exitCode, 1); - assertEquals(text.includes("Re-run with --yes"), true); -}); - -Deno.test("psCommand --stop: --yes stops without asking", async () => { - const { stopCalls, exitCode } = await runPs([fakeInstance(), fakeInstance({ pid: 5, pids: [5, 6] })], { stop: true, yes: true }); - assertEquals(stopCalls.length, 1); - assertEquals(stopCalls[0].length, 2); - assertEquals(exitCode, 0); -}); - -Deno.test("psCommand --stop: an interactive 'no' stops nothing, a 'yes' stops everything", async () => { - const no = await runPs([fakeInstance()], { stop: true }, { interactive: true, confirm: false }); - assertEquals(no.stopCalls.length, 0); - assertEquals(no.text.includes("Cancelled"), true); - const yes = await runPs([fakeInstance()], { stop: true }, { interactive: true, confirm: true }); - assertEquals(yes.stopCalls.length, 1); -}); - -Deno.test("psCommand --stop: survivors (alive even after SIGKILL) are reported and make the exit code non-zero", async () => { - const { text, exitCode } = await runPs([fakeInstance({ pids: [7, 8] })], { stop: true, yes: true }, { survivors: [8] }); - assertEquals(text.includes("Could not stop (still running even after SIGKILL): 8"), true); - assertEquals(exitCode, 1); -}); - -Deno.test("psCommand --stop: processes that needed SIGKILL are named, and that alone is not a failure", async () => { - const { text, exitCode } = await runPs([fakeInstance({ pids: [7, 8] })], { stop: true, yes: true }, { killed: [8] }); - assertEquals(text.includes("1 did not exit on SIGTERM and were killed (SIGKILL): 8"), true); - assertEquals(exitCode, 0); -}); - -Deno.test("psCommand --stop: says nothing about SIGKILL when everything exited gracefully", async () => { - const { text } = await runPs([fakeInstance()], { stop: true, yes: true }); - assertEquals(text.includes("SIGKILL"), false); -}); - -Deno.test("findDuneInstances: a bare `cli.ts serve` with NO site anywhere is not reported (weak matches need a confirmed site)", opts, async () => { - const dir = await Deno.makeTempDir({ prefix: "ps_test_nosite_" }); // no "dune" in the command, no config/site.yaml - let child: Deno.ChildProcess | undefined; - try { - await Deno.writeTextFile(join(dir, "cli.ts"), `setInterval(() => {}, 1000);\nawait new Promise(() => {});\n`); - child = spawnDeno(["run", "-A", "cli.ts", "serve"], dir); - const pid = child.pid; - await new Promise((r) => setTimeout(r, 600)); - assertEquals((await findDuneInstances()).some((i) => i.pids.includes(pid)), false); - } finally { - if (child) await stop(child); - await Deno.remove(dir, { recursive: true }).catch(() => {}); - } -}); - -// ── --idle: the selective form ─────────────────────────────────────────────── - -const LIVE = () => fakeInstance({ pid: 10, pids: [10, 11], ports: [3000], portStatus: "listening", title: "Live Site" }); -const IDLE_A = () => fakeInstance({ pid: 20, pids: [20], title: "Idle A" }); -const IDLE_B = () => fakeInstance({ pid: 30, pids: [30, 31, 32], title: "Idle B" }); - -Deno.test("psCommand --idle: lists only the not-listening instances and says how many live ones it left out", async () => { - const { text, stopCalls } = await runPs([LIVE(), IDLE_A(), IDLE_B()], { idle: true }); - assertEquals(text.includes("Idle A") && text.includes("Idle B"), true); - assertEquals(text.includes("Live Site"), false, "a listening instance is not shown"); - assertEquals(text.includes("2 idle instances shown; 1 listening instance left out"), true); - assertEquals(stopCalls.length, 0, "--idle alone never stops anything"); -}); - -Deno.test("psCommand --stop --idle: stops ONLY the idle instances, never a listening one", async () => { - const { stopCalls, exitCode } = await runPs([LIVE(), IDLE_A(), IDLE_B()], { stop: true, idle: true, yes: true }); - assertEquals(stopCalls.length, 1); - assertEquals(stopCalls[0].map((i) => i.pid).sort(), [20, 30]); - assertEquals(stopCalls[0].flatMap((i) => i.pids).includes(10), false); - assertEquals(stopCalls[0].flatMap((i) => i.pids).includes(11), false, "not even the live one's child processes"); - assertEquals(exitCode, 0); -}); - -Deno.test("psCommand --stop --idle: the confirmation names the idle count and process count", async () => { - const { text } = await runPs([LIVE(), IDLE_A(), IDLE_B()], { stop: true, idle: true }, { interactive: false }); - assertEquals(text.includes("Stop 2 idle instances (4 processes)?"), true, text); -}); - -Deno.test("psCommand --stop --idle: without --yes and without a terminal it still refuses", async () => { - const { stopCalls, exitCode } = await runPs([LIVE(), IDLE_A()], { stop: true, idle: true }, { interactive: false }); - assertEquals(stopCalls.length, 0); - assertEquals(exitCode, 1); -}); - -Deno.test("psCommand --stop --idle: with nothing idle it stops nothing and says so", async () => { - const { text, stopCalls, exitCode } = await runPs([LIVE()], { stop: true, idle: true, yes: true }); - assertEquals(stopCalls.length, 0); - assertEquals(text.includes("No idle instances: all 1 running instance is listening."), true); - assertEquals(exitCode, 0); -}); - -Deno.test("psCommand --idle: when lsof is unavailable nothing is assumed idle, and --stop does not proceed", async () => { - const unknown = fakeInstance({ pid: 40, pids: [40], portStatus: "unknown" }); - const { text, stopCalls, exitCode } = await runPs([unknown], { stop: true, idle: true, yes: true }); - assertEquals(stopCalls.length, 0, "an instance of unknown state must not be stopped as 'idle'"); - assertEquals(text.includes("Can't tell which instances are idle"), true); - assertEquals(exitCode, 1); +Deno.test("psCommand: shows child-process counts and says so when nothing is running", async () => { + const text = await runPs([LIVE()]); + assertEquals(text.includes("(+1 child process)"), true); + assertEquals((await runPs([])).includes("No dune dev/serve instances found"), true); }); -Deno.test("psCommand: plain listing points at --stop --idle when idle instances exist", async () => { - const { text } = await runPs([LIVE(), IDLE_A()], {}); - assertEquals(text.includes("`dune ps --stop --idle` stops only the not-listening ones"), true); - const none = await runPs([LIVE()], {}); - assertEquals(none.text.includes("--stop --idle"), false, "no hint when there is nothing idle"); +Deno.test("psCommand: points at `dune stop` when idle instances exist, and only then", async () => { + const withIdle = await runPs([LIVE(), IDLE()]); + assertEquals(withIdle.includes("`dune stop --idle` stops only the not-listening ones"), true); + assertEquals((await runPs([LIVE()])).includes("dune stop"), false, "no hint when there is nothing idle"); }); diff --git a/tests/cli/stop_test.ts b/tests/cli/stop_test.ts new file mode 100644 index 00000000..a877956d --- /dev/null +++ b/tests/cli/stop_test.ts @@ -0,0 +1,328 @@ +/** + * Tests for `dune stop` (src/cli/stop.ts). + * + * Three layers: real processes for the signalling (stopInstances), a pure + * table for target selection (selectInstances), and the command flow against + * injected fakes, so no flow test can stop a real instance. + */ + +import { assertEquals, assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { join } from "@std/path"; +import { type DuneInstance, findDuneInstances } from "../../src/cli/instances.ts"; +import { selectInstances, type StopOptions, stopCommand, stopInstances } from "../../src/cli/stop.ts"; +import { fakeInstance, opts, startTaskTree, stop } from "./ps_helpers.ts"; + +// ── stopInstances: real processes ──────────────────────────────────────────── + +Deno.test("stopInstances: stops the whole tree and reports nothing left", opts, async () => { + const port = 18_914; + const { site, child } = await startTaskTree(port); + try { + const found = (await findDuneInstances()).find((i) => i.pids.includes(child.pid)); + assertExists(found); + const result = await stopInstances([found], { timeoutMs: 8000 }); + assertEquals(result.survivors, []); + assertEquals(result.skipped, []); + assertEquals(result.killed, [], "a process that exits on SIGTERM must not be SIGKILLed"); + assertEquals(result.stopped.sort(), [...found.pids].sort()); + let answered = true; + try { + await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(800) }); + } catch { + answered = false; + } + assertEquals(answered, false); + } finally { + await stop(child); + await Deno.remove(site, { recursive: true }).catch(() => {}); + } +}); + +/** A process that swallows SIGTERM, the way a server stuck in its own shutdown does. */ +async function withStubbornProcess(fn: (pid: number, child: Deno.ChildProcess) => Promise): Promise { + const dir = await Deno.makeTempDir({ prefix: "stop_test_stubborn_" }); + let child: Deno.ChildProcess | undefined; + try { + await Deno.writeTextFile( + join(dir, "stubborn.ts"), + `Deno.addSignalListener("SIGTERM", () => {});\nconsole.log("ready");\nsetInterval(() => {}, 1000);\n`, + ); + child = new Deno.Command(Deno.execPath(), { + args: ["run", "-A", "stubborn.ts"], + cwd: dir, + stdout: "piped", + stderr: "null", + }).spawn(); + // Wait until the handler is installed, or SIGTERM would just kill it. + const reader = child.stdout.getReader(); + const first = await reader.read(); + assertEquals(new TextDecoder().decode(first.value).includes("ready"), true); + reader.releaseLock(); + await fn(child.pid, child); + } finally { + if (child) { + try { + child.kill("SIGKILL"); + } catch { /* already gone */ } + await child.status.catch(() => {}); + } + await Deno.remove(dir, { recursive: true }).catch(() => {}); + } +} + +Deno.test("stopInstances: without force, a process that ignores SIGTERM is left alone and reported", opts, async () => { + await withStubbornProcess(async (pid, child) => { + const result = await stopInstances([fakeInstance({ pid, pids: [pid] })], { timeoutMs: 600 }); + assertEquals(result.survivors, [pid]); + assertEquals(result.killed, []); + assertEquals(result.stopped, []); + // Still running: nothing sent SIGKILL. + assertEquals(await Promise.race([child.status.then(() => "exited"), new Promise((r) => setTimeout(() => r("alive"), 300))]), "alive"); + }); +}); + +Deno.test("stopInstances: with force, that process is SIGKILLed after the grace period and reported as killed", opts, async () => { + await withStubbornProcess(async (pid, child) => { + const result = await stopInstances([fakeInstance({ pid, pids: [pid] })], { force: true, timeoutMs: 600, killTimeoutMs: 5000 }); + assertEquals(result.killed, [pid]); + assertEquals(result.stopped, [pid]); + assertEquals(result.survivors, []); + assertEquals((await child.status).signal, "SIGKILL"); + }); +}); + +Deno.test("stopInstances: never signals this process, even if an instance lists it", async () => { + const result = await stopInstances([fakeInstance({ pids: [Deno.pid] })], { timeoutMs: 300, force: true }); + assertEquals(result.skipped, [Deno.pid]); + assertEquals(result.stopped, []); + assertEquals(result.killed, []); + assertEquals(result.survivors, []); + // (If it had signalled itself, this assertion would never run.) +}); + +// ── selectInstances ────────────────────────────────────────────────────────── + +const LIVE = () => fakeInstance({ pid: 10, pids: [10, 11], ports: [3000], portStatus: "listening", title: "Live Site" }); +const IDLE_A = () => fakeInstance({ pid: 20, pids: [20], title: "Idle A" }); +const IDLE_B = () => fakeInstance({ pid: 30, pids: [30, 31, 32], title: "Idle B" }); +const ALL = () => [LIVE(), IDLE_A(), IDLE_B()]; + +function pidsOf(sel: ReturnType): number[] { + if (!sel.ok) throw new Error(`expected a selection, got: ${sel.message}`); + return sel.instances.map((i) => i.pid); +} + +Deno.test("selectInstances: no target is a usage error (exit 2) and selects nothing", () => { + const sel = selectInstances(ALL(), {}); + assertEquals(sel.ok, false); + if (!sel.ok) { + assertEquals(sel.exitCode, 2); + assertEquals(sel.message.includes("Usage: dune stop"), true); + } +}); + +Deno.test("selectInstances: more than one target is a usage error, never a union", () => { + for (const o of [{ pid: "10", all: true }, { port: "3000", idle: true }, { idle: true, all: true }, { pid: "10", port: "3000" }] as StopOptions[]) { + const sel = selectInstances(ALL(), o); + assertEquals(sel.ok, false, JSON.stringify(o)); + if (!sel.ok) assertEquals(sel.exitCode, 2); + } +}); + +Deno.test("selectInstances: a pid matches the instance by its top process OR any child process", () => { + assertEquals(pidsOf(selectInstances(ALL(), { pid: "10" })), [10]); + assertEquals(pidsOf(selectInstances(ALL(), { pid: "11" })), [10], "a child pid selects its whole instance"); + assertEquals(pidsOf(selectInstances(ALL(), { pid: "32" })), [30]); +}); + +Deno.test("selectInstances: a pid or port nobody has is a failure (exit 1), naming it", () => { + const pid = selectInstances(ALL(), { pid: "424242" }); + assertEquals(pid.ok, false); + if (!pid.ok) { + assertEquals(pid.exitCode, 1); + assertEquals(pid.message.includes("424242"), true); + } + const port = selectInstances(ALL(), { port: "9999" }); + assertEquals(port.ok, false); + if (!port.ok) assertEquals(port.exitCode, 1); +}); + +Deno.test("selectInstances: --port picks the instance listening on it", () => { + assertEquals(pidsOf(selectInstances(ALL(), { port: "3000" })), [10]); +}); + +Deno.test("selectInstances: invalid numbers are usage errors (exit 2)", () => { + for (const o of [{ pid: "abc" }, { pid: "-5" }, { pid: "0" }, { pid: "1.5" }, { port: "70000" }, { port: "x" }] as StopOptions[]) { + const sel = selectInstances(ALL(), o); + assertEquals(sel.ok, false, JSON.stringify(o)); + if (!sel.ok) assertEquals(sel.exitCode, 2, JSON.stringify(o)); + } +}); + +Deno.test("selectInstances: --idle takes only not-listening instances, never a listening one or its children", () => { + const sel = selectInstances(ALL(), { idle: true }); + assertEquals(pidsOf(sel), [20, 30]); + if (sel.ok) assertEquals(sel.instances.flatMap((i) => i.pids).some((p) => p === 10 || p === 11), false); +}); + +Deno.test("selectInstances: --idle with everything listening is a clean no-op (exit 0)", () => { + const sel = selectInstances([LIVE()], { idle: true }); + assertEquals(sel.ok, false); + if (!sel.ok) { + assertEquals(sel.exitCode, 0); + assertEquals(sel.message.includes("No idle instances: all 1 running instance is listening."), true); + } +}); + +Deno.test("selectInstances: --idle without lsof assumes nothing is idle (exit 1)", () => { + const sel = selectInstances([fakeInstance({ pid: 40, pids: [40], portStatus: "unknown" })], { idle: true }); + assertEquals(sel.ok, false); + if (!sel.ok) { + assertEquals(sel.exitCode, 1); + assertEquals(sel.message.includes("Can't tell which instances are idle"), true); + } +}); + +Deno.test("selectInstances: --all takes everything", () => { + assertEquals(pidsOf(selectInstances(ALL(), { all: true })), [10, 20, 30]); +}); + +// ── stopCommand flow (fakes only) ──────────────────────────────────────────── + +async function runStop( + instances: DuneInstance[], + o: StopOptions, + deps: { interactive?: boolean; confirm?: boolean; survivors?: number[]; killed?: number[] } = {}, +) { + const lines: string[] = []; + const stopCalls: { instances: DuneInstance[]; force: boolean }[] = []; + const priorExit = Deno.exitCode; + Deno.exitCode = 0; + try { + await stopCommand(o, { + find: () => Promise.resolve(instances), + stop: (insts, so) => { + stopCalls.push({ instances: insts, force: so?.force === true }); + const all = insts.flatMap((i) => i.pids); + return Promise.resolve({ + stopped: all.filter((p) => !(deps.survivors ?? []).includes(p)), + killed: deps.killed ?? [], + survivors: deps.survivors ?? [], + skipped: [], + }); + }, + isInteractive: () => deps.interactive ?? false, + confirm: () => Promise.resolve(deps.confirm ?? false), + log: (l) => lines.push(l), + }); + return { text: lines.join("\n"), stopCalls, exitCode: Deno.exitCode }; + } finally { + Deno.exitCode = priorExit; + } +} + +Deno.test("stopCommand: with no target it prints usage, exits 2 and never scans or stops", async () => { + let scanned = false; + const lines: string[] = []; + const prior = Deno.exitCode; + try { + await stopCommand({}, { find: () => { scanned = true; return Promise.resolve(ALL()); }, log: (l) => lines.push(l) }); + assertEquals(Deno.exitCode, 2); + } finally { + Deno.exitCode = prior; + } + assertEquals(scanned, false); + assertEquals(lines.join("\n").includes("Usage: dune stop"), true); +}); + +Deno.test("stopCommand: refuses without --yes when not interactive, and says how to proceed", async () => { + const { text, stopCalls, exitCode } = await runStop(ALL(), { all: true }, { interactive: false }); + assertEquals(stopCalls.length, 0); + assertEquals(exitCode, 1); + assertEquals(text.includes("Re-run with --yes"), true); +}); + +Deno.test("stopCommand: --yes stops without asking", async () => { + const { stopCalls, exitCode } = await runStop(ALL(), { all: true, yes: true }); + assertEquals(stopCalls.length, 1); + assertEquals(stopCalls[0].instances.length, 3); + assertEquals(exitCode, 0); +}); + +Deno.test("stopCommand: an interactive 'no' stops nothing, a 'yes' stops", async () => { + const no = await runStop([IDLE_A()], { all: true }, { interactive: true, confirm: false }); + assertEquals(no.stopCalls.length, 0); + assertEquals(no.text.includes("Cancelled"), true); + const yes = await runStop([IDLE_A()], { all: true }, { interactive: true, confirm: true }); + assertEquals(yes.stopCalls.length, 1); +}); + +Deno.test("stopCommand: shows what it is about to stop, with an instance and process count in the question", async () => { + const { text } = await runStop(ALL(), { idle: true }, { interactive: false }); + assertEquals(text.includes("Idle A") && text.includes("Idle B"), true); + assertEquals(text.includes("Live Site"), false, "a listening instance is not even shown for --idle"); + assertEquals(text.includes("Stop 2 idle instances (4 processes)?"), true, text); +}); + +Deno.test("stopCommand --idle: stops only the idle instances", async () => { + const { stopCalls } = await runStop(ALL(), { idle: true, yes: true }); + assertEquals(stopCalls[0].instances.map((i) => i.pid), [20, 30]); +}); + +Deno.test("stopCommand : stops that one instance only", async () => { + const { stopCalls } = await runStop(ALL(), { pid: "31", yes: true }); + assertEquals(stopCalls[0].instances.map((i) => i.pid), [30]); +}); + +Deno.test("stopCommand: --force reaches stopInstances; without it, it does not", async () => { + assertEquals((await runStop([IDLE_A()], { all: true, yes: true, force: true })).stopCalls[0].force, true); + assertEquals((await runStop([IDLE_A()], { all: true, yes: true })).stopCalls[0].force, false); +}); + +Deno.test("stopCommand: a process that ignored SIGTERM is reported with the --force hint and fails the exit code", async () => { + const { text, exitCode } = await runStop([fakeInstance({ pids: [7, 8] })], { all: true, yes: true }, { survivors: [8] }); + assertEquals(text.includes("Still running (ignored SIGTERM): 8"), true); + assertEquals(text.includes("Re-run with --force"), true); + assertEquals(exitCode, 1); +}); + +Deno.test("stopCommand --force: survivors of SIGKILL are reported as such and fail the exit code", async () => { + const { text, exitCode } = await runStop([fakeInstance({ pids: [7, 8] })], { all: true, yes: true, force: true }, { survivors: [8] }); + assertEquals(text.includes("Could not stop (still running even after SIGKILL): 8"), true); + assertEquals(text.includes("Re-run with --force"), false); + assertEquals(exitCode, 1); +}); + +Deno.test("stopCommand --force: processes that needed SIGKILL are named, and that alone is not a failure", async () => { + const { text, exitCode } = await runStop([fakeInstance({ pids: [7, 8] })], { all: true, yes: true, force: true }, { killed: [8] }); + assertEquals(text.includes("1 did not exit on SIGTERM and were killed (SIGKILL): 8"), true); + assertEquals(exitCode, 0); +}); + +Deno.test("stopCommand: says nothing about SIGKILL when everything exited gracefully", async () => { + const { text } = await runStop([IDLE_A()], { all: true, yes: true }); + assertEquals(text.includes("SIGKILL"), false); +}); + +Deno.test("stopCommand: nothing running — exit 0 for --all/--idle, exit 1 when a pid or port was named", async () => { + assertEquals((await runStop([], { all: true, yes: true })).exitCode, 0); + assertEquals((await runStop([], { idle: true, yes: true })).exitCode, 0); + assertEquals((await runStop([], { pid: "5", yes: true })).exitCode, 1); + assertEquals((await runStop([], { port: "3000", yes: true })).exitCode, 1); + assertEquals((await runStop([], { all: true, yes: true })).stopCalls.length, 0); +}); + +Deno.test("stopCommand: a named pid nobody has stops nothing and exits 1", async () => { + const { text, stopCalls, exitCode } = await runStop(ALL(), { pid: "424242", yes: true }); + assertEquals(stopCalls.length, 0); + assertEquals(exitCode, 1); + assertEquals(text.includes("424242"), true); +}); + +Deno.test("stopCommand --idle: when lsof is unavailable nothing is assumed idle", async () => { + const unknown = fakeInstance({ pid: 40, pids: [40], portStatus: "unknown" }); + const { stopCalls, exitCode, text } = await runStop([unknown], { idle: true, yes: true }); + assertEquals(stopCalls.length, 0, "an instance of unknown state must not be stopped as 'idle'"); + assertEquals(exitCode, 1); + assertEquals(text.includes("Can't tell which instances are idle"), true); +}); From fdeb9e628eded87e3f6504ff8fca6c3e34499b21 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Mon, 26 Oct 2026 21:51:32 +0100 Subject: [PATCH 07/12] 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 --- CHANGELOG.md | 12 +++ deno.lock | 1 + src/cli/instances.ts | 82 ++++++++++++++++++- src/cli/ps.ts | 21 +++-- src/cli/stop.ts | 9 ++- tests/cli/ps_helpers.ts | 2 + tests/cli/ps_test.ts | 172 +++++++++++++++++++++++++++++++++++++++- tests/cli/stop_test.ts | 8 +- 8 files changed, 294 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d7c45ba..96e849a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,18 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what ### Added +- **`dune ps` now tells a hung server from a healthy one, and finds servers + that registered themselves.** Every listening instance is sent a request to + `/health/live` (3-second timeout; any HTTP status counts as an answer). A + blocked event loop still lets the kernel accept the connection but never + answers it, so such an instance is shown as **NOT RESPONDING** and counted + separately — which matters because it looks perfectly healthy to `lsof`. + Instances that wrote an entry to the instance registry are also listed even + if their command line doesn't look like dune, and show their `@dune/core` + version. `dune stop --idle` now also takes listening-but-not-responding + instances; since a server stuck in a loop can't handle SIGTERM, + `dune stop --force` is how to end one. + - **Running instances now register themselves.** `dune dev` and `dune serve` write a small record (pid, process start time, mode, site root, title, port, `@dune/core` version) to `~/.dune/instances/.json` diff --git a/deno.lock b/deno.lock index a7861082..928541f3 100644 --- a/deno.lock +++ b/deno.lock @@ -5,6 +5,7 @@ "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.1", "jsr:@deno/loader@~0.3.10": "0.3.14", "jsr:@denosaurs/plug@1": "1.1.0", + "jsr:@dune/plugin-admin@*": "3.0.0", "jsr:@dune/plugin-admin@3": "3.0.0", "jsr:@fresh/build-id@1": "1.0.1", "jsr:@fresh/core@2": "2.3.3", diff --git a/src/cli/instances.ts b/src/cli/instances.ts index 820eac65..4a584b8e 100644 --- a/src/cli/instances.ts +++ b/src/cli/instances.ts @@ -42,6 +42,7 @@ import { isAbsolute, join, resolve } from "@std/path"; import { parseUserYaml } from "../security/safe-yaml.ts"; +import { listRegisteredInstances } from "../runtime/instance-registry.ts"; /** A dune dev/serve-shaped process (tree) found running on this machine. */ export interface DuneInstance { @@ -66,6 +67,15 @@ export interface DuneInstance { * so we can't tell. */ portStatus: "listening" | "not-listening" | "unknown"; + /** + * Whether a listening instance answered an HTTP request: `"responding"`, + * `"not-responding"` (accepts connections but gives no answer in time — + * the signature of a blocked event loop, or just a very busy server), or + * `"unknown"` (not listening, or not probed). + */ + responsive: "responding" | "not-responding" | "unknown"; + /** `@dune/core` version, when the instance registered itself (see runtime/instance-registry.ts). */ + version: string | null; /** `ps`'s own elapsed-time string (e.g. "01:23:45" or "2-09:03:55"). */ elapsed: string | null; /** The process's command line, for identifying unconfirmed instances. */ @@ -317,6 +327,33 @@ async function confirmSiteRoot(dirs: (string | null)[]): Promise return null; } +// ── Health probe ───────────────────────────────────────────────────────────── + +export const PROBE_TIMEOUT_MS = 3_000; + +/** + * Does the server on `port` answer HTTP at all? Any status counts — even a 404 + * proves the event loop is running — so only a timeout or a refused/reset + * connection is "not responding". A blocked event loop still lets the kernel + * accept the TCP connection, but nothing ever answers it, which is exactly + * what the timeout catches. + */ +export async function probeResponsive( + port: number, + timeoutMs = PROBE_TIMEOUT_MS, +): Promise<"responding" | "not-responding"> { + try { + const res = await fetch(`http://localhost:${port}/health/live`, { + signal: AbortSignal.timeout(timeoutMs), + redirect: "manual", + }); + await res.body?.cancel(); + return "responding"; + } catch { + return "not-responding"; + } +} + /** * Find every dune dev/serve instance on this machine. * @@ -325,7 +362,10 @@ async function confirmSiteRoot(dirs: (string | null)[]): Promise * port, uptime, command) rather than a false negative — but `root`/`title` * are null in that case, and the caller should say so plainly. */ -export async function findDuneInstances(): Promise { +export async function findDuneInstances( + opts: { probe?: (port: number) => Promise<"responding" | "not-responding"> } = {}, +): Promise { + const probe = opts.probe ?? probeResponsive; const table = await readProcessTable(); const byPid = new Map(table.map((r) => [r.pid, r])); const children = new Map(); @@ -394,9 +434,49 @@ export async function findDuneInstances(): Promise { title, ports, portStatus: !haveLsof ? "unknown" : ports.length > 0 ? "listening" : "not-listening", + responsive: "unknown", + version: null, elapsed: c.elapsed, command: c.command, }); } + + // 5. Instances that registered themselves (runtime/instance-registry.ts): + // attach what they report about themselves, and add any the command-line + // matching missed (a server started in an unusual way). + for (const rec of await listRegisteredInstances()) { + const existing = instances.find((i) => i.pids.includes(rec.pid)); + if (existing) { + existing.version = rec.version; + if (existing.root === null) { + existing.root = rec.root; + existing.title = rec.title ?? (await readSiteTitle(rec.root)); + } + continue; + } + const row = byPid.get(rec.pid); + if (!row) continue; + const pids = [rec.pid, ...descendantsOf(rec.pid, children)]; + const ports = haveLsof ? await getListeningPorts(pids) : []; + instances.push({ + pid: rec.pid, + pids, + mode: rec.mode, + root: rec.root, + title: rec.title ?? (await readSiteTitle(rec.root)), + ports, + portStatus: !haveLsof ? "unknown" : ports.length > 0 ? "listening" : "not-listening", + responsive: "unknown", + version: rec.version, + elapsed: row.elapsed, + command: row.command, + }); + } + + // 6. Probe every listening instance, all at once. + await Promise.all(instances.map(async (inst) => { + if (inst.ports.length > 0) inst.responsive = await probe(inst.ports[0]); + })); + return instances.sort((a, b) => a.pid - b.pid); } diff --git a/src/cli/ps.ts b/src/cli/ps.ts index e96b532a..9d7f0da2 100644 --- a/src/cli/ps.ts +++ b/src/cli/ps.ts @@ -16,7 +16,12 @@ export interface PsDeps { } export function describePort(inst: DuneInstance): string { - if (inst.portStatus === "listening") return inst.ports.map((p) => `http://localhost:${p}`).join(", "); + if (inst.portStatus === "listening") { + const urls = inst.ports.map((p) => `http://localhost:${p}`).join(", "); + return inst.responsive === "not-responding" + ? `${urls} — NOT RESPONDING (no answer to a health check; the server may be hung)` + : urls; + } if (inst.portStatus === "not-listening") return "not listening (no open port; likely a hung or leftover process)"; return "port unknown (lsof not available)"; } @@ -27,7 +32,7 @@ export function formatInstance(inst: DuneInstance): string[] { ? ` (+${inst.pids.length - 1} child process${inst.pids.length > 2 ? "es" : ""})` : ""; return [ - ` PID ${inst.pid} ${inst.mode.padEnd(5)} up ${inst.elapsed ?? "?"}${extra}`, + ` PID ${inst.pid} ${inst.mode.padEnd(5)} up ${inst.elapsed ?? "?"}${inst.version ? ` v${inst.version}` : ""}${extra}`, ` ${inst.title ?? "(untitled)"}`, ` ${inst.root ?? "(site root unconfirmed — no config/site.yaml via --root, PWD or live cwd)"}`, ` ${describePort(inst)}`, @@ -53,9 +58,13 @@ export async function psCommand(deps: PsDeps = {}): Promise { for (const inst of all) for (const line of formatInstance(inst)) log(line); const serving = all.filter((i) => i.portStatus === "listening").length; - const idle = all.filter((i) => i.portStatus === "not-listening").length; - log(` ${all.length} instance${all.length === 1 ? "" : "s"}: ${serving} listening, ${idle} not listening\n`); - if (idle > 0) { - log(" `dune stop --idle` stops only the not-listening ones; `dune stop ` stops one instance."); + const stuck = all.filter((i) => i.responsive === "not-responding").length; + const notListening = all.filter((i) => i.portStatus === "not-listening").length; + log( + ` ${all.length} instance${all.length === 1 ? "" : "s"}: ${serving} listening` + + `${stuck > 0 ? ` (${stuck} not responding)` : ""}, ${notListening} not listening\n`, + ); + if (stuck + notListening > 0) { + log(" `dune stop --idle` stops only the ones that aren't serving (not listening or not responding); `dune stop ` stops one instance."); } } diff --git a/src/cli/stop.ts b/src/cli/stop.ts index 4107df00..71088c7e 100644 --- a/src/cli/stop.ts +++ b/src/cli/stop.ts @@ -106,7 +106,7 @@ export interface StopOptions { pid?: string; /** The instance listening on this port. */ port?: string; - /** Only instances that are running but not listening (hung or leftover). */ + /** Only instances that aren't serving: running but not listening, or listening but not responding (hung or leftover). */ idle?: boolean; /** Every instance `dune ps` lists. */ all?: boolean; @@ -120,7 +120,8 @@ export const STOP_USAGE = `Usage: dune stop | --port | --idle | --all the instance this process belongs to (see \`dune ps\`) --port the instance listening on that port - --idle only instances that are running but not listening + --idle only instances that aren't serving: running but not + listening, or listening but not responding --all every instance \`dune ps\` lists --force SIGKILL processes that ignore SIGTERM (default: report them) --yes, -y don't ask for confirmation`; @@ -164,7 +165,7 @@ export function selectInstances(all: DuneInstance[], opts: StopOptions): Selecti } if (opts.idle) { - const idle = all.filter((i) => i.portStatus === "not-listening"); + const idle = all.filter((i) => i.portStatus === "not-listening" || i.responsive === "not-responding"); const unknown = all.filter((i) => i.portStatus === "unknown").length; if (idle.length === 0 && unknown > 0) { return { @@ -176,7 +177,7 @@ export function selectInstances(all: DuneInstance[], opts: StopOptions): Selecti if (idle.length === 0) { return { ok: false, - message: `No idle instances: all ${all.length} running instance${all.length === 1 ? " is" : "s are"} listening.`, + message: `No idle instances: all ${all.length} running instance${all.length === 1 ? " is" : "s are"} listening and responding.`, exitCode: 0, }; } diff --git a/tests/cli/ps_helpers.ts b/tests/cli/ps_helpers.ts index 11052e6c..4cbb4acf 100644 --- a/tests/cli/ps_helpers.ts +++ b/tests/cli/ps_helpers.ts @@ -74,6 +74,8 @@ export function fakeInstance(overrides: Partial = {}): DuneInstanc title: "Fake", ports: [], portStatus: "not-listening", + responsive: "unknown", + version: null, elapsed: "01:00", command: "deno run -A main.ts serve", ...overrides, diff --git a/tests/cli/ps_test.ts b/tests/cli/ps_test.ts index f9b09bcc..50508128 100644 --- a/tests/cli/ps_test.ts +++ b/tests/cli/ps_test.ts @@ -17,6 +17,7 @@ import { findDuneInstances, matchDuneCommand, parseRootArg, + probeResponsive, } from "../../src/cli/instances.ts"; import { psCommand } from "../../src/cli/ps.ts"; import { fakeInstance, makeSite, opts, SERVER, spawnDeno, startTaskTree, stop, waitForPort } from "./ps_helpers.ts"; @@ -357,6 +358,162 @@ Deno.test("findDuneInstances: a bare `cli.ts serve` with NO site anywhere is not } }); +// ── health probe and registry ──────────────────────────────────────────────── + +/** Server shape for a listener that accepts TCP connections but never answers HTTP. */ +const MUTE_SERVER = (port: number) => + `const l = Deno.listen({ port: ${port} });\nsetInterval(() => {}, 1000);\nawait new Promise(() => {});\n`; + +const quickProbe = (port: number) => probeResponsive(port, 600); + +Deno.test("probeResponsive: a server that answers (even with a 404) is responding", opts, async () => { + const server = Deno.serve({ port: 18_921, onListen: () => {} }, () => new Response("nope", { status: 404 })); + try { + assertEquals(await probeResponsive(18_921, 2000), "responding"); + } finally { + await server.shutdown(); + } +}); + +Deno.test("probeResponsive: a listener that never answers, and a closed port, are not responding", opts, async () => { + const listener = Deno.listen({ port: 18_922 }); + try { + assertEquals(await probeResponsive(18_922, 500), "not-responding"); + } finally { + listener.close(); + } + assertEquals(await probeResponsive(18_923, 500), "not-responding"); +}); + +Deno.test("findDuneInstances: a listening instance that answers is 'responding'; one that accepts but never answers is 'not-responding'", opts, async () => { + const live = await makeSite("dune_test_ps_resp_", "Responding"); + const mute = await makeSite("dune_test_ps_mute_", "Mute"); + let a: Deno.ChildProcess | undefined; + let b: Deno.ChildProcess | undefined; + try { + await Deno.writeTextFile(join(live, "main.ts"), SERVER(18_924) + `await new Promise(() => {});\n`); + await Deno.writeTextFile(join(mute, "main.ts"), MUTE_SERVER(18_925)); + a = spawnDeno(["run", "-A", "main.ts", "serve"], live); + b = spawnDeno(["run", "-A", "main.ts", "serve"], mute); + await waitForPort(18_924); + await new Promise((r) => setTimeout(r, 800)); + + const found = await findDuneInstances({ probe: quickProbe }); + const ok = found.find((i) => i.pid === a!.pid); + const bad = found.find((i) => i.pid === b!.pid); + assertExists(ok); + assertExists(bad); + assertEquals(ok.responsive, "responding"); + assertEquals(bad.portStatus, "listening", "it does hold the port"); + assertEquals(bad.responsive, "not-responding"); + } finally { + if (a) await stop(a); + if (b) await stop(b); + await Deno.remove(live, { recursive: true }).catch(() => {}); + await Deno.remove(mute, { recursive: true }).catch(() => {}); + } +}); + +Deno.test("findDuneInstances: a not-listening instance is not probed ('unknown')", opts, async () => { + const site = await makeSite("dune_test_ps_noprobe_", "Idle"); + let child: Deno.ChildProcess | undefined; + try { + await Deno.writeTextFile(join(site, "main.ts"), `setInterval(() => {}, 1000);\nawait new Promise(() => {});\n`); + child = spawnDeno(["run", "-A", "main.ts", "serve"], site); + await new Promise((r) => setTimeout(r, 600)); + const found = (await findDuneInstances({ probe: quickProbe })).find((i) => i.pid === child!.pid); + assertExists(found); + assertEquals(found.portStatus, "not-listening"); + assertEquals(found.responsive, "unknown", "nothing to probe without a port"); + } finally { + if (child) await stop(child); + await Deno.remove(site, { recursive: true }).catch(() => {}); + } +}); + +/** Run `body` with DUNE_REGISTRY_DIR pointing at a fresh temp dir (for this process and any child it spawns). */ +async function withRegistryDir(fn: (dir: string) => Promise): Promise { + const dir = await Deno.makeTempDir({ prefix: "dune_test_ps_registry_" }); + const prev = Deno.env.get("DUNE_REGISTRY_DIR"); + Deno.env.set("DUNE_REGISTRY_DIR", dir); + try { + await fn(dir); + } finally { + if (prev === undefined) Deno.env.delete("DUNE_REGISTRY_DIR"); + else Deno.env.set("DUNE_REGISTRY_DIR", prev); + await Deno.remove(dir, { recursive: true }).catch(() => {}); + } +} + +const REGISTRY_URL = new URL("../../src/runtime/instance-registry.ts", import.meta.url).href; +// The child imports dune's own source, whose bare specifiers (@std/path) need dune's import map. +const DUNE_CONFIG = new URL("../../deno.json", import.meta.url).pathname; + +Deno.test("findDuneInstances: a server whose command line doesn't look like dune is still found, via the registry", opts, async () => { + await withRegistryDir(async () => { + const site = await makeSite("dune_test_ps_regonly_", "Registry Only"); + let child: Deno.ChildProcess | undefined; + try { + await Deno.writeTextFile( + join(site, "weird.ts"), + `import { registerInstance } from ${JSON.stringify(REGISTRY_URL)};\n` + + `await registerInstance({ mode: "serve", root: ${JSON.stringify(site)}, title: "Registry Only", port: 18926, version: "9.9.9" });\n` + + SERVER(18_926) + `await new Promise(() => {});\n`, + ); + child = spawnDeno(["run", "-A", `--config=${DUNE_CONFIG}`, "weird.ts"], site); + await waitForPort(18_926); + await new Promise((r) => setTimeout(r, 500)); + + const found = (await findDuneInstances({ probe: quickProbe })).find((i) => i.pid === child!.pid); + assertExists(found, "the registry should reveal a process the command-line matching can't"); + assertEquals(found.version, "9.9.9"); + assertEquals(found.mode, "serve"); + assertEquals(found.root, site); + assertEquals(found.title, "Registry Only"); + assertEquals(found.ports, [18_926]); + assertEquals(found.responsive, "responding"); + } finally { + if (child) await stop(child); + await Deno.remove(site, { recursive: true }).catch(() => {}); + } + }); +}); + +Deno.test("findDuneInstances: a registered process that command-line matching also finds appears once, with its version", opts, async () => { + await withRegistryDir(async () => { + const site = await makeSite("dune_test_ps_regboth_", "Both"); + let child: Deno.ChildProcess | undefined; + try { + await Deno.writeTextFile( + join(site, "main.ts"), + `import { registerInstance } from ${JSON.stringify(REGISTRY_URL)};\n` + + `await registerInstance({ mode: "serve", root: ${JSON.stringify(site)}, title: "Both", port: 18927, version: "1.2.3" });\n` + + SERVER(18_927) + `await new Promise(() => {});\n`, + ); + child = spawnDeno(["run", "-A", `--config=${DUNE_CONFIG}`, "main.ts", "serve"], site); + await waitForPort(18_927); + await new Promise((r) => setTimeout(r, 500)); + const mine = (await findDuneInstances({ probe: quickProbe })).filter((i) => i.pids.includes(child!.pid)); + assertEquals(mine.length, 1); + assertEquals(mine[0].version, "1.2.3"); + } finally { + if (child) await stop(child); + await Deno.remove(site, { recursive: true }).catch(() => {}); + } + }); +}); + +Deno.test("findDuneInstances: a stale registry record (process gone) adds nothing", opts, async () => { + await withRegistryDir(async (dir) => { + await Deno.writeTextFile( + join(dir, "999999.json"), + JSON.stringify({ pid: 999_999, processStart: "Mon Jan 1 00:00:00 2001", mode: "serve", root: "/nowhere", title: null, port: 1, version: "0", registeredAt: "x" }), + ); + const found = await findDuneInstances({ probe: quickProbe }); + assertEquals(found.some((i) => i.pid === 999_999), false); + }); +}); + // ── psCommand output (fakes only: never touches real processes) ────────────── async function runPs(instances: DuneInstance[]): Promise { @@ -387,8 +544,21 @@ Deno.test("psCommand: shows child-process counts and says so when nothing is run assertEquals((await runPs([])).includes("No dune dev/serve instances found"), true); }); +Deno.test("psCommand: flags a listening-but-unresponsive instance, counts it, and shows the version when known", async () => { + const text = await runPs([ + fakeInstance({ pid: 1, pids: [1], ports: [3000], portStatus: "listening", responsive: "not-responding", version: "0.34.14" }), + fakeInstance({ pid: 2, pids: [2], ports: [3001], portStatus: "listening", responsive: "responding" }), + ]); + assertEquals(text.includes("http://localhost:3000 — NOT RESPONDING"), true); + assertEquals(text.includes("http://localhost:3001\n") || text.includes("http://localhost:3001"), true); + assertEquals(text.includes("NOT RESPONDING") && text.split("NOT RESPONDING").length === 2, true, "only the stuck one is flagged"); + assertEquals(text.includes("v0.34.14"), true); + assertEquals(text.includes("2 instances: 2 listening (1 not responding), 0 not listening"), true, text); + assertEquals(text.includes("`dune stop --idle`"), true); +}); + Deno.test("psCommand: points at `dune stop` when idle instances exist, and only then", async () => { const withIdle = await runPs([LIVE(), IDLE()]); - assertEquals(withIdle.includes("`dune stop --idle` stops only the not-listening ones"), true); + assertEquals(withIdle.includes("`dune stop --idle` stops only the ones that aren't serving"), true); assertEquals((await runPs([LIVE()])).includes("dune stop"), false, "no hint when there is nothing idle"); }); diff --git a/tests/cli/stop_test.ts b/tests/cli/stop_test.ts index a877956d..3e2c796d 100644 --- a/tests/cli/stop_test.ts +++ b/tests/cli/stop_test.ts @@ -165,12 +165,18 @@ Deno.test("selectInstances: --idle takes only not-listening instances, never a l if (sel.ok) assertEquals(sel.instances.flatMap((i) => i.pids).some((p) => p === 10 || p === 11), false); }); +Deno.test("selectInstances: --idle also takes a listening instance that is not responding, but not a responding one", () => { + const stuck = fakeInstance({ pid: 50, pids: [50], ports: [3100], portStatus: "listening", responsive: "not-responding" }); + const fine = fakeInstance({ pid: 60, pids: [60], ports: [3200], portStatus: "listening", responsive: "responding" }); + assertEquals(pidsOf(selectInstances([stuck, fine, IDLE_A()], { idle: true })), [50, 20]); +}); + Deno.test("selectInstances: --idle with everything listening is a clean no-op (exit 0)", () => { const sel = selectInstances([LIVE()], { idle: true }); assertEquals(sel.ok, false); if (!sel.ok) { assertEquals(sel.exitCode, 0); - assertEquals(sel.message.includes("No idle instances: all 1 running instance is listening."), true); + assertEquals(sel.message.includes("No idle instances: all 1 running instance is listening and responding."), true); } }); From aad9c32380ee17e8eff87f1f3f63df04755a2248 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Mon, 26 Oct 2026 22:46:14 +0100 Subject: [PATCH 08/12] 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 --- CHANGELOG.md | 36 +++- deno.json | 3 +- src/cli/dev.ts | 5 +- src/cli/serve.ts | 2 + src/runtime/watchdog.ts | 271 ++++++++++++++++++++++----- tests/runtime/watchdog_test.ts | 332 ++++++++++++++++++++++++++++++--- 6 files changed, 562 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c58facfb..1cf9b072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,14 +19,34 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what thread that serves every request, so every URL stops answering at once, with nothing in the logs. `dune dev` and `dune serve` now run a small watchdog Worker that expects a heartbeat from the main thread every second. After 15 - seconds of silence (`DUNE_WATCHDOG_TIMEOUT_MS`) it writes a report to - `/.dune/hangs/` naming the requests that were in flight (plus a native - stack sample on macOS), logs the same lines to stderr, and SIGKILLs the - process so a supervisor (systemd `Restart=`, the `dune dev` wrapper exiting - non-zero) sees a dead process instead of a zombie. `DUNE_WATCHDOG=warn` - reports without killing; `DUNE_WATCHDOG=off` disables it. The process is - killed with SIGKILL rather than `Deno.exit()` because an exit requested from a - Worker only ends the Worker — the spinning main thread carries on. + seconds of silence (`DUNE_WATCHDOG_TIMEOUT_MS`; 2 minutes until the server is + up, `DUNE_WATCHDOG_STARTUP_TIMEOUT_MS`, so a large site's initial indexing + can't put a supervised process into a kill/restart loop) it writes a report + to `/.dune/hangs/` naming the requests that were in flight (path + segments longer than 24 characters are cut, so tokens in URLs don't land in + it; the newest 20 reports are kept; plus a native stack sample on macOS), logs + the same lines to stderr, and SIGKILLs the process so a supervisor (systemd + `Restart=`; the `dune dev` wrapper exits 137) sees a dead process instead of + a zombie. The kill always happens: writing the report is abandoned after 5 + seconds. `DUNE_WATCHDOG=warn` reports without killing and is chosen + automatically when the process runs under `--inspect`/`--inspect-brk` (a + breakpoint pauses only the main thread); `DUNE_WATCHDOG=off` disables it. A + process that is suspended and resumed (laptop sleep, Ctrl-Z) is not mistaken + for a hang: the stall clock is monotonic and the Worker discards a gap in its + own ticks. It kills with SIGKILL rather than `Deno.exit()` because an exit + requested from a Worker only ends the Worker — the spinning main thread + carries on. +- **Telling the watchdog about legitimately synchronous work.** The watchdog + can't tell an infinite loop from a long computation, only whether the loop is + making progress. `@dune/core/watchdog` exports two functions for code that + blocks on purpose: `watchdogProgress()`, called per item inside a long + synchronous loop (counts as a heartbeat; rate-limited, so cheap), and + `watchdogGrace(ms, reason)`, a declared busy window for a single long native + call (capped at 10 minutes, nests, expires on its own, logged each time). A + loop that calls `watchdogProgress()` on every iteration of an unbounded loop + looks alive forever; use it only where the loop has a natural end. Measured + on a 4,474-page site: startup indexing and the search build never held the + event loop for over a second, so none of Dune's own loops needed either call. - The main thread also logs `[dune] event loop was blocked for ~N.Ns` whenever it was held for over a second, which shows a creeping problem before it becomes a hang. diff --git a/deno.json b/deno.json index da4f135c..b4877555 100644 --- a/deno.json +++ b/deno.json @@ -54,7 +54,8 @@ "./i18n": "./src/i18n/rtl.ts", "./api": "./src/api/handlers.ts", "./bootstrap": "./src/runtime/bootstrap.ts", - "./fresh-app": "./src/runtime/server.ts" + "./fresh-app": "./src/runtime/server.ts", + "./watchdog": "./src/runtime/watchdog.ts" }, "imports": { "@dune/plugin-admin": "jsr:@dune/plugin-admin@^3.0", diff --git a/src/cli/dev.ts b/src/cli/dev.ts index e97bd636..81e309b5 100644 --- a/src/cli/dev.ts +++ b/src/cli/dev.ts @@ -40,7 +40,7 @@ export async function devCommand(root: string, options: DevOptions = {}) { // A server whose event loop is stuck can't answer anything, log anything or // be stopped gracefully; see runtime/watchdog.ts. Dev has no graceful-exit // path to hook (Ctrl-C / --watch restarts end the process), so no stop(). - startWatchdog({ root, label: "dune dev" }); + const watchdog = startWatchdog({ root, label: "dune dev" }); registerEsbuildWorkerCleanup(); @@ -67,6 +67,7 @@ export async function devCommand(root: string, options: DevOptions = {}) { await manager.init(root, { port, debug, dev: true }); console.log(`\n 🌐 http://localhost:${port} (${manager.siteCount()} sites)\n`); Deno.serve({ port, handler: (req, info) => manager.handle(req, info) }); + watchdog.ready(); return; } @@ -213,6 +214,8 @@ export async function devCommand(root: string, options: DevOptions = {}) { const { app, notifyReload } = await createDuneApp(ctx, { root, port, debug, dev: true }); // Wire the content watcher's notifyReload reference now that the app is built. notifyContentReload = notifyReload; + // Startup is over: from here on the normal (short) stall limit applies. + watchdog.ready(); return app; }, { port }); } diff --git a/src/cli/serve.ts b/src/cli/serve.ts index 4aba5861..af7f8e6c 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -175,6 +175,7 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { }, ); + watchdog.ready(); await server.finished; await drainInFlight(() => inFlight, drainDeadlineMs); cleanup(); @@ -380,6 +381,7 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { }, ); + watchdog.ready(); await server.finished; // Safety-net drain poll — in normal operation inFlight is already 0 here diff --git a/src/runtime/watchdog.ts b/src/runtime/watchdog.ts index d149a0a2..4c86edef 100644 --- a/src/runtime/watchdog.ts +++ b/src/runtime/watchdog.ts @@ -14,13 +14,35 @@ * supervisor (systemd `Restart=`, the `dune dev` wrapper's exit) sees a * dead process instead of a zombie. SIGKILL, not `Deno.exit()`: an exit * requested from a worker only ends the worker; the spinning main thread - * carries on. - * - `warn`: same report, no kill. + * carries on. The kill always happens: writing the report is raced against + * a timeout. + * - `warn`: same report, no kill. Used automatically when an inspector flag + * (`--inspect`, `--inspect-brk`, ...) is present, because a breakpoint + * pauses only the main thread and would otherwise look like a hang. * - `off`: nothing starts. * - * The main thread also notices its own timer drift and logs a one-line warning - * when the loop was held for over a second, which makes a creeping problem - * visible before it becomes a hang. + * It measures a lack of *progress*, not of idleness, and it cannot tell an + * infinite loop from a long computation by looking at the thread. Work that is + * legitimately synchronous says so: + * - `watchdogProgress()`, called per item inside a long synchronous loop, + * counts as a heartbeat (rate-limited, so calling it 10k times is cheap). + * It only protects loops that call it: an unbounded loop that calls it on + * every iteration looks alive forever. Most indexing/compile loops are + * async and yield anyway; only genuinely synchronous ones need it. + * - `watchdogGrace(ms, reason)` declares an expected-busy window for work + * where per-item progress can't be reported (one long native call). It is + * capped, auto-expires, nests, and is logged when used. + * + * Two further guards against false kills: the stall clock is monotonic + * (`performance.now()`), and the Worker treats a tick of its own that ran + * late as "the whole process was suspended" (laptop sleep, Ctrl-Z / SIGSTOP) + * and resets rather than fires. Until the server reports `ready()` the limit + * is the much longer startup limit, so a big site's initial indexing can't + * put a supervised process into a kill/restart loop. + * + * The main thread also logs a one-line warning when its own timer ran over a + * second late, which makes a creeping problem visible before it becomes a + * hang. * * @module */ @@ -30,25 +52,53 @@ export type WatchdogMode = "exit" | "warn" | "off"; export interface WatchdogOptions { /** Site root; reports go to `/.dune/hangs/`. */ root: string; - /** What to do on a stall. Default: `DUNE_WATCHDOG` env, else `exit`. */ + /** What to do on a stall. Default: `DUNE_WATCHDOG` env, else `exit` (`warn` under an inspector). */ mode?: WatchdogMode; - /** Heartbeat silence that counts as a stall. Default: `DUNE_WATCHDOG_TIMEOUT_MS` env, else 15000. */ + /** Heartbeat silence that counts as a stall once ready. Default: `DUNE_WATCHDOG_TIMEOUT_MS` env, else 15000. */ stallMs?: number; + /** The limit before `ready()` is called. Default: `DUNE_WATCHDOG_STARTUP_TIMEOUT_MS` env, else 120000. */ + startupStallMs?: number; /** Heartbeat period. Default 1000; tests shorten it. */ heartbeatMs?: number; /** Event-loop delay (over the expected tick) that logs a warning. Default 1000. */ lagWarnMs?: number; /** Shown in the report and log line, e.g. `dev` / `serve`. */ label?: string; + /** How long writing the report may take before the kill goes ahead anyway. Default 5000; tests shorten it. */ + reportTimeoutMs?: number; } export interface Watchdog { + /** The server is up: switch from the startup limit to the normal one. */ + ready(): void; stop(): void; } +const DEFAULT_STALL_MS = 15_000; +const DEFAULT_STARTUP_STALL_MS = 120_000; +const DEFAULT_HEARTBEAT_MS = 1_000; +const DEFAULT_LAG_WARN_MS = 1_000; +/** `watchdogProgress()` posts at most this often. */ +const PROGRESS_MIN_INTERVAL_MS = 250; +/** No grace window may be longer than this, whatever the caller asks. */ +export const MAX_GRACE_MS = 10 * 60_000; +/** Reports kept in `.dune/hangs/`; older ones are deleted. */ +const MAX_REPORTS = 20; +/** Writing the report must not be able to stop the kill. */ +const REPORT_TIMEOUT_MS = 5_000; + // The running watchdog's Worker, so request middleware can report to it. let activeWorker: Worker | null = null; -let nextRequestId = 0; +let nextId = 0; +let lastProgressPost = 0; + +/** + * Hide anything token-shaped before a request path goes into a report: any + * path segment longer than 24 characters is cut to its first six. + */ +export function maskPath(path: string): string { + return path.split("/").map((seg) => (seg.length > 24 ? `${seg.slice(0, 6)}…` : seg)).join("/"); +} /** * Record a request as in flight; call the returned function when it @@ -59,14 +109,40 @@ let nextRequestId = 0; export function trackRequest(method: string, path: string): () => void { const worker = activeWorker; if (!worker) return () => {}; - const id = ++nextRequestId; - worker.postMessage({ type: "req+", id, desc: `${method} ${path}` }); + const id = ++nextId; + worker.postMessage({ type: "req+", id, desc: `${method} ${maskPath(path)}` }); return () => worker.postMessage({ type: "req-", id }); } -const DEFAULT_STALL_MS = 15_000; -const DEFAULT_HEARTBEAT_MS = 1_000; -const DEFAULT_LAG_WARN_MS = 1_000; +/** + * Report progress from inside a long synchronous loop: counts as a heartbeat. + * Cheap and rate-limited; a no-op when no watchdog is running. Call it once + * per item, never in a loop that has no natural end. + */ +export function watchdogProgress(): void { + const worker = activeWorker; + if (!worker) return; + const now = performance.now(); + if (now - lastProgressPost < PROGRESS_MIN_INTERVAL_MS) return; + lastProgressPost = now; + worker.postMessage({ type: "beat" }); +} + +/** + * Declare that the process is expected to be busy for up to `ms` (capped at + * `MAX_GRACE_MS`): the stall check pauses until the returned function is + * called or the window expires, whichever is first. Windows nest. Use it only + * for work that cannot report progress per item; it is logged each time. + */ +export function watchdogGrace(ms: number, reason: string): () => void { + const worker = activeWorker; + if (!worker) return () => {}; + const capped = Math.max(0, Math.min(ms, MAX_GRACE_MS)); + const id = ++nextId; + console.warn(`[dune] watchdog: grace for "${reason}" (up to ${Math.round(capped / 1000)}s)`); + worker.postMessage({ type: "grace+", id, ms: capped }); + return () => worker.postMessage({ type: "grace-", id }); +} /** Parse `DUNE_WATCHDOG`; anything unrecognised falls back to the default. */ export function parseWatchdogMode(value: string | undefined): WatchdogMode { @@ -74,19 +150,52 @@ export function parseWatchdogMode(value: string | undefined): WatchdogMode { return v === "off" || v === "warn" || v === "exit" ? v : "exit"; } -function parseStallMs(value: string | undefined): number { +function parseMs(value: string | undefined, fallback: number): number { const n = Number(value); - return Number.isFinite(n) && n >= 1000 ? n : DEFAULT_STALL_MS; + return Number.isFinite(n) && n >= 1000 ? n : fallback; +} + +/** Was this process started with an inspector flag? (`Deno.args` only has script args, so ask `ps`.) */ +export function inspectorAttached(): boolean { + if (Deno.build.os === "windows") return false; + try { + const out = new Deno.Command("ps", { + args: ["-o", "args=", "-p", String(Deno.pid)], + stdout: "piped", + stderr: "null", + }).outputSync(); + return /(^|\s)--inspect(-brk|-wait)?([=\s]|$)/.test(new TextDecoder().decode(out.stdout)); + } catch { + return false; + } } // Runs in the Worker. Kept as a string so it works from any module origin // (jsr:/https: included), where a relative worker URL would not. const WORKER_SOURCE = ` let cfg = null; -let last = Date.now(); +let last = performance.now(); +let lastTick = performance.now(); let fired = false; -const inflight = new Map(); // id -> { desc, since } +let ready = false; +const TICK_MS = 500; +const inflight = new Map(); // id -> { desc, since } +const graces = new Map(); // id -> expiry (performance.now() ms) const pad = (n) => String(n).padStart(2, "0"); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function prune(dir) { + try { + const names = []; + for await (const e of Deno.readDir(dir)) if (/^hang-.*\\.txt$/.test(e.name) && !e.name.endsWith(".sample.txt")) names.push(e.name); + names.sort(); + for (const n of names.slice(0, Math.max(0, names.length - cfg.maxReports))) { + const base = n.slice(0, -4); + await Deno.remove(dir + "/" + n).catch(() => {}); + await Deno.remove(dir + "/" + base + ".sample.txt").catch(() => {}); + } + } catch { /* retention is best effort */ } +} async function report(silentMs) { const now = new Date(); @@ -101,16 +210,17 @@ async function report(silentMs) { "command: " + cfg.label, "root: " + cfg.root, "deno: " + Deno.version.deno, - "uptime: " + Math.round((Date.now() - cfg.startedAt) / 1000) + "s", + "uptime: " + Math.round((performance.now() - cfg.startedAt) / 1000) + "s", + "phase: " + (ready ? "serving" : "startup"), "stalled: main thread silent for " + Math.round(silentMs / 1000) + "s (limit " + - Math.round(cfg.stallMs / 1000) + "s)", + Math.round(limit() / 1000) + "s)", "action: " + (cfg.mode === "exit" ? "SIGKILL" : "none (DUNE_WATCHDOG=warn)"), ]; - const now2 = Date.now(); + const nowMs = performance.now(); const pending = [...inflight.values()].sort((a, b) => a.since - b.since); lines.push("in-flight requests: " + pending.length); for (const r of pending.slice(0, 20)) { - lines.push(" " + r.desc + " (" + Math.round((now2 - r.since) / 1000) + "s)"); + lines.push(" " + r.desc + " (" + Math.round((nowMs - r.since) / 1000) + "s)"); } let sampled = false; try { @@ -128,41 +238,87 @@ async function report(silentMs) { else lines.push("sample: unavailable on this platform; run 'eu-stack -p " + cfg.pid + "' or 'gdb -p " + cfg.pid + "' next time before killing"); await Deno.writeTextFile(base + ".txt", lines.join("\\n") + "\\n"); lines.push("report: " + base + ".txt"); + await prune(dir); } catch (err) { lines.push("report: could not be written (" + err + ")"); } return lines; } -self.onmessage = async (e) => { +function limit() { + return ready ? cfg.stallMs : cfg.startupStallMs; +} + +function graceActive(t) { + for (const [id, expiry] of graces) { + if (expiry <= t) graces.delete(id); + } + return graces.size > 0; +} + +async function onStall(silent) { + console.error("[dune] watchdog: the main thread has not responded for " + + Math.round(silent / 1000) + "s (event loop blocked)"); + // The kill must not depend on the report: a hung 'sample', a slow or + // network filesystem, or a failing mkdir would otherwise leave the process + // running forever. + const lines = await Promise.race([report(silent), sleep(cfg.reportTimeoutMs).then(() => null)]); + if (lines === null) { + console.error("[dune] watchdog: the report did not finish within " + Math.round(cfg.reportTimeoutMs / 1000) + "s"); + } else { + for (const l of lines.filter((l) => /^(in-flight| [A-Z]+ |sample|report)/.test(l))) { + console.error("[dune] watchdog: " + l.trim()); + } + } + if (cfg.mode === "exit") { + console.error("[dune] watchdog: killing pid " + cfg.pid + " so it can be restarted"); + try { + Deno.kill(cfg.pid, "SIGKILL"); + return; + } catch (err) { + // Typically a missing --allow-run. Say so, and fall back to reporting + // only: silently never firing again would be the worst outcome. + console.error("[dune] watchdog: could not kill pid " + cfg.pid + " (" + err + "); " + + "continuing in warn mode"); + cfg.mode = "warn"; + } + } + fired = false; // keep watching, and report again if it stays stuck + last = performance.now(); +} + +self.onmessage = (e) => { const msg = e.data; if (msg.type === "init") { cfg = msg; - last = Date.now(); - setInterval(async () => { - const silent = Date.now() - last; - if (fired || silent < cfg.stallMs) return; + last = lastTick = performance.now(); + setInterval(() => { + const t = performance.now(); + const gap = t - lastTick; + lastTick = t; + // Our own timer ran far later than scheduled: the whole process was + // suspended (sleep, SIGSTOP/Ctrl-Z). The main thread could not have + // beaten us to the next heartbeat, so forget the gap. + if (gap > TICK_MS * 2) { last = t; return; } + const silent = t - last; + if (fired || silent < limit() || graceActive(t)) return; fired = true; - console.error("[dune] watchdog: the main thread has not responded for " + - Math.round(silent / 1000) + "s (event loop blocked)"); - const lines = await report(silent); - for (const l of lines.filter((l) => /^(in-flight| [A-Z]+ |sample|report)/.test(l))) { - console.error("[dune] watchdog: " + l.trim()); - } - if (cfg.mode === "exit") { - console.error("[dune] watchdog: killing pid " + cfg.pid + " so it can be restarted"); - Deno.kill(cfg.pid, "SIGKILL"); - } else { - fired = false; // 'warn': keep watching, and report again if it stays stuck - last = Date.now(); - } - }, 500); + onStall(silent); + }, TICK_MS); } else if (msg.type === "beat") { - last = Date.now(); + last = performance.now(); + } else if (msg.type === "ready") { + ready = true; + last = performance.now(); } else if (msg.type === "req+") { - inflight.set(msg.id, { desc: msg.desc, since: Date.now() }); + inflight.set(msg.id, { desc: msg.desc, since: performance.now() }); } else if (msg.type === "req-") { inflight.delete(msg.id); + } else if (msg.type === "grace+") { + graces.set(msg.id, performance.now() + msg.ms); + } else if (msg.type === "grace-") { + graces.delete(msg.id); + last = performance.now(); } }; `; @@ -172,10 +328,18 @@ self.onmessage = async (e) => { * it; a no-op handle when the mode is `off`. */ export function startWatchdog(opts: WatchdogOptions): Watchdog { - const mode = opts.mode ?? parseWatchdogMode(Deno.env.get("DUNE_WATCHDOG")); - if (mode === "off") return { stop() {} }; + let mode = opts.mode ?? parseWatchdogMode(Deno.env.get("DUNE_WATCHDOG")); + if (mode === "off") return { ready() {}, stop() {} }; + if (mode === "exit" && opts.mode === undefined && Deno.env.get("DUNE_WATCHDOG") === undefined && inspectorAttached()) { + console.warn("[dune] watchdog: inspector detected, reporting stalls instead of killing (DUNE_WATCHDOG=warn)"); + mode = "warn"; + } - const stallMs = opts.stallMs ?? parseStallMs(Deno.env.get("DUNE_WATCHDOG_TIMEOUT_MS")); + const stallMs = opts.stallMs ?? parseMs(Deno.env.get("DUNE_WATCHDOG_TIMEOUT_MS"), DEFAULT_STALL_MS); + const startupStallMs = Math.max( + stallMs, + opts.startupStallMs ?? parseMs(Deno.env.get("DUNE_WATCHDOG_STARTUP_TIMEOUT_MS"), DEFAULT_STARTUP_STALL_MS), + ); const heartbeatMs = opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS; const lagWarnMs = opts.lagWarnMs ?? DEFAULT_LAG_WARN_MS; @@ -189,15 +353,18 @@ export function startWatchdog(opts: WatchdogOptions): Watchdog { type: "init", mode, stallMs, + startupStallMs, root: opts.root, pid: Deno.pid, label: opts.label ?? "dune", - startedAt: Date.now(), + startedAt: performance.now(), + maxReports: MAX_REPORTS, + reportTimeoutMs: opts.reportTimeoutMs ?? REPORT_TIMEOUT_MS, }); - let expected = Date.now() + heartbeatMs; + let expected = performance.now() + heartbeatMs; const timer = setInterval(() => { - const now = Date.now(); + const now = performance.now(); const lag = now - expected; expected = now + heartbeatMs; worker.postMessage({ type: "beat" }); @@ -205,7 +372,6 @@ export function startWatchdog(opts: WatchdogOptions): Watchdog { console.warn(`[dune] event loop was blocked for ~${(lag / 1000).toFixed(1)}s`); } }, heartbeatMs); - // The heartbeat must not be what keeps a finished process alive. Deno.unrefTimer(timer); @@ -222,5 +388,10 @@ 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); - return { stop }; + return { + ready: () => { + if (!stopped) worker.postMessage({ type: "ready" }); + }, + stop, + }; } diff --git a/tests/runtime/watchdog_test.ts b/tests/runtime/watchdog_test.ts index e69471ed..dfabeee0 100644 --- a/tests/runtime/watchdog_test.ts +++ b/tests/runtime/watchdog_test.ts @@ -8,10 +8,12 @@ import { assert, assertEquals, assertStringIncludes } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { join } from "@std/path"; -import { parseWatchdogMode } from "../../src/runtime/watchdog.ts"; +import { maskPath, parseWatchdogMode } from "../../src/runtime/watchdog.ts"; const WATCHDOG_URL = new URL("../../src/runtime/watchdog.ts", import.meta.url).href; -const CHILD_DEADLINE_MS = 20_000; +const CHILD_DEADLINE_MS = 25_000; +const noSanitize = { sanitizeOps: false, sanitizeResources: false }; +const unixOnly = { ...noSanitize, ignore: Deno.build.os === "windows" }; interface ChildResult { code: number; @@ -21,19 +23,30 @@ interface ChildResult { timedOut: boolean; } +interface ChildOptions { + env?: Record; + /** Extra args to `deno run` (permissions, inspector flags). Default: `-A`. */ + denoArgs?: string[]; +} + /** Run `body` as a module that has already started a watchdog configured by `opts`. */ -async function runChild(root: string, opts: string, body: string): Promise { +async function runChild(root: string, opts: string, body: string, extra: ChildOptions = {}): Promise { const script = join(root, "child.ts"); await Deno.writeTextFile( script, - `import { startWatchdog } from ${JSON.stringify(WATCHDOG_URL)};\n` + + `import { startWatchdog, watchdogProgress, watchdogGrace } from ${JSON.stringify(WATCHDOG_URL)};\n` + `const wd = startWatchdog({ root: ${JSON.stringify(root)}, ${opts} });\n${body}\n`, ); const child = new Deno.Command(Deno.execPath(), { - args: ["run", "-A", script], + args: ["run", ...(extra.denoArgs ?? ["-A"]), script], + env: extra.env, stdout: "piped", stderr: "piped", }).spawn(); + return await finish(child); +} + +async function finish(child: Deno.ChildProcess): Promise { let timedOut = false; const killer = setTimeout(() => { timedOut = true; @@ -72,6 +85,13 @@ async function reports(root: string): Promise { } } +const mainReports = (names: string[]) => names.filter((f) => /^hang-.*\.txt$/.test(f) && !f.endsWith(".sample.txt")); + +/** Busy-wait `ms` of wall time. */ +const SPIN = (ms: number) => `{ const t = Date.now(); while (Date.now() - t < ${ms}) {} }`; + +// ── units ──────────────────────────────────────────────────────────────────── + Deno.test("parseWatchdogMode: accepts the three modes, defaults anything else to exit", () => { assertEquals(parseWatchdogMode("off"), "off"); assertEquals(parseWatchdogMode("WARN"), "warn"); @@ -80,31 +100,38 @@ Deno.test("parseWatchdogMode: accepts the three modes, defaults anything else to assertEquals(parseWatchdogMode("nonsense"), "exit"); }); +Deno.test("maskPath: short segments are kept, token-sized ones are cut", () => { + assertEquals(maskPath("/blog/my-first-post/"), "/blog/my-first-post/"); + assertEquals(maskPath("/auth/verify/0123456789abcdef0123456789abcdef"), "/auth/verify/012345…"); + assertEquals(maskPath("/a/b"), "/a/b"); +}); + +// ── the kill ───────────────────────────────────────────────────────────────── + Deno.test( "watchdog (exit): a main thread stuck in a loop is SIGKILLed, with a report written first", - { sanitizeOps: false, sanitizeResources: false }, + noSanitize, async () => { await withRoot(async (root) => { const r = await runChild( root, - `mode: "exit", stallMs: 1500, heartbeatMs: 100`, + `mode: "exit", stallMs: 1500, startupStallMs: 1500, heartbeatMs: 100`, `import { trackRequest } from ${JSON.stringify(WATCHDOG_URL)};\n` + `trackRequest("GET", "/fine")();\n` + - `trackRequest("GET", "/stuck");\n` + + `trackRequest("GET", "/stuck/0123456789abcdef0123456789abcdef");\n` + `setTimeout(() => { console.log("spinning"); while (true) {} }, 300);\n` + `Deno.serve({ port: 0, onListen: () => {} }, () => new Response("ok"));`, ); assert(!r.timedOut, "child was still running at the deadline — the watchdog did not fire"); assertEquals(r.signal, "SIGKILL"); assertStringIncludes(r.stderr, "event loop blocked"); - const files = await reports(root); - assert(files.some((f) => /^hang-.*\.txt$/.test(f) && !f.endsWith(".sample.txt")), `no report in ${files}`); - const report = await Deno.readTextFile( - join(root, ".dune", "hangs", files.find((f) => !f.endsWith(".sample.txt"))!), - ); + const files = mainReports(await reports(root)); + assert(files.length > 0, `no report in ${await reports(root)}`); + const report = await Deno.readTextFile(join(root, ".dune", "hangs", files[0])); assertStringIncludes(report, "main thread silent for"); assertStringIncludes(report, "action: SIGKILL"); - assertStringIncludes(report, "GET /stuck"); + assertStringIncludes(report, "GET /stuck/"); + assert(!report.includes("0123456789abcdef0123456789abcdef"), "a token-sized path segment must be masked"); assert(!report.includes("GET /fine"), "a finished request must not be listed"); }); }, @@ -112,15 +139,15 @@ Deno.test( Deno.test( "watchdog (warn): reports a stall but leaves the process alone", - { sanitizeOps: false, sanitizeResources: false }, + noSanitize, async () => { await withRoot(async (root) => { // Block for ~2.5s (past the 1.5s limit), then carry on and exit normally. const r = await runChild( root, - `mode: "warn", stallMs: 1500, heartbeatMs: 100`, + `mode: "warn", stallMs: 1500, startupStallMs: 1500, heartbeatMs: 100`, `setTimeout(async () => {\n` + - ` const t = Date.now(); while (Date.now() - t < 2500) {}\n` + + ` ${SPIN(2500)}\n` + ` // Let the report (and macOS 'sample') finish before stopping.\n` + ` for (let i = 0; i < 100; i++) {\n` + ` try { for await (const e of Deno.readDir(${JSON.stringify(root)} + "/.dune/hangs")) if (e.name.endsWith(".txt") && !e.name.endsWith(".sample.txt")) { wd.stop(); Deno.exit(0); } } catch {}\n` + @@ -140,14 +167,13 @@ Deno.test( Deno.test( "watchdog: a briefly blocked loop under the limit is not reported, but the lag is logged", - { sanitizeOps: false, sanitizeResources: false }, + noSanitize, async () => { await withRoot(async (root) => { const r = await runChild( root, `mode: "exit", stallMs: 5000, heartbeatMs: 100, lagWarnMs: 500`, - `setTimeout(() => { const t = Date.now(); while (Date.now() - t < 900) {} }, 300);\n` + - `setTimeout(() => wd.stop(), 1500);`, + `setTimeout(() => { ${SPIN(900)} }, 300);\n` + `setTimeout(() => wd.stop(), 1500);`, ); assert(!r.timedOut); assertEquals(r.code, 0); @@ -159,17 +185,13 @@ Deno.test( Deno.test( "watchdog: after stop(), a finished process exits on its own", - { sanitizeOps: false, sanitizeResources: false }, + noSanitize, async () => { await withRoot(async (root) => { // A Worker can't be unref'd, so stop() is the contract: once it has // been called (as `dune serve`'s shutdown does) nothing of the // watchdog may hold the process open. - const r = await runChild( - root, - `mode: "exit", heartbeatMs: 100`, - `setTimeout(() => wd.stop(), 300);`, - ); + const r = await runChild(root, `mode: "exit", heartbeatMs: 100`, `setTimeout(() => wd.stop(), 300);`); assert(!r.timedOut, "process lingered after its work was done"); assertEquals(r.code, 0); }); @@ -178,7 +200,7 @@ Deno.test( Deno.test( "watchdog (off): starts nothing and the process exits normally", - { sanitizeOps: false, sanitizeResources: false }, + noSanitize, async () => { await withRoot(async (root) => { const r = await runChild(root, `mode: "off"`, `console.log("done");`); @@ -188,3 +210,259 @@ Deno.test( }); }, ); + +// ── not killing what shouldn't be killed ───────────────────────────────────── + +Deno.test( + "watchdog: a process suspended and resumed (laptop sleep, Ctrl-Z) is not mistaken for a hang", + unixOnly, + async () => { + await withRoot(async (root) => { + const script = join(root, "child.ts"); + await Deno.writeTextFile( + script, + `import { startWatchdog } from ${JSON.stringify(WATCHDOG_URL)};\n` + + `const wd = startWatchdog({ root: ${JSON.stringify(root)}, mode: "exit", stallMs: 1500, startupStallMs: 1500, heartbeatMs: 100 });\n` + + `wd.ready();\nconsole.log("ready");\nsetTimeout(() => wd.stop(), 7000);\n`, + ); + const child = new Deno.Command(Deno.execPath(), { args: ["run", "-A", script], stdout: "piped", stderr: "piped" }).spawn(); + // Wait until the watchdog is running, then freeze the whole process well past the limit. + const reader = child.stdout.getReader(); + const first = await reader.read(); + assertStringIncludes(new TextDecoder().decode(first.value), "ready"); + reader.releaseLock(); + await new Promise((r) => setTimeout(r, 400)); + Deno.kill(child.pid, "SIGSTOP"); + await new Promise((r) => setTimeout(r, 3500)); + Deno.kill(child.pid, "SIGCONT"); + const r = await finish(child); + assert(!r.timedOut); + assertEquals(r.signal, null, `was killed after resume:\n${r.stderr}`); + assertEquals(r.code, 0); + assertEquals((await reports(root)).length, 0, "no report for a pause"); + }); + }, +); + +Deno.test( + "watchdog: until ready() the long startup limit applies; after it, the normal one", + noSanitize, + async () => { + await withRoot(async (root) => { + // 2.5s of blocking during startup is over the 1.5s normal limit but under the 6s startup limit. + const startup = await runChild( + root, + `mode: "exit", stallMs: 1500, startupStallMs: 6000, heartbeatMs: 100`, + `setTimeout(() => { ${SPIN(2500)} wd.stop(); }, 300);`, + ); + assert(!startup.timedOut); + assertEquals(startup.signal, null, `killed during startup:\n${startup.stderr}`); + assertEquals(startup.code, 0); + + // The same block after ready() is a stall. + const serving = await runChild( + root, + `mode: "exit", stallMs: 1500, startupStallMs: 6000, heartbeatMs: 100`, + `wd.ready();\nsetTimeout(() => { while (true) {} }, 300);`, + ); + assert(!serving.timedOut); + assertEquals(serving.signal, "SIGKILL"); + }); + }, +); + +Deno.test( + "watchdogProgress: a synchronous loop that keeps reporting progress survives, one that doesn't is killed", + noSanitize, + async () => { + await withRoot(async (root) => { + const alive = await runChild( + root, + `mode: "exit", stallMs: 1500, startupStallMs: 1500, heartbeatMs: 100`, + `wd.ready();\nsetTimeout(() => { const t = Date.now(); while (Date.now() - t < 3500) { watchdogProgress(); } wd.stop(); }, 300);`, + ); + assert(!alive.timedOut); + assertEquals(alive.signal, null, `a loop reporting progress was killed:\n${alive.stderr}`); + assertEquals(alive.code, 0); + + const stuck = await runChild( + root, + `mode: "exit", stallMs: 1500, startupStallMs: 1500, heartbeatMs: 100`, + `wd.ready();\nsetTimeout(() => { while (true) {} }, 300);`, + ); + assertEquals(stuck.signal, "SIGKILL"); + }); + }, +); + +Deno.test( + "watchdogGrace: protects a declared busy window, is logged, and expires on its own", + noSanitize, + async () => { + await withRoot(async (root) => { + const within = await runChild( + root, + `mode: "exit", stallMs: 1500, startupStallMs: 1500, heartbeatMs: 100`, + `wd.ready();\nsetTimeout(() => { const release = watchdogGrace(10000, "test import"); ${SPIN(3500)} release(); wd.stop(); }, 300);`, + ); + assert(!within.timedOut); + assertEquals(within.signal, null, `killed inside its grace window:\n${within.stderr}`); + assertStringIncludes(within.stderr, 'grace for "test import"'); + + // A 1s window followed by a loop that never ends: the window must lapse and the kill follow. + const expired = await runChild( + root, + `mode: "exit", stallMs: 1500, startupStallMs: 1500, heartbeatMs: 100`, + `wd.ready();\nsetTimeout(() => { watchdogGrace(1000, "short"); while (true) {} }, 300);`, + ); + assert(!expired.timedOut, "grace never expired"); + assertEquals(expired.signal, "SIGKILL"); + }); + }, +); + +Deno.test( + "watchdog: nested grace windows are refcounted — releasing one leaves the other in force", + noSanitize, + async () => { + await withRoot(async (root) => { + const r = await runChild( + root, + `mode: "exit", stallMs: 1500, startupStallMs: 1500, heartbeatMs: 100`, + `wd.ready();\nsetTimeout(() => {\n` + + ` const outer = watchdogGrace(10000, "outer");\n` + + ` const inner = watchdogGrace(10000, "inner");\n` + + ` inner();\n` + + ` ${SPIN(3000)}\n` + + ` outer(); wd.stop();\n` + + `}, 300);`, + ); + assert(!r.timedOut); + assertEquals(r.signal, null, `outer window should still have protected the loop:\n${r.stderr}`); + }); + }, +); + +Deno.test( + "watchdog: under an inspector it reports instead of killing, since a breakpoint pauses only the main thread", + unixOnly, + async () => { + await withRoot(async (root) => { + const env = Deno.env.toObject(); + delete env.DUNE_WATCHDOG; + const r = await runChild( + root, + `stallMs: 1500, startupStallMs: 1500, heartbeatMs: 100`, // no mode option and no DUNE_WATCHDOG: the default applies + `wd.ready();\nsetTimeout(() => { ${SPIN(2500)} setTimeout(() => wd.stop(), 1500); }, 300);`, + { denoArgs: ["-A", "--inspect=127.0.0.1:19349"], env }, + ); + assert(!r.timedOut); + assertEquals(r.signal, null, `was killed under an inspector:\n${r.stderr}`); + assertEquals(r.code, 0); + assertStringIncludes(r.stderr, "inspector detected"); + assertStringIncludes(r.stderr, "event loop blocked"); + }); + }, +); + +// ── the kill must always happen, and say so when it can't ──────────────────── + +Deno.test( + "watchdog: the kill goes ahead even when writing the report hangs (a stuck 'sample')", + { ...noSanitize, ignore: Deno.build.os !== "darwin" }, + async () => { + await withRoot(async (root) => { + const bin = join(root, "fakebin"); + await Deno.mkdir(bin); + await Deno.writeTextFile(join(bin, "sample"), "#!/bin/sh\nsleep 60\n"); + await Deno.chmod(join(bin, "sample"), 0o755); + const r = await runChild( + root, + `mode: "exit", stallMs: 1500, startupStallMs: 1500, heartbeatMs: 100, reportTimeoutMs: 1000`, + `wd.ready();\nsetTimeout(() => { while (true) {} }, 300);`, + { env: { ...Deno.env.toObject(), PATH: `${bin}:${Deno.env.get("PATH")}` } }, + ); + assert(!r.timedOut, "a hung report prevented the kill"); + assertEquals(r.signal, "SIGKILL"); + assertStringIncludes(r.stderr, "did not finish"); + }); + }, +); + +// ── bookkeeping ────────────────────────────────────────────────────────────── + +Deno.test( + "watchdog: old reports are pruned so .dune/hangs/ can't grow without bound", + noSanitize, + async () => { + await withRoot(async (root) => { + const dir = join(root, ".dune", "hangs"); + await Deno.mkdir(dir, { recursive: true }); + for (let i = 0; i < 24; i++) { + const stamp = `20200101-0000${String(i).padStart(2, "0")}-1`; + await Deno.writeTextFile(join(dir, `hang-${stamp}.txt`), "old\n"); + await Deno.writeTextFile(join(dir, `hang-${stamp}.sample.txt`), "old sample\n"); + } + const r = await runChild( + root, + `mode: "warn", stallMs: 1500, startupStallMs: 1500, heartbeatMs: 100`, + `wd.ready();\nsetTimeout(async () => {\n` + + ` ${SPIN(2500)}\n` + + ` for (let i = 0; i < 100; i++) {\n` + + ` let n = 0; for await (const e of Deno.readDir(${JSON.stringify(dir)})) if (/^hang-2026|^hang-20[3-9]/.test(e.name) && e.name.endsWith(".txt") && !e.name.endsWith(".sample.txt")) n++;\n` + + ` if (n > 0) { await new Promise((r) => setTimeout(r, 500)); break; }\n` + + ` await new Promise((r) => setTimeout(r, 100));\n` + + ` }\n` + + ` wd.stop();\n` + + `}, 300);`, + ); + assert(!r.timedOut); + const names = await reports(root); + assertEquals(mainReports(names).length, 20, `expected 20 reports kept, got ${mainReports(names).length}`); + assertEquals(names.filter((n) => n.endsWith(".sample.txt")).length <= 20, true, "pruned reports take their samples with them"); + }); + }, +); + +// ── what the supervisor and the dune dev wrapper see ───────────────────────── + +Deno.test( + "watchdog: a wrapper around a killed process exits non-zero, and a child that reads stdin (like esbuild) does not outlive it", + unixOnly, + async () => { + await withRoot(async (root) => { + const forward = new URL("../../src/cli/forward-signals.ts", import.meta.url).href; + const pidFile = join(root, "grandchild.pid"); + await Deno.writeTextFile( + join(root, "server.ts"), + `import { startWatchdog } from ${JSON.stringify(WATCHDOG_URL)};\n` + + `const wd = startWatchdog({ root: ${JSON.stringify(root)}, mode: "exit", stallMs: 1500, startupStallMs: 1500, heartbeatMs: 100 });\n` + + `wd.ready();\n` + + // Stands in for the esbuild service: exits when its stdin closes (the parent died). + `const g = new Deno.Command(Deno.execPath(), { args: ["eval", "await Deno.stdin.read(new Uint8Array(1))"], stdin: "piped", stdout: "null", stderr: "null" }).spawn();\n` + + `await Deno.writeTextFile(${JSON.stringify(pidFile)}, String(g.pid));\n` + + `setTimeout(() => { while (true) {} }, 400);\n`, + ); + // Same shape as the dune re-exec wrapper (src/cli/reexec.ts). + await Deno.writeTextFile( + join(root, "wrapper.ts"), + `import { waitForwardingSignals } from ${JSON.stringify(forward)};\n` + + `const child = new Deno.Command(Deno.execPath(), { args: ["run", "-A", ${JSON.stringify(join(root, "server.ts"))}], stdout: "null", stderr: "null" }).spawn();\n` + + `const status = await waitForwardingSignals(child);\nDeno.exit(status.code);\n`, + ); + const wrapper = new Deno.Command(Deno.execPath(), { args: ["run", "-A", join(root, "wrapper.ts")], stdout: "piped", stderr: "piped" }).spawn(); + const r = await finish(wrapper); + assert(!r.timedOut, "the wrapper never saw its child die"); + assertEquals(r.code, 137, "the wrapper must exit non-zero so a supervisor sees a failure"); + + const gpid = Number(await Deno.readTextFile(pidFile)); + let alive = true; + for (let i = 0; i < 30 && alive; i++) { + await new Promise((res) => setTimeout(res, 100)); + const ps = await new Deno.Command("ps", { args: ["-p", String(gpid)], stdout: "null", stderr: "null" }).output(); + alive = ps.success; + } + assertEquals(alive, false, "a child reading the parent's stdin should end when the parent is SIGKILLed"); + }); + }, +); From 9c2508407b044c302537c81f15563fb7bfe45b21 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Mon, 26 Oct 2026 23:01:40 +0100 Subject: [PATCH 09/12] 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 --- CHANGELOG.md | 31 +++++--- deno.lock | 1 + src/cli/instances.ts | 50 +++++++++++- src/cli/stop.ts | 165 +++++++++++++++++++++++++++++++++------- tests/cli/ps_helpers.ts | 15 ++++ tests/cli/ps_test.ts | 44 +++++++++++ tests/cli/stop_test.ts | 102 +++++++++++++++++++++++-- 7 files changed, 359 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb8b7371..8ca1ec28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,18 +32,25 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what ### Added -- **`dune stop`** stops dune instances, with their child processes. A target - is required, so stopping every server on a machine always has to be asked - for by name: `dune stop ` (any process of an instance), `dune stop - --port `, `dune stop --idle` (only instances that are running but not - listening — hung or leftover processes; an instance whose port state is - unknown, with no `lsof`, is never assumed idle) or `dune stop --all`. It - sends SIGTERM and waits up to 10 seconds for a server to drain; a process - that ignores it is reported, and only `--force` escalates to SIGKILL. It - asks first (`--yes` skips the prompt; with no terminal it refuses unless - `--yes` is given), never signals itself or its parent processes, and exits 2 - for a usage error, 1 when a named pid/port matches nothing or something would - not stop, and 0 otherwise. `dune ps` stays read-only and now points at it. +- **`dune stop`** stops dune instances, with their whole process tree (including + any other tool a `deno task` wrapper started). A target is required, so + stopping every server on a machine always has to be asked for by name: `dune + stop ` (any process of an instance), `dune stop --port `, `dune stop + --idle` (running but not listening for at least 2 minutes — younger ones may + still be starting; an instance whose port state is unknown, with no `lsof`, + is never assumed idle) or `dune stop --all`. `--all` and `--idle` act only on + *confirmed* instances (a `config/site.yaml` at the site root): a command line + that merely looks like dune, such as another Deno project's `main.ts serve`, + is listed by `dune ps` but must be named by pid. Only your own processes are + considered. It sends SIGTERM and waits up to 10 seconds for a server to + drain; a process that ignores it is reported, and only `--force` escalates to + SIGKILL. Each pid's start time is re-checked immediately before it is + signalled, so a pid that exited and was reused while the prompt was open is + never hit, and a signal the OS refuses is reported as such. It asks first + (`--yes` skips the prompt; with no terminal it refuses unless `--yes` is + given), never signals itself or its parent processes, and exits 2 for a + usage error, 1 when a named pid/port matches nothing or something would not + stop, and 0 otherwise. `dune ps` stays read-only and now points at it. ## [0.34.14] — 2026-10-21 diff --git a/deno.lock b/deno.lock index a7861082..928541f3 100644 --- a/deno.lock +++ b/deno.lock @@ -5,6 +5,7 @@ "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.1", "jsr:@deno/loader@~0.3.10": "0.3.14", "jsr:@denosaurs/plug@1": "1.1.0", + "jsr:@dune/plugin-admin@*": "3.0.0", "jsr:@dune/plugin-admin@3": "3.0.0", "jsr:@fresh/build-id@1": "1.0.1", "jsr:@fresh/core@2": "2.3.3", diff --git a/src/cli/instances.ts b/src/cli/instances.ts index 820eac65..9dc6585b 100644 --- a/src/cli/instances.ts +++ b/src/cli/instances.ts @@ -68,6 +68,21 @@ export interface DuneInstance { portStatus: "listening" | "not-listening" | "unknown"; /** `ps`'s own elapsed-time string (e.g. "01:23:45" or "2-09:03:55"). */ elapsed: string | null; + /** `elapsed` in seconds, or null when unparseable. */ + ageSeconds: number | null; + /** + * True when this is verifiably a dune site (a `config/site.yaml` was found at + * its root). A command-line match alone is only a guess — any other Deno + * project can run `main.ts serve` — so `dune stop --all/--idle` never act on + * an unconfirmed instance; it must be named by pid. + */ + confirmed: boolean; + /** + * Start time of every pid in `pids` as read when this instance was found. + * `dune stop` re-checks each one right before signalling, so a pid that + * exited and was reused by an unrelated process in the meantime is left alone. + */ + startTimes: Record; /** The process's command line, for identifying unconfirmed instances. */ command: string; } @@ -99,10 +114,22 @@ export async function runCommand( export interface ProcRow { pid: number; ppid: number; + /** Owning user id. */ + uid: number; + /** `ps`'s start time ("Mon Oct 26 15:38:49 2026"): with the pid, identifies one process for its whole life. */ + started: string; elapsed: string; command: string; } +/** Seconds from `ps`'s elapsed-time string ("05:12", "01:23:45", "2-09:03:55"); null if unparseable. */ +export function parseElapsedSeconds(elapsed: string | null): number | null { + if (!elapsed) return null; + const m = elapsed.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/); + if (!m) return null; + return (Number(m[1] ?? 0) * 24 + Number(m[2] ?? 0)) * 3600 + Number(m[3]) * 60 + Number(m[4]); +} + /** * Snapshot of the whole process table with parent links, so a server's * wrapper/child/worker processes can be grouped without further spawns. @@ -113,15 +140,22 @@ export interface ProcRow { * cut off before the trailing `dev`/`serve` token the match depends on. */ export async function readProcessTable(): Promise { - const out = await runCommand("ps", ["-eo", "pid=,ppid=,etime=,command="], { + const out = await runCommand("ps", ["-eo", "pid=,ppid=,uid=,lstart=,etime=,command="], { COLUMNS: "4096", }); if (!out) return []; const rows: ProcRow[] = []; for (const line of out.split("\n")) { - const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/); + const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\w{3}\s+\w{3}\s+\d+\s+[\d:]+\s+\d{4})\s+(\S+)\s+(.*)$/); if (!m) continue; - rows.push({ pid: parseInt(m[1], 10), ppid: parseInt(m[2], 10), elapsed: m[3], command: m[4] }); + rows.push({ + pid: parseInt(m[1], 10), + ppid: parseInt(m[2], 10), + uid: parseInt(m[3], 10), + started: m[4].replace(/\s+/g, " "), + elapsed: m[5], + command: m[6], + }); } return rows; } @@ -326,7 +360,12 @@ async function confirmSiteRoot(dirs: (string | null)[]): Promise * are null in that case, and the caller should say so plainly. */ export async function findDuneInstances(): Promise { - const table = await readProcessTable(); + const allRows = await readProcessTable(); + // Only this user's processes: another user's server is not ours to list or + // signal (the kill would be refused anyway). Where the uid can't be read + // (not POSIX) nothing is filtered. + const myUid = Deno.build.os === "windows" ? null : Deno.uid(); + const table = myUid === null ? allRows : allRows.filter((r) => r.uid === myUid); const byPid = new Map(table.map((r) => [r.pid, r])); const children = new Map(); for (const r of table) children.set(r.ppid, [...(children.get(r.ppid) ?? []), r.pid]); @@ -395,6 +434,9 @@ export async function findDuneInstances(): Promise { ports, portStatus: !haveLsof ? "unknown" : ports.length > 0 ? "listening" : "not-listening", elapsed: c.elapsed, + ageSeconds: parseElapsedSeconds(c.elapsed), + confirmed: root !== null, + startTimes: Object.fromEntries(pids.flatMap((p) => byPid.get(p) ? [[p, byPid.get(p)!.started]] : [])), command: c.command, }); } diff --git a/src/cli/stop.ts b/src/cli/stop.ts index 4107df00..461d195a 100644 --- a/src/cli/stop.ts +++ b/src/cli/stop.ts @@ -2,9 +2,16 @@ * dune stop — stop dune dev/serve instances found by `dune ps`. * * A target is required: a PID (any process of an instance), `--port`, `--idle` - * (only instances that are running but not listening) or `--all`. A bare - * `dune stop` does nothing but print usage, so stopping every server on a - * machine always has to be asked for by name. + * (only instances that are running but not listening, and not just starting) + * or `--all`. A bare `dune stop` does nothing but print usage, so stopping + * every server on a machine always has to be asked for by name. + * + * `--all` and `--idle` act only on *confirmed* instances (a `config/site.yaml` + * was found at the site root): a command line that merely looks like dune + * (`main.ts serve`) could be any Deno project, so those must be named by pid. + * Only this user's processes are considered, and each pid's start time is + * re-checked immediately before it is signalled, so a pid that exited and was + * reused while the prompt was open is never hit. * * SIGTERM first, so a server can drain. A process that ignores it is reported, * and only `--force` escalates to SIGKILL (which can orphan a wrapper's child, @@ -41,6 +48,10 @@ export interface StopResult { survivors: number[]; /** Pids deliberately not signalled (this process and its ancestors). */ skipped: number[]; + /** Pids that had already exited, or whose pid now belongs to a different process: not signalled. */ + changed: number[]; + /** Pids the operating system refused to signal (owned by another user). */ + denied: number[]; } async function alivePids(pids: number[]): Promise { @@ -61,11 +72,42 @@ async function waitUntilGone(pids: number[], timeoutMs: number): Promise> { + const out = new Map(); + if (pids.length === 0) return out; + const text = await runCommand("ps", ["-o", "pid=,lstart=", "-p", pids.join(",")]); + for (const line of text?.split("\n") ?? []) { + const m = line.match(/^\s*(\d+)\s+(.*\S)\s*$/); + if (m) out.set(parseInt(m[1], 10), m[2].replace(/\s+/g, " ")); + } + return out; +} + +/** + * Signal each pid that is still the same process it was when the instance was + * found (same start time). Anything else is reported, not signalled: a pid + * can be reused by an unrelated process while the confirmation prompt is open. + */ +async function signalVerified( + pids: number[], + expected: Map, + signal: Deno.Signal, + sink: { changed: Set; denied: Set }, +): Promise { + const now = await currentStartTimes(pids); for (const pid of pids) { + const want = expected.get(pid); + if (!want || now.get(pid) !== want) { + sink.changed.add(pid); + continue; + } try { Deno.kill(pid, signal); - } catch { /* already gone */ } + } catch (err) { + if (err instanceof Deno.errors.PermissionDenied) sink.denied.add(pid); + else sink.changed.add(pid); // already gone + } } } @@ -84,19 +126,32 @@ export async function stopInstances( const targets = [...new Set(instances.flatMap((i) => i.pids))]; const skipped = targets.filter((p) => protectedPids.has(p)); const toSignal = targets.filter((p) => !protectedPids.has(p)); + const expected = new Map(); + for (const inst of instances) for (const p of inst.pids) expected.set(p, inst.startTimes[p]); + const sink = { changed: new Set(), denied: new Set() }; - signalAll(toSignal, "SIGTERM"); - const afterTerm = await waitUntilGone(toSignal, timeoutMs); + await signalVerified(toSignal, expected, "SIGTERM", sink); + const signalled = toSignal.filter((p) => !sink.changed.has(p) && !sink.denied.has(p)); + const afterTerm = await waitUntilGone(signalled, timeoutMs); let survivors = afterTerm; let killed: number[] = []; if (opts.force && afterTerm.length > 0) { - signalAll(afterTerm, "SIGKILL"); - survivors = await waitUntilGone(afterTerm, killTimeoutMs); - killed = afterTerm.filter((p) => !survivors.includes(p)); + await signalVerified(afterTerm, expected, "SIGKILL", sink); + const sent = afterTerm.filter((p) => !sink.changed.has(p) && !sink.denied.has(p)); + const left = await waitUntilGone(sent, killTimeoutMs); + survivors = afterTerm.filter((p) => left.includes(p) || sink.denied.has(p)); + killed = sent.filter((p) => !left.includes(p)); } - const gone = new Set(toSignal.filter((p) => !survivors.includes(p))); - return { stopped: toSignal.filter((p) => gone.has(p)), killed, survivors, skipped }; + const gone = new Set(signalled.filter((p) => !survivors.includes(p))); + return { + stopped: signalled.filter((p) => gone.has(p)), + killed, + survivors, + skipped, + changed: [...sink.changed], + denied: [...sink.denied], + }; } // ── Choosing what to stop ──────────────────────────────────────────────────── @@ -116,17 +171,28 @@ export interface StopOptions { yes?: boolean; } +/** `--idle` leaves alone a not-listening instance younger than this: it may still be starting (indexing a big site takes a while). Matches the watchdog's startup limit. */ +export const IDLE_MIN_AGE_SECONDS = 120; + export const STOP_USAGE = `Usage: dune stop | --port | --idle | --all [--force] [--yes] the instance this process belongs to (see \`dune ps\`) --port the instance listening on that port - --idle only instances that are running but not listening - --all every instance \`dune ps\` lists + --idle only instances that are running but have not been + listening for at least 2 minutes (younger ones may still + be starting) + --all every confirmed instance \`dune ps\` lists --force SIGKILL processes that ignore SIGTERM (default: report them) - --yes, -y don't ask for confirmation`; + --yes, -y don't ask for confirmation + +Only your own processes are considered. \`--idle\` and \`--all\` act only on +instances confirmed as dune sites (a config/site.yaml at the site root); one +that only looks like dune by its command line must be named by pid. Stopping +an instance stops its whole process tree, including any other tool its +wrapper started.`; export type Selection = - | { ok: true; instances: DuneInstance[]; label: string } + | { ok: true; instances: DuneInstance[]; label: string; notes: string[] } | { ok: false; message: string; exitCode: number }; function parseNumber(value: string, what: string, max: number): number | string { @@ -134,6 +200,8 @@ function parseNumber(value: string, what: string, max: number): number | string return Number.isInteger(n) && n > 0 && n <= max ? n : `"${value}" is not a valid ${what}.`; } +const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; + /** Pure: pick the instances `opts` asks for out of `all`, or explain why none. */ export function selectInstances(all: DuneInstance[], opts: StopOptions): Selection { const chosen = [opts.pid !== undefined, opts.port !== undefined, opts.idle === true, opts.all === true] @@ -143,14 +211,19 @@ export function selectInstances(all: DuneInstance[], opts: StopOptions): Selecti return { ok: false, message: `Choose one target: a pid, --port, --idle or --all.\n\n${STOP_USAGE}`, exitCode: 2 }; } + const unconfirmedNote = (found: DuneInstance[]): string[] => + found.some((i) => !i.confirmed) + ? ["Not confirmed as a dune site: no config/site.yaml was found for it. It matches dune by its command line only."] + : []; + if (opts.pid !== undefined) { const pid = parseNumber(opts.pid, "pid", 4_194_304); if (typeof pid === "string") return { ok: false, message: pid, exitCode: 2 }; const found = all.filter((i) => i.pids.includes(pid)); if (found.length === 0) { - return { ok: false, message: `No dune instance has a process with pid ${pid}. \`dune ps\` lists them.`, exitCode: 1 }; + return { ok: false, message: `No dune instance of yours has a process with pid ${pid}. \`dune ps\` lists them.`, exitCode: 1 }; } - return { ok: true, instances: found, label: "" }; + return { ok: true, instances: found, label: "", notes: unconfirmedNote(found) }; } if (opts.port !== undefined) { @@ -158,32 +231,59 @@ export function selectInstances(all: DuneInstance[], opts: StopOptions): Selecti if (typeof port === "string") return { ok: false, message: port, exitCode: 2 }; const found = all.filter((i) => i.ports.includes(port)); if (found.length === 0) { - return { ok: false, message: `No dune instance is listening on port ${port}. \`dune ps\` lists them.`, exitCode: 1 }; + return { ok: false, message: `No dune instance of yours is listening on port ${port}. \`dune ps\` lists them.`, exitCode: 1 }; } - return { ok: true, instances: found, label: "" }; + return { ok: true, instances: found, label: "", notes: unconfirmedNote(found) }; + } + + // --all / --idle: confirmed instances only. + const confirmed = all.filter((i) => i.confirmed); + const notes: string[] = []; + const skippedUnconfirmed = all.length - confirmed.length; + if (skippedUnconfirmed > 0) { + notes.push( + `${plural(skippedUnconfirmed, "process that only looks", "processes that only look")} like dune by its command line ` + + `(no config/site.yaml found) ${skippedUnconfirmed === 1 ? "was" : "were"} left out; stop ${skippedUnconfirmed === 1 ? "it" : "them"} by pid if intended.`, + ); } if (opts.idle) { - const idle = all.filter((i) => i.portStatus === "not-listening"); - const unknown = all.filter((i) => i.portStatus === "unknown").length; - if (idle.length === 0 && unknown > 0) { + const notServing = confirmed.filter((i) => i.portStatus === "not-listening"); + const mature = notServing.filter((i) => i.ageSeconds === null || i.ageSeconds >= IDLE_MIN_AGE_SECONDS); + const young = notServing.length - mature.length; + if (young > 0) { + notes.push( + `${plural(young, "instance", "instances")} not listening yet ${young === 1 ? "was" : "were"} left out because ` + + `${young === 1 ? "it is" : "they are"} under ${IDLE_MIN_AGE_SECONDS / 60} minutes old and may still be starting.`, + ); + } + const unknown = confirmed.filter((i) => i.portStatus === "unknown").length; + if (mature.length === 0 && unknown > 0) { return { ok: false, message: "Can't tell which instances are idle: `lsof` isn't available, so no instance can be shown as not listening.", exitCode: 1, }; } - if (idle.length === 0) { + if (mature.length === 0) { + const why = [...notes].map((n) => `\n ${n}`).join(""); return { ok: false, - message: `No idle instances: all ${all.length} running instance${all.length === 1 ? " is" : "s are"} listening.`, + message: `No idle instances: ${confirmed.length === 0 ? "none of the running instances is a confirmed dune site" : `all ${confirmed.length} confirmed running instance${confirmed.length === 1 ? " is" : "s are"} listening`}.${why}`, exitCode: 0, }; } - return { ok: true, instances: idle, label: "idle " }; + return { ok: true, instances: mature, label: "idle ", notes }; } - return { ok: true, instances: all, label: "" }; + if (confirmed.length === 0) { + return { + ok: false, + message: `None of the running instances is a confirmed dune site.${notes.map((n) => `\n ${n}`).join("")}`, + exitCode: 0, + }; + } + return { ok: true, instances: confirmed, label: "", notes }; } // ── Command ────────────────────────────────────────────────────────────────── @@ -227,9 +327,11 @@ export async function stopCommand(opts: StopOptions = {}, deps: StopDeps = {}): return; } - const { instances, label } = selection; + const { instances, label, notes } = selection; log(""); for (const inst of instances) for (const line of formatInstance(inst)) log(line); + for (const note of notes) log(` Note: ${note}`); + if (notes.length > 0) log(""); const procCount = new Set(instances.flatMap((i) => i.pids)).size; const question = `Stop ${instances.length} ${label}instance${instances.length === 1 ? "" : "s"} (${procCount} process${procCount === 1 ? "" : "es"})?`; @@ -252,6 +354,13 @@ export async function stopCommand(opts: StopOptions = {}, deps: StopDeps = {}): if (result.skipped.length > 0) { log(` Left alone (this command's own process and its parents): ${result.skipped.join(", ")}`); } + if (result.changed.length > 0) { + log(` Not signalled (already exited, or the pid now belongs to a different process): ${result.changed.join(", ")}`); + } + if (result.denied.length > 0) { + log(` Not permitted (owned by another user): ${result.denied.join(", ")}`); + Deno.exitCode = 1; + } if (result.killed.length > 0) { log(` ${result.killed.length} did not exit on SIGTERM and were killed (SIGKILL): ${result.killed.join(", ")}`); } diff --git a/tests/cli/ps_helpers.ts b/tests/cli/ps_helpers.ts index 11052e6c..d62f2ffd 100644 --- a/tests/cli/ps_helpers.ts +++ b/tests/cli/ps_helpers.ts @@ -75,8 +75,23 @@ export function fakeInstance(overrides: Partial = {}): DuneInstanc ports: [], portStatus: "not-listening", elapsed: "01:00", + ageSeconds: 3600, + confirmed: true, + startTimes: {}, command: "deno run -A main.ts serve", ...overrides, }; } + +/** Start times of real processes, in the form `findDuneInstances` records them. */ +export async function realStartTimes(pids: number[]): Promise> { + const out = await new Deno.Command("ps", { args: ["-o", "pid=,lstart=", "-p", pids.join(",")], stdout: "piped", stderr: "null" }) + .output(); + const times: Record = {}; + for (const line of new TextDecoder().decode(out.stdout).split("\n")) { + const m = line.match(/^\s*(\d+)\s+(.*\S)\s*$/); + if (m) times[parseInt(m[1], 10)] = m[2].replace(/\s+/g, " "); + } + return times; +} diff --git a/tests/cli/ps_test.ts b/tests/cli/ps_test.ts index f9b09bcc..576d1699 100644 --- a/tests/cli/ps_test.ts +++ b/tests/cli/ps_test.ts @@ -19,6 +19,7 @@ import { parseRootArg, } from "../../src/cli/instances.ts"; import { psCommand } from "../../src/cli/ps.ts"; +import { parseElapsedSeconds, readProcessTable } from "../../src/cli/instances.ts"; import { fakeInstance, makeSite, opts, SERVER, spawnDeno, startTaskTree, stop, waitForPort } from "./ps_helpers.ts"; /** @@ -357,6 +358,49 @@ Deno.test("findDuneInstances: a bare `cli.ts serve` with NO site anywhere is not } }); +Deno.test("parseElapsedSeconds: mm:ss, hh:mm:ss and d-hh:mm:ss", () => { + assertEquals(parseElapsedSeconds("05:12"), 312); + assertEquals(parseElapsedSeconds("01:23:45"), 5025); + assertEquals(parseElapsedSeconds("2-09:03:55"), 2 * 86400 + 9 * 3600 + 3 * 60 + 55); + assertEquals(parseElapsedSeconds("garbage"), null); + assertEquals(parseElapsedSeconds(null), null); +}); + +Deno.test("readProcessTable: carries the owner's uid and a start time for every row, and this process is ours", opts, async () => { + const table = await readProcessTable(); + const me = table.find((r) => r.pid === Deno.pid); + assertExists(me); + assertEquals(me.uid, Deno.uid()); + assertEquals(table.every((r) => Number.isInteger(r.uid) && /\d{4}$/.test(r.started) && /\d:\d\d:\d\d/.test(r.started)), true); +}); + +Deno.test("findDuneInstances: a site is 'confirmed'; a dune-looking process with no config/site.yaml is not", opts, async () => { + const site = await makeSite("dune_test_ps_conf_", "Confirmed"); + const bare = await Deno.makeTempDir({ prefix: "dune_test_ps_unconf_" }); + let a: Deno.ChildProcess | undefined; + let b: Deno.ChildProcess | undefined; + try { + for (const dir of [site, bare]) await Deno.writeTextFile(join(dir, "main.ts"), `setInterval(() => {}, 1000);\nawait new Promise(() => {});\n`); + a = spawnDeno(["run", "-A", "main.ts", "serve"], site); + b = spawnDeno(["run", "-A", "main.ts", "serve"], bare); + await new Promise((r) => setTimeout(r, 700)); + const found = await findDuneInstances(); + const withSite = found.find((i) => i.pid === a!.pid); + const without = found.find((i) => i.pid === b!.pid); + assertExists(withSite); + assertExists(without, "still listed — ps shows it — but unconfirmed"); + assertEquals(withSite.confirmed, true); + assertEquals(without.confirmed, false); + assertEquals(typeof withSite.ageSeconds, "number"); + assertEquals(Object.keys(withSite.startTimes).map(Number).includes(a.pid), true, "start times recorded for the tree"); + } finally { + if (a) await stop(a); + if (b) await stop(b); + await Deno.remove(site, { recursive: true }).catch(() => {}); + await Deno.remove(bare, { recursive: true }).catch(() => {}); + } +}); + // ── psCommand output (fakes only: never touches real processes) ────────────── async function runPs(instances: DuneInstance[]): Promise { diff --git a/tests/cli/stop_test.ts b/tests/cli/stop_test.ts index a877956d..2a4ab98d 100644 --- a/tests/cli/stop_test.ts +++ b/tests/cli/stop_test.ts @@ -10,7 +10,7 @@ import { assertEquals, assertExists } from "https://deno.land/std@0.224.0/assert import { join } from "@std/path"; import { type DuneInstance, findDuneInstances } from "../../src/cli/instances.ts"; import { selectInstances, type StopOptions, stopCommand, stopInstances } from "../../src/cli/stop.ts"; -import { fakeInstance, opts, startTaskTree, stop } from "./ps_helpers.ts"; +import { fakeInstance, opts, realStartTimes, startTaskTree, stop } from "./ps_helpers.ts"; // ── stopInstances: real processes ──────────────────────────────────────────── @@ -72,7 +72,7 @@ async function withStubbornProcess(fn: (pid: number, child: Deno.ChildProcess) = Deno.test("stopInstances: without force, a process that ignores SIGTERM is left alone and reported", opts, async () => { await withStubbornProcess(async (pid, child) => { - const result = await stopInstances([fakeInstance({ pid, pids: [pid] })], { timeoutMs: 600 }); + const result = await stopInstances([fakeInstance({ pid, pids: [pid], startTimes: await realStartTimes([pid]) })], { timeoutMs: 600 }); assertEquals(result.survivors, [pid]); assertEquals(result.killed, []); assertEquals(result.stopped, []); @@ -83,7 +83,7 @@ Deno.test("stopInstances: without force, a process that ignores SIGTERM is left Deno.test("stopInstances: with force, that process is SIGKILLed after the grace period and reported as killed", opts, async () => { await withStubbornProcess(async (pid, child) => { - const result = await stopInstances([fakeInstance({ pid, pids: [pid] })], { force: true, timeoutMs: 600, killTimeoutMs: 5000 }); + const result = await stopInstances([fakeInstance({ pid, pids: [pid], startTimes: await realStartTimes([pid]) })], { force: true, timeoutMs: 600, killTimeoutMs: 5000 }); assertEquals(result.killed, [pid]); assertEquals(result.stopped, [pid]); assertEquals(result.survivors, []); @@ -100,6 +100,39 @@ Deno.test("stopInstances: never signals this process, even if an instance lists // (If it had signalled itself, this assertion would never run.) }); +Deno.test("stopInstances: a pid whose start time no longer matches (exited and reused) is not signalled", opts, async () => { + await withStubbornProcess(async (pid, child) => { + // The instance was "found" when pid belonged to some other process: here, a different start time. + const result = await stopInstances( + [fakeInstance({ pid, pids: [pid], startTimes: { [pid]: "Mon Jan 1 00:00:00 2001" } })], + { force: true, timeoutMs: 600, killTimeoutMs: 600 }, + ); + assertEquals(result.changed, [pid]); + assertEquals(result.stopped, []); + assertEquals(result.killed, []); + assertEquals(await Promise.race([child.status.then(() => "exited"), new Promise((r) => setTimeout(() => r("alive"), 300))]), "alive", "must not have been signalled, not even by SIGKILL"); + }); +}); + +Deno.test("stopInstances: a pid recorded without a start time is never signalled", opts, async () => { + await withStubbornProcess(async (pid, child) => { + const result = await stopInstances([fakeInstance({ pid, pids: [pid], startTimes: {} })], { force: true, timeoutMs: 600, killTimeoutMs: 600 }); + assertEquals(result.changed, [pid]); + assertEquals(await Promise.race([child.status.then(() => "exited"), new Promise((r) => setTimeout(() => r("alive"), 300))]), "alive"); + }); +}); + +Deno.test("stopInstances: a process the operating system refuses to signal is reported as denied, not as ignoring SIGTERM", { + ...opts, + ignore: Deno.uid() === 0, // as root pid 1 would really be signalled +}, async () => { + // pid 1 belongs to root; SIGTERM from an ordinary user is refused (EPERM). + const result = await stopInstances([fakeInstance({ pid: 1, pids: [1], startTimes: await realStartTimes([1]) })], { timeoutMs: 300 }); + assertEquals(result.denied, [1]); + assertEquals(result.stopped, []); + assertEquals(result.survivors, []); +}); + // ── selectInstances ────────────────────────────────────────────────────────── const LIVE = () => fakeInstance({ pid: 10, pids: [10, 11], ports: [3000], portStatus: "listening", title: "Live Site" }); @@ -170,7 +203,7 @@ Deno.test("selectInstances: --idle with everything listening is a clean no-op (e assertEquals(sel.ok, false); if (!sel.ok) { assertEquals(sel.exitCode, 0); - assertEquals(sel.message.includes("No idle instances: all 1 running instance is listening."), true); + assertEquals(sel.message.includes("No idle instances: all 1 confirmed running instance is listening"), true); } }); @@ -183,6 +216,47 @@ Deno.test("selectInstances: --idle without lsof assumes nothing is idle (exit 1) } }); +Deno.test("selectInstances: --all and --idle leave out unconfirmed instances, and say how to stop them", () => { + const stray = fakeInstance({ pid: 70, pids: [70], confirmed: false, root: null, title: null }); + const all = selectInstances([...ALL(), stray], { all: true }); + assertEquals(pidsOf(all), [10, 20, 30]); + if (all.ok) assertEquals(all.notes.some((n) => n.includes("by pid")), true); + const idle = selectInstances([...ALL(), stray], { idle: true }); + assertEquals(pidsOf(idle), [20, 30], "an unconfirmed not-listening process is not 'idle' to kill"); +}); + +Deno.test("selectInstances: only unconfirmed instances running — --all is a clean no-op that explains why", () => { + const stray = fakeInstance({ pid: 70, pids: [70], confirmed: false }); + const sel = selectInstances([stray], { all: true }); + assertEquals(sel.ok, false); + if (!sel.ok) { + assertEquals(sel.exitCode, 0); + assertEquals(sel.message.includes("confirmed dune site"), true); + } +}); + +Deno.test("selectInstances: an unconfirmed instance can still be stopped by naming it, with a warning", () => { + const stray = fakeInstance({ pid: 70, pids: [70, 71], confirmed: false }); + const sel = selectInstances([stray], { pid: "71" }); + assertEquals(pidsOf(sel), [70]); + if (sel.ok) assertEquals(sel.notes[0].includes("Not confirmed"), true); +}); + +Deno.test("selectInstances: --idle leaves out a not-listening instance that is still young (it may be starting)", () => { + const young = fakeInstance({ pid: 80, pids: [80], ageSeconds: 45 }); + const old = fakeInstance({ pid: 81, pids: [81], ageSeconds: 600 }); + const sel = selectInstances([young, old], { idle: true }); + assertEquals(pidsOf(sel), [81]); + if (sel.ok) assertEquals(sel.notes.some((n) => n.includes("still be starting")), true); + // Nothing old enough: a clean no-op that says why. + const none = selectInstances([young], { idle: true }); + assertEquals(none.ok, false); + if (!none.ok) { + assertEquals(none.exitCode, 0); + assertEquals(none.message.includes("may still be starting"), true); + } +}); + Deno.test("selectInstances: --all takes everything", () => { assertEquals(pidsOf(selectInstances(ALL(), { all: true })), [10, 20, 30]); }); @@ -192,7 +266,7 @@ Deno.test("selectInstances: --all takes everything", () => { async function runStop( instances: DuneInstance[], o: StopOptions, - deps: { interactive?: boolean; confirm?: boolean; survivors?: number[]; killed?: number[] } = {}, + deps: { interactive?: boolean; confirm?: boolean; survivors?: number[]; killed?: number[]; changed?: number[]; denied?: number[] } = {}, ) { const lines: string[] = []; const stopCalls: { instances: DuneInstance[]; force: boolean }[] = []; @@ -209,6 +283,8 @@ async function runStop( killed: deps.killed ?? [], survivors: deps.survivors ?? [], skipped: [], + changed: deps.changed ?? [], + denied: deps.denied ?? [], }); }, isInteractive: () => deps.interactive ?? false, @@ -299,6 +375,22 @@ Deno.test("stopCommand --force: processes that needed SIGKILL are named, and tha assertEquals(exitCode, 0); }); +Deno.test("stopCommand: reports pids that were not signalled because they exited or were reused, and ones that were denied", async () => { + const changed = await runStop([IDLE_A()], { all: true, yes: true }, { changed: [9] }); + assertEquals(changed.text.includes("Not signalled (already exited, or the pid now belongs to a different process): 9"), true); + assertEquals(changed.exitCode, 0); + const denied = await runStop([IDLE_A()], { all: true, yes: true }, { denied: [1] }); + assertEquals(denied.text.includes("Not permitted (owned by another user): 1"), true); + assertEquals(denied.exitCode, 1); +}); + +Deno.test("stopCommand: shows the notes (unconfirmed left out) before asking", async () => { + const stray = fakeInstance({ pid: 70, pids: [70], confirmed: false }); + const { text } = await runStop([IDLE_A(), stray], { all: true }, { interactive: false }); + assertEquals(text.includes("Note: 1 process that only looks like dune"), true, text); + assertEquals(text.includes("Stop 1 instance (1 process)?"), true); +}); + Deno.test("stopCommand: says nothing about SIGKILL when everything exited gracefully", async () => { const { text } = await runStop([IDLE_A()], { all: true, yes: true }); assertEquals(text.includes("SIGKILL"), false); From 8c2db8f9f62bc33a3e4279271f31b90c69fe7954 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 27 Oct 2026 16:15:53 +0100 Subject: [PATCH 10/12] 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 --- CHANGELOG.md | 13 ++++-- src/cli/instances.ts | 97 ++++++++++++++++++++++++++++++++++++------ src/cli/ps.ts | 20 +++++++-- src/cli/stop.ts | 36 ++++++++++++---- tests/cli/ps_test.ts | 70 +++++++++++++++++++++++++++--- tests/cli/stop_test.ts | 74 ++++++++++++++++++++++++++++++++ 6 files changed, 275 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca1ec28..f93c7ca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,10 +39,15 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what --idle` (running but not listening for at least 2 minutes — younger ones may still be starting; an instance whose port state is unknown, with no `lsof`, is never assumed idle) or `dune stop --all`. `--all` and `--idle` act only on - *confirmed* instances (a `config/site.yaml` at the site root): a command line - that merely looks like dune, such as another Deno project's `main.ts serve`, - is listed by `dune ps` but must be named by pid. Only your own processes are - considered. It sends SIGTERM and waits up to 10 seconds for a server to + *confirmed* instances: one whose own command line points at a dune site (the + directory of the `main.ts` it runs, its `--root`, or the dune CLI's working + directory, each holding a `config/site.yaml`). Merely running from inside a + site directory confirms nothing, so another Deno project's `main.ts serve` + is listed by `dune ps`, marked unconfirmed, and must be named by pid. The + prompt shows each instance's full command line. Only your own processes are + considered (read from the process table, no `--allow-sys` needed); without + `--allow-run` it says what to re-run with instead of reporting no instances, + and a kill Deno's permissions refuse is reported as such. It sends SIGTERM and waits up to 10 seconds for a server to drain; a process that ignores it is reported, and only `--force` escalates to SIGKILL. Each pid's start time is re-checked immediately before it is signalled, so a pid that exited and was reused while the prompt was open is diff --git a/src/cli/instances.ts b/src/cli/instances.ts index 9dc6585b..bc6d98f8 100644 --- a/src/cli/instances.ts +++ b/src/cli/instances.ts @@ -40,7 +40,7 @@ * @module */ -import { isAbsolute, join, resolve } from "@std/path"; +import { dirname, fromFileUrl, isAbsolute, join, resolve } from "@std/path"; import { parseUserYaml } from "../security/safe-yaml.ts"; /** A dune dev/serve-shaped process (tree) found running on this machine. */ @@ -87,6 +87,9 @@ export interface DuneInstance { command: string; } +/** Deno's permission system refused something `dune ps`/`dune stop` needs. */ +export class MissingPermissionError extends Error {} + export async function runCommand( cmd: string, args: string[], @@ -102,8 +105,15 @@ export async function runCommand( const { code, stdout } = await command.output(); if (code !== 0) return null; return new TextDecoder().decode(stdout); - } catch { - // Binary not found, no permission, etc. — treat as "can't tell", not fatal. + } catch (err) { + // Deno's own permission system refusing is not "can't tell": silently + // reporting no instances (or all pids as gone) would mislead. + if (err instanceof Deno.errors.NotCapable) { + throw new MissingPermissionError( + `\`dune ps\`/\`dune stop\` need to run \`${cmd}\`: re-run with --allow-run (or -A).`, + ); + } + // Binary not found, etc. — treat as "can't tell", not fatal. return null; } } @@ -164,6 +174,8 @@ export async function readProcessTable(): Promise { export interface CommandMatch { mode: "dev" | "serve"; strength: "strong" | "weak"; + /** Which invocation shape matched; decides how the site root is read from the command line. */ + kind: "entry" | "cli" | "task"; } const DENO_EXE = /^(?:\S*\/)?deno\s/; @@ -186,15 +198,15 @@ export function matchDuneCommand(command: string): CommandMatch | null { if (!DENO_EXE.test(command)) return null; const entry = command.match(/\bmain\.ts\s+(dev|serve)\b/); - if (entry) return { mode: entry[1] as "dev" | "serve", strength: "strong" }; + if (entry) return { mode: entry[1] as "dev" | "serve", strength: "strong", kind: "entry" }; const cli = command.match(/(?:^|[\s/])cli(?:\.ts)?\s+(dev|serve)\b/); if (cli) { - return { mode: cli[1] as "dev" | "serve", strength: /dune/i.test(command) ? "strong" : "weak" }; + return { mode: cli[1] as "dev" | "serve", strength: /dune/i.test(command) ? "strong" : "weak", kind: "cli" }; } const task = command.match(/\bdeno\s+task\s+(dev|serve)\b/); - if (task) return { mode: task[1] as "dev" | "serve", strength: "weak" }; + if (task) return { mode: task[1] as "dev" | "serve", strength: "weak", kind: "task" }; return null; } @@ -205,6 +217,40 @@ export function parseRootArg(command: string): string | null { return m ? m[1].replace(/^["']|["']$/g, "") : null; } +/** + * The site root the command line itself points at, or null. This is the strict + * evidence behind "confirmed": a process merely *running from inside* a site + * directory (its `PWD` or cwd) proves nothing, since any tool started from a + * site repo inherits that. + * - `--root `, when given; + * - the generated entrypoint: the directory holding the `main.ts` it runs + * (`cli({ root: import.meta.dirname })`), however the process was started; + * - the dune CLI proper: its working directory, since `dune dev` defaults + * `--root` to `.`; + * - nothing for a bare `deno task` wrapper (its child carries the evidence). + * `base` is the process's PWD/cwd, used to resolve relative paths. + */ +export function commandSiteRootHint(command: string, match: CommandMatch, base: string | null): string | null { + const rootArg = parseRootArg(command); + if (rootArg) return isAbsolute(rootArg) ? rootArg : base ? resolve(base, rootArg) : null; + if (match.kind === "entry") { + const m = command.match(/(\S*main\.ts)\s+(?:dev|serve)\b/); + if (!m) return null; + let script = m[1].replace(/^["']|["']$/g, ""); + if (script.startsWith("file://")) { + try { + script = fromFileUrl(script); + } catch { + return null; + } + } + if (isAbsolute(script)) return dirname(script); + return base ? dirname(resolve(base, script)) : null; + } + if (match.kind === "cli" && match.strength === "strong") return base; + return null; +} + /** All descendant pids of `pid`, from an in-memory process table. */ function descendantsOf(pid: number, children: Map): number[] { const out: number[] = []; @@ -359,12 +405,18 @@ async function confirmSiteRoot(dirs: (string | null)[]): Promise * port, uptime, command) rather than a false negative — but `root`/`title` * are null in that case, and the caller should say so plainly. */ -export async function findDuneInstances(): Promise { +export async function findDuneInstances( + opts: { onWarning?: (message: string) => void } = {}, +): Promise { const allRows = await readProcessTable(); // Only this user's processes: another user's server is not ours to list or - // signal (the kill would be refused anyway). Where the uid can't be read - // (not POSIX) nothing is filtered. - const myUid = Deno.build.os === "windows" ? null : Deno.uid(); + // signal (the kill would be refused anyway). Our own uid comes from our own + // row in the table (Deno.uid() would need --allow-sys); if that row is + // missing nothing can be filtered, and we say so. + const myUid = allRows.find((r) => r.pid === Deno.pid)?.uid ?? null; + if (myUid === null && allRows.length > 0) { + opts.onWarning?.("Could not tell which user owns this process, so processes of other users are not filtered out."); + } const table = myUid === null ? allRows : allRows.filter((r) => r.uid === myUid); const byPid = new Map(table.map((r) => [r.pid, r])); const children = new Map(); @@ -375,6 +427,12 @@ export async function findDuneInstances(): Promise { match: CommandMatch; root: string | null; } + /** A site root the command line itself points at (see commandSiteRootHint), verified on disk. */ + const strictRootOf = async (row: ProcRow, match: CommandMatch): Promise => { + const [pwd, cwd] = await Promise.all([getPwdEnv(row.pid), getLiveCwd(row.pid)]); + const hint = commandSiteRootHint(row.command, match, pwd ?? cwd); + return hint ? await confirmSiteRoot([hint]) : null; + }; const cands: Cand[] = []; for (const row of table) { const match = matchDuneCommand(row.command); @@ -408,7 +466,22 @@ export async function findDuneInstances(): Promise { for (const c of tops) { const pids = [c.pid, ...descendantsOf(c.pid, children)]; // A tree's site root can sit on any member (e.g. the `--root` is on the child). - let root = c.root; + // Confirmed only on evidence from the command line, never from where the + // process happens to be running: any tool started from inside a site repo + // inherits that directory. Any member of the tree can provide it (a + // `deno task` wrapper has none of its own). + let confirmedRoot = await strictRootOf(c, c.match); + if (!confirmedRoot) { + for (const pid of pids.slice(1)) { + const row = byPid.get(pid); + const childMatch = row ? matchDuneCommand(row.command) : null; + if (row && childMatch) { + confirmedRoot = await strictRootOf(row, childMatch); + if (confirmedRoot) break; + } + } + } + let root = confirmedRoot ?? c.root; if (!root) { for (const pid of pids.slice(1)) { const row = byPid.get(pid); @@ -435,7 +508,7 @@ export async function findDuneInstances(): Promise { portStatus: !haveLsof ? "unknown" : ports.length > 0 ? "listening" : "not-listening", elapsed: c.elapsed, ageSeconds: parseElapsedSeconds(c.elapsed), - confirmed: root !== null, + confirmed: confirmedRoot !== null, startTimes: Object.fromEntries(pids.flatMap((p) => byPid.get(p) ? [[p, byPid.get(p)!.started]] : [])), command: c.command, }); diff --git a/src/cli/ps.ts b/src/cli/ps.ts index e96b532a..12e2971b 100644 --- a/src/cli/ps.ts +++ b/src/cli/ps.ts @@ -7,7 +7,7 @@ * @module */ -import { type DuneInstance, findDuneInstances } from "./instances.ts"; +import { type DuneInstance, findDuneInstances, MissingPermissionError } from "./instances.ts"; /** Injection points so the command's output is testable without touching real processes. */ export interface PsDeps { @@ -22,7 +22,7 @@ export function describePort(inst: DuneInstance): string { } /** The lines `dune ps` prints for one instance (shared with `dune stop`, which shows what it is about to stop). */ -export function formatInstance(inst: DuneInstance): string[] { +export function formatInstance(inst: DuneInstance, opts: { fullCommand?: boolean } = {}): string[] { const extra = inst.pids.length > 1 ? ` (+${inst.pids.length - 1} child process${inst.pids.length > 2 ? "es" : ""})` : ""; @@ -31,7 +31,9 @@ export function formatInstance(inst: DuneInstance): string[] { ` ${inst.title ?? "(untitled)"}`, ` ${inst.root ?? "(site root unconfirmed — no config/site.yaml via --root, PWD or live cwd)"}`, ` ${describePort(inst)}`, - ` $ ${inst.command.length > 110 ? inst.command.slice(0, 107) + "..." : inst.command}\n`, + ` $ ${!opts.fullCommand && inst.command.length > 110 ? inst.command.slice(0, 107) + "..." : inst.command}`, + ...(inst.confirmed ? [] : [` (not confirmed as a dune site: no config/site.yaml at the root its command line points to)`]), + "", ]; } @@ -43,7 +45,17 @@ export async function psCommand(deps: PsDeps = {}): Promise { } log("🏜️ Dune — scanning for running instances...\n"); - const all = await (deps.find ?? findDuneInstances)(); + let all: DuneInstance[]; + try { + all = await (deps.find ?? (() => findDuneInstances({ onWarning: (m) => log(` ⚠ ${m}`) })))(); + } catch (err) { + if (err instanceof MissingPermissionError) { + log(` ${err.message}`); + Deno.exitCode = 1; + return; + } + throw err; + } if (all.length === 0) { log(" No dune dev/serve instances found running locally."); diff --git a/src/cli/stop.ts b/src/cli/stop.ts index 461d195a..70c9a0a9 100644 --- a/src/cli/stop.ts +++ b/src/cli/stop.ts @@ -21,7 +21,7 @@ * @module */ -import { type DuneInstance, findDuneInstances, readProcessTable, runCommand } from "./instances.ts"; +import { type DuneInstance, findDuneInstances, MissingPermissionError, readProcessTable, runCommand } from "./instances.ts"; import { formatInstance } from "./ps.ts"; // ── Signalling ─────────────────────────────────────────────────────────────── @@ -52,6 +52,8 @@ export interface StopResult { changed: number[]; /** Pids the operating system refused to signal (owned by another user). */ denied: number[]; + /** Pids Deno's own permission system refused to signal (needs `--allow-run`). */ + needsPermission: number[]; } async function alivePids(pids: number[]): Promise { @@ -93,7 +95,7 @@ async function signalVerified( pids: number[], expected: Map, signal: Deno.Signal, - sink: { changed: Set; denied: Set }, + sink: { changed: Set; denied: Set; needsPermission: Set }, ): Promise { const now = await currentStartTimes(pids); for (const pid of pids) { @@ -105,7 +107,8 @@ async function signalVerified( try { Deno.kill(pid, signal); } catch (err) { - if (err instanceof Deno.errors.PermissionDenied) sink.denied.add(pid); + if (err instanceof Deno.errors.PermissionDenied) sink.denied.add(pid); // EPERM: someone else's + else if (err instanceof Deno.errors.NotCapable) sink.needsPermission.add(pid); // Deno's permissions else sink.changed.add(pid); // already gone } } @@ -128,19 +131,19 @@ export async function stopInstances( const toSignal = targets.filter((p) => !protectedPids.has(p)); const expected = new Map(); for (const inst of instances) for (const p of inst.pids) expected.set(p, inst.startTimes[p]); - const sink = { changed: new Set(), denied: new Set() }; + const sink = { changed: new Set(), denied: new Set(), needsPermission: new Set() }; await signalVerified(toSignal, expected, "SIGTERM", sink); - const signalled = toSignal.filter((p) => !sink.changed.has(p) && !sink.denied.has(p)); + const signalled = toSignal.filter((p) => !sink.changed.has(p) && !sink.denied.has(p) && !sink.needsPermission.has(p)); const afterTerm = await waitUntilGone(signalled, timeoutMs); let survivors = afterTerm; let killed: number[] = []; if (opts.force && afterTerm.length > 0) { await signalVerified(afterTerm, expected, "SIGKILL", sink); - const sent = afterTerm.filter((p) => !sink.changed.has(p) && !sink.denied.has(p)); + const sent = afterTerm.filter((p) => !sink.changed.has(p) && !sink.denied.has(p) && !sink.needsPermission.has(p)); const left = await waitUntilGone(sent, killTimeoutMs); - survivors = afterTerm.filter((p) => left.includes(p) || sink.denied.has(p)); + survivors = afterTerm.filter((p) => left.includes(p) || sink.denied.has(p) || sink.needsPermission.has(p)); killed = sent.filter((p) => !left.includes(p)); } const gone = new Set(signalled.filter((p) => !survivors.includes(p))); @@ -151,6 +154,7 @@ export async function stopInstances( skipped, changed: [...sink.changed], denied: [...sink.denied], + needsPermission: [...sink.needsPermission], }; } @@ -312,7 +316,17 @@ export async function stopCommand(opts: StopOptions = {}, deps: StopDeps = {}): return; } - const all = await (deps.find ?? findDuneInstances)(); + let all: DuneInstance[]; + try { + all = await (deps.find ?? (() => findDuneInstances({ onWarning: (m) => log(` ⚠ ${m}`) })))(); + } catch (err) { + if (err instanceof MissingPermissionError) { + log(` ${err.message}`); + Deno.exitCode = 1; + return; + } + throw err; + } if (all.length === 0) { log(" No dune dev/serve instances found running locally."); // Naming a pid or port that isn't there is a failure; --idle/--all with nothing to do is not. @@ -329,7 +343,7 @@ export async function stopCommand(opts: StopOptions = {}, deps: StopDeps = {}): const { instances, label, notes } = selection; log(""); - for (const inst of instances) for (const line of formatInstance(inst)) log(line); + for (const inst of instances) for (const line of formatInstance(inst, { fullCommand: true })) log(line); for (const note of notes) log(` Note: ${note}`); if (notes.length > 0) log(""); @@ -361,6 +375,10 @@ export async function stopCommand(opts: StopOptions = {}, deps: StopDeps = {}): log(` Not permitted (owned by another user): ${result.denied.join(", ")}`); Deno.exitCode = 1; } + if (result.needsPermission.length > 0) { + log(` Not allowed by Deno's permissions (re-run with --allow-run, or -A): ${result.needsPermission.join(", ")}`); + Deno.exitCode = 1; + } if (result.killed.length > 0) { log(` ${result.killed.length} did not exit on SIGTERM and were killed (SIGKILL): ${result.killed.join(", ")}`); } diff --git a/tests/cli/ps_test.ts b/tests/cli/ps_test.ts index 576d1699..de55b8e3 100644 --- a/tests/cli/ps_test.ts +++ b/tests/cli/ps_test.ts @@ -19,7 +19,7 @@ import { parseRootArg, } from "../../src/cli/instances.ts"; import { psCommand } from "../../src/cli/ps.ts"; -import { parseElapsedSeconds, readProcessTable } from "../../src/cli/instances.ts"; +import { commandSiteRootHint, parseElapsedSeconds, readProcessTable } from "../../src/cli/instances.ts"; import { fakeInstance, makeSite, opts, SERVER, spawnDeno, startTaskTree, stop, waitForPort } from "./ps_helpers.ts"; /** @@ -221,15 +221,15 @@ Deno.test("matchDuneCommand: the generated entrypoint, the dune CLI and jsr form ["/Users/x/.deno/bin/deno run -A --config=/tmp/c.json --no-lock https://jsr.io/@dune/core/0.34.10/src/cli.ts serve --env-file", "serve"], ]; for (const [cmd, mode] of strong) { - assertEquals(matchDuneCommand(cmd), { mode, strength: "strong" }, cmd); + assertEquals(matchDuneCommand(cmd), { mode, strength: "strong", kind: cmd.includes("main.ts") ? "entry" : "cli" }, cmd); } }); Deno.test("matchDuneCommand: `deno task dev|serve` and a bare `cli.ts dev|serve` are only weak matches (they could be any project)", () => { - assertEquals(matchDuneCommand("deno task serve --port 3100"), { mode: "serve", strength: "weak" }); - assertEquals(matchDuneCommand("/Users/x/.deno/bin/deno task dev"), { mode: "dev", strength: "weak" }); - assertEquals(matchDuneCommand("deno run -A src/cli.ts dev --root ../site"), { mode: "dev", strength: "weak" }); - assertEquals(matchDuneCommand("deno run -A other-tool/cli.ts serve"), { mode: "serve", strength: "weak" }); + assertEquals(matchDuneCommand("deno task serve --port 3100"), { mode: "serve", strength: "weak", kind: "task" }); + assertEquals(matchDuneCommand("/Users/x/.deno/bin/deno task dev"), { mode: "dev", strength: "weak", kind: "task" }); + assertEquals(matchDuneCommand("deno run -A src/cli.ts dev --root ../site"), { mode: "dev", strength: "weak", kind: "cli" }); + assertEquals(matchDuneCommand("deno run -A other-tool/cli.ts serve"), { mode: "serve", strength: "weak", kind: "cli" }); }); Deno.test("matchDuneCommand: unrelated commands do not match", () => { @@ -401,6 +401,58 @@ Deno.test("findDuneInstances: a site is 'confirmed'; a dune-looking process with } }); +Deno.test("commandSiteRootHint: only what the command line itself points at counts, never where the process happens to run", () => { + const hint = (cmd: string, base: string | null) => { + const m = matchDuneCommand(cmd); + return m ? commandSiteRootHint(cmd, m, base) : "NO MATCH"; + }; + // The generated entrypoint: the directory of the main.ts it runs, whatever the cwd. + assertEquals(hint("deno run -A /sites/a/main.ts serve", "/elsewhere"), "/sites/a"); + assertEquals(hint("deno run -A main.ts serve", "/sites/a"), "/sites/a"); + assertEquals(hint("deno run -A ./web/main.ts dev", "/sites"), "/sites/web"); + assertEquals(hint("deno run -A file:///sites/b/main.ts serve", "/elsewhere"), "/sites/b"); + assertEquals(hint("deno run -A main.ts serve", null), null, "relative path and no cwd: no evidence"); + // --root wins, absolute or relative to the process's directory. + assertEquals(hint("deno run -A /x/dune/src/cli.ts dev --root /sites/c", "/elsewhere"), "/sites/c"); + assertEquals(hint("deno run -A /x/dune/src/cli.ts dev --root ../d", "/sites/c"), "/sites/d"); + // The dune CLI with no --root works on its cwd. + assertEquals(hint("deno run -A /x/dune/src/cli.ts dev", "/sites/e"), "/sites/e"); + // A bare `deno task` wrapper carries no evidence of its own. + assertEquals(hint("deno task serve", "/sites/f"), null); + // A non-dune cli.ts is a weak match: no evidence from the cwd. + assertEquals(hint("deno run -A cli.ts serve", "/sites/g"), null); +}); + +Deno.test("findDuneInstances: an unrelated main.ts run from INSIDE a site directory is not confirmed by the directory alone", opts, async () => { + const site = await makeSite("dune_test_ps_inside_", "Inside"); + const elsewhere = await Deno.makeTempDir({ prefix: "dune_test_ps_tool_" }); + const own = await makeSite("dune_test_ps_own_", "Own"); + let stray: Deno.ChildProcess | undefined; + let genuine: Deno.ChildProcess | undefined; + try { + const body = `setInterval(() => {}, 1000);\nawait new Promise(() => {});\n`; + await Deno.writeTextFile(join(elsewhere, "main.ts"), body); + await Deno.writeTextFile(join(own, "main.ts"), body); + // Someone's own tool, started from a shell that happens to be sitting in the site repo. + stray = spawnDeno(["run", "-A", join(elsewhere, "main.ts"), "serve"], site); + // The site's own entrypoint, started from somewhere else entirely. + genuine = spawnDeno(["run", "-A", join(own, "main.ts"), "serve"], elsewhere); + await new Promise((r) => setTimeout(r, 800)); + const found = await findDuneInstances(); + const a = found.find((i) => i.pid === stray!.pid); + const b = found.find((i) => i.pid === genuine!.pid); + assertExists(a); + assertExists(b); + assertEquals(a.confirmed, false, "running from inside a dune site proves nothing about the process"); + assertEquals(b.confirmed, true, "its main.ts sits in a dune site, wherever it was started from"); + assertEquals(await realpath(b.root!), await realpath(own)); + } finally { + if (stray) await stop(stray); + if (genuine) await stop(genuine); + for (const d of [site, elsewhere, own]) await Deno.remove(d, { recursive: true }).catch(() => {}); + } +}); + // ── psCommand output (fakes only: never touches real processes) ────────────── async function runPs(instances: DuneInstance[]): Promise { @@ -431,6 +483,12 @@ Deno.test("psCommand: shows child-process counts and says so when nothing is run assertEquals((await runPs([])).includes("No dune dev/serve instances found"), true); }); +Deno.test("psCommand: marks an unconfirmed instance as such", async () => { + const text = await runPs([fakeInstance({ pid: 9, pids: [9], confirmed: false })]); + assertEquals(text.includes("not confirmed as a dune site"), true); + assertEquals((await runPs([fakeInstance()])).includes("not confirmed"), false); +}); + Deno.test("psCommand: points at `dune stop` when idle instances exist, and only then", async () => { const withIdle = await runPs([LIVE(), IDLE()]); assertEquals(withIdle.includes("`dune stop --idle` stops only the not-listening ones"), true); diff --git a/tests/cli/stop_test.ts b/tests/cli/stop_test.ts index 2a4ab98d..c88715c9 100644 --- a/tests/cli/stop_test.ts +++ b/tests/cli/stop_test.ts @@ -133,6 +133,79 @@ Deno.test("stopInstances: a process the operating system refuses to signal is re assertEquals(result.survivors, []); }); +const STOP_URL = new URL("../../src/cli/stop.ts", import.meta.url).href; +const INSTANCES_URL = new URL("../../src/cli/instances.ts", import.meta.url).href; +const PS_URL = new URL("../../src/cli/ps.ts", import.meta.url).href; +const DUNE_CONFIG = new URL("../../deno.json", import.meta.url).pathname; + +/** Run a small script under a restricted set of permissions and return what it printed. */ +async function runRestricted(dir: string, code: string, permFlags: string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> { + const script = join(dir, "restricted.ts"); + await Deno.writeTextFile(script, code); + const out = await new Deno.Command(Deno.execPath(), { + args: ["run", `--config=${DUNE_CONFIG}`, ...permFlags, script], + stdout: "piped", + stderr: "piped", + }).output(); + const dec = new TextDecoder(); + return { ok: out.success, stdout: dec.decode(out.stdout), stderr: dec.decode(out.stderr) }; +} + +Deno.test("dune ps works without --allow-sys (our own uid comes from our own process-table row)", opts, async () => { + const dir = await Deno.makeTempDir({ prefix: "stop_test_perm_" }); + try { + const r = await runRestricted( + dir, + `import { psCommand } from ${JSON.stringify(PS_URL)};\nawait psCommand({ log: () => {} });\nconsole.log("fine");\n`, + ["--allow-run", "--allow-read", "--allow-env"], + ); + assertEquals(r.ok, true, r.stderr); + assertEquals(r.stdout.includes("fine"), true); + } finally { + await Deno.remove(dir, { recursive: true }).catch(() => {}); + } +}); + +Deno.test("without --allow-run, finding instances fails loudly with the fix, instead of reporting none", opts, async () => { + const dir = await Deno.makeTempDir({ prefix: "stop_test_perm_" }); + try { + const r = await runRestricted( + dir, + `import { findDuneInstances, MissingPermissionError } from ${JSON.stringify(INSTANCES_URL)};\n` + + `try { await findDuneInstances(); console.log("NO ERROR"); } catch (e) { console.log(e instanceof MissingPermissionError ? e.message : "WRONG " + e); }\n`, + ["--allow-read", "--allow-env"], + ); + assertEquals(r.stdout.includes("--allow-run"), true, r.stdout + r.stderr); + } finally { + await Deno.remove(dir, { recursive: true }).catch(() => {}); + } +}); + +Deno.test("a kill refused by Deno's own permissions is reported as needing --allow-run, not as 'already gone'", opts, async () => { + await withStubbornProcess(async (pid, child) => { + const dir = await Deno.makeTempDir({ prefix: "stop_test_perm_" }); + try { + const times = await realStartTimes([pid]); + const r = await runRestricted( + dir, + `import { stopInstances } from ${JSON.stringify(STOP_URL)};\n` + + `const inst = { pid: ${pid}, pids: [${pid}], mode: "serve", root: null, title: null, ports: [], portStatus: "not-listening", elapsed: "1:00", ageSeconds: 60, confirmed: true, startTimes: ${JSON.stringify(times)}, command: "x" };\n` + + `console.log(JSON.stringify(await stopInstances([inst], { timeoutMs: 300 })));\n`, + // `ps` may run, signalling may not. + ["--allow-run=ps", "--allow-read", "--allow-env", "--allow-sys"], + ); + assertEquals(r.ok, true, r.stderr); + const result = JSON.parse(r.stdout.trim().split("\n").pop()!); + assertEquals(result.needsPermission, [pid], r.stdout); + assertEquals(result.changed, [], "must not be reported as 'already exited'"); + // And it really was not signalled. + assertEquals(await Promise.race([child.status.then(() => "exited"), new Promise((x) => setTimeout(() => x("alive"), 300))]), "alive"); + } finally { + await Deno.remove(dir, { recursive: true }).catch(() => {}); + } + }); +}); + // ── selectInstances ────────────────────────────────────────────────────────── const LIVE = () => fakeInstance({ pid: 10, pids: [10, 11], ports: [3000], portStatus: "listening", title: "Live Site" }); @@ -285,6 +358,7 @@ async function runStop( skipped: [], changed: deps.changed ?? [], denied: deps.denied ?? [], + needsPermission: [], }); }, isInteractive: () => deps.interactive ?? false, From ae8b38567df426b3fd1266e1a7e87626b9606bf7 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 27 Oct 2026 17:02:29 +0100 Subject: [PATCH 11/12] chore: drop test-run drift from deno.lock --- deno.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/deno.lock b/deno.lock index 928541f3..a7861082 100644 --- a/deno.lock +++ b/deno.lock @@ -5,7 +5,6 @@ "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.1", "jsr:@deno/loader@~0.3.10": "0.3.14", "jsr:@denosaurs/plug@1": "1.1.0", - "jsr:@dune/plugin-admin@*": "3.0.0", "jsr:@dune/plugin-admin@3": "3.0.0", "jsr:@fresh/build-id@1": "1.0.1", "jsr:@fresh/core@2": "2.3.3", From 9a579450210e12a6e3935d4f4d97b6f1016db0f6 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 27 Oct 2026 17:21:44 +0100 Subject: [PATCH 12/12] docs(changelog): #53 entries on top of main's [Unreleased] --- CHANGELOG.md | 98 +++++++++++++++++----------------------------------- 1 file changed, 31 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1d8408..82b9cdb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,36 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what ### Added +- **Running instances now register themselves.** `dune dev` and `dune serve` + write a small record (pid, process start time, mode, site root, title, port, + `@dune/core` version) to `~/.dune/instances/.json` + (`DUNE_REGISTRY_DIR` overrides the directory) and remove it on a graceful + exit. A kill or crash leaves the file behind, so readers confirm the pid is + alive *and* has the recorded start time (pids get reused) and delete the ones + that fail. This is groundwork for `dune ps` to list instances from what each + one says about itself rather than guessing from command lines and `lsof`; + nothing reads it yet, and a registry that can't be written never stops a + server from starting. Unix only. + +- **`dune ps` now tells a hung server from a healthy one, and finds servers + that registered themselves.** Every listening instance is sent a `GET + /health/live` (which shows up in its access log): to the port it registered + when it did, otherwise to every port it listens on, counting only dune's own + answer so a debugger's inspector port can't make a hung server look healthy. + A blocked event loop still lets the kernel accept the connection but never + answers it, so an instance that fails three probes in a row (3 seconds each, + about 10 seconds in all) is shown as **NOT RESPONDING** and counted + separately — it looks perfectly healthy to `lsof`. One failed probe proves + nothing, and a port that speaks HTTP but not like dune gives "can't tell", + not a hung verdict. Instances that wrote an entry to the instance registry + are also listed even if their command line doesn't look like dune, count as + confirmed, and show their `@dune/core` version. `dune stop --idle` also takes + confirmed instances that are listening but not responding, once they are at + least 2 minutes old. With the watchdog in its default mode a hung server is + killed within seconds, so this mostly matters under `DUNE_WATCHDOG=warn`/`off`; + a server stuck in a loop can't handle SIGTERM, so `dune stop --force` is + how to end one. + - **Event-loop watchdog: a server whose main thread gets stuck no longer hangs silently until someone restarts it.** One synchronous loop (catastrophic regex backtracking, a conversion that never terminates) blocks the single @@ -93,73 +123,6 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what ### Added -- **`dune ps` now tells a hung server from a healthy one, and finds servers - that registered themselves.** Every listening instance is sent a `GET - /health/live` (which shows up in its access log): to the port it registered - when it did, otherwise to every port it listens on, counting only dune's own - answer so a debugger's inspector port can't make a hung server look healthy. - A blocked event loop still lets the kernel accept the connection but never - answers it, so an instance that fails three probes in a row (3 seconds each, - about 10 seconds in all) is shown as **NOT RESPONDING** and counted - separately — it looks perfectly healthy to `lsof`. One failed probe proves - nothing, and a port that speaks HTTP but not like dune gives "can't tell", - not a hung verdict. Instances that wrote an entry to the instance registry - are also listed even if their command line doesn't look like dune, count as - confirmed, and show their `@dune/core` version. `dune stop --idle` also takes - confirmed instances that are listening but not responding, once they are at - least 2 minutes old. With the watchdog in its default mode a hung server is - killed within seconds, so this mostly matters under `DUNE_WATCHDOG=warn`/`off`; - a server stuck in a loop can't handle SIGTERM, so `dune stop --force` is - how to end one. - -- **Running instances now register themselves.** `dune dev` and `dune serve` - write a small record (pid, process start time, mode, site root, title, port, - `@dune/core` version) to `~/.dune/instances/.json` - (`DUNE_REGISTRY_DIR` overrides the directory) and remove it on a graceful - exit. A kill or crash leaves the file behind, so readers confirm the pid is - alive *and* has the recorded start time (pids get reused) and delete the ones - that fail. This is groundwork for `dune ps` to list instances from what each - one says about itself rather than guessing from command lines and `lsof`; - nothing reads it yet, and a registry that can't be written never stops a - server from starting. Unix only. -- **Event-loop watchdog: a server whose main thread gets stuck no longer hangs - silently until someone restarts it.** One synchronous loop (catastrophic - regex backtracking, a conversion that never terminates) blocks the single - thread that serves every request, so every URL stops answering at once, with - nothing in the logs. `dune dev` and `dune serve` now run a small watchdog - Worker that expects a heartbeat from the main thread every second. After 15 - seconds of silence (`DUNE_WATCHDOG_TIMEOUT_MS`; 2 minutes until the server is - up, `DUNE_WATCHDOG_STARTUP_TIMEOUT_MS`, so a large site's initial indexing - can't put a supervised process into a kill/restart loop) it writes a report - to `/.dune/hangs/` naming the requests that were in flight (path - segments longer than 24 characters are cut, so tokens in URLs don't land in - it; the newest 20 reports are kept; plus a native stack sample on macOS), logs - the same lines to stderr, and SIGKILLs the process so a supervisor (systemd - `Restart=`; the `dune dev` wrapper exits 137) sees a dead process instead of - a zombie. The kill always happens: writing the report is abandoned after 5 - seconds. `DUNE_WATCHDOG=warn` reports without killing and is chosen - automatically when the process runs under `--inspect`/`--inspect-brk` (a - breakpoint pauses only the main thread); `DUNE_WATCHDOG=off` disables it. A - process that is suspended and resumed (laptop sleep, Ctrl-Z) is not mistaken - for a hang: the stall clock is monotonic and the Worker discards a gap in its - own ticks. It kills with SIGKILL rather than `Deno.exit()` because an exit - requested from a Worker only ends the Worker — the spinning main thread - carries on. -- **Telling the watchdog about legitimately synchronous work.** The watchdog - can't tell an infinite loop from a long computation, only whether the loop is - making progress. `@dune/core/watchdog` exports two functions for code that - blocks on purpose: `watchdogProgress()`, called per item inside a long - synchronous loop (counts as a heartbeat; rate-limited, so cheap), and - `watchdogGrace(ms, reason)`, a declared busy window for a single long native - call (capped at 10 minutes, nests, expires on its own, logged each time). A - loop that calls `watchdogProgress()` on every iteration of an unbounded loop - looks alive forever; use it only where the loop has a natural end. Measured - on a 4,474-page site: startup indexing and the search build never held the - event loop for over a second, so none of Dune's own loops needed either call. -- The main thread also logs `[dune] event loop was blocked for ~N.Ns` whenever - it was held for over a second, which shows a creeping problem before it - becomes a hang. - - **`dune stop`** stops dune instances, with their whole process tree (including any other tool a `deno task` wrapper started). A target is required, so stopping every server on a machine always has to be asked for by name: `dune @@ -185,6 +148,7 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what usage error, 1 when a named pid/port matches nothing or something would not stop, and 0 otherwise. `dune ps` stays read-only and now points at it. + ## [0.34.14] — 2026-10-21 ### Security