mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-11 15:39:04 +02:00
`browse stop --force-restart` on a live-but-busy daemon fell through the
stop short-circuit into ensureServer(), whose force-restart path kills the
daemon and then STARTS A FRESH ONE (daemon + Chromium, multi-second churn)
just so sendCommand('stop') can shut it down again — the #2254 churn in
force clothing. gstack-upgrade's Step 4.8 sends users down exactly this
path when a stale daemon is busy after an upgrade.
The stop short-circuit now handles it: live pid + --force-restart → kill
the daemon (tree-kill on Windows, TERM→KILL on POSIX), reap the orphaned
Chromium + clear profile locks, remove the state file, exit 0 — no server
is ever started. Pinned in stop-dead-daemon.test.ts: a wedged live "daemon"
is killed, the state file stays gone (a booted daemon would have rewritten
it), and no Starting/Restarting output appears.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
145 lines
5.8 KiB
TypeScript
145 lines
5.8 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.
|
|
const wedged = spawn('bun', ['-e', 'await Bun.sleep(300000)'], { stdio: '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);
|
|
});
|