mirror of https://github.com/garrytan/gstack.git
feat(sidebar): silent re-attach with scrollback replay (Commit 3 client side)
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>
This commit is contained in:
parent
b315ccb0d4
commit
104652578b
|
|
@ -0,0 +1,93 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -42,7 +42,14 @@
|
|||
};
|
||||
|
||||
/** State machine. */
|
||||
const STATE = { IDLE: 'idle', CONNECTING: 'connecting', LIVE: 'live', ENDED: 'ended', NO_CLAUDE: 'no-claude' };
|
||||
const STATE = {
|
||||
IDLE: 'idle',
|
||||
CONNECTING: 'connecting',
|
||||
LIVE: 'live',
|
||||
ENDED: 'ended',
|
||||
NO_CLAUDE: 'no-claude',
|
||||
RECONNECTING: 'reconnecting', // v1.44 Commit 3 — re-attach loop active
|
||||
};
|
||||
let state = STATE.IDLE;
|
||||
|
||||
let term = null;
|
||||
|
|
@ -65,6 +72,28 @@
|
|||
* server can scope the disposal to one terminal rather than all.
|
||||
*/
|
||||
let currentSessionId = null;
|
||||
/**
|
||||
* Commit 3 re-attach loop. Set true while a re-attach is in flight so
|
||||
* concurrent ws.close events (e.g. user clicks Restart mid-reconnect)
|
||||
* can short-circuit. Reset by every state transition out of RECONNECTING.
|
||||
*/
|
||||
let reattachInFlight = false;
|
||||
/**
|
||||
* Set true after a {type:"reattach-begin"} text frame and reset after
|
||||
* the next binary frame is treated as replay payload. The flag is what
|
||||
* lets the message handler distinguish "this binary is the scrollback
|
||||
* replay, write RIS first to clear xterm" from "this is live PTY
|
||||
* output, just feed it through."
|
||||
*/
|
||||
let nextBinaryIsReplay = false;
|
||||
/**
|
||||
* Re-attach backoff schedule (ms). 1s, 2s, 4s, 8s, then 8s steady until
|
||||
* 60s total elapsed (Commit 3 detach window). If all attempts fail,
|
||||
* fall through to ENDED state and the user clicks Restart for a fresh
|
||||
* session.
|
||||
*/
|
||||
const REATTACH_BACKOFF_MS = [1000, 2000, 4000, 8000];
|
||||
const REATTACH_WINDOW_MS = 60_000;
|
||||
/**
|
||||
* 25s client-side WS keepalive interval (v1.44+). Belt-and-suspenders with
|
||||
* the server-side ping in terminal-agent.ts: server pings cover most
|
||||
|
|
@ -166,6 +195,187 @@
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit 3 — re-attach loop. Triggered by an unexpected WS close
|
||||
* (anything other than the v1.44 intentional codes) while state was
|
||||
* LIVE. Posts /pty-session/reattach with the current sessionId; on
|
||||
* success opens a new WS, feeds the {type:"reattach-begin"} +
|
||||
* replay-binary handshake from the agent into xterm.
|
||||
*
|
||||
* Backoff: 1s, 2s, 4s, 8s, then 8s steady. Total wall budget is the
|
||||
* server's DETACH_WINDOW_MS (default 60s) — past that point the
|
||||
* server has disposed our session and any re-attach attempt will
|
||||
* return 410 Gone.
|
||||
*
|
||||
* Aborts on:
|
||||
* - reattachInFlight transitions to false (user clicked Restart or
|
||||
* navigated away)
|
||||
* - 410 Gone from /pty-session/reattach (lease expired)
|
||||
* - 401 (auth invalid)
|
||||
* - REATTACH_WINDOW_MS elapsed
|
||||
*/
|
||||
function startReattachLoop(prevSessionId) {
|
||||
if (!prevSessionId) {
|
||||
setState(STATE.ENDED);
|
||||
return;
|
||||
}
|
||||
const serverPort = getServerPort();
|
||||
const authToken = getAuthToken();
|
||||
if (!serverPort || !authToken) {
|
||||
setState(STATE.ENDED);
|
||||
return;
|
||||
}
|
||||
reattachInFlight = true;
|
||||
setState(STATE.RECONNECTING);
|
||||
const startedAt = Date.now();
|
||||
let attempt = 0;
|
||||
|
||||
const tick = async () => {
|
||||
if (!reattachInFlight) return;
|
||||
if (Date.now() - startedAt > REATTACH_WINDOW_MS) {
|
||||
reattachInFlight = false;
|
||||
setState(STATE.ENDED);
|
||||
return;
|
||||
}
|
||||
attempt += 1;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(`http://127.0.0.1:${serverPort}/pty-session/reattach`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ sessionId: prevSessionId }),
|
||||
credentials: 'include',
|
||||
});
|
||||
} catch (err) {
|
||||
scheduleNextAttempt();
|
||||
return;
|
||||
}
|
||||
if (resp.status === 410) {
|
||||
// Server disposed the session — lease window closed. No point
|
||||
// retrying; fall through so the user clicks Restart for a fresh
|
||||
// session.
|
||||
reattachInFlight = false;
|
||||
setState(STATE.ENDED);
|
||||
return;
|
||||
}
|
||||
if (resp.status === 401) {
|
||||
reattachInFlight = false;
|
||||
autoConnectAborted = true;
|
||||
setState(STATE.IDLE, {
|
||||
message: 'Auth invalid — reload the sidebar or restart your gstack session.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
scheduleNextAttempt();
|
||||
return;
|
||||
}
|
||||
let body;
|
||||
try { body = await resp.json(); } catch { body = null; }
|
||||
if (!body || !body.terminalPort || !body.attachToken) {
|
||||
scheduleNextAttempt();
|
||||
return;
|
||||
}
|
||||
reattachInFlight = false;
|
||||
openReattachWebSocket(body.terminalPort, body.attachToken, body.sessionId || prevSessionId);
|
||||
};
|
||||
|
||||
const scheduleNextAttempt = () => {
|
||||
const backoffIdx = Math.min(attempt - 1, REATTACH_BACKOFF_MS.length - 1);
|
||||
const delay = REATTACH_BACKOFF_MS[backoffIdx] ?? 8000;
|
||||
setTimeout(tick, delay);
|
||||
};
|
||||
|
||||
tick();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the post-reattach WebSocket. Mostly a clone of connect()'s
|
||||
* attach wiring but with the {type:"reattach-begin"} → RIS → binary
|
||||
* replay handshake added. The xterm element is REUSED (not disposed) so
|
||||
* the buffer flash is minimal — RIS clears it cleanly just before the
|
||||
* replay arrives.
|
||||
*/
|
||||
function openReattachWebSocket(terminalPort, attachToken, sessionId) {
|
||||
currentSessionId = sessionId || null;
|
||||
try { window.gstackPtySession = currentSessionId; } catch {}
|
||||
setState(STATE.LIVE);
|
||||
ensureXterm();
|
||||
nextBinaryIsReplay = false;
|
||||
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`]);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
} catch {}
|
||||
if (keepaliveInterval) clearInterval(keepaliveInterval);
|
||||
keepaliveInterval = setInterval(() => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try { ws.send(JSON.stringify({ type: 'keepalive' })); } catch {}
|
||||
}, KEEPALIVE_INTERVAL_MS);
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
if (typeof ev.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === 'reattach-begin') {
|
||||
// Clear xterm before the replay binary arrives — RIS (\x1bc)
|
||||
// is a full hardware reset that flushes the buffer and
|
||||
// resets all attributes. The server's replay starts with
|
||||
// DECSTR + optional alt-screen re-enter for safety.
|
||||
try { term.write('\x1bc'); } catch {}
|
||||
nextBinaryIsReplay = true;
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'error' && msg.code === 'CLAUDE_NOT_FOUND') {
|
||||
setState(STATE.NO_CLAUDE);
|
||||
try { ws.close(); } catch {}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'ping') {
|
||||
try { ws.send(JSON.stringify({ type: 'pong', ts: msg.ts })); } catch {}
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data;
|
||||
// First binary frame after reattach-begin is the replay payload;
|
||||
// write it through unchanged (server already prefixed soft-reset).
|
||||
// Subsequent binary frames are live PTY output.
|
||||
term.write(buf);
|
||||
if (nextBinaryIsReplay) nextBinaryIsReplay = false;
|
||||
});
|
||||
|
||||
ws.addEventListener('close', (ev) => {
|
||||
ws = null;
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
keepaliveInterval = null;
|
||||
}
|
||||
// If THIS reattach WS also closes uncleanly, recurse into another
|
||||
// re-attach loop with the SAME sessionId — the server may still
|
||||
// be inside the detach window. The state check + sessionId guard
|
||||
// prevent runaway recursion (ENDED short-circuits the next loop).
|
||||
if (state !== STATE.LIVE) return;
|
||||
const code = (ev && (ev.code ?? 1006)) || 1006;
|
||||
const intentional = code === 1000 || code === 4001 || code === 4404;
|
||||
if (intentional || !currentSessionId) {
|
||||
setState(intentional ? STATE.ENDED : STATE.ENDED);
|
||||
return;
|
||||
}
|
||||
startReattachLoop(currentSessionId);
|
||||
});
|
||||
ws.addEventListener('error', (err) => {
|
||||
console.error('[gstack terminal] reattach ws error', err);
|
||||
});
|
||||
}
|
||||
|
||||
async function checkClaudeAvailable(terminalPort) {
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${terminalPort}/claude-available`, {
|
||||
|
|
@ -455,13 +665,26 @@
|
|||
term.write(buf);
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
ws.addEventListener('close', (ev) => {
|
||||
ws = null;
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
keepaliveInterval = null;
|
||||
}
|
||||
if (state !== STATE.NO_CLAUDE) setState(STATE.ENDED);
|
||||
if (state === STATE.NO_CLAUDE) return;
|
||||
// v1.44 Commit 3 — re-attach loop on transient close. Clean codes
|
||||
// (1000 = pty exited, 4001 = intentional restart, 4404 = no-claude)
|
||||
// skip the loop and fall through to ENDED. Any other code
|
||||
// (1006 abnormal, 1001 going-away) is a candidate for re-attach
|
||||
// within the 60s server-side detach window, provided we still
|
||||
// have a sessionId to match against.
|
||||
const code = (ev && (ev.code ?? 1006)) || 1006;
|
||||
const intentional = code === 1000 || code === 4001 || code === 4404;
|
||||
if (state === STATE.LIVE && !intentional && currentSessionId) {
|
||||
startReattachLoop(currentSessionId);
|
||||
return;
|
||||
}
|
||||
setState(STATE.ENDED);
|
||||
});
|
||||
|
||||
ws.addEventListener('error', (err) => {
|
||||
|
|
@ -648,13 +871,26 @@
|
|||
term.write(buf);
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
ws.addEventListener('close', (ev) => {
|
||||
ws = null;
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
keepaliveInterval = null;
|
||||
}
|
||||
if (state !== STATE.NO_CLAUDE) setState(STATE.ENDED);
|
||||
if (state === STATE.NO_CLAUDE) return;
|
||||
// v1.44 Commit 3 — re-attach loop on transient close. Clean codes
|
||||
// (1000 = pty exited, 4001 = intentional restart, 4404 = no-claude)
|
||||
// skip the loop and fall through to ENDED. Any other code
|
||||
// (1006 abnormal, 1001 going-away) is a candidate for re-attach
|
||||
// within the 60s server-side detach window, provided we still
|
||||
// have a sessionId to match against.
|
||||
const code = (ev && (ev.code ?? 1006)) || 1006;
|
||||
const intentional = code === 1000 || code === 4001 || code === 4404;
|
||||
if (state === STATE.LIVE && !intentional && currentSessionId) {
|
||||
startReattachLoop(currentSessionId);
|
||||
return;
|
||||
}
|
||||
setState(STATE.ENDED);
|
||||
});
|
||||
ws.addEventListener('error', (err) => {
|
||||
console.error('[gstack terminal] ws error', err);
|
||||
|
|
|
|||
Loading…
Reference in New Issue