keep terminals alive when officer restarts under them

the pty sidecar registered `term.onData` with the socket that happened to be live
when the session was created. officer is a pm2 peer that restarts constantly, and
every restart hands this process a brand new socket, so every pre-existing session
went on writing to a closed one — where sendJson's readyState check dropped it
silently. the shell survived and still accepted input, because input arrives on the
new socket, but nothing ever came back. you typed and the terminal sat there. the
only way out was to close the panel, which orphaned the shell.

sendJson now reads the module-level socket at send time instead of taking one as an
argument, so there is no socket to capture and go stale. that is the whole fix.

the scrollback replay on re-attach becomes its own event, pty:replay -> 'replay'
on the browser socket. it used to arrive as ordinary output, which was fine for a
page load (fresh xterm) but not for a restart: the browser keeps its terminal, so
replaying blind printed a second copy of everything still on screen. marked as
history, Terminal.tsx resets and rebuilds from the sidecar's 50KB buffer instead.
it also stays out of the `output` branch so it cannot re-trigger the command /
initial-input logic that scrapes output for a sentinel.

added an integration test, because this is a reconnect bug and nothing short of an
actual reconnect proves it: it stands up a fake registration socket, runs the real
sidecar against it, echoes into a real shell, kills the socket, rebinds the same
port the way pm2 does, and asserts output still flows. verified it fails against
the old sendJson (times out after 15s waiting for the post-restart echo) and
passes in ~400ms with the fix. it never touches the running officer — the sidecar
dials API_URL, overridden per spawn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 04:59:06 +00:00
co-authored by Claude Opus 4.8
parent b27dd7512b
commit b92e8dffad
5 changed files with 186 additions and 36 deletions
@@ -0,0 +1,137 @@
import { describe, test, expect, afterAll } from 'bun:test';
import type { ServerWebSocket, Subprocess } from 'bun';
// Integration test for the one thing about the pty sidecar that cannot be reasoned about from the code
// alone: what happens to a live shell when officer goes away and comes back. It stands up a fake
// registration socket, runs the real sidecar against it, then kills the socket and rebinds the same port
// the way `pm2 restart officer` does.
//
// Nothing here touches the running officer — the sidecar dials API_URL, which is overridden per spawn.
const SIDECAR = 'src/servers/api/terminal/pty-sidecar.mjs';
type Frame = Record<string, any>;
// One fake officer. `stop()` drops the socket; a new instance on the same port is the restart.
function fakeOfficer(port?: number) {
let socket: ServerWebSocket<unknown> | null = null;
const frames: Frame[] = [];
const waiters: Array<{ match: (f: Frame) => boolean; resolve: (f: Frame) => void }> = [];
const server = Bun.serve({
port: port ?? 0,
fetch(req, srv) {
if (new URL(req.url).pathname === '/api/sidecar/register' && srv.upgrade(req)) return undefined;
return new Response('no', { status: 404 });
},
websocket: {
open(ws) {
socket = ws;
},
message(_ws, raw) {
const frame = JSON.parse(String(raw)) as Frame;
frames.push(frame);
for (let i = waiters.length - 1; i >= 0; i--) {
if (waiters[i]!.match(frame)) waiters.splice(i, 1)[0]!.resolve(frame);
}
},
close() {
socket = null;
},
},
});
return {
port: server.port,
send: (msg: Frame) => socket?.send(JSON.stringify(msg)),
/** Resolve on the first frame matching `match`, including ones already received. */
await: (match: (f: Frame) => boolean, timeoutMs = 15_000) =>
new Promise<Frame>((resolve, reject) => {
const seen = frames.find(match);
if (seen) return resolve(seen);
const timer = setTimeout(() => reject(new Error(`timed out waiting for a frame`)), timeoutMs);
waiters.push({
match,
resolve: (f) => {
clearTimeout(timer);
resolve(f);
},
});
}),
/** Output frames for one session, concatenated — the terminal's visible text. */
outputFor: (sessionId: string) =>
frames
.filter((f) => f.type === 'pty:output' && f.sessionId === sessionId)
.map((f) => f.data as string)
.join(''),
stop: () => server.stop(true),
};
}
const isOutput = (sessionId: string, needle: string) => (f: Frame) =>
f.type === 'pty:output' && f.sessionId === sessionId && String(f.data).includes(needle);
let child: Subprocess | null = null;
afterAll(() => child?.kill());
describe('pty sidecar', () => {
test('a shell keeps streaming output after officer restarts under it', async () => {
const sessionId = 'test-restart';
let officer = fakeOfficer();
const port = officer.port;
child = Bun.spawn(['node', SIDECAR], {
env: { ...process.env, API_URL: `ws://127.0.0.1:${port}` },
stdout: 'ignore',
stderr: 'ignore',
});
await officer.await((f) => f.type === 'register' && f.capabilities?.includes('terminal'));
// A plain non-interactive shell: no rc files, no prompt, so the assertions below are about output
// the test itself caused.
officer.send({
type: 'pty:init',
id: 'i1',
sessionId,
config: {
sessionId,
host: true,
shell: { command: '/bin/sh', args: [] },
cwd: '/tmp',
homeDir: '/tmp',
cols: 80,
rows: 24,
},
});
await officer.await((f) => f.type === 'pty:ready' && f.sessionId === sessionId);
officer.send({ type: 'pty:input', id: 'in1', sessionId, data: 'echo before-restart\n' });
await officer.await(isOutput(sessionId, 'before-restart'));
// ── the restart ──
officer.stop();
officer = fakeOfficer(port);
await officer.await((f) => f.type === 'register');
// The shell is the same process; only officer changed. Before the sendJson fix this input was
// accepted and executed, but its output went to the socket captured at init time and vanished.
officer.send({ type: 'pty:input', id: 'in2', sessionId, data: 'echo after-restart\n' });
await officer.await(isOutput(sessionId, 'after-restart'));
// Re-attaching replays the scrollback, marked as history so the client can rebuild rather than
// append — and it contains what happened on both sides of the restart.
officer.send({ type: 'pty:init', id: 'i2', sessionId, config: { sessionId, host: true, cols: 80, rows: 24 } });
const replay = await officer.await((f) => f.type === 'pty:replay' && f.sessionId === sessionId);
expect(String(replay.data)).toContain('before-restart');
expect(String(replay.data)).toContain('after-restart');
// Re-attach must not spawn a second shell, and must not replay as ordinary output.
await officer.await((f) => f.type === 'pty:ready' && f.id === 'i2');
expect(officer.outputFor(sessionId)).not.toContain('before-restart');
officer.send({ type: 'pty:close', id: 'c1', sessionId });
await officer.await((f) => f.type === 'pty:exit' && f.sessionId === sessionId);
officer.stop();
}, 30_000);
});