From b7cf83cfc5feddb905e16895d7f9328c47352b8f Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 18 May 2026 07:23:48 -0700 Subject: [PATCH] fix(web): recover terminal websockets after mobile background/resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile Chrome freezes backgrounded tabs and closes their websockets. WebTerminal opened a single one-shot socket whose onclose only set state to "error" — no reconnect, no recovery path — so terminals stayed dead after minimizing and reopening the browser. Extract the socket lifecycle into TerminalConnection: exponential- backoff reconnect on unexpected close, plus visibilitychange / pageshow / resume / online listeners that reconnect immediately when the page comes back. The server already keys sessions by terminalId and adopts/respawns the PTY on reattach, so reopening the same URL resumes the session; ?replay=0 after first bytes avoids re-dumping scrollback xterm already holds. --- .../WebTerminal/TerminalConnection.ts | 237 ++++++++++++++++++ .../components/WebTerminal/WebTerminal.tsx | 207 ++++++--------- 2 files changed, 311 insertions(+), 133 deletions(-) create mode 100644 apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/TerminalConnection.ts diff --git a/apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/TerminalConnection.ts b/apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/TerminalConnection.ts new file mode 100644 index 00000000000..55a031ea417 --- /dev/null +++ b/apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/TerminalConnection.ts @@ -0,0 +1,237 @@ +import { getAuthToken } from "../../../../../trpc/auth-token"; +import { getRelayUrl } from "../../../../../trpc/relay-url"; + +export type TerminalConnectionState = "connecting" | "reconnecting" | "error"; + +type TerminalServerMessage = + | { type: "attached"; terminalId: string } + | { type: "title"; title: string | null } + | { type: "error"; message: string } + | { type: "exit"; exitCode: number; signal: number }; + +export type TerminalControlMessage = TerminalServerMessage; + +type TerminalClientMessage = + | { type: "input"; data: string } + | { type: "resize"; cols: number; rows: number }; + +interface TerminalConnectionTarget { + workspaceId: string; + terminalId: string; + routingKey: string; +} + +interface TerminalConnectionHandlers { + onBinary: (bytes: Uint8Array) => void; + onControl: (message: TerminalControlMessage) => void; + onStateChange: (state: TerminalConnectionState) => void; +} + +const BASE_RECONNECT_DELAY_MS = 500; +const MAX_RECONNECT_DELAY_MS = 10_000; +const MAX_RECONNECT_ATTEMPTS = 12; + +// Owns the terminal WebSocket lifecycle: exponential-backoff reconnect on an +// unexpected close, plus page-visibility recovery. Mobile browsers freeze +// backgrounded tabs and drop the socket; the visibility, pageshow, resume and +// online listeners reconnect the moment the page comes back. The server keys +// sessions by terminalId and adopts/respawns the PTY on reattach, so reopening +// the same URL resumes the session. +export class TerminalConnection { + private readonly target: TerminalConnectionTarget; + private readonly handlers: TerminalConnectionHandlers; + private socket: WebSocket | null = null; + private state: TerminalConnectionState = "connecting"; + private generation = 0; + private reconnectAttempt = 0; + private reconnectTimer: ReturnType | null = null; + private hasReceivedBytes = false; + private everAttached = false; + private terminated = false; + private disposed = false; + + constructor( + target: TerminalConnectionTarget, + handlers: TerminalConnectionHandlers, + ) { + this.target = target; + this.handlers = handlers; + } + + start() { + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", this.handleResume); + document.addEventListener("resume", this.handleResume); + } + if (typeof window !== "undefined") { + window.addEventListener("pageshow", this.handleResume); + window.addEventListener("online", this.handleResume); + } + void this.connect(); + } + + dispose() { + this.disposed = true; + this.cancelReconnect(); + if (typeof document !== "undefined") { + document.removeEventListener("visibilitychange", this.handleResume); + document.removeEventListener("resume", this.handleResume); + } + if (typeof window !== "undefined") { + window.removeEventListener("pageshow", this.handleResume); + window.removeEventListener("online", this.handleResume); + } + this.teardownSocket(); + } + + send(message: TerminalClientMessage) { + const socket = this.socket; + if (!socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify(message)); + } + + private connect = async () => { + if (this.disposed || this.terminated) return; + this.cancelReconnect(); + this.teardownSocket(); + const generation = ++this.generation; + this.emitState(this.everAttached ? "reconnecting" : "connecting"); + + let url: string; + try { + url = await this.buildUrl(); + } catch { + if (generation !== this.generation || this.disposed) return; + this.scheduleReconnect(); + return; + } + if (generation !== this.generation || this.disposed || this.terminated) { + return; + } + + let socket: WebSocket; + try { + socket = new WebSocket(url); + } catch { + this.scheduleReconnect(); + return; + } + socket.binaryType = "arraybuffer"; + this.socket = socket; + this.attachListeners(socket); + }; + + private async buildUrl(): Promise { + const token = await getAuthToken(); + const base = getRelayUrl().replace(/^http/, "ws").replace(/\/$/, ""); + const url = new URL( + `${base}/hosts/${this.target.routingKey}/terminal/${encodeURIComponent( + this.target.terminalId, + )}`, + ); + url.searchParams.set("workspaceId", this.target.workspaceId); + url.searchParams.set("themeType", "dark"); + url.searchParams.set("token", token); + // Once xterm holds scrollback, skip the daemon ring-buffer re-dump on + // reattach; the in-memory buffer still replays output missed offline. + if (this.hasReceivedBytes) url.searchParams.set("replay", "0"); + return url.toString(); + } + + private attachListeners(socket: WebSocket) { + socket.onmessage = (event) => { + if (this.socket !== socket) return; + if (event.data instanceof ArrayBuffer) { + this.hasReceivedBytes = true; + this.handlers.onBinary(new Uint8Array(event.data)); + return; + } + let message: TerminalServerMessage; + try { + message = JSON.parse(String(event.data)) as TerminalServerMessage; + } catch { + return; + } + if (message.type === "attached") { + this.reconnectAttempt = 0; + this.everAttached = true; + } else if (message.type === "exit" || message.type === "error") { + this.terminated = true; + this.cancelReconnect(); + } + this.handlers.onControl(message); + }; + + socket.onclose = () => { + if (this.socket !== socket) return; + this.socket = null; + if (this.terminated || this.disposed) return; + this.scheduleReconnect(); + }; + } + + private teardownSocket() { + const socket = this.socket; + this.socket = null; + if (!socket) return; + socket.onmessage = null; + socket.onclose = null; + try { + socket.close(); + } catch { + // best-effort + } + } + + private scheduleReconnect() { + if (this.reconnectTimer !== null) return; + if (this.terminated || this.disposed) return; + if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { + this.emitState("error"); + return; + } + this.emitState("reconnecting"); + // Frozen tabs don't run timers; the visibility listener reconnects on + // resume instead of burning the attempt budget on a timer that won't fire. + if (typeof document !== "undefined" && document.hidden) return; + + const delay = Math.min( + BASE_RECONNECT_DELAY_MS * 2 ** this.reconnectAttempt, + MAX_RECONNECT_DELAY_MS, + ); + this.reconnectAttempt += 1; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + void this.connect(); + }, delay); + } + + private cancelReconnect() { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private handleResume = () => { + if (this.disposed || this.terminated) return; + if (typeof document !== "undefined" && document.hidden) return; + this.reconnectAttempt = 0; + const socket = this.socket; + if ( + socket && + (socket.readyState === WebSocket.OPEN || + socket.readyState === WebSocket.CONNECTING) + ) { + return; + } + this.cancelReconnect(); + void this.connect(); + }; + + private emitState(state: TerminalConnectionState) { + if (this.state === state) return; + this.state = state; + this.handlers.onStateChange(state); + } +} diff --git a/apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx b/apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx index e5ee7f489b9..0562039afba 100644 --- a/apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx +++ b/apps/web/src/app/workspaces/[workspaceId]/components/WebTerminal/WebTerminal.tsx @@ -6,8 +6,7 @@ import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { useCallback, useEffect, useRef, useState } from "react"; import { MobileTerminalInput } from "../../../../../components/MobileTerminalInput"; -import { getAuthToken } from "../../../../../trpc/auth-token"; -import { getRelayUrl } from "../../../../../trpc/relay-url"; +import { TerminalConnection } from "./TerminalConnection"; const TERMINAL_THEME: ITheme = { background: "#151110", @@ -42,16 +41,12 @@ interface WebTerminalProps { routingKey: string; } -type ConnectionState = "connecting" | "open" | "error" | "exited"; - -// Wire protocol mirrors the desktop's terminal transport -// (apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts): binary -// frames are raw PTY bytes, control messages are JSON. -type TerminalServerMessage = - | { type: "attached"; terminalId: string } - | { type: "title"; title: string | null } - | { type: "error"; message: string } - | { type: "exit"; exitCode: number; signal: number }; +type ConnectionState = + | "connecting" + | "open" + | "reconnecting" + | "error" + | "exited"; export function WebTerminal({ workspaceId, @@ -59,48 +54,59 @@ export function WebTerminal({ routingKey, }: WebTerminalProps) { const containerRef = useRef(null); - const socketRef = useRef(null); + const connectionRef = useRef(null); const [state, setState] = useState("connecting"); const [errorMessage, setErrorMessage] = useState(null); const sendSequence = useCallback((sequence: string) => { - const socket = socketRef.current; - if (!socket || socket.readyState !== WebSocket.OPEN) return; - socket.send(JSON.stringify({ type: "input", data: sequence })); + connectionRef.current?.send({ type: "input", data: sequence }); }, []); useEffect(() => { - let cancelled = false; - let terminal: Terminal | null = null; - let fitAddon: FitAddon | null = null; - let socket: WebSocket | null = null; - let resizeObserver: ResizeObserver | null = null; + const container = containerRef.current; + if (!container) return; + let resizeTimer: ReturnType | null = null; const visualViewport = window.visualViewport; - const sendResize = () => { - const activeSocket = socketRef.current; - if ( - !terminal || - !activeSocket || - activeSocket.readyState !== WebSocket.OPEN - ) { - return; + const terminal = new Terminal({ + cursorBlink: true, + cursorStyle: "block", + fontFamily: TERMINAL_FONT_FAMILY, + fontSize: 14, + scrollback: 5000, + theme: TERMINAL_THEME, + allowProposedApi: true, + }); + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(container); + if (window.matchMedia("(pointer: coarse)").matches) { + const xtermInput = terminal.textarea; + if (xtermInput) { + xtermInput.readOnly = true; + xtermInput.inputMode = "none"; + xtermInput.tabIndex = -1; } - activeSocket.send( - JSON.stringify({ - type: "resize", - cols: terminal.cols, - rows: terminal.rows, - }), - ); + } + try { + fitAddon.fit(); + } catch { + // container may not be sized yet + } + + const sendResize = () => { + connectionRef.current?.send({ + type: "resize", + cols: terminal.cols, + rows: terminal.rows, + }); }; // Refit on every layout change; the visualViewport listeners are what // keep the prompt above the soft keyboard on mobile, since the keyboard // resizes the visual viewport rather than the layout viewport. const refit = () => { - if (!fitAddon) return; try { fitAddon.fit(); } catch { @@ -110,63 +116,19 @@ export function WebTerminal({ resizeTimer = setTimeout(sendResize, 150); }; - (async () => { - try { - const token = await getAuthToken(); - if (cancelled) return; - const container = containerRef.current; - if (!container) return; - - terminal = new Terminal({ - cursorBlink: true, - cursorStyle: "block", - fontFamily: TERMINAL_FONT_FAMILY, - fontSize: 14, - scrollback: 5000, - theme: TERMINAL_THEME, - allowProposedApi: true, - }); - fitAddon = new FitAddon(); - terminal.loadAddon(fitAddon); - terminal.open(container); - if (window.matchMedia("(pointer: coarse)").matches) { - const xtermInput = terminal.textarea; - if (xtermInput) { - xtermInput.readOnly = true; - xtermInput.inputMode = "none"; - xtermInput.tabIndex = -1; - } - } - try { - fitAddon.fit(); - } catch { - // container may not be sized yet - } - - const wsBase = getRelayUrl().replace(/^http/, "ws").replace(/\/$/, ""); - const url = `${wsBase}/hosts/${routingKey}/terminal/${encodeURIComponent(terminalId)}?workspaceId=${encodeURIComponent(workspaceId)}&themeType=dark&token=${encodeURIComponent(token)}`; - socket = new WebSocket(url); - socket.binaryType = "arraybuffer"; - socketRef.current = socket; - - socket.onmessage = (event) => { - if (event.data instanceof ArrayBuffer) { - terminal?.write(new Uint8Array(event.data)); - return; - } - let message: TerminalServerMessage; - try { - message = JSON.parse(String(event.data)) as TerminalServerMessage; - } catch { - return; - } + const connection = new TerminalConnection( + { workspaceId, terminalId, routingKey }, + { + onBinary: (bytes) => terminal.write(bytes), + onControl: (message) => { switch (message.type) { case "attached": + setErrorMessage(null); setState("open"); sendResize(); return; case "exit": - terminal?.write( + terminal.write( `\r\n\x1b[33m[process exited code=${message.exitCode}]\x1b[0m\r\n`, ); setState("exited"); @@ -178,53 +140,30 @@ export function WebTerminal({ default: return; } - }; - - socket.onclose = () => { - setState((previous) => - previous === "open" || previous === "connecting" - ? "error" - : previous, - ); - }; - - socket.onerror = () => { - setErrorMessage("WebSocket connection failed."); - }; - - terminal.onData((data) => { - const activeSocket = socketRef.current; - if (activeSocket?.readyState === WebSocket.OPEN) { - activeSocket.send(JSON.stringify({ type: "input", data })); - } - }); - - resizeObserver = new ResizeObserver(refit); - resizeObserver.observe(container); - visualViewport?.addEventListener("resize", refit); - visualViewport?.addEventListener("scroll", refit); - } catch (caught) { - if (cancelled) return; - setErrorMessage( - caught instanceof Error ? caught.message : String(caught), - ); - setState("error"); - } - })(); + }, + onStateChange: (next) => setState(next), + }, + ); + connectionRef.current = connection; + connection.start(); + + terminal.onData((data) => { + connectionRef.current?.send({ type: "input", data }); + }); + + const resizeObserver = new ResizeObserver(refit); + resizeObserver.observe(container); + visualViewport?.addEventListener("resize", refit); + visualViewport?.addEventListener("scroll", refit); return () => { - cancelled = true; if (resizeTimer !== null) clearTimeout(resizeTimer); - resizeObserver?.disconnect(); + resizeObserver.disconnect(); visualViewport?.removeEventListener("resize", refit); visualViewport?.removeEventListener("scroll", refit); - try { - socket?.close(); - } catch { - // best-effort - } - terminal?.dispose(); - socketRef.current = null; + connection.dispose(); + connectionRef.current = null; + terminal.dispose(); }; }, [workspaceId, terminalId, routingKey]); @@ -239,9 +178,11 @@ export function WebTerminal({ > {state === "connecting" ? "Connecting…" - : state === "exited" - ? "Process exited." - : (errorMessage ?? "Disconnected.")} + : state === "reconnecting" + ? "Reconnecting…" + : state === "exited" + ? "Process exited." + : (errorMessage ?? "Disconnected.")} )}