perf: v2.5.10 — ready for 10+ concurrent users, leaner locally

Measured on a copy of a real 1.2 GB / 253K-event database, 10 simulated
browsers + 10 hooks/s for 90 s: event-loop delay p99 1239 ms → 32 ms (max
2.0 s → 94 ms), 266 failed requests → 0, 74 → 111 requests/s served, server
RSS 633 → 346 MB. Entry bundle 975 KB raw → 394 KB (106 KB brotli).

Server
- /api/analytics: the events-table scans run in the read worker
  (scoped-stats eventAggregates, read-pool job analyticsEvents); runRead takes
  maxAgeMs — analytics 10 s, /api/workflows 30 s — keyed by the args
- /api/workflows/skill-usage: +event_type and a tool_name range instead of
  LIKE, so it uses idx_events_tool (784 ms → a few ms)
- idx_events_session_type_tool (session_id, event_type, tool_name,
  created_at) replaces idx_events_session_type: covering reads for session
  stats, the hook dedupes and the import resume point (1.2 s → 20 ms); 64 MB
  cache_size and 256 MB mmap_size on both connections
- WAL: the server sets wal_autocheckpoint = 0 and checkpoints PASSIVE every
  DASHBOARD_WAL_CHECKPOINT_MS (5 s) from the read worker's own writable
  connection; a failed checkpoint restores the automatic one (hook COMMIT p95
  109 ms → 15 ms at 100 hooks/s)
- tmux: one list-sessions answers a whole GET /api/lanes and the heal beat
  (liveSessions), shared for 1 s and cleared by this process's new/kill —
  GET /api/run's sync spawns had blocked 60 s of a 90 s run; the heal beat
  skips tmux when nothing runs; attach options chained into 2 spawns
- hooks: TurnDuration/APIError deduped against one read per hook instead of a
  query per transcript entry (23 ms/hook on a 763-turn session); one lane
  scan per hook; hourly upserts skip rows that did not grow; frames are sent
  after the transaction commits (and after the pane binding); new_event
  carries its row id; a split pane's (slot 1–9) hooks no longer rebind the
  lane's session_id
- watchdog liveness probe runs ps asynchronously; transcript newline scan via
  indexOf; startup subagent backfill fingerprints the files it already checked
- pty attach: backpressure (pause past 1 MB unsent, resume under 256 KB)
- static: hashed assets served from their pre-compressed .br/.gz sibling

Client
- hidden tabs neither refetch on WS events nor poll (useLiveRefresh, panes,
  lane cards, git header, changes panel, settings); no per-tab hourly
  /updates/check (the server already checks)
- refetch storms throttled (workspace tree/session list 3 s, /api/run only on
  a run-key change, notification pane lookup only when shown); WS reconnect
  jitter; terminal retry backoff 1.5 → 30 s, stops on 1008
- heavier throttles for aggregates (15 s) and detail views; Monitor keepIfSame;
  GET in-flight dedupe in request()
- lazy Dashboard/History, xterm (with the first terminal), lane modals, Sagi
  (only while enabled); only the active locale loads; Workspace chunk
  prefetched on a /workspace deep link
- Sagi: no work while disabled, pupil via CSS variables, memo avatar
- build: .br/.gz siblings, sourcemap hidden; service worker no longer caches
  /assets/ (cache v3 drops the old one)

Tests: scripts/test-server.js drops the GIT_DIR-family variables a hook
exports, so a suite run from a linked worktree's pre-commit can no longer
commit into the real repository or set core.bare.

Docs: ARCHITECTURE, DATABASE, API, DEPLOYMENT, client README,
docs/plans/perf/README.md (audit, checklist, results).
This commit is contained in:
2026-10-07 13:24:15 +07:00
parent 8fdda66893
commit ca821008e2
75 changed files with 1711 additions and 699 deletions
+327 -260
View File
@@ -9,6 +9,9 @@
* The socket auto-reconnects: a server restart kills the attach PTY, and
* without a retry the pane silently freezes on its last painted frame — it
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
* Retries back off from 1.5 s to 30 s (reset once a socket opens) and stop on
* close code 1008. xterm and its addons are loaded on the first terminal
* (`loadXterm`), not bundled with the Workspace chunk.
* Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
@@ -32,12 +35,10 @@
*/
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { CanvasAddon } from "@xterm/addon-canvas";
import { ClipboardAddon, type IClipboardProvider } from "@xterm/addon-clipboard";
import { SearchAddon } from "@xterm/addon-search";
import { WebLinksAddon } from "@xterm/addon-web-links";
import type { Terminal } from "@xterm/xterm";
import type { CanvasAddon } from "@xterm/addon-canvas";
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
import type { SearchAddon } from "@xterm/addon-search";
import { ArrowDown, ArrowUp, X } from "lucide-react";
import "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme";
@@ -72,11 +73,49 @@ const clipboardProvider: IClipboardProvider = {
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
},
};
// The addon's typings declare one argument, its code takes (base64, provider).
const ClipboardWithProvider = ClipboardAddon as unknown as new (
base64: undefined,
provider: IClipboardProvider
) => ClipboardAddon;
/** xterm and its addons (~400 KB) load with the first terminal, not with the
* Workspace chunk. The result is cached, so every later pane (and a test that
* awaits `loadXterm()` first) sets up synchronously. */
const importXterm = async () => {
const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-canvas"),
import("@xterm/addon-clipboard"),
import("@xterm/addon-search"),
import("@xterm/addon-web-links"),
]);
return {
Terminal: xterm.Terminal,
FitAddon: fit.FitAddon,
CanvasAddon: canvas.CanvasAddon,
SearchAddon: search.SearchAddon,
WebLinksAddon: links.WebLinksAddon,
// The addon's typings declare one argument, its code takes (base64, provider).
ClipboardWithProvider: clipboard.ClipboardAddon as unknown as new (
base64: undefined,
provider: IClipboardProvider
) => ClipboardAddon,
};
};
type Xterm = Awaited<ReturnType<typeof importXterm>>;
let xtermLoaded: Xterm | null = null;
let xtermLoading: Promise<Xterm> | null = null;
export function loadXterm(): Promise<Xterm> {
xtermLoading ??= importXterm().then(
(x) => (xtermLoaded = x),
(err) => {
xtermLoading = null; // a failed chunk fetch may be retried by the next pane
throw err;
}
);
return xtermLoading;
}
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
const RETRY_MIN_MS = 1500;
const RETRY_MAX_MS = 30_000;
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
interface TerminalViewProps {
@@ -160,265 +199,293 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
};
useEffect(() => {
// An explicit fontFamily pins every glyph to one bundled webfont. Left
// unset, xterm falls back to the browser/OS default (`courier-new`),
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
// substitutes those specific glyphs from a different face with a
// mismatched advance width, corrupting the cell it's drawn into even
// under the canvas renderer (which only stops a glyph from shoving its
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
// already bundled for the rest of the UI; main.tsx additionally imports
// its `vietnamese` subset so this covers the diacritics too.
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
// cursor to column 0 on a line feed — and xterm's own docs say not to use
// it on a PTY, where termios already does that translation for the streams
// that want it. tmux scrolls a pane by emitting a bare LF and then keeps
// drawing at the column it was on; with this on, that draw landed at
// column 0 instead, stamping the first characters of the line into the
// left margin of the rows below. That is the torn left edge of issue #33,
// and why it only showed up once content scrolled.
const term = new Terminal({
fontSize: 13,
cursorBlink: true,
fontFamily: '"JetBrains Mono", monospace',
theme: paletteRef.current,
screenReaderMode: readFlag(SCREEN_READER_KEY),
});
termRef.current = term;
const fit = new FitAddon();
term.loadAddon(fit);
const search = new SearchAddon();
term.loadAddon(search);
searchRef.current = search;
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
term.loadAddon(
new WebLinksAddon((_e, uri) => {
window.open(uri, "_blank", "noopener,noreferrer");
})
);
if (containerRef.current) term.open(containerRef.current);
term.textarea?.setAttribute("aria-describedby", hintId);
// Draw each cell at a fixed position instead of letting the browser lay the
// row out as text. xterm's default DOM renderer aligns a row with a single
// `letter-spacing`, which only holds while every glyph comes from one
// monospace font — Vietnamese diacritics and CJK fall through to a fallback
// face whose advance width differs, so glyphs shove their neighbours along
// and the row's tail spills onto the row below. The buffer is correct
// throughout (copying the line yields clean text); only the painting drifts,
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
// context keeps the DOM renderer rather than losing the terminal.
let canvas: CanvasAddon | null = null;
try {
canvas = new CanvasAddon();
term.loadAddon(canvas);
} catch {
canvas = null;
}
fit.fit();
// Take the keyboard as soon as the pane is on screen: the terminal IS the
// page here, and having to click it first meant the first thing typed went
// nowhere. A click elsewhere still moves focus away, as in any editor.
if (autoFocusRef.current) term.focus();
// When the pane's program has NOT enabled wheel reporting, xterm falls back
// to converting each wheel notch on an alt-screen buffer into a cursor-key
// press (ESC[A / ESC[B). In a `claude` pane that reads as arrow up/down —
// the wheel silently walks the prompt history instead of scrolling. Kill
// ONLY that branch: xterm runs this same handler before sending a real SGR
// mouse report too (`bindMouse`'s wheel case consults it), so a blanket
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
// because that protocol reports button presses only, never the wheel — so
// xterm takes the emulation path for it as well.
term.attachCustomWheelEventHandler(() => {
const mode = term.modes.mouseTrackingMode;
const tracked = mode !== "none" && mode !== "x10";
return tracked || term.buffer.active.type !== "alternate";
});
// xterm sends Shift+Tab as ESC[Z but — unlike plain Tab — never marks the
// event cancelled, so the browser still runs its default action and moves
// focus out of the terminal. The pane then only ever sees the first press,
// which is why `shift+tab to cycle` (permission mode in claude) looked dead
// here while working in a real terminal. Returning true keeps xterm's own
// handling; the preventDefault only stops focus from escaping.
// Ctrl+M toggles "Tab moves focus" (VS Code's way out of its terminal);
// in that mode Tab and Shift+Tab are not the pane's, so the browser moves
// focus. Cmd+F / Ctrl+Shift+F open the find bar. Returning false keeps xterm from
// handling — and from cancelling — the key.
term.attachCustomKeyEventHandler((ev) => {
if (ev.type !== "keydown") return true;
const key = ev.key.toLowerCase();
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "m") {
ev.preventDefault();
tabMovesRef.current = !tabMovesRef.current;
setTabMoves(tabMovesRef.current);
return false;
}
// Find: Cmd+F on macOS, Ctrl+Shift+F elsewhere — never plain Ctrl+F,
// which is readline's forward-char (and bound by many TUIs).
const findChord =
!ev.altKey &&
key === "f" &&
((ev.metaKey && !ev.ctrlKey && !ev.shiftKey) || (ev.ctrlKey && ev.shiftKey && !ev.metaKey));
if (findChord) {
ev.preventDefault();
setFind((f) => f ?? "");
requestAnimationFrame(() => findInput.current?.focus());
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true;
});
const decoder = new TextDecoder();
let ws: WebSocket | null = null;
/**
* Tell the server this terminal's size. tmux draws the pane at whatever
* width it last heard, and a pane WIDER than xterm is silently corrupting:
* every over-long row autowraps its tail onto the next row's left margin,
* and tmux — believing that row unchanged — never repaints over it. The
* result is stray characters glued to the left edge of blank lines, which
* only becomes visible once a scroll repaints history. Reproduced with a
* one-column difference; two columns leaves two characters per line.
*
* A zero-size fit is never reported: the console collapses with CSS
* (`display:none` leaves the container 0×0), and fitting to that would
* resize the real tmux pane down to nothing.
*/
const sendSizeNow = () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (term.cols < 2 || term.rows < 2) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
};
// A divider drag or a window resize fires a resize per frame; tmux and the
// program in the pane redraw on each, so the LAST size is sent once things
// settle (trailing, 150 ms).
let sizeTimer: ReturnType<typeof setTimeout> | null = null;
const sendSize = () => {
if (sizeTimer) clearTimeout(sizeTimer);
sizeTimer = setTimeout(() => {
sizeTimer = null;
sendSizeNow();
}, 150);
};
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let ended = false; // server said the pane exited — nothing left to attach to
let disposed = false;
const attach = ({
Terminal,
FitAddon,
CanvasAddon,
SearchAddon,
WebLinksAddon,
ClipboardWithProvider,
}: Xterm) => {
// An explicit fontFamily pins every glyph to one bundled webfont. Left
// unset, xterm falls back to the browser/OS default (`courier-new`),
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
// substitutes those specific glyphs from a different face with a
// mismatched advance width, corrupting the cell it's drawn into even
// under the canvas renderer (which only stops a glyph from shoving its
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
// already bundled for the rest of the UI; main.tsx additionally imports
// its `vietnamese` subset so this covers the diacritics too.
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
// cursor to column 0 on a line feed — and xterm's own docs say not to use
// it on a PTY, where termios already does that translation for the streams
// that want it. tmux scrolls a pane by emitting a bare LF and then keeps
// drawing at the column it was on; with this on, that draw landed at
// column 0 instead, stamping the first characters of the line into the
// left margin of the rows below. That is the torn left edge of issue #33,
// and why it only showed up once content scrolled.
const term = new Terminal({
fontSize: 13,
cursorBlink: true,
fontFamily: '"JetBrains Mono", monospace',
theme: paletteRef.current,
screenReaderMode: readFlag(SCREEN_READER_KEY),
});
termRef.current = term;
const fit = new FitAddon();
term.loadAddon(fit);
const search = new SearchAddon();
term.loadAddon(search);
searchRef.current = search;
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
term.loadAddon(
new WebLinksAddon((_e, uri) => {
window.open(uri, "_blank", "noopener,noreferrer");
})
);
if (containerRef.current) term.open(containerRef.current);
term.textarea?.setAttribute("aria-describedby", hintId);
const connect = () => {
// The attach itself carries this terminal's size. tmux sizes the window
// to the client that attached most recently, so spawning the attach at a
// default 80×24 and correcting it from the first `resize` message a beat
// later makes the pane's program (claude) redraw its whole UI at 80
// columns first — and everything painted at that width stays in the
// scrollback as torn fragments along the left margin, visible as soon as
// you scroll back (issue #33). Happens on every attach: page load,
// lane switch, and each 1.5 s reconnect.
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
ws = sock;
wsRef.current = sock;
// Server sends PTY bytes as binary frames — default binaryType ("blob")
// would hand onmessage a Blob that the string checks below never match,
// silently dropping all terminal output. "arraybuffer" keeps it sync.
sock.binaryType = "arraybuffer";
// Draw each cell at a fixed position instead of letting the browser lay the
// row out as text. xterm's default DOM renderer aligns a row with a single
// `letter-spacing`, which only holds while every glyph comes from one
// monospace font — Vietnamese diacritics and CJK fall through to a fallback
// face whose advance width differs, so glyphs shove their neighbours along
// and the row's tail spills onto the row below. The buffer is correct
// throughout (copying the line yields clean text); only the painting drifts,
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
// context keeps the DOM renderer rather than losing the terminal.
let canvas: CanvasAddon | null = null;
try {
canvas = new CanvasAddon();
term.loadAddon(canvas);
} catch {
canvas = null;
}
sock.onopen = () => {
setReconnecting(false);
sendSizeNow();
};
sock.onmessage = (event) => {
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
// `stream: true` carries a UTF-8 multi-byte char's trailing bytes over
// to the next decode() call when a PTY chunk splits mid-character —
// without it, a Vietnamese/accented char split across two WS frames
// decodes to garbage on both sides (U+FFFD or stray bytes).
const data = isArrayBuffer
? decoder.decode(event.data as ArrayBuffer, { stream: true })
: event.data;
if (typeof data === "string") {
// A JSON control frame is the only thing that starts with `{"type"`.
if (data.startsWith('{"type"')) {
try {
const msg = JSON.parse(data);
if (msg.type === "exit") {
ended = true;
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
onExitRef.current?.();
}
return;
} catch {
/* not JSON — fall through and render as PTY output */
}
}
term.write(data);
fit.fit();
// Take the keyboard as soon as the pane is on screen: the terminal IS the
// page here, and having to click it first meant the first thing typed went
// nowhere. A click elsewhere still moves focus away, as in any editor.
if (autoFocusRef.current) term.focus();
// When the pane's program has NOT enabled wheel reporting, xterm falls back
// to converting each wheel notch on an alt-screen buffer into a cursor-key
// press (ESC[A / ESC[B). In a `claude` pane that reads as arrow up/down —
// the wheel silently walks the prompt history instead of scrolling. Kill
// ONLY that branch: xterm runs this same handler before sending a real SGR
// mouse report too (`bindMouse`'s wheel case consults it), so a blanket
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
// because that protocol reports button presses only, never the wheel — so
// xterm takes the emulation path for it as well.
term.attachCustomWheelEventHandler(() => {
const mode = term.modes.mouseTrackingMode;
const tracked = mode !== "none" && mode !== "x10";
return tracked || term.buffer.active.type !== "alternate";
});
// xterm sends Shift+Tab as ESC[Z but — unlike plain Tab — never marks the
// event cancelled, so the browser still runs its default action and moves
// focus out of the terminal. The pane then only ever sees the first press,
// which is why `shift+tab to cycle` (permission mode in claude) looked dead
// here while working in a real terminal. Returning true keeps xterm's own
// handling; the preventDefault only stops focus from escaping.
// Ctrl+M toggles "Tab moves focus" (VS Code's way out of its terminal);
// in that mode Tab and Shift+Tab are not the pane's, so the browser moves
// focus. Cmd+F / Ctrl+Shift+F open the find bar. Returning false keeps xterm from
// handling — and from cancelling — the key.
term.attachCustomKeyEventHandler((ev) => {
if (ev.type !== "keydown") return true;
const key = ev.key.toLowerCase();
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "m") {
ev.preventDefault();
tabMovesRef.current = !tabMovesRef.current;
setTabMoves(tabMovesRef.current);
return false;
}
// Find: Cmd+F on macOS, Ctrl+Shift+F elsewhere — never plain Ctrl+F,
// which is readline's forward-char (and bound by many TUIs).
const findChord =
!ev.altKey &&
key === "f" &&
((ev.metaKey && !ev.ctrlKey && !ev.shiftKey) ||
(ev.ctrlKey && ev.shiftKey && !ev.metaKey));
if (findChord) {
ev.preventDefault();
setFind((f) => f ?? "");
requestAnimationFrame(() => findInput.current?.focus());
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true;
});
const decoder = new TextDecoder();
let ws: WebSocket | null = null;
/**
* Tell the server this terminal's size. tmux draws the pane at whatever
* width it last heard, and a pane WIDER than xterm is silently corrupting:
* every over-long row autowraps its tail onto the next row's left margin,
* and tmux — believing that row unchanged — never repaints over it. The
* result is stray characters glued to the left edge of blank lines, which
* only becomes visible once a scroll repaints history. Reproduced with a
* one-column difference; two columns leaves two characters per line.
*
* A zero-size fit is never reported: the console collapses with CSS
* (`display:none` leaves the container 0×0), and fitting to that would
* resize the real tmux pane down to nothing.
*/
const sendSizeNow = () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (term.cols < 2 || term.rows < 2) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
};
sock.onclose = () => {
if (disposed || ended) return;
// tmux keeps the session alive across a server restart, so re-attaching
// repaints the pane in full — no output is lost by retrying. Input is
// not: the overlay says so rather than letting keystrokes vanish.
setReconnecting(true);
retryTimer = setTimeout(connect, 1500);
// A divider drag or a window resize fires a resize per frame; tmux and the
// program in the pane redraw on each, so the LAST size is sent once things
// settle (trailing, 150 ms).
let sizeTimer: ReturnType<typeof setTimeout> | null = null;
const sendSize = () => {
if (sizeTimer) clearTimeout(sizeTimer);
sizeTimer = setTimeout(() => {
sizeTimer = null;
sendSizeNow();
}, 150);
};
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let ended = false; // server said the pane exited — nothing left to attach to
let retryMs = RETRY_MIN_MS;
const connect = () => {
// The attach itself carries this terminal's size. tmux sizes the window
// to the client that attached most recently, so spawning the attach at a
// default 80×24 and correcting it from the first `resize` message a beat
// later makes the pane's program (claude) redraw its whole UI at 80
// columns first — and everything painted at that width stays in the
// scrollback as torn fragments along the left margin, visible as soon as
// you scroll back (issue #33). Happens on every attach: page load,
// lane switch, and each reconnect.
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
ws = sock;
wsRef.current = sock;
// Server sends PTY bytes as binary frames — default binaryType ("blob")
// would hand onmessage a Blob that the string checks below never match,
// silently dropping all terminal output. "arraybuffer" keeps it sync.
sock.binaryType = "arraybuffer";
sock.onopen = () => {
retryMs = RETRY_MIN_MS;
setReconnecting(false);
sendSizeNow();
};
sock.onmessage = (event) => {
const isArrayBuffer =
Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
// `stream: true` carries a UTF-8 multi-byte char's trailing bytes over
// to the next decode() call when a PTY chunk splits mid-character —
// without it, a Vietnamese/accented char split across two WS frames
// decodes to garbage on both sides (U+FFFD or stray bytes).
const data = isArrayBuffer
? decoder.decode(event.data as ArrayBuffer, { stream: true })
: event.data;
if (typeof data === "string") {
// A JSON control frame is the only thing that starts with `{"type"`.
if (data.startsWith('{"type"')) {
try {
const msg = JSON.parse(data);
if (msg.type === "exit") {
ended = true;
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
onExitRef.current?.();
}
return;
} catch {
/* not JSON — fall through and render as PTY output */
}
}
term.write(data);
}
};
sock.onclose = (e?: CloseEvent) => {
// 1008: the server refused this attach (policy) — retrying cannot help.
if (disposed || ended || e?.code === 1008) return;
// tmux keeps the session alive across a server restart, so re-attaching
// repaints the pane in full — no output is lost by retrying. Input is
// not: the overlay says so rather than letting keystrokes vanish.
// Backoff (1.5 s → 30 s, reset on open) so a server that stays down is
// not hammered by every open pane.
setReconnecting(true);
retryTimer = setTimeout(connect, retryMs);
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
};
};
connect();
const dataDisposable = term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
});
// xterm's own size change is the event worth reporting, not the
// container's: cols also move when the webfont finishes loading or the
// page zooms, neither of which resizes the element ResizeObserver watches.
const resizeDisposable = term.onResize(sendSize);
// Re-assert on focus. Several attach clients can share one tmux session
// (a second tab, another lane pane), and tmux's `window-size latest` sizes
// the window to whichever client was last active — resizing this pane out
// from under a terminal that is never told. Coming back to it says so.
const reassert = () => sendSizeNow();
term.textarea?.addEventListener("focus", reassert);
const refit = () => {
// Never fit a collapsed console — see sendSize.
const el = containerRef.current;
if (!el || !el.clientWidth || !el.clientHeight) return;
fit.fit(); // a real change fires onResize, which reports it
};
const resizeObserver = new ResizeObserver(refit);
if (containerRef.current) resizeObserver.observe(containerRef.current);
// A webfont landing after open changes the cell, not the container, so the
// observer never fires — and xterm re-measures its cell only on a font
// option change or a resize, so the fit kept the fallback font's cell and
// the rows/cols overflowed the pane. Re-setting the family (a trailing
// space keeps it a change) makes xterm measure again before the fit.
const onFontLoaded = () => {
const family = term.options.fontFamily ?? "";
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
refit();
};
document.fonts?.addEventListener("loadingdone", onFontLoaded);
return () => {
if (retryTimer) clearTimeout(retryTimer);
if (sizeTimer) clearTimeout(sizeTimer);
searchRef.current = null;
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
term.textarea?.removeEventListener("focus", reassert);
resizeDisposable.dispose();
dataDisposable.dispose();
ws?.close();
canvas?.dispose();
term.dispose();
termRef.current = null;
};
};
connect();
const dataDisposable = term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
});
// xterm's own size change is the event worth reporting, not the
// container's: cols also move when the webfont finishes loading or the
// page zooms, neither of which resizes the element ResizeObserver watches.
const resizeDisposable = term.onResize(sendSize);
// Re-assert on focus. Several attach clients can share one tmux session
// (a second tab, another lane pane), and tmux's `window-size latest` sizes
// the window to whichever client was last active — resizing this pane out
// from under a terminal that is never told. Coming back to it says so.
const reassert = () => sendSizeNow();
term.textarea?.addEventListener("focus", reassert);
const refit = () => {
// Never fit a collapsed console — see sendSize.
const el = containerRef.current;
if (!el || !el.clientWidth || !el.clientHeight) return;
fit.fit(); // a real change fires onResize, which reports it
// Synchronous once xterm is loaded; the first pane waits for the chunk, and
// `disposed` drops a setup that lands after this effect was cleaned up.
let teardown: (() => void) | undefined;
const start = (x: Xterm) => {
if (!disposed) teardown = attach(x);
};
const resizeObserver = new ResizeObserver(refit);
if (containerRef.current) resizeObserver.observe(containerRef.current);
// A webfont landing after open changes the cell, not the container, so the
// observer never fires — and xterm re-measures its cell only on a font
// option change or a resize, so the fit kept the fallback font's cell and
// the rows/cols overflowed the pane. Re-setting the family (a trailing
// space keeps it a change) makes xterm measure again before the fit.
const onFontLoaded = () => {
const family = term.options.fontFamily ?? "";
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
refit();
};
document.fonts?.addEventListener("loadingdone", onFontLoaded);
if (xtermLoaded) start(xtermLoaded);
else loadXterm().then(start, () => undefined);
return () => {
disposed = true;
if (retryTimer) clearTimeout(retryTimer);
if (sizeTimer) clearTimeout(sizeTimer);
searchRef.current = null;
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
term.textarea?.removeEventListener("focus", reassert);
resizeDisposable.dispose();
dataDisposable.dispose();
ws?.close();
canvas?.dispose();
term.dispose();
termRef.current = null;
teardown?.();
};
}, [runId, wsBaseUrl]);
@@ -3,13 +3,14 @@
* @description Tests for the xterm.js-backed terminal view: verifies it opens
* a WS connection to the right URL, writes incoming binary frames to the
* mocked terminal, forwards typed input as outgoing binary frames, and
* re-attaches after the socket drops (server restart) but not after `exit`,
* re-attaches after the socket drops (server restart) with backoff, but not
* after `exit` or a policy close,
* and recolours live on a dark-theme change without re-attaching.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
import { TerminalView } from "../TerminalView";
import { TerminalView, loadXterm } from "../TerminalView";
import { pasteToRun } from "../../../lib/panePaste";
const writeMock = vi.fn();
@@ -87,7 +88,7 @@ class MockWebSocket {
sent: unknown[] = [];
onopen: (() => void) | null = null;
onmessage: ((e: { data: unknown }) => void) | null = null;
onclose: (() => void) | null = null;
onclose: ((e?: { code: number }) => void) | null = null;
readyState = 1; // OPEN — the component only sends on an open socket
static OPEN = 1;
constructor(url: string) {
@@ -105,6 +106,9 @@ class MockWebSocket {
global.WebSocket = MockWebSocket;
describe("TerminalView", () => {
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
beforeAll(() => loadXterm());
beforeEach(() => {
termState.modes.mouseTrackingMode = "none";
termState.buffer.active.type = "alternate";
@@ -388,6 +392,23 @@ describe("TerminalView", () => {
vi.useRealTimers();
});
it("backs off between failed re-attaches, and stops on a policy close (1008)", () => {
vi.useFakeTimers();
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
MockWebSocket.instances[0]!.close();
vi.advanceTimersByTime(1600);
expect(MockWebSocket.instances).toHaveLength(2);
MockWebSocket.instances[1]!.close(); // failed again: now 3 s
vi.advanceTimersByTime(1600);
expect(MockWebSocket.instances).toHaveLength(2);
vi.advanceTimersByTime(1500);
expect(MockWebSocket.instances).toHaveLength(3);
act(() => MockWebSocket.instances[2]!.onclose?.({ code: 1008 }));
vi.advanceTimersByTime(60_000);
expect(MockWebSocket.instances).toHaveLength(3);
vi.useRealTimers();
});
it("does not re-attach once the server reported the pane exited", () => {
vi.useFakeTimers();
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);