(
opts: EnsureDaemonOptions = {},
)
| 87 | * branch — that's a user-actionable situation, not a programming error. |
| 88 | */ |
| 89 | export async function ensureDaemon( |
| 90 | opts: EnsureDaemonOptions = {}, |
| 91 | ): Promise<EnsureDaemonResult> { |
| 92 | const verbose = opts.verbose !== false; |
| 93 | const expectedVersion = opts.version ?? readPackageVersion(); |
| 94 | const stateFile = opts.stateFile ?? resolveStateFilePath(); |
| 95 | |
| 96 | const existing = readStateFile(stateFile); |
| 97 | if (existing) { |
| 98 | const health = await healthCheck(existing.port); |
| 99 | if (health) { |
| 100 | if (health.version === expectedVersion) { |
| 101 | log(verbose, `attached to existing daemon pid=${existing.pid} port=${existing.port}`); |
| 102 | return { port: existing.port, version: health.version, spawned: false }; |
| 103 | } |
| 104 | // Version mismatch: refuse if active boards exist (Codex finding). |
| 105 | if (health.activeBoards > 0) { |
| 106 | process.stderr.write( |
| 107 | `[design-daemon] WARNING: existing daemon is gstack ${health.version}; this CLI is ${expectedVersion}.\n` + |
| 108 | `[design-daemon] ${health.activeBoards} active board(s) detected. Refusing to auto-kill.\n` + |
| 109 | `[design-daemon] Submit or close the open boards, then re-run.\n` + |
| 110 | `[design-daemon] Or force restart: $D daemon stop (will drop in-memory history).\n`, |
| 111 | ); |
| 112 | process.exit(1); |
| 113 | } |
| 114 | // No active boards — safe to graceful-shutdown and respawn. |
| 115 | log(verbose, `daemon version mismatch (${health.version} vs ${expectedVersion}); shutting down`); |
| 116 | await gracefulShutdownExistingDaemon(existing.port); |
| 117 | await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, verbose); |
| 118 | } else { |
| 119 | // State file points at an unresponsive port. Either the daemon |
| 120 | // crashed or the PID got reused. Identity-verify before any SIGTERM |
| 121 | // so we don't kill an unrelated process (Codex finding). |
| 122 | log(verbose, `state file present (pid=${existing.pid}) but /health unresponsive`); |
| 123 | await killByPidWithIdentity(existing.pid, existing.cmdlineMarker, verbose); |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | // Spawn under exclusive lock; re-read state INSIDE the lock so we don't |
| 128 | // race a concurrent CLI that won the lock first. |
| 129 | const lockPath = resolveLockFilePath(stateFile); |
| 130 | const release = acquireLock(lockPath); |
| 131 | if (!release) { |
| 132 | // Another process is starting the daemon. Wait for it. |
| 133 | log(verbose, "another CLI is spawning the daemon; waiting…"); |
| 134 | const start = Date.now(); |
| 135 | while (Date.now() - start < MAX_START_WAIT_MS) { |
| 136 | const fresh = readStateFile(stateFile); |
| 137 | if (fresh) { |
| 138 | const h = await healthCheck(fresh.port); |
| 139 | if (h) return { port: fresh.port, version: h.version, spawned: false }; |
| 140 | } |
| 141 | await delay(POLL_INTERVAL_MS); |
| 142 | } |
| 143 | throw new Error("Timed out waiting for concurrent daemon spawn"); |
| 144 | } |
| 145 | |
| 146 | try { |
no test coverage detected