diff --git a/web/src/lib/pty-reconnect.test.ts b/web/src/lib/pty-reconnect.test.ts index 995076d41b..56202eb4b3 100644 --- a/web/src/lib/pty-reconnect.test.ts +++ b/web/src/lib/pty-reconnect.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest' import { PTY_RECONNECT_BASE_MS, + PTY_KEEPALIVE_INTERVAL_MS, PTY_RECONNECT_MAX_ATTEMPTS, PTY_RECONNECT_MAX_MS, ptyReconnectDelayMs, shouldBlockPtyInput, + shouldSendPtyKeepalive, shouldReconnectPtyOnPageResume } from './pty-reconnect' @@ -139,6 +141,36 @@ describe('shouldBlockPtyInput', () => { }) }) +describe('shouldSendPtyKeepalive', () => { + it('keeps an active, visible PTY socket alive every 20 seconds', () => { + expect(PTY_KEEPALIVE_INTERVAL_MS).toBe(20_000) + expect( + shouldSendPtyKeepalive({ + isActive: true, + visibilityState: 'visible', + socketReadyState: 1 + }) + ).toBe(true) + }) + + it('does not send control traffic from hidden tabs or closed sockets', () => { + expect( + shouldSendPtyKeepalive({ + isActive: true, + visibilityState: 'hidden', + socketReadyState: 1 + }) + ).toBe(false) + expect( + shouldSendPtyKeepalive({ + isActive: true, + visibilityState: 'visible', + socketReadyState: 3 + }) + ).toBe(false) + }) +}) + describe('ptyReconnectDelayMs', () => { it('doubles from the base on each attempt and clamps at the cap', () => { expect(Array.from({ length: PTY_RECONNECT_MAX_ATTEMPTS }, (_, i) => ptyReconnectDelayMs(i + 1))).toEqual([ diff --git a/web/src/lib/pty-reconnect.ts b/web/src/lib/pty-reconnect.ts index 4a6ce8cf7a..da592461ba 100644 --- a/web/src/lib/pty-reconnect.ts +++ b/web/src/lib/pty-reconnect.ts @@ -28,6 +28,12 @@ export const PTY_RECONNECT_BASE_MS = 250 export const PTY_RECONNECT_MAX_MS = 3000 export const PTY_RECONNECT_MAX_ATTEMPTS = 5 +// Browsers cannot emit WebSocket ping frames directly. A resize control frame +// is consumed by `/api/pty` without reaching the child process, so it is a +// safe application-level keepalive for quiet terminals behind idle-closing +// proxies. +export const PTY_KEEPALIVE_INTERVAL_MS = 20_000 + /** Delay before PTY reconnect `attempt` (1-based: ChatPage bumps its counter before scheduling). */ export function ptyReconnectDelayMs(attempt: number): number { return reconnectBackoffDelayMs(attempt - 1, { @@ -63,6 +69,16 @@ const WS_OPEN = 1 const WS_CLOSING = 2 const WS_CLOSED = 3 +export interface PtyKeepaliveInput { + isActive: boolean + visibilityState?: DocumentVisibilityState + socketReadyState?: number | null +} + +export function shouldSendPtyKeepalive({ isActive, visibilityState, socketReadyState }: PtyKeepaliveInput): boolean { + return isActive && visibilityState !== 'hidden' && socketReadyState === WS_OPEN +} + export function shouldReconnectPtyOnPageResume({ isActive, visibilityState, diff --git a/web/src/pages/ChatPage.test.tsx b/web/src/pages/ChatPage.test.tsx index 77b0d7d257..6fc28d2166 100644 --- a/web/src/pages/ChatPage.test.tsx +++ b/web/src/pages/ChatPage.test.tsx @@ -15,6 +15,10 @@ class FakeFitAddon { } class FakeWebglAddon { + constructor() { + webglAddonConstructed += 1; + } + onContextLoss() { return { dispose() {} }; } @@ -150,7 +154,7 @@ class FakeWebSocket { this.readyState = 3; } - send() {} + send = vi.fn(); } type CloseEventLike = { @@ -161,6 +165,7 @@ type CloseEventLike = { let container: HTMLDivElement; let root: Root; +let webglAddonConstructed = 0; // jsdom runs without an origin here (per-file @vitest-environment jsdom on a // node-default config), so localStorage is undefined. Stub it so components @@ -195,6 +200,7 @@ async function render(ui: ReactNode) { beforeEach(() => { FakeWebSocket.instances = []; + webglAddonConstructed = 0; maybeReloadForLoopbackWsAuthFailure.mockClear(); apiMocks.buildWsUrl.mockReset(); apiMocks.buildWsUrl.mockResolvedValue("ws://localhost/api/pty?channel=chat-1"); @@ -259,6 +265,65 @@ afterEach(async () => { }); describe("ChatPage", () => { + it("uses the canvas renderer and sends a visible PTY keepalive", async () => { + vi.useFakeTimers(); + try { + const { default: ChatPage } = await import("./ChatPage"); + await render( + + + , + ); + await act(async () => { + await Promise.resolve(); + }); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(webglAddonConstructed).toBe(0); + + const socket = FakeWebSocket.instances[0]; + await act(async () => socket.onopen?.()); + socket.send.mockClear(); + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + + expect(socket.send).toHaveBeenCalledWith("\x1b[RESIZE:80;24]"); + } finally { + vi.useRealTimers(); + } + }); + + it("defers a reconnect while the chat tab is inactive", async () => { + vi.useFakeTimers(); + try { + const { default: ChatPage } = await import("./ChatPage"); + await render( + + + , + ); + await act(async () => { + await Promise.resolve(); + }); + const socket = FakeWebSocket.instances[0]; + await act(async () => { + socket.onclose?.({ code: 1001, reason: "", wasClean: true }); + root.render( + + + , + ); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + + expect(FakeWebSocket.instances).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + it("treats loopback 4401 closes as stale-token reload candidates", async () => { const { default: ChatPage } = await import("./ChatPage"); diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 3deb64c7e2..ca74a9eac9 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -4,7 +4,7 @@ *
(dashboard chrome) . * └─
(rounded, dark bg, padded — the "terminal window" . * look that gives the page a distinct visual identity) . - * └─ @xterm/xterm Terminal (WebGL renderer, Unicode 11 widths) . + * └─ @xterm/xterm Terminal (canvas renderer, Unicode 11 widths) . * │ onData keystrokes → WebSocket → PTY master . * │ onResize terminal resize → `\x1b[RESIZE:cols;rows]` . * │ write(data) PTY output bytes → VT100 parser . @@ -19,7 +19,6 @@ import { FitAddon } from "@xterm/addon-fit"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; -import { WebglAddon } from "@xterm/addon-webgl"; import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { Button } from "@nous-research/ui/ui/components/button"; @@ -43,6 +42,7 @@ import { shouldRestoreTerminalFocus } from "@/lib/pty-focus"; import { PtyResumeSanitizer } from "@/lib/pty-resume-sanitizer"; import { PTY_CONNECTING_TIMEOUT_MS, + PTY_KEEPALIVE_INTERVAL_MS, PTY_RECONNECT_INPUT_MESSAGE, PTY_RECONNECT_MAX_ATTEMPTS, PTY_RESUME_RECONNECT_THROTTLE_MS, @@ -51,6 +51,7 @@ import { type PtyConnectionState, ptyReconnectDelayMs, shouldBlockPtyInput, + shouldSendPtyKeepalive, shouldReconnectPtyOnPageResume, } from "@/lib/pty-reconnect"; import { @@ -195,6 +196,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const termRef = useRef(null); const fitRef = useRef(null); const wsRef = useRef(null); + const isActiveRef = useRef(isActive); + useEffect(() => { + isActiveRef.current = isActive; + }, [isActive]); const stickToBottomRef = useRef(true); // Exposed to the main metrics-sync effect so it can refit the terminal // the moment `isActive` flips back to true (display:none → display:flex @@ -333,10 +338,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // to misfire on the next activation (#106403: repeated last character). useEffect(() => { if (!isActive) { + clearReconnectTimer(); ptyInputLineRef.current = ""; mobileReplacementInputUntilRef.current = 0; } - }, [isActive]); + }, [clearReconnectTimer, isActive]); // Raw state for the mobile side-sheet + a derived value that force- // closes whenever the chat tab isn't active. The *derived* value is // what side-effects (body-scroll lock, keydown listener, portal render) @@ -923,24 +929,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { }; } - // WebGL draws from a texture atlas sized with device pixels. On phones and - // in DevTools device mode that often produces *visually* much larger cells - // than `fontSize` suggests — users see "huge" text even at 7–9px settings. - // The canvas/DOM renderer tracks `fontSize` faithfully; use it for narrow - // hosts. Wide layouts still get WebGL for crisp box-drawing. - const useWebgl = terminalTierWidthPx(host) >= 768; - if (useWebgl) { - try { - const webgl = new WebglAddon(); - webgl.onContextLoss(() => webgl.dispose()); - term.loadAddon(webgl); - } catch (err) { - console.warn( - "[hermes-chat] WebGL renderer unavailable; falling back to default", - err, - ); - } - } + // Keep the default canvas renderer. Reconnects recreate the xterm + // instance; avoiding WebGL prevents those short-lived instances from + // exhausting the browser's limited WebGL context budget. // Initial fit + resize observer. fit.fit() reads the container's // current bounding box and resizes the terminal grid to match. @@ -1177,6 +1168,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // attempt cannot open a socket behind the replacement this schedules. let ticketSuperseded = false; let ticketTimer: ReturnType | null = null; + let keepaliveTimer: ReturnType | null = null; + const clearKeepaliveTimer = () => { + if (keepaliveTimer) { + clearInterval(keepaliveTimer); + keepaliveTimer = null; + } + }; const clearTicketTimer = () => { if (ticketTimer) { clearTimeout(ticketTimer); @@ -1186,6 +1184,16 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // `code` is null when the attempt died before any socket existed — the // banner then omits the "(code N)" suffix rather than inventing one. const scheduleReconnect = (code: number | null) => { + // ChatPage remains mounted behind other dashboard routes. Do not churn + // through reconnect attempts while it is inactive or the document is + // hidden; the page-resume listener starts one when the user returns. + if ( + !isActiveRef.current || + (typeof document !== "undefined" && document.visibilityState === "hidden") + ) { + setPtyState("closed"); + return; + } if (reconnectTimerRef.current) { return; } @@ -1290,7 +1298,23 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // out against on its first paint. The double-rAF block above will // follow up with the authoritative measurement — at worst Ink // reflows once after the PTY boots, which is imperceptible. - ws.send(`\x1b[RESIZE:${term.cols};${term.rows}]`); + const sendTerminalResize = () => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(`\x1b[RESIZE:${term.cols};${term.rows}]`); + } + }; + sendTerminalResize(); + keepaliveTimer = setInterval(() => { + if ( + shouldSendPtyKeepalive({ + isActive: isActiveRef.current, + visibilityState: document.visibilityState, + socketReadyState: ws.readyState, + }) + ) { + sendTerminalResize(); + } + }, PTY_KEEPALIVE_INTERVAL_MS); // Resumed sessions replay scrollback over the socket. Start pinned to // the bottom so the latest output is in view; released once the user // scrolls up (#59591). @@ -1384,6 +1408,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { }; ws.onclose = (ev) => { + clearKeepaliveTimer(); // Drain buffered sanitizer state. A buffered partial escape is dropped // (writing an unterminated CSI would wedge xterm's parser); a buffered // newline run is emitted collapsed. @@ -1563,6 +1588,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { clearReconnectTimer(); clearConnectingTimer(); clearTicketTimer(); + clearKeepaliveTimer(); ticketSuperseded = true; connectInFlightRef.current = false; // Phase 5.3: ``ws`` is local to the IIFE that opens it (the gated-mode @@ -1720,6 +1746,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { window.addEventListener("pageshow", onResume); window.addEventListener("focus", onResume); window.addEventListener("online", onResume); + onResume(); return () => { document.removeEventListener("visibilitychange", onResume);