Skip to content
Merged
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>.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
Expand Down
9 changes: 9 additions & 0 deletions src/cli/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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`);
Expand Down
14 changes: 14 additions & 0 deletions src/cli/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) ──────────────────────────────────────
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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");
}
170 changes: 170 additions & 0 deletions src/runtime/instance-registry.ts
Original file line number Diff line number Diff line change
@@ -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: `<registry dir>/<pid>.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<InstanceRecord, "mode" | "root" | "title" | "port" | "version">;

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<string | null> {
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<InstanceHandle> {
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<string, unknown>;
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<InstanceRecord[]> {
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);
}
Loading
Loading