fix: harden web terminal paste and reconnect
- Queue bounded ordered terminal input so acknowledgements remain responsive - Prevent stale sockets and retries from replacing healthy connections - Preserve desktop scrollback behavior and add terminal regression coverage Quality gates: frontend tests (91 passed), typecheck, lint, build, Python compilation, LSP diagnostics. Backend pytest skipped by user request.
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getTerminalScrollbackLimit } from "./terminal.tsx";
|
||||
import {
|
||||
getTerminalScrollbackLimit,
|
||||
isCurrentWebSocket,
|
||||
shouldRetryWebSocketClose,
|
||||
} from "./terminal.tsx";
|
||||
|
||||
describe("getTerminalScrollbackLimit", () => {
|
||||
it("retains normal-buffer history for custom mobile swipe scrolling", () => {
|
||||
@@ -10,4 +14,19 @@ describe("getTerminalScrollbackLimit", () => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,17 @@ 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,
|
||||
@@ -87,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>(() => {});
|
||||
@@ -115,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(/\/+$/, "");
|
||||
@@ -165,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;
|
||||
@@ -205,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);
|
||||
@@ -264,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);
|
||||
@@ -279,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})`);
|
||||
|
||||
@@ -299,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);
|
||||
@@ -311,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();
|
||||
@@ -410,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;
|
||||
@@ -658,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();
|
||||
}
|
||||
@@ -674,6 +709,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
|
||||
return () => {
|
||||
isUnmountingRef.current = true;
|
||||
clearReconnectTimer();
|
||||
clearTimeout(resizeTimeout);
|
||||
clearTimeout(windowResizeTimeout);
|
||||
clearTimeout(headerHideTimeout);
|
||||
@@ -687,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);
|
||||
|
||||
Reference in New Issue
Block a user