From 0a72eaf52b884737e54aa17a2de7e29529752397 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 22 Oct 2026 09:28:16 +0200 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 6f14bf30c8dbdd881e86a2bd15b0bf3020613be0 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Mon, 26 Oct 2026 17:30:32 +0100 Subject: [PATCH 4/7] 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 9c2508407b044c302537c81f15563fb7bfe45b21 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Mon, 26 Oct 2026 23:01:40 +0100 Subject: [PATCH 5/7] 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 6/7] 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 7/7] 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",