From 874a6f193dee0b14de95e74a0ca4700f4dafccf9 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 2 Aug 2026 19:04:45 +0800 Subject: [PATCH] Stabilize WebCRT multi-tab detach and page navigation. Mint a fresh ws_ticket on every terminal mount, soft-retry transient WS drops without killing the PTY, auto-reattach on tab focus, and persist open sessions across leaving /webcrt. Co-authored-by: Cursor --- web/src/components/WebTerminal.tsx | 206 +++++++++++++++++------------ web/src/pages/WebcrtPage.tsx | 149 ++++++++++++++++++--- 2 files changed, 246 insertions(+), 109 deletions(-) diff --git a/web/src/components/WebTerminal.tsx b/web/src/components/WebTerminal.tsx index 2f638f7..0fb9ed4 100644 --- a/web/src/components/WebTerminal.tsx +++ b/web/src/components/WebTerminal.tsx @@ -3,6 +3,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { useI18n } from "../i18n"; +import { webcrtWsUrl } from "../services/api"; import { applyKeywordHighlight, type KeywordHighlightConfig, @@ -56,7 +57,10 @@ function xtermThemeFromColors(colors: TermColors) { } type Props = { - wsUrl: string; + /** Mint a fresh ws_ticket on every mount when set (preferred). */ + sessionId?: string; + /** Fallback URL; prefer sessionId so tab focus does not reuse a spent ticket. */ + wsUrl?: string; title?: string; recording?: boolean; autoFocus?: boolean; @@ -171,6 +175,7 @@ function loadPrefs(): { copyOnSelect: boolean; pasteDelayMs: number } { export const WebTerminal = forwardRef(function WebTerminal( { wsUrl, + sessionId, title, recording, autoFocus = true, @@ -397,6 +402,7 @@ export const WebTerminal = forwardRef(function WebTerm useEffect(() => { const host = hostRef.current; if (!host) return; + if (!sessionId && !wsUrl) return; const term = new Terminal({ cursorBlink: true, @@ -437,12 +443,10 @@ export const WebTerminal = forwardRef(function WebTerm // Guard against React StrictMode remount: the first WS teardown must not // report closed/error after a newer socket owns the terminal. let cancelled = false; - const ws = new WebSocket(wsUrl); - ws.binaryType = "arraybuffer"; - wsRef.current = ws; + let ws: WebSocket | null = null; onStatusRef.current?.("connecting"); - const isActiveSocket = () => !cancelled && wsRef.current === ws; + const isActiveSocket = () => !cancelled && !!ws && wsRef.current === ws; // Coalesce high-rate stdout into one paint frame to cut xterm write churn. let writeBuf = ""; @@ -478,102 +482,128 @@ export const WebTerminal = forwardRef(function WebTerm sendJson({ type: "resize", cols: term.cols, rows: term.rows }); }; - ws.onopen = () => { - if (!isActiveSocket()) return; - onStatusRef.current?.("open"); - sendResize(); - onReadyRef.current?.(); - window.setTimeout(maybeFocus, 30); - }; + const attachHandlers = (socket: WebSocket) => { + socket.onopen = () => { + if (!isActiveSocket()) return; + onStatusRef.current?.("open"); + sendResize(); + onReadyRef.current?.(); + window.setTimeout(maybeFocus, 30); + }; - ws.onmessage = (ev) => { - if (!isActiveSocket()) return; - if (ev.data instanceof ArrayBuffer) { - writeStdout(decodeBytes(ev.data, encodingRef.current)); - maybeFocus(); - return; - } - if (typeof Blob !== "undefined" && ev.data instanceof Blob) { - void ev.data.arrayBuffer().then((buf) => { - if (!isActiveSocket()) return; - writeStdout(decodeBytes(buf, encodingRef.current)); - maybeFocus(); - }); - return; - } - try { - const msg = JSON.parse(String(ev.data || "{}")) as { - type?: string; - data?: string; - state?: string; - message?: string; - phase?: string; - sftp_ready?: boolean; - cli_hop?: boolean; - }; - if (msg.type === "stdout" && typeof msg.data === "string") { - writeStdout(msg.data); + socket.onmessage = (ev) => { + if (!isActiveSocket()) return; + if (ev.data instanceof ArrayBuffer) { + writeStdout(decodeBytes(ev.data, encodingRef.current)); maybeFocus(); return; } - if (msg.type === "status") { - if (!isActiveSocket()) return; - const phase = typeof msg.phase === "string" ? msg.phase : undefined; - const meta = - typeof msg.sftp_ready === "boolean" || typeof msg.cli_hop === "boolean" - ? { - sftpReady: typeof msg.sftp_ready === "boolean" ? msg.sftp_ready : undefined, - cliHop: typeof msg.cli_hop === "boolean" ? msg.cli_hop : undefined, - } - : undefined; - onStatusRef.current?.(String(msg.state || ""), msg.message, phase, meta); - if (msg.state === "connected" || msg.state === "connecting") { + if (typeof Blob !== "undefined" && ev.data instanceof Blob) { + void ev.data.arrayBuffer().then((buf) => { + if (!isActiveSocket()) return; + writeStdout(decodeBytes(buf, encodingRef.current)); + maybeFocus(); + }); + return; + } + try { + const msg = JSON.parse(String(ev.data || "{}")) as { + type?: string; + data?: string; + state?: string; + message?: string; + phase?: string; + sftp_ready?: boolean; + cli_hop?: boolean; + }; + if (msg.type === "stdout" && typeof msg.data === "string") { + writeStdout(msg.data); maybeFocus(); return; } - if (msg.state === "warning") { - const m = String(msg.message || ""); - const dropMatch = /^queue_dropped:(\d+)/i.exec(m); - if (dropMatch) { + if (msg.type === "status") { + if (!isActiveSocket()) return; + const phase = typeof msg.phase === "string" ? msg.phase : undefined; + const meta = + typeof msg.sftp_ready === "boolean" || typeof msg.cli_hop === "boolean" + ? { + sftpReady: typeof msg.sftp_ready === "boolean" ? msg.sftp_ready : undefined, + cliHop: typeof msg.cli_hop === "boolean" ? msg.cli_hop : undefined, + } + : undefined; + onStatusRef.current?.(String(msg.state || ""), msg.message, phase, meta); + if (msg.state === "connected" || msg.state === "connecting") { + maybeFocus(); + return; + } + if (msg.state === "warning") { + const m = String(msg.message || ""); + const dropMatch = /^queue_dropped:(\d+)/i.exec(m); + if (dropMatch) { + term.writeln( + `\r\n\x1b[33m${tRef.current("webcrt.term.outputTruncated", { count: dropMatch[1] })}\x1b[0m`, + ); + } + return; + } + if (msg.state === "closed" || msg.state === "error") { + const detail = msg.message ? `: ${msg.message}` : ""; term.writeln( - `\r\n\x1b[33m${tRef.current("webcrt.term.outputTruncated", { count: dropMatch[1] })}\x1b[0m`, + `\r\n\x1b[33m${tRef.current("webcrt.term.sessionStatus", { + state: String(msg.state || ""), + detail, + })}\x1b[0m`, ); } return; } - if (msg.state === "closed" || msg.state === "error") { - const detail = msg.message ? `: ${msg.message}` : ""; - term.writeln( - `\r\n\x1b[33m${tRef.current("webcrt.term.sessionStatus", { - state: String(msg.state || ""), - detail, - })}\x1b[0m`, - ); - } + if (msg.type === "pong") return; + } catch { + writeStdout(String(ev.data || "")); + } + }; + + socket.onerror = () => { + if (!isActiveSocket()) return; + onStatusRef.current?.("error", "websocket_error"); + }; + + socket.onclose = (ev) => { + // Intentional unmount/remount closes the socket; do not flip UI to "closed". + if (!isActiveSocket()) return; + onStatusRef.current?.("closed", `websocket_closed:${ev.code}`); + }; + + }; + + void (async () => { + let url = String(wsUrl || "").trim(); + if (sessionId) { + try { + url = await webcrtWsUrl(sessionId); + } catch { + if (!cancelled) onStatusRef.current?.("error", "websocket_error"); return; } - if (msg.type === "pong") return; - } catch { - writeStdout(String(ev.data || "")); } - }; - - ws.onerror = () => { - if (!isActiveSocket()) return; - onStatusRef.current?.("error", "websocket_error"); - term.writeln(`\r\n\x1b[31m${tRef.current("webcrt.term.wsError")}\x1b[0m`); - }; - - ws.onclose = (ev) => { - // Intentional unmount/remount closes the socket; do not flip UI to "closed". - if (!isActiveSocket()) return; - onStatusRef.current?.("closed", `websocket_closed:${ev.code}`); - if (!ev.wasClean) { - term.writeln( - `\r\n\x1b[33m${tRef.current("webcrt.term.wsClosed", { code: ev.code })}\x1b[0m`, - ); + if (cancelled || !url) { + if (!cancelled) onStatusRef.current?.("error", "websocket_error"); + return; } - }; + const socket = new WebSocket(url); + socket.binaryType = "arraybuffer"; + if (cancelled) { + try { + socket.close(); + } catch { + /* ignore */ + } + return; + } + ws = socket; + wsRef.current = socket; + attachHandlers(socket); + })(); const dataDisposable = term.onData((data) => { const normalized = data.replace(/\x7f/g, "\x08"); @@ -677,7 +707,7 @@ export const WebTerminal = forwardRef(function WebTerm selDisposable.dispose(); if (wsRef.current === ws) wsRef.current = null; try { - ws.close(); + ws?.close(); } catch { /* ignore */ } @@ -685,8 +715,8 @@ export const WebTerminal = forwardRef(function WebTerm termRef.current = null; fitRef.current = null; }; - // title is display-only; remounting on title change tears down a live WS. - }, [wsUrl]); + // Fresh ticket per mount when sessionId is set; wsUrl is fallback only. + }, [wsUrl, sessionId]); const pasteFromClipboard = async () => { try { diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index 5c2132c..343e1a9 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -78,8 +78,9 @@ const KEEPALIVE_OPTIONS = [0, 15, 30, 60, 120] as const; /** Compact recording chunks before they grow unbounded (join + trim). */ const LOG_COMPACT_CHUNKS = 1500; const LOG_MAX_CHARS = 8 * 1024 * 1024; -/** Keep active + (N-1) recent tabs mounted; colder tabs detach WS/xterm until focused. */ -const WARM_TAB_LIMIT = 2; +/** Keep active + recent tabs mounted; colder tabs detach WS until focused (fresh ticket on remount). */ +const WARM_TAB_LIMIT = 8; +const OPEN_TABS_STORAGE_KEY = "netx.webcrt.openTabs.v1"; type ColorSchemeId = "dark" | "blackWhite" | "whiteBlack" | "greenBlack" | "amberBlack" | "custom"; @@ -667,10 +668,42 @@ export function WebcrtPage() { const [keywordInput, setKeywordInput] = useState(""); const [keyword, setKeyword] = useState(""); const [page, setPage] = useState(1); - const [tabs, setTabs] = useState([]); - const [activeTabKey, setActiveTabKey] = useState(""); + const [tabs, setTabs] = useState(() => { + try { + const raw = sessionStorage.getItem(OPEN_TABS_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as { tabs?: TermTab[] }; + const items = Array.isArray(parsed.tabs) ? parsed.tabs : []; + return items + .filter((t) => t && t.key && t.target && t.sessionId) + .map((t) => ({ + ...t, + // Force remount + fresh ticket after returning to the page. + status: "connecting" as const, + connectPhase: "authenticating" as const, + termEpoch: Number(t.termEpoch || 0) + 1, + wsUrl: "", + errorMessage: undefined, + })); + } catch { + return []; + } + }); + const [activeTabKey, setActiveTabKey] = useState(() => { + try { + const raw = sessionStorage.getItem(OPEN_TABS_STORAGE_KEY); + if (!raw) return ""; + const parsed = JSON.parse(raw) as { activeTabKey?: string; tabs?: TermTab[] }; + const key = String(parsed.activeTabKey || ""); + if (key && (parsed.tabs || []).some((t) => t.key === key && t.sessionId)) return key; + const first = (parsed.tabs || []).find((t) => t.sessionId); + return first?.key || ""; + } catch { + return ""; + } + }); /** MRU tab keys for warm-mount (active always mounts via isActive even before this updates). */ - const [warmOrder, setWarmOrder] = useState([]); + const [warmOrder, setWarmOrder] = useState(() => (activeTabKey ? [activeTabKey] : [])); const [sftpOpen, setSftpOpen] = useState(false); const [sftpPath, setSftpPath] = useState("."); const [sftpBusy, setSftpBusy] = useState(false); @@ -723,6 +756,8 @@ export function WebcrtPage() { /** Chunk lists avoid O(n²) string append while recording. */ const logBuffersRef = useRef>(new Map()); const lastQueueDropToastAtRef = useRef(0); + /** Limit silent WS remount retries per tab to avoid storms. */ + const softWsRetryRef = useRef>(new Map()); const optionsMenuRef = useRef(null); const tabMenuRef = useRef(null); const treeMenuRef = useRef(null); @@ -736,6 +771,32 @@ export function WebcrtPage() { const warmTabKeys = useMemo(() => new Set(warmOrder.slice(0, WARM_TAB_LIMIT)), [warmOrder]); + // Persist open tabs so leaving /webcrt and coming back can re-attach within detach grace. + useEffect(() => { + const slim = tabs + .filter((t) => t.sessionId) + .map((t) => ({ + key: t.key, + sessionId: t.sessionId, + wsUrl: "", + termEpoch: t.termEpoch, + target: t.target, + status: t.status, + recording: t.recording, + encoding: t.encoding, + cliHop: t.cliHop, + sftpReady: t.sftpReady, + })); + try { + sessionStorage.setItem( + OPEN_TABS_STORAGE_KEY, + JSON.stringify({ tabs: slim, activeTabKey }), + ); + } catch { + /* ignore quota */ + } + }, [tabs, activeTabKey]); + useEffect(() => { if (!optionsMenuOpen) return; const onDoc = (e: MouseEvent) => { @@ -976,16 +1037,13 @@ export function WebcrtPage() { const reattachTab = useCallback( (tab: TermTab) => { if (!tab.sessionId) return false; + // WebTerminal mints a fresh ticket from sessionId on each mount. updateTab(tab.key, { status: "connecting", connectPhase: "authenticating", termEpoch: tab.termEpoch + 1, - wsUrl: "", errorMessage: undefined, }); - void webcrtWsUrl(tab.sessionId).then((wsUrl) => { - updateTab(tab.key, { wsUrl }); - }); setActiveTabKey(tab.key); return true; }, @@ -2098,6 +2156,18 @@ export function WebcrtPage() { onClick={() => { setActiveTabKey(tab.key); setTabMenu(null); + const cur = tabsRef.current.find((x) => x.key === tab.key); + // Soft-closed tabs still hold a live backend session — re-attach immediately. + if ( + cur?.sessionId && + (cur.status === "closed" || cur.status === "error") && + !isDeviceClosedMessage(cur.errorMessage) && + !isSshAuthFailure(cur.errorMessage) && + !isSessionGoneError(cur.errorMessage) + ) { + reattachTab(cur); + return; + } window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 0); }} onContextMenu={(e) => { @@ -2199,9 +2269,11 @@ export function WebcrtPage() {
{tabs.map((tab) => { const isActive = activeTabKey === tab.key; - // Cap concurrent xterm+WS: active always mounts; keep one recent background warm. - // Colder tabs detach (server grace) and re-attach on focus via the same session_id. - const mountTerminal = Boolean(tab.wsUrl && (isActive || warmTabKeys.has(tab.key))); + // Cap concurrent xterm+WS: active always mounts; keep recent tabs warm. + // Colder tabs detach (server grace); remount mints a fresh ws_ticket via sessionId. + const mountTerminal = Boolean( + tab.sessionId && (isActive || warmTabKeys.has(tab.key)), + ); return (
{ if (state === "connected") { + softWsRetryRef.current.delete(tab.key); updateTab(tab.key, { status: "connected", connectPhase: undefined, @@ -2341,19 +2415,39 @@ export function WebcrtPage() { void openTarget(tab.target, { force: true }); return; } - // Transient browser WS failures (StrictMode remount / ticket race) must - // NOT delete the device PTY — keep session_id for reconnect/reattach. + // Transient browser WS failures (tab switch / page leave / ticket race): + // keep session_id and silently remount with a fresh ticket (once). const transientWs = /^(websocket_error|websocket_closed)/i.test(String(message || "")) || String(message || "").toLowerCase().includes("websocket"); + if (transientWs && sid) { + const retries = softWsRetryRef.current.get(tab.key) || 0; + if (retries < 1) { + softWsRetryRef.current.set(tab.key, retries + 1); + updateTab(tab.key, { + status: "connecting", + connectPhase: "authenticating", + termEpoch: tab.termEpoch + 1, + errorMessage: undefined, + }); + return; + } + softWsRetryRef.current.set(tab.key, 0); + updateTab(tab.key, { + status: "closed", + connectPhase: undefined, + errorMessage: t("webcrt.disconnectBanner"), + }); + return; + } updateTab(tab.key, { - status: transientWs ? "closed" : "error", - sessionId: transientWs ? tab.sessionId : "", - wsUrl: transientWs ? tab.wsUrl : "", + status: "error", + sessionId: "", + wsUrl: "", connectPhase: undefined, errorMessage: webcrtErrorMessage(errMsg, t), }); - if (sid && !transientWs) { + if (sid) { void closeWebcrtSession(sid).catch(() => undefined); } if (isInventoryTarget(tab.target) && isSshAuthFailure(errMsg)) { @@ -2361,8 +2455,21 @@ export function WebcrtPage() { } } else if (state === "closed") { const msg = String(message || ""); - // Local WS drop: keep session_id so reconnect / tab-focus can re-attach. - if (msg.startsWith("websocket_closed:")) { + // Local WS drop (unmount / navigate away): PTY stays in detach grace. + // Remount with a fresh ticket instead of showing a hard disconnect. + if (msg.startsWith("websocket_closed:") && tab.sessionId) { + const retries = softWsRetryRef.current.get(tab.key) || 0; + if (retries < 1) { + softWsRetryRef.current.set(tab.key, retries + 1); + updateTab(tab.key, { + status: "connecting", + connectPhase: "authenticating", + termEpoch: tab.termEpoch + 1, + errorMessage: undefined, + }); + return; + } + softWsRetryRef.current.set(tab.key, 0); updateTab(tab.key, { status: "closed", connectPhase: undefined,