Skip to content

fix: a terminal pane reconnects when you touch it, and says why it dropped - #71

Merged
Lexus2016 merged 1 commit into
mainfrom
fix/terminal-reconnect
Aug 25, 2026
Merged

Lexus2016 merged 1 commit into
mainfrom
fix/terminal-reconnect

Conversation

@Lexus2016

Copy link
Copy Markdown
Owner

The report

The terminal drops out often and says it is not connected. It happens when I paste a command, or when I start scrolling the content. At some point the terminal freezes and stays unavailable until I reload the browser window.

What was wrong

Three separate places where the client gives up instead of reconnecting. None of them is the thing that drops the socket — they are what turns a dropped socket into a pane that stays dead until F5.

1. Typing into a dead pane was a silent drain.

term.onData(d => { if (entry.ws && entry.ws.readyState === 1) entry.ws.send(...) });

No else. The keystroke vanished — no reconnect, no message, nothing in the console. xterm renders locally, so the pane still looks alive. That is the freeze in the report.

2. Paste and the send line named the problem and did nothing about it.

Both did toast(t('term.paste.notconnected'), true); return; — on the two controls that exist precisely because a pane has gone unresponsive. That toast is the "Terminal is not connected" the reporter sees.

3. Every automatic reconnect path is conditional on a state a half-dead socket does not report.

onclose only runs if a close event ever arrives; visibilitychange only fires on a tab switch; the backoff can be 30 s out; and entry.exited latches the retry off for good. The only unconditional path was the 🔄 button.

What this changes

_reviveTerminal(sessionId, frame) makes any interaction a reconnect trigger and carries the frame through, so the first thing you type is not the thing that gets lost.

  • 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; it delivers the queued frame from its own onopen.
  • _pending is cleared before the send, so a failing send cannot replay it on the next open too.
  • The exited latch is cleared, for the same reason refreshTerminalSession() clears it: the user asking outranks the latch.

Diagnosis that did not exist

ws.onclose took no argument — the close code was discarded — and the terminal socket had no onerror at all (the chat socket has had one since it was written). A terminal failure therefore left no trace on either end, which is why "it freezes sometimes" was never explained. 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.

Next occurrence will say which it was: 1006 abnormal close with no FIN, 1009 frame over maxPayload, 1011 server error.

What this does NOT fix

What drops the socket in the first place is still unidentified. This PR fixes what the UI does about it. The close code added here is what will name the cause.

One hypothesis was checked and is wrong: a missing server-side ws.on('error') on the terminal socket. It is there, at the top of the wssTerm connection block — it just sits above both sub-blocks, which is why it does not appear near either handler.

Verification

npm test → exit 0. test/composer-terminal.test.js gains 13 checks and had to be repinned twice: it caught the staleness guard being displaced by a log line, and the send-line behaviour changing. That is exactly what it was written for, and both are now pinned in their new form — including the negative pin that the dead-socket refusal is gone.

…opped

Reported: the terminal freezes on paste or while scrolling, shows "Terminal is
not connected", and stays dead until the browser window is reloaded.

Three client-side causes, all of them "the code gives up instead of
reconnecting":

- term.onData guarded on readyState === 1 and nothing else, so a keystroke into
  a dead socket vanished silently. xterm renders locally, so the pane still
  looks alive — that is the freeze.
- pasteToTerminal and sendToTerminal toasted term.paste.notconnected and
  returned, naming the problem on the two controls that exist BECAUSE a pane has
  gone unresponsive.
- 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 on a tab switch, and the backoff can be 30s out.

_reviveTerminal() makes any interaction a reconnect trigger and carries the
frame through. Only the LAST frame is queued (stale keystrokes replayed into a
TUI are worse than a dropped one), a CONNECTING socket is not raced with a
second one, and _pending is cleared before the send so a failure cannot replay
it on the next open.

Diagnosis, which did not exist: ws.onclose took no argument and there was no
onerror on the terminal socket at all, so a failure left no trace on either end.
Both exist now; the log sits after the staleness guard so a superseded socket
closing does not bury the real ones.

NOT fixed, and stated as such in the CHANGELOG: what drops the socket in the
first place. The close code added here is what will name it. An earlier
hypothesis — a missing ws.on('error') server-side — was checked and is wrong;
it is there, at the top of the wssTerm connection block.

test/composer-terminal.test.js gains 13 checks and had to be repinned twice: it
caught the staleness guard being displaced and the send-line behaviour changing,
which is exactly what it was written for.
@Lexus2016
Lexus2016 merged commit 80a27d3 into main Aug 25, 2026
2 checks passed
@Lexus2016
Lexus2016 deleted the fix/terminal-reconnect branch October 7, 2026 08:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant