Skip to content

Commit 4148f28

Browse files
authored
fix(cli): probe port 3847 before spawning a daemon — stop duplicate daemons (INT-2473) (#228)
Daemon detection was PID-file-only, but a launchd-managed instance never writes the PID file. Every bare `openswarm` launch therefore auto-started a SECOND daemon next to the launchd one, and both worked the same Linear queue in parallel (observed 2026-07-05: two daemons, 8 duplicated in-flight tasks). - daemon.ts: add probeDaemonPort() (GET /api/stats, short timeout) and getDaemonStatusFull() which ORs the PID file with the port probe. - startDaemon(): async; refuses with a clear launchctl hint when the port already answers. PID-file check still short-circuits without probing. - cli.ts: TUI auto-start gates on getDaemonStatusFull(); `status` reports externally managed daemons as running; `stop` points at launchctl instead of a misleading "not running". Verified live against the running launchd daemon: `status` → "externally managed", `start` → refused, `stop` → launchctl guidance; daemon stayed single. Hermetic tests (fetch stub + homedir isolation, no sockets) pin the refusal, external status, and PID-file short-circuit: 9 pass.
1 parent 818c020 commit 4148f28

3 files changed

Lines changed: 187 additions & 9 deletions

File tree

src/cli.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ program
421421

422422
const { startDaemon, getDaemonStatus, readLogTail } = await import('./cli/daemon.js');
423423
try {
424-
const { pid, logFile } = startDaemon();
424+
const { pid, logFile } = await startDaemon();
425425
// The child can die immediately on a startup error (bad config, port in
426426
// use, throwing dependency). Spawning only proves the OS forked it — wait
427427
// briefly and confirm it's actually alive before claiming success, so we
@@ -453,10 +453,18 @@ program
453453
.option('-t, --timeout <ms>', 'Max time to wait for graceful shutdown (default 10000)', '10000')
454454
.action(async (opts: { timeout: string }) => {
455455
const timeoutMs = parseInt(opts.timeout, 10);
456-
const { stopDaemon } = await import('./cli/daemon.js');
456+
const { stopDaemon, probeDaemonPort } = await import('./cli/daemon.js');
457457
try {
458458
const stopped = await stopDaemon(Number.isFinite(timeoutMs) ? timeoutMs : 10_000);
459459
if (!stopped) {
460+
// No PID file — but an externally managed (launchd) daemon may still be
461+
// serving. Point at the right lever instead of a misleading "not running".
462+
if (await probeDaemonPort()) {
463+
console.log('OpenSwarm is running but externally managed (no PID file — e.g. launchd).');
464+
console.log(' stop: launchctl bootout gui/$UID/com.intrect.openswarm');
465+
console.log(' restart: launchctl kickstart -k gui/$UID/com.intrect.openswarm');
466+
return;
467+
}
460468
console.log('OpenSwarm is not running.');
461469
return;
462470
}
@@ -473,14 +481,20 @@ program
473481
.command('status')
474482
.description('Report daemon status (pid, uptime, log path)')
475483
.action(async () => {
476-
const { getDaemonStatus } = await import('./cli/daemon.js');
477-
const status = getDaemonStatus();
484+
const { getDaemonStatusFull } = await import('./cli/daemon.js');
485+
const status = await getDaemonStatusFull();
478486
if (!status.running) {
479487
console.log('OpenSwarm is not running.');
480488
console.log(` pid file: ${status.pidFile}`);
481489
console.log(` log file: ${status.logFile}`);
482490
return;
483491
}
492+
if (status.external) {
493+
console.log('OpenSwarm is running (externally managed — e.g. launchd).');
494+
console.log(' port 3847 is responding; no PID file for this instance.');
495+
console.log(` restart: launchctl kickstart -k gui/$UID/com.intrect.openswarm`);
496+
return;
497+
}
484498
const uptime = status.uptimeSeconds ?? 0;
485499
const h = Math.floor(uptime / 3600);
486500
const m = Math.floor((uptime % 3600) / 60);
@@ -664,9 +678,13 @@ async function launchChatTui(sessionId?: string): Promise<void> {
664678
// agent itself doesn't depend on the daemon. Done before console muting so the
665679
// one-line status is visible.
666680
try {
667-
const { getDaemonStatus, startDaemon } = await import('./cli/daemon.js');
668-
if (!getDaemonStatus().running) {
669-
const { pid } = startDaemon();
681+
const { getDaemonStatus, getDaemonStatusFull, startDaemon } = await import('./cli/daemon.js');
682+
// Port-probe-aware check: a launchd-managed daemon has no PID file, and
683+
// spawning a second daemon next to it double-processes the same task
684+
// queue (INT-2473). Only spawn when neither the PID file nor the API port
685+
// shows a live daemon.
686+
if (!(await getDaemonStatusFull()).running) {
687+
const { pid } = await startDaemon();
670688
process.stdout.write(`Starting OpenSwarm daemon (pid ${pid}) for the monitor tabs…\n`);
671689
await new Promise((r) => setTimeout(r, 1500));
672690
if (!getDaemonStatus().running) {

src/cli/daemon.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest';
2+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
6+
// Isolate the PID file: daemon.ts derives its state dir from homedir() at
7+
// module load, so point homedir at a temp dir BEFORE importing the module.
8+
const TEST_HOME = join(tmpdir(), `osw-daemon-test-home-${process.pid}`);
9+
vi.mock('node:os', async () => {
10+
const actual = await vi.importActual<typeof import('node:os')>('node:os');
11+
return { ...actual, homedir: () => TEST_HOME };
12+
});
13+
14+
// No real sockets: probeDaemonPort goes through global fetch, which each test
15+
// stubs. This keeps the suite runnable in sandboxes that forbid listen().
16+
function stubFetch(impl: (url: string, init?: { signal?: AbortSignal }) => Promise<Response>): ReturnType<typeof vi.fn> {
17+
const fn = vi.fn(impl);
18+
vi.stubGlobal('fetch', fn);
19+
return fn;
20+
}
21+
22+
const PID_FILE = join(TEST_HOME, '.config', 'openswarm', 'openswarm.pid');
23+
24+
beforeAll(() => {
25+
mkdirSync(join(TEST_HOME, '.config', 'openswarm'), { recursive: true });
26+
});
27+
28+
afterEach(() => {
29+
vi.unstubAllGlobals();
30+
rmSync(PID_FILE, { force: true });
31+
});
32+
33+
describe('probeDaemonPort', () => {
34+
it('returns true when the daemon API answers 200', async () => {
35+
const { probeDaemonPort } = await import('./daemon.js');
36+
const fetchFn = stubFetch(async (url) => {
37+
expect(url).toBe('http://127.0.0.1:3847/api/stats');
38+
return new Response('{}', { status: 200 });
39+
});
40+
expect(await probeDaemonPort()).toBe(true);
41+
expect(fetchFn).toHaveBeenCalledTimes(1);
42+
});
43+
44+
it('returns false on a non-OK response', async () => {
45+
const { probeDaemonPort } = await import('./daemon.js');
46+
stubFetch(async () => new Response('', { status: 500 }));
47+
expect(await probeDaemonPort()).toBe(false);
48+
});
49+
50+
it('returns false when the connection is refused', async () => {
51+
const { probeDaemonPort } = await import('./daemon.js');
52+
stubFetch(async () => {
53+
throw new TypeError('fetch failed: ECONNREFUSED');
54+
});
55+
expect(await probeDaemonPort()).toBe(false);
56+
});
57+
58+
it('returns false when the server hangs past the timeout', async () => {
59+
const { probeDaemonPort } = await import('./daemon.js');
60+
stubFetch(
61+
(_url, init) =>
62+
new Promise((_resolve, reject) => {
63+
init?.signal?.addEventListener('abort', () => reject(new DOMException('timeout', 'TimeoutError')));
64+
})
65+
);
66+
expect(await probeDaemonPort(3847, 50)).toBe(false);
67+
});
68+
});
69+
70+
describe('getDaemonStatusFull', () => {
71+
it('reports an externally managed daemon when the port answers without a PID file', async () => {
72+
const { getDaemonStatusFull } = await import('./daemon.js');
73+
stubFetch(async () => new Response('{}', { status: 200 }));
74+
const status = await getDaemonStatusFull();
75+
expect(status.running).toBe(true);
76+
expect(status.external).toBe(true);
77+
});
78+
79+
it('reports not running when neither the PID file nor the port shows a daemon', async () => {
80+
const { getDaemonStatusFull } = await import('./daemon.js');
81+
stubFetch(async () => {
82+
throw new TypeError('fetch failed: ECONNREFUSED');
83+
});
84+
const status = await getDaemonStatusFull();
85+
expect(status.running).toBe(false);
86+
expect(status.external).toBeUndefined();
87+
});
88+
89+
it('prefers the PID file and skips the port probe when the PID is alive', async () => {
90+
const { getDaemonStatusFull } = await import('./daemon.js');
91+
writeFileSync(PID_FILE, String(process.pid));
92+
const fetchFn = stubFetch(async () => new Response('{}', { status: 200 }));
93+
const status = await getDaemonStatusFull();
94+
expect(status.running).toBe(true);
95+
expect(status.external).toBeUndefined();
96+
expect(status.pid).toBe(process.pid);
97+
expect(fetchFn).not.toHaveBeenCalled();
98+
});
99+
});
100+
101+
describe('startDaemon duplicate prevention (INT-2473)', () => {
102+
it('refuses to spawn when the daemon port is already serving (launchd case)', async () => {
103+
const { startDaemon } = await import('./daemon.js');
104+
stubFetch(async () => new Response('{}', { status: 200 }));
105+
await expect(startDaemon()).rejects.toThrow(/already serving port 3847/);
106+
});
107+
108+
it('refuses via the PID file without probing when a spawned daemon is alive', async () => {
109+
const { startDaemon } = await import('./daemon.js');
110+
writeFileSync(PID_FILE, String(process.pid));
111+
const fetchFn = stubFetch(async () => new Response('{}', { status: 200 }));
112+
await expect(startDaemon()).rejects.toThrow(/already running \(pid/);
113+
expect(fetchFn).not.toHaveBeenCalled();
114+
});
115+
});

src/cli/daemon.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@ const STATE_DIR = join(homedir(), '.config', 'openswarm');
1818
const LOG_DIR = join(STATE_DIR, 'logs');
1919
const PID_FILE = join(STATE_DIR, 'openswarm.pid');
2020
const LOG_FILE = join(LOG_DIR, 'openswarm.log');
21+
const DAEMON_PORT = 3847;
2122

2223
export interface DaemonStatus {
2324
running: boolean;
2425
pid?: number;
2526
uptimeSeconds?: number;
2627
pidFile: string;
2728
logFile: string;
29+
/** True when no PID file matched but the daemon API answered — a launchd-managed or manually started instance. */
30+
external?: boolean;
2831
}
2932

3033
function ensureStateDirs(): void {
@@ -49,6 +52,25 @@ function readPidFile(): number | null {
4952
return pid;
5053
}
5154

55+
/**
56+
* Probe the daemon's HTTP API directly. The PID file only tracks daemons
57+
* spawned by `openswarm start` — a launchd-managed (or manually run) instance
58+
* never writes it, so PID-file-only detection reports "not running" while a
59+
* daemon is actively serving. That mis-detection made the TUI auto-start spawn
60+
* a SECOND daemon working the same Linear queue in parallel (INT-2473).
61+
* The port answers for any daemon regardless of how it was started.
62+
*/
63+
export async function probeDaemonPort(port = DAEMON_PORT, timeoutMs = 800): Promise<boolean> {
64+
try {
65+
const res = await fetch(`http://127.0.0.1:${port}/api/stats`, {
66+
signal: AbortSignal.timeout(timeoutMs),
67+
});
68+
return res.ok;
69+
} catch {
70+
return false;
71+
}
72+
}
73+
5274
/**
5375
* Resolve the path to dist/index.js. daemon.js lives at dist/cli/daemon.js,
5476
* so index.js is one level up.
@@ -65,9 +87,9 @@ function closeFdQuietly(fd: number): void {
6587
/**
6688
* Start the service as a detached background process.
6789
* Returns the child PID on success.
68-
* Throws if a daemon is already running.
90+
* Throws if a daemon is already running (PID file OR port 3847 responding).
6991
*/
70-
export function startDaemon(): { pid: number; logFile: string } {
92+
export async function startDaemon(): Promise<{ pid: number; logFile: string }> {
7193
ensureStateDirs();
7294

7395
const existing = readPidFile();
@@ -82,6 +104,15 @@ export function startDaemon(): { pid: number; logFile: string } {
82104
try { unlinkSync(PID_FILE); } catch { /* ignore */ }
83105
}
84106

107+
// No PID file, but the API may still be live: a launchd-managed or manually
108+
// started daemon. Spawning another would double-process the same task queue.
109+
if (await probeDaemonPort()) {
110+
throw new Error(
111+
`OpenSwarm is already serving port ${DAEMON_PORT} (externally managed — e.g. launchd). ` +
112+
`Not spawning a duplicate. Use 'launchctl kickstart -k gui/$UID/com.intrect.openswarm' to restart it.`
113+
);
114+
}
115+
85116
const indexPath = resolveIndexPath();
86117
if (!existsSync(indexPath)) {
87118
throw new Error(`Service entrypoint not found: ${indexPath}`);
@@ -184,4 +215,18 @@ export function getDaemonStatus(): DaemonStatus {
184215
return { running: true, pid, uptimeSeconds, pidFile: PID_FILE, logFile: LOG_FILE };
185216
}
186217

218+
/**
219+
* Like getDaemonStatus, but also detects daemons the PID file can't see
220+
* (launchd-managed / manually started) by probing the API port. Prefer this
221+
* anywhere the answer gates spawning a new daemon.
222+
*/
223+
export async function getDaemonStatusFull(): Promise<DaemonStatus> {
224+
const base = getDaemonStatus();
225+
if (base.running) return base;
226+
if (await probeDaemonPort()) {
227+
return { running: true, external: true, pidFile: PID_FILE, logFile: LOG_FILE };
228+
}
229+
return base;
230+
}
231+
187232
export const DAEMON_PATHS = { STATE_DIR, LOG_DIR, PID_FILE, LOG_FILE } as const;

0 commit comments

Comments
 (0)