Files
gstack/browse/test/sidepanel-reattach.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

94 lines
4.4 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// v1.44 Commit 3 — client-side re-attach loop.
//
// On unexpected WS close (anything other than clean 1000 / 4001 / 4404),
// the sidebar now silently posts /pty-session/reattach with backoff,
// opens a new WS with the fresh attachToken, writes RIS to xterm when
// the agent sends {type:"reattach-begin"}, then treats the next binary
// frame as the scrollback replay payload. Static-grep tripwires defend
// the load-bearing protocol invariants; live re-attach exercises belong
// in the e2e tier.
const TERMINAL_JS = path.resolve(
import.meta.path, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
);
describe('sidepanel re-attach loop (v1.44+ Commit 3)', () => {
test('1. STATE.RECONNECTING exists for the in-flight re-attach window', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
expect(src).toContain("RECONNECTING: 'reconnecting'");
});
test('2. backoff schedule matches the eng-review plan (1s/2s/4s/8s, 60s window)', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
expect(src).toContain('REATTACH_BACKOFF_MS = [1000, 2000, 4000, 8000]');
expect(src).toContain('REATTACH_WINDOW_MS = 60_000');
});
test('3. startReattachLoop posts /pty-session/reattach with sessionId', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
expect(src).toMatch(/function startReattachLoop\(prevSessionId\)/);
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
expect(block).toContain('/pty-session/reattach');
expect(block).toContain('sessionId: prevSessionId');
});
test('4. 410 Gone from re-attach short-circuits to ENDED (no retry loop)', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
// 410 = lease window expired. Retrying wouldn't help; fall through
// so the user clicks Restart for a fresh session.
expect(block).toContain('resp.status === 410');
expect(block).toContain('setState(STATE.ENDED)');
});
test('5. 401 from re-attach sticky-aborts auto-connect', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
const block = sliceBetween(src, 'function startReattachLoop', 'function openReattachWebSocket');
expect(block).toContain('resp.status === 401');
expect(block).toContain('autoConnectAborted = true');
});
test('6. openReattachWebSocket handles {type:"reattach-begin"} → RIS to xterm', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
const block = sliceBetween(src, 'function openReattachWebSocket', 'async function checkClaudeAvailable');
expect(block).toContain("msg.type === 'reattach-begin'");
// RIS (\x1bc) is the full-reset escape that clears xterm cleanly
// before the replay binary arrives.
expect(block).toContain("term.write('\\x1bc')");
expect(block).toContain('nextBinaryIsReplay = true');
});
test('7. live connect()/forceRestart() close handlers trigger re-attach on transient close', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
// Both the connect() and forceRestart() close handlers must route
// through startReattachLoop for non-clean codes. Count = 3
// (open-reattach close handler + connect close + forceRestart close).
const occurrences = (src.match(/startReattachLoop\(currentSessionId\)/g) || []).length;
expect(occurrences).toBeGreaterThanOrEqual(3);
});
test('8. clean codes (1000 / 4001 / 4404) bypass the re-attach loop', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
// The branch guard MUST exclude these codes from re-attach. 1000 =
// PTY exited (claude quit), 4001 = intentional restart, 4404 = no
// claude on PATH. Re-attaching in those cases would be wasted work
// (or actively wrong — a force-restart that re-attaches to its own
// pre-restart session is the bug we're avoiding).
expect(src).toContain('code === 1000');
expect(src).toContain('code === 4001');
expect(src).toContain('code === 4404');
});
});
function sliceBetween(source: string, start: string, end: string): string {
const i = source.indexOf(start);
if (i === -1) throw new Error(`marker not found: ${start}`);
const j = source.indexOf(end, i + start.length);
if (j === -1) throw new Error(`end marker not found: ${end}`);
return source.slice(i, j);
}