Files
gstack/browse/test/terminal-agent-pid-identity.test.ts
T
Garry TanandClaude Fable 5 bbaf5068b0 fix(watchdog): signal-0 liveness, tick-scaled respawn guard, windowsHide
Three-bug chain behind the Windows terminal-agent leak (console window
strobing every 60s, one orphaned agent per watchdog tick until the box
ran out of committable memory):

1. isProcessAlive shelled out to `tasklist /FI "PID eq <pid>"` on Windows
   with a 3s timeout. A Bun.spawnSync that hits its timeout still RETURNS
   with partial stdout, so the `.includes()` PID match read a LIVE agent
   as dead — killAgentByRecord skipped the kill, the watchdog respawned
   around the survivor, and every orphan slowed the next tasklist enough
   to produce the next false negative. Now: `process.kill(pid, 0)` on
   every platform (Node and Bun both map signal 0 to an OpenProcess
   existence check on Windows), with EPERM counted as alive. No
   subprocess, no timeout, no console window.

2. The respawn circuit-breaker was mathematically unreachable — verified
   in this tree: RESPAWN_GUARD_WINDOW_MS was a fixed 60_000 against a
   60_000ms default tick, and each tick pushes at most one respawn
   timestamp, so three pushes span ~120s and can never coexist inside a
   60s window (eviction is strict `>`, and setInterval drift plus
   per-tick work always ages the prior entry past the boundary). The
   guard could not fire at the default tick rate and a steady
   one-per-tick leak ran unbounded. The window now scales with the tick:
   max(60_000, tick * (RESPAWN_GUARD_MAX + 2)), so "3 crashes in quick
   succession → stop" holds at any tick value.

3. The tasklist probe popped a visible console per tick (no windowsHide).
   Removing the shell-out kills that site; the agent-spawn site itself
   already passes windowsHide: true (landed with the bun-polyfill
   windowsHide commit — PR #2414's terminal-agent-control.ts hunk is
   reconciled there rather than duplicated).

New browse/test/process-liveness-windows.test.ts pins all three: no
subprocess from the probe, a static tripwire against reintroducing
`tasklist` + `PID eq` liveness checks in src/, the spawnTerminalAgent
windowsHide + stdio contract, and the window-derived-from-tick
arithmetic. terminal-agent-watchdog.test.ts test 4 now pins the
window/tick relationship instead of the fixed literal that let this
ship. Also converts `new URL(import.meta.url).pathname` to
`import.meta.path` across the static-grep tests it touches — the
pathname form yields /C:/... on Windows and breaks path.resolve.

Contributed by @SYKhayyat (PR #2414).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 20:20:53 -07:00

162 lines
6.3 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import {
readAgentRecord,
writeAgentRecord,
clearAgentRecord,
killAgentByRecord,
agentRecordPath,
type AgentRecord,
} from '../src/terminal-agent-control';
// REGRESSION TEST for the v1.44 PID-identity migration.
//
// Pre-v1.44, both `cli.ts` and `server.ts` killed the terminal-agent with
// `spawnSync('pkill', ['-f', 'terminal-agent\\.ts'])`. That command matches
// by argv regex — any process whose command line contains the string
// `terminal-agent.ts` got SIGTERM'd. In practice this killed:
//
// * sibling gstack sessions on the same host
// * editor processes (vim, code, less) that had the file open
// * any second gstack run on the host
//
// The v1.44 migration replaces both kill sites with identity-based PID kill
// against the record written at `<stateDir>/terminal-agent-pid` by the
// agent's own boot path. This test is the static-grep tripwire that prevents
// reintroducing the regex teardown anywhere in the source tree.
//
// Pattern mirrors browse/test/server-embedder-terminal-port.test.ts (Test 4)
// and browse/test/server-sanitize-surrogates.test.ts: read source files
// directly, assert an invariant on their contents.
const SRC_DIR = path.resolve(import.meta.path, '..', '..', 'src');
function readAllSourceFiles(): Array<{ file: string; content: string }> {
const out: Array<{ file: string; content: string }> = [];
for (const entry of fs.readdirSync(SRC_DIR)) {
if (!entry.endsWith('.ts')) continue;
const full = path.join(SRC_DIR, entry);
out.push({ file: entry, content: fs.readFileSync(full, 'utf-8') });
}
return out;
}
describe('terminal-agent PID identity (v1.44+)', () => {
test('1. no source file calls `pkill -f terminal-agent`', () => {
// The regex matches both `pkill -f terminal-agent\.ts` (escaped form
// used in spawnSync args) and `pkill -f terminal-agent.ts` (literal),
// since the dot is the only difference and both are footguns.
const offenders: string[] = [];
for (const { file, content } of readAllSourceFiles()) {
// Walk line by line so we can skip comments that mention the historical
// pattern (acceptable as documentation, not as code).
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!/pkill/.test(line)) continue;
if (!/terminal-agent/.test(line)) continue;
// Skip comment lines — historical mentions in JSDoc are fine.
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
offenders.push(`${file}:${i + 1}: ${trimmed}`);
}
}
expect(offenders).toEqual([]);
});
test('2. neither cli.ts nor server.ts calls spawnSync with pkill', () => {
// Tighter check — even if someone routes through a different code path,
// any spawnSync('pkill', ...) anywhere in src/ is the smell.
const offenders: string[] = [];
for (const { file, content } of readAllSourceFiles()) {
if (/spawnSync\s*\(\s*['"]pkill['"]/.test(content)) {
offenders.push(file);
}
}
expect(offenders).toEqual([]);
});
test('3. readAgentRecord round-trips writeAgentRecord', () => {
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gstack-pid-id-'));
try {
const record: AgentRecord = {
pid: 12345,
gen: 'test-gen-abcdef',
startedAt: Date.now(),
};
writeAgentRecord(tmpDir, record);
const read = readAgentRecord(tmpDir);
expect(read).toEqual(record);
expect(fs.existsSync(agentRecordPath(tmpDir))).toBe(true);
clearAgentRecord(tmpDir);
expect(readAgentRecord(tmpDir)).toBeNull();
expect(fs.existsSync(agentRecordPath(tmpDir))).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('4. readAgentRecord returns null on missing or malformed file', () => {
const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gstack-pid-id-'));
try {
// Missing.
expect(readAgentRecord(tmpDir)).toBeNull();
// Malformed: wrong type for pid.
fs.writeFileSync(agentRecordPath(tmpDir), JSON.stringify({ pid: 'not-a-number', gen: 'x', startedAt: 0 }));
expect(readAgentRecord(tmpDir)).toBeNull();
// Malformed: not JSON.
fs.writeFileSync(agentRecordPath(tmpDir), 'definitely not json');
expect(readAgentRecord(tmpDir)).toBeNull();
// Missing field.
fs.writeFileSync(agentRecordPath(tmpDir), JSON.stringify({ pid: 1, gen: 'x' }));
expect(readAgentRecord(tmpDir)).toBeNull();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('5. killAgentByRecord returns false for a dead PID and never throws', () => {
// PID 2147483646 is below Linux PID_MAX_LIMIT but way above macOS's
// typical max — no real process will ever hold it. isProcessAlive
// returns false; killAgentByRecord no-ops.
const record: AgentRecord = {
pid: 2147483646,
gen: 'sentinel',
startedAt: Date.now(),
};
const result = killAgentByRecord(record, 'SIGTERM');
expect(result).toBe(false);
});
test('6. killAgentByRecord skips the kill when isProcessAlive is false', () => {
// Guard via process.kill stub: confirm killAgentByRecord does NOT call
// process.kill with a non-zero signal when the PID is dead. This is the
// belt-and-suspenders defense against PID-reuse: even if isProcessAlive
// changes implementation, killAgentByRecord must validate liveness first.
const origKill = process.kill;
const kills: Array<[number, NodeJS.Signals | number]> = [];
(process as any).kill = ((pid: number, sig: NodeJS.Signals | number) => {
kills.push([pid, sig ?? 'SIGTERM']);
if (sig === 0) {
const err: any = new Error('ESRCH');
err.code = 'ESRCH';
throw err;
}
return true;
}) as any;
try {
const record: AgentRecord = { pid: 9999999, gen: 'x', startedAt: Date.now() };
killAgentByRecord(record, 'SIGTERM');
const terminations = kills.filter(([, s]) => s !== 0);
expect(terminations).toEqual([]);
} finally {
(process as any).kill = origKill;
}
});
});