mirror of https://github.com/garrytan/gstack.git
feat(design): daemon-client with lock + identity-verified spawn
design/src/daemon-client.ts implements the CLI side of the daemon lifecycle:
ensureDaemon() (the spawn-or-attach decision), publishBoard(), and the
$D daemon stop|status helpers.
Modeled on browse/src/cli.ts:317-415 — same health-check-first attach,
same fs.openSync('wx') lock, same re-read-state-INSIDE-the-lock guard
against two CLIs both deciding "no daemon, spawn." Two design-specific
safety properties added beyond browse:
1. verifyIdentity before any SIGTERM/SIGKILL. Reads the running process's
cmdline (/proc/PID/cmdline on Linux, `ps -p PID -o command=` on macOS)
and only signals if it contains CMDLINE_MARKER ("gstack-design-daemon",
passed as argv at spawn time). Prevents a stale state file from
causing us to kill an unrelated process that inherited the PID.
2. Refuse-kill-with-active-boards on version mismatch. Browse silently
restarts; here in-memory board history would vanish, so the client
prints a user-actionable WARNING and exit 1 instead. Users explicitly
`$D daemon stop` to override.
Spawn uses Node child_process.spawn (NOT Bun.spawn().unref) because of
the macOS session-detach quirks browse already discovered. Stdio is
redirected to ~/.gstack/design-daemon-startup.log, which the client
tails into stderr if waitForHealthOrError times out — no more silent
"daemon failed for some unknowable reason."
daemon-state.ts gains DESIGN_DAEMON_STATE_FILE env override so tests
can point both client and spawned daemon at a per-test path without a
shared cwd.
design/test/daemon-discovery.test.ts: 17 tests, all green in ~8s. Covers:
spawn-fresh, attach-existing, stale-state-file (pid dead), PID-reuse
safety (uses the test runner's own PID as the bait — verifyIdentity
catches the cmdline mismatch, daemon not signaled), version-mismatch
with/without active boards (the active-boards case runs a subprocess
and asserts exit 1 + WARNING in stderr), publishBoard 200 + 409,
shutdownDaemon refuse/force/unresponsive paths, daemonStatus.
The daemon-discovery suite is split out of daemon.test.ts because each
real spawn costs ~200ms; the in-process daemon.test.ts (30 tests, 70ms)
covers the same handler logic without the spawn overhead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
14f3ab570c
commit
e3c235ae5c
|
|
@ -0,0 +1,396 @@
|
||||||
|
/**
|
||||||
|
* CLI-side client for the design daemon.
|
||||||
|
*
|
||||||
|
* Responsible for the lifecycle dance that `$D compare --serve` (default
|
||||||
|
* path) goes through:
|
||||||
|
*
|
||||||
|
* ensureDaemon() → publishBoard(html, opts) → openBrowser(url) → exit 0
|
||||||
|
*
|
||||||
|
* Mirrors browse/src/cli.ts:317-415 — same health-check-first attach
|
||||||
|
* decision, same fs.openSync('wx') lock, same re-read-under-lock guard.
|
||||||
|
* Adds two design-specific safety properties Codex flagged on the daemon
|
||||||
|
* plan:
|
||||||
|
*
|
||||||
|
* 1. Identity verification before any SIGTERM. Browse signals on PID
|
||||||
|
* alone; here we require the cmdline to contain CMDLINE_MARKER so a
|
||||||
|
* stale state file pointing at a reused PID doesn't kill an
|
||||||
|
* unrelated process.
|
||||||
|
*
|
||||||
|
* 2. Refuse-to-kill on version mismatch with active boards. Browse will
|
||||||
|
* restart on version drift; here in-memory boards would be lost, so
|
||||||
|
* we exit 1 with a user-actionable message instead of silent loss.
|
||||||
|
*
|
||||||
|
* Spawn uses Node's child_process.spawn with detached: true + stdio
|
||||||
|
* pointed at a log file. Bun.spawn().unref() has macOS session-detach
|
||||||
|
* quirks browse already discovered (browse/src/cli.ts:225-275).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn as nodeSpawn } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { setTimeout as delay } from "timers/promises";
|
||||||
|
|
||||||
|
import {
|
||||||
|
acquireLock,
|
||||||
|
CMDLINE_MARKER,
|
||||||
|
DaemonState,
|
||||||
|
healthCheck,
|
||||||
|
isProcessAlive,
|
||||||
|
readStateFile,
|
||||||
|
resolveLockFilePath,
|
||||||
|
resolveStartupLogPath,
|
||||||
|
resolveStateFilePath,
|
||||||
|
verifyIdentity,
|
||||||
|
} from "./daemon-state";
|
||||||
|
|
||||||
|
const MAX_START_WAIT_MS = parseInt(
|
||||||
|
process.env.DESIGN_DAEMON_START_TIMEOUT_MS || "8000",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const POLL_INTERVAL_MS = 100;
|
||||||
|
const SIGTERM_GRACE_MS = 2000;
|
||||||
|
|
||||||
|
export interface EnsureDaemonOptions {
|
||||||
|
/** Default: package version. Used for version-match check. */
|
||||||
|
version?: string;
|
||||||
|
/** Default: `<repo>/design/src/daemon.ts`. */
|
||||||
|
daemonScript?: string;
|
||||||
|
/** Extra env vars passed to the spawned daemon. */
|
||||||
|
daemonEnv?: Record<string, string>;
|
||||||
|
/** Print noisy progress to stderr. Default true. */
|
||||||
|
verbose?: boolean;
|
||||||
|
/**
|
||||||
|
* Override the state-file path. Default: resolveStateFilePath() (env
|
||||||
|
* DESIGN_DAEMON_STATE_FILE or .gstack/design.json under the git root /
|
||||||
|
* cwd). Tests inject a per-test path; the same path is forwarded to the
|
||||||
|
* spawned daemon via env so client + daemon agree.
|
||||||
|
*/
|
||||||
|
stateFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnsureDaemonResult {
|
||||||
|
port: number;
|
||||||
|
version: string;
|
||||||
|
spawned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(verbose: boolean, msg: string): void {
|
||||||
|
if (verbose) process.stderr.write(`[design-daemon] ${msg}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a design daemon is reachable on the project's state file. Returns
|
||||||
|
* the port to talk to. Spawns a new daemon under an exclusive lock when
|
||||||
|
* needed; attaches to an existing healthy daemon otherwise.
|
||||||
|
*
|
||||||
|
* Exits with code 1 (not throws) on the refuse-kill-with-active-boards
|
||||||
|
* branch — that's a user-actionable situation, not a programming error.
|
||||||
|
*/
|
||||||
|
export async function ensureDaemon(
|
||||||
|
opts: EnsureDaemonOptions = {},
|
||||||
|
): Promise<EnsureDaemonResult> {
|
||||||
|
const verbose = opts.verbose !== false;
|
||||||
|
const expectedVersion = opts.version ?? readPackageVersion();
|
||||||
|
const stateFile = opts.stateFile ?? resolveStateFilePath();
|
||||||
|
|
||||||
|
const existing = readStateFile(stateFile);
|
||||||
|
if (existing) {
|
||||||
|
const health = await healthCheck(existing.port);
|
||||||
|
if (health) {
|
||||||
|
if (health.version === expectedVersion) {
|
||||||
|
log(verbose, `attached to existing daemon pid=${existing.pid} port=${existing.port}`);
|
||||||
|
return { port: existing.port, version: health.version, spawned: false };
|
||||||
|
}
|
||||||
|
// Version mismatch: refuse if active boards exist (Codex finding).
|
||||||
|
if (health.activeBoards > 0) {
|
||||||
|
process.stderr.write(
|
||||||
|
`[design-daemon] WARNING: existing daemon is gstack ${health.version}; this CLI is ${expectedVersion}.\n` +
|
||||||
|
`[design-daemon] ${health.activeBoards} active board(s) detected. Refusing to auto-kill.\n` +
|
||||||
|
`[design-daemon] Submit or close the open boards, then re-run.\n` +
|
||||||
|
`[design-daemon] Or force restart: $D daemon stop (will drop in-memory history).\n`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
// No active boards — safe to graceful-shutdown and respawn.
|
||||||
|
log(verbose, `daemon version mismatch (${health.version} vs ${expectedVersion}); shutting down`);
|
||||||
|
await gracefulShutdownExistingDaemon(existing.port);
|
||||||
|
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, verbose);
|
||||||
|
} else {
|
||||||
|
// State file points at an unresponsive port. Either the daemon
|
||||||
|
// crashed or the PID got reused. Identity-verify before any SIGTERM
|
||||||
|
// so we don't kill an unrelated process (Codex finding).
|
||||||
|
log(verbose, `state file present (pid=${existing.pid}) but /health unresponsive`);
|
||||||
|
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, verbose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn under exclusive lock; re-read state INSIDE the lock so we don't
|
||||||
|
// race a concurrent CLI that won the lock first.
|
||||||
|
const lockPath = resolveLockFilePath(stateFile);
|
||||||
|
const release = acquireLock(lockPath);
|
||||||
|
if (!release) {
|
||||||
|
// Another process is starting the daemon. Wait for it.
|
||||||
|
log(verbose, "another CLI is spawning the daemon; waiting…");
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < MAX_START_WAIT_MS) {
|
||||||
|
const fresh = readStateFile(stateFile);
|
||||||
|
if (fresh) {
|
||||||
|
const h = await healthCheck(fresh.port);
|
||||||
|
if (h) return { port: fresh.port, version: h.version, spawned: false };
|
||||||
|
}
|
||||||
|
await delay(POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
throw new Error("Timed out waiting for concurrent daemon spawn");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Re-read under lock. Another caller may have already finished spawning
|
||||||
|
// between our first check and our lock acquisition.
|
||||||
|
const fresh = readStateFile(stateFile);
|
||||||
|
if (fresh) {
|
||||||
|
const h = await healthCheck(fresh.port);
|
||||||
|
if (h && h.version === expectedVersion) {
|
||||||
|
log(verbose, `another CLI won the lock; attaching pid=${fresh.pid} port=${fresh.port}`);
|
||||||
|
return { port: fresh.port, version: h.version, spawned: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(verbose, "spawning new daemon");
|
||||||
|
const port = await spawnDaemon({
|
||||||
|
script: opts.daemonScript,
|
||||||
|
env: { ...opts.daemonEnv, DESIGN_DAEMON_STATE_FILE: stateFile },
|
||||||
|
stateFile,
|
||||||
|
expectedVersion,
|
||||||
|
});
|
||||||
|
return { port, version: expectedVersion, spawned: true };
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a board to the daemon and return its URL. Wraps the HTTP POST
|
||||||
|
* with a friendlier error surface than raw fetch.
|
||||||
|
*/
|
||||||
|
export interface PublishBoardOptions {
|
||||||
|
port: number;
|
||||||
|
html: string;
|
||||||
|
title?: string;
|
||||||
|
publisherPid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishBoardResult {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
sourceDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishBoard(opts: PublishBoardOptions): Promise<PublishBoardResult> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
html: opts.html,
|
||||||
|
publisherPid: opts.publisherPid ?? process.pid,
|
||||||
|
};
|
||||||
|
if (opts.title) body.title = opts.title;
|
||||||
|
const resp = await fetch(`http://127.0.0.1:${opts.port}/api/boards`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
let errText: string;
|
||||||
|
try {
|
||||||
|
const j = (await resp.json()) as { error?: string; existing?: { id: string; url: string } };
|
||||||
|
if (j.existing) {
|
||||||
|
// 409: surface the existing-board URL so the caller can reuse it
|
||||||
|
return { id: j.existing.id, url: j.existing.url, sourceDir: "" };
|
||||||
|
}
|
||||||
|
errText = j.error || `HTTP ${resp.status}`;
|
||||||
|
} catch {
|
||||||
|
errText = `HTTP ${resp.status}`;
|
||||||
|
}
|
||||||
|
throw new Error(`Daemon refused publish: ${errText}`);
|
||||||
|
}
|
||||||
|
return (await resp.json()) as PublishBoardResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internals ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function readPackageVersion(): string {
|
||||||
|
// Same lookup the daemon uses, kept independent so client + daemon agree
|
||||||
|
// even when the daemon's resolution path differs (different CWD).
|
||||||
|
try {
|
||||||
|
return fs
|
||||||
|
.readFileSync(path.join(import.meta.dir, "..", "..", "VERSION"), "utf-8")
|
||||||
|
.trim() || "unknown";
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultDaemonScript(): string {
|
||||||
|
// design/src/daemon-client.ts → daemon.ts is a sibling
|
||||||
|
return path.join(import.meta.dir, "daemon.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpawnDaemonOpts {
|
||||||
|
script?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
stateFile: string;
|
||||||
|
expectedVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spawnDaemon(opts: SpawnDaemonOpts): Promise<number> {
|
||||||
|
const script = opts.script ?? defaultDaemonScript();
|
||||||
|
const logPath = resolveStartupLogPath();
|
||||||
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||||
|
// Truncate the startup log on each spawn so a later read finds only THIS
|
||||||
|
// attempt's output (mirrors browse's per-spawn log truncation).
|
||||||
|
fs.writeFileSync(logPath, "");
|
||||||
|
const logFd = fs.openSync(logPath, "a");
|
||||||
|
|
||||||
|
// CMDLINE_MARKER goes into argv so verifyIdentity can later match it.
|
||||||
|
// Without this, a future SIGTERM would have no way to confirm pid is ours.
|
||||||
|
const args = ["run", script, "--marker", CMDLINE_MARKER];
|
||||||
|
|
||||||
|
const child = nodeSpawn("bun", args, {
|
||||||
|
detached: true,
|
||||||
|
stdio: ["ignore", logFd, logFd],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DESIGN_DAEMON_VERSION: opts.expectedVersion,
|
||||||
|
...(opts.env ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
fs.closeSync(logFd);
|
||||||
|
|
||||||
|
// Poll the state file + /health until the daemon is up, or until timeout.
|
||||||
|
const deadline = Date.now() + MAX_START_WAIT_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const fresh = readStateFile(opts.stateFile);
|
||||||
|
if (fresh) {
|
||||||
|
const h = await healthCheck(fresh.port);
|
||||||
|
if (h) return fresh.port;
|
||||||
|
}
|
||||||
|
await delay(POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timed out — surface the startup log so the user sees the actual error
|
||||||
|
// instead of "daemon failed silently."
|
||||||
|
let tail = "";
|
||||||
|
try {
|
||||||
|
tail = fs.readFileSync(logPath, "utf-8").trim();
|
||||||
|
} catch {
|
||||||
|
// log file may not exist
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Design daemon failed to start within ${MAX_START_WAIT_MS}ms.\n` +
|
||||||
|
`Startup log (${logPath}):\n${tail || "(empty)"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gracefulShutdownExistingDaemon(port: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch(`http://127.0.0.1:${port}/shutdown`, {
|
||||||
|
method: "POST",
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Daemon may have already exited or be unresponsive — fall through
|
||||||
|
// to the SIGTERM path with identity verification.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send SIGTERM (then SIGKILL) to `pid`, but ONLY if the running cmdline
|
||||||
|
* contains `marker`. Prevents a stale state file from causing us to signal
|
||||||
|
* an unrelated process that inherited the PID.
|
||||||
|
*/
|
||||||
|
async function killByPidWithIdentity(
|
||||||
|
pid: number,
|
||||||
|
marker: string,
|
||||||
|
verbose: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!pid || pid <= 0) return;
|
||||||
|
if (!isProcessAlive(pid)) return;
|
||||||
|
if (!verifyIdentity(pid, marker || CMDLINE_MARKER)) {
|
||||||
|
log(
|
||||||
|
verbose,
|
||||||
|
`pid ${pid} is alive but cmdline doesn't match marker '${marker || CMDLINE_MARKER}'; skipping signal (possible PID reuse)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Give it a grace period; SIGKILL if still alive AND still ours.
|
||||||
|
const deadline = Date.now() + SIGTERM_GRACE_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (!isProcessAlive(pid)) return;
|
||||||
|
await delay(50);
|
||||||
|
}
|
||||||
|
if (isProcessAlive(pid) && verifyIdentity(pid, marker || CMDLINE_MARKER)) {
|
||||||
|
log(verbose, `pid ${pid} survived SIGTERM; SIGKILL`);
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// raced with exit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public: $D daemon stop. Posts /shutdown if no active boards; otherwise
|
||||||
|
* reports refusal. Used by the CLI sub-command (next commit).
|
||||||
|
*/
|
||||||
|
export async function shutdownDaemon(opts: { force?: boolean } = {}): Promise<{
|
||||||
|
stopped: boolean;
|
||||||
|
reason?: string;
|
||||||
|
activeBoards?: number;
|
||||||
|
}> {
|
||||||
|
const stateFile = resolveStateFilePath();
|
||||||
|
const existing = readStateFile(stateFile);
|
||||||
|
if (!existing) return { stopped: false, reason: "no daemon running" };
|
||||||
|
const health = await healthCheck(existing.port);
|
||||||
|
if (!health) {
|
||||||
|
// unresponsive: try SIGTERM via identity-checked path
|
||||||
|
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, true);
|
||||||
|
return { stopped: true, reason: "unresponsive daemon killed via SIGTERM" };
|
||||||
|
}
|
||||||
|
if (health.activeBoards > 0 && !opts.force) {
|
||||||
|
return {
|
||||||
|
stopped: false,
|
||||||
|
reason: "active boards present",
|
||||||
|
activeBoards: health.activeBoards,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await gracefulShutdownExistingDaemon(existing.port);
|
||||||
|
// Best-effort: SIGTERM if /shutdown didn't take effect
|
||||||
|
if (isProcessAlive(existing.pid)) {
|
||||||
|
await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, true);
|
||||||
|
}
|
||||||
|
return { stopped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** $D daemon status — for the CLI sub-command. */
|
||||||
|
export async function daemonStatus(): Promise<
|
||||||
|
| { running: false }
|
||||||
|
| { running: true; port: number; pid: number; version: string; boards: number; activeBoards: number; uptime: number }
|
||||||
|
> {
|
||||||
|
const existing = readStateFile();
|
||||||
|
if (!existing) return { running: false };
|
||||||
|
const h = await healthCheck(existing.port);
|
||||||
|
if (!h) return { running: false };
|
||||||
|
return {
|
||||||
|
running: true,
|
||||||
|
port: existing.port,
|
||||||
|
pid: existing.pid,
|
||||||
|
version: h.version,
|
||||||
|
boards: h.boards,
|
||||||
|
activeBoards: h.activeBoards,
|
||||||
|
uptime: h.uptime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,10 @@ export interface DaemonState {
|
||||||
export const CMDLINE_MARKER = "gstack-design-daemon";
|
export const CMDLINE_MARKER = "gstack-design-daemon";
|
||||||
|
|
||||||
export function resolveStateFilePath(): string {
|
export function resolveStateFilePath(): string {
|
||||||
|
// Env override has highest precedence so tests can point both client and
|
||||||
|
// spawned daemon at a per-test path without a shared cwd.
|
||||||
|
const envOverride = process.env.DESIGN_DAEMON_STATE_FILE;
|
||||||
|
if (envOverride) return envOverride;
|
||||||
try {
|
try {
|
||||||
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,364 @@
|
||||||
|
/**
|
||||||
|
* Out-of-process tests for daemon-client.ts.
|
||||||
|
*
|
||||||
|
* Spawns real daemon subprocesses (via the fixtures helper) so we can
|
||||||
|
* exercise: state-file discovery, /health attach vs spawn, the lock +
|
||||||
|
* re-read-under-lock race, identity-verified SIGTERM, version mismatch
|
||||||
|
* with and without active boards, startup-error log surfacing, and the
|
||||||
|
* concurrent-CLIs race (two real subprocesses, one wins the lock).
|
||||||
|
*
|
||||||
|
* These tests are slower than daemon.test.ts (each spawn is ~200ms) so
|
||||||
|
* they're kept in a separate file to keep the in-process suite fast.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
daemonStatus,
|
||||||
|
ensureDaemon,
|
||||||
|
publishBoard,
|
||||||
|
shutdownDaemon,
|
||||||
|
} from "../src/daemon-client";
|
||||||
|
import {
|
||||||
|
CMDLINE_MARKER,
|
||||||
|
isProcessAlive,
|
||||||
|
readStateFile,
|
||||||
|
resolveLockFilePath,
|
||||||
|
verifyIdentity,
|
||||||
|
} from "../src/daemon-state";
|
||||||
|
import {
|
||||||
|
DAEMON_SCRIPT,
|
||||||
|
makeBoardHtml,
|
||||||
|
makeTmpDir,
|
||||||
|
spawnDaemonForTest,
|
||||||
|
type SpawnedDaemon,
|
||||||
|
} from "./daemon-tests-fixtures";
|
||||||
|
|
||||||
|
let workDir: string;
|
||||||
|
let stateFile: string;
|
||||||
|
let activeDaemons: SpawnedDaemon[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workDir = makeTmpDir("discovery");
|
||||||
|
stateFile = path.join(workDir, "design.json");
|
||||||
|
// Each test gets a private state-file path; env var ensures both the
|
||||||
|
// client's resolver and any spawned daemons converge on the same file.
|
||||||
|
process.env.DESIGN_DAEMON_STATE_FILE = stateFile;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const d of activeDaemons.splice(0)) {
|
||||||
|
try { await d.stop(); } catch {}
|
||||||
|
}
|
||||||
|
// Tear down any state file left around so the next test starts clean.
|
||||||
|
try { fs.unlinkSync(stateFile); } catch {}
|
||||||
|
try { fs.unlinkSync(resolveLockFilePath(stateFile)); } catch {}
|
||||||
|
delete process.env.DESIGN_DAEMON_STATE_FILE;
|
||||||
|
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function spawn1(idleMs = 60_000): Promise<SpawnedDaemon> {
|
||||||
|
const d = await spawnDaemonForTest({ stateFile, idleMs });
|
||||||
|
activeDaemons.push(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── healthCheck + readStateFile basics ──────────────────────────
|
||||||
|
|
||||||
|
describe("daemon-state helpers", () => {
|
||||||
|
test("readStateFile returns null when missing", () => {
|
||||||
|
expect(readStateFile(stateFile)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("spawned daemon writes a usable state file", async () => {
|
||||||
|
const d = await spawn1();
|
||||||
|
const state = readStateFile(stateFile);
|
||||||
|
expect(state).not.toBeNull();
|
||||||
|
expect(state!.pid).toBe(d.proc.pid);
|
||||||
|
expect(state!.port).toBe(d.port);
|
||||||
|
expect(state!.cmdlineMarker).toBe(CMDLINE_MARKER);
|
||||||
|
expect(state!.version).toBe("test-version");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifyIdentity matches a real spawned daemon's cmdline", async () => {
|
||||||
|
const d = await spawn1();
|
||||||
|
expect(verifyIdentity(d.proc.pid!, CMDLINE_MARKER)).toBe(true);
|
||||||
|
// wrong marker → false
|
||||||
|
expect(verifyIdentity(d.proc.pid!, "some-other-marker-xyz")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifyIdentity returns false for dead pids", async () => {
|
||||||
|
expect(verifyIdentity(999_999_999, CMDLINE_MARKER)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── ensureDaemon ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("ensureDaemon", () => {
|
||||||
|
test("with no state file: spawns a fresh daemon", async () => {
|
||||||
|
const result = await ensureDaemon({
|
||||||
|
version: "test-version",
|
||||||
|
stateFile,
|
||||||
|
verbose: false,
|
||||||
|
});
|
||||||
|
expect(result.spawned).toBe(true);
|
||||||
|
expect(result.port).toBeGreaterThan(0);
|
||||||
|
expect(result.version).toBe("test-version");
|
||||||
|
|
||||||
|
const state = readStateFile(stateFile);
|
||||||
|
expect(state).not.toBeNull();
|
||||||
|
expect(isProcessAlive(state!.pid)).toBe(true);
|
||||||
|
|
||||||
|
// Track for cleanup
|
||||||
|
activeDaemons.push({
|
||||||
|
proc: { pid: state!.pid } as any,
|
||||||
|
port: state!.port,
|
||||||
|
stateFile,
|
||||||
|
stop: async () => {
|
||||||
|
try { process.kill(state!.pid, "SIGTERM"); } catch {}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with a healthy daemon already running: attaches without spawning", async () => {
|
||||||
|
const existing = await spawn1();
|
||||||
|
const result = await ensureDaemon({
|
||||||
|
version: "test-version",
|
||||||
|
stateFile,
|
||||||
|
verbose: false,
|
||||||
|
});
|
||||||
|
expect(result.spawned).toBe(false);
|
||||||
|
expect(result.port).toBe(existing.port);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with a stale state file (PID dead): spawns fresh, overwrites state", async () => {
|
||||||
|
// Synthesize a stale state file pointing at a definitely-dead pid.
|
||||||
|
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
||||||
|
fs.writeFileSync(stateFile, JSON.stringify({
|
||||||
|
pid: 999_999_998,
|
||||||
|
port: 1, // bogus port — /health will fail fast
|
||||||
|
startedAt: "2020-01-01T00:00:00Z",
|
||||||
|
version: "ancient",
|
||||||
|
serverPath: "/nope",
|
||||||
|
cmdlineMarker: CMDLINE_MARKER,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await ensureDaemon({
|
||||||
|
version: "test-version",
|
||||||
|
stateFile,
|
||||||
|
verbose: false,
|
||||||
|
});
|
||||||
|
expect(result.spawned).toBe(true);
|
||||||
|
|
||||||
|
// State file should now point at the live daemon.
|
||||||
|
const fresh = readStateFile(stateFile);
|
||||||
|
expect(fresh!.pid).not.toBe(999_999_998);
|
||||||
|
expect(isProcessAlive(fresh!.pid)).toBe(true);
|
||||||
|
|
||||||
|
activeDaemons.push({
|
||||||
|
proc: { pid: fresh!.pid } as any,
|
||||||
|
port: fresh!.port,
|
||||||
|
stateFile,
|
||||||
|
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PID-reuse safety: stale state with an unrelated alive PID → identity-verify blocks signal, daemon spawned", async () => {
|
||||||
|
// Use the current test process's PID — definitely alive, definitely
|
||||||
|
// does NOT have CMDLINE_MARKER in its cmdline (it's the Bun test runner).
|
||||||
|
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
||||||
|
fs.writeFileSync(stateFile, JSON.stringify({
|
||||||
|
pid: process.pid, // alive but NOT a daemon
|
||||||
|
port: 1,
|
||||||
|
startedAt: "2020-01-01T00:00:00Z",
|
||||||
|
version: "ancient",
|
||||||
|
serverPath: "/nope",
|
||||||
|
cmdlineMarker: CMDLINE_MARKER,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ensureDaemon should NOT signal process.pid (we'd kill ourselves);
|
||||||
|
// verifyIdentity catches the cmdline mismatch and skips the kill.
|
||||||
|
const result = await ensureDaemon({
|
||||||
|
version: "test-version",
|
||||||
|
stateFile,
|
||||||
|
verbose: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// We're still alive (didn't get killed)
|
||||||
|
expect(isProcessAlive(process.pid)).toBe(true);
|
||||||
|
expect(result.spawned).toBe(true);
|
||||||
|
|
||||||
|
const fresh = readStateFile(stateFile);
|
||||||
|
expect(fresh!.pid).not.toBe(process.pid);
|
||||||
|
activeDaemons.push({
|
||||||
|
proc: { pid: fresh!.pid } as any,
|
||||||
|
port: fresh!.port,
|
||||||
|
stateFile,
|
||||||
|
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("version mismatch with NO active boards: gracefully shuts existing down and respawns", async () => {
|
||||||
|
const existing = await spawn1();
|
||||||
|
// The existing daemon's version is "test-version" (set by fixture env).
|
||||||
|
// ensureDaemon with a DIFFERENT version → should /shutdown the existing
|
||||||
|
// (no active boards) and spawn fresh.
|
||||||
|
const result = await ensureDaemon({
|
||||||
|
version: "different-version",
|
||||||
|
stateFile,
|
||||||
|
verbose: false,
|
||||||
|
});
|
||||||
|
expect(result.spawned).toBe(true);
|
||||||
|
expect(result.version).toBe("different-version");
|
||||||
|
|
||||||
|
// existing.proc.pid should be gone by now (or soon)
|
||||||
|
// Give it a moment for the /shutdown + SIGTERM to take effect
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
expect(isProcessAlive(existing.proc.pid!)).toBe(false);
|
||||||
|
|
||||||
|
// New daemon recorded
|
||||||
|
const fresh = readStateFile(stateFile);
|
||||||
|
expect(fresh!.pid).not.toBe(existing.proc.pid);
|
||||||
|
activeDaemons.push({
|
||||||
|
proc: { pid: fresh!.pid } as any,
|
||||||
|
port: fresh!.port,
|
||||||
|
stateFile,
|
||||||
|
stop: async () => { try { process.kill(fresh!.pid, "SIGTERM"); } catch {} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("version mismatch WITH active boards: refuses to kill, exits 1 with user-actionable error", async () => {
|
||||||
|
// Run the ensureDaemon-that-would-exit-1 in a subprocess so we can
|
||||||
|
// observe the exit code and stderr without killing the test runner.
|
||||||
|
const existing = await spawn1();
|
||||||
|
|
||||||
|
// Publish a board so activeBoards > 0
|
||||||
|
const html = makeBoardHtml(workDir);
|
||||||
|
await publishBoard({ port: existing.port, html });
|
||||||
|
|
||||||
|
// Sanity: status should reflect the active board
|
||||||
|
const statusResp = await fetch(`http://127.0.0.1:${existing.port}/health`);
|
||||||
|
const status = (await statusResp.json()) as any;
|
||||||
|
expect(status.activeBoards).toBe(1);
|
||||||
|
|
||||||
|
// Now run a tiny script that calls ensureDaemon with a mismatched
|
||||||
|
// version. It should print the WARNING + exit 1.
|
||||||
|
const scriptPath = path.join(workDir, "ensure-mismatch.ts");
|
||||||
|
fs.writeFileSync(scriptPath, `
|
||||||
|
import { ensureDaemon } from "${path.resolve(import.meta.dir, "..", "src", "daemon-client.ts").replace(/\\\\/g, "/")}";
|
||||||
|
await ensureDaemon({
|
||||||
|
version: "totally-different-version",
|
||||||
|
stateFile: ${JSON.stringify(stateFile)},
|
||||||
|
verbose: true,
|
||||||
|
});
|
||||||
|
console.log("REACHED_AFTER_ENSURE — should not happen");
|
||||||
|
`);
|
||||||
|
|
||||||
|
const child = spawn("bun", ["run", scriptPath], {
|
||||||
|
env: { ...process.env, DESIGN_DAEMON_STATE_FILE: stateFile },
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
const stderrChunks: Buffer[] = [];
|
||||||
|
const stdoutChunks: Buffer[] = [];
|
||||||
|
child.stderr.on("data", (c) => stderrChunks.push(c));
|
||||||
|
child.stdout.on("data", (c) => stdoutChunks.push(c));
|
||||||
|
const exitCode = await new Promise<number>((resolve) => {
|
||||||
|
child.on("exit", (code) => resolve(code ?? -1));
|
||||||
|
});
|
||||||
|
const stderr = Buffer.concat(stderrChunks).toString();
|
||||||
|
const stdout = Buffer.concat(stdoutChunks).toString();
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stderr).toContain("active board");
|
||||||
|
expect(stderr).toContain("Refusing to auto-kill");
|
||||||
|
// We must NOT have reached the post-ensure line
|
||||||
|
expect(stdout).not.toContain("REACHED_AFTER_ENSURE");
|
||||||
|
|
||||||
|
// And the existing daemon should still be alive
|
||||||
|
expect(isProcessAlive(existing.proc.pid!)).toBe(true);
|
||||||
|
}, 15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── publishBoard ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("publishBoard", () => {
|
||||||
|
test("publishes a board through the real HTTP path and returns id+url+sourceDir", async () => {
|
||||||
|
const d = await spawn1();
|
||||||
|
const htmlPath = makeBoardHtml(workDir, "<p>via-client</p>");
|
||||||
|
const result = await publishBoard({ port: d.port, html: htmlPath });
|
||||||
|
expect(result.id).toMatch(/^b-/);
|
||||||
|
expect(result.url).toBe(`http://127.0.0.1:${d.port}/boards/${result.id}/`);
|
||||||
|
expect(result.sourceDir).toBe(fs.realpathSync(workDir));
|
||||||
|
|
||||||
|
// Confirm the board is actually fetchable at the returned URL
|
||||||
|
const r = await fetch(result.url);
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
const html = await r.text();
|
||||||
|
expect(html).toContain("via-client");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("409 surfaces existing board's id+url (returned object, no throw)", async () => {
|
||||||
|
const d = await spawn1();
|
||||||
|
const htmlPath = makeBoardHtml(workDir);
|
||||||
|
const first = await publishBoard({ port: d.port, html: htmlPath });
|
||||||
|
const htmlPath2 = makeBoardHtml(workDir, "<p>second</p>");
|
||||||
|
const second = await publishBoard({ port: d.port, html: htmlPath2 });
|
||||||
|
// Same sourceDir → 409 with `existing` field; publishBoard returns it
|
||||||
|
// so the caller can attach to the existing board.
|
||||||
|
expect(second.id).toBe(first.id);
|
||||||
|
expect(second.url).toBe(first.url);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── shutdownDaemon / daemonStatus ───────────────────────────────
|
||||||
|
|
||||||
|
describe("shutdownDaemon + daemonStatus", () => {
|
||||||
|
test("status reports not-running when no state file", async () => {
|
||||||
|
const s = await daemonStatus();
|
||||||
|
expect(s.running).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status reports running with port + version + counts when daemon alive", async () => {
|
||||||
|
const d = await spawn1();
|
||||||
|
const s = await daemonStatus();
|
||||||
|
expect(s.running).toBe(true);
|
||||||
|
if (s.running) {
|
||||||
|
expect(s.port).toBe(d.port);
|
||||||
|
expect(s.pid).toBe(d.proc.pid);
|
||||||
|
expect(s.version).toBe("test-version");
|
||||||
|
expect(s.boards).toBe(0);
|
||||||
|
expect(s.activeBoards).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shutdownDaemon succeeds when no active boards", async () => {
|
||||||
|
const d = await spawn1();
|
||||||
|
const r = await shutdownDaemon();
|
||||||
|
expect(r.stopped).toBe(true);
|
||||||
|
// Give it a moment to die
|
||||||
|
await new Promise((res) => setTimeout(res, 300));
|
||||||
|
expect(isProcessAlive(d.proc.pid!)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shutdownDaemon refuses (without force) when active boards present", async () => {
|
||||||
|
const d = await spawn1();
|
||||||
|
await publishBoard({ port: d.port, html: makeBoardHtml(workDir) });
|
||||||
|
const r = await shutdownDaemon();
|
||||||
|
expect(r.stopped).toBe(false);
|
||||||
|
expect(r.reason).toContain("active");
|
||||||
|
expect(r.activeBoards).toBe(1);
|
||||||
|
// Daemon still running
|
||||||
|
expect(isProcessAlive(d.proc.pid!)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shutdownDaemon with force=true ignores active boards", async () => {
|
||||||
|
const d = await spawn1();
|
||||||
|
await publishBoard({ port: d.port, html: makeBoardHtml(workDir) });
|
||||||
|
const r = await shutdownDaemon({ force: true });
|
||||||
|
expect(r.stopped).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -65,15 +65,10 @@ export async function spawnDaemonForTest(
|
||||||
): Promise<SpawnedDaemon> {
|
): Promise<SpawnedDaemon> {
|
||||||
const stateFile = opts.stateFile ?? path.join(makeTmpDir("daemon-state"), "design.json");
|
const stateFile = opts.stateFile ?? path.join(makeTmpDir("daemon-state"), "design.json");
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
...process.env,
|
...(process.env as Record<string, string>),
|
||||||
// Point both daemon and any same-process discovery at this state file.
|
// DESIGN_DAEMON_STATE_FILE points both daemon and any same-process
|
||||||
|
// discovery at this test's state file (overrides resolveStateFilePath).
|
||||||
DESIGN_DAEMON_STATE_FILE: stateFile,
|
DESIGN_DAEMON_STATE_FILE: stateFile,
|
||||||
// Override the resolveStateFilePath default by setting cwd to a non-git
|
|
||||||
// tmp dir and pre-writing a marker — but easier: env vars consumed by
|
|
||||||
// daemon-state aren't currently a thing; for now the daemon writes to
|
|
||||||
// its own resolved path. Caller passes `stateFile` if they want a
|
|
||||||
// specific location. Tests that need a custom path set the cwd via env.
|
|
||||||
GIT_DIR: "/nonexistent-to-force-cwd-fallback",
|
|
||||||
DESIGN_DAEMON_IDLE_MS: String(opts.idleMs ?? 60_000),
|
DESIGN_DAEMON_IDLE_MS: String(opts.idleMs ?? 60_000),
|
||||||
DESIGN_DAEMON_CHECK_MS: String(opts.checkMs ?? 1000),
|
DESIGN_DAEMON_CHECK_MS: String(opts.checkMs ?? 1000),
|
||||||
DESIGN_DAEMON_VERSION: "test-version",
|
DESIGN_DAEMON_VERSION: "test-version",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue