fix(webcrt): keep session across WS remount and fix blank terminal

Allow brief WebSocket re-attach after React StrictMode cleanup, ensure terminal pane has layout height, and show connect status in xterm.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-29 17:32:23 +08:00
parent 873f2bfe34
commit 183cb7eec9
5 changed files with 95 additions and 21 deletions

View file

@ -15,6 +15,8 @@ from .db import get_db
from .webcrt_service import ( from .webcrt_service import (
close_session, close_session,
create_session, create_session,
detach_session,
get_session,
list_sessions, list_sessions,
mark_attached, mark_attached,
) )
@ -151,6 +153,12 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
sess.touch() sess.touch()
await websocket.send_json({"type": "pong"}) await websocket.send_json({"type": "pong"})
elif mtype == "close": elif mtype == "close":
stop.set()
close_session(
session_id,
reason="client_close",
client=_client_label(websocket=websocket),
)
break break
except WebSocketDisconnect: except WebSocketDisconnect:
_log.info("webcrt ws disconnected session=%s", session_id) _log.info("webcrt ws disconnected session=%s", session_id)
@ -163,8 +171,10 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
await reader_task await reader_task
except Exception: except Exception:
pass pass
close_session( # Keep device session briefly so React remount / blip can re-attach.
session_id, if get_session(session_id) is not None:
reason="ws_disconnect", detach_session(
client=_client_label(websocket=websocket), session_id,
) grace_sec=8.0,
client=_client_label(websocket=websocket),
)

View file

@ -66,6 +66,7 @@ class WebcrtSession:
created_at: float = field(default_factory=time.time) created_at: float = field(default_factory=time.time)
last_activity: float = field(default_factory=time.time) last_activity: float = field(default_factory=time.time)
attached: bool = False attached: bool = False
detach_deadline: float | None = None
closed: bool = False closed: bool = False
close_reason: str = "" close_reason: str = ""
out_queue: queue.Queue[bytes | None] = field(default_factory=queue.Queue) out_queue: queue.Queue[bytes | None] = field(default_factory=queue.Queue)
@ -181,7 +182,7 @@ def _reaper_loop() -> None:
_reap_sessions() _reap_sessions()
except Exception: except Exception:
_log.exception("webcrt reaper failed") _log.exception("webcrt reaper failed")
time.sleep(5) time.sleep(2)
def _reap_sessions() -> None: def _reap_sessions() -> None:
@ -194,7 +195,15 @@ def _reap_sessions() -> None:
if sess.closed: if sess.closed:
_sessions.pop(sess.session_id, None) _sessions.pop(sess.session_id, None)
continue continue
if not sess.attached and (now - sess.created_at) > attach: if sess.attached:
if (now - sess.last_activity) > idle:
to_close.append((sess, "idle_timeout"))
continue
# Not attached: either never attached, or briefly detached for reconnect.
if sess.detach_deadline is not None:
if now >= sess.detach_deadline:
to_close.append((sess, "detach_timeout"))
elif (now - sess.created_at) > attach:
to_close.append((sess, "attach_timeout")) to_close.append((sess, "attach_timeout"))
elif (now - sess.last_activity) > idle: elif (now - sess.last_activity) > idle:
to_close.append((sess, "idle_timeout")) to_close.append((sess, "idle_timeout"))
@ -327,14 +336,35 @@ def mark_attached(session_id: str) -> WebcrtSession:
sess = get_session(session_id) sess = get_session(session_id)
if sess is None: if sess is None:
raise HTTPException(status_code=404, detail="webcrt_session_not_found") raise HTTPException(status_code=404, detail="webcrt_session_not_found")
if sess.attached: # Allow re-attach after brief WS drop (React StrictMode remount / network blip).
raise HTTPException(status_code=409, detail="webcrt_session_already_attached")
sess.attached = True sess.attached = True
sess.detach_deadline = None
sess.touch() sess.touch()
_audit("session_attached", session_id=session_id, ne_id=sess.ne_id, ne_ip=sess.ne_ip) _audit("session_attached", session_id=session_id, ne_id=sess.ne_id, ne_ip=sess.ne_ip)
return sess return sess
def detach_session(session_id: str, *, grace_sec: float = 8.0, client: str = "") -> dict[str, Any]:
"""Mark session unattached but keep device channel open briefly for reconnect."""
sess = get_session(session_id)
if sess is None:
return {"ok": True, "session_id": session_id, "detached": False}
if sess.closed:
return {"ok": True, "session_id": session_id, "detached": False}
sess.attached = False
sess.detach_deadline = time.time() + max(1.0, float(grace_sec))
sess.touch()
_audit(
"session_detached",
session_id=session_id,
ne_id=sess.ne_id,
ne_ip=sess.ne_ip,
grace_sec=grace_sec,
client=client or "",
)
return {"ok": True, "session_id": session_id, "detached": True}
def close_session(session_id: str, *, reason: str = "closed", client: str = "") -> dict[str, Any]: def close_session(session_id: str, *, reason: str = "closed", client: str = "") -> dict[str, Any]:
with _sessions_lock: with _sessions_lock:
sess = _sessions.pop(session_id, None) sess = _sessions.pop(session_id, None)

View file

@ -5,11 +5,12 @@ import "@xterm/xterm/css/xterm.css";
type Props = { type Props = {
wsUrl: string; wsUrl: string;
title?: string;
onStatus?: (state: string, message?: string) => void; onStatus?: (state: string, message?: string) => void;
onReady?: () => void; onReady?: () => void;
}; };
export function WebTerminal({ wsUrl, onStatus, onReady }: Props) { export function WebTerminal({ wsUrl, title, onStatus, onReady }: Props) {
const hostRef = useRef<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null); const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null); const fitRef = useRef<FitAddon | null>(null);
@ -40,13 +41,26 @@ export function WebTerminal({ wsUrl, onStatus, onReady }: Props) {
const fit = new FitAddon(); const fit = new FitAddon();
term.loadAddon(fit); term.loadAddon(fit);
term.open(host); term.open(host);
fit.fit();
termRef.current = term; termRef.current = term;
fitRef.current = fit; fitRef.current = fit;
const doFit = () => {
try {
fit.fit();
} catch {
/* ignore */
}
};
// Fit after layout; hidden/zero-size parents need a deferred pass.
requestAnimationFrame(() => {
doFit();
window.setTimeout(doFit, 50);
});
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
wsRef.current = ws; wsRef.current = ws;
onStatusRef.current?.("connecting"); onStatusRef.current?.("connecting");
term.writeln(`\x1b[90mConnecting${title ? ` ${title}` : ""}…\x1b[0m`);
const sendJson = (payload: Record<string, unknown>) => { const sendJson = (payload: Record<string, unknown>) => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
@ -55,12 +69,13 @@ export function WebTerminal({ wsUrl, onStatus, onReady }: Props) {
}; };
const sendResize = () => { const sendResize = () => {
fit.fit(); doFit();
sendJson({ type: "resize", cols: term.cols, rows: term.rows }); sendJson({ type: "resize", cols: term.cols, rows: term.rows });
}; };
ws.onopen = () => { ws.onopen = () => {
onStatusRef.current?.("open"); onStatusRef.current?.("open");
term.writeln("\x1b[32mConnected.\x1b[0m");
sendResize(); sendResize();
onReadyRef.current?.(); onReadyRef.current?.();
}; };
@ -79,6 +94,10 @@ export function WebTerminal({ wsUrl, onStatus, onReady }: Props) {
} }
if (msg.type === "status") { if (msg.type === "status") {
onStatusRef.current?.(String(msg.state || ""), msg.message); onStatusRef.current?.(String(msg.state || ""), msg.message);
if (msg.state === "connected") {
// Server ack; keep terminal clean.
return;
}
if (msg.state === "closed" || msg.state === "error") { if (msg.state === "closed" || msg.state === "error") {
const detail = msg.message ? `: ${msg.message}` : ""; const detail = msg.message ? `: ${msg.message}` : "";
term.writeln(`\r\n\x1b[33m[session ${msg.state}${detail}]\x1b[0m`); term.writeln(`\r\n\x1b[33m[session ${msg.state}${detail}]\x1b[0m`);
@ -93,6 +112,7 @@ export function WebTerminal({ wsUrl, onStatus, onReady }: Props) {
ws.onerror = () => { ws.onerror = () => {
onStatusRef.current?.("error", "websocket_error"); onStatusRef.current?.("error", "websocket_error");
term.writeln("\r\n\x1b[31m[websocket error]\x1b[0m");
}; };
ws.onclose = () => { ws.onclose = () => {
@ -123,10 +143,8 @@ export function WebTerminal({ wsUrl, onStatus, onReady }: Props) {
window.removeEventListener("resize", onWinResize); window.removeEventListener("resize", onWinResize);
ro?.disconnect(); ro?.disconnect();
dataDisposable.dispose(); dataDisposable.dispose();
// Do not send {type:"close"} here — React StrictMode remounts and needs reconnect.
try { try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "close" }));
}
ws.close(); ws.close();
} catch { } catch {
/* ignore */ /* ignore */
@ -136,7 +154,7 @@ export function WebTerminal({ wsUrl, onStatus, onReady }: Props) {
termRef.current = null; termRef.current = null;
fitRef.current = null; fitRef.current = null;
}; };
}, [wsUrl]); }, [wsUrl, title]);
return <div className="webcrt-term" ref={hostRef} />; return <div className="webcrt-term" ref={hostRef} />;
} }

View file

@ -1424,8 +1424,8 @@ pre {
.webcrt-term { .webcrt-term {
width: 100%; width: 100%;
height: min(70vh, 720px); height: 100%;
min-height: 360px; min-height: 240px;
background: #0f172a; background: #0f172a;
padding: 8px; padding: 8px;
box-sizing: border-box; box-sizing: border-box;
@ -1656,6 +1656,9 @@ pre {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0;
height: 100%;
overflow: hidden;
background: #808080; background: #808080;
} }
@ -1666,6 +1669,7 @@ pre {
background: #6e6e6e; background: #6e6e6e;
border-bottom: 1px solid #505050; border-bottom: 1px solid #505050;
min-height: 28px; min-height: 28px;
flex-shrink: 0;
} }
.webcrt-tabs__item { .webcrt-tabs__item {
@ -1697,16 +1701,26 @@ pre {
.webcrt-main__body { .webcrt-main__body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
position: relative;
background: #0f172a; background: #0f172a;
} }
.webcrt-main__pane { .webcrt-main__pane {
height: 100%; position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
.webcrt-main__pane[hidden] {
display: none !important;
} }
.webcrt-main__pane .webcrt-term { .webcrt-main__pane .webcrt-term {
height: 100%; flex: 1;
min-height: 0; width: 100%;
height: auto !important;
min-height: 240px !important;
} }
.webcrt-main__empty, .webcrt-main__empty,
@ -1733,6 +1747,7 @@ pre {
border-top: 1px solid #a8a8a8; border-top: 1px solid #a8a8a8;
font-size: 11px; font-size: 11px;
color: #334155; color: #334155;
flex-shrink: 0;
} }
@media (max-width: 900px) { @media (max-width: 900px) {

View file

@ -350,6 +350,7 @@ export function WebcrtPage() {
{tab.wsUrl ? ( {tab.wsUrl ? (
<WebTerminal <WebTerminal
wsUrl={tab.wsUrl} wsUrl={tab.wsUrl}
title={tab.target.ip_address || tab.target.name}
onStatus={(state) => { onStatus={(state) => {
if (state === "open" || state === "connected") updateTab(tab.key, { status: "connected" }); if (state === "open" || state === "connected") updateTab(tab.key, { status: "connected" });
else if (state === "error") updateTab(tab.key, { status: "error" }); else if (state === "error") updateTab(tab.key, { status: "error" });