diff --git a/src/commands/daemon-cmd.test.ts b/src/commands/daemon-cmd.test.ts index 45ba1da..21274a0 100644 --- a/src/commands/daemon-cmd.test.ts +++ b/src/commands/daemon-cmd.test.ts @@ -85,7 +85,7 @@ describe('daemon start', () => { vi.mocked(dc.health) .mockResolvedValueOnce(null) .mockResolvedValueOnce(health({ pid: 99 })); - vi.mocked(dc.spawnDaemon).mockResolvedValue(true); + vi.mocked(dc.spawnDaemon).mockResolvedValue({ ok: true }); await run(['daemon', 'start']); expect(logs.join()).toContain('started'); expect(logs.join()).toContain('99'); @@ -93,25 +93,35 @@ describe('daemon start', () => { it('reports a failed spawn and sets a non-zero exit code', async () => { vi.mocked(dc.health).mockResolvedValue(null); - vi.mocked(dc.spawnDaemon).mockResolvedValue(false); + vi.mocked(dc.spawnDaemon).mockResolvedValue({ ok: false, reason: 'unreachable' }); await run(['daemon', 'start']); expect(logs.join()).toContain('failed'); expect(process.exitCode).toBe(1); }); + + it('reports a port held by a foreign process and sets a non-zero exit code', async () => { + vi.mocked(dc.health).mockResolvedValue(null); + vi.mocked(dc.spawnDaemon).mockResolvedValue({ ok: false, reason: 'port-in-use' }); + await run(['daemon', 'start']); + expect(logs.join()).toContain('in use by another process'); + expect(logs.join()).toContain('AGENTAGE_DAEMON_PORT'); + expect(process.exitCode).toBe(1); + }); }); describe('daemon stop', () => { it('stops a running daemon', async () => { vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(true); + vi.mocked(lifecycle.stopDaemonSafely).mockResolvedValue(true); await run(['daemon', 'stop']); - expect(lifecycle.stopDaemon).toHaveBeenCalled(); + expect(lifecycle.stopDaemonSafely).toHaveBeenCalled(); expect(logs.join()).toContain('stopped'); }); it('is a no-op when nothing is running', async () => { vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(false); await run(['daemon', 'stop']); - expect(lifecycle.stopDaemon).not.toHaveBeenCalled(); + expect(lifecycle.stopDaemonSafely).not.toHaveBeenCalled(); expect(logs.join()).toContain('not running'); }); }); diff --git a/src/commands/daemon-cmd.ts b/src/commands/daemon-cmd.ts index 1afe25f..ba8a3ec 100644 --- a/src/commands/daemon-cmd.ts +++ b/src/commands/daemon-cmd.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { type Command } from 'commander'; -import { isDaemonRunning, resolvePort, stopDaemon } from '../daemon/lifecycle.js'; +import { isDaemonRunning, resolvePort, stopDaemonSafely } from '../daemon/lifecycle.js'; import { health, mismatchNotice, spawnDaemon, syncStatus } from '../lib/daemon-client.js'; const startAction = async (): Promise => { @@ -10,8 +10,17 @@ const startAction = async (): Promise => { console.log(chalk.gray(`Daemon already running (pid ${existing.pid}, port ${port}).`)); return; } - if (!(await spawnDaemon(port))) { - console.error(chalk.red('Daemon failed to start.')); + const outcome = await spawnDaemon(port); + if (!outcome.ok) { + if (outcome.reason === 'port-in-use') { + console.error( + chalk.red( + `Port ${port} is in use by another process - set AGENTAGE_DAEMON_PORT to use a different port.` + ) + ); + } else { + console.error(chalk.red('Daemon failed to start.')); + } process.exitCode = 1; return; } @@ -19,13 +28,12 @@ const startAction = async (): Promise => { console.log(chalk.green(`Daemon started (pid ${h?.pid ?? '?'}, port ${port}).`)); }; -const stopAction = (): void => { +const stopAction = async (): Promise => { if (!isDaemonRunning()) { console.log(chalk.gray('Daemon is not running.')); return; } - stopDaemon(); - console.log(chalk.green('Daemon stopped.')); + if (await stopDaemonSafely()) console.log(chalk.green('Daemon stopped.')); }; const statusAction = async (): Promise => { @@ -91,7 +99,10 @@ export const registerDaemon = (program: Command): void => { .command('start') .description('Start the daemon (idempotent)') .action(() => startAction()); - daemon.command('stop').description('Stop the daemon').action(stopAction); + daemon + .command('stop') + .description('Stop the daemon') + .action(() => stopAction()); daemon .command('status') .description('Show the daemon pid, uptime, and version') diff --git a/src/commands/update.test.ts b/src/commands/update.test.ts index 3c12736..5d7d582 100644 --- a/src/commands/update.test.ts +++ b/src/commands/update.test.ts @@ -111,7 +111,7 @@ describe('restartDaemonIfRunning', () => { }); const start = vi.fn(async () => { order.push('start'); - return true; + return { ok: true } as const; }); expect(await restartDaemonIfRunning({ running: () => true, stop, start })).toBe('restarted'); expect(order).toEqual(['stop', 'start']); @@ -119,13 +119,13 @@ describe('restartDaemonIfRunning', () => { it('returns failed when the new daemon does not come up', async () => { const stop = vi.fn(async () => true); - const start = vi.fn(async () => false); + const start = vi.fn(async () => ({ ok: false, reason: 'unreachable' }) as const); expect(await restartDaemonIfRunning({ running: () => true, stop, start })).toBe('failed'); }); it('is a no-op when the daemon is not running', async () => { const stop = vi.fn(async () => true); - const start = vi.fn(async () => true); + const start = vi.fn(async () => ({ ok: true }) as const); expect(await restartDaemonIfRunning({ running: () => false, stop, start })).toBe('not-running'); expect(stop).not.toHaveBeenCalled(); expect(start).not.toHaveBeenCalled(); diff --git a/src/commands/update.ts b/src/commands/update.ts index 943f1e5..78b0dd5 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -3,7 +3,7 @@ import { promisify } from 'node:util'; import chalk from 'chalk'; import { type Command } from 'commander'; import { isDaemonRunning, resolvePort, stopDaemonAndWait } from '../daemon/lifecycle.js'; -import { spawnDaemon } from '../lib/daemon-client.js'; +import { spawnDaemon, type SpawnOutcome } from '../lib/daemon-client.js'; import { checkForUpdate, INSTALL_HINT, type UpdateInfo } from '../lib/update-check.js'; import { acquireUpdateLock, releaseUpdateLock } from '../lib/update-lock.js'; import { VERSION } from '../utils/version.js'; @@ -15,7 +15,7 @@ export type RestartOutcome = 'restarted' | 'failed' | 'not-running'; export interface RestartDeps { running?: () => boolean; stop?: () => Promise; - start?: (port: number) => Promise; + start?: (port: number) => Promise; } // Restart a running daemon so it picks up the freshly installed binary; a stopped daemon is left @@ -26,7 +26,7 @@ export const restartDaemonIfRunning = async (deps: RestartDeps = {}): Promise { + it('is true only for an error carrying the EADDRINUSE code', () => { + const busy = new Error('port 4243 already in use') as NodeJS.ErrnoException; + busy.code = 'EADDRINUSE'; + expect(isEaddrinuse(busy)).toBe(true); + expect(isEaddrinuse(new Error('other'))).toBe(false); + expect(isEaddrinuse(null)).toBe(false); + expect(isEaddrinuse('EADDRINUSE')).toBe(false); + }); +}); + +describe('createStateCleanup', () => { + it('never removes files before ownership is marked (race loser leaves the winner alone)', () => { + const remove = vi.fn(); + const state = createStateCleanup(remove); + state.cleanup(); + expect(remove).not.toHaveBeenCalled(); + }); + + it('removes files only after ownership is marked', () => { + const remove = vi.fn(); + const state = createStateCleanup(remove); + state.markOwned(); + state.cleanup(); + expect(remove).toHaveBeenCalledOnce(); + }); +}); + +describe('safeReschedule', () => { + it('runs every step even when one throws, logging the failure', () => { + const onError = vi.fn(); + const ran: string[] = []; + safeReschedule( + [ + () => ran.push('a'), + () => { + throw new Error('bad config'); + }, + () => ran.push('c'), + ], + onError + ); + expect(ran).toEqual(['a', 'c']); + expect(onError).toHaveBeenCalledWith('bad config'); + }); +}); diff --git a/src/daemon-entry.ts b/src/daemon-entry.ts index ec0a912..68d7186 100644 --- a/src/daemon-entry.ts +++ b/src/daemon-entry.ts @@ -1,7 +1,9 @@ import { unwatchFile, watchFile } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { isAccountVault } from '@agentage/memory-core'; import { createClientProvider } from './daemon/client-provider.js'; import { + EADDRINUSE_EXIT_CODE, generateDaemonToken, removePidFile, removePortFile, @@ -19,6 +21,37 @@ import { createDiscoverWatcher } from './sync/discover/watcher.js'; import { createSyncManager } from './sync/manager.js'; import { VERSION } from './utils/version.js'; +export const isEaddrinuse = (err: unknown): boolean => + typeof err === 'object' && err !== null && (err as NodeJS.ErrnoException).code === 'EADDRINUSE'; + +// Gate state-file cleanup on ownership: a loser of an autostart race must never wipe the winner's +// pid/port/token files. Only the process that actually wrote them may remove them. +export const createStateCleanup = ( + remove: () => void +): { markOwned: () => void; cleanup: () => void } => { + let owned = false; + return { + markOwned: () => { + owned = true; + }, + cleanup: () => { + if (owned) remove(); + }, + }; +}; + +// Run each reschedule independently: a transiently-invalid config edit must not crash the daemon or +// stop the other channels rescheduling; the throwing one keeps its last-good schedule. +export const safeReschedule = (steps: Array<() => void>, onError: (msg: string) => void): void => { + for (const step of steps) { + try { + step(); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } + } +}; + // Unset/empty/invalid -> undefined (the watcher's defaults apply); the watcher floors low values. const envInt = (name: string): number | undefined => { const raw = process.env[name]; @@ -27,6 +60,12 @@ const envInt = (name: string): number | undefined => { return Number.isFinite(v) && v >= 0 ? v : undefined; }; +const state = createStateCleanup(() => { + removePidFile(); + removePortFile(); + removeTokenFile(); +}); + // The detached, long-lived engine host: one loopback HTTP server that owns a single in-process // engine and serialises every vault mutation, avoiding concurrent git index.lock collisions. It // runs both sync loops (git origins + the account/couch channel) and reschedules on config change. @@ -64,16 +103,17 @@ const main = async (): Promise => { writePidFile(process.pid); writePortFile(port); writeTokenFile(authToken); - git.reschedule(); - couch.reschedule(); - discover.reschedule(); + state.markOwned(); + + const reschedule = (): void => + safeReschedule( + [() => git.reschedule(), () => couch.reschedule(), () => discover.reschedule()], + (msg) => console.error(`[daemon] reschedule failed: ${msg}`) + ); + reschedule(); const configPath = vaultsJsonPath(); - watchFile(configPath, { interval: 2000 }, () => { - git.reschedule(); - couch.reschedule(); - discover.reschedule(); - }); + watchFile(configPath, { interval: 2000 }, reschedule); const shutdown = (): void => { unwatchFile(configPath); @@ -81,9 +121,7 @@ const main = async (): Promise => { couch.stop(); discover.stop(); server.stop().finally(() => { - removePidFile(); - removePortFile(); - removeTokenFile(); + state.cleanup(); process.exit(0); }); }; @@ -91,10 +129,26 @@ const main = async (): Promise => { process.on('SIGINT', shutdown); }; -main().catch((err: unknown) => { - console.error(err instanceof Error ? err.message : String(err)); - removePidFile(); - removePortFile(); - removeTokenFile(); - process.exit(1); -}); +// Only self-invoke when run directly (spawnDaemon's `node daemon-entry.js`); importing for tests +// must not boot a daemon. +const invokedDirectly = (): boolean => { + const entry = process.argv[1]; + return !!entry && fileURLToPath(import.meta.url) === entry; +}; + +if (invokedDirectly()) { + process.on('uncaughtException', (err: unknown) => { + console.error(`[daemon] uncaught: ${err instanceof Error ? err.message : String(err)}`); + state.cleanup(); + process.exit(1); + }); + main().catch((err: unknown) => { + if (isEaddrinuse(err)) { + // Another daemon owns the port + our state files: exit distinctly, touch nothing. + process.exit(EADDRINUSE_EXIT_CODE); + } + console.error(err instanceof Error ? err.message : String(err)); + state.cleanup(); + process.exit(1); + }); +} diff --git a/src/daemon/lifecycle.test.ts b/src/daemon/lifecycle.test.ts index 3cc232b..1eeaaf8 100644 --- a/src/daemon/lifecycle.test.ts +++ b/src/daemon/lifecycle.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { spawn } from 'node:child_process'; import { DEFAULT_DAEMON_PORT, @@ -12,6 +12,7 @@ import { resolvePort, stopDaemon, stopDaemonAndWait, + stopDaemonSafely, writePidFile, writePortFile, } from './lifecycle.js'; @@ -104,6 +105,54 @@ describe('stopDaemon', () => { }); }); +describe('stopDaemonSafely', () => { + afterEach(() => vi.restoreAllMocks()); + + const spawnLive = (): number => { + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore', + }); + return child.pid!; + }; + + it('signals a live pid the /health probe confirms', async () => { + const pid = spawnLive(); + writePidFile(pid); + writePortFile(5000); + expect(await stopDaemonSafely(async () => pid)).toBe(true); + expect(isDaemonRunning()).toBe(false); + }); + + it('refuses to signal when the recorded port reports a different pid (recycled)', async () => { + const warn = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const pid = spawnLive(); + writePidFile(pid); + writePortFile(5000); + try { + expect(await stopDaemonSafely(async () => pid + 1)).toBe(false); + expect(isProcessAlive(pid)).toBe(true); + expect(warn).toHaveBeenCalled(); + } finally { + process.kill(pid, 'SIGKILL'); + } + }); + + it('falls back to a blind signal with a caveat when no port was recorded', async () => { + const warn = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const pid = spawnLive(); + writePidFile(pid); + expect(await stopDaemonSafely(async () => null)).toBe(true); + expect(warn).toHaveBeenCalled(); + }); + + it('defers to stopDaemon (returns false) when nothing live is recorded', async () => { + expect(await stopDaemonSafely(async () => 1)).toBe(false); + writePidFile(DEAD_PID); + writePortFile(5000); + expect(await stopDaemonSafely(async () => 1)).toBe(false); + }); +}); + describe('stopDaemonAndWait', () => { it('returns true immediately when nothing is running', async () => { expect(await stopDaemonAndWait()).toBe(true); diff --git a/src/daemon/lifecycle.ts b/src/daemon/lifecycle.ts index d6c3df3..2560e2c 100644 --- a/src/daemon/lifecycle.ts +++ b/src/daemon/lifecycle.ts @@ -1,12 +1,16 @@ import { randomBytes } from 'node:crypto'; import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import chalk from 'chalk'; import { getConfigDir } from '../lib/config.js'; // The daemon's on-disk state lives beside the config so an isolated AGENTAGE_CONFIG_DIR fully // isolates a daemon (pid, port, token) from any other - tests never collide with a real one. export const DEFAULT_DAEMON_PORT = 4243; +// The daemon entry exits with this code on EADDRINUSE so a spawning CLI can short-circuit fast. +export const EADDRINUSE_EXIT_CODE = 3; + const pidPath = (): string => join(getConfigDir(), 'daemon.pid'); const portPath = (): string => join(getConfigDir(), 'daemon.port'); const tokenPath = (): string => join(getConfigDir(), 'daemon.token'); @@ -90,6 +94,44 @@ export const stopDaemon = (): boolean => { return alive; }; +// Lightweight, cycle-free /health probe (daemon-client imports this module) returning the reported +// pid; null when the port holds no reachable agentage daemon. +const probeDaemonPid = async (port: number): Promise => { + try { + const res = await fetch(`http://127.0.0.1:${port}/api/health`, { + signal: AbortSignal.timeout(500), + }); + if (!res.ok) return null; + const h = (await res.json()) as { pid?: unknown }; + return typeof h.pid === 'number' ? h.pid : null; + } catch { + return null; + } +}; + +// stopDaemon signals a bare recorded pid, which the OS may have recycled onto an unrelated process. +// Guard it: signal only when /health confirms the pid is our daemon, or - if no port was recorded - +// fall back to the blind signal with a printed caveat. Refuse otherwise rather than kill a stranger. +export const stopDaemonSafely = async ( + probe: (port: number) => Promise = probeDaemonPid +): Promise => { + const pid = readPid(); + if (pid === null || !isProcessAlive(pid)) return stopDaemon(); + const port = readNumberFile(portPath()); + const confirmed = port !== null && (await probe(port)) === pid; + if (confirmed) return stopDaemon(); + if (port === null) { + console.error(chalk.yellow(`No recorded port; signalling pid ${pid} without confirmation.`)); + return stopDaemon(); + } + console.error( + chalk.yellow( + `Daemon on port ${port} did not confirm pid ${pid}; not signalling. Delete the pid file to force.` + ) + ); + return false; +}; + // Stop, then wait (bounded) for the old process to actually exit so a restart can rebind the // port without an EADDRINUSE window. Returns whether the process is confirmed gone. export const stopDaemonAndWait = async (timeoutMs = 2000): Promise => { diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 75e5809..ae6842f 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -143,8 +143,13 @@ export const createDaemonServer = (opts: DaemonServerOptions): DaemonServer => { const start = (port: number, host: string = LOOPBACK): Promise => new Promise((resolve, reject) => { - const onError = (err: NodeJS.ErrnoException): void => - reject(err.code === 'EADDRINUSE' ? new Error(`port ${port} already in use`) : err); + const onError = (err: NodeJS.ErrnoException): void => { + if (err.code !== 'EADDRINUSE') return reject(err); + // Preserve the code so the entry point can exit distinctly on a busy port. + const busy = new Error(`port ${port} already in use`) as NodeJS.ErrnoException; + busy.code = 'EADDRINUSE'; + reject(busy); + }; server.once('error', onError); server.listen(port, host, () => { const addr = server.address(); @@ -154,7 +159,17 @@ export const createDaemonServer = (opts: DaemonServerOptions): DaemonServer => { }); }); - const stop = (): Promise => new Promise((resolve) => server.close(() => resolve())); + // close() alone hangs on idle keep-alive sockets; drop idle ones now, force the rest after a grace. + const stop = (): Promise => + new Promise((resolve) => { + server.closeIdleConnections(); + const grace = setTimeout(() => server.closeAllConnections(), 500); + grace.unref(); + server.close(() => { + clearTimeout(grace); + resolve(); + }); + }); return { server, start, stop }; }; diff --git a/src/lib/daemon-client.test.ts b/src/lib/daemon-client.test.ts index c2ae28c..ef42711 100644 --- a/src/lib/daemon-client.test.ts +++ b/src/lib/daemon-client.test.ts @@ -1,4 +1,6 @@ +import { once } from 'node:events'; import { mkdtempSync, rmSync } from 'node:fs'; +import { connect } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; @@ -127,6 +129,21 @@ describe('DaemonClient <-> server round trip', () => { }); }); +describe('server stop() lifecycle', () => { + it('resolves promptly despite an idle keep-alive connection', async () => { + const { port, stop } = await startServer(() => mockClient()); + const sock = connect(port, '127.0.0.1'); + sock.on('error', () => undefined); + await once(sock, 'connect'); + const outcome = await Promise.race([ + stop().then(() => 'stopped' as const), + new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), 2000)), + ]); + sock.destroy(); + expect(outcome).toBe('stopped'); + }); +}); + describe('health + waitForHealth', () => { it('waitForHealth resolves true against a live server', async () => { const { port, stop } = await startServer(() => mockClient()); @@ -156,7 +173,7 @@ describe('ensureDaemon fallback logic', () => { const live: Health = { ok: true, version: VERSION, pid: 1, uptime: 1, served: 0 }; it('uses an already-running daemon without spawning', async () => { - const spawn = vi.fn(async () => true); + const spawn = vi.fn(async () => ({ ok: true }) as const); const client = await ensureDaemon({ port: 40000, probe: async () => live, spawn }); expect(client).not.toBeNull(); expect(spawn).not.toHaveBeenCalled(); @@ -167,7 +184,7 @@ describe('ensureDaemon fallback logic', () => { const client = await ensureDaemon({ port: 40000, probe: async () => ({ ...live, version: '0.0.0-old' }), - spawn: async () => true, + spawn: async () => ({ ok: true }), }); expect(client).not.toBeNull(); expect(warn).toHaveBeenCalled(); @@ -177,16 +194,37 @@ describe('ensureDaemon fallback logic', () => { const client = await ensureDaemon({ port: 40000, probe: async () => null, - spawn: async () => true, + spawn: async () => ({ ok: true }), }); expect(client).not.toBeNull(); }); - it('returns null when absent and the fork is blocked', async () => { + it('returns null when absent and the spawn is blocked', async () => { + const client = await ensureDaemon({ + port: 40000, + probe: async () => null, + spawn: async () => ({ ok: false, reason: 'blocked' }), + }); + expect(client).toBeNull(); + }); + + it('returns null when the spawned daemon dies on a busy port', async () => { + const client = await ensureDaemon({ + port: 40000, + probe: async () => null, + spawn: async () => ({ ok: false, reason: 'port-in-use' }), + }); + expect(client).toBeNull(); + }); + + it('returns null when no token is readable (nothing to authenticate with)', async () => { + const readToken = (): string | null => null; + expect(await ensureDaemon({ port: 40000, probe: async () => live, readToken })).toBeNull(); const client = await ensureDaemon({ port: 40000, probe: async () => null, - spawn: async () => false, + spawn: async () => ({ ok: true }), + readToken, }); expect(client).toBeNull(); }); diff --git a/src/lib/daemon-client.ts b/src/lib/daemon-client.ts index c12e721..fa3f943 100644 --- a/src/lib/daemon-client.ts +++ b/src/lib/daemon-client.ts @@ -8,7 +8,7 @@ import { type SearchResult, type WriteResult, } from '@agentage/memory-core'; -import { readDaemonToken, resolvePort } from '../daemon/lifecycle.js'; +import { EADDRINUSE_EXIT_CODE, readDaemonToken, resolvePort } from '../daemon/lifecycle.js'; import { type SyncResult } from '../sync/cycle.js'; import { type CouchSyncResult } from '../sync/couch/manager.js'; import { type SyncStatus } from '../sync/manager.js'; @@ -133,35 +133,49 @@ export const mismatchNotice = (daemonVersion: string): string | null => const entryPath = (): string => fileURLToPath(new URL('../daemon-entry.js', import.meta.url)); +// Why the autostart failed: a busy port (foreign process) needs a distinct user message and lets +// callers avoid paying the full health wait; the others just mean "fall back to a DirectClient". +export type SpawnOutcome = + { ok: true } | { ok: false; reason: 'port-in-use' | 'unreachable' | 'blocked' }; + // Detached spawn (not fork: fork's IPC channel keeps the parent event loop alive past unref) so -// the daemon outlives this CLI; ignore stdio + unref so the CLI can exit. Returns false (never -// throws) when spawning is blocked - callers then fall back to a DirectClient. +// the daemon outlives this CLI; ignore stdio + unref so the CLI can exit. Watches the child's exit +// so a fast EADDRINUSE death short-circuits the health wait instead of burning the full timeout. export const spawnDaemon = async ( port: number, opts: { timeoutMs?: number } = {} -): Promise => { +): Promise => { + let child: ReturnType; try { - const child = spawnChild(process.execPath, [entryPath()], { + child = spawnChild(process.execPath, [entryPath()], { detached: true, stdio: 'ignore', env: { ...process.env, AGENTAGE_DAEMON_PORT: String(port) }, }); - child.unref(); } catch { - return false; + return { ok: false, reason: 'blocked' }; } - return waitForHealth(port, { timeoutMs: opts.timeoutMs ?? 4000 }); + const exited = new Promise((resolve) => { + child.once('exit', (code) => + resolve({ ok: false, reason: code === EADDRINUSE_EXIT_CODE ? 'port-in-use' : 'unreachable' }) + ); + }); + child.unref(); + const healthy = waitForHealth(port, { timeoutMs: opts.timeoutMs ?? 4000 }).then( + (ok): SpawnOutcome => (ok ? { ok: true } : { ok: false, reason: 'unreachable' }) + ); + return Promise.race([healthy, exited]); }; export interface EnsureDeps { port?: number; probe?: (port: number) => Promise; - spawn?: (port: number) => Promise; + spawn?: (port: number) => Promise; readToken?: () => string | null; } // DO3/DO4/DO9: prefer a live daemon, autostart one if absent, and return null when it is -// unreachable, cannot be forked, or has no readable token (nothing to authenticate with) so the +// unreachable, cannot be spawned, or has no readable token (nothing to authenticate with) so the // caller falls back to the in-process DirectClient. export const ensureDaemon = async (deps: EnsureDeps = {}): Promise => { const port = deps.port ?? resolvePort(); @@ -175,6 +189,6 @@ export const ensureDaemon = async (deps: EnsureDeps = {}): Promise false); - return started && readToken() ? createDaemonClient(port) : null; + const outcome = await spawn(port).catch((): SpawnOutcome => ({ ok: false, reason: 'blocked' })); + return outcome.ok && readToken() ? createDaemonClient(port) : null; };