Simplify terminal streaming protocol

This commit is contained in:
Mohamed Boudra
2026-03-22 22:31:23 +07:00
parent 54282409bb
commit 50ea5a76fb
35 changed files with 1809 additions and 4121 deletions
@@ -1,9 +1,10 @@
"use dom";
import { useEffect, useMemo, useRef, useState } from "react";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import type { DOMProps } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import { focusWithRetries } from "../utils/web-focus";
@@ -64,11 +65,7 @@ function buildXtermThemeKey(theme: ITheme): string {
interface TerminalEmulatorProps {
dom?: DOMProps;
streamKey: string;
initialOutputText: string;
initialOutputChunkSequence?: number;
outputChunkText: string;
outputChunkSequence: number;
outputChunkReplay?: boolean;
initialSnapshot: TerminalState | null;
testId?: string;
xtermTheme?: ITheme;
swipeGesturesEnabled?: boolean;
@@ -84,12 +81,17 @@ interface TerminalEmulatorProps {
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
}
export type TerminalEmulatorHandle = {
writeOutput: (text: string) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
};
declare global {
interface Window {}
}
@@ -122,13 +124,9 @@ function ensureTerminalScrollbarStyle(): void {
document.head.appendChild(styleElement);
}
export default function TerminalEmulator({
const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProps>(function TerminalEmulator({
streamKey,
initialOutputText,
initialOutputChunkSequence = 0,
outputChunkText,
outputChunkSequence,
outputChunkReplay = false,
initialSnapshot,
testId = "terminal-surface",
xtermTheme = {
background: "#0b0b0b",
@@ -142,15 +140,13 @@ export default function TerminalEmulator({
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onOutputChunkConsumed,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
}: TerminalEmulatorProps) {
}, ref) {
const rootRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
const appliedChunkSequenceRef = useRef(0);
const mountedThemeRef = useRef<ITheme>(xtermTheme);
const viewportRef = useRef<HTMLElement | null>(null);
const dragStartOffsetRef = useRef(0);
@@ -315,17 +311,15 @@ export default function TerminalEmulator({
runtime.mount({
root,
host,
initialOutputText,
initialSnapshot,
theme: mountedThemeRef.current,
});
appliedChunkSequenceRef.current = Math.max(0, Math.floor(initialOutputChunkSequence));
return () => {
runtime.unmount();
if (runtimeRef.current === runtime) {
runtimeRef.current = null;
}
appliedChunkSequenceRef.current = 0;
};
}, [streamKey]);
@@ -344,40 +338,21 @@ export default function TerminalEmulator({
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
}, [pendingModifiers]);
useEffect(() => {
const runtime = runtimeRef.current;
if (outputChunkSequence <= 0) {
return;
}
if (outputChunkSequence <= appliedChunkSequenceRef.current) {
onOutputChunkConsumed?.(outputChunkSequence);
return;
}
if (!runtime) {
onOutputChunkConsumed?.(outputChunkSequence);
return;
}
appliedChunkSequenceRef.current = outputChunkSequence;
if (outputChunkText.length === 0) {
runtime.clear({
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
},
});
return;
}
runtime.write({
text: outputChunkText,
suppressInput: outputChunkReplay,
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
useImperativeHandle(
ref,
() => ({
writeOutput: (text: string) => {
runtimeRef.current?.write({ text });
},
});
}, [onOutputChunkConsumed, outputChunkReplay, outputChunkSequence, outputChunkText]);
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
clear: () => {
runtimeRef.current?.clear();
},
}),
[],
);
useEffect(() => {
if (focusRequestToken <= 0) {
@@ -694,4 +669,6 @@ export default function TerminalEmulator({
) : null}
</div>
);
}
});
export default TerminalEmulator;
+30 -86
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
@@ -18,7 +18,7 @@ import {
} from "@/terminal/runtime/terminal-stream-controller";
import { usePanelStore } from "@/stores/panel-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator from "./terminal-emulator";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
interface TerminalPaneProps {
serverId: string;
@@ -26,7 +26,6 @@ interface TerminalPaneProps {
terminalId: string;
}
const MAX_OUTPUT_CHARS = 200_000;
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
const MODIFIER_LABELS = {
@@ -103,44 +102,8 @@ export function TerminalPane({
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const workspaceTerminalSession = useMemo(
() =>
getWorkspaceTerminalSession({
scopeKey,
maxOutputChars: MAX_OUTPUT_CHARS,
}),
[scopeKey],
);
const outputSession = workspaceTerminalSession.outputSession;
const subscribeOutputSession = useCallback(
(listener: () => void) => outputSession.subscribe(listener),
[outputSession],
);
const getOutputSessionState = useCallback(() => outputSession.getState(), [outputSession]);
const outputState = useSyncExternalStore(
subscribeOutputSession,
getOutputSessionState,
getOutputSessionState,
);
const selectedOutputState = useMemo(() => {
if (outputState.selectedTerminalId === terminalId) {
return outputState;
}
return {
...outputState,
selectedTerminalId: terminalId,
snapshotText: outputSession.readSnapshot({ terminalId }),
snapshotSequence: 0,
chunkText: "",
chunkSequence: 0,
chunkReplay: false,
};
}, [outputSession, outputState, terminalId]);
const [activeStream, setActiveStream] = useState<{
terminalId: string;
streamId: number;
} | null>(null);
const emulatorRef = useRef<TerminalEmulatorHandle | null>(null);
const workspaceTerminalSession = useMemo(() => getWorkspaceTerminalSession({ scopeKey }), [scopeKey]);
const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
@@ -150,6 +113,7 @@ export function TerminalPane({
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
useEffect(() => {
terminalIdRef.current = terminalId;
@@ -248,13 +212,16 @@ export function TerminalPane({
return;
}
workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId });
if (terminalIdRef.current === exitedTerminalId) {
emulatorRef.current?.clear();
}
streamControllerRef.current?.handleStreamExit({
terminalId: exitedTerminalId,
streamId: message.payload.streamId,
});
setModifiers({ ...EMPTY_MODIFIERS });
});
}, [client, isConnected]);
}, [client, isConnected, workspaceTerminalSession.snapshots]);
useEffect(() => {
lastReportedSizeRef.current = null;
@@ -263,20 +230,11 @@ export function TerminalPane({
const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
setIsAttaching(status.isAttaching);
setStreamError(status.error);
if (status.terminalId && typeof status.streamId === "number") {
setActiveStream({
terminalId: status.terminalId,
streamId: status.streamId,
});
return;
}
setActiveStream(null);
}, []);
useEffect(() => {
streamControllerRef.current?.dispose();
streamControllerRef.current = null;
setActiveStream(null);
setIsAttaching(false);
setStreamError(null);
@@ -287,12 +245,18 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => lastReportedSizeRef.current,
resumeOffsets: workspaceTerminalSession.resumeOffsets,
onChunk: ({ terminalId, text, replay }) => {
outputSession.append({ terminalId, text, replay });
onOutput: ({ terminalId, text }) => {
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
return;
}
emulatorRef.current?.writeOutput(text);
},
onReset: ({ terminalId }) => {
outputSession.clearTerminal({ terminalId });
onSnapshot: ({ terminalId, state }) => {
workspaceTerminalSession.snapshots.set({ terminalId, state });
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
return;
}
emulatorRef.current?.renderSnapshot(state);
},
onStatusChange: handleStreamControllerStatus,
});
@@ -313,26 +277,16 @@ export function TerminalPane({
handleStreamControllerStatus,
isConnected,
isScreenFocused,
outputSession,
workspaceTerminalSession.resumeOffsets,
workspaceTerminalSession.snapshots,
]);
useEffect(() => {
pendingTerminalInputRef.current = [];
const nextTerminalId = isScreenFocused ? terminalId : null;
outputSession.setSelectedTerminal({
terminalId: nextTerminalId,
});
streamControllerRef.current?.setTerminal({
terminalId: nextTerminalId,
});
}, [isScreenFocused, outputSession, terminalId]);
const activeStreamId =
activeStream && activeStream.terminalId === terminalId ? activeStream.streamId : null;
const getCurrentActiveStreamId = useCallback(() => {
return streamControllerRef.current?.getActiveStreamId() ?? null;
}, []);
}, [isScreenFocused, terminalId]);
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
const queue = pendingTerminalInputRef.current;
@@ -398,8 +352,10 @@ export function TerminalPane({
}, [dispatchTerminalInputEntry]);
useEffect(() => {
flushPendingTerminalInput();
}, [activeStreamId, flushPendingTerminalInput]);
if (!isAttaching && !streamError) {
flushPendingTerminalInput();
}
}, [flushPendingTerminalInput, isAttaching, streamError]);
const clearPendingModifiers = useCallback(() => {
setModifiers({ ...EMPTY_MODIFIERS });
@@ -443,7 +399,7 @@ export function TerminalPane({
}
return true;
},
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, getCurrentActiveStreamId],
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput],
);
const handleTerminalData = useCallback(
@@ -496,7 +452,6 @@ export function TerminalPane({
clearPendingModifiers,
client,
dispatchTerminalInputEntry,
getCurrentActiveStreamId,
modifiers.alt,
modifiers.ctrl,
modifiers.shift,
@@ -538,13 +493,6 @@ export function TerminalPane({
clearPendingModifiers();
}, [clearPendingModifiers]);
const handleOutputChunkConsumed = useCallback(
(sequence: number) => {
outputSession.consume({ sequence });
},
[outputSession],
);
const toggleModifier = useCallback(
(modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
@@ -589,6 +537,7 @@ export function TerminalPane({
{isScreenFocused ? (
<View style={styles.terminalGestureContainer}>
<TerminalEmulator
ref={emulatorRef}
dom={{
style: { flex: 1 },
matchContents: false,
@@ -600,11 +549,7 @@ export function TerminalPane({
contentInsetAdjustmentBehavior: "never",
}}
streamKey={`${scopeKey}:${terminalId}`}
initialOutputText={selectedOutputState.snapshotText}
initialOutputChunkSequence={selectedOutputState.snapshotSequence}
outputChunkText={selectedOutputState.chunkText}
outputChunkSequence={selectedOutputState.chunkSequence}
outputChunkReplay={selectedOutputState.chunkReplay}
initialSnapshot={initialSnapshot}
testId="terminal-surface"
xtermTheme={xtermTheme}
swipeGesturesEnabled={swipeGesturesEnabled}
@@ -624,7 +569,6 @@ export function TerminalPane({
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onPendingModifiersConsumed={handlePendingModifiersConsumed}
onOutputChunkConsumed={handleOutputChunkConsumed}
pendingModifiers={modifiers}
focusRequestToken={focusRequestToken}
resizeRequestToken={resizeRequestToken}
@@ -5,10 +5,12 @@ import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
type StubTerminal = {
write: (text: string, callback?: () => void) => void;
reset: () => void;
resize?: (cols: number, rows: number) => void;
focus: () => void;
refresh?: (start: number, end: number) => void;
options?: { theme?: unknown };
rows?: number;
cols?: number;
};
function createRuntimeWithTerminal(): {
@@ -35,10 +37,12 @@ function createRuntimeWithTerminal(): {
resetCalls += 1;
terminal.resetCalls = resetCalls;
},
resize: () => {},
focus: () => {},
refresh: () => {},
options: { theme: undefined },
rows: 0,
cols: 0,
resetCalls,
};
@@ -188,12 +192,16 @@ describe("terminal-emulator-runtime", () => {
refresh,
options: { theme: { background: "before" } },
rows: 12,
cols: 40,
};
(runtime as unknown as { terminal: StubTerminal }).terminal = terminal;
runtime.setTheme({ theme: { background: "after" } as never });
expect(terminal.options?.theme).toEqual({ background: "after" });
expect(terminal.options?.theme).toEqual({
background: "after",
overviewRulerBorder: "after",
});
expect(refresh).toHaveBeenCalledWith(0, 11);
});
@@ -2,6 +2,7 @@ import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal, type ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
@@ -10,11 +11,12 @@ import {
normalizeTerminalTransportKey,
shouldInterceptDomTerminalKey,
} from "@/utils/terminal-keys";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
export type TerminalEmulatorRuntimeMountInput = {
root: HTMLDivElement;
host: HTMLDivElement;
initialOutputText: string;
initialSnapshot: TerminalState | null;
theme: ITheme;
};
@@ -50,8 +52,10 @@ type TerminalEmulatorRuntimeDisposables = {
};
type TerminalOutputOperation = {
type: "write" | "clear";
type: "write" | "clear" | "snapshot";
text: string;
rows?: number;
cols?: number;
suppressInput?: boolean;
onCommitted?: () => void;
};
@@ -332,8 +336,8 @@ export class TerminalEmulatorRuntime {
fitAndEmitResize(true);
}, 0);
if (input.initialOutputText.length > 0) {
this.write({ text: input.initialOutputText, suppressInput: true });
if (input.initialSnapshot) {
this.renderSnapshot({ state: input.initialSnapshot });
}
this.processOutputQueue();
@@ -434,6 +438,22 @@ export class TerminalEmulatorRuntime {
this.processOutputQueue();
}
renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void {
if (!input.state) {
this.clear(input);
return;
}
this.outputOperations.push({
type: "snapshot",
text: renderTerminalSnapshotToAnsi(input.state),
rows: input.state.rows,
cols: input.state.cols,
suppressInput: true,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
this.processOutputQueue();
}
resize(input?: { force?: boolean }): void {
this.fitAndEmitResize?.(input?.force ?? false);
}
@@ -523,6 +543,22 @@ export class TerminalEmulatorRuntime {
return;
}
if (operation.type === "snapshot") {
try {
if (
typeof operation.cols === "number" &&
typeof operation.rows === "number" &&
(terminal.cols !== operation.cols || terminal.rows !== operation.rows)
) {
terminal.resize(operation.cols, operation.rows);
}
terminal.reset();
} catch {
finalizeOperation(operation);
return;
}
}
const text = operation.text;
this.inFlightOutputOperationTimeout = setTimeout(() => {
finalizeOperation(operation);
@@ -1,141 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { TerminalOutputDeliveryQueue } from "./terminal-output-delivery-queue";
describe("terminal-output-delivery-queue", () => {
it("retries in-flight delivery when consume is missing", () => {
vi.useFakeTimers();
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
deliveryTimeoutMs: 100,
});
queue.enqueue({ sequence: 1, text: "a", replay: false });
queue.enqueue({ sequence: 2, text: "b", replay: false });
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
vi.advanceTimersByTime(100);
expect(delivered).toEqual([
{ sequence: 1, text: "a", replay: false },
{ sequence: 1, text: "a", replay: false },
]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a", replay: false },
{ sequence: 1, text: "a", replay: false },
{ sequence: 2, text: "b", replay: false },
]);
vi.useRealTimers();
});
it("delivers first chunk immediately and blocks later chunks until consumed", () => {
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "a", replay: false });
queue.enqueue({ sequence: 2, text: "b", replay: false });
queue.enqueue({ sequence: 3, text: "c", replay: false });
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a", replay: false },
{ sequence: 3, text: "bc", replay: false },
]);
});
it("ignores stale consume acknowledgements", () => {
const delivered = vi.fn();
const queue = new TerminalOutputDeliveryQueue({ onDeliver: delivered });
queue.enqueue({ sequence: 1, text: "x", replay: false });
queue.consume({ sequence: 99 });
queue.enqueue({ sequence: 2, text: "y", replay: false });
expect(delivered).toHaveBeenCalledTimes(1);
expect(delivered).toHaveBeenNthCalledWith(1, { sequence: 1, text: "x", replay: false });
queue.consume({ sequence: 1 });
expect(delivered).toHaveBeenCalledTimes(2);
expect(delivered).toHaveBeenNthCalledWith(2, { sequence: 2, text: "y", replay: false });
});
it("resets in-flight and pending chunks", () => {
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "hello", replay: false });
queue.enqueue({ sequence: 2, text: " world", replay: false });
queue.reset();
queue.enqueue({ sequence: 3, text: "next", replay: false });
expect(delivered).toEqual([
{ sequence: 1, text: "hello", replay: false },
{ sequence: 3, text: "next", replay: false },
]);
});
it("preserves empty chunk payloads for authoritative clears", () => {
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "abc", replay: false });
queue.consume({ sequence: 1 });
queue.enqueue({ sequence: 2, text: "", replay: false });
expect(delivered).toEqual([
{ sequence: 1, text: "abc", replay: false },
{ sequence: 2, text: "", replay: false },
]);
});
it("treats clear chunks as a delivery barrier and drops pending stale text", () => {
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "a", replay: false });
queue.enqueue({ sequence: 2, text: "b", replay: false });
queue.enqueue({ sequence: 3, text: "", replay: false });
queue.enqueue({ sequence: 4, text: "c", replay: false });
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a", replay: false },
{ sequence: 3, text: "", replay: false },
]);
queue.consume({ sequence: 3 });
expect(delivered).toEqual([
{ sequence: 1, text: "a", replay: false },
{ sequence: 3, text: "", replay: false },
{ sequence: 4, text: "c", replay: false },
]);
});
});
@@ -1,113 +0,0 @@
export type TerminalOutputDeliveryChunk = {
sequence: number;
text: string;
replay: boolean;
};
export type TerminalOutputDeliveryQueueOptions = {
onDeliver: (chunk: TerminalOutputDeliveryChunk) => void;
deliveryTimeoutMs?: number;
};
const DEFAULT_DELIVERY_TIMEOUT_MS = 8_000;
export class TerminalOutputDeliveryQueue {
private readonly pendingChunks: TerminalOutputDeliveryChunk[] = [];
private inFlightChunk: TerminalOutputDeliveryChunk | null = null;
private inFlightTimeout: ReturnType<typeof setTimeout> | null = null;
private lastSeenSequence = 0;
private readonly deliveryTimeoutMs: number;
constructor(private readonly options: TerminalOutputDeliveryQueueOptions) {
this.deliveryTimeoutMs = options.deliveryTimeoutMs ?? DEFAULT_DELIVERY_TIMEOUT_MS;
}
enqueue(chunk: TerminalOutputDeliveryChunk): void {
if (chunk.sequence <= 0) {
return;
}
if (chunk.sequence <= this.lastSeenSequence) {
return;
}
this.lastSeenSequence = chunk.sequence;
if (chunk.text.length === 0) {
this.pendingChunks.length = 0;
this.pendingChunks.push(chunk);
this.tryDeliver();
return;
}
const lastPendingChunk = this.pendingChunks[this.pendingChunks.length - 1];
if (
lastPendingChunk &&
lastPendingChunk.text.length > 0 &&
lastPendingChunk.replay === chunk.replay
) {
lastPendingChunk.sequence = chunk.sequence;
lastPendingChunk.text += chunk.text;
} else {
this.pendingChunks.push(chunk);
}
this.tryDeliver();
}
consume(input: { sequence: number }): void {
if (this.inFlightChunk?.sequence !== input.sequence) {
return;
}
this.clearInFlightTimeout();
this.inFlightChunk = null;
this.tryDeliver();
}
reset(): void {
this.clearInFlightTimeout();
this.pendingChunks.length = 0;
this.inFlightChunk = null;
this.lastSeenSequence = 0;
}
private tryDeliver(): void {
if (this.inFlightChunk) {
return;
}
const nextChunk = this.pendingChunks.shift();
if (!nextChunk) {
return;
}
this.inFlightChunk = nextChunk;
this.deliverInFlightChunk();
}
private deliverInFlightChunk(): void {
const chunk = this.inFlightChunk;
if (!chunk) {
return;
}
this.clearInFlightTimeout();
this.inFlightTimeout = setTimeout(() => {
if (!this.inFlightChunk) {
return;
}
this.deliverInFlightChunk();
}, this.deliveryTimeoutMs);
this.options.onDeliver({
sequence: chunk.sequence,
text: chunk.text,
replay: chunk.replay,
});
}
private clearInFlightTimeout(): void {
if (!this.inFlightTimeout) {
return;
}
clearTimeout(this.inFlightTimeout);
this.inFlightTimeout = null;
}
}
@@ -1,99 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { TerminalOutputPump } from "./terminal-output-pump";
describe("terminal-output-pump", () => {
it("batches selected-terminal chunk bursts into ordered flushes", () => {
vi.useFakeTimers();
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 100,
onSelectedOutputChunk: (chunk) => {
chunks.push(chunk);
},
});
pump.setSelectedTerminal({ terminalId: "term-1" });
pump.append({ terminalId: "term-1", text: "a", replay: false });
pump.append({ terminalId: "term-1", text: "b", replay: false });
pump.append({ terminalId: "term-1", text: "c", replay: false });
expect(chunks).toEqual([]);
vi.runOnlyPendingTimers();
expect(chunks).toEqual([{ sequence: 1, text: "abc", replay: false }]);
vi.useRealTimers();
});
it("keeps per-terminal snapshots and switches selected stream deterministically", () => {
vi.useFakeTimers();
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 10,
onSelectedOutputChunk: (chunk) => {
chunks.push(chunk);
},
});
pump.setSelectedTerminal({ terminalId: "term-1" });
pump.append({ terminalId: "term-1", text: "hello", replay: false });
vi.runOnlyPendingTimers();
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("hello");
pump.append({ terminalId: "term-2", text: "world", replay: false });
vi.runOnlyPendingTimers();
expect(pump.readSnapshot({ terminalId: "term-2" })).toBe("world");
pump.setSelectedTerminal({ terminalId: "term-2" });
pump.append({ terminalId: "term-2", text: "!", replay: false });
vi.runOnlyPendingTimers();
expect(chunks).toEqual([
{ sequence: 1, text: "hello", replay: false },
{ sequence: 2, text: "!", replay: false },
]);
vi.useRealTimers();
});
it("resets selected output when clearing selected terminal", () => {
vi.useFakeTimers();
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 10,
onSelectedOutputChunk: (chunk) => {
chunks.push(chunk);
},
});
pump.setSelectedTerminal({ terminalId: "term-1" });
pump.append({ terminalId: "term-1", text: "abc", replay: false });
vi.runOnlyPendingTimers();
pump.clearTerminal({ terminalId: "term-1" });
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("");
expect(chunks).toEqual([
{ sequence: 1, text: "abc", replay: false },
{ sequence: 2, text: "", replay: false },
]);
vi.useRealTimers();
});
it("prunes orphaned terminal buffers", () => {
const pump = new TerminalOutputPump({
maxOutputChars: 100,
onSelectedOutputChunk: () => {},
});
pump.append({ terminalId: "a", text: "one", replay: false });
pump.append({ terminalId: "b", text: "two", replay: false });
pump.prune({ terminalIds: ["b"] });
expect(pump.readSnapshot({ terminalId: "a" })).toBe("");
expect(pump.readSnapshot({ terminalId: "b" })).toBe("two");
});
});
@@ -1,175 +0,0 @@
import {
appendTerminalOutputBuffer,
createTerminalOutputBuffer,
readTerminalOutputBuffer,
type TerminalOutputBuffer,
} from "@/utils/terminal-output-buffer";
export type TerminalOutputChunk = {
sequence: number;
text: string;
replay: boolean;
};
export type TerminalOutputPumpOptions = {
maxOutputChars: number;
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => void;
};
export type TerminalOutputPumpSetSelectedInput = {
terminalId: string | null;
};
export type TerminalOutputPumpAppendInput = {
terminalId: string;
text: string;
replay: boolean;
};
export type TerminalOutputPumpReadInput = {
terminalId: string | null;
};
export type TerminalOutputPumpClearInput = {
terminalId: string;
};
export type TerminalOutputPumpPruneInput = {
terminalIds: string[];
};
export class TerminalOutputPump {
private readonly buffersByTerminalId = new Map<string, TerminalOutputBuffer>();
private selectedTerminalId: string | null = null;
private selectedChunkSequence = 0;
private selectedChunkAccumulator = "";
private selectedChunkAccumulatorReplay: boolean | null = null;
private selectedChunkFlushTimer: ReturnType<typeof setTimeout> | null = null;
constructor(private readonly options: TerminalOutputPumpOptions) {}
setSelectedTerminal(input: TerminalOutputPumpSetSelectedInput): void {
if (this.selectedTerminalId === input.terminalId) {
return;
}
this.clearSelectedChunkFlushTimer();
this.selectedChunkAccumulator = "";
this.selectedChunkAccumulatorReplay = null;
this.selectedTerminalId = input.terminalId;
}
append(input: TerminalOutputPumpAppendInput): void {
if (input.text.length === 0) {
return;
}
let buffer = this.buffersByTerminalId.get(input.terminalId);
if (!buffer) {
buffer = createTerminalOutputBuffer();
this.buffersByTerminalId.set(input.terminalId, buffer);
}
appendTerminalOutputBuffer({
buffer,
text: input.text,
maxChars: this.options.maxOutputChars,
});
if (this.selectedTerminalId !== input.terminalId) {
return;
}
if (
this.selectedChunkAccumulator.length > 0 &&
typeof this.selectedChunkAccumulatorReplay === "boolean" &&
this.selectedChunkAccumulatorReplay !== input.replay
) {
this.flushSelectedChunkAccumulator();
}
if (this.selectedChunkAccumulator.length === 0) {
this.selectedChunkAccumulatorReplay = input.replay;
}
this.selectedChunkAccumulator += input.text;
this.scheduleSelectedChunkFlush();
}
clearTerminal(input: TerminalOutputPumpClearInput): void {
this.buffersByTerminalId.delete(input.terminalId);
if (this.selectedTerminalId === input.terminalId) {
this.clearSelectedChunkFlushTimer();
this.selectedChunkAccumulator = "";
this.selectedChunkAccumulatorReplay = null;
this.emitSelectedChunk({ text: "", replay: false });
}
}
prune(input: TerminalOutputPumpPruneInput): void {
const terminalIdSet = new Set(input.terminalIds);
for (const terminalId of Array.from(this.buffersByTerminalId.keys())) {
if (!terminalIdSet.has(terminalId)) {
this.buffersByTerminalId.delete(terminalId);
}
}
}
readSnapshot(input: TerminalOutputPumpReadInput): string {
if (!input.terminalId) {
return "";
}
const buffer = this.buffersByTerminalId.get(input.terminalId);
if (!buffer) {
return "";
}
return readTerminalOutputBuffer({ buffer });
}
dispose(): void {
this.clearSelectedChunkFlushTimer();
this.selectedChunkAccumulator = "";
this.selectedTerminalId = null;
this.buffersByTerminalId.clear();
}
private scheduleSelectedChunkFlush(): void {
if (this.selectedChunkFlushTimer) {
return;
}
this.selectedChunkFlushTimer = setTimeout(() => {
this.selectedChunkFlushTimer = null;
this.flushSelectedChunkAccumulator();
}, 0);
}
private flushSelectedChunkAccumulator(): void {
if (this.selectedChunkAccumulator.length === 0) {
return;
}
const text = this.selectedChunkAccumulator;
const replay = this.selectedChunkAccumulatorReplay ?? false;
this.selectedChunkAccumulator = "";
this.selectedChunkAccumulatorReplay = null;
this.emitSelectedChunk({ text, replay });
}
private emitSelectedChunk(input: { text: string; replay: boolean }): void {
this.selectedChunkSequence += 1;
this.options.onSelectedOutputChunk({
sequence: this.selectedChunkSequence,
text: input.text,
replay: input.replay,
});
}
private clearSelectedChunkFlushTimer(): void {
if (!this.selectedChunkFlushTimer) {
return;
}
clearTimeout(this.selectedChunkFlushTimer);
this.selectedChunkFlushTimer = null;
}
}
@@ -1,97 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getTerminalOutputSession,
releaseTerminalOutputSession,
retainTerminalOutputSession,
} from "./terminal-output-session";
describe("terminal-output-session", () => {
async function flushAsyncWork(): Promise<void> {
await Promise.resolve();
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 0);
});
await Promise.resolve();
}
it("returns the same session instance for the same scope key", () => {
const a = getTerminalOutputSession({
scopeKey: "scope-a",
maxOutputChars: 1_000,
});
const b = getTerminalOutputSession({
scopeKey: "scope-a",
maxOutputChars: 50,
});
expect(a).toBe(b);
});
it("evicts a scope session when the retain count returns to zero", () => {
const scopeKey = "scope-retain-release";
const first = getTerminalOutputSession({
scopeKey,
maxOutputChars: 1_000,
});
retainTerminalOutputSession({ scopeKey });
releaseTerminalOutputSession({ scopeKey });
const second = getTerminalOutputSession({
scopeKey,
maxOutputChars: 1_000,
});
expect(second).not.toBe(first);
});
it("publishes selected terminal snapshot and chunk state via delivery/consume", async () => {
const session = getTerminalOutputSession({
scopeKey: "scope-session-state",
maxOutputChars: 1_000,
});
session.setSelectedTerminal({ terminalId: null });
session.setSelectedTerminal({ terminalId: "term-1" });
session.append({
terminalId: "term-1",
text: "abc",
replay: false,
});
await flushAsyncWork();
const stateAfterAppend = session.getState();
expect(stateAfterAppend.selectedTerminalId).toBe("term-1");
expect(stateAfterAppend.chunkSequence).toBeGreaterThan(0);
expect(stateAfterAppend.chunkText).toBe("abc");
expect(stateAfterAppend.snapshotText).toBe("abc");
expect(stateAfterAppend.snapshotSequence).toBe(stateAfterAppend.chunkSequence);
session.consume({ sequence: stateAfterAppend.chunkSequence });
const stateAfterConsume = session.getState();
expect(stateAfterConsume.snapshotText).toBe("abc");
});
it("keeps chunks for non-selected terminals out of selected state", async () => {
const session = getTerminalOutputSession({
scopeKey: "scope-other-terminal",
maxOutputChars: 1_000,
});
session.setSelectedTerminal({ terminalId: "term-1" });
session.append({
terminalId: "term-2",
text: "hidden",
replay: false,
});
await flushAsyncWork();
const state = session.getState();
expect(state.selectedTerminalId).toBe("term-1");
expect(state.chunkText).toBe("");
expect(state.snapshotText).toBe("");
});
});
@@ -1,184 +0,0 @@
import { TerminalOutputPump, type TerminalOutputChunk } from "./terminal-output-pump";
import {
TerminalOutputDeliveryQueue,
type TerminalOutputDeliveryChunk,
} from "./terminal-output-delivery-queue";
type TerminalOutputState = {
selectedTerminalId: string | null;
snapshotText: string;
snapshotSequence: number;
chunkText: string;
chunkSequence: number;
chunkReplay: boolean;
};
const EMPTY_STATE: TerminalOutputState = {
selectedTerminalId: null,
snapshotText: "",
snapshotSequence: 0,
chunkText: "",
chunkSequence: 0,
chunkReplay: false,
};
export type TerminalOutputSessionAppendInput = {
terminalId: string;
text: string;
replay: boolean;
};
export type TerminalOutputSessionSetSelectedInput = {
terminalId: string | null;
};
export type TerminalOutputSessionPruneInput = {
terminalIds: string[];
};
export type TerminalOutputSessionConsumeInput = {
sequence: number;
};
export type TerminalOutputSessionReadSnapshotInput = {
terminalId: string | null;
};
export class TerminalOutputSession {
private readonly listeners = new Set<() => void>();
private readonly outputPump: TerminalOutputPump;
private readonly deliveryQueue: TerminalOutputDeliveryQueue;
private state: TerminalOutputState = EMPTY_STATE;
constructor(input: { maxOutputChars: number }) {
this.outputPump = new TerminalOutputPump({
maxOutputChars: input.maxOutputChars,
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => {
this.deliveryQueue.enqueue(chunk);
},
});
this.deliveryQueue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk: TerminalOutputDeliveryChunk) => {
const snapshotText = this.outputPump.readSnapshot({
terminalId: this.state.selectedTerminalId,
});
this.state = {
...this.state,
snapshotText,
snapshotSequence: chunk.sequence,
chunkText: chunk.text,
chunkSequence: chunk.sequence,
chunkReplay: chunk.replay,
};
this.emit();
},
});
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
getState(): TerminalOutputState {
return this.state;
}
setSelectedTerminal(input: TerminalOutputSessionSetSelectedInput): void {
if (this.state.selectedTerminalId === input.terminalId) {
return;
}
this.deliveryQueue.reset();
this.outputPump.setSelectedTerminal({ terminalId: input.terminalId });
const snapshotText = this.outputPump.readSnapshot({
terminalId: input.terminalId,
});
this.state = {
selectedTerminalId: input.terminalId,
snapshotText,
snapshotSequence: this.state.snapshotSequence,
chunkText: "",
chunkSequence: 0,
chunkReplay: false,
};
this.emit();
}
append(input: TerminalOutputSessionAppendInput): void {
this.outputPump.append(input);
}
readSnapshot(input: TerminalOutputSessionReadSnapshotInput): string {
return this.outputPump.readSnapshot(input);
}
clearTerminal(input: { terminalId: string }): void {
this.outputPump.clearTerminal(input);
if (this.state.selectedTerminalId !== input.terminalId) {
return;
}
this.deliveryQueue.reset();
this.state = {
...this.state,
snapshotText: "",
chunkText: "",
chunkSequence: 0,
chunkReplay: false,
};
this.emit();
}
prune(input: TerminalOutputSessionPruneInput): void {
this.outputPump.prune(input);
}
consume(input: TerminalOutputSessionConsumeInput): void {
this.deliveryQueue.consume(input);
}
private emit(): void {
for (const listener of this.listeners) {
listener();
}
}
}
const sessionsByScopeKey = new Map<string, TerminalOutputSession>();
const sessionRefCountByScopeKey = new Map<string, number>();
export function getTerminalOutputSession(input: {
scopeKey: string;
maxOutputChars: number;
}): TerminalOutputSession {
const existing = sessionsByScopeKey.get(input.scopeKey);
if (existing) {
return existing;
}
const session = new TerminalOutputSession({
maxOutputChars: input.maxOutputChars,
});
sessionsByScopeKey.set(input.scopeKey, session);
return session;
}
export function retainTerminalOutputSession(input: { scopeKey: string }): void {
const current = sessionRefCountByScopeKey.get(input.scopeKey) ?? 0;
sessionRefCountByScopeKey.set(input.scopeKey, current + 1);
}
export function releaseTerminalOutputSession(input: { scopeKey: string }): void {
const current = sessionRefCountByScopeKey.get(input.scopeKey) ?? 0;
if (current <= 1) {
sessionRefCountByScopeKey.delete(input.scopeKey);
sessionsByScopeKey.delete(input.scopeKey);
return;
}
sessionRefCountByScopeKey.set(input.scopeKey, current - 1);
}
@@ -0,0 +1,117 @@
import { Terminal as HeadlessTerminal } from "@xterm/headless";
import { Terminal as ClientTerminal } from "@xterm/xterm";
import { describe, expect, it } from "vitest";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
type SnapshotCell = {
char: string;
fg: number | undefined;
bg: number | undefined;
fgMode?: number;
bgMode?: number;
bold?: boolean;
italic?: boolean;
underline?: boolean;
};
type SnapshotState = {
rows: number;
cols: number;
grid: SnapshotCell[][];
scrollback: SnapshotCell[][];
cursor: { row: number; col: number };
};
async function writeToTerminal(
terminal: Pick<ClientTerminal | HeadlessTerminal, "write">,
text: string,
): Promise<void> {
await new Promise<void>((resolve) => {
terminal.write(text, () => resolve());
});
}
function extractState(terminal: ClientTerminal | HeadlessTerminal): SnapshotState {
const grid: SnapshotCell[][] = [];
const scrollback: SnapshotCell[][] = [];
const buffer = terminal.buffer.active;
const baseY = buffer.baseY;
for (let row = 0; row < baseY; row += 1) {
scrollback.push(extractRow(terminal, row));
}
for (let row = 0; row < terminal.rows; row += 1) {
grid.push(extractRow(terminal, baseY + row));
}
return {
rows: terminal.rows,
cols: terminal.cols,
grid,
scrollback,
cursor: {
row: buffer.cursorY,
col: buffer.cursorX,
},
};
}
function extractRow(terminal: ClientTerminal | HeadlessTerminal, row: number): SnapshotCell[] {
const cells: SnapshotCell[] = [];
const line = terminal.buffer.active.getLine(row);
for (let col = 0; col < terminal.cols; col += 1) {
const cell = line?.getCell(col);
if (!cell) {
cells.push({ char: " ", fg: undefined, bg: undefined });
continue;
}
const fgModeRaw = cell.getFgColorMode();
const bgModeRaw = cell.getBgColorMode();
const fgMode = fgModeRaw >> 24;
const bgMode = bgModeRaw >> 24;
cells.push({
char: cell.getChars() || " ",
fg: fgMode !== 0 ? cell.getFgColor() : undefined,
bg: bgMode !== 0 ? cell.getBgColor() : undefined,
fgMode: fgMode !== 0 ? fgMode : undefined,
bgMode: bgMode !== 0 ? bgMode : undefined,
bold: cell.isBold() !== 0,
italic: cell.isItalic() !== 0,
underline: cell.isUnderline() !== 0,
});
}
return cells;
}
describe("terminal-snapshot", () => {
it("replays extracted terminal state into a client xterm with matching grid, scrollback, and cursor", async () => {
const source = new HeadlessTerminal({
rows: 4,
cols: 12,
allowProposedApi: true,
scrollback: 100,
});
await writeToTerminal(source, "plain\r\n");
await writeToTerminal(source, "\u001b[31mred\u001b[0m\r\n");
await writeToTerminal(source, "\u001b[1mbold\u001b[0m\r\n");
await writeToTerminal(source, "cursor");
await writeToTerminal(source, "\u001b[2D");
const snapshot = extractState(source);
const client = new ClientTerminal({
rows: snapshot.rows,
cols: snapshot.cols,
allowProposedApi: true,
scrollback: 100,
});
await writeToTerminal(client, renderTerminalSnapshotToAnsi(snapshot));
expect(extractState(client)).toEqual(snapshot);
});
});
@@ -0,0 +1,158 @@
import type { TerminalCell, TerminalState } from "@server/shared/messages";
type TerminalStyle = {
fg: number | undefined;
bg: number | undefined;
fgMode: number | undefined;
bgMode: number | undefined;
bold: boolean;
italic: boolean;
underline: boolean;
};
const DEFAULT_STYLE: TerminalStyle = {
fg: undefined,
bg: undefined,
fgMode: undefined,
bgMode: undefined,
bold: false,
italic: false,
underline: false,
};
export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
const rows = [...state.scrollback, ...state.grid];
const lines: string[] = ["\u001b[?7l"];
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex] ?? [];
lines.push(renderTerminalRow(row));
if (rowIndex < rows.length - 1) {
lines.push("\r\n");
}
}
lines.push("\u001b[0m");
lines.push(`\u001b[${state.cursor.row + 1};${state.cursor.col + 1}H`);
lines.push("\u001b[?7h");
return lines.join("");
}
function renderTerminalRow(row: TerminalCell[]): string {
const output: string[] = [];
const length = getTerminalRowLength(row);
let previousStyle = DEFAULT_STYLE;
for (let index = 0; index < length; index += 1) {
const cell = row[index] ?? { char: " " };
const nextStyle = getTerminalStyle(cell);
if (!terminalStylesEqual(previousStyle, nextStyle)) {
output.push(styleToAnsi(nextStyle));
previousStyle = nextStyle;
}
output.push(cell.char || " ");
}
if (!terminalStylesEqual(previousStyle, DEFAULT_STYLE)) {
output.push("\u001b[0m");
}
return output.join("");
}
function getTerminalRowLength(row: TerminalCell[]): number {
for (let index = row.length - 1; index >= 0; index -= 1) {
const cell = row[index];
if (!cell) {
continue;
}
if (cell.char !== " ") {
return index + 1;
}
if (
cell.fg !== undefined ||
cell.bg !== undefined ||
cell.fgMode !== undefined ||
cell.bgMode !== undefined ||
cell.bold ||
cell.italic ||
cell.underline
) {
return index + 1;
}
}
return 0;
}
function getTerminalStyle(cell: TerminalCell): TerminalStyle {
return {
fg: cell.fg,
bg: cell.bg,
fgMode: cell.fgMode,
bgMode: cell.bgMode,
bold: Boolean(cell.bold),
italic: Boolean(cell.italic),
underline: Boolean(cell.underline),
};
}
function terminalStylesEqual(left: TerminalStyle, right: TerminalStyle): boolean {
return (
left.fg === right.fg &&
left.bg === right.bg &&
left.fgMode === right.fgMode &&
left.bgMode === right.bgMode &&
left.bold === right.bold &&
left.italic === right.italic &&
left.underline === right.underline
);
}
function styleToAnsi(style: TerminalStyle): string {
const codes = ["0"];
if (style.bold) {
codes.push("1");
}
if (style.italic) {
codes.push("3");
}
if (style.underline) {
codes.push("4");
}
if (style.fg !== undefined && style.fgMode !== undefined) {
codes.push(...colorToSgr(style.fgMode, style.fg, false));
}
if (style.bg !== undefined && style.bgMode !== undefined) {
codes.push(...colorToSgr(style.bgMode, style.bg, true));
}
return `\u001b[${codes.join(";")}m`;
}
function colorToSgr(mode: number, value: number, background: boolean): string[] {
if (mode === 1) {
if (value >= 8) {
return [String((background ? 100 : 90) + (value - 8))];
}
return [String((background ? 40 : 30) + value)];
}
if (mode === 2) {
return [background ? "48" : "38", "5", String(value)];
}
if (mode === 3) {
return [
background ? "48" : "38",
"2",
String((value >> 16) & 0xff),
String((value >> 8) & 0xff),
String(value & 0xff),
];
}
return [];
}
@@ -2,154 +2,118 @@ import { describe, expect, it } from "vitest";
import {
TerminalStreamController,
type TerminalStreamControllerAttachPayload,
type TerminalStreamControllerChunk,
type TerminalStreamControllerClient,
type TerminalStreamControllerResumeOffsets,
type TerminalStreamControllerStatus,
} from "./terminal-stream-controller";
type FakeStreamSubscriber = (chunk: TerminalStreamControllerChunk) => void;
type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array }
| {
terminalId: string;
type: "snapshot";
state: {
rows: number;
cols: number;
grid: Array<Array<{ char: string }>>;
scrollback: Array<Array<{ char: string }>>;
cursor: { row: number; col: number };
};
};
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
private readonly streamSubscribers = new Map<number, Set<FakeStreamSubscriber>>();
private readonly pendingChunksByStreamId = new Map<number, TerminalStreamControllerChunk[]>();
public attachCalls: Array<{
private readonly listeners = new Set<(event: TerminalStreamEvent) => void>();
public subscribeCalls: string[] = [];
public unsubscribeCalls: string[] = [];
public resizeCalls: Array<{ terminalId: string; rows: number; cols: number }> = [];
public nextSubscribeResponses: Array<{
terminalId: string;
options?: {
resumeOffset?: number;
rows?: number;
cols?: number;
};
state: {
rows: number;
cols: number;
grid: Array<Array<{ char: string }>>;
scrollback: Array<Array<{ char: string }>>;
cursor: { row: number; col: number };
} | null;
error?: string | null;
}> = [];
public detachCalls: number[] = [];
public nextAttachResponses: TerminalStreamControllerAttachPayload[] = [];
async attachTerminalStream(
terminalId: string,
options?: {
resumeOffset?: number;
rows?: number;
cols?: number;
},
): Promise<TerminalStreamControllerAttachPayload> {
this.attachCalls.push({ terminalId, options });
const response = this.nextAttachResponses.shift();
async subscribeTerminal(terminalId: string) {
this.subscribeCalls.push(terminalId);
const response = this.nextSubscribeResponses.shift();
if (!response) {
throw new Error("Missing fake attach response");
throw new Error("Missing fake subscribe response");
}
return response;
}
async detachTerminalStream(streamId: number): Promise<void> {
this.detachCalls.push(streamId);
unsubscribeTerminal(terminalId: string): void {
this.unsubscribeCalls.push(terminalId);
}
onTerminalStreamData(
streamId: number,
handler: (chunk: TerminalStreamControllerChunk) => void,
): () => void {
const pendingChunks = this.pendingChunksByStreamId.get(streamId);
if (pendingChunks && pendingChunks.length > 0) {
for (const chunk of pendingChunks) {
handler(chunk);
}
this.pendingChunksByStreamId.delete(streamId);
}
const subscribers = this.streamSubscribers.get(streamId) ?? new Set();
subscribers.add(handler);
this.streamSubscribers.set(streamId, subscribers);
return () => {
const current = this.streamSubscribers.get(streamId);
current?.delete(handler);
if (current && current.size === 0) {
this.streamSubscribers.delete(streamId);
}
};
}
emitChunk(input: {
streamId: number;
offset?: number;
endOffset: number;
replay?: boolean;
data: string;
}): void {
const subscribers = this.streamSubscribers.get(input.streamId);
if (!subscribers || subscribers.size === 0) {
return;
}
const bytes = new TextEncoder().encode(input.data);
const chunk: TerminalStreamControllerChunk = {
offset: input.offset ?? input.endOffset - bytes.byteLength,
endOffset: input.endOffset,
replay: input.replay,
data: bytes,
};
for (const subscriber of subscribers) {
subscriber(chunk);
}
}
bufferChunk(input: {
streamId: number;
offset?: number;
endOffset: number;
replay?: boolean;
data: string;
}): void {
const chunks = this.pendingChunksByStreamId.get(input.streamId) ?? [];
const bytes = new TextEncoder().encode(input.data);
chunks.push({
offset: input.offset ?? input.endOffset - bytes.byteLength,
endOffset: input.endOffset,
replay: input.replay,
data: bytes,
sendTerminalInput(
terminalId: string,
message: { type: "resize"; rows: number; cols: number },
): void {
this.resizeCalls.push({
terminalId,
rows: message.rows,
cols: message.cols,
});
this.pendingChunksByStreamId.set(input.streamId, chunks);
}
onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void {
this.listeners.add(handler);
return () => {
this.listeners.delete(handler);
};
}
emit(event: TerminalStreamEvent): void {
for (const listener of this.listeners) {
listener(event);
}
}
}
function createControllerHarness(input?: {
client?: FakeTerminalStreamClient;
resumeOffsets?: TerminalStreamControllerResumeOffsets;
}): {
client: FakeTerminalStreamClient;
chunks: Array<{ terminalId: string; text: string }>;
outputs: Array<{ terminalId: string; text: string }>;
snapshots: Array<{ terminalId: string; text: string }>;
statuses: TerminalStreamControllerStatus[];
resets: string[];
controller: TerminalStreamController;
} {
const client = input?.client ?? new FakeTerminalStreamClient();
const chunks: Array<{ terminalId: string; text: string }> = [];
const outputs: Array<{ terminalId: string; text: string }> = [];
const snapshots: Array<{ terminalId: string; text: string }> = [];
const statuses: TerminalStreamControllerStatus[] = [];
const resets: string[] = [];
const controller = new TerminalStreamController({
client,
getPreferredSize: () => ({ rows: 24, cols: 80 }),
resumeOffsets: input?.resumeOffsets,
onChunk: (chunk) => {
chunks.push({
terminalId: chunk.terminalId,
text: chunk.text,
onOutput: ({ terminalId, text }) => {
outputs.push({ terminalId, text });
},
onSnapshot: ({ terminalId, state }) => {
snapshots.push({
terminalId,
text: state.grid
.map((row) => row.map((cell) => cell.char).join(""))
.join("\n"),
});
},
onStatusChange: (status) => {
statuses.push(status);
},
onReset: ({ terminalId }) => {
resets.push(terminalId);
},
waitForDelay: async () => {},
});
return {
client,
chunks,
outputs,
snapshots,
statuses,
resets,
controller,
};
}
@@ -163,425 +127,122 @@ async function flushAsyncWork(): Promise<void> {
}
describe("terminal-stream-controller", () => {
it("streams burst chunks in order without dropping intermediate chunks", async () => {
it("subscribes to a terminal, resizes it, and forwards snapshot/output events", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 7,
currentOffset: 0,
reset: false,
harness.client.nextSubscribeResponses.push({
terminalId: "term-1",
state: null,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
harness.client.emitChunk({
streamId: 7,
endOffset: 1,
data: "a",
harness.client.emit({
terminalId: "term-1",
type: "snapshot",
state: {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
},
});
harness.client.emitChunk({
streamId: 7,
endOffset: 2,
data: "b",
});
harness.client.emitChunk({
streamId: 7,
endOffset: 3,
data: "c",
harness.client.emit({
terminalId: "term-1",
type: "output",
data: new TextEncoder().encode(" world"),
});
expect(harness.chunks).toEqual([
{ terminalId: "term-1", text: "a" },
{ terminalId: "term-1", text: "b" },
{ terminalId: "term-1", text: "c" },
]);
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.client.resizeCalls).toEqual([{ terminalId: "term-1", rows: 24, cols: 80 }]);
expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]);
expect(harness.outputs).toEqual([{ terminalId: "term-1", text: " world" }]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
error: null,
});
});
it("retries retryable attach failures and then attaches", async () => {
it("retries retryable subscribe failures and then attaches", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: null,
currentOffset: 0,
reset: false,
harness.client.nextSubscribeResponses.push({
terminalId: "term-1",
state: null,
error: "network disconnected",
});
harness.client.nextAttachResponses.push({
streamId: 9,
currentOffset: 5,
reset: false,
harness.client.nextSubscribeResponses.push({
terminalId: "term-1",
state: null,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
expect(harness.client.attachCalls.length).toBe(2);
expect(harness.client.attachCalls[1]?.options).toEqual({
resumeOffset: 0,
rows: 24,
cols: 80,
});
expect(harness.controller.getActiveStreamId()).toBe(9);
expect(harness.client.subscribeCalls).toEqual(["term-1", "term-1"]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
streamId: 9,
isAttaching: false,
error: null,
});
});
it("handles stream exit by reconnecting on the same terminal", async () => {
it("reconnects to the selected terminal when the stream exits", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 3,
currentOffset: 0,
reset: false,
harness.client.nextSubscribeResponses.push({
terminalId: "term-1",
state: null,
error: null,
});
harness.client.nextAttachResponses.push({
streamId: 4,
currentOffset: 2,
reset: false,
harness.client.nextSubscribeResponses.push({
terminalId: "term-1",
state: null,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
harness.client.emitChunk({
streamId: 3,
endOffset: 2,
data: "hi",
});
harness.controller.handleStreamExit({
terminalId: "term-1",
streamId: 3,
});
harness.controller.handleStreamExit({ terminalId: "term-1" });
await flushAsyncWork();
expect(harness.client.attachCalls.length).toBe(2);
expect(harness.client.attachCalls[1]?.options).toEqual({
resumeOffset: 2,
rows: 24,
cols: 80,
});
expect(harness.controller.getActiveStreamId()).toBe(4);
expect(harness.client.subscribeCalls).toEqual(["term-1", "term-1"]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
streamId: 4,
isAttaching: false,
error: null,
});
});
it("recovers when stream exits before initial attach completes", async () => {
it("unsubscribes when switching terminals and on dispose", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 1,
currentOffset: 1200,
reset: false,
harness.client.nextSubscribeResponses.push({
terminalId: "term-1",
state: null,
error: null,
});
harness.client.nextAttachResponses.push({
streamId: 2,
currentOffset: 1200,
reset: false,
harness.client.nextSubscribeResponses.push({
terminalId: "term-2",
state: null,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-attach-exit" });
harness.controller.handleStreamExit({
terminalId: "term-attach-exit",
streamId: 1,
});
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
expect(harness.client.attachCalls).toHaveLength(2);
expect(harness.client.attachCalls[1]?.options).toEqual({
resumeOffset: 1200,
rows: 24,
cols: 80,
});
expect(harness.client.detachCalls).toContain(1);
expect(harness.controller.getActiveStreamId()).toBe(2);
});
harness.controller.setTerminal({ terminalId: "term-2" });
await flushAsyncWork();
it("emits reset callback when attach indicates output reset", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 12,
currentOffset: 0,
reset: true,
harness.controller.dispose();
expect(harness.client.unsubscribeCalls).toEqual(["term-1", "term-2"]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: null,
isAttaching: false,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-reset" });
await flushAsyncWork();
expect(harness.resets).toEqual(["term-reset"]);
expect(harness.controller.getActiveStreamId()).toBe(12);
});
it("delivers buffered replay chunks flushed synchronously during subscribe", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 17,
replayedFrom: 0,
currentOffset: 10,
reset: false,
error: null,
});
harness.client.bufferChunk({
streamId: 17,
offset: 0,
endOffset: 10,
replay: true,
data: "buffered-replay",
});
harness.controller.setTerminal({ terminalId: "term-buffered" });
await flushAsyncWork();
expect(harness.chunks).toEqual([{ terminalId: "term-buffered", text: "buffered-replay" }]);
});
it("clears stale selected output when bootstrap replay starts before current offset", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 61,
replayedFrom: 0,
currentOffset: 20,
reset: false,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-bootstrap-reset" });
await flushAsyncWork();
expect(harness.resets).toEqual(["term-bootstrap-reset"]);
});
it("reattaches and replays from last contiguous offset when chunk offsets gap", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 31,
currentOffset: 0,
reset: false,
error: null,
});
harness.client.nextAttachResponses.push({
streamId: 32,
currentOffset: 8,
reset: false,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-gap" });
await flushAsyncWork();
harness.client.emitChunk({
streamId: 31,
offset: 0,
endOffset: 2,
data: "ok",
});
harness.client.emitChunk({
streamId: 31,
offset: 4,
endOffset: 8,
data: "miss",
});
await flushAsyncWork();
expect(harness.chunks).toEqual([{ terminalId: "term-gap", text: "ok" }]);
expect(harness.client.detachCalls).toContain(31);
expect(harness.client.attachCalls.length).toBe(2);
expect(harness.client.attachCalls[1]?.options).toEqual({
resumeOffset: 2,
rows: 24,
cols: 80,
});
expect(harness.controller.getActiveStreamId()).toBe(32);
});
it("does not treat replay range before currentOffset as a live gap", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 41,
replayedFrom: 50,
currentOffset: 100,
reset: false,
error: null,
});
harness.client.bufferChunk({
streamId: 41,
offset: 80,
endOffset: 100,
data: "replay-tail",
});
harness.controller.setTerminal({ terminalId: "term-replay" });
await flushAsyncWork();
harness.client.emitChunk({
streamId: 41,
offset: 100,
endOffset: 102,
data: "ok",
});
expect(harness.client.detachCalls).toEqual([]);
expect(harness.client.attachCalls).toHaveLength(1);
expect(harness.controller.getActiveStreamId()).toBe(41);
expect(harness.chunks).toEqual([
{ terminalId: "term-replay", text: "replay-tail" },
{ terminalId: "term-replay", text: "ok" },
]);
});
it("accepts clamped replay start after reconnect without entering a reconnect loop", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 71,
replayedFrom: 205,
currentOffset: 205,
reset: false,
error: null,
});
harness.client.nextAttachResponses.push({
streamId: 72,
replayedFrom: 396,
currentOffset: 978,
reset: true,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-clamped-replay" });
await flushAsyncWork();
harness.client.emitChunk({
streamId: 71,
offset: 205,
endOffset: 299,
data: "first",
});
harness.client.emitChunk({
streamId: 71,
offset: 396,
endOffset: 493,
data: "gap",
});
await flushAsyncWork();
expect(harness.client.attachCalls).toHaveLength(2);
expect(harness.client.attachCalls[1]?.options).toEqual({
resumeOffset: 299,
rows: 24,
cols: 80,
});
expect(harness.controller.getActiveStreamId()).toBe(72);
harness.client.emitChunk({
streamId: 72,
replay: true,
offset: 396,
endOffset: 493,
data: "replay-1",
});
harness.client.emitChunk({
streamId: 72,
replay: true,
offset: 493,
endOffset: 590,
data: "replay-2",
});
harness.client.emitChunk({
streamId: 72,
replay: true,
offset: 687,
endOffset: 784,
data: "replay-3",
});
harness.client.emitChunk({
streamId: 72,
replay: false,
offset: 978,
endOffset: 1075,
data: "live",
});
await flushAsyncWork();
expect(harness.client.attachCalls).toHaveLength(2);
expect(harness.client.detachCalls.filter((streamId) => streamId === 71)).toHaveLength(1);
expect(harness.chunks.at(-1)).toEqual({
terminalId: "term-clamped-replay",
text: "live",
});
});
it("reuses external resume offsets across controller recreation", async () => {
const client = new FakeTerminalStreamClient();
const resumeOffsetByTerminalId = new Map<string, number>();
const resumeOffsets: TerminalStreamControllerResumeOffsets = {
get: ({ terminalId }) => resumeOffsetByTerminalId.get(terminalId),
set: ({ terminalId, offset }) => {
resumeOffsetByTerminalId.set(terminalId, offset);
},
clear: ({ terminalId }) => {
resumeOffsetByTerminalId.delete(terminalId);
},
prune: ({ terminalIds }) => {
const terminalIdSet = new Set(terminalIds);
for (const terminalId of Array.from(resumeOffsetByTerminalId.keys())) {
if (!terminalIdSet.has(terminalId)) {
resumeOffsetByTerminalId.delete(terminalId);
}
}
},
};
client.nextAttachResponses.push({
streamId: 81,
currentOffset: 0,
reset: false,
error: null,
});
const firstHarness = createControllerHarness({
client,
resumeOffsets,
});
firstHarness.controller.setTerminal({ terminalId: "term-shared-resume" });
await flushAsyncWork();
client.emitChunk({
streamId: 81,
offset: 0,
endOffset: 2,
data: "ok",
});
await flushAsyncWork();
firstHarness.controller.dispose();
client.nextAttachResponses.push({
streamId: 82,
currentOffset: 2,
reset: false,
error: null,
});
const secondHarness = createControllerHarness({
client,
resumeOffsets,
});
secondHarness.controller.setTerminal({ terminalId: "term-shared-resume" });
await flushAsyncWork();
expect(client.attachCalls.at(-1)?.options).toEqual({
resumeOffset: 2,
rows: 24,
cols: 80,
});
});
});
@@ -1,41 +1,28 @@
import type { TerminalState } from "@server/shared/messages";
import {
getTerminalAttachRetryDelayMs,
getTerminalResumeOffset,
isTerminalAttachRetryableError,
type TerminalResumeOffsetStore,
updateTerminalResumeOffset,
waitForDuration,
withPromiseTimeout,
} from "@/utils/terminal-attach";
export type TerminalStreamControllerAttachPayload = {
streamId: number | null;
replayedFrom?: number;
currentOffset: number;
reset: boolean;
error?: string | null;
};
export type TerminalStreamControllerChunk = {
offset: number;
endOffset: number;
replay?: boolean;
data: Uint8Array;
};
export type TerminalStreamControllerClient = {
attachTerminalStream: (
subscribeTerminal: (terminalId: string) => Promise<{
terminalId: string;
state: TerminalState | null;
error?: string | null;
}>;
unsubscribeTerminal: (terminalId: string) => void;
sendTerminalInput: (
terminalId: string,
options?: {
resumeOffset?: number;
rows?: number;
cols?: number;
},
) => Promise<TerminalStreamControllerAttachPayload>;
detachTerminalStream: (streamId: number) => Promise<unknown>;
onTerminalStreamData: (
streamId: number,
handler: (chunk: TerminalStreamControllerChunk) => void,
message: { type: "resize"; rows: number; cols: number },
) => void;
onTerminalStreamEvent: (
handler: (
event:
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalState },
) => void,
) => () => void;
};
@@ -46,22 +33,15 @@ export type TerminalStreamControllerSize = {
export type TerminalStreamControllerStatus = {
terminalId: string | null;
streamId: number | null;
isAttaching: boolean;
error: string | null;
};
export type TerminalStreamControllerResumeOffsets = TerminalResumeOffsetStore & {
clear: (input: { terminalId: string }) => void;
prune: (input: { terminalIds: string[] }) => void;
};
export type TerminalStreamControllerOptions = {
client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null;
resumeOffsets?: TerminalStreamControllerResumeOffsets;
onChunk: (input: { terminalId: string; text: string; replay: boolean }) => void;
onReset?: (input: { terminalId: string }) => void;
onOutput: (input: { terminalId: string; text: string }) => void;
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
maxAttachAttempts?: number;
attachTimeoutMs?: number;
@@ -76,37 +56,40 @@ export type TerminalStreamControllerOptions = {
getRetryDelayMs?: (input: { attempt: number }) => number;
};
type TerminalStreamControllerActiveStream = {
terminalId: string;
streamId: number;
decoder: TextDecoder;
nextExpectedOffset: number | null;
catchUpEndOffset: number | null;
unsubscribe: () => void;
};
const DEFAULT_ATTACH_MAX_ATTEMPTS = 4;
const DEFAULT_ATTACH_TIMEOUT_MS = 12_000;
const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…";
export class TerminalStreamController {
private readonly exitedStreamsWhileAttaching = new Set<number>();
private readonly internalResumeOffsetByTerminalId = new Map<string, number>();
private readonly unsubscribeStreamEvents: () => void;
private readonly decoder = new TextDecoder();
private selectedTerminalId: string | null = null;
private activeStream: TerminalStreamControllerActiveStream | null = null;
private attachGeneration = 0;
private isDisposed = false;
private status: TerminalStreamControllerStatus = {
terminalId: null,
streamId: null,
isAttaching: false,
error: null,
};
constructor(private readonly options: TerminalStreamControllerOptions) {}
constructor(private readonly options: TerminalStreamControllerOptions) {
this.unsubscribeStreamEvents = this.options.client.onTerminalStreamEvent((event) => {
if (this.isDisposed || event.terminalId !== this.selectedTerminalId) {
return;
}
if (event.type === "snapshot") {
this.decoder.decode();
this.options.onSnapshot({
terminalId: event.terminalId,
state: event.state,
});
return;
}
getActiveStreamId(): number | null {
return this.activeStream?.streamId ?? null;
const text = this.decoder.decode(event.data, { stream: true });
if (text.length === 0) {
return;
}
this.options.onOutput({
terminalId: event.terminalId,
text,
});
});
}
setTerminal(input: { terminalId: string | null }): void {
@@ -115,26 +98,23 @@ export class TerminalStreamController {
}
const nextTerminalId = input.terminalId;
const previousTerminalId = this.selectedTerminalId;
const isSameTerminal = previousTerminalId === nextTerminalId;
const hasActiveStreamForSelection =
isSameTerminal &&
this.activeStream?.terminalId === nextTerminalId &&
typeof this.activeStream.streamId === "number";
if (hasActiveStreamForSelection) {
if (this.selectedTerminalId === nextTerminalId) {
return;
}
const previousTerminalId = this.selectedTerminalId;
this.selectedTerminalId = nextTerminalId;
this.attachGeneration += 1;
const generation = this.attachGeneration;
void this.detachActiveStream({ shouldDetach: true });
this.decoder.decode();
if (previousTerminalId) {
this.options.client.unsubscribeTerminal(previousTerminalId);
}
if (!nextTerminalId) {
this.updateStatus({
terminalId: null,
streamId: null,
isAttaching: false,
error: null,
});
@@ -143,7 +123,6 @@ export class TerminalStreamController {
this.updateStatus({
terminalId: nextTerminalId,
streamId: null,
isAttaching: true,
error: null,
});
@@ -153,53 +132,23 @@ export class TerminalStreamController {
});
}
handleStreamExit(input: { terminalId: string; streamId: number }): void {
if (this.isDisposed) {
handleStreamExit(input: { terminalId: string }): void {
if (this.isDisposed || this.selectedTerminalId !== input.terminalId) {
return;
}
if (this.selectedTerminalId !== input.terminalId) {
return;
}
const activeStream = this.activeStream;
const activeMatches =
Boolean(activeStream) &&
activeStream!.terminalId === input.terminalId &&
activeStream!.streamId === input.streamId;
if (activeMatches) {
this.attachGeneration += 1;
const generation = this.attachGeneration;
void this.detachActiveStream({ shouldDetach: false });
this.updateStatus({
terminalId: input.terminalId,
streamId: null,
isAttaching: true,
error: this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE,
});
void this.attachTerminal({
terminalId: input.terminalId,
generation,
});
return;
}
if (this.status.isAttaching && this.status.terminalId === input.terminalId) {
this.exitedStreamsWhileAttaching.add(input.streamId);
}
}
pruneResumeOffsets(input: { terminalIds: string[] }): void {
if (this.options.resumeOffsets) {
this.options.resumeOffsets.prune(input);
return;
}
const terminalIdSet = new Set(input.terminalIds);
for (const terminalId of Array.from(this.internalResumeOffsetByTerminalId.keys())) {
if (!terminalIdSet.has(terminalId)) {
this.internalResumeOffsetByTerminalId.delete(terminalId);
}
}
this.attachGeneration += 1;
const generation = this.attachGeneration;
this.decoder.decode();
this.updateStatus({
terminalId: input.terminalId,
isAttaching: true,
error: this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE,
});
void this.attachTerminal({
terminalId: input.terminalId,
generation,
});
}
dispose(): void {
@@ -208,12 +157,15 @@ export class TerminalStreamController {
}
this.isDisposed = true;
this.attachGeneration += 1;
this.decoder.decode();
const selectedTerminalId = this.selectedTerminalId;
this.selectedTerminalId = null;
void this.detachActiveStream({ shouldDetach: true });
this.internalResumeOffsetByTerminalId.clear();
if (selectedTerminalId) {
this.options.client.unsubscribeTerminal(selectedTerminalId);
}
this.unsubscribeStreamEvents();
this.updateStatus({
terminalId: null,
streamId: null,
isAttaching: false,
error: null,
});
@@ -229,53 +181,27 @@ export class TerminalStreamController {
getRetryDelayMs = getTerminalAttachRetryDelayMs,
} = this.options;
let lastErrorMessage = "Unable to attach terminal stream";
let lastErrorMessage = "Unable to subscribe to terminal";
for (let attempt = 0; attempt < maxAttachAttempts; attempt += 1) {
if (
!this.isAttachGenerationCurrent({
generation: input.generation,
terminalId: input.terminalId,
})
) {
if (!this.isAttachGenerationCurrent(input)) {
return;
}
try {
const preferredSize = this.options.getPreferredSize();
const resumeOffset = getTerminalResumeOffset({
terminalId: input.terminalId,
resumeOffsetStore: this.getResumeOffsetStore(),
});
const attachPayload = await withTimeout({
promise: this.options.client.attachTerminalStream(input.terminalId, {
...(resumeOffset !== undefined ? { resumeOffset } : {}),
...(preferredSize ? { rows: preferredSize.rows, cols: preferredSize.cols } : {}),
}),
const payload = await withTimeout({
promise: this.options.client.subscribeTerminal(input.terminalId),
timeoutMs: attachTimeoutMs,
timeoutMessage: "Timed out attaching terminal stream",
timeoutMessage: "Timed out subscribing to terminal",
});
updateTerminalResumeOffset({
terminalId: input.terminalId,
offset: attachPayload.currentOffset,
resumeOffsetStore: this.getResumeOffsetStore(),
});
if (
!this.isAttachGenerationCurrent({
generation: input.generation,
terminalId: input.terminalId,
})
) {
if (typeof attachPayload.streamId === "number") {
void this.options.client.detachTerminalStream(attachPayload.streamId).catch(() => {});
}
if (!this.isAttachGenerationCurrent(input)) {
this.options.client.unsubscribeTerminal(input.terminalId);
return;
}
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream";
if (payload.error) {
lastErrorMessage = payload.error;
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
@@ -284,82 +210,30 @@ export class TerminalStreamController {
this.updateStatus({
terminalId: input.terminalId,
streamId: null,
isAttaching: false,
error: lastErrorMessage,
});
return;
}
if (this.exitedStreamsWhileAttaching.has(attachPayload.streamId)) {
this.exitedStreamsWhileAttaching.delete(attachPayload.streamId);
void this.options.client.detachTerminalStream(attachPayload.streamId).catch(() => {});
this.attachGeneration += 1;
void this.attachTerminal({
terminalId: input.terminalId,
generation: this.attachGeneration,
const preferredSize = this.options.getPreferredSize();
if (preferredSize) {
this.options.client.sendTerminalInput(input.terminalId, {
type: "resize",
rows: preferredSize.rows,
cols: preferredSize.cols,
});
return;
}
if (attachPayload.reset) {
this.options.resumeOffsets?.clear({ terminalId: input.terminalId });
this.options.onReset?.({ terminalId: input.terminalId });
}
const decoder = new TextDecoder();
const streamId = attachPayload.streamId;
const replayedFromOffset =
typeof attachPayload.replayedFrom === "number"
? Math.max(0, Math.floor(attachPayload.replayedFrom))
: null;
const currentOffset = Math.max(0, Math.floor(attachPayload.currentOffset));
const shouldResetForReplayBootstrap =
typeof resumeOffset !== "number" &&
typeof replayedFromOffset === "number" &&
replayedFromOffset < currentOffset;
if (shouldResetForReplayBootstrap) {
this.options.onReset?.({ terminalId: input.terminalId });
}
const startExpectedOffset =
replayedFromOffset ??
(typeof resumeOffset === "number"
? Math.max(0, Math.floor(resumeOffset))
: currentOffset);
const catchUpEndOffset = currentOffset;
const activeStream: TerminalStreamControllerActiveStream = {
terminalId: input.terminalId,
streamId,
decoder,
nextExpectedOffset: startExpectedOffset,
catchUpEndOffset,
unsubscribe: () => {},
};
this.activeStream = activeStream;
const unsubscribe = this.options.client.onTerminalStreamData(streamId, (chunk) => {
this.handleChunk({
terminalId: input.terminalId,
streamId,
chunk,
decoder,
});
});
if (this.activeStream === activeStream) {
activeStream.unsubscribe = unsubscribe;
} else {
unsubscribe();
}
this.updateStatus({
terminalId: input.terminalId,
streamId,
isAttaching: false,
error: null,
});
return;
} catch (error) {
lastErrorMessage =
error instanceof Error ? error.message : "Unable to attach terminal stream";
error instanceof Error ? error.message : "Unable to subscribe to terminal";
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
@@ -368,7 +242,6 @@ export class TerminalStreamController {
this.updateStatus({
terminalId: input.terminalId,
streamId: null,
isAttaching: false,
error: lastErrorMessage,
});
@@ -378,187 +251,19 @@ export class TerminalStreamController {
this.updateStatus({
terminalId: input.terminalId,
streamId: null,
isAttaching: false,
error: lastErrorMessage,
});
}
private handleChunk(input: {
terminalId: string;
streamId: number;
chunk: TerminalStreamControllerChunk;
decoder: TextDecoder;
}): void {
const activeStream = this.activeStream;
if (!activeStream) {
return;
}
if (activeStream.streamId !== input.streamId || activeStream.terminalId !== input.terminalId) {
return;
}
const chunkOffset = Number.isFinite(input.chunk.offset)
? Math.max(0, Math.floor(input.chunk.offset))
: 0;
const chunkEndOffset = Number.isFinite(input.chunk.endOffset)
? Math.max(0, Math.floor(input.chunk.endOffset))
: chunkOffset;
if (chunkEndOffset < chunkOffset) {
return;
}
const expectedOffset = activeStream.nextExpectedOffset;
if (typeof expectedOffset === "number") {
if (chunkEndOffset <= expectedOffset) {
return;
}
if (chunkOffset !== expectedOffset) {
const catchUpEndOffset = activeStream.catchUpEndOffset;
const canSkipReplayGap =
chunkOffset > expectedOffset &&
typeof catchUpEndOffset === "number" &&
expectedOffset < catchUpEndOffset &&
chunkOffset <= catchUpEndOffset;
if (canSkipReplayGap) {
activeStream.nextExpectedOffset = chunkOffset;
} else {
this.recoverFromStreamGap({
terminalId: input.terminalId,
streamId: input.streamId,
expectedOffset,
observedOffset: chunkOffset,
});
return;
}
}
}
if (
typeof activeStream.catchUpEndOffset === "number" &&
chunkEndOffset >= activeStream.catchUpEndOffset
) {
activeStream.catchUpEndOffset = null;
}
activeStream.nextExpectedOffset = chunkEndOffset;
updateTerminalResumeOffset({
terminalId: input.terminalId,
offset: chunkEndOffset,
resumeOffsetStore: this.getResumeOffsetStore(),
});
const text = input.decoder.decode(input.chunk.data, { stream: true });
if (text.length === 0) {
return;
}
this.options.onChunk({
terminalId: input.terminalId,
text,
replay: Boolean(input.chunk.replay),
});
}
private recoverFromStreamGap(input: {
terminalId: string;
streamId: number;
expectedOffset: number;
observedOffset: number;
}): void {
const activeStream = this.activeStream;
if (!activeStream) {
return;
}
if (activeStream.streamId !== input.streamId || activeStream.terminalId !== input.terminalId) {
return;
}
if (this.selectedTerminalId !== input.terminalId) {
return;
}
updateTerminalResumeOffset({
terminalId: input.terminalId,
offset: input.expectedOffset,
resumeOffsetStore: this.getResumeOffsetStore(),
});
this.attachGeneration += 1;
const generation = this.attachGeneration;
void this.detachActiveStream({ shouldDetach: true });
this.updateStatus({
terminalId: input.terminalId,
streamId: null,
isAttaching: true,
error: this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE,
});
void this.attachTerminal({
terminalId: input.terminalId,
generation,
});
}
private async detachActiveStream(input: { shouldDetach: boolean }): Promise<void> {
const activeStream = this.activeStream;
if (!activeStream) {
return;
}
this.activeStream = null;
try {
const tail = activeStream.decoder.decode();
if (tail.length > 0) {
this.options.onChunk({
terminalId: activeStream.terminalId,
text: tail,
replay: false,
});
}
} catch {
// no-op
}
try {
activeStream.unsubscribe();
} catch {
// no-op
}
if (!input.shouldDetach) {
return;
}
try {
await this.options.client.detachTerminalStream(activeStream.streamId);
} catch {
// no-op
}
}
private isAttachGenerationCurrent(input: { generation: number; terminalId: string }): boolean {
private isAttachGenerationCurrent(input: { terminalId: string; generation: number }): boolean {
if (this.isDisposed) {
return false;
}
return (
this.attachGeneration === input.generation && this.selectedTerminalId === input.terminalId
);
return this.attachGeneration === input.generation && this.selectedTerminalId === input.terminalId;
}
private updateStatus(status: TerminalStreamControllerStatus): void {
this.status = status;
this.options.onStatusChange?.(status);
}
private getResumeOffsetStore(): TerminalResumeOffsetStore {
if (this.options.resumeOffsets) {
return this.options.resumeOffsets;
}
return {
get: ({ terminalId }) => this.internalResumeOffsetByTerminalId.get(terminalId),
set: ({ terminalId, offset }) => {
this.internalResumeOffsetByTerminalId.set(terminalId, offset);
},
};
}
}
@@ -10,43 +10,56 @@ describe("workspace-terminal-session", () => {
it("returns the same workspace session instance for the same scope", () => {
const first = getWorkspaceTerminalSession({
scopeKey: "workspace-a",
maxOutputChars: 1_000,
});
const second = getWorkspaceTerminalSession({
scopeKey: "workspace-a",
maxOutputChars: 50,
});
expect(second).toBe(first);
});
it("preserves resume offsets across repeated lookups", () => {
it("preserves snapshots across repeated lookups", () => {
const first = getWorkspaceTerminalSession({
scopeKey: "workspace-resume",
maxOutputChars: 1_000,
scopeKey: "workspace-snapshots",
});
first.resumeOffsets.set({
first.snapshots.set({
terminalId: "term-1",
offset: 42,
state: {
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
},
});
const second = getWorkspaceTerminalSession({
scopeKey: "workspace-resume",
maxOutputChars: 1_000,
scopeKey: "workspace-snapshots",
});
expect(second.resumeOffsets.get({ terminalId: "term-1" })).toBe(42);
expect(second.snapshots.get({ terminalId: "term-1" })).toEqual({
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
});
});
it("evicts workspace terminal session state when the workspace retain count returns to zero", () => {
it("evicts workspace terminal session state when the retain count returns to zero", () => {
const scopeKey = "workspace-release";
const first = getWorkspaceTerminalSession({
scopeKey,
maxOutputChars: 1_000,
});
first.resumeOffsets.set({
first.snapshots.set({
terminalId: "term-1",
offset: 128,
state: {
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
},
});
retainWorkspaceTerminalSession({ scopeKey });
@@ -54,10 +67,9 @@ describe("workspace-terminal-session", () => {
const second = getWorkspaceTerminalSession({
scopeKey,
maxOutputChars: 1_000,
});
expect(second).not.toBe(first);
expect(second.resumeOffsets.get({ terminalId: "term-1" })).toBeUndefined();
expect(second.snapshots.get({ terminalId: "term-1" })).toBeNull();
});
});
@@ -1,85 +1,63 @@
import {
getTerminalOutputSession,
releaseTerminalOutputSession,
retainTerminalOutputSession,
type TerminalOutputSession,
} from "./terminal-output-session";
import type { TerminalState } from "@server/shared/messages";
export type WorkspaceTerminalResumeOffsets = {
get: (input: { terminalId: string }) => number | undefined;
set: (input: { terminalId: string; offset: number }) => void;
export type WorkspaceTerminalSnapshots = {
get: (input: { terminalId: string }) => TerminalState | null;
set: (input: { terminalId: string; state: TerminalState }) => void;
clear: (input: { terminalId: string }) => void;
prune: (input: { terminalIds: string[] }) => void;
};
export type WorkspaceTerminalSession = {
scopeKey: string;
outputSession: TerminalOutputSession;
resumeOffsets: WorkspaceTerminalResumeOffsets;
snapshots: WorkspaceTerminalSnapshots;
};
type WorkspaceTerminalSessionRecord = {
resumeOffsetByTerminalId: Map<string, number>;
snapshotByTerminalId: Map<string, TerminalState>;
session: WorkspaceTerminalSession;
};
const sessionsByScopeKey = new Map<string, WorkspaceTerminalSessionRecord>();
const refCountByScopeKey = new Map<string, number>();
function createResumeOffsets(input: {
resumeOffsetByTerminalId: Map<string, number>;
}): WorkspaceTerminalResumeOffsets {
function createSnapshots(input: {
snapshotByTerminalId: Map<string, TerminalState>;
}): WorkspaceTerminalSnapshots {
return {
get: ({ terminalId }) => {
const offset = input.resumeOffsetByTerminalId.get(terminalId);
if (typeof offset !== "number" || !Number.isFinite(offset)) {
return undefined;
}
return Math.max(0, Math.floor(offset));
},
set: ({ terminalId, offset }) => {
if (!Number.isFinite(offset)) {
return;
}
input.resumeOffsetByTerminalId.set(terminalId, Math.max(0, Math.floor(offset)));
get: ({ terminalId }) => input.snapshotByTerminalId.get(terminalId) ?? null,
set: ({ terminalId, state }) => {
input.snapshotByTerminalId.set(terminalId, state);
},
clear: ({ terminalId }) => {
input.resumeOffsetByTerminalId.delete(terminalId);
input.snapshotByTerminalId.delete(terminalId);
},
prune: ({ terminalIds }) => {
const terminalIdSet = new Set(terminalIds);
for (const terminalId of Array.from(input.resumeOffsetByTerminalId.keys())) {
for (const terminalId of Array.from(input.snapshotByTerminalId.keys())) {
if (!terminalIdSet.has(terminalId)) {
input.resumeOffsetByTerminalId.delete(terminalId);
input.snapshotByTerminalId.delete(terminalId);
}
}
},
};
}
export function getWorkspaceTerminalSession(input: {
scopeKey: string;
maxOutputChars: number;
}): WorkspaceTerminalSession {
export function getWorkspaceTerminalSession(input: { scopeKey: string }): WorkspaceTerminalSession {
const existing = sessionsByScopeKey.get(input.scopeKey);
if (existing) {
return existing.session;
}
const resumeOffsetByTerminalId = new Map<string, number>();
const snapshotByTerminalId = new Map<string, TerminalState>();
const session: WorkspaceTerminalSession = {
scopeKey: input.scopeKey,
outputSession: getTerminalOutputSession({
scopeKey: input.scopeKey,
maxOutputChars: input.maxOutputChars,
}),
resumeOffsets: createResumeOffsets({
resumeOffsetByTerminalId,
snapshots: createSnapshots({
snapshotByTerminalId,
}),
};
sessionsByScopeKey.set(input.scopeKey, {
resumeOffsetByTerminalId,
snapshotByTerminalId,
session,
});
return session;
@@ -88,11 +66,9 @@ export function getWorkspaceTerminalSession(input: {
export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void {
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
refCountByScopeKey.set(input.scopeKey, current + 1);
retainTerminalOutputSession(input);
}
export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void {
releaseTerminalOutputSession(input);
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
if (current > 1) {
refCountByScopeKey.set(input.scopeKey, current - 1);
+3 -46
View File
@@ -1,10 +1,8 @@
import { describe, expect, it } from "vitest";
import {
getTerminalResumeOffset,
getTerminalAttachRetryDelayMs,
isTerminalAttachRetryableError,
updateTerminalResumeOffset,
withPromiseTimeout,
} from "./terminal-attach";
@@ -24,53 +22,12 @@ describe("terminal-attach", () => {
expect(isTerminalAttachRetryableError({ message: "Network disconnected during attach" })).toBe(
true,
);
expect(isTerminalAttachRetryableError({ message: "stream ended before ack" })).toBe(true);
expect(isTerminalAttachRetryableError({ message: "stream ended before snapshot" })).toBe(
true,
);
expect(isTerminalAttachRetryableError({ message: "permission denied" })).toBe(false);
});
it("reads and updates resume offsets monotonically", () => {
const offsets = new Map<string, number>();
const terminalId = "term-1";
const resumeOffsetStore = {
get: ({ terminalId }: { terminalId: string }) => offsets.get(terminalId),
set: ({ terminalId, offset }: { terminalId: string; offset: number }) => {
offsets.set(terminalId, offset);
},
};
expect(
getTerminalResumeOffset({
terminalId,
resumeOffsetStore,
}),
).toBeUndefined();
updateTerminalResumeOffset({
terminalId,
offset: 8,
resumeOffsetStore,
});
expect(
getTerminalResumeOffset({
terminalId,
resumeOffsetStore,
}),
).toBe(8);
// Stale offsets must not move resume backwards.
updateTerminalResumeOffset({
terminalId,
offset: 3,
resumeOffsetStore,
});
expect(
getTerminalResumeOffset({
terminalId,
resumeOffsetStore,
}),
).toBe(8);
});
it("resolves before timeout when promise completes", async () => {
await expect(
withPromiseTimeout({
-43
View File
@@ -8,49 +8,6 @@ const TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS = [
"stream ended",
] as const;
export type TerminalResumeOffsetStore = {
get: (input: { terminalId: string }) => number | undefined;
set: (input: { terminalId: string; offset: number }) => void;
};
export function getTerminalResumeOffset(input: {
terminalId: string;
resumeOffsetStore: TerminalResumeOffsetStore;
}): number | undefined {
const offset = input.resumeOffsetStore.get({ terminalId: input.terminalId });
if (typeof offset !== "number" || !Number.isFinite(offset)) {
return undefined;
}
const normalizedOffset = Math.max(0, Math.floor(offset));
return normalizedOffset;
}
export function updateTerminalResumeOffset(input: {
terminalId: string;
offset: number;
resumeOffsetStore: TerminalResumeOffsetStore;
}): void {
if (!Number.isFinite(input.offset)) {
return;
}
const normalizedOffset = Math.max(0, Math.floor(input.offset));
const previousOffset =
getTerminalResumeOffset({
terminalId: input.terminalId,
resumeOffsetStore: input.resumeOffsetStore,
}) ?? -1;
if (normalizedOffset <= previousOffset) {
return;
}
input.resumeOffsetStore.set({
terminalId: input.terminalId,
offset: normalizedOffset,
});
}
export function getTerminalAttachRetryDelayMs(input: { attempt: number }): number {
const clampedAttempt = Math.max(0, input.attempt);
const exponentialDelay = 250 * 2 ** clampedAttempt;