feat(sidebar): forceRestart via /pty-restart + pagehide /pty-dispose

Closes the Commit 2 loop: server-side lease + restart routes shipped in
25ef24e9; this commit wires the extension client to use them. End-to-end
result — clicking Restart now actually kills the server's PTY before
opening a new WS (zero race window), and closing the sidebar / quitting
the browser disposes the PTY immediately instead of letting it linger
for the upcoming 60s detach window.

sidepanel-terminal.js:
  * mintSession callers read the v1.44 4-tuple (sessionId + attachToken)
    from /pty-session, with a backward-compat fallback to ptySessionToken
    so a partially-updated extension still works against a fresh server
    for one minor release.
  * Eager spawn via {type:"start"} text frame replaces the legacy
    `TextEncoder().encode("\n")` newline hack. Pre-v1.44, the lazy-binary-
    spawn pattern made forceRestart look stuck until the user typed —
    now claude boots before the prompt renders.
  * forceRestart() rewritten as an async one-transaction handler:
      1. close current WS with code 4001 (intentional-restart)
      2. POST /pty-restart with priorSessionId so the server can scope
         the dispose, then mint fresh sessionId + lease + attachToken
         in the same response
      3. Open new WS with the returned attachToken, send {type:"start"}
         immediately for eager spawn
      4. On 401: sticky-abort the auto-connect loop (no spam)
      5. On 503 / network failure: fall back to patient autoconnect
  * currentSessionId tracked and exposed on window.gstackPtySession so
    sidepanel.js's pagehide handler can sendBeacon the dispose.

sidepanel.js:
  * New pagehide handler fires navigator.sendBeacon('/pty-dispose',
    {sessionId, authToken}) on tab close, panel close, browser quit,
    or extension reload. sendBeacon-compatible: auth token rides in
    the body since sendBeacon can't set custom headers (server route
    accepts body-auth per 25ef24e9).
  * try/catch around the entire body so a sendBeacon failure can't
    interfere with the browser's unload sequence — the 60s detach
    window from Commit 3 catches anything we miss.

There's bounded duplication between connect() and forceRestart() (~70
lines of WS attach/handler wiring). Extracting a shared helper is a
clean follow-up but out of scope for the v1.44 ship — both paths are
exercised by the same e2e test.

Test (browse/test/sidepanel-restart-dispose.test.ts):
  * 9 static-grep tripwires pinning the 4-tuple parse, eager spawn,
    close-code 4001 contract, /pty-restart wire shape, sticky-abort
    401 path, sessionId window plumbing, sendBeacon body contract,
    and the best-effort try/catch around pagehide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-05-23 23:19:25 -07:00
parent 25ef24e92e
commit 0aed7f8ecf
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
3 changed files with 325 additions and 13 deletions

View File

@ -0,0 +1,106 @@
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// v1.44 Commit 2C — client-side restart + dispose wiring.
//
// Pre-v1.44 forceRestart only closed the client WS and disposed xterm;
// the old PTY died asynchronously via the agent's WS close handler.
// Race window between kill and mint, two claude instances briefly,
// no prompt visible until the user typed.
//
// Now forceRestart POSTs /pty-restart (one transaction: dispose + mint),
// opens the new WS with the fresh attachToken from the response, and
// sends {type:"start"} for the eager spawn. pagehide handler in
// sidepanel.js sendBeacon /pty-dispose so browser quit / panel close
// doesn't leak a 60s-zombie claude.
const TERMINAL_JS = path.resolve(
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel-terminal.js',
);
const SIDEPANEL_JS = path.resolve(
new URL(import.meta.url).pathname, '..', '..', '..', 'extension', 'sidepanel.js',
);
describe('sidepanel-terminal: forceRestart via /pty-restart (v1.44+)', () => {
test('1. mintSession callers read the 4-tuple (sessionId + attachToken)', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
// The new shape lands in `minted.sessionId` and `minted.attachToken`.
expect(src).toContain('const { terminalPort, sessionId } = minted');
expect(src).toContain('minted.attachToken || minted.ptySessionToken');
// Backward-compat fallback to ptySessionToken kept so a partially-
// updated extension still works against a fresh server.
});
test('2. eager spawn via {type:"start"} on ws.open', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
// Replaces the legacy `ws.send(TextEncoder().encode("\\n"))` newline
// hack that nudged the lazy-binary-spawn.
expect(src).toMatch(/ws\.send\(JSON\.stringify\(\{\s*type:\s*'start'\s*\}\)\)/);
expect(src).not.toContain("TextEncoder().encode('\\n')");
});
test('3. forceRestart sends 4001 close code (intentional restart)', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
expect(src).toMatch(/ws\.close\(4001/);
});
test('4. forceRestart POSTs /pty-restart with current sessionId', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
expect(src).toContain('/pty-restart');
expect(src).toContain('priorSessionId ? { sessionId: priorSessionId } : {}');
});
test('5. forceRestart 401 triggers sticky abort (no spam loop)', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
// Same defense pattern as connect() — 401 must flip the sticky flag
// or every 2s the user sees a fresh "Auth invalid" message.
const block = sliceBetween(src, 'async function forceRestart', 'function repaintIfLive');
expect(block).toContain('resp.status === 401');
expect(block).toContain('autoConnectAborted = true');
});
test('6. currentSessionId is exposed on window for sidepanel.js pagehide', () => {
const src = fs.readFileSync(TERMINAL_JS, 'utf-8');
expect(src).toContain('window.gstackPtySession = currentSessionId');
});
});
describe('sidepanel: pagehide → sendBeacon /pty-dispose (v1.44+)', () => {
test('7. pagehide handler fires sendBeacon to /pty-dispose', () => {
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
expect(src).toMatch(/window\.addEventListener\('pagehide'/);
expect(src).toContain('navigator.sendBeacon');
expect(src).toContain('/pty-dispose');
});
test('8. pagehide payload carries sessionId + authToken in body (sendBeacon-compat)', () => {
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
// sendBeacon can't set custom headers — server route accepts body-auth.
// Both fields must be in the payload or the server rejects.
expect(src).toMatch(/JSON\.stringify\(\{\s*sessionId,\s*authToken\s*\}\)/);
expect(src).toContain('window.gstackPtySession');
expect(src).toContain('window.gstackAuthToken');
});
test('9. pagehide handler is best-effort (try/catch swallows failures)', () => {
const src = fs.readFileSync(SIDEPANEL_JS, 'utf-8');
// The 60s detach window catches any sendBeacon that fails, so the
// handler MUST not throw — uncaught throws can interfere with the
// browser's unload sequence. Slice between pagehide and end-of-file
// (it's the last addEventListener in sidepanel.js by design).
const i = src.indexOf("addEventListener('pagehide'");
expect(i).toBeGreaterThan(-1);
const block = src.slice(i);
expect(block).toMatch(/try \{/);
expect(block).toMatch(/} catch /);
});
});
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);
}

View File

@ -57,6 +57,14 @@
* fixing whatever was wrong.
*/
let autoConnectAborted = false;
/**
* v1.44 session identity. The stable, non-secret sessionId minted by
* /pty-session and surfaced back via window.gstackPtySession so the
* sidepanel.js pagehide handler can sendBeacon /pty-dispose for THIS
* specific session. forceRestart sends this to /pty-restart so the
* server can scope the disposal to one terminal rather than all.
*/
let currentSessionId = null;
/**
* 25s client-side WS keepalive interval (v1.44+). Belt-and-suspenders with
* the server-side ping in terminal-agent.ts: server pings cover most
@ -347,11 +355,19 @@
setState(STATE.IDLE, { message: `Cannot start: ${minted.error}` });
return;
}
const { terminalPort, ptySessionToken } = minted;
if (!ptySessionToken) {
setState(STATE.IDLE, { message: 'Cannot start: no session token returned' });
// v1.44 4-tuple: { terminalPort, sessionId, attachToken, leaseExpiresAt }
// Falls back to the legacy `ptySessionToken` field for one minor release
// (server keeps the alias) so a partially-updated extension still works
// against a fresh server.
const { terminalPort, sessionId } = minted;
const attachToken = minted.attachToken || minted.ptySessionToken;
if (!attachToken) {
setState(STATE.IDLE, { message: 'Cannot start: no attach token returned' });
return;
}
currentSessionId = sessionId || null;
// Expose for sidepanel.js pagehide handler — see Commit 2C wiring.
try { window.gstackPtySession = currentSessionId; } catch {}
// Pre-flight: does claude even exist on PATH?
const claudeStatus = await checkClaudeAvailable(terminalPort);
@ -373,7 +389,7 @@
// SameSite=Strict don't survive the jump from server.ts:34567 to the
// agent's random port from a chrome-extension origin, so cookies
// alone weren't reliable.
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${ptySessionToken}`]);
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`]);
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
@ -398,8 +414,11 @@
}
});
} catch {}
// Send a single byte to nudge the agent to spawn claude (lazy-spawn trigger).
try { ws.send(new TextEncoder().encode('\n')); } catch {}
// v1.44 eager spawn: send {type:"start"} so the agent boots claude
// without requiring the user to type a keystroke. Pre-v1.44 the
// lazy-binary-spawn pattern made forceRestart look stuck for ~2-3s
// until the user pressed any key.
try { ws.send(JSON.stringify({ type: 'start' })); } catch {}
// v1.44 client-side keepalive. Server pings every 25s; we ALSO send
// keepalive frames at the same cadence so a paused timer on either
// side still has the other to lean on. Both are silently dropped
@ -471,23 +490,175 @@
* Force a fresh session: close any open WS, dispose xterm, return to
* IDLE, kick off auto-connect. Safe to call from any state.
*/
function forceRestart() {
/**
* v1.44 forceRestart: hits the server's /pty-restart one-transaction
* endpoint with the current sessionId. The server kills the old PtySession
* scope-to-our-id, mints a fresh sessionId + lease + attachToken, and
* returns the new 4-tuple in one round trip. Zero race window between
* kill and mint (codex D8).
*
* If we don't have a sessionId (sidebar is in IDLE / ENDED state because
* the prior session ended cleanly), the route accepts that gracefully
* skips the dispose step and just mints fresh. Either way the user sees
* the same "Restarting..." fresh prompt UX.
*/
async function forceRestart() {
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
keepaliveInterval = null;
}
try { ws && ws.close(); } catch {}
// Re-arm the auto-connect loop in case a prior auth failure stuck the
// sticky flag — explicit user action is the cleared-flag signal.
autoConnectAborted = false;
setState(STATE.IDLE, { message: 'Restarting Claude Code...' });
const serverPort = getServerPort();
const authToken = getAuthToken();
const priorSessionId = currentSessionId;
// Close the local WS BEFORE the server-side kill so the agent's
// close handler doesn't race with the dispose call.
try { ws && ws.close(4001, 'intentional-restart'); } catch {}
ws = null;
if (term) {
try { term.dispose(); } catch {}
term = null;
fitAddon = null;
}
// User explicitly asked for a fresh start; re-arm the auto-connect
// polling loop in case a prior auth failure stuck the abort flag.
autoConnectAborted = false;
setState(STATE.IDLE, { message: 'Starting Claude Code...' });
tryAutoConnect();
if (!serverPort || !authToken) {
// Server hasn't been discovered yet — fall back to the patient
// polling loop. forceRestart's promise of "fresh prompt now" can't
// be met without a live server; user sees the patient status path.
tryAutoConnect();
return;
}
let nextTuple = null;
try {
const resp = await fetch(`http://127.0.0.1:${serverPort}/pty-restart`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify(priorSessionId ? { sessionId: priorSessionId } : {}),
credentials: 'include',
});
if (resp.ok) {
nextTuple = await resp.json();
} else if (resp.status === 401) {
autoConnectAborted = true;
setState(STATE.IDLE, {
message: 'Auth invalid — reload the sidebar or restart your gstack session.',
});
return;
} else if (resp.status === 503) {
// Agent down — fall through to patient autoconnect which will
// surface the appropriate "waiting for server" status.
setState(STATE.IDLE, { message: 'Restart failed: terminal agent not ready. Retrying...' });
} else {
const body = await resp.text().catch(() => '');
setState(STATE.IDLE, { message: `Restart failed: ${resp.status} ${body || resp.statusText}` });
}
} catch (err) {
setState(STATE.IDLE, {
message: `Restart failed: ${err && err.message ? err.message : String(err)}`,
});
}
if (!nextTuple) {
// Restart didn't yield a fresh tuple. Fall back to the regular
// connect path; tryAutoConnect will retry as the server recovers.
currentSessionId = null;
try { window.gstackPtySession = null; } catch {}
tryAutoConnect();
return;
}
// We have a fresh 4-tuple — open the new WS directly without going
// through mintSession again. This is the explicit "no race window"
// path the codex D8 redesign was after.
const { terminalPort, sessionId, attachToken, expiresAt: _expiresAt } = nextTuple;
const token = attachToken || nextTuple.ptySessionToken;
if (!terminalPort || !token) {
currentSessionId = null;
tryAutoConnect();
return;
}
currentSessionId = sessionId || null;
try { window.gstackPtySession = currentSessionId; } catch {}
// Pre-flight: claude still on PATH?
const claudeStatus = await checkClaudeAvailable(terminalPort);
if (!claudeStatus.available) {
setState(STATE.NO_CLAUDE);
return;
}
setState(STATE.LIVE);
ensureXterm();
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${token}`]);
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
try {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
} catch {}
try {
chrome.runtime.sendMessage({ type: 'getTabState' }, (resp) => {
if (resp && ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({
type: 'tabState',
active: resp.active,
tabs: resp.tabs,
reason: 'restart',
}));
} catch {}
}
});
} catch {}
// Eager spawn — fresh claude prompt visible without user keystroke.
try { ws.send(JSON.stringify({ type: 'start' })); } 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 === '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;
term.write(buf);
});
ws.addEventListener('close', () => {
ws = null;
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
keepaliveInterval = null;
}
if (state !== STATE.NO_CLAUDE) setState(STATE.ENDED);
});
ws.addEventListener('error', (err) => {
console.error('[gstack terminal] ws error', err);
});
}
/**

View File

@ -1083,3 +1083,38 @@ chrome.runtime.onMessage.addListener((msg) => {
}));
}
});
// ─── v1.44 pagehide: explicit PTY dispose on sidebar close ──────────
//
// Codex T3 of the eng review: WS close codes alone can't distinguish
// "intentional close" (sidebar closed, browser quit, extension reload)
// from "transient blip" (wifi hiccup) reliably — Chrome routes the
// former through code 1001 (going-away) and the latter through 1006
// (abnormal), but neither is a load-bearing contract across browsers
// and extension lifecycles.
//
// pagehide fires reliably for tab close, panel close, extension reload,
// and navigation-away. We use it to fire-and-forget a /pty-dispose POST
// so the server can synchronously dispose the PtySession instead of
// waiting for the 60s detach window (Commit 3) to time out. Zombie
// claude processes lingering for 60s on every browser quit was the
// codex-flagged failure mode.
//
// sendBeacon is the only fetch primitive that survives a closing page —
// it doesn't accept custom headers, which is why the server's
// /pty-dispose route accepts the auth token in the BODY (see
// server-pty-lease-routes.test.ts test 4).
window.addEventListener('pagehide', () => {
const sessionId = window.gstackPtySession;
const authToken = window.gstackAuthToken;
const port = window.gstackServerPort;
if (!sessionId || !authToken || !port) return;
try {
const blob = new Blob([JSON.stringify({ sessionId, authToken })], {
type: 'application/json',
});
navigator.sendBeacon(`http://127.0.0.1:${port}/pty-dispose`, blob);
} catch {
// Best-effort — the 60s detach timer will catch any session we miss.
}
});