single-instance.ts
171 lines · ios-qa/daemon/src/single-instance.ts at 3f79d81
1// Single-instance enforcement. Daemon takes an exclusive flock on
2// ~/.gstack/ios-qa-daemon.pid on startup. Second invocation discovers the
3// existing daemon's port + connects. Stale lock (PID dead) is reclaimed.
4//
5// Readiness protocol: daemon writes `READY: port=<n> pid=<pid>` to stdout
6// once both listeners are up; the spawner reads stdout with a 5s timeout.
8import { readFile, mkdir, unlink } from 'fs/promises';
9import { existsSync, openSync, writeSync, closeSync, unlinkSync } from 'fs';
10import { join, dirname } from 'path';
11import { spawn } from 'child_process';
12import { resolveStateRoot } from '../../../lib/state-root';
14export interface PidfileContents {
15 pid: number;
16 port: number;
17 startedAt: number;
18}
20export function defaultPidfilePath(): string {
21 return process.env.GSTACK_IOS_DAEMON_PIDFILE
22 ?? join(resolveStateRoot(), 'ios-qa-daemon.pid');
23}
25/**
26 * Try to claim the pidfile. Returns:
27 * - { claimed: true } when this process now owns the lock
28 * - { claimed: false, existing } when another live daemon holds it
29 *
30 * The "live" check is process.kill(pid, 0): succeeds if the PID exists,
31 * fails with ESRCH if not. We DO NOT trust a stale pidfile.
32 */
33export async function tryClaim(opts: {
34 port: number;
35 path?: string;
36}): Promise<
37 | { claimed: true; release: () => Promise<void> }
38 | { claimed: false; existing: PidfileContents }
39> {
40 const path = opts.path ?? defaultPidfilePath();
41 await mkdir(dirname(path), { recursive: true, mode: 0o700 });
43 // Check for an existing pidfile.
44 if (existsSync(path)) {
45 try {
46 const raw = await readFile(path, 'utf-8');
47 const existing = JSON.parse(raw) as PidfileContents;
48 if (isAlive(existing.pid)) {
49 return { claimed: false, existing };
50 }
51 // Stale — drop it and continue to claim.
52 await unlink(path).catch(() => {});
53 } catch {
54 // Unparseable pidfile — treat as stale.
55 await unlink(path).catch(() => {});
56 }
57 }
59 // Use SYNCHRONOUS open with O_EXCL for atomic exclusion. Bun's async
60 // fs.open(wx) doesn't reliably preserve O_EXCL semantics across concurrent
61 // calls in the same process. Sync openSync goes straight to syscall and is
62 // genuinely atomic.
63 //
64 // Constant 0x800 = O_EXCL on macOS/Linux; combined with O_CREAT (0x200) and
65 // O_WRONLY (0x1) it's the equivalent of 'wx'. The sync API accepts the
66 // string flag form too, but explicit numeric flags are the most defensive.
67 const contents: PidfileContents = {
68 pid: process.pid,
69 port: opts.port,
70 startedAt: Date.now(),
71 };
72 let fd: number;
73 try {
74 fd = openSync(path, 'wx', 0o600);
75 } catch (err: unknown) {
76 const e = err as { code?: string };
77 if (e.code === 'EEXIST') {
78 // Race: another caller won.
79 const raw = await readFile(path, 'utf-8').catch(() => '{}');
80 const existing = JSON.parse(raw || '{}') as PidfileContents;
81 return { claimed: false, existing };
82 }
83 throw err;
84 }
85 try {
86 writeSync(fd, JSON.stringify(contents, null, 2));
87 } finally {
88 closeSync(fd);
89 }
91 // Cleanup on exit.
92 const cleanup = async () => {
93 try {
94 // Verify we still own it before unlinking.
95 const raw = await readFile(path, 'utf-8');
96 const cur = JSON.parse(raw) as PidfileContents;
97 if (cur.pid === process.pid) {
98 await unlink(path);
99 }
100 } catch {
101 // best-effort
102 }
103 };
105 process.on('exit', () => {
106 try { unlinkSync(path); } catch { /* ignore */ }
107 });
108 process.on('SIGINT', () => { cleanup().finally(() => process.exit(0)); });
109 process.on('SIGTERM', () => { cleanup().finally(() => process.exit(0)); });
111 return { claimed: true, release: cleanup };
112}
114function isAlive(pid: number): boolean {
115 if (!Number.isInteger(pid) || pid <= 0) return false;
116 try {
117 process.kill(pid, 0);
118 return true;
119 } catch (err: unknown) {
120 const e = err as { code?: string };
121 return e.code !== 'ESRCH';
122 }
123}
125/**
126 * Spawn a daemon process and wait for the READY line. Returns the port the
127 * daemon claims to be listening on.
128 *
129 * Used by /ios-qa skill to spawn-on-demand. If another daemon is already
130 * running, the spawned child detects the existing pidfile and prints a
131 * READY line with the existing port (loaded from the pidfile).
132 */
133export async function spawnAndWaitReady(opts: {
134 cmd: string;
135 args: string[];
136 timeoutMs?: number;
137 env?: NodeJS.ProcessEnv;
138}): Promise<{ pid: number; port: number }> {
139 const timeoutMs = opts.timeoutMs ?? 5000;
140 const child = spawn(opts.cmd, opts.args, {
141 stdio: ['ignore', 'pipe', 'inherit'],
142 detached: true,
143 env: opts.env ?? process.env,
144 });
146 return new Promise((resolve, reject) => {
147 let buffer = '';
148 const onTimeout = setTimeout(() => {
149 child.kill('SIGTERM');
150 reject(new Error(`daemon spawn timeout after ${timeoutMs}ms`));
151 }, timeoutMs);
153 child.stdout?.on('data', (chunk: Buffer) => {
154 buffer += chunk.toString();
155 const match = buffer.match(/READY:\s*port=(\d+)\s+pid=(\d+)/);
156 if (match) {
157 clearTimeout(onTimeout);
158 child.unref();
159 resolve({ pid: parseInt(match[2]!, 10), port: parseInt(match[1]!, 10) });
160 }
161 });
162 child.on('error', (err) => {
163 clearTimeout(onTimeout);
164 reject(err);
165 });
166 child.on('exit', (code, signal) => {
167 clearTimeout(onTimeout);
168 reject(new Error(`daemon exited before READY (code=${code} signal=${signal})`));
169 });
170 });
171}