diff --git a/design/src/daemon-client.ts b/design/src/daemon-client.ts new file mode 100644 index 000000000..a8ed6be9b --- /dev/null +++ b/design/src/daemon-client.ts @@ -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: `/design/src/daemon.ts`. */ + daemonScript?: string; + /** Extra env vars passed to the spawned daemon. */ + daemonEnv?: Record; + /** 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 { + 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 { + const body: Record = { + 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; + stateFile: string; + expectedVersion: string; +} + +async function spawnDaemon(opts: SpawnDaemonOpts): Promise { + 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 { + 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 { + 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, + }; +} diff --git a/design/src/daemon-state.ts b/design/src/daemon-state.ts index beeac8670..a6eb83176 100644 --- a/design/src/daemon-state.ts +++ b/design/src/daemon-state.ts @@ -27,6 +27,10 @@ export interface DaemonState { export const CMDLINE_MARKER = "gstack-design-daemon"; 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 { const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", diff --git a/design/test/daemon-discovery.test.ts b/design/test/daemon-discovery.test.ts new file mode 100644 index 000000000..12ed66cba --- /dev/null +++ b/design/test/daemon-discovery.test.ts @@ -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 { + 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((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, "

via-client

"); + 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, "

second

"); + 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); + }); +}); diff --git a/design/test/daemon-tests-fixtures.ts b/design/test/daemon-tests-fixtures.ts index 38fe2bf40..f5e3bbdcb 100644 --- a/design/test/daemon-tests-fixtures.ts +++ b/design/test/daemon-tests-fixtures.ts @@ -65,15 +65,10 @@ export async function spawnDaemonForTest( ): Promise { const stateFile = opts.stateFile ?? path.join(makeTmpDir("daemon-state"), "design.json"); const env: Record = { - ...process.env, - // Point both daemon and any same-process discovery at this state file. + ...(process.env as Record), + // 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, - // 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_CHECK_MS: String(opts.checkMs ?? 1000), DESIGN_DAEMON_VERSION: "test-version",