mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-25 02:59:59 +02:00
b315ccb0d4
The agent side of Commit 3 — the "magic" feature. A network blip (wifi
hiccup, MV3 panel suspend, brief Chromium pause) now silently reconnects
the sidebar to the SAME claude session with scrollback intact. No more
"Session ended" message + manual Restart click + losing your tool-call
output. Server-side /pty-session/reattach (25ef24e9) and the extension
re-attach loop (next commit) close the loop end-to-end.
Ring buffer (T10):
* Per-session frames: Buffer[] capped at 1 MB (env-overridable via
GSTACK_PTY_RING_BUFFER_BYTES). Each PTY write is one frame, so
eviction is at frame boundaries and never cuts a UTF-8 sequence or
ANSI CSI in half.
* appendToRingBuffer eviction loop keeps at least one frame even at
extreme caps — a single oversized frame can't empty the buffer.
* Alt-screen tracking via canonical xterm CSI ?1049h / CSI ?1049l
sequences. lastIndexOf comparison so trailing state wins when both
appear in one render frame (quick tool-call open+close).
Replay payload (T5 — codex outside-voice):
* buildReplayPayload prefixes DECSTR soft reset (\x1b[!p) and
conditionally re-enters alt-screen if claude was in a tool call at
detach. The client writes RIS (\x1bc) FIRST to clear pre-blip xterm
content; the server's prelude resets character attributes; the ring
buffer replays cleanly on top.
* Order is enforced by the {type:"reattach-begin"} text frame the
agent sends right before the binary replay — client waits for it,
writes RIS, then treats the next binary frame as the replay payload.
Detach state machine (T9):
* PtySession.liveWs decouples the PTY callback from the original ws
closure. On re-attach, swapping session.liveWs is enough — the
on-data callback writes to the new ws automatically.
* close(ws, code, _reason): codes 4001 (intentional restart), 4404
(no-claude), and 1000 (clean exit) trigger immediate dispose.
Anything else (1006 abnormal, 1001 going-away from network blip /
panel suspend) starts a 60s detach timer instead. claude keeps
running, output keeps accumulating in the ring buffer.
* Detach timer is unref'd so the bun process can still exit cleanly
on natural shutdown.
* Sessions without a sessionId (legacy single-shot grants) can't
re-attach by definition — those fall through to immediate dispose.
Re-attach lookup (T9):
* WS open() checks sessionsById[sessionId] FIRST. If a detached
session is sitting there, cancel its detach timer, swap liveWs,
rebind the WS-keyed map, restart keepalive, send reattach-begin
+ replay payload. The PTY process is unchanged.
* /internal/restart now cancels any pending detach timer before
disposal — otherwise the timer would later try to dispose an
already-disposed session.
Env knobs for e2e:
* GSTACK_PTY_RING_BUFFER_BYTES — compress to 256 for eviction tests.
* GSTACK_PTY_DETACH_WINDOW_MS — compress to 1000 for "did the timer
fire?" tests without waiting a minute per assertion.
Tests:
* browse/test/terminal-agent-detach-reattach.test.ts — 10 static-grep
tripwires for the load-bearing properties: interface shape, env
knobs, eviction floor, alt-screen tracking, replay prelude
composition, re-attach lookup, close-code routing, detach timer
unref, /internal/restart timer cancellation, on-data through
session.liveWs.
* browse/test/terminal-agent-session-routing.test.ts test 7 widened
to match the new close(ws, code, _reason) signature.
* browse/test/terminal-agent-keepalive.test.ts test 3 widened
similarly. Both stay regressions for the prior contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
89 lines
4.2 KiB
TypeScript
89 lines
4.2 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
// v1.44 WS keepalive — static-grep invariants for the protocol contract.
|
|
//
|
|
// terminal-agent.ts and sidepanel-terminal.js cooperate on a 25s ping/pong +
|
|
// keepalive cycle so long-idle PTY connections survive NAT idle timeouts and
|
|
// Chromium's MV3 panel suspension heuristics. The wiring is invisible to
|
|
// integration tests (you'd have to wait 25s to observe a ping) but trivially
|
|
// regressed by a refactor. These tests fail CI if either side stops sending
|
|
// or stops accepting the protocol frames.
|
|
|
|
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
|
|
const CLIENT_JS = path.resolve(new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js');
|
|
|
|
describe('terminal-agent WS keepalive (v1.44+)', () => {
|
|
test('1. agent has a KEEPALIVE_INTERVAL_MS env knob, default 25000', () => {
|
|
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
expect(src).toContain('GSTACK_PTY_KEEPALIVE_INTERVAL_MS');
|
|
expect(src).toMatch(/KEEPALIVE_INTERVAL_MS\s*=\s*parseInt\(/);
|
|
// Default constant present so the env knob has a fallback.
|
|
expect(src).toContain("'25000'");
|
|
});
|
|
|
|
test('2. WS open handler starts a ping interval on the session', () => {
|
|
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
// The open(ws) handler in the websocket: { ... } block must call
|
|
// setInterval to drive the ping cadence and store the handle.
|
|
const wsBlock = sliceBetween(src, 'websocket: {', 'function handleTabState');
|
|
expect(wsBlock).toMatch(/open\s*\(\s*ws\s*\)/);
|
|
expect(wsBlock).toContain('setInterval');
|
|
expect(wsBlock).toContain("type: 'ping'");
|
|
expect(wsBlock).toContain('pingInterval');
|
|
});
|
|
|
|
test('3. WS close handler clears the ping interval', () => {
|
|
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
const wsBlock = sliceBetween(src, 'websocket: {', 'function handleTabState');
|
|
// close(ws, code?, reason?) MUST clearInterval the pingInterval —
|
|
// otherwise we leak timers across reconnects and the ping handler
|
|
// captures a dead ws ref. Signature widened in Commit 3 to include
|
|
// the close code for the detach state machine, hence the loose match.
|
|
expect(wsBlock).toMatch(/close\s*\(\s*ws/);
|
|
expect(wsBlock).toContain('clearInterval(session.pingInterval)');
|
|
});
|
|
|
|
test('4. message handler accepts pong / keepalive frames silently', () => {
|
|
const src = fs.readFileSync(AGENT_TS, 'utf-8');
|
|
// The text-frame router must recognize the keepalive vocabulary —
|
|
// if a future refactor strips this branch, unknown-text-frame
|
|
// suppression would still drop them but we lose intent.
|
|
expect(src).toMatch(/msg\?\.type === 'pong'/);
|
|
expect(src).toMatch(/msg\?\.type === 'keepalive'/);
|
|
});
|
|
|
|
test('5. client sends keepalive every 25s on ws.open', () => {
|
|
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
|
expect(src).toContain('keepaliveInterval');
|
|
expect(src).toMatch(/setInterval\(/);
|
|
expect(src).toContain("type: 'keepalive'");
|
|
expect(src).toContain('KEEPALIVE_INTERVAL_MS = 25000');
|
|
});
|
|
|
|
test('6. client replies pong to server ping', () => {
|
|
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
|
// The ws.message handler must short-circuit on msg.type === 'ping'
|
|
// and reply with {type: 'pong', ts: msg.ts}.
|
|
expect(src).toMatch(/msg\.type === 'ping'/);
|
|
expect(src).toMatch(/type: 'pong'/);
|
|
});
|
|
|
|
test('7. client clears keepalive in close + teardown + forceRestart', () => {
|
|
const src = fs.readFileSync(CLIENT_JS, 'utf-8');
|
|
// Three teardown paths exist; all three must drop the interval to
|
|
// avoid leaking timers across reconnect attempts.
|
|
const occurrences = (src.match(/clearInterval\(keepaliveInterval\)/g) || []).length;
|
|
expect(occurrences).toBeGreaterThanOrEqual(3);
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|