fix(web): keep chat PTY connections alive
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user