fix: WebSocket reconnection in DirectTerminal (#248)

* fix: add WebSocket reconnection with exponential backoff in DirectTerminal

A single network hiccup permanently killed the terminal with no retry.
Now transient disconnects trigger automatic reconnection (1s→2s→4s…15s max)
while permanent errors (4001 auth, 4004 not-found) still fail immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: reset reconnection state when sessionId changes

Reset permanentErrorRef and reconnectAttemptRef at the start of the
useEffect so switching sessions doesn't inherit stale error/retry state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-03-01 20:18:07 +05:30 committed by GitHub
parent 2108620ee9
commit 3cb03f6222
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 97 additions and 56 deletions

View File

@ -45,6 +45,9 @@ export function DirectTerminal({
const terminalInstance = useRef<TerminalType | null>(null);
const fitAddon = useRef<FitAddonType | null>(null);
const ws = useRef<WebSocket | null>(null);
const reconnectAttemptRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const permanentErrorRef = useRef(false);
const [fullscreen, setFullscreen] = useState(startFullscreen);
const [status, setStatus] = useState<"connecting" | "connected" | "error">("connecting");
const [error, setError] = useState<string | null>(null);
@ -65,12 +68,18 @@ export function DirectTerminal({
useEffect(() => {
if (!terminalRef.current) return;
// Prevent retry loop on persistent errors
if (error && status === "error") return;
// Reset reconnection state when sessionId changes
permanentErrorRef.current = false;
reconnectAttemptRef.current = 0;
// Dynamically import xterm.js to avoid SSR issues
let mounted = true;
let cleanup: (() => void) | null = null;
let inputDisposable: { dispose(): void } | null = null;
const PERMANENT_CLOSE_CODES = new Set([4001, 4004]); // auth failure, session not found
const MAX_RECONNECT_DELAY = 15_000;
Promise.all([
import("xterm").then((mod) => mod.Terminal),
@ -155,65 +164,18 @@ export function DirectTerminal({
// Fit terminal to container
fit.fit();
// Connect WebSocket
// WebSocket URL (stable across reconnects)
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const hostname = window.location.hostname;
const port = process.env.NEXT_PUBLIC_DIRECT_TERMINAL_PORT ?? "14801";
const wsUrl = `${protocol}//${hostname}:${port}/ws?session=${encodeURIComponent(sessionId)}`;
console.log("[DirectTerminal] Connecting to:", wsUrl);
const websocket = new WebSocket(wsUrl);
ws.current = websocket;
websocket.binaryType = "arraybuffer";
websocket.onopen = () => {
console.log("[DirectTerminal] WebSocket connected");
setStatus("connected");
setError(null);
// Send initial size
websocket.send(
JSON.stringify({
type: "resize",
cols: terminal.cols,
rows: terminal.rows,
}),
);
};
websocket.onmessage = (event) => {
const data =
typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
terminal.write(data);
};
websocket.onerror = (event) => {
console.error("[DirectTerminal] WebSocket error:", event);
setStatus("error");
setError("WebSocket connection error");
};
websocket.onclose = (event) => {
console.log("[DirectTerminal] WebSocket closed:", event.code, event.reason);
if (status === "connected") {
setStatus("error");
setError("Connection closed");
}
};
// Terminal input → WebSocket
const disposable = terminal.onData((data) => {
if (websocket.readyState === WebSocket.OPEN) {
websocket.send(data);
}
});
// Handle window resize
// Handle window resize (works with whatever ws is current)
const handleResize = () => {
if (fit && websocket.readyState === WebSocket.OPEN) {
const currentWs = ws.current;
if (fit && currentWs?.readyState === WebSocket.OPEN) {
fit.fit();
websocket.send(
currentWs.send(
JSON.stringify({
type: "resize",
cols: terminal.cols,
@ -225,22 +187,101 @@ export function DirectTerminal({
window.addEventListener("resize", handleResize);
// Terminal input → current WebSocket
inputDisposable = terminal.onData((data) => {
if (ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(data);
}
});
function connectWebSocket() {
if (!mounted) return;
console.log("[DirectTerminal] Connecting to:", wsUrl);
const websocket = new WebSocket(wsUrl);
ws.current = websocket;
websocket.binaryType = "arraybuffer";
websocket.onopen = () => {
console.log("[DirectTerminal] WebSocket connected");
reconnectAttemptRef.current = 0;
setStatus("connected");
setError(null);
// Send initial size
websocket.send(
JSON.stringify({
type: "resize",
cols: terminal.cols,
rows: terminal.rows,
}),
);
};
websocket.onmessage = (event) => {
const data =
typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
terminal.write(data);
};
websocket.onerror = (event) => {
console.error("[DirectTerminal] WebSocket error:", event);
};
websocket.onclose = (event) => {
console.log("[DirectTerminal] WebSocket closed:", event.code, event.reason);
if (!mounted) return;
// Permanent errors — don't retry
if (PERMANENT_CLOSE_CODES.has(event.code)) {
permanentErrorRef.current = true;
setStatus("error");
setError(event.reason || `Connection refused (${event.code})`);
return;
}
// Transient failure — schedule reconnect with exponential backoff
const attempt = reconnectAttemptRef.current;
const delay = Math.min(1000 * Math.pow(2, attempt), MAX_RECONNECT_DELAY);
reconnectAttemptRef.current = attempt + 1;
console.log(`[DirectTerminal] Reconnecting in ${delay}ms (attempt ${attempt + 1})`);
setStatus("connecting");
setError(null);
reconnectTimerRef.current = setTimeout(connectWebSocket, delay);
};
}
connectWebSocket();
// Store cleanup function to be called from useEffect cleanup
cleanup = () => {
window.removeEventListener("resize", handleResize);
disposable.dispose();
websocket.close();
inputDisposable?.dispose();
inputDisposable = null;
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
ws.current?.close();
terminal.dispose();
};
})
.catch((err) => {
console.error("[DirectTerminal] Failed to load xterm.js:", err);
permanentErrorRef.current = true;
setStatus("error");
setError("Failed to load terminal");
});
return () => {
mounted = false;
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
cleanup?.();
};
}, [sessionId, variant]);