diff --git a/CHANGELOG.md b/CHANGELOG.md index 08040a46..6ac0a2ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what ### Added +- **Running instances now register themselves.** `dune dev` and `dune serve` + write a small record (pid, process start time, mode, site root, title, port, + `@dune/core` version) to `~/.dune/instances/.json` + (`DUNE_REGISTRY_DIR` overrides the directory) and remove it on a graceful + exit. A kill or crash leaves the file behind, so readers confirm the pid is + alive *and* has the recorded start time (pids get reused) and delete the ones + that fail. This is groundwork for `dune ps` to list instances from what each + one says about itself rather than guessing from command lines and `lsof`; + nothing reads it yet, and a registry that can't be written never stops a + server from starting. Unix only. - **Event-loop watchdog: a server whose main thread gets stuck no longer hangs silently until someone restarts it.** One synchronous loop (catastrophic regex backtracking, a conversion that never terminates) blocks the single diff --git a/src/cli/dev.ts b/src/cli/dev.ts index 81e309b5..ed7e613e 100644 --- a/src/cli/dev.ts +++ b/src/cli/dev.ts @@ -23,6 +23,8 @@ import { checkLockfileStaleness } from "./lockfile.ts"; import { resolveContentDirPath } from "../content/content-root.ts"; import { registerEsbuildWorkerCleanup } from "./esbuild-cleanup.ts"; import { startWatchdog } from "../runtime/watchdog.ts"; +import { registerInstance } from "../runtime/instance-registry.ts"; +import { CORE_VERSION } from "../plugins/mod.ts"; export interface DevOptions { port?: number; @@ -65,6 +67,7 @@ export async function devCommand(root: string, options: DevOptions = {}) { const { MultisiteManager } = await import("../multisite/mod.ts"); const manager = new MultisiteManager(); await manager.init(root, { port, debug, dev: true }); + await registerInstance({ mode: "dev", root, title: null, port, version: CORE_VERSION }); console.log(`\n 🌐 http://localhost:${port} (${manager.siteCount()} sites)\n`); Deno.serve({ port, handler: (req, info) => manager.handle(req, info) }); watchdog.ready(); @@ -76,6 +79,12 @@ export async function devCommand(root: string, options: DevOptions = {}) { const ctx = await bootstrap(root, { debug, buildSearch: true }); const { engine, collections, taxonomy, search, config, pluginPublicRoutes, hooks } = ctx; + // For `dune ps` (runtime/instance-registry.ts). Dev ends via signals or a + // --watch restart rather than a graceful path, so the record is left to the + // unload handler and, failing that, to readers pruning it. + await registerInstance({ + mode: "dev", root, title: config.site.title ?? null, port, version: CORE_VERSION, + }); const adminPrefix = config.admin?.path ?? "/admin"; console.log(` 📄 ${engine.pages.length} pages indexed`); diff --git a/src/cli/serve.ts b/src/cli/serve.ts index af7f8e6c..2b4fa2f8 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -33,6 +33,8 @@ import { resolveContentDirPath } from "../content/content-root.ts"; import { createEmailClient, createEmailProvider } from "../email/mod.ts"; import { checkLockfileStaleness } from "./lockfile.ts"; import { startWatchdog } from "../runtime/watchdog.ts"; +import { type InstanceHandle, registerInstance } from "../runtime/instance-registry.ts"; +import { CORE_VERSION } from "../plugins/mod.ts"; export interface ServeOptions { port?: number; @@ -111,6 +113,10 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { // shutdown paths below stop it explicitly or the process would never exit. const watchdog = startWatchdog({ root, label: "dune serve" }); + // Tell `dune ps` about this process (runtime/instance-registry.ts). Removed + // again on graceful shutdown; a kill leaves a stale file that readers prune. + let instance: InstanceHandle | undefined; + // ── Lockfile staleness check ───────────────────────────────────────────── if (await checkLockfileStaleness(root)) { if (frozen) { @@ -142,6 +148,9 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { const { MultisiteManager } = await import("../multisite/mod.ts"); const manager = new MultisiteManager(); await manager.init(root, { port, debug, dev: false }); + instance = await registerInstance({ + mode: "serve", root, title: null, port, version: CORE_VERSION, + }); console.log(` 🌐 http://localhost:${port} (${manager.siteCount()} sites)\n`); // ── Graceful shutdown (multi-site) ────────────────────────────────────── @@ -179,6 +188,7 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { await server.finished; await drainInFlight(() => inFlight, drainDeadlineMs); cleanup(); + instance?.unregister(); watchdog.stop(); console.log("[dune] shutdown complete"); return; @@ -189,6 +199,9 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { const ctx = await bootstrap(root, { debug, buildSearch: true }); const { engine, config, pluginPublicRoutes, storage, hooks } = ctx; + instance = await registerInstance({ + mode: "serve", root, title: config.site.title ?? null, port, version: CORE_VERSION, + }); const adminPrefix = config.admin?.path ?? "/admin"; // Expose the configured runtimeDir to ConsoleEmailProvider so its dev-email @@ -395,6 +408,7 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { // flush step is required here. cleanup(); + instance?.unregister(); watchdog.stop(); console.log("[dune] shutdown complete"); } diff --git a/src/runtime/instance-registry.ts b/src/runtime/instance-registry.ts new file mode 100644 index 00000000..1548475b --- /dev/null +++ b/src/runtime/instance-registry.ts @@ -0,0 +1,170 @@ +/** + * Instance registry. + * + * `dune dev` / `dune serve` record themselves in a per-user directory so tools + * (`dune ps`) can list running instances from what each one says about + * itself instead of guessing from process command lines and `lsof`, which + * misses processes started in unexpected ways and says nothing about whether + * one is healthy. + * + * One JSON file per process: `/.json`. A process removes + * its file on a graceful exit, but a SIGKILL, crash or default signal + * termination leaves it behind, so every reader verifies a record against the + * live process (pid exists *and* has the recorded start time, since pids are + * reused) and prunes the ones that fail. + * + * The directory is `~/.dune/instances`, overridable with `DUNE_REGISTRY_DIR` + * (tests; unusual setups). Nothing here may stop a server from starting: every + * failure is swallowed. Unix only (like `dune ps`): where the process start time + * can't be read there is nothing to verify a record against, so nothing is + * written. + * + * @module + */ + +import { join } from "@std/path"; + +export interface InstanceRecord { + pid: number; + /** The OS-reported start time of the process (`ps -o lstart=`). */ + processStart: string; + mode: "dev" | "serve"; + /** Absolute site root. */ + root: string; + title: string | null; + /** The port the server was asked to listen on. */ + port: number | null; + /** `@dune/core` version running. */ + version: string; + /** ISO timestamp of registration. */ + registeredAt: string; +} + +export type InstanceInit = Pick; + +export interface InstanceHandle { + /** Remove this process's record. Safe to call more than once. */ + unregister(): void; +} + +/** Where records live. */ +export function registryDir(): string { + const override = Deno.env.get("DUNE_REGISTRY_DIR"); + if (override) return override; + const home = Deno.env.get("HOME") ?? Deno.env.get("USERPROFILE") ?? ""; + return join(home, ".dune", "instances"); +} + +/** + * The OS-reported start time of `pid`, or null when it can't be read (not a + * Unix `ps`, process gone). Compared as an opaque string: only equality with + * an earlier reading of the same process matters. + */ +export async function readProcessStart(pid: number): Promise { + if (Deno.build.os === "windows") return null; + try { + const out = await new Deno.Command("ps", { + args: ["-o", "lstart=", "-p", String(pid)], + stdout: "piped", + stderr: "null", + }).output(); + if (!out.success) return null; + const text = new TextDecoder().decode(out.stdout).trim(); + return text || null; + } catch { + return null; + } +} + +function fileFor(dir: string, pid: number): string { + return join(dir, `${pid}.json`); +} + +/** + * Record this process. Never throws: a registry that can't be written must not + * keep a site from starting. The record is written atomically (temp file + + * rename) so a reader never sees half a file. + */ +export async function registerInstance(init: InstanceInit): Promise { + const dir = registryDir(); + const file = fileFor(dir, Deno.pid); + let removed = false; + const unregister = () => { + if (removed) return; + removed = true; + try { + Deno.removeSync(file); + } catch { /* already gone, or never written */ } + }; + + try { + const processStart = await readProcessStart(Deno.pid); + if (processStart === null) { + removed = true; + return { unregister }; + } + const record: InstanceRecord = { + pid: Deno.pid, + processStart, + ...init, + registeredAt: new Date().toISOString(), + }; + await Deno.mkdir(dir, { recursive: true }); + const tmp = `${file}.${crypto.randomUUID()}.tmp`; + await Deno.writeTextFile(tmp, JSON.stringify(record, null, 2) + "\n"); + await Deno.rename(tmp, file); + // A graceful or natural exit cleans up; anything else leaves a stale file + // that readers prune. + globalThis.addEventListener("unload", unregister); + } catch { + removed = true; // nothing to clean up + } + return { unregister }; +} + +function isRecord(value: unknown): value is InstanceRecord { + if (typeof value !== "object" || value === null) return false; + const r = value as Record; + return typeof r.pid === "number" && typeof r.root === "string" && + typeof r.processStart === "string" && + (r.mode === "dev" || r.mode === "serve") && typeof r.version === "string"; +} + +/** + * Registered instances that are still running. Records for dead processes, or + * whose pid now belongs to a different process, are deleted as they are found. + */ +export async function listRegisteredInstances(): Promise { + const dir = registryDir(); + const live: InstanceRecord[] = []; + let entries: Deno.DirEntry[]; + try { + entries = []; + for await (const e of Deno.readDir(dir)) entries.push(e); + } catch { + return live; // no registry yet + } + + for (const entry of entries) { + if (!entry.isFile || !/^\d+\.json$/.test(entry.name)) continue; + const file = join(dir, entry.name); + let record: InstanceRecord | null = null; + try { + const parsed: unknown = JSON.parse(await Deno.readTextFile(file)); + if (isRecord(parsed) && `${parsed.pid}.json` === entry.name) record = parsed; + } catch { /* unreadable or half-written: treated as stale below */ } + + // Alive AND the same process: pids are reused, so a live pid with a + // different start time is someone else's process and this record is stale. + const valid = record !== null && + (await readProcessStart(record.pid)) === record.processStart; + if (valid && record) { + live.push(record); + } else { + try { + await Deno.remove(file); + } catch { /* raced with its owner or another reader */ } + } + } + return live.sort((a, b) => a.pid - b.pid); +} diff --git a/tests/runtime/instance_registry_test.ts b/tests/runtime/instance_registry_test.ts new file mode 100644 index 00000000..a69d1ff7 --- /dev/null +++ b/tests/runtime/instance_registry_test.ts @@ -0,0 +1,165 @@ +/** + * Tests for the instance registry (src/runtime/instance-registry.ts). + * Every test points DUNE_REGISTRY_DIR at a temp directory, so nothing here + * touches the real ~/.dune. + */ + +import { assert, assertEquals, assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { join } from "@std/path"; +import { + listRegisteredInstances, + readProcessStart, + registerInstance, + registryDir, +} from "../../src/runtime/instance-registry.ts"; + +const unixOnly = { ignore: Deno.build.os === "windows" }; + +async function withRegistry(fn: (dir: string) => Promise): Promise { + const dir = await Deno.makeTempDir({ prefix: "dune_test_registry_" }); + const prev = Deno.env.get("DUNE_REGISTRY_DIR"); + Deno.env.set("DUNE_REGISTRY_DIR", join(dir, "instances")); + try { + await fn(join(dir, "instances")); + } finally { + if (prev === undefined) Deno.env.delete("DUNE_REGISTRY_DIR"); + else Deno.env.set("DUNE_REGISTRY_DIR", prev); + await Deno.remove(dir, { recursive: true }).catch(() => {}); + } +} + +const INIT = { + mode: "serve" as const, + root: "/sites/example", + title: "Example", + port: 3123, + version: "9.9.9", +}; + +Deno.test("registryDir: honours DUNE_REGISTRY_DIR, defaults under ~/.dune", () => { + const prev = Deno.env.get("DUNE_REGISTRY_DIR"); + try { + Deno.env.set("DUNE_REGISTRY_DIR", "/somewhere/else"); + assertEquals(registryDir(), "/somewhere/else"); + Deno.env.delete("DUNE_REGISTRY_DIR"); + assert(registryDir().endsWith(join(".dune", "instances"))); + } finally { + if (prev !== undefined) Deno.env.set("DUNE_REGISTRY_DIR", prev); + } +}); + +Deno.test("register: writes a record for this process, listed back with its fields", unixOnly, async () => { + await withRegistry(async (dir) => { + const handle = await registerInstance(INIT); + const names: string[] = []; + for await (const e of Deno.readDir(dir)) names.push(e.name); + assertEquals(names, [`${Deno.pid}.json`], "exactly the record, no leftover temp file"); + + const list = await listRegisteredInstances(); + assertEquals(list.length, 1); + const r = list[0]; + assertEquals(r.pid, Deno.pid); + assertEquals(r.mode, "serve"); + assertEquals(r.root, "/sites/example"); + assertEquals(r.title, "Example"); + assertEquals(r.port, 3123); + assertEquals(r.version, "9.9.9"); + assertExists(r.processStart); + handle.unregister(); + }); +}); + +Deno.test("unregister: removes the record, and is safe to repeat", unixOnly, async () => { + await withRegistry(async () => { + const handle = await registerInstance(INIT); + handle.unregister(); + handle.unregister(); + assertEquals(await listRegisteredInstances(), []); + }); +}); + +Deno.test("list: a record whose process is gone is dropped and its file pruned", unixOnly, async () => { + await withRegistry(async (dir) => { + // A real process that has definitely exited. + const child = new Deno.Command(Deno.execPath(), { args: ["eval", "1"], stdout: "null", stderr: "null" }) + .spawn(); + const pid = child.pid; + const start = await readProcessStart(pid) ?? "Thu Jan 1 00:00:00 1970"; + await child.status; + await Deno.mkdir(dir, { recursive: true }); + await Deno.writeTextFile( + join(dir, `${pid}.json`), + JSON.stringify({ pid, processStart: start, ...INIT, registeredAt: new Date().toISOString() }), + ); + assertEquals(await listRegisteredInstances(), []); + assertEquals([...Deno.readDirSync(dir)].length, 0, "stale file should be pruned"); + }); +}); + +Deno.test("list: a live pid with a different start time (pid reuse) is treated as stale", unixOnly, async () => { + await withRegistry(async (dir) => { + await Deno.mkdir(dir, { recursive: true }); + await Deno.writeTextFile( + join(dir, `${Deno.pid}.json`), + JSON.stringify({ + pid: Deno.pid, + processStart: "Mon Jan 1 00:00:00 2001", // not when this process started + ...INIT, + registeredAt: new Date().toISOString(), + }), + ); + assertEquals(await listRegisteredInstances(), []); + assertEquals([...Deno.readDirSync(dir)].length, 0); + }); +}); + +Deno.test("list: garbage, mismatched names and non-record files never throw and are not listed", unixOnly, async () => { + await withRegistry(async (dir) => { + await Deno.mkdir(dir, { recursive: true }); + await Deno.writeTextFile(join(dir, "123.json"), "{ not json"); + await Deno.writeTextFile(join(dir, "124.json"), JSON.stringify({ pid: 999, nope: true })); + await Deno.writeTextFile(join(dir, "notes.txt"), "keep me"); + assertEquals(await listRegisteredInstances(), []); + const left = [...Deno.readDirSync(dir)].map((e) => e.name); + assertEquals(left, ["notes.txt"], "unrelated files are left alone"); + }); +}); + +Deno.test("list: no registry directory yet is an empty list, not an error", async () => { + await withRegistry(async () => { + assertEquals(await listRegisteredInstances(), []); + }); +}); + +Deno.test("register: an unwritable registry location doesn't throw", unixOnly, async () => { + const prev = Deno.env.get("DUNE_REGISTRY_DIR"); + try { + // A path under a regular file can't become a directory. + const file = await Deno.makeTempFile(); + Deno.env.set("DUNE_REGISTRY_DIR", join(file, "instances")); + const handle = await registerInstance(INIT); + handle.unregister(); + await Deno.remove(file); + } finally { + if (prev === undefined) Deno.env.delete("DUNE_REGISTRY_DIR"); + else Deno.env.set("DUNE_REGISTRY_DIR", prev); + } +}); + +Deno.test("register: a process exiting normally leaves no record behind", unixOnly, async () => { + await withRegistry(async (dir) => { + const url = new URL("../../src/runtime/instance-registry.ts", import.meta.url).href; + const out = await new Deno.Command(Deno.execPath(), { + args: [ + "eval", + `import { registerInstance } from ${JSON.stringify(url)};` + + `await registerInstance({ mode: "dev", root: "/x", title: null, port: null, version: "0" });`, + ], + env: { DUNE_REGISTRY_DIR: dir }, + stdout: "null", + stderr: "piped", + }).output(); + assert(out.success, new TextDecoder().decode(out.stderr)); + assertEquals([...Deno.readDirSync(dir)].length, 0, "unload handler should have removed the record"); + }); +});