Skip to content

Commit 0fc6a85

Browse files
Diffuzmetallymichael
authored andcommitted
Improve built-in terminal reliability and remote replay performance (get-bb#1076)
Closes get-bb#1075. ## Summary - batch raw PTY output into ordered chunks and preserve arbitrary UTF-8/binary bytes end to end - add bounded browser and daemon websocket backpressure, bad-socket isolation, heartbeat/reconnect, input queuing, and sequence deduplication - reconnect from the last received sequence and bound initial browser replay to the latest 512 KiB instead of replaying the full 4 MiB scrollback on every open - serialize terminal restart, open the replacement before closing the old PTY, and wait for daemon exit acknowledgement so final output precedes exit - keep threadless terminal caches current, make latest-attached clients own resize, and restore ownership on failed attach/detach - enable xterm Unicode 11 width rules and Nerd Font fallbacks - bump the host-daemon protocol from 75 to 76 for byte-preserving input and required bounded-replay fields ## Measurements ### Sustained output A local 16 MiB output probe improved from about 6.5 MiB/s to 53–63 MiB/s. Websocket output messages dropped from 16,386 to 258–480 depending on PTY chunking. Input remained responsive during a 16 MiB remote flood. Over the Tailscale path used for QA, the full burst completed without disconnect while a concurrent input echo returned in 101 ms. ### Reopening a populated remote terminal Measured through an SSH tunnel to a patched VPS: | | Before | After | |---|---:|---:| | Time to final replayed line | 2,210 ms | 556 ms | | Replay bytes | 4,192,407 | 461,853 | | Websocket output messages | 1,025 | 15 | A subsequent reconnect requested the previous `nextSeq` and replayed only 319 bytes rather than the retained history. ### Input integrity A 200,009-byte payload containing emoji and combining characters crossed multiple 64 KiB request boundaries on the VPS with an exact SHA-256 match. CLI `--stdin` also has coverage for invalid UTF-8 bytes. ## Validation On current `main`: - `pnpm exec turbo run typecheck` for app, CLI, host daemon, host-daemon contract, server, SDK, server contract, and templates - 191 focused tests across app terminal UI/transport/controller, CLI terminal commands, daemon connection/PTY manager, server hub/routes, and both contracts - production builds for app, CLI, server, and host daemon - patched local and VPS live stacks, including latency, flood/backpressure, atomic restart, large paste, replay truncation, reconnect, and multi-client resize ownership --------- Co-authored-by: Michael Yong <wrong92@gmail.com>
1 parent 799b250 commit 0fc6a85

36 files changed

Lines changed: 2583 additions & 352 deletions

apps/app/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"@tiptap/starter-kit": "^3.26.0",
7777
"@tiptap/suggestion": "^3.26.0",
7878
"@xterm/addon-fit": "0.12.0-beta.292",
79+
"@xterm/addon-unicode11": "^0.9.0",
7980
"@xterm/addon-web-links": "0.13.0-beta.292",
8081
"@xterm/addon-webgl": "0.20.0-beta.291",
8182
"@xterm/xterm": "6.1.0-beta.292",
@@ -129,10 +130,10 @@
129130
"@types/node": "^22.0.0",
130131
"@types/react": "^19.0.0",
131132
"@types/react-dom": "^19.0.0",
132-
"bb-plugin-automations": "workspace:*",
133133
"@typescript-eslint/parser": "^8.63.0",
134134
"@vitejs/plugin-react": "^6.0.1",
135135
"babel-plugin-react-compiler": "^1.0.0",
136+
"bb-plugin-automations": "workspace:*",
136137
"eslint": "^9.39.3",
137138
"eslint-plugin-react-hooks": "^7.0.1",
138139
"mdast-util-to-hast": "^13.2.1",

apps/app/src/components/thread/terminal/ThreadTerminalContent.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ function controller(isPanelOpen: boolean): ThreadTerminalController {
3636
canCreateTerminal: true,
3737
closingTerminalId: null,
3838
emptyTerminalMessage: "No terminals",
39+
handleActiveTerminalSessionChange: () => undefined,
3940
handleActiveTerminalTitleChange: () => undefined,
4041
handleActiveTerminalUserInput: () => undefined,
4142
handleClosePanel: () => undefined,

apps/app/src/components/thread/terminal/ThreadTerminalContent.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export function ThreadTerminalContent({
129129
isPanelOpen={controller.isPanelOpen}
130130
onOpenLink={onOpenLink}
131131
onSelectionAddToChat={onSelectionAddToChat}
132+
onSessionChange={controller.handleActiveTerminalSessionChange}
132133
onTitleChange={controller.handleActiveTerminalTitleChange}
133134
onUserInput={controller.handleActiveTerminalUserInput}
134135
session={controller.activeSession}

apps/app/src/components/thread/terminal/ThreadTerminalPanel.stories.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ function noopAction(): void {}
7676

7777
function noopTerminalIdAction(_terminalId: string): void {}
7878

79+
function noopSessionChange(_session: TerminalSession): void {}
80+
7981
function noopTitleChange(_title: string): void {}
8082

8183
function makeController({
@@ -98,6 +100,7 @@ function makeController({
98100
canCreateTerminal,
99101
closingTerminalId,
100102
emptyTerminalMessage,
103+
handleActiveTerminalSessionChange: noopSessionChange,
101104
handleActiveTerminalTitleChange: noopTitleChange,
102105
handleActiveTerminalUserInput: noopAction,
103106
handleClosePanel: noopAction,
@@ -237,10 +240,7 @@ function RunningTerminalPreview() {
237240
export function Overview() {
238241
return (
239242
<StoryCard labelWidth="190px">
240-
<StoryRow
241-
label="disconnected"
242-
hint="Replacement available."
243-
>
243+
<StoryRow label="disconnected" hint="Replacement available.">
244244
<TerminalContentStage controller={disconnectedController} />
245245
</StoryRow>
246246
<StoryRow
@@ -252,7 +252,10 @@ export function Overview() {
252252
<StoryRow label="starting" hint="Session exists but is not running yet.">
253253
<TerminalContentStage controller={startingController} />
254254
</StoryRow>
255-
<StoryRow label="exited" hint="Terminal has ended and cannot accept input.">
255+
<StoryRow
256+
label="exited"
257+
hint="Terminal has ended and cannot accept input."
258+
>
256259
<TerminalContentStage controller={exitedController} />
257260
</StoryRow>
258261
<StoryRow label="empty" hint="Right panel tab with no visible sessions.">

apps/app/src/components/thread/terminal/ThreadTerminalView.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,110 @@
1+
import { TERMINAL_DATA_MAX_BYTES } from "@bb/domain";
2+
import { Terminal } from "@xterm/xterm";
13
import { describe, expect, it, vi } from "vitest";
24
import {
35
buildTerminalThemeFromCssColors,
6+
decodeTerminalOutputBytes,
7+
encodeTerminalInputChunks,
8+
forwardTerminalData,
49
loadOptionalTerminalWebglAddon,
510
loadTerminalWebglRenderer,
11+
TERMINAL_ALLOW_PROPOSED_API,
12+
TERMINAL_FONT_FAMILY,
13+
TERMINAL_UNICODE_VERSION,
14+
writeTerminalOutput,
615
} from "./ThreadTerminalView";
716

17+
describe("terminal output encoding", () => {
18+
it("splits large paste input at the wire limit without losing UTF-8 bytes", () => {
19+
const input = `${"a".repeat(TERMINAL_DATA_MAX_BYTES - 1)}🙂tail`;
20+
const chunks = encodeTerminalInputChunks(input);
21+
const decoded = Buffer.concat(
22+
chunks.map((chunk) => Buffer.from(chunk, "base64")),
23+
);
24+
25+
expect(chunks).toHaveLength(2);
26+
expect(
27+
chunks.every(
28+
(chunk) =>
29+
Buffer.from(chunk, "base64").byteLength <= TERMINAL_DATA_MAX_BYTES,
30+
),
31+
).toBe(true);
32+
expect(decoded.toString("utf8")).toBe(input);
33+
});
34+
35+
it("keeps UTF-8 bytes intact when a glyph spans output chunks", () => {
36+
const encoded = new TextEncoder().encode("🙂");
37+
const first = decodeTerminalOutputBytes(
38+
Buffer.from(encoded.subarray(0, 2)).toString("base64"),
39+
);
40+
const second = decodeTerminalOutputBytes(
41+
Buffer.from(encoded.subarray(2)).toString("base64"),
42+
);
43+
const decoder = new TextDecoder();
44+
45+
expect(
46+
decoder.decode(first, { stream: true }) + decoder.decode(second),
47+
).toBe("🙂");
48+
});
49+
50+
it("does not send terminal protocol replies generated by replayed output", async () => {
51+
const terminal = new Terminal({ cols: 80, rows: 24 });
52+
const onInput = vi.fn<(dataBase64: string) => void>();
53+
const onUserInput = vi.fn();
54+
const replayWriteState = { suppressedWriteCount: 0 };
55+
terminal.onData((data) => {
56+
forwardTerminalData({
57+
data,
58+
onInput,
59+
onUserInput,
60+
replayWriteState,
61+
sessionStatus: "running",
62+
});
63+
});
64+
65+
writeTerminalOutput({
66+
data: "\u001b[6n",
67+
isReplay: true,
68+
replayWriteState,
69+
terminal,
70+
});
71+
await new Promise<void>((resolve) => terminal.write("", resolve));
72+
73+
expect(onInput).not.toHaveBeenCalled();
74+
expect(onUserInput).not.toHaveBeenCalled();
75+
76+
writeTerminalOutput({
77+
data: "\u001b[6n",
78+
isReplay: false,
79+
replayWriteState,
80+
terminal,
81+
});
82+
await new Promise<void>((resolve) => terminal.write("", resolve));
83+
84+
expect(onInput).toHaveBeenCalledOnce();
85+
const encodedReply = onInput.mock.calls[0]?.[0];
86+
if (encodedReply === undefined) {
87+
throw new Error("Expected xterm to emit a cursor-position reply");
88+
}
89+
expect(Buffer.from(encodedReply, "base64").toString("utf8")).toBe(
90+
"\u001b[1;1R",
91+
);
92+
expect(onUserInput).toHaveBeenCalledOnce();
93+
expect(replayWriteState.suppressedWriteCount).toBe(0);
94+
terminal.dispose();
95+
});
96+
97+
it("enables the proposed xterm API required by the Unicode addon", () => {
98+
expect(TERMINAL_ALLOW_PROPOSED_API).toBe(true);
99+
expect(TERMINAL_UNICODE_VERSION).toBe("11");
100+
});
101+
102+
it("prefers installed Nerd Font families before system monospace fallbacks", () => {
103+
expect(TERMINAL_FONT_FAMILY).toContain("Nerd Font");
104+
expect(TERMINAL_FONT_FAMILY).toContain("ui-monospace");
105+
});
106+
});
107+
8108
describe("buildTerminalThemeFromCssColors", () => {
9109
it("paints the terminal canvas and cursor cutout with the sidebar surface", () => {
10110
const get = vi.fn((name: string) => name);

0 commit comments

Comments
 (0)