diff --git a/CHANGELOG.md b/CHANGELOG.md index 10351b1f..c49c3681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,51 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what that owns the file. Language-prefixed media links that already exist in the wild start working too. +- **`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 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: 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 + 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 ### Security diff --git a/README.md b/README.md index c8ddbd8a..d88398cd 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ This snapshots `deno.json` at install time rather than reading it live, so it go | `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 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 30afd52e..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"; @@ -197,6 +199,17 @@ export async function main(args: string[] = Deno.args) { 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; + case "validate": await validateCommand(root, { debug: options.debug === true, diff --git a/src/cli/args.ts b/src/cli/args.ts index 6de40b5a..51213155 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -103,6 +103,10 @@ export function parseCliOptions(args: string[]): ParsedCliArgs { options.integrity = args[++i]; } else if (args[i] === "--readonly") { options.readonly = 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 2bb99631..c04ccf59 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -20,7 +20,16 @@ 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) + 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..bc6d98f8 --- /dev/null +++ b/src/cli/instances.ts @@ -0,0 +1,517 @@ +/** + * 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 { 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. */ +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; + /** `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; +} + +/** Deno's permission system refused something `dune ps`/`dune stop` needs. */ +export class MissingPermissionError extends Error {} + +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 (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; + } +} + + +// ── Process table ──────────────────────────────────────────────────────────── + +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. + * + * `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=,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+(\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), + uid: parseInt(m[3], 10), + started: m[4].replace(/\s+/g, " "), + elapsed: m[5], + command: m[6], + }); + } + return rows; +} + +/** How a command line matched: `strong` is unambiguous, `weak` needs a confirmed site. */ +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/; + +/** + * 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", 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", kind: "cli" }; + } + + const task = command.match(/\bdeno\s+task\s+(dev|serve)\b/); + if (task) return { mode: task[1] as "dev" | "serve", strength: "weak", kind: "task" }; + + 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; +} + +/** + * 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[] = []; + 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( + 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). 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(); + 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; + } + /** 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); + 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). + // 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); + 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, + ageSeconds: parseElapsedSeconds(c.elapsed), + confirmed: confirmedRoot !== null, + startTimes: Object.fromEntries(pids.flatMap((p) => byPid.get(p) ? [[p, byPid.get(p)!.started]] : [])), + 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 09a019a0..12e2971b 100644 --- a/src/cli/ps.ts +++ b/src/cli/ps.ts @@ -1,323 +1,73 @@ /** * dune ps — list dune dev/serve instances currently running on this machine. * - * 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. - * - * 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). - * - * 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 { join } from "@std/path"; -import { parseUserYaml } from "../security/safe-yaml.ts"; +import { type DuneInstance, findDuneInstances, MissingPermissionError } from "./instances.ts"; -/** A dune dev/serve-shaped process found running on this machine. */ -export interface DuneInstance { - pid: 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. - */ - 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: number[]; - /** `ps`'s own elapsed-time string (e.g. "01:23:45" or "2-09:03:55"). */ - elapsed: string | null; +/** Injection points so the command's output is testable without touching real processes. */ +export interface PsDeps { + find?: () => Promise; + log?: (line: string) => void; } -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; - } -} - -/** - * 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). - * - * `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. - */ -async function findCandidates(): Promise< - { pid: number; elapsed: string; command: string }[] -> { - const out = await runCommand("ps", ["-eo", "pid=,etime=,command="], { - COLUMNS: "4096", - }); - if (!out) return []; - - const results: { pid: number; elapsed: string; command: string }[] = []; - 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 }); - } - return results; +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)"; } -/** - * 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; -} - -/** - * 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; +/** 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, opts: { fullCommand?: boolean } = {}): 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)}`, + ` $ ${!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)`]), + "", + ]; } -/** - * 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)); +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."); + return; } - 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 { + log("🏜️ Dune — scanning for running instances...\n"); + let all: DuneInstance[]; 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; + all = await (deps.find ?? (() => findDuneInstances({ onWarning: (m) => log(` ⚠ ${m}`) })))(); + } catch (err) { + if (err instanceof MissingPermissionError) { + log(` ${err.message}`); + Deno.exitCode = 1; + return; } - return null; - } catch { - return null; - } -} - -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. - */ -async function confirmSiteRoot( - dir: string | null, - fallbackDir: string | null, -): Promise { - for (const candidate of [dir, fallbackDir]) { - if (!candidate) continue; - try { - await Deno.stat(join(candidate, "config", "site.yaml")); - return candidate; - } catch { /* try the next candidate */ } + throw err; } - return null; -} -/** - * Find every dune dev/serve-shaped process 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. - */ -export async function findDuneInstances(): Promise { - const candidates = await findCandidates(); - const instances: DuneInstance[] = []; - - for (const { pid, elapsed, command } of candidates) { - const [pwdRoot, liveCwd] = await Promise.all([getPwdEnv(pid), getLiveCwd(pid)]); - const root = await confirmSiteRoot(pwdRoot, liveCwd); - - const [title, ports] = await Promise.all([ - root ? readSiteTitle(root) : Promise.resolve(null), - getListeningPorts(pid), - ]); - - instances.push({ - pid, - mode: modeFromCommand(command), - root, - title, - ports, - elapsed, - }); - } - - return instances; -} - -export async function psCommand(): Promise { - if (Deno.build.os === "windows") { - console.log( - " ⚠ `dune ps` needs `ps`/`lsof` and isn't supported on Windows yet.", - ); + if (all.length === 0) { + log(" No dune dev/serve instances found running locally."); return; } - console.log("🏜️ Dune — scanning for running instances...\n"); - const instances = await findDuneInstances(); - - if (instances.length === 0) { - console.log(" No dune dev/serve instances found running locally."); - return; - } + for (const inst of all) for (const line of formatInstance(inst)) log(line); - 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`); + const serving = all.filter((i) => i.portStatus === "listening").length; + const idle = all.filter((i) => i.portStatus === "not-listening").length; + log(` ${all.length} instance${all.length === 1 ? "" : "s"}: ${serving} listening, ${idle} not listening\n`); + if (idle > 0) { + log(" `dune stop --idle` stops only the not-listening ones; `dune stop ` stops one instance."); } } 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..70c9a0a9 --- /dev/null +++ b/src/cli/stop.ts @@ -0,0 +1,393 @@ +/** + * 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, 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, + * 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, MissingPermissionError, 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[]; + /** 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[]; + /** Pids Deno's own permission system refused to signal (needs `--allow-run`). */ + needsPermission: 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; +} + +/** Current start time of each of `pids` that still exists. */ +async function currentStartTimes(pids: 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; needsPermission: 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 (err) { + 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 + } + } +} + +/** + * 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)); + 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(), needsPermission: new Set() }; + + await signalVerified(toSignal, expected, "SIGTERM", sink); + 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) && !sink.needsPermission.has(p)); + const left = await waitUntilGone(sent, killTimeoutMs); + 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))); + return { + stopped: signalled.filter((p) => gone.has(p)), + killed, + survivors, + skipped, + changed: [...sink.changed], + denied: [...sink.denied], + needsPermission: [...sink.needsPermission], + }; +} + +// ── 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; +} + +/** `--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 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 + +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; notes: 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}.`; +} + +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] + .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 }; + } + + 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 of yours has a process with pid ${pid}. \`dune ps\` lists them.`, exitCode: 1 }; + } + return { ok: true, instances: found, label: "", notes: unconfirmedNote(found) }; + } + + 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 of yours is listening on port ${port}. \`dune ps\` lists them.`, exitCode: 1 }; + } + 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 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 (mature.length === 0) { + const why = [...notes].map((n) => `\n ${n}`).join(""); + return { + ok: false, + 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: mature, label: "idle ", notes }; + } + + 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 ────────────────────────────────────────────────────────────────── + +/** 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; + } + + 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. + 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, notes } = selection; + log(""); + 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(""); + + 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.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.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(", ")}`); + } + 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..d62f2ffd --- /dev/null +++ b/tests/cli/ps_helpers.ts @@ -0,0 +1,97 @@ +/** + * 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", + 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 2bdbcff3..de55b8e3 100644 --- a/tests/cli/ps_test.ts +++ b/tests/cli/ps_test.ts @@ -1,26 +1,30 @@ /** - * 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"; import { join } from "@std/path"; -import { findDuneInstances } from "../../src/cli/ps.ts"; - -const BOOT_TIMEOUT_MS = 10_000; -const POLL_INTERVAL_MS = 100; +import { + type DuneInstance, + findDuneInstances, + matchDuneCommand, + parseRootArg, +} from "../../src/cli/instances.ts"; +import { psCommand } from "../../src/cli/ps.ts"; +import { commandSiteRootHint, parseElapsedSeconds, readProcessTable } from "../../src/cli/instances.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. */ @@ -28,29 +32,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 }, @@ -220,9 +201,296 @@ 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", 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", 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", () => { + 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); +}); + +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(() => {}); + } +}); + +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("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(() => {}); + } +}); + +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(() => {}); + } +}); + +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 { + const lines: string[] = []; + 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([ + 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: 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: 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); + 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..c88715c9 --- /dev/null +++ b/tests/cli/stop_test.ts @@ -0,0 +1,494 @@ +/** + * 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, realStartTimes, 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], startTimes: await realStartTimes([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], startTimes: await realStartTimes([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.) +}); + +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, []); +}); + +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" }); +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 confirmed 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 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]); +}); + +// ── stopCommand flow (fakes only) ──────────────────────────────────────────── + +async function runStop( + instances: DuneInstance[], + o: StopOptions, + deps: { interactive?: boolean; confirm?: boolean; survivors?: number[]; killed?: number[]; changed?: number[]; denied?: 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: [], + changed: deps.changed ?? [], + denied: deps.denied ?? [], + needsPermission: [], + }); + }, + 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: 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); +}); + +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); +});