mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-26 19:49:57 +02:00
104652578b
Closes the v1.44 long-lived-sidebar loop end-to-end. When the WS dies for
a transient reason (wifi blip, MV3 panel suspend, brief Chromium pause),
the sidebar now silently re-attaches to the SAME claude session inside the
server's 60s detach window. Scrollback replays cleanly; the user keeps
typing without noticing anything happened.
State machine:
* New STATE.RECONNECTING covers the in-flight re-attach window.
setState transitions out of this state reset reattachInFlight so a
concurrent user action (Restart click, panel navigate) short-circuits
cleanly.
* Backoff schedule REATTACH_BACKOFF_MS = [1000, 2000, 4000, 8000] then
8s steady until REATTACH_WINDOW_MS (60s) elapses. Past that point
the server has disposed our session and /pty-session/reattach
returns 410 Gone.
startReattachLoop(prevSessionId):
* Posts /pty-session/reattach with sessionId.
* On 200 with a valid 4-tuple, opens the post-reattach WS directly.
* On 410 (lease expired) — short-circuits to ENDED. No retry; the user
clicks Restart for a fresh session.
* On 401 — sticky-aborts the auto-connect loop. Same defense as 25ef24e9
so we don't spam "Auth invalid" every 2s.
* On network failure or other non-OK status — schedules the next
backoff tick.
openReattachWebSocket(terminalPort, attachToken, sessionId):
* Mostly a clone of connect()'s attach wiring. Reuses the live xterm
element — RIS clears the buffer cleanly when the agent's
{type:"reattach-begin"} arrives, so the visual flash is minimal.
* Handshake: on `{type:"reattach-begin"}` text frame → write `\x1bc`
(RIS) to xterm + set nextBinaryIsReplay = true. The next binary
frame IS the server-built replay payload (DECSTR soft-reset prefix
+ optional alt-screen re-enter + ring buffer contents).
* If THIS reattach WS also dies uncleanly, recurses into another
re-attach loop with the same sessionId — the server's detach window
may still be open. State guard prevents runaway recursion.
connect() + forceRestart() close handlers (existing):
* Both updated to call startReattachLoop on transient close codes
(anything other than 1000 / 4001 / 4404). Was just setState(ENDED).
* Clean codes still bypass — re-attaching to a force-restart's
pre-restart session would be the bug we're avoiding.
Test (browse/test/sidepanel-reattach.test.ts):
* 8 static-grep tripwires for the load-bearing properties: state
constant, backoff schedule, /pty-session/reattach wiring, 410
short-circuit (no retry past lease window), 401 sticky-abort,
reattach-begin → RIS handshake, all three close handlers route
through the loop, clean-code bypass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
94 lines
4.5 KiB
TypeScript
94 lines
4.5 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(
|
|
new URL(import.meta.url).pathname, '..', '..', '..', '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);
|
|
}
|