diff --git a/CHANGELOG.md b/CHANGELOG.md index 10b4f194..1fe3658d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,25 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what ### Added +- **`dune ps` now tells a hung server from a healthy one, and finds servers + that registered themselves.** Every listening instance is sent a `GET + /health/live` (which shows up in its access log): to the port it registered + when it did, otherwise to every port it listens on, counting only dune's own + answer so a debugger's inspector port can't make a hung server look healthy. + A blocked event loop still lets the kernel accept the connection but never + answers it, so an instance that fails three probes in a row (3 seconds each, + about 10 seconds in all) is shown as **NOT RESPONDING** and counted + separately — it looks perfectly healthy to `lsof`. One failed probe proves + nothing, and a port that speaks HTTP but not like dune gives "can't tell", + not a hung verdict. Instances that wrote an entry to the instance registry + are also listed even if their command line doesn't look like dune, count as + confirmed, and show their `@dune/core` version. `dune stop --idle` also takes + confirmed instances that are listening but not responding, once they are at + least 2 minutes old. With the watchdog in its default mode a hung server is + killed within seconds, so this mostly matters under `DUNE_WATCHDOG=warn`/`off`; + a server stuck in a loop can't handle SIGTERM, so `dune stop --force` is + how to end one. + - **Running instances now register themselves.** `dune dev` and `dune serve` write a small record (pid, process start time, mode, site root, title, port, `@dune/core` version) to `~/.dune/instances/.json` diff --git a/src/cli/instances.ts b/src/cli/instances.ts index bc6d98f8..f6e9b0ce 100644 --- a/src/cli/instances.ts +++ b/src/cli/instances.ts @@ -42,6 +42,7 @@ import { dirname, fromFileUrl, isAbsolute, join, resolve } from "@std/path"; import { parseUserYaml } from "../security/safe-yaml.ts"; +import { listRegisteredInstances } from "../runtime/instance-registry.ts"; /** A dune dev/serve-shaped process (tree) found running on this machine. */ export interface DuneInstance { @@ -66,6 +67,15 @@ export interface DuneInstance { * so we can't tell. */ portStatus: "listening" | "not-listening" | "unknown"; + /** + * Whether a listening instance answered an HTTP request: `"responding"`, + * `"not-responding"` (accepts connections but gives no answer in time — + * the signature of a blocked event loop, or just a very busy server), or + * `"unknown"` (not listening, or not probed). + */ + responsive: "responding" | "not-responding" | "unknown"; + /** `@dune/core` version, when the instance registered itself (see runtime/instance-registry.ts). */ + version: string | null; /** `ps`'s own elapsed-time string (e.g. "01:23:45" or "2-09:03:55"). */ elapsed: string | null; /** `elapsed` in seconds, or null when unparseable. */ @@ -397,6 +407,70 @@ async function confirmSiteRoot(dirs: (string | null)[]): Promise return null; } +// ── Health probe ───────────────────────────────────────────────────────────── + +export const PROBE_TIMEOUT_MS = 3_000; +const PROBE_ATTEMPTS = 3; +const PROBE_GAP_MS = 500; + +/** What answered on a port: dune's own health check, some other HTTP service, or nothing in time. */ +export type PortProbe = "dune" | "other" | "none"; + +/** + * Ask `port` for `/health/live`. A blocked event loop still lets the kernel + * accept the TCP connection, but nothing ever answers it, which is exactly what + * the timeout catches. A JSON body with a `status` field is dune's own answer; + * anything else that speaks HTTP (a debugger's inspector port, another tool) + * is "other". Probes 127.0.0.1, not `localhost`, so IPv6 resolution can't + * change the result. Note this sends a real GET to the instance, which shows + * up in its access log. + */ +export async function probePort(port: number, timeoutMs = PROBE_TIMEOUT_MS): Promise { + try { + const res = await fetch(`http://127.0.0.1:${port}/health/live`, { + signal: AbortSignal.timeout(timeoutMs), + redirect: "manual", + }); + const text = (await res.text()).slice(0, 512); + try { + const body = JSON.parse(text); + if (res.ok && body && typeof body === "object" && typeof body.status === "string") return "dune"; + } catch { /* not JSON */ } + return "other"; + } catch { + return "none"; + } +} + +/** + * Is this instance answering? Probes the port the instance itself registered + * when there is one (any HTTP answer counts), otherwise every listening port + * (only dune's own answer counts, so a debugger's inspector port can't make a + * hung server look healthy). A single failure proves nothing — a busy server + * can be slow for a few seconds — so it takes `attempts` failures in a row, + * spaced `gapMs` apart. If some port speaks HTTP but none answers like dune, + * the verdict is "unknown" rather than a claim that it is hung. + */ +export async function judgeResponsive( + ports: number[], + registeredPort: number | null, + probe: (port: number) => Promise, + attempts = PROBE_ATTEMPTS, + gapMs = PROBE_GAP_MS, +): Promise<"responding" | "not-responding" | "unknown"> { + if (ports.length === 0) return "unknown"; + const useRegistered = registeredPort !== null && ports.includes(registeredPort); + const candidates = useRegistered ? [registeredPort] : ports; + let sawOther = false; + for (let i = 0; i < attempts; i++) { + const results = await Promise.all(candidates.map(probe)); + if (results.some((r) => (useRegistered ? r !== "none" : r === "dune"))) return "responding"; + if (results.includes("other")) sawOther = true; + if (i < attempts - 1) await new Promise((r) => setTimeout(r, gapMs)); + } + return sawOther ? "unknown" : "not-responding"; +} + /** * Find every dune dev/serve instance on this machine. * @@ -406,8 +480,15 @@ async function confirmSiteRoot(dirs: (string | null)[]): Promise * are null in that case, and the caller should say so plainly. */ export async function findDuneInstances( - opts: { onWarning?: (message: string) => void } = {}, + opts: { + onWarning?: (message: string) => void; + probe?: (port: number) => Promise; + probeAttempts?: number; + probeGapMs?: number; + } = {}, ): Promise { + const probe = opts.probe ?? probePort; + const registeredPorts = new Map(); // instance pid -> port it registered 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 @@ -506,6 +587,8 @@ export async function findDuneInstances( title, ports, portStatus: !haveLsof ? "unknown" : ports.length > 0 ? "listening" : "not-listening", + responsive: "unknown", + version: null, elapsed: c.elapsed, ageSeconds: parseElapsedSeconds(c.elapsed), confirmed: confirmedRoot !== null, @@ -513,5 +596,55 @@ export async function findDuneInstances( command: c.command, }); } + + // 5. Instances that registered themselves (runtime/instance-registry.ts): + // attach what they report about themselves, and add any the command-line + // matching missed (a server started in an unusual way). + for (const rec of await listRegisteredInstances()) { + const existing = instances.find((i) => i.pids.includes(rec.pid)); + if (existing) { + existing.version = rec.version; + existing.confirmed = true; // the process says so itself + registeredPorts.set(existing.pid, rec.port); + if (existing.root === null) { + existing.root = rec.root; + existing.title = rec.title ?? (await readSiteTitle(rec.root)); + } + continue; + } + const row = byPid.get(rec.pid); + if (!row) continue; + const pids = [rec.pid, ...descendantsOf(rec.pid, children)]; + const ports = haveLsof ? await getListeningPorts(pids) : []; + instances.push({ + pid: rec.pid, + pids, + mode: rec.mode, + root: rec.root, + title: rec.title ?? (await readSiteTitle(rec.root)), + ports, + portStatus: !haveLsof ? "unknown" : ports.length > 0 ? "listening" : "not-listening", + responsive: "unknown", + version: rec.version, + elapsed: row.elapsed, + ageSeconds: parseElapsedSeconds(row.elapsed), + confirmed: true, + startTimes: Object.fromEntries(pids.flatMap((p) => byPid.get(p) ? [[p, byPid.get(p)!.started]] : [])), + command: row.command, + }); + registeredPorts.set(rec.pid, rec.port); + } + + // 6. Probe every listening instance, all at once. + await Promise.all(instances.map(async (inst) => { + inst.responsive = await judgeResponsive( + inst.ports, + registeredPorts.get(inst.pid) ?? null, + probe, + opts.probeAttempts, + opts.probeGapMs, + ); + })); + return instances.sort((a, b) => a.pid - b.pid); } diff --git a/src/cli/ps.ts b/src/cli/ps.ts index 12e2971b..7d85053f 100644 --- a/src/cli/ps.ts +++ b/src/cli/ps.ts @@ -16,7 +16,12 @@ export interface PsDeps { } export function describePort(inst: DuneInstance): string { - if (inst.portStatus === "listening") return inst.ports.map((p) => `http://localhost:${p}`).join(", "); + if (inst.portStatus === "listening") { + const urls = inst.ports.map((p) => `http://localhost:${p}`).join(", "); + return inst.responsive === "not-responding" + ? `${urls} — NOT RESPONDING (no answer to a health check; the server may be hung)` + : urls; + } if (inst.portStatus === "not-listening") return "not listening (no open port; likely a hung or leftover process)"; return "port unknown (lsof not available)"; } @@ -27,7 +32,7 @@ export function formatInstance(inst: DuneInstance, opts: { fullCommand?: boolean ? ` (+${inst.pids.length - 1} child process${inst.pids.length > 2 ? "es" : ""})` : ""; return [ - ` PID ${inst.pid} ${inst.mode.padEnd(5)} up ${inst.elapsed ?? "?"}${extra}`, + ` PID ${inst.pid} ${inst.mode.padEnd(5)} up ${inst.elapsed ?? "?"}${inst.version ? ` v${inst.version}` : ""}${extra}`, ` ${inst.title ?? "(untitled)"}`, ` ${inst.root ?? "(site root unconfirmed — no config/site.yaml via --root, PWD or live cwd)"}`, ` ${describePort(inst)}`, @@ -65,9 +70,13 @@ export async function psCommand(deps: PsDeps = {}): Promise { for (const inst of all) for (const line of formatInstance(inst)) log(line); const serving = all.filter((i) => i.portStatus === "listening").length; - const idle = all.filter((i) => i.portStatus === "not-listening").length; - log(` ${all.length} instance${all.length === 1 ? "" : "s"}: ${serving} listening, ${idle} not listening\n`); - if (idle > 0) { - log(" `dune stop --idle` stops only the not-listening ones; `dune stop ` stops one instance."); + const stuck = all.filter((i) => i.responsive === "not-responding").length; + const notListening = all.filter((i) => i.portStatus === "not-listening").length; + log( + ` ${all.length} instance${all.length === 1 ? "" : "s"}: ${serving} listening` + + `${stuck > 0 ? ` (${stuck} not responding)` : ""}, ${notListening} not listening\n`, + ); + if (stuck + notListening > 0) { + log(" `dune stop --idle` stops only the ones that aren't serving (not listening or not responding); `dune stop ` stops one instance."); } } diff --git a/src/cli/stop.ts b/src/cli/stop.ts index 70c9a0a9..2829bd9d 100644 --- a/src/cli/stop.ts +++ b/src/cli/stop.ts @@ -165,7 +165,7 @@ export interface StopOptions { pid?: string; /** The instance listening on this port. */ port?: string; - /** Only instances that are running but not listening (hung or leftover). */ + /** Only instances that aren't serving: running but not listening, or listening but not responding (hung or leftover). */ idle?: boolean; /** Every instance `dune ps` lists. */ all?: boolean; @@ -182,8 +182,9 @@ export const STOP_USAGE = `Usage: dune stop | --port | --idle | --all the instance this process belongs to (see \`dune ps\`) --port the instance listening on that port - --idle only instances that are running but have not been - listening for at least 2 minutes (younger ones may still + --idle only instances that aren't serving: running but not + listening, or listening but not responding to a health + check, 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) @@ -252,12 +253,12 @@ export function selectInstances(all: DuneInstance[], opts: StopOptions): Selecti } if (opts.idle) { - const notServing = confirmed.filter((i) => i.portStatus === "not-listening"); + const notServing = confirmed.filter((i) => i.portStatus === "not-listening" || i.responsive === "not-responding"); 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 ` + + `${plural(young, "instance", "instances")} not serving 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.`, ); } @@ -273,7 +274,7 @@ export function selectInstances(all: DuneInstance[], opts: StopOptions): Selecti 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}`, + 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 and responding`}.${why}`, exitCode: 0, }; } diff --git a/tests/cli/ps_helpers.ts b/tests/cli/ps_helpers.ts index d62f2ffd..eab0a035 100644 --- a/tests/cli/ps_helpers.ts +++ b/tests/cli/ps_helpers.ts @@ -74,6 +74,8 @@ export function fakeInstance(overrides: Partial = {}): DuneInstanc title: "Fake", ports: [], portStatus: "not-listening", + responsive: "unknown", + version: null, elapsed: "01:00", ageSeconds: 3600, confirmed: true, diff --git a/tests/cli/ps_test.ts b/tests/cli/ps_test.ts index de55b8e3..d4937532 100644 --- a/tests/cli/ps_test.ts +++ b/tests/cli/ps_test.ts @@ -17,6 +17,9 @@ import { findDuneInstances, matchDuneCommand, parseRootArg, + judgeResponsive, + type PortProbe, + probePort, } from "../../src/cli/instances.ts"; import { psCommand } from "../../src/cli/ps.ts"; import { commandSiteRootHint, parseElapsedSeconds, readProcessTable } from "../../src/cli/instances.ts"; @@ -453,6 +456,213 @@ Deno.test("findDuneInstances: an unrelated main.ts run from INSIDE a site direct } }); +// ── health probe and registry ──────────────────────────────────────────────── + +/** Server shape for a listener that accepts TCP connections but never answers HTTP. */ +const MUTE_SERVER = (port: number) => + `const l = Deno.listen({ port: ${port} });\nsetInterval(() => {}, 1000);\nawait new Promise(() => {});\n`; + +const quickProbe = (port: number) => probePort(port, 600); +const fastOpts = { probe: quickProbe, probeAttempts: 2, probeGapMs: 100 }; + +Deno.test("probePort: dune's own health answer, some other HTTP service, and silence are told apart", opts, async () => { + const dune = Deno.serve({ port: 18_921, onListen: () => {} }, () => Response.json({ status: "alive" })); + const other = Deno.serve({ port: 18_928, onListen: () => {} }, () => new Response("nope", { status: 404 })); + const listener = Deno.listen({ port: 18_922 }); // accepts the connection, never answers + try { + assertEquals(await probePort(18_921, 2000), "dune"); + assertEquals(await probePort(18_928, 2000), "other"); + assertEquals(await probePort(18_922, 500), "none"); + } finally { + listener.close(); + await dune.shutdown(); + await other.shutdown(); + } + assertEquals(await probePort(18_923, 500), "none", "a closed port"); +}); + +Deno.test("judgeResponsive: one slow or failed probe is not enough; it takes every attempt failing", async () => { + let calls = 0; + const flaky = (): Promise => Promise.resolve(++calls < 3 ? "none" : "dune"); + assertEquals(await judgeResponsive([3000], null, flaky, 3, 1), "responding", "answered on the third attempt"); + assertEquals(calls, 3); + assertEquals(await judgeResponsive([3000], null, () => Promise.resolve("none"), 3, 1), "not-responding"); +}); + +Deno.test("judgeResponsive: the registered port is the one that counts, and any HTTP answer from it is enough", async () => { + const probed: number[] = []; + const probe = (p: number): Promise => { probed.push(p); return Promise.resolve(p === 10_000 ? "other" : "dune"); }; + // A debugger on 9229 would answer, but the site registered 10000. + assertEquals(await judgeResponsive([9229, 10_000], 10_000, probe, 1, 1), "responding"); + assertEquals(probed, [10_000], "only the registered port is probed"); +}); + +Deno.test("judgeResponsive: without a registered port every listening port is probed, and only dune's own answer counts", async () => { + // Hung site on 10000 (silent) + an inspector on 9229 (answers HTTP, not like dune): never "responding". + const probe = (p: number): Promise => Promise.resolve(p === 9229 ? "other" : "none"); + assertEquals(await judgeResponsive([9229, 10_000], null, probe, 2, 1), "unknown", "can't tell — not claimed to be hung"); + // Healthy site on 10000 alongside the inspector. + const healthy = (p: number): Promise => Promise.resolve(p === 9229 ? "other" : "dune"); + assertEquals(await judgeResponsive([9229, 10_000], null, healthy, 2, 1), "responding"); + assertEquals(await judgeResponsive([], null, healthy), "unknown", "nothing listening, nothing to probe"); +}); + +Deno.test("findDuneInstances: a listening instance that answers is 'responding'; one that accepts but never answers is 'not-responding'", opts, async () => { + const live = await makeSite("dune_test_ps_resp_", "Responding"); + const mute = await makeSite("dune_test_ps_mute_", "Mute"); + let a: Deno.ChildProcess | undefined; + let b: Deno.ChildProcess | undefined; + try { + await Deno.writeTextFile(join(live, "main.ts"), `Deno.serve({ port: 18924, onListen: () => {} }, () => Response.json({ status: "alive" }));\nawait new Promise(() => {});\n`); + await Deno.writeTextFile(join(mute, "main.ts"), MUTE_SERVER(18_925)); + a = spawnDeno(["run", "-A", "main.ts", "serve"], live); + b = spawnDeno(["run", "-A", "main.ts", "serve"], mute); + await waitForPort(18_924); + await new Promise((r) => setTimeout(r, 800)); + + const found = await findDuneInstances(fastOpts); + const ok = found.find((i) => i.pid === a!.pid); + const bad = found.find((i) => i.pid === b!.pid); + assertExists(ok); + assertExists(bad); + assertEquals(ok.responsive, "responding"); + assertEquals(bad.portStatus, "listening", "it does hold the port"); + assertEquals(bad.responsive, "not-responding"); + } finally { + if (a) await stop(a); + if (b) await stop(b); + await Deno.remove(live, { recursive: true }).catch(() => {}); + await Deno.remove(mute, { recursive: true }).catch(() => {}); + } +}); + +Deno.test("findDuneInstances: a not-listening instance is not probed ('unknown')", opts, async () => { + const site = await makeSite("dune_test_ps_noprobe_", "Idle"); + let child: Deno.ChildProcess | undefined; + try { + await Deno.writeTextFile(join(site, "main.ts"), `setInterval(() => {}, 1000);\nawait new Promise(() => {});\n`); + child = spawnDeno(["run", "-A", "main.ts", "serve"], site); + await new Promise((r) => setTimeout(r, 600)); + const found = (await findDuneInstances(fastOpts)).find((i) => i.pid === child!.pid); + assertExists(found); + assertEquals(found.portStatus, "not-listening"); + assertEquals(found.responsive, "unknown", "nothing to probe without a port"); + } finally { + if (child) await stop(child); + await Deno.remove(site, { recursive: true }).catch(() => {}); + } +}); + +/** Run `body` with DUNE_REGISTRY_DIR pointing at a fresh temp dir (for this process and any child it spawns). */ +async function withRegistryDir(fn: (dir: string) => Promise): Promise { + const dir = await Deno.makeTempDir({ prefix: "dune_test_ps_registry_" }); + const prev = Deno.env.get("DUNE_REGISTRY_DIR"); + Deno.env.set("DUNE_REGISTRY_DIR", dir); + try { + await fn(dir); + } finally { + if (prev === undefined) Deno.env.delete("DUNE_REGISTRY_DIR"); + else Deno.env.set("DUNE_REGISTRY_DIR", prev); + await Deno.remove(dir, { recursive: true }).catch(() => {}); + } +} + +const REGISTRY_URL = new URL("../../src/runtime/instance-registry.ts", import.meta.url).href; +// The child imports dune's own source, whose bare specifiers (@std/path) need dune's import map. +const DUNE_CONFIG = new URL("../../deno.json", import.meta.url).pathname; + +Deno.test("findDuneInstances: a server whose command line doesn't look like dune is still found, via the registry", opts, async () => { + await withRegistryDir(async () => { + const site = await makeSite("dune_test_ps_regonly_", "Registry Only"); + let child: Deno.ChildProcess | undefined; + try { + await Deno.writeTextFile( + join(site, "weird.ts"), + `import { registerInstance } from ${JSON.stringify(REGISTRY_URL)};\n` + + `await registerInstance({ mode: "serve", root: ${JSON.stringify(site)}, title: "Registry Only", port: 18926, version: "9.9.9" });\n` + + SERVER(18_926) + `await new Promise(() => {});\n`, + ); + child = spawnDeno(["run", "-A", `--config=${DUNE_CONFIG}`, "weird.ts"], site); + await waitForPort(18_926); + await new Promise((r) => setTimeout(r, 500)); + + const found = (await findDuneInstances(fastOpts)).find((i) => i.pid === child!.pid); + assertExists(found, "the registry should reveal a process the command-line matching can't"); + assertEquals(found.version, "9.9.9"); + assertEquals(found.mode, "serve"); + assertEquals(found.root, site); + assertEquals(found.title, "Registry Only"); + assertEquals(found.ports, [18_926]); + assertEquals(found.responsive, "responding"); + } finally { + if (child) await stop(child); + await Deno.remove(site, { recursive: true }).catch(() => {}); + } + }); +}); + +Deno.test("findDuneInstances: a registered process that command-line matching also finds appears once, with its version", opts, async () => { + await withRegistryDir(async () => { + const site = await makeSite("dune_test_ps_regboth_", "Both"); + let child: Deno.ChildProcess | undefined; + try { + await Deno.writeTextFile( + join(site, "main.ts"), + `import { registerInstance } from ${JSON.stringify(REGISTRY_URL)};\n` + + `await registerInstance({ mode: "serve", root: ${JSON.stringify(site)}, title: "Both", port: 18927, version: "1.2.3" });\n` + + SERVER(18_927) + `await new Promise(() => {});\n`, + ); + child = spawnDeno(["run", "-A", `--config=${DUNE_CONFIG}`, "main.ts", "serve"], site); + await waitForPort(18_927); + await new Promise((r) => setTimeout(r, 500)); + const mine = (await findDuneInstances(fastOpts)).filter((i) => i.pids.includes(child!.pid)); + assertEquals(mine.length, 1); + assertEquals(mine[0].version, "1.2.3"); + } finally { + if (child) await stop(child); + await Deno.remove(site, { recursive: true }).catch(() => {}); + } + }); +}); + +Deno.test("findDuneInstances: a registered process is confirmed even though its command line says nothing about a site", opts, async () => { + await withRegistryDir(async () => { + const site = await makeSite("dune_test_ps_regconf_", "Registered"); + const elsewhere = await Deno.makeTempDir({ prefix: "dune_test_ps_regconf_tool_" }); + let child: Deno.ChildProcess | undefined; + try { + await Deno.writeTextFile( + join(elsewhere, "main.ts"), + `import { registerInstance } from ${JSON.stringify(REGISTRY_URL)};\n` + + `await registerInstance({ mode: "serve", root: ${JSON.stringify(site)}, title: "Registered", port: null, version: "3.2.1" });\n` + + `setInterval(() => {}, 1000);\nawait new Promise(() => {});\n`, + ); + // Started from a directory with no site, from a script outside any site: unconfirmed by its command line. + child = spawnDeno(["run", "-A", `--config=${DUNE_CONFIG}`, join(elsewhere, "main.ts"), "serve"], elsewhere); + await new Promise((r) => setTimeout(r, 1200)); + const found = (await findDuneInstances(fastOpts)).find((i) => i.pid === child!.pid); + assertExists(found); + assertEquals(found.confirmed, true, "the process itself says it is a dune site"); + assertEquals(found.version, "3.2.1"); + } finally { + if (child) await stop(child); + await Deno.remove(site, { recursive: true }).catch(() => {}); + await Deno.remove(elsewhere, { recursive: true }).catch(() => {}); + } + }); +}); + +Deno.test("findDuneInstances: a stale registry record (process gone) adds nothing", opts, async () => { + await withRegistryDir(async (dir) => { + await Deno.writeTextFile( + join(dir, "999999.json"), + JSON.stringify({ pid: 999_999, processStart: "Mon Jan 1 00:00:00 2001", mode: "serve", root: "/nowhere", title: null, port: 1, version: "0", registeredAt: "x" }), + ); + const found = await findDuneInstances(fastOpts); + assertEquals(found.some((i) => i.pid === 999_999), false); + }); +}); + // ── psCommand output (fakes only: never touches real processes) ────────────── async function runPs(instances: DuneInstance[]): Promise { @@ -489,8 +699,21 @@ Deno.test("psCommand: marks an unconfirmed instance as such", async () => { assertEquals((await runPs([fakeInstance()])).includes("not confirmed"), false); }); +Deno.test("psCommand: flags a listening-but-unresponsive instance, counts it, and shows the version when known", async () => { + const text = await runPs([ + fakeInstance({ pid: 1, pids: [1], ports: [3000], portStatus: "listening", responsive: "not-responding", version: "0.34.14" }), + fakeInstance({ pid: 2, pids: [2], ports: [3001], portStatus: "listening", responsive: "responding" }), + ]); + assertEquals(text.includes("http://localhost:3000 — NOT RESPONDING"), true); + assertEquals(text.includes("http://localhost:3001\n") || text.includes("http://localhost:3001"), true); + assertEquals(text.includes("NOT RESPONDING") && text.split("NOT RESPONDING").length === 2, true, "only the stuck one is flagged"); + assertEquals(text.includes("v0.34.14"), true); + assertEquals(text.includes("2 instances: 2 listening (1 not responding), 0 not listening"), true, text); + assertEquals(text.includes("`dune stop --idle`"), true); +}); + Deno.test("psCommand: points at `dune stop` when idle instances exist, and only then", async () => { const withIdle = await runPs([LIVE(), IDLE()]); - assertEquals(withIdle.includes("`dune stop --idle` stops only the not-listening ones"), true); + assertEquals(withIdle.includes("`dune stop --idle` stops only the ones that aren't serving"), true); assertEquals((await runPs([LIVE()])).includes("dune stop"), false, "no hint when there is nothing idle"); }); diff --git a/tests/cli/stop_test.ts b/tests/cli/stop_test.ts index c88715c9..430a20a0 100644 --- a/tests/cli/stop_test.ts +++ b/tests/cli/stop_test.ts @@ -271,12 +271,24 @@ Deno.test("selectInstances: --idle takes only not-listening instances, never a l if (sel.ok) assertEquals(sel.instances.flatMap((i) => i.pids).some((p) => p === 10 || p === 11), false); }); +Deno.test("selectInstances: --idle also takes a listening instance that is not responding, but not a responding one", () => { + const stuck = fakeInstance({ pid: 50, pids: [50], ports: [3100], portStatus: "listening", responsive: "not-responding" }); + const fine = fakeInstance({ pid: 60, pids: [60], ports: [3200], portStatus: "listening", responsive: "responding" }); + assertEquals(pidsOf(selectInstances([stuck, fine, IDLE_A()], { idle: true })), [50, 20]); +}); + +Deno.test("selectInstances: a not-responding instance that is still young is left alone too (it may be indexing)", () => { + const youngStuck = fakeInstance({ pid: 55, pids: [55], ports: [3300], portStatus: "listening", responsive: "not-responding", ageSeconds: 30 }); + const sel = selectInstances([youngStuck], { idle: true }); + assertEquals(sel.ok, 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); + assertEquals(sel.message.includes("No idle instances: all 1 confirmed running instance is listening and responding"), true); } });