terminal: stop replaying questions, stop opening two sockets, bind the word keys
three separate faults behind "reconnecting gets weird and the keyboard is not natural". ── the replay typed into the shell ── the pty buffer was stored raw and replayed verbatim on every re-attach. anything in it that ASKS the terminal a question — DSR, DA, DECRQM, XTVERSION, XTGETTCAP, the OSC colour queries — got asked again, and xterm answered correctly by writing the reply to its input. the pty receives that as a keystroke nobody typed. stripped on the way IN, since the buffer is the thing that gets replayed and a live client already answered them once when they were legitimately asked. only questions are removed; everything that draws is untouched. where a control shares its final byte with one that draws, the parameter is enumerated rather than wildcarded — CSI 18 t asks the window size, CSI 22 t pushes the title, and stripping the second would change what a replay renders. 36 tests, both directions, because both fail silently. ── two sockets on one session ── handleClose armed a reconnect timer; handleVisibilityChange fired on tab focus whenever readyState was CLOSED — which is exactly what a pending timer leaves. both ran. every keystroke went twice, two replay frames fought over the screen, and only one socket was ever cleaned up because __terminalCleanup is overwritten by whichever connect ran last. connect() is now the single guard, and a stale socket's close no longer speaks for the session. ── the keyboard ── alt-arrow was dead for everyone: xterm.js 5 rewrote it into the ctrl-arrow sequence, xterm.js 6 removed that rewrite and emits the honest ^[[1;3C/D (verified — the string 1;3D does not appear anywhere in the 6.0 bundle). nothing bound it. so it broke on a dependency bump, with no shell config changed. bound in zsh rather than translated in the browser, deliberately: tmux.conf claims M-Left/M-Right for pane switching, and a client-side rewrite would send ^[b to tmux and break it. the real sequence lets tmux handle it inside a session and zsh outside. ctrl-arrow was worse and more embarrassing: it worked for MEMBERS and not for the OWNER. shell-skel/zshrc has had the bindings all along; the owner's .zshrc is assembled in machine-setup and never got them. the owner had a strictly worse shell than the accounts they provision. confirmed with `zsh -i -c bindkey` before and after. also: escape-time 10 in tmux.conf. the 500ms default delays every Alt chord and every Escape, which is most of what "not natural" felt like. applied to this host by hand — setup only runs at install. cmd+arrow is left alone: xterm emits nothing for it, so there is no sequence to bind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -243,6 +243,26 @@ export const TerminalView = ({
|
||||
const connect = () => {
|
||||
if (disposed) return;
|
||||
|
||||
// ── One socket per session, enforced here rather than at each caller ──
|
||||
//
|
||||
// There are two independent reconnect triggers — `handleClose` arms a timer, and
|
||||
// `handleVisibilityChange` fires when the tab comes back — and they overlap exactly: a pending timer
|
||||
// leaves `readyState === CLOSED`, which is the visibility handler's own condition to reconnect. Both
|
||||
// then ran, and the session ended up with TWO live sockets: every keystroke delivered twice, two
|
||||
// `replay` frames fighting over the screen, two `resize` frames racing. That is the "goes weird after
|
||||
// reconnecting" report, and it is also why only one socket was ever cleaned up —
|
||||
// `__terminalCleanup` is overwritten by whichever connect ran last.
|
||||
//
|
||||
// Guarding the entry point covers both callers and any future third one.
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
const existing = wsRef.current;
|
||||
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fitAddon.fit();
|
||||
const cols = term.cols;
|
||||
const rows = term.rows;
|
||||
@@ -335,6 +355,10 @@ export const TerminalView = ({
|
||||
|
||||
const handleClose = () => {
|
||||
cleanupWs();
|
||||
// A socket that is no longer the session's must not arm a reconnect: its close arrives AFTER the
|
||||
// replacement is already open, and the timer it sets would then tear down a healthy connection to
|
||||
// build a third. Only the current socket speaks for the session.
|
||||
if (wsRef.current !== ws) return;
|
||||
if (disposed || processExited) {
|
||||
onConnectionChangeRef.current?.('disconnected');
|
||||
term.write('\r\n[Disconnected]\r\n');
|
||||
@@ -381,13 +405,13 @@ export const TerminalView = ({
|
||||
});
|
||||
|
||||
// Reconnect when tab becomes visible again
|
||||
// Coming back to the tab should not WAIT out a backoff that may be seconds long — but it must not open a
|
||||
// second socket either. `connect` now decides that (it returns early on a live one), so this only has to
|
||||
// say "try now", and the CLOSED/CLOSING distinction that used to be here stops mattering.
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && !disposed && !processExited) {
|
||||
if (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED) {
|
||||
reconnectAttempts = 0;
|
||||
connect();
|
||||
}
|
||||
}
|
||||
if (document.visibilityState !== 'visible' || disposed || processExited) return;
|
||||
reconnectAttempts = 0;
|
||||
connect();
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user