Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## Unreleased

### A terminal pane that loses its socket now reconnects when you touch it

- **Typing into a dead pane was a silent drain.** `term.onData` guarded on
`readyState === 1` and did nothing else, so every keystroke vanished with no
reconnect and no sign anything was wrong. xterm renders locally, so the pane looked
alive — that is the "terminal freezes until I reload the browser" report.
- **Paste and the send line named the problem and did nothing about it.** Both toasted
"Terminal is not connected" and returned, on the two controls that exist precisely
because a pane has gone unresponsive. All three paths now revive the socket and
carry the frame through, so the first thing you type is not the thing that is lost.
- **A terminal socket failure used to leave no trace.** `ws.onclose` took no argument
(the close code was discarded) and there was no `onerror` at all. Both exist now, so
the next occurrence says whether it was an abnormal close, an oversized frame or a
server error instead of looking identical to every other cause.

Note: this fixes what the UI does about a dropped terminal socket. What drops it in
the first place is not yet identified — the close code added here is what will name it.

## 7.9.0

A remote chat can no longer get stuck for good, a broken local Claude CLI install
Expand Down
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,22 @@ Four reports that all reduce to "the UI moved or stopped listening". Pinned by
panels animate `width .25s`, so measuring from the click measures the box the user
is leaving. The observer compares `clientWidth` before re-measuring — the callback
sets the height, so an unguarded one re-enters forever.
- **Touching a pane is itself a reconnect trigger (`_reviveTerminal`).** Every
automatic path is conditional on a state a half-dead socket does not report, so all
three interaction paths used to give up instead: `term.onData` dropped the keystroke
SILENTLY (the pane renders locally, so it looks alive — this is the "terminal froze
until I reloaded the page" report), while paste and the send line toasted "not
connected" and returned, naming the problem on the very controls that exist because
a pane went unresponsive. They now revive and carry the frame. Only the LAST frame
is queued — a queue of stale keystrokes replayed into a TUI is worse than a dropped
one — a socket already CONNECTING is not raced with a second one, and `_pending` is
cleared BEFORE the send so a failure cannot replay it on the next open too.
- **A close code is the only evidence of WHY a pane died.** `ws.onclose` took no
argument and there was no `onerror` at all, so a terminal socket failure left no
trace on either end — which is why "it freezes sometimes" stayed unexplained. Both
exist now; the log sits AFTER the staleness guard, because a superseded socket
closing is routine and would bury the closes that mean something. 1006 is an
abnormal close with no FIN, 1009 a frame over `maxPayload`, 1011 a server error.
- **`sendToTerminal()` has no emptiness guard, on purpose.** A bare Return is the most
useful thing this line can send: it accepts a prompt's default (`[Y/n]`, "press enter
to continue"). `showTerminalView()` clears the box, because one send line serves every
Expand Down
77 changes: 73 additions & 4 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17793,7 +17793,16 @@ <h3 data-i18n="kb.title">⌨️ Keyboard Shortcuts</h3>

// Bound to `entry.ws`, not a captured socket — so a later reconnect (fresh
// WebSocket swapped into entry.ws) keeps routing keystrokes without re-wiring.
term.onData(d => { if (entry.ws && entry.ws.readyState === 1) entry.ws.send(JSON.stringify({ type: 'input', data: d })); });
// A dead socket used to make this a silent drain: the guard was `readyState === 1`
// and nothing else, so every keystroke vanished with no reconnect and no sign that
// anything was wrong. That IS the "terminal froze until I reloaded the page" report
// — the pane looks live because xterm renders locally.
term.onData(d => {
if (entry.ws && entry.ws.readyState === 1) { entry.ws.send(JSON.stringify({ type: 'input', data: d })); return; }
// Typing is the clearest possible "I am using this pane". Reconnect on it, and
// queue the keystroke so the first thing typed is not the thing that gets lost.
_reviveTerminal(sessionId, { type: 'input', data: d });
});
// Anything that steals focus — a modal, a toast button, the send line below, the
// browser restoring focus after an alt-tab — leaves the pane visually active but
// deaf. A click on it is an unambiguous "I am typing here"; don't steal focus back
Expand All @@ -17812,6 +17821,42 @@ <h3 data-i18n="kb.title">⌨️ Keyboard Shortcuts</h3>
// scrollback are left untouched; only the socket is replaced, and the server
// re-paints the current tmux pane on attach (see terminal-bridge.js captureScreen),
// so the view catches back up to what actually happened while disconnected.
// Bring a pane back the moment the user touches it, and don't lose what they were
// trying to send. Every automatic reconnect path is conditional on a state a
// half-dead socket does not report: onclose only runs if a close event arrives,
// visibilitychange only fires on a tab switch, and the backoff can be 30s out.
// Meanwhile the three interaction paths (typing, paste, the send line) each just
// gave up — one silently, two with a toast that named the problem and did nothing
// about it. This is the "just reconnect and carry on" the pane always implied.
// `frame` is optional and replayed once the socket opens; only the LAST one is
// kept, because a queue of stale keystrokes replayed into a TUI is worse than a
// dropped one. `exited` is cleared for the same reason refreshTerminalSession()
// clears it: a latched pane never retries, and the user asking for it outranks the
// latch. Returns true when the socket was already live and nothing was queued.
function _reviveTerminal(sessionId, frame) {
const entry = _terms.get(sessionId); if (!entry) return false;
if (entry.ws && entry.ws.readyState === 1) {
if (frame) { try { entry.ws.send(JSON.stringify(frame)); } catch {} }
return true;
}
if (frame) entry._pending = frame;
// A socket still CONNECTING will deliver _pending from its own onopen; opening a
// second one on every keystroke would stampede the server.
if (entry.ws && entry.ws.readyState === 0) return false;
entry.exited = false;
entry._reconnectDelay = 1000;
_connectTerminalWs(sessionId);
return false;
}

// Deliver what the user typed or pasted into a socket that turned out to be dead.
// Cleared BEFORE the send, so a failing send cannot replay it on the next open too.
function _flushTerminalPending(entry, ws) {
if (!entry || !entry._pending) return;
const f = entry._pending; entry._pending = null;
try { ws.send(JSON.stringify(f)); } catch {}
}

function _connectTerminalWs(sessionId) {
const entry = _terms.get(sessionId); if (!entry) return;
const term = entry.term;
Expand All @@ -17830,6 +17875,7 @@ <h3 data-i18n="kb.title">⌨️ Keyboard Shortcuts</h3>
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
entry._reconnectDelay = 1000;
_flushTerminalPending(entry, ws);
if (_termSessionId !== sessionId) return;
sendTermResize();
// xterm routes keystrokes through a hidden textarea. A reconnect swaps entry.ws
Expand Down Expand Up @@ -17886,12 +17932,23 @@ <h3 data-i18n="kb.title">⌨️ Keyboard Shortcuts</h3>
}
_paintTermStatus(sessionId);
};
ws.onclose = () => {
// The chat socket has had one of these since it was written (search `onerror`);
// the terminal never did, so a socket-level failure left no trace anywhere —
// which is why "the terminal freezes sometimes" stayed unexplained.
ws.onerror = () => { console.warn('[terminal] socket error', { session: sessionId }); };
ws.onclose = (ev) => {
// Staleness first, and it guards the STATUS too, not just the retry. This socket
// may already have been superseded (refreshTerminalSession() detaches before it
// closes) -- painting "disconnected" from here would mark the pane the user is
// typing into as dead while it is working.
if (_terms.get(sessionId) !== entry || entry.ws !== ws) return;
// `ev` used to be ignored entirely, and there was no onerror either, so a pane
// that died left no trace anywhere — which is why "the terminal freezes
// sometimes" stayed unexplained. A close code is the only evidence of WHY:
// 1006 abnormal/no FIN, 1001 going away, 1009 frame too big, 1011 server error.
// Logged AFTER the staleness guard on purpose: a superseded socket closing is
// routine, and logging it would bury the real ones.
if (ev && ev.code !== 1000) console.warn('[terminal] closed', { session: sessionId, code: ev.code, reason: ev.reason || '' });
entry.dot = '#8b949e'; entry.state = t('term.state.disconnected'); _paintTermStatus(sessionId);
// Auto-retry an unexpected drop (network blip, server restart, idle proxy
// timeout) even while this terminal tab stays in the foreground the whole
Expand Down Expand Up @@ -17943,7 +18000,14 @@ <h3 data-i18n="kb.title">⌨️ Keyboard Shortcuts</h3>
// enter to continue"), which is exactly what this line exists to reach.
const text = box.value;
const entry = _termSessionId && _terms.get(_termSessionId);
if (!entry || !entry.ws || entry.ws.readyState !== 1) { toast(t('term.paste.notconnected'), true); return; }
// Reconnect and deliver, rather than refusing: this line is most useful exactly
// when a pane has gone unresponsive, which is also when the old guard refused.
if (!entry || !entry.ws || entry.ws.readyState !== 1) {
_reviveTerminal(_termSessionId, { type: 'input', data: text + '\r' });
box.value = '';
toast(t('term.state.reconnecting'));
return;
}
// \r, not \n: a PTY sees the Return KEY, and a TUI reading in canonical or raw mode
// both treat CR as submit. Sent as ONE frame with the text so a prompt that echoes
// and submits on the same tick cannot split them.
Expand Down Expand Up @@ -18010,7 +18074,12 @@ <h3 data-i18n="kb.title">⌨️ Keyboard Shortcuts</h3>
const sid = _terminalPasteTarget();
if (!sid) { toast(t('term.paste.notarget'), true); return; }
const entry = _terms.get(sid);
if (!entry?.ws || entry.ws.readyState !== 1) { toast(t('term.paste.notconnected'), true); return; }
if (!entry?.ws || entry.ws.readyState !== 1) {
_reviveTerminal(sid, { type: 'paste', data: text });
switchTab(sid);
toast(t('term.state.reconnecting'));
return;
}
entry.ws.send(JSON.stringify({ type: 'paste', data: text }));
switchTab(sid);
toast(t('term.paste.done'));
Expand Down
49 changes: 46 additions & 3 deletions test/composer-terminal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,24 @@ check('it reconnects unconditionally — no readyState test', /readyState/.test(
// A close event from a socket refreshTerminalSession() already replaced must not
// repaint the pane the user is typing into. The guard has to sit ABOVE the paint,
// not only above the retry — nothing repaints the status back afterwards.
const onclose = TB.slice(TB.indexOf('ws.onclose = () => {'));
// The handler takes the close EVENT: a close code is the only evidence of why a pane
// died (1006 abnormal, 1009 frame too big, 1011 server error), and it used to be
// discarded, which is why "the terminal freezes sometimes" had no trace to follow.
check('the close handler receives the event', TB.includes('ws.onclose = (ev) => {'), true);
const onclose = TB.slice(TB.indexOf('ws.onclose = (ev) => {'));
const oncloseBody = onclose.slice(0, onclose.indexOf('\n };') + 5);
// indexOf alone would pass on -1 (guard deleted) < paint index — assert presence first.
check('a stale close is ignored, and before it touches the status',
oncloseBody.includes('entry.ws !== ws) return;')
&& oncloseBody.indexOf('entry.ws !== ws) return;') < oncloseBody.indexOf("term.state.disconnected"), true);
// …and before the logging too: a superseded socket closing is routine, and logging
// it would bury the closes that actually explain something.
check('the close code is logged, after the staleness guard',
oncloseBody.includes('ev.code') &&
oncloseBody.indexOf('entry.ws !== ws) return;') < oncloseBody.indexOf('ev.code'), true);
// The chat socket has had one since it was written; the terminal had none, so a
// socket-level failure was invisible on both ends.
check('the terminal socket has an onerror at all', /ws\.onerror = \(\) =>/.test(TB), true);

console.log('\n— 4. sending a line into a terminal session —');

Expand All @@ -145,15 +157,46 @@ const stsBody = sts.slice(0, sts.indexOf('\n}\n') + 3);
// on the engine pane alike, where a `view=engine` viewer may only send `input`.
check('it writes the terminal protocol\'s input frame', /type: 'input', data: text \+ '\\r'/.test(stsBody), true);
check('CR, not LF — a PTY needs the Return key', /'\\n'/.test(stsBody), false);
check('it refuses to send on a socket that is not OPEN', /readyState !== 1/.test(stsBody), true);
check('and says so instead of failing silently', /toast\(t\('term\.paste\.notconnected'\), true\)/.test(stsBody), true);
check('it notices a socket that is not OPEN', /readyState !== 1/.test(stsBody), true);
// It used to toast "not connected" and return — naming the problem and doing nothing
// about it, on the one control that exists BECAUSE a pane has gone unresponsive.
// Every automatic reconnect path is conditional on a state a half-dead socket does
// not report, so touching the pane has to be a reconnect trigger of its own.
check('and reconnects instead of refusing', /_reviveTerminal\(/.test(stsBody), true);
check('carrying the line, so the first thing typed is not the thing that is lost',
/_reviveTerminal\([^)]*,\s*\{ type: 'input', data: text \+ '\\r' \}\)/.test(stsBody), true);
check('the dead-socket refusal is gone', /paste\.notconnected/.test(stsBody), false);
check('it clears the box after sending', /box\.value = ''/.test(stsBody), true);
// A bare Return is the single most useful thing to send: it accepts a prompt default.
check('an empty line is still sendable', /!text\.trim\(\)\) return/.test(stsBody), false);
// One send line serves every pane, so a draft must not follow the user to another one.
check('switching panes clears the shared send line',
/function showTerminalView[\s\S]{0,1600}?\$i\('termSendInput'\)[^\n]*value = ''/.test(TB), true);

console.log('\n— a dead socket must not swallow what the user does —');
// The silent drain that produced "the terminal froze until I reloaded the page":
// the pane renders locally, so it looks alive while every keystroke goes nowhere.
{
const od = TB.slice(TB.indexOf('term.onData(d => {'));
const odBody = od.slice(0, od.indexOf('\n });') + 6);
check('onData still fast-paths a live socket', /readyState === 1/.test(odBody), true);
check('and revives a dead one instead of dropping the keystroke',
/_reviveTerminal\(sessionId, \{ type: 'input', data: d \}\)/.test(odBody), true);
// Reconnecting on every keystroke would stampede the server; a socket already
// CONNECTING will deliver the queued frame from its own onopen.
const rv = TB.slice(TB.indexOf('function _reviveTerminal'));
const rvBody = rv.slice(0, rv.indexOf('\n}\n') + 3);
check('a CONNECTING socket is not raced with a second one', /readyState === 0\) return false/.test(rvBody), true);
check('only the LAST frame is queued — stale keystrokes replayed into a TUI are worse',
/entry\._pending = frame/.test(rvBody), true);
check('it clears the exited latch, like the manual refresh does', /entry\.exited = false/.test(rvBody), true);
// Cleared before the send, or a failing send replays it on the next open as well.
const fl = TB.slice(TB.indexOf('function _flushTerminalPending'));
const flBody = fl.slice(0, fl.indexOf('\n}\n') + 3);
check('the queued frame is cleared BEFORE it is sent',
flBody.indexOf('entry._pending = null') < flBody.indexOf('ws.send'), true);
}

console.log('\n— focus: why a deaf pane used to need a tab switch —');

check('a reconnect refocuses the pane',
Expand Down
Loading