fix: merge web terminal resilience

This commit is contained in:
Developer
2026-07-18 10:32:56 +00:00
8 changed files with 341 additions and 39 deletions
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import {
getTerminalScrollbackLimit,
isCurrentWebSocket,
shouldRetryWebSocketClose,
} from "./terminal.tsx";
describe("getTerminalScrollbackLimit", () => {
it("retains normal-buffer history for custom mobile swipe scrolling", () => {
expect(getTerminalScrollbackLimit(true)).toBe(10_000);
});
it("keeps desktop scrollback disabled to prevent stale-frame wheel scrolling", () => {
expect(getTerminalScrollbackLimit(false)).toBe(0);
});
it("rejects stale WebSocket callbacks after a replacement connection", () => {
const current = {} as WebSocket;
const stale = {} as WebSocket;
expect(isCurrentWebSocket(current, current)).toBe(true);
expect(isCurrentWebSocket(current, stale)).toBe(false);
});
it("retries a heartbeat timeout but not a server socket replacement", () => {
expect(shouldRetryWebSocketClose(4000, "Heartbeat timeout")).toBe(true);
expect(shouldRetryWebSocketClose(4000, "New connection established")).toBe(
false,
);
});
});
@@ -53,6 +53,21 @@ const BRACKETED_PASTE_DISABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x
const BRACKETED_PASTE_CONTROL_TAIL_LENGTH =
BRACKETED_PASTE_ENABLE_SEQUENCE.length - 1;
export function getTerminalScrollbackLimit(isMobile: boolean): number {
return isMobile ? 10_000 : 0;
}
export function isCurrentWebSocket(
current: WebSocket | null,
candidate: WebSocket,
): boolean {
return current === candidate;
}
export function shouldRetryWebSocketClose(code: number, reason: string): boolean {
return code !== 1000 && !(code === 4000 && reason === "New connection established");
}
function matchesByteSequence(
data: Uint8Array,
start: number,
@@ -83,6 +98,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const bracketedPasteEnabledRef = useRef(false);
const pasteTextRef = useRef<(text: string) => void>(() => {});
const reconnectAttemptsRef = useRef(0);
const reconnectTimerRef = useRef<number | null>(null);
const onTerminalReadyRef = useRef(onTerminalReady);
onTerminalReadyRef.current = onTerminalReady;
const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {});
@@ -111,7 +127,23 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
return fontSize;
}, [fontSize]);
const clearReconnectTimer = useCallback(() => {
if (reconnectTimerRef.current !== null) {
window.clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}, []);
const connectWebSocket = useCallback(() => {
const currentWs = wsRef.current;
if (
currentWs?.readyState === WebSocket.CONNECTING ||
currentWs?.readyState === WebSocket.OPEN
) {
return currentWs;
}
clearReconnectTimer();
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
@@ -161,6 +193,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
ws.onopen = () => {
if (!isCurrentWebSocket(wsRef.current, ws)) {
ws.close(1000, "Superseded connection");
return;
}
setStatus("connected");
setError(null);
reconnectAttemptsRef.current = 0;
@@ -201,7 +238,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
ws.onmessage = (event) => {
if (!termRef.current) return;
if (!isCurrentWebSocket(wsRef.current, ws) || !termRef.current) return;
if (event.data instanceof ArrayBuffer) {
const data = new Uint8Array(event.data);
@@ -260,6 +297,10 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
ws.onclose = (event) => {
if (!isCurrentWebSocket(wsRef.current, ws)) return;
wsRef.current = null;
if (ackTimeout) window.clearTimeout(ackTimeout);
// Clean up heartbeat check
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
@@ -275,18 +316,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
return;
}
if (event.code === 1000) {
if (!shouldRetryWebSocketClose(event.code, event.reason)) {
setStatus("disconnected");
return;
}
if (event.code === 4000) {
// Server closed old connection for concurrent connection - don't reconnect
// The new connection is already established
return;
}
// Transient errors: attempt reconnection
// Transient errors: attempt reconnection.
setStatus("disconnected");
setError(`Connection closed (code: ${event.code})`);
@@ -295,11 +330,14 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const delay =
RECONNECT_DELAY_BASE *
Math.pow(2, reconnectAttemptsRef.current - 1);
setTimeout(() => {
if (isUnmountingRef.current) {
return;
}
if (document.visibilityState !== "hidden") {
clearReconnectTimer();
reconnectTimerRef.current = window.setTimeout(() => {
reconnectTimerRef.current = null;
if (
!isUnmountingRef.current &&
document.visibilityState !== "hidden" &&
wsRef.current === null
) {
connectWebSocket();
}
}, delay);
@@ -307,15 +345,18 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
ws.onerror = () => {
if (!isCurrentWebSocket(wsRef.current, ws)) return;
setStatus("error");
setError("WebSocket error");
};
return ws;
}, [instanceId, sessionId]);
}, [clearReconnectTimer, instanceId, sessionId]);
useEffect(() => {
if (!terminalRef.current) return;
isUnmountingRef.current = false;
permanentErrorRef.current = null;
// Initialize terminal
const currentFontSize = calculateFontSize();
@@ -326,14 +367,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
lineHeight: 1.2,
letterSpacing: 0,
allowTransparency: false,
// This terminal only ever hosts full-screen TUI tools (pi-agent,
// opencode), which repaint in place in the normal buffer and do not
// use the alternate screen or mouse tracking. With scrollback, every
// repaint accumulates as history → a viewport scrollbar appears and
// the mouse-wheel scrolls through stale frames instead of the app.
// scrollback:0 keeps only the live viewport: no bar, no stale-frame
// wheel jank. (Scrollbar is also hidden via CSS for belt-and-suspenders.)
scrollback: 0,
// Desktop tools repaint in place, so retaining their normal buffer
// creates stale frames that native wheel scrolling can revisit. Mobile
// instead uses its custom touch handler to scroll normal-buffer output,
// which requires retained history.
scrollback: getTerminalScrollbackLimit(isMobile),
ignoreBracketedPasteMode: false,
fastScrollSensitivity: 0,
scrollSensitivity: 0,
@@ -409,7 +447,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Open xterm first (must happen before fit)
term.open(container);
term.focus();
const ws = connectWebSocket();
connectWebSocket();
pasteTextRef.current = (text: string) => {
const currentWs = wsRef.current;
@@ -657,14 +695,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Visibility API for reconnection
const handleVisibilityChange = () => {
const currentWs = wsRef.current;
if (
document.visibilityState === "visible" &&
ws &&
ws.readyState !== WebSocket.OPEN
!permanentErrorRef.current &&
(currentWs === null || currentWs.readyState === WebSocket.CLOSED)
) {
if (permanentErrorRef.current) {
return;
}
reconnectAttemptsRef.current = 0;
connectWebSocket();
}
@@ -673,6 +709,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
return () => {
isUnmountingRef.current = true;
clearReconnectTimer();
clearTimeout(resizeTimeout);
clearTimeout(windowResizeTimeout);
clearTimeout(headerHideTimeout);
@@ -686,8 +723,10 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
container.removeEventListener("paste", handleBrowserPaste, true);
pasteTextRef.current = () => {};
bracketedPasteEnabledRef.current = false;
if (ws) {
ws.close(1000, "Component unmounting");
const currentWs = wsRef.current;
wsRef.current = null;
if (currentWs) {
currentWs.close(1000, "Component unmounting");
}
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
@@ -699,7 +738,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Ignore disposal errors from partially torn-down terminal
}
};
}, [instanceId, connectWebSocket]);
}, [instanceId, connectWebSocket, isMobile]);
useImperativeHandle(ref, () => ({
fit: () => {