fix(web): keep chat PTY connections alive

This commit is contained in:
KoNit-K
2026-09-15 21:27:38 +08:00
committed by Teknium
parent af0953985f
commit b23c4fad36
4 changed files with 163 additions and 23 deletions

View File

@@ -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([

View File

@@ -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,

View File

@@ -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(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
await act(async () => {
await Promise.resolve();
});
const socket = FakeWebSocket.instances[0];
await act(async () => {
socket.onclose?.({ code: 1001, reason: "", wasClean: true });
root.render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive={false} />
</MemoryRouter>,
);
});
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");

View File

@@ -4,7 +4,7 @@
* <div host> (dashboard chrome) .
* └─ <div wrapper> (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<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(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<typeof setTimeout> | null = null;
let keepaliveTimer: ReturnType<typeof setInterval> | 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);