mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 04:00:47 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
9314f11ce7
commit
874a6f193d
2 changed files with 246 additions and 109 deletions
|
|
@ -3,6 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
|
import { webcrtWsUrl } from "../services/api";
|
||||||
import {
|
import {
|
||||||
applyKeywordHighlight,
|
applyKeywordHighlight,
|
||||||
type KeywordHighlightConfig,
|
type KeywordHighlightConfig,
|
||||||
|
|
@ -56,7 +57,10 @@ function xtermThemeFromColors(colors: TermColors) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
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;
|
title?: string;
|
||||||
recording?: boolean;
|
recording?: boolean;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
|
@ -171,6 +175,7 @@ function loadPrefs(): { copyOnSelect: boolean; pasteDelayMs: number } {
|
||||||
export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerminal(
|
export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerminal(
|
||||||
{
|
{
|
||||||
wsUrl,
|
wsUrl,
|
||||||
|
sessionId,
|
||||||
title,
|
title,
|
||||||
recording,
|
recording,
|
||||||
autoFocus = true,
|
autoFocus = true,
|
||||||
|
|
@ -397,6 +402,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
if (!sessionId && !wsUrl) return;
|
||||||
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
|
|
@ -437,12 +443,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
// Guard against React StrictMode remount: the first WS teardown must not
|
// Guard against React StrictMode remount: the first WS teardown must not
|
||||||
// report closed/error after a newer socket owns the terminal.
|
// report closed/error after a newer socket owns the terminal.
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const ws = new WebSocket(wsUrl);
|
let ws: WebSocket | null = null;
|
||||||
ws.binaryType = "arraybuffer";
|
|
||||||
wsRef.current = ws;
|
|
||||||
onStatusRef.current?.("connecting");
|
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.
|
// Coalesce high-rate stdout into one paint frame to cut xterm write churn.
|
||||||
let writeBuf = "";
|
let writeBuf = "";
|
||||||
|
|
@ -478,7 +482,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
sendJson({ type: "resize", cols: term.cols, rows: term.rows });
|
sendJson({ type: "resize", cols: term.cols, rows: term.rows });
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onopen = () => {
|
const attachHandlers = (socket: WebSocket) => {
|
||||||
|
socket.onopen = () => {
|
||||||
if (!isActiveSocket()) return;
|
if (!isActiveSocket()) return;
|
||||||
onStatusRef.current?.("open");
|
onStatusRef.current?.("open");
|
||||||
sendResize();
|
sendResize();
|
||||||
|
|
@ -486,7 +491,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
window.setTimeout(maybeFocus, 30);
|
window.setTimeout(maybeFocus, 30);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
socket.onmessage = (ev) => {
|
||||||
if (!isActiveSocket()) return;
|
if (!isActiveSocket()) return;
|
||||||
if (ev.data instanceof ArrayBuffer) {
|
if (ev.data instanceof ArrayBuffer) {
|
||||||
writeStdout(decodeBytes(ev.data, encodingRef.current));
|
writeStdout(decodeBytes(ev.data, encodingRef.current));
|
||||||
|
|
@ -558,23 +563,48 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {
|
socket.onerror = () => {
|
||||||
if (!isActiveSocket()) return;
|
if (!isActiveSocket()) return;
|
||||||
onStatusRef.current?.("error", "websocket_error");
|
onStatusRef.current?.("error", "websocket_error");
|
||||||
term.writeln(`\r\n\x1b[31m${tRef.current("webcrt.term.wsError")}\x1b[0m`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (ev) => {
|
socket.onclose = (ev) => {
|
||||||
// Intentional unmount/remount closes the socket; do not flip UI to "closed".
|
// Intentional unmount/remount closes the socket; do not flip UI to "closed".
|
||||||
if (!isActiveSocket()) return;
|
if (!isActiveSocket()) return;
|
||||||
onStatusRef.current?.("closed", `websocket_closed:${ev.code}`);
|
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`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
let url = String(wsUrl || "").trim();
|
||||||
|
if (sessionId) {
|
||||||
|
try {
|
||||||
|
url = await webcrtWsUrl(sessionId);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) onStatusRef.current?.("error", "websocket_error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 dataDisposable = term.onData((data) => {
|
||||||
const normalized = data.replace(/\x7f/g, "\x08");
|
const normalized = data.replace(/\x7f/g, "\x08");
|
||||||
// Large pastes from xterm arrive as one onData blob.
|
// Large pastes from xterm arrive as one onData blob.
|
||||||
|
|
@ -677,7 +707,7 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
selDisposable.dispose();
|
selDisposable.dispose();
|
||||||
if (wsRef.current === ws) wsRef.current = null;
|
if (wsRef.current === ws) wsRef.current = null;
|
||||||
try {
|
try {
|
||||||
ws.close();
|
ws?.close();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
@ -685,8 +715,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
||||||
termRef.current = null;
|
termRef.current = null;
|
||||||
fitRef.current = null;
|
fitRef.current = null;
|
||||||
};
|
};
|
||||||
// title is display-only; remounting on title change tears down a live WS.
|
// Fresh ticket per mount when sessionId is set; wsUrl is fallback only.
|
||||||
}, [wsUrl]);
|
}, [wsUrl, sessionId]);
|
||||||
|
|
||||||
const pasteFromClipboard = async () => {
|
const pasteFromClipboard = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,9 @@ const KEEPALIVE_OPTIONS = [0, 15, 30, 60, 120] as const;
|
||||||
/** Compact recording chunks before they grow unbounded (join + trim). */
|
/** Compact recording chunks before they grow unbounded (join + trim). */
|
||||||
const LOG_COMPACT_CHUNKS = 1500;
|
const LOG_COMPACT_CHUNKS = 1500;
|
||||||
const LOG_MAX_CHARS = 8 * 1024 * 1024;
|
const LOG_MAX_CHARS = 8 * 1024 * 1024;
|
||||||
/** Keep active + (N-1) recent tabs mounted; colder tabs detach WS/xterm until focused. */
|
/** Keep active + recent tabs mounted; colder tabs detach WS until focused (fresh ticket on remount). */
|
||||||
const WARM_TAB_LIMIT = 2;
|
const WARM_TAB_LIMIT = 8;
|
||||||
|
const OPEN_TABS_STORAGE_KEY = "netx.webcrt.openTabs.v1";
|
||||||
|
|
||||||
type ColorSchemeId = "dark" | "blackWhite" | "whiteBlack" | "greenBlack" | "amberBlack" | "custom";
|
type ColorSchemeId = "dark" | "blackWhite" | "whiteBlack" | "greenBlack" | "amberBlack" | "custom";
|
||||||
|
|
||||||
|
|
@ -667,10 +668,42 @@ export function WebcrtPage() {
|
||||||
const [keywordInput, setKeywordInput] = useState("");
|
const [keywordInput, setKeywordInput] = useState("");
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [tabs, setTabs] = useState<TermTab[]>([]);
|
const [tabs, setTabs] = useState<TermTab[]>(() => {
|
||||||
const [activeTabKey, setActiveTabKey] = 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). */
|
/** MRU tab keys for warm-mount (active always mounts via isActive even before this updates). */
|
||||||
const [warmOrder, setWarmOrder] = useState<string[]>([]);
|
const [warmOrder, setWarmOrder] = useState<string[]>(() => (activeTabKey ? [activeTabKey] : []));
|
||||||
const [sftpOpen, setSftpOpen] = useState(false);
|
const [sftpOpen, setSftpOpen] = useState(false);
|
||||||
const [sftpPath, setSftpPath] = useState(".");
|
const [sftpPath, setSftpPath] = useState(".");
|
||||||
const [sftpBusy, setSftpBusy] = useState(false);
|
const [sftpBusy, setSftpBusy] = useState(false);
|
||||||
|
|
@ -723,6 +756,8 @@ export function WebcrtPage() {
|
||||||
/** Chunk lists avoid O(n²) string append while recording. */
|
/** Chunk lists avoid O(n²) string append while recording. */
|
||||||
const logBuffersRef = useRef<Map<string, string[]>>(new Map());
|
const logBuffersRef = useRef<Map<string, string[]>>(new Map());
|
||||||
const lastQueueDropToastAtRef = useRef(0);
|
const lastQueueDropToastAtRef = useRef(0);
|
||||||
|
/** Limit silent WS remount retries per tab to avoid storms. */
|
||||||
|
const softWsRetryRef = useRef<Map<string, number>>(new Map());
|
||||||
const optionsMenuRef = useRef<HTMLDivElement | null>(null);
|
const optionsMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const tabMenuRef = useRef<HTMLDivElement | null>(null);
|
const tabMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const treeMenuRef = useRef<HTMLDivElement | null>(null);
|
const treeMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
@ -736,6 +771,32 @@ export function WebcrtPage() {
|
||||||
|
|
||||||
const warmTabKeys = useMemo(() => new Set(warmOrder.slice(0, WARM_TAB_LIMIT)), [warmOrder]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!optionsMenuOpen) return;
|
if (!optionsMenuOpen) return;
|
||||||
const onDoc = (e: MouseEvent) => {
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
|
@ -976,16 +1037,13 @@ export function WebcrtPage() {
|
||||||
const reattachTab = useCallback(
|
const reattachTab = useCallback(
|
||||||
(tab: TermTab) => {
|
(tab: TermTab) => {
|
||||||
if (!tab.sessionId) return false;
|
if (!tab.sessionId) return false;
|
||||||
|
// WebTerminal mints a fresh ticket from sessionId on each mount.
|
||||||
updateTab(tab.key, {
|
updateTab(tab.key, {
|
||||||
status: "connecting",
|
status: "connecting",
|
||||||
connectPhase: "authenticating",
|
connectPhase: "authenticating",
|
||||||
termEpoch: tab.termEpoch + 1,
|
termEpoch: tab.termEpoch + 1,
|
||||||
wsUrl: "",
|
|
||||||
errorMessage: undefined,
|
errorMessage: undefined,
|
||||||
});
|
});
|
||||||
void webcrtWsUrl(tab.sessionId).then((wsUrl) => {
|
|
||||||
updateTab(tab.key, { wsUrl });
|
|
||||||
});
|
|
||||||
setActiveTabKey(tab.key);
|
setActiveTabKey(tab.key);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
@ -2098,6 +2156,18 @@ export function WebcrtPage() {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveTabKey(tab.key);
|
setActiveTabKey(tab.key);
|
||||||
setTabMenu(null);
|
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);
|
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 0);
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
|
|
@ -2199,9 +2269,11 @@ export function WebcrtPage() {
|
||||||
<div className="webcrt-main__terms">
|
<div className="webcrt-main__terms">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = activeTabKey === tab.key;
|
const isActive = activeTabKey === tab.key;
|
||||||
// Cap concurrent xterm+WS: active always mounts; keep one recent background warm.
|
// Cap concurrent xterm+WS: active always mounts; keep recent tabs warm.
|
||||||
// Colder tabs detach (server grace) and re-attach on focus via the same session_id.
|
// Colder tabs detach (server grace); remount mints a fresh ws_ticket via sessionId.
|
||||||
const mountTerminal = Boolean(tab.wsUrl && (isActive || warmTabKeys.has(tab.key)));
|
const mountTerminal = Boolean(
|
||||||
|
tab.sessionId && (isActive || warmTabKeys.has(tab.key)),
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
|
|
@ -2259,7 +2331,8 @@ export function WebcrtPage() {
|
||||||
if (handle) termRefs.current.set(tab.key, handle);
|
if (handle) termRefs.current.set(tab.key, handle);
|
||||||
else termRefs.current.delete(tab.key);
|
else termRefs.current.delete(tab.key);
|
||||||
}}
|
}}
|
||||||
wsUrl={tab.wsUrl}
|
sessionId={tab.sessionId || undefined}
|
||||||
|
wsUrl={tab.wsUrl || undefined}
|
||||||
title={deviceLabel(tab.target)}
|
title={deviceLabel(tab.target)}
|
||||||
recording={tab.recording}
|
recording={tab.recording}
|
||||||
encoding={tab.encoding}
|
encoding={tab.encoding}
|
||||||
|
|
@ -2293,6 +2366,7 @@ export function WebcrtPage() {
|
||||||
}}
|
}}
|
||||||
onStatus={(state, message, phase, meta) => {
|
onStatus={(state, message, phase, meta) => {
|
||||||
if (state === "connected") {
|
if (state === "connected") {
|
||||||
|
softWsRetryRef.current.delete(tab.key);
|
||||||
updateTab(tab.key, {
|
updateTab(tab.key, {
|
||||||
status: "connected",
|
status: "connected",
|
||||||
connectPhase: undefined,
|
connectPhase: undefined,
|
||||||
|
|
@ -2341,19 +2415,39 @@ export function WebcrtPage() {
|
||||||
void openTarget(tab.target, { force: true });
|
void openTarget(tab.target, { force: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Transient browser WS failures (StrictMode remount / ticket race) must
|
// Transient browser WS failures (tab switch / page leave / ticket race):
|
||||||
// NOT delete the device PTY — keep session_id for reconnect/reattach.
|
// keep session_id and silently remount with a fresh ticket (once).
|
||||||
const transientWs =
|
const transientWs =
|
||||||
/^(websocket_error|websocket_closed)/i.test(String(message || "")) ||
|
/^(websocket_error|websocket_closed)/i.test(String(message || "")) ||
|
||||||
String(message || "").toLowerCase().includes("websocket");
|
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, {
|
updateTab(tab.key, {
|
||||||
status: transientWs ? "closed" : "error",
|
status: "connecting",
|
||||||
sessionId: transientWs ? tab.sessionId : "",
|
connectPhase: "authenticating",
|
||||||
wsUrl: transientWs ? tab.wsUrl : "",
|
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: "error",
|
||||||
|
sessionId: "",
|
||||||
|
wsUrl: "",
|
||||||
connectPhase: undefined,
|
connectPhase: undefined,
|
||||||
errorMessage: webcrtErrorMessage(errMsg, t),
|
errorMessage: webcrtErrorMessage(errMsg, t),
|
||||||
});
|
});
|
||||||
if (sid && !transientWs) {
|
if (sid) {
|
||||||
void closeWebcrtSession(sid).catch(() => undefined);
|
void closeWebcrtSession(sid).catch(() => undefined);
|
||||||
}
|
}
|
||||||
if (isInventoryTarget(tab.target) && isSshAuthFailure(errMsg)) {
|
if (isInventoryTarget(tab.target) && isSshAuthFailure(errMsg)) {
|
||||||
|
|
@ -2361,8 +2455,21 @@ export function WebcrtPage() {
|
||||||
}
|
}
|
||||||
} else if (state === "closed") {
|
} else if (state === "closed") {
|
||||||
const msg = String(message || "");
|
const msg = String(message || "");
|
||||||
// Local WS drop: keep session_id so reconnect / tab-focus can re-attach.
|
// Local WS drop (unmount / navigate away): PTY stays in detach grace.
|
||||||
if (msg.startsWith("websocket_closed:")) {
|
// 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, {
|
updateTab(tab.key, {
|
||||||
status: "closed",
|
status: "closed",
|
||||||
connectPhase: undefined,
|
connectPhase: undefined,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue