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:
@@ -41,9 +41,17 @@ const sessions = new Map();
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
const sendJson = (ws, msg) => {
|
||||
// Always resolve the CURRENT registration socket, never one captured in a closure.
|
||||
//
|
||||
// `term.onData` used to close over the socket that was live when the session was created. Officer is a
|
||||
// PM2 peer that restarts often, and each restart gives this process a brand new socket — so every
|
||||
// pre-existing session went on writing to a closed one, where the readyState check below dropped it
|
||||
// silently. The shell stayed alive and kept accepting input (that arrives on the new socket), but its
|
||||
// output never came back: the terminal looked frozen until you closed the panel. Reading the module
|
||||
// variable at send time is the whole fix.
|
||||
const sendJson = (msg) => {
|
||||
try {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
} catch {
|
||||
@@ -86,7 +94,7 @@ const appendBuffer = (session, data) => {
|
||||
|
||||
// ── Command handler ──
|
||||
|
||||
async function handleCommand(ws, msg) {
|
||||
async function handleCommand(msg) {
|
||||
switch (msg.type) {
|
||||
case 'pty:init': {
|
||||
const { sessionId, config } = msg;
|
||||
@@ -96,9 +104,12 @@ async function handleCommand(ws, msg) {
|
||||
console.log(`[pty-sidecar] init sessionId=${sessionId} existing=${!!existing} total=${sessions.size}`);
|
||||
|
||||
if (existing) {
|
||||
// Replay buffer
|
||||
// Re-attach. The scrollback goes out as `pty:replay`, not as ordinary output, because the client
|
||||
// may already be showing some of it: after an officer restart the browser keeps its terminal and
|
||||
// reconnects, so replaying blind appended a second copy of everything on screen. Marked as
|
||||
// history, the client can reset and rebuild from it instead.
|
||||
if (existing.buffer.length > 0) {
|
||||
sendJson(ws, { type: 'pty:output', sessionId, data: existing.buffer });
|
||||
sendJson({ type: 'pty:replay', sessionId, data: existing.buffer });
|
||||
}
|
||||
|
||||
// Resize PTY to new client dimensions
|
||||
@@ -114,7 +125,7 @@ async function handleCommand(ws, msg) {
|
||||
}
|
||||
}
|
||||
|
||||
sendJson(ws, { type: 'pty:ready', id: msg.id, sessionId });
|
||||
sendJson({ type: 'pty:ready', id: msg.id, sessionId });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -163,8 +174,8 @@ async function handleCommand(ws, msg) {
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to start terminal';
|
||||
sendJson(ws, { type: 'pty:output', sessionId, data: `\r\n[Terminal error] ${message}\r\n` });
|
||||
sendJson(ws, { type: 'pty:exit', sessionId, exitCode: 1 });
|
||||
sendJson({ type: 'pty:output', sessionId, data: `\r\n[Terminal error] ${message}\r\n` });
|
||||
sendJson({ type: 'pty:exit', sessionId, exitCode: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,16 +190,16 @@ async function handleCommand(ws, msg) {
|
||||
|
||||
term.onData((output) => {
|
||||
appendBuffer(session, output);
|
||||
sendJson(ws, { type: 'pty:output', sessionId, data: output });
|
||||
sendJson({ type: 'pty:output', sessionId, data: output });
|
||||
});
|
||||
|
||||
term.onExit(({ exitCode, signal }) => {
|
||||
console.log(`[pty-sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`);
|
||||
sendJson(ws, { type: 'pty:exit', sessionId, exitCode, signal });
|
||||
sendJson({ type: 'pty:exit', sessionId, exitCode, signal });
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
sendJson(ws, { type: 'pty:ready', id: msg.id, sessionId });
|
||||
sendJson({ type: 'pty:ready', id: msg.id, sessionId });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,7 +262,7 @@ function connect() {
|
||||
ws.on('open', () => {
|
||||
reconnectAttempt = 0;
|
||||
console.log('[pty-sidecar] connected, sending registration...');
|
||||
sendJson(ws, { type: 'register', name: 'pty', capabilities: ['terminal'] });
|
||||
sendJson({ type: 'register', name: 'pty', capabilities: ['terminal'] });
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
@@ -263,7 +274,7 @@ function connect() {
|
||||
return;
|
||||
}
|
||||
|
||||
handleCommand(ws, msg);
|
||||
handleCommand(msg);
|
||||
} catch {
|
||||
// skip malformed messages
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user