Files
gstack/browse/test/stop-dead-daemon.test.ts
T
Garry TanandClaude Fable 5 5c081a355a fix(test): kill the four worst fixed sleeps (300s/30s/30s/20s)
- watchdog.test: the 20s blind wait for one production parent-watchdog
  tick becomes BROWSE_PARENT_WATCHDOG_INTERVAL_MS=250 (new env knob in
  server.ts, NaN-safe, production default unchanged) + polls for the
  boot line and the tick's stay-alive log — strictly stronger (the old
  form never proved a tick observed the parent death). 24s → 3.6s.
- stop-dead-daemon / terminal-agent-owner-watchdog: the 300s/30s
  stand-in child lifetimes become stdin-EOF-bound — the child can never
  self-exit mid-test on a slow runner (spurious-failure class) and
  self-reaps instantly if the test dies (no 300s orphans). Node-compat
  stdin APIs (owner-watchdog runs on the Windows lane).
- browser-skill-commands: the sleeper fixture's 30s self-time becomes
  8s (no stdin pipe exists in runToFiles) — far above the 1s product
  timeout it must outlive, below the test ceiling, so a timeout-kill
  regression fails on clean assertions instead of an opaque bun
  timeout; added: stdout must NOT contain 'done'.

45/45 green across the four files + server tripwires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 05:31:02 +00:00

154 lines
6.4 KiB
TypeScript

/**
* #2254: `browse stop` against a daemon that isn't running must report
* success and exit 0 — WITHOUT starting a daemon just to stop it. The old
* flow routed stop through ensureServer(), which booted a fresh daemon +
* Chromium (multi-second, resource churn) and then shut it down — or
* crash-restarted on a stale state file.
*
* Integration pattern follows busy-daemon-recovery.test.ts: a scratch
* BROWSE_STATE_FILE + a real spawned CLI.
*/
import { describe, test, expect } from 'bun:test';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
import { isProcessAlive } from '../src/error-handling';
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;
}
/** Grab a port that is definitely closed (bind, read, release). */
async function closedPort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address();
if (!addr || typeof addr === 'string') { reject(new Error('bad address')); return; }
const port = addr.port;
srv.close(() => resolve(port));
});
});
}
describe('#2254 stop on a dead daemon', () => {
test('no daemon state at all → exit 0, nothing spawned', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-stop-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
const result = await runCli(['stop'], baseEnv(stateFile));
expect(result.code).toBe(0);
expect(result.stdout).toContain('nothing to stop');
// The load-bearing half: NO daemon was started to serve the stop —
// a spawned daemon would have written the state file.
expect(fs.existsSync(stateFile)).toBe(false);
expect(result.stderr).not.toContain('Starting server');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
test('stale state (dead pid + closed port) → exit 0, state cleaned, nothing spawned', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-stop-'));
const stateFile = path.join(tmpDir, 'browse.json');
try {
// A pid that is certainly not alive and a port nothing listens on.
const port = await closedPort();
fs.writeFileSync(stateFile, JSON.stringify({
pid: 2147483646,
port,
token: 'stale-token',
startedAt: new Date().toISOString(),
serverPath: '',
mode: 'launched' as const,
}, null, 2));
const result = await runCli(['stop'], baseEnv(stateFile));
expect(result.code).toBe(0);
expect(result.stdout).toContain('nothing to stop');
// Stale state cleaned, and no daemon spawned to replace it.
expect(fs.existsSync(stateFile)).toBe(false);
expect(result.stderr).not.toContain('Starting server');
expect(result.stderr).not.toContain('Restarting');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
});
describe('stop --force-restart on a LIVE daemon', () => {
test('kills it directly — never boots a fresh daemon just to stop it', async () => {
// A live-but-BUSY daemon: the pid is alive but nothing answers /health.
// Pre-fix, stop --force-restart fell through to ensureServer(), whose
// force-restart path killed the daemon and then STARTED a fresh one
// (daemon + Chromium) so sendCommand('stop') could stop it again —
// exactly what gstack-upgrade Step 4.8 triggers on a stale-busy daemon.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-stop-force-'));
const stateFile = path.join(tmpDir, 'browse.json');
// Portable long-lived child standing in for the wedged daemon process.
// Its lifetime is tied to this test process instead of a fixed sleep: it
// blocks until its stdin (a pipe we hold open) hits EOF. That means it
// can never self-exit mid-test — which would let the "pid is dead"
// assertion below pass without the CLI having killed anything — and it
// reaps itself the moment the test process dies, even on a hard kill
// where the finally block never runs.
const wedged = spawn('bun', ['-e',
"process.stdin.resume(); const bye = () => process.exit(0); "
+ "process.stdin.on('end', bye); process.stdin.on('error', bye); process.stdin.on('close', bye);",
], { stdio: ['pipe', 'ignore', 'ignore'] });
try {
const port = await closedPort();
fs.writeFileSync(stateFile, JSON.stringify({
pid: wedged.pid,
port,
token: 'busy-token',
startedAt: new Date().toISOString(),
serverPath: '',
mode: 'launched' as const,
}, null, 2));
const result = await runCli(['stop', '--force-restart'], baseEnv(stateFile));
expect(result.code).toBe(0);
expect(result.stdout).toContain('Daemon stopped (forced');
// The load-bearing half: NO fresh daemon was booted to serve the stop.
// A spawned daemon would have re-written the state file.
expect(fs.existsSync(stateFile)).toBe(false);
expect(result.stderr).not.toContain('Starting server');
expect(result.stdout + result.stderr).not.toContain('Restarting');
// And the live pid is actually gone.
const deadline = Date.now() + 3000;
while (Date.now() < deadline && isProcessAlive(wedged.pid!)) {
await new Promise((r) => setTimeout(r, 100));
}
expect(isProcessAlive(wedged.pid!)).toBe(false);
} finally {
try { wedged.kill('SIGKILL'); } catch { /* already gone */ }
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30_000);
});