Files
gstack/browse/test/server-pty-lease-routes.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

95 lines
4.8 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// Server-side route shape for the v1.44 lease + restart + dispose +
// lease-refresh wiring. Live route exercises require the terminal-agent
// loopback to be live (e2e-tier); these static-grep tripwires pin the
// load-bearing protocol invariants.
const SERVER_TS = path.resolve(import.meta.path, '..', '..', 'src', 'server.ts');
describe('server: PTY lease routes (v1.44+ Commit 2)', () => {
test('1. /pty-session returns the 4-tuple shape (sessionId, attachToken, leaseExpiresAt)', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/pty-session' &&", "url.pathname === '/pty-session/reattach'");
expect(block).toContain('mintLease()');
expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)');
expect(block).toContain('sessionId: lease.sessionId');
expect(block).toContain('attachToken: minted.token');
expect(block).toContain('leaseExpiresAt: lease.expiresAt');
// Backward compat: legacy ptySessionToken alias preserved for one release.
expect(block).toContain('ptySessionToken: minted.token');
});
test('2. /pty-session/reattach validates lease + mints fresh attachToken', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/pty-session/reattach'", "url.pathname === '/pty-restart'");
// Validate-first: rejects unknown/expired sessionId with 410 Gone so
// the client knows to fall back to a fresh /pty-session.
expect(block).toContain('validateLease(sessionId)');
expect(block).toContain('status: 410');
// Mint fresh token bound to SAME sessionId.
expect(block).toContain('grantPtyToken(minted.token, sessionId!)');
});
test('3. /pty-restart is one transaction — dispose + revoke + fresh mint', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/pty-restart'", "url.pathname === '/pty-dispose'");
// Disposes old session (best-effort — missing sessionId is non-fatal).
expect(block).toContain('restartPtySession(oldSessionId)');
expect(block).toContain('revokeLease(oldSessionId)');
// Then mints fresh sessionId + lease + attachToken in the same handler.
expect(block).toContain('mintLease()');
expect(block).toContain('grantPtyToken(minted.token, lease.sessionId)');
// Returns the same 4-tuple shape so the client doesn't need a
// separate /pty-session round-trip.
expect(block).toContain('attachToken: minted.token');
expect(block).toContain('leaseExpiresAt: lease.expiresAt');
});
test('4. /pty-dispose accepts body-token (sendBeacon-compatible)', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/pty-dispose'", "url.pathname === '/internal/lease-refresh'");
// sendBeacon can't set custom headers, so the route MUST accept the
// auth token in the request body. Otherwise pagehide cleanup fails
// silently every time the user closes the browser.
expect(block).toContain('body?.authToken');
expect(block).toContain('authedByBody');
// Both auth paths must validate against authToken — never just trust
// a body-supplied token without the equality check.
expect(block).toContain('authTokenFromBody === authToken');
});
test('5. /internal/lease-refresh resets the daemon idle timer (T6)', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/internal/lease-refresh'", '─── /pty-inject-scan');
expect(block).toContain('refreshLease(sessionId)');
expect(block).toContain('resetIdleTimer()');
// Refresh failure (unknown / expired) MUST 410, not 200, so the
// agent knows to close the WS and force a clean re-auth.
expect(block).toContain('status: 410');
});
test('6. grantPtyToken loopback carries sessionId binding', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
expect(src).toMatch(/grantPtyToken\(token: string, sessionId\?: string\)/);
expect(src).toContain('sessionId ? { token, sessionId } : { token }');
});
test('7. restartPtySession helper exists and POSTs the agent /internal/restart', () => {
const src = fs.readFileSync(SERVER_TS, 'utf-8');
expect(src).toMatch(/async function restartPtySession\(sessionId: string\)/);
expect(src).toContain('/internal/restart');
expect(src).toContain('JSON.stringify({ sessionId })');
});
});
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);
}