mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-14 08:59:01 +02:00
The CLI killed live-but-busy daemons: a heavy dev-mode page (cold-compiling Next.js route, timed-out navigation still churning) kept the daemon from answering /health longer than the old ~1s probe window (3 × 250ms), so the connection-error path declared it dead, SIGTERMed a healthy process, and every kill lost the session's tabs, cookies, and logins (reproduced 4/4 in the #2219 report). New contract (decision 9 / F10): - probeHealthWithBackoff is budget-based: ~8s total (HEALTH_PROBE_TOTAL_BUDGET_MS), 500ms intervals, each probe self-bounded at 2s — sized to the observed busy windows. - decideDaemonRestart (pure, exported, unit-tested) encodes the IRON RULE: healthy-after-probe → retry the SAME daemon; alive+unhealthy → "daemon busy — retry or --force-restart" + NONZERO exit, daemon untouched; only a DEAD pid (or an explicit --force-restart) reaches kill+restart. - --force-restart global flag (extractGlobalFlags): the one consent path that replaces a live daemon, always announcing the state it costs. - Wired at all three kill sites: sendCommand's connection-error branch, ensureServer's stale-state path (which previously killServer'd any alive pid whose single 2s health probe missed), and connect — which used to "Kill ANY existing server" and now refuses to replace a healthy daemon without 'browse disconnect' or --force-restart. pair-agent's internal headed switch passes --force-restart explicitly (the mode switch is that command's stated purpose), preserving its behavior. E5 IRON RULE regression tests (busy-daemon-iron-rule.test.ts, real spawned CLI + fake daemons + live sleep-pid stand-ins per the busy-daemon-recovery.test.ts pattern): healthy daemon SURVIVES connect (refused with guidance, pid alive, state file untouched); wedged-alive daemon + plain command → busy report, nonzero exit, pid alive; wedged daemon + --force-restart IS killed and a real replacement daemon serves the command. Plus pure-function coverage of all four decision outcomes and the ~8s budget pin. Tests: busy-daemon-iron-rule 8 pass (16.7s, includes a real daemon lifecycle); busy-daemon-recovery + proxy-config + daemon-mismatch-refuse + cli-lock + cli-start-final-healthcheck + cli-setsid-daemonize 39 pass. Fixes #2219. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
227 lines
9.5 KiB
TypeScript
227 lines
9.5 KiB
TypeScript
/**
|
|
* #2219 IRON RULE regression tests (E5): an alive daemon pid is NEVER
|
|
* auto-killed. Killing a live daemon loses the session's tabs, cookies, and
|
|
* logins — strictly worse than a slow command. Only an explicit
|
|
* --force-restart may replace a live daemon.
|
|
*
|
|
* Integration legs follow the busy-daemon-recovery.test.ts pattern: a fake
|
|
* HTTP daemon + a live `sleep` child standing in for the daemon PID, wired
|
|
* through BROWSE_STATE_FILE. Unit legs pin the pure decision function.
|
|
*/
|
|
|
|
import { describe, test, expect, afterEach } from 'bun:test';
|
|
import { spawn, type ChildProcess } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import * as http from 'http';
|
|
import { isProcessAlive } from '../src/error-handling';
|
|
import { decideDaemonRestart, HEALTH_PROBE_TOTAL_BUDGET_MS } from '../src/cli';
|
|
|
|
// ─── Unit: the pure restart decision (decision 9 / F10) ──────────────────
|
|
|
|
describe('decideDaemonRestart (pure)', () => {
|
|
test('healthy after probe → retry against the SAME daemon', () => {
|
|
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: true, forceRestart: false }))
|
|
.toBe('retry-command');
|
|
// Even with --force-restart in hand, a healthy daemon is retried, not killed.
|
|
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: true, forceRestart: true }))
|
|
.toBe('retry-command');
|
|
});
|
|
|
|
test('IRON RULE: alive + unhealthy + no flag → report busy, never kill', () => {
|
|
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: false, forceRestart: false }))
|
|
.toBe('report-busy');
|
|
});
|
|
|
|
test('alive + unhealthy + explicit --force-restart → force-restart', () => {
|
|
expect(decideDaemonRestart({ pidAlive: true, healthyAfterProbe: false, forceRestart: true }))
|
|
.toBe('force-restart');
|
|
});
|
|
|
|
test('dead pid → restart, with or without the flag', () => {
|
|
expect(decideDaemonRestart({ pidAlive: false, healthyAfterProbe: false, forceRestart: false }))
|
|
.toBe('restart-dead');
|
|
expect(decideDaemonRestart({ pidAlive: false, healthyAfterProbe: false, forceRestart: true }))
|
|
.toBe('restart-dead');
|
|
});
|
|
|
|
test('probe budget is ~8s (F10) — long enough for heavy-page busy windows', () => {
|
|
expect(HEALTH_PROBE_TOTAL_BUDGET_MS).toBeGreaterThanOrEqual(7_000);
|
|
expect(HEALTH_PROBE_TOTAL_BUDGET_MS).toBeLessThanOrEqual(10_000);
|
|
});
|
|
});
|
|
|
|
// ─── Integration: real spawned CLI vs fake daemons ───────────────────────
|
|
|
|
/** A daemon whose /health always answers healthy but never serves /command. */
|
|
async function startHealthyDaemon(): Promise<{ port: number; close: () => Promise<void> }> {
|
|
const server = http.createServer((req, res) => {
|
|
if (req.url === '/health') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ status: 'healthy' }));
|
|
return;
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end('ok');
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', () => resolve());
|
|
});
|
|
const addr = server.address();
|
|
if (!addr || typeof addr === 'string') throw new Error('fake daemon: bad address');
|
|
return { port: addr.port, close: () => new Promise((r) => server.close(() => r())) };
|
|
}
|
|
|
|
/** A WEDGED daemon: alive socket, but /health always answers unhealthy. */
|
|
async function startWedgedDaemon(): Promise<{ port: number; close: () => Promise<void> }> {
|
|
const server = http.createServer((req, res) => {
|
|
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ status: 'wedged' }));
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', () => resolve());
|
|
});
|
|
const addr = server.address();
|
|
if (!addr || typeof addr === 'string') throw new Error('fake daemon: bad address');
|
|
return { port: addr.port, close: () => new Promise((r) => server.close(() => r())) };
|
|
}
|
|
|
|
function runCli(args: string[], env: Record<string, string>, timeoutMs = 30_000):
|
|
Promise<{ code: number; stdout: string; stderr: string }> {
|
|
const cliPath = path.resolve(import.meta.dir, '../src/cli.ts');
|
|
return new Promise((resolve) => {
|
|
const proc = spawn('bun', ['run', cliPath, ...args], { timeout: timeoutMs, env });
|
|
let stdout = ''; let stderr = '';
|
|
proc.stdout.on('data', (d) => stdout += d.toString());
|
|
proc.stderr.on('data', (d) => stderr += d.toString());
|
|
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
});
|
|
}
|
|
|
|
function baseEnv(stateFile: string): Record<string, string> {
|
|
const env: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(process.env)) {
|
|
if (v !== undefined) env[k] = v;
|
|
}
|
|
env.BROWSE_STATE_FILE = stateFile;
|
|
return env;
|
|
}
|
|
|
|
let pidChild: ChildProcess | null = null;
|
|
afterEach(() => { pidChild?.kill('SIGKILL'); pidChild = null; });
|
|
|
|
describe('#2219 iron rule (CLI integration)', () => {
|
|
test('healthy daemon SURVIVES `browse connect` — refused with guidance, no kill', async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-iron-'));
|
|
const stateFile = path.join(tmpDir, 'browse.json');
|
|
const daemon = await startHealthyDaemon();
|
|
try {
|
|
pidChild = spawn('sleep', ['60'], { stdio: 'ignore' });
|
|
const daemonPid = pidChild.pid!;
|
|
const stateContent = {
|
|
pid: daemonPid,
|
|
port: daemon.port,
|
|
token: 'iron-rule-token',
|
|
startedAt: new Date().toISOString(),
|
|
serverPath: '',
|
|
mode: 'launched' as const,
|
|
};
|
|
fs.writeFileSync(stateFile, JSON.stringify(stateContent, null, 2));
|
|
|
|
const result = await runCli(['connect'], baseEnv(stateFile));
|
|
|
|
expect(result.code).not.toBe(0);
|
|
expect(result.stderr).toContain('healthy daemon is already running');
|
|
expect(result.stderr).toContain('--force-restart');
|
|
// THE IRON RULE: the daemon process was not killed.
|
|
expect(isProcessAlive(daemonPid)).toBe(true);
|
|
// And the state file was not clobbered.
|
|
expect(JSON.parse(fs.readFileSync(stateFile, 'utf-8'))).toEqual(stateContent);
|
|
} finally {
|
|
await daemon.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 30_000);
|
|
|
|
test('wedged-alive daemon + plain command → busy report + nonzero exit, NO kill', async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-iron-'));
|
|
const stateFile = path.join(tmpDir, 'browse.json');
|
|
const daemon = await startWedgedDaemon();
|
|
try {
|
|
pidChild = spawn('sleep', ['120'], { stdio: 'ignore' });
|
|
const daemonPid = pidChild.pid!;
|
|
fs.writeFileSync(stateFile, JSON.stringify({
|
|
pid: daemonPid,
|
|
port: daemon.port,
|
|
token: 'iron-rule-token',
|
|
startedAt: new Date().toISOString(),
|
|
serverPath: '',
|
|
mode: 'launched' as const,
|
|
}, null, 2));
|
|
|
|
const result = await runCli(['status'], baseEnv(stateFile));
|
|
|
|
expect(result.code).not.toBe(0);
|
|
expect(result.stderr).toContain('Daemon busy');
|
|
expect(result.stderr).toContain('--force-restart');
|
|
// Never killed, never restarted.
|
|
expect(isProcessAlive(daemonPid)).toBe(true);
|
|
expect(result.stderr).not.toContain('Restarting');
|
|
} finally {
|
|
await daemon.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 45_000);
|
|
|
|
test('wedged-alive daemon + --force-restart IS killed (explicit consent path)', async () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-iron-'));
|
|
const stateFile = path.join(tmpDir, 'browse.json');
|
|
const daemon = await startWedgedDaemon();
|
|
try {
|
|
pidChild = spawn('sleep', ['120'], { stdio: 'ignore' });
|
|
const daemonPid = pidChild.pid!;
|
|
fs.writeFileSync(stateFile, JSON.stringify({
|
|
pid: daemonPid,
|
|
port: daemon.port,
|
|
token: 'iron-rule-token',
|
|
startedAt: new Date().toISOString(),
|
|
serverPath: '',
|
|
mode: 'launched' as const,
|
|
}, null, 2));
|
|
|
|
// `status` with --force-restart: the wedged "daemon" must be killed and
|
|
// a REAL daemon started in its place.
|
|
const result = await runCli(['--force-restart', 'status'], baseEnv(stateFile), 60_000);
|
|
|
|
// The wedged pid was killed — the explicit consent path.
|
|
expect(isProcessAlive(daemonPid)).toBe(false);
|
|
expect(result.stderr).toContain('--force-restart');
|
|
// The replacement daemon answered the command.
|
|
expect(result.code).toBe(0);
|
|
} finally {
|
|
// Kill the REAL daemon's whole PROCESS GROUP, not just its pid.
|
|
// startServer spawns the daemon detached (setsid — its own group
|
|
// leader), so a bare SIGKILL on the pid orphans its Chromium child,
|
|
// which then squats memory for the REST of the suite (~100 files) —
|
|
// enough pressure on a loaded box for the OS to kill a LATER test's
|
|
// in-process Chromium, whose disconnect handler process.exit(1)s the
|
|
// whole bun run mid-suite with no summary.
|
|
try {
|
|
const newState = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
if (newState?.pid && isProcessAlive(newState.pid)) {
|
|
try {
|
|
process.kill(-newState.pid, 'SIGKILL'); // group: daemon + Chromium
|
|
} catch {
|
|
process.kill(newState.pid, 'SIGKILL'); // fallback: pid only
|
|
}
|
|
}
|
|
} catch { /* state file gone — nothing started */ }
|
|
await daemon.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 90_000);
|
|
});
|