gstack/ios-qa/daemon/src/single-instance.ts

172 lines
5.3 KiB
TypeScript

// Single-instance enforcement. Daemon takes an exclusive flock on
// ~/.gstack/ios-qa-daemon.pid on startup. Second invocation discovers the
// existing daemon's port + connects. Stale lock (PID dead) is reclaimed.
//
// Readiness protocol: daemon writes `READY: port=<n> pid=<pid>` to stdout
// once both listeners are up; the spawner reads stdout with a 5s timeout.
import { readFile, mkdir, unlink } from 'fs/promises';
import { existsSync, openSync, writeSync, closeSync, unlinkSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { spawn } from 'child_process';
export interface PidfileContents {
pid: number;
port: number;
startedAt: number;
}
export function defaultPidfilePath(): string {
return process.env.GSTACK_IOS_DAEMON_PIDFILE
?? join(homedir(), '.gstack', 'ios-qa-daemon.pid');
}
/**
* Try to claim the pidfile. Returns:
* - { claimed: true } when this process now owns the lock
* - { claimed: false, existing } when another live daemon holds it
*
* The "live" check is process.kill(pid, 0): succeeds if the PID exists,
* fails with ESRCH if not. We DO NOT trust a stale pidfile.
*/
export async function tryClaim(opts: {
port: number;
path?: string;
}): Promise<
| { claimed: true; release: () => Promise<void> }
| { claimed: false; existing: PidfileContents }
> {
const path = opts.path ?? defaultPidfilePath();
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
// Check for an existing pidfile.
if (existsSync(path)) {
try {
const raw = await readFile(path, 'utf-8');
const existing = JSON.parse(raw) as PidfileContents;
if (isAlive(existing.pid)) {
return { claimed: false, existing };
}
// Stale — drop it and continue to claim.
await unlink(path).catch(() => {});
} catch {
// Unparseable pidfile — treat as stale.
await unlink(path).catch(() => {});
}
}
// Use SYNCHRONOUS open with O_EXCL for atomic exclusion. Bun's async
// fs.open(wx) doesn't reliably preserve O_EXCL semantics across concurrent
// calls in the same process. Sync openSync goes straight to syscall and is
// genuinely atomic.
//
// Constant 0x800 = O_EXCL on macOS/Linux; combined with O_CREAT (0x200) and
// O_WRONLY (0x1) it's the equivalent of 'wx'. The sync API accepts the
// string flag form too, but explicit numeric flags are the most defensive.
const contents: PidfileContents = {
pid: process.pid,
port: opts.port,
startedAt: Date.now(),
};
let fd: number;
try {
fd = openSync(path, 'wx', 0o600);
} catch (err: unknown) {
const e = err as { code?: string };
if (e.code === 'EEXIST') {
// Race: another caller won.
const raw = await readFile(path, 'utf-8').catch(() => '{}');
const existing = JSON.parse(raw || '{}') as PidfileContents;
return { claimed: false, existing };
}
throw err;
}
try {
writeSync(fd, JSON.stringify(contents, null, 2));
} finally {
closeSync(fd);
}
// Cleanup on exit.
const cleanup = async () => {
try {
// Verify we still own it before unlinking.
const raw = await readFile(path, 'utf-8');
const cur = JSON.parse(raw) as PidfileContents;
if (cur.pid === process.pid) {
await unlink(path);
}
} catch {
// best-effort
}
};
process.on('exit', () => {
try { unlinkSync(path); } catch { /* ignore */ }
});
process.on('SIGINT', () => { cleanup().finally(() => process.exit(0)); });
process.on('SIGTERM', () => { cleanup().finally(() => process.exit(0)); });
return { claimed: true, release: cleanup };
}
function isAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err: unknown) {
const e = err as { code?: string };
return e.code !== 'ESRCH';
}
}
/**
* Spawn a daemon process and wait for the READY line. Returns the port the
* daemon claims to be listening on.
*
* Used by /ios-qa skill to spawn-on-demand. If another daemon is already
* running, the spawned child detects the existing pidfile and prints a
* READY line with the existing port (loaded from the pidfile).
*/
export async function spawnAndWaitReady(opts: {
cmd: string;
args: string[];
timeoutMs?: number;
env?: NodeJS.ProcessEnv;
}): Promise<{ pid: number; port: number }> {
const timeoutMs = opts.timeoutMs ?? 5000;
const child = spawn(opts.cmd, opts.args, {
stdio: ['ignore', 'pipe', 'inherit'],
detached: true,
env: opts.env ?? process.env,
});
return new Promise((resolve, reject) => {
let buffer = '';
const onTimeout = setTimeout(() => {
child.kill('SIGTERM');
reject(new Error(`daemon spawn timeout after ${timeoutMs}ms`));
}, timeoutMs);
child.stdout?.on('data', (chunk: Buffer) => {
buffer += chunk.toString();
const match = buffer.match(/READY:\s*port=(\d+)\s+pid=(\d+)/);
if (match) {
clearTimeout(onTimeout);
child.unref();
resolve({ pid: parseInt(match[2]!, 10), port: parseInt(match[1]!, 10) });
}
});
child.on('error', (err) => {
clearTimeout(onTimeout);
reject(err);
});
child.on('exit', (code, signal) => {
clearTimeout(onTimeout);
reject(new Error(`daemon exited before READY (code=${code} signal=${signal})`));
});
});
}