Simplify terminal streaming protocol
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
"use dom";
|
"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 type { DOMProps } from "expo/dom";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import type { ITheme } from "@xterm/xterm";
|
import type { ITheme } from "@xterm/xterm";
|
||||||
|
import type { TerminalState } from "@server/shared/messages";
|
||||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||||
import { focusWithRetries } from "../utils/web-focus";
|
import { focusWithRetries } from "../utils/web-focus";
|
||||||
@@ -64,11 +65,7 @@ function buildXtermThemeKey(theme: ITheme): string {
|
|||||||
interface TerminalEmulatorProps {
|
interface TerminalEmulatorProps {
|
||||||
dom?: DOMProps;
|
dom?: DOMProps;
|
||||||
streamKey: string;
|
streamKey: string;
|
||||||
initialOutputText: string;
|
initialSnapshot: TerminalState | null;
|
||||||
initialOutputChunkSequence?: number;
|
|
||||||
outputChunkText: string;
|
|
||||||
outputChunkSequence: number;
|
|
||||||
outputChunkReplay?: boolean;
|
|
||||||
testId?: string;
|
testId?: string;
|
||||||
xtermTheme?: ITheme;
|
xtermTheme?: ITheme;
|
||||||
swipeGesturesEnabled?: boolean;
|
swipeGesturesEnabled?: boolean;
|
||||||
@@ -84,12 +81,17 @@ interface TerminalEmulatorProps {
|
|||||||
meta: boolean;
|
meta: boolean;
|
||||||
}) => Promise<void> | void;
|
}) => Promise<void> | void;
|
||||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||||
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
|
|
||||||
pendingModifiers?: PendingTerminalModifiers;
|
pendingModifiers?: PendingTerminalModifiers;
|
||||||
focusRequestToken?: number;
|
focusRequestToken?: number;
|
||||||
resizeRequestToken?: number;
|
resizeRequestToken?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TerminalEmulatorHandle = {
|
||||||
|
writeOutput: (text: string) => void;
|
||||||
|
renderSnapshot: (state: TerminalState | null) => void;
|
||||||
|
clear: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {}
|
interface Window {}
|
||||||
}
|
}
|
||||||
@@ -122,13 +124,9 @@ function ensureTerminalScrollbarStyle(): void {
|
|||||||
document.head.appendChild(styleElement);
|
document.head.appendChild(styleElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TerminalEmulator({
|
const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProps>(function TerminalEmulator({
|
||||||
streamKey,
|
streamKey,
|
||||||
initialOutputText,
|
initialSnapshot,
|
||||||
initialOutputChunkSequence = 0,
|
|
||||||
outputChunkText,
|
|
||||||
outputChunkSequence,
|
|
||||||
outputChunkReplay = false,
|
|
||||||
testId = "terminal-surface",
|
testId = "terminal-surface",
|
||||||
xtermTheme = {
|
xtermTheme = {
|
||||||
background: "#0b0b0b",
|
background: "#0b0b0b",
|
||||||
@@ -142,15 +140,13 @@ export default function TerminalEmulator({
|
|||||||
onResize,
|
onResize,
|
||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
onOutputChunkConsumed,
|
|
||||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||||
focusRequestToken = 0,
|
focusRequestToken = 0,
|
||||||
resizeRequestToken = 0,
|
resizeRequestToken = 0,
|
||||||
}: TerminalEmulatorProps) {
|
}, ref) {
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||||
const appliedChunkSequenceRef = useRef(0);
|
|
||||||
const mountedThemeRef = useRef<ITheme>(xtermTheme);
|
const mountedThemeRef = useRef<ITheme>(xtermTheme);
|
||||||
const viewportRef = useRef<HTMLElement | null>(null);
|
const viewportRef = useRef<HTMLElement | null>(null);
|
||||||
const dragStartOffsetRef = useRef(0);
|
const dragStartOffsetRef = useRef(0);
|
||||||
@@ -315,17 +311,15 @@ export default function TerminalEmulator({
|
|||||||
runtime.mount({
|
runtime.mount({
|
||||||
root,
|
root,
|
||||||
host,
|
host,
|
||||||
initialOutputText,
|
initialSnapshot,
|
||||||
theme: mountedThemeRef.current,
|
theme: mountedThemeRef.current,
|
||||||
});
|
});
|
||||||
appliedChunkSequenceRef.current = Math.max(0, Math.floor(initialOutputChunkSequence));
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
runtime.unmount();
|
runtime.unmount();
|
||||||
if (runtimeRef.current === runtime) {
|
if (runtimeRef.current === runtime) {
|
||||||
runtimeRef.current = null;
|
runtimeRef.current = null;
|
||||||
}
|
}
|
||||||
appliedChunkSequenceRef.current = 0;
|
|
||||||
};
|
};
|
||||||
}, [streamKey]);
|
}, [streamKey]);
|
||||||
|
|
||||||
@@ -344,40 +338,21 @@ export default function TerminalEmulator({
|
|||||||
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
||||||
}, [pendingModifiers]);
|
}, [pendingModifiers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useImperativeHandle(
|
||||||
const runtime = runtimeRef.current;
|
ref,
|
||||||
if (outputChunkSequence <= 0) {
|
() => ({
|
||||||
return;
|
writeOutput: (text: string) => {
|
||||||
}
|
runtimeRef.current?.write({ text });
|
||||||
|
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
});
|
renderSnapshot: (state: TerminalState | null) => {
|
||||||
}, [onOutputChunkConsumed, outputChunkReplay, outputChunkSequence, outputChunkText]);
|
runtimeRef.current?.renderSnapshot({ state });
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
runtimeRef.current?.clear();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusRequestToken <= 0) {
|
if (focusRequestToken <= 0) {
|
||||||
@@ -694,4 +669,6 @@ export default function TerminalEmulator({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default TerminalEmulator;
|
||||||
|
|||||||
@@ -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 { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
||||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||||
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "@/terminal/runtime/terminal-stream-controller";
|
} from "@/terminal/runtime/terminal-stream-controller";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||||
import TerminalEmulator from "./terminal-emulator";
|
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||||
|
|
||||||
interface TerminalPaneProps {
|
interface TerminalPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -26,7 +26,6 @@ interface TerminalPaneProps {
|
|||||||
terminalId: string;
|
terminalId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_OUTPUT_CHARS = 200_000;
|
|
||||||
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
||||||
|
|
||||||
const MODIFIER_LABELS = {
|
const MODIFIER_LABELS = {
|
||||||
@@ -103,44 +102,8 @@ export function TerminalPane({
|
|||||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||||
const workspaceTerminalSession = useMemo(
|
const emulatorRef = useRef<TerminalEmulatorHandle | null>(null);
|
||||||
() =>
|
const workspaceTerminalSession = useMemo(() => getWorkspaceTerminalSession({ scopeKey }), [scopeKey]);
|
||||||
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 [isAttaching, setIsAttaching] = useState(false);
|
const [isAttaching, setIsAttaching] = useState(false);
|
||||||
const [streamError, setStreamError] = useState<string | null>(null);
|
const [streamError, setStreamError] = useState<string | null>(null);
|
||||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||||
@@ -150,6 +113,7 @@ export function TerminalPane({
|
|||||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||||
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
||||||
|
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
terminalIdRef.current = terminalId;
|
terminalIdRef.current = terminalId;
|
||||||
@@ -248,13 +212,16 @@ export function TerminalPane({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId });
|
||||||
|
if (terminalIdRef.current === exitedTerminalId) {
|
||||||
|
emulatorRef.current?.clear();
|
||||||
|
}
|
||||||
streamControllerRef.current?.handleStreamExit({
|
streamControllerRef.current?.handleStreamExit({
|
||||||
terminalId: exitedTerminalId,
|
terminalId: exitedTerminalId,
|
||||||
streamId: message.payload.streamId,
|
|
||||||
});
|
});
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
});
|
});
|
||||||
}, [client, isConnected]);
|
}, [client, isConnected, workspaceTerminalSession.snapshots]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
@@ -263,20 +230,11 @@ export function TerminalPane({
|
|||||||
const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
|
const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
|
||||||
setIsAttaching(status.isAttaching);
|
setIsAttaching(status.isAttaching);
|
||||||
setStreamError(status.error);
|
setStreamError(status.error);
|
||||||
if (status.terminalId && typeof status.streamId === "number") {
|
|
||||||
setActiveStream({
|
|
||||||
terminalId: status.terminalId,
|
|
||||||
streamId: status.streamId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActiveStream(null);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
streamControllerRef.current?.dispose();
|
streamControllerRef.current?.dispose();
|
||||||
streamControllerRef.current = null;
|
streamControllerRef.current = null;
|
||||||
setActiveStream(null);
|
|
||||||
setIsAttaching(false);
|
setIsAttaching(false);
|
||||||
setStreamError(null);
|
setStreamError(null);
|
||||||
|
|
||||||
@@ -287,12 +245,18 @@ export function TerminalPane({
|
|||||||
const controller = new TerminalStreamController({
|
const controller = new TerminalStreamController({
|
||||||
client,
|
client,
|
||||||
getPreferredSize: () => lastReportedSizeRef.current,
|
getPreferredSize: () => lastReportedSizeRef.current,
|
||||||
resumeOffsets: workspaceTerminalSession.resumeOffsets,
|
onOutput: ({ terminalId, text }) => {
|
||||||
onChunk: ({ terminalId, text, replay }) => {
|
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
|
||||||
outputSession.append({ terminalId, text, replay });
|
return;
|
||||||
|
}
|
||||||
|
emulatorRef.current?.writeOutput(text);
|
||||||
},
|
},
|
||||||
onReset: ({ terminalId }) => {
|
onSnapshot: ({ terminalId, state }) => {
|
||||||
outputSession.clearTerminal({ terminalId });
|
workspaceTerminalSession.snapshots.set({ terminalId, state });
|
||||||
|
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emulatorRef.current?.renderSnapshot(state);
|
||||||
},
|
},
|
||||||
onStatusChange: handleStreamControllerStatus,
|
onStatusChange: handleStreamControllerStatus,
|
||||||
});
|
});
|
||||||
@@ -313,26 +277,16 @@ export function TerminalPane({
|
|||||||
handleStreamControllerStatus,
|
handleStreamControllerStatus,
|
||||||
isConnected,
|
isConnected,
|
||||||
isScreenFocused,
|
isScreenFocused,
|
||||||
outputSession,
|
workspaceTerminalSession.snapshots,
|
||||||
workspaceTerminalSession.resumeOffsets,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pendingTerminalInputRef.current = [];
|
pendingTerminalInputRef.current = [];
|
||||||
const nextTerminalId = isScreenFocused ? terminalId : null;
|
const nextTerminalId = isScreenFocused ? terminalId : null;
|
||||||
outputSession.setSelectedTerminal({
|
|
||||||
terminalId: nextTerminalId,
|
|
||||||
});
|
|
||||||
streamControllerRef.current?.setTerminal({
|
streamControllerRef.current?.setTerminal({
|
||||||
terminalId: nextTerminalId,
|
terminalId: nextTerminalId,
|
||||||
});
|
});
|
||||||
}, [isScreenFocused, outputSession, terminalId]);
|
}, [isScreenFocused, terminalId]);
|
||||||
|
|
||||||
const activeStreamId =
|
|
||||||
activeStream && activeStream.terminalId === terminalId ? activeStream.streamId : null;
|
|
||||||
const getCurrentActiveStreamId = useCallback(() => {
|
|
||||||
return streamControllerRef.current?.getActiveStreamId() ?? null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
|
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
|
||||||
const queue = pendingTerminalInputRef.current;
|
const queue = pendingTerminalInputRef.current;
|
||||||
@@ -398,8 +352,10 @@ export function TerminalPane({
|
|||||||
}, [dispatchTerminalInputEntry]);
|
}, [dispatchTerminalInputEntry]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
flushPendingTerminalInput();
|
if (!isAttaching && !streamError) {
|
||||||
}, [activeStreamId, flushPendingTerminalInput]);
|
flushPendingTerminalInput();
|
||||||
|
}
|
||||||
|
}, [flushPendingTerminalInput, isAttaching, streamError]);
|
||||||
|
|
||||||
const clearPendingModifiers = useCallback(() => {
|
const clearPendingModifiers = useCallback(() => {
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
@@ -443,7 +399,7 @@ export function TerminalPane({
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, getCurrentActiveStreamId],
|
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTerminalData = useCallback(
|
const handleTerminalData = useCallback(
|
||||||
@@ -496,7 +452,6 @@ export function TerminalPane({
|
|||||||
clearPendingModifiers,
|
clearPendingModifiers,
|
||||||
client,
|
client,
|
||||||
dispatchTerminalInputEntry,
|
dispatchTerminalInputEntry,
|
||||||
getCurrentActiveStreamId,
|
|
||||||
modifiers.alt,
|
modifiers.alt,
|
||||||
modifiers.ctrl,
|
modifiers.ctrl,
|
||||||
modifiers.shift,
|
modifiers.shift,
|
||||||
@@ -538,13 +493,6 @@ export function TerminalPane({
|
|||||||
clearPendingModifiers();
|
clearPendingModifiers();
|
||||||
}, [clearPendingModifiers]);
|
}, [clearPendingModifiers]);
|
||||||
|
|
||||||
const handleOutputChunkConsumed = useCallback(
|
|
||||||
(sequence: number) => {
|
|
||||||
outputSession.consume({ sequence });
|
|
||||||
},
|
|
||||||
[outputSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleModifier = useCallback(
|
const toggleModifier = useCallback(
|
||||||
(modifier: keyof ModifierState) => {
|
(modifier: keyof ModifierState) => {
|
||||||
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
|
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
|
||||||
@@ -589,6 +537,7 @@ export function TerminalPane({
|
|||||||
{isScreenFocused ? (
|
{isScreenFocused ? (
|
||||||
<View style={styles.terminalGestureContainer}>
|
<View style={styles.terminalGestureContainer}>
|
||||||
<TerminalEmulator
|
<TerminalEmulator
|
||||||
|
ref={emulatorRef}
|
||||||
dom={{
|
dom={{
|
||||||
style: { flex: 1 },
|
style: { flex: 1 },
|
||||||
matchContents: false,
|
matchContents: false,
|
||||||
@@ -600,11 +549,7 @@ export function TerminalPane({
|
|||||||
contentInsetAdjustmentBehavior: "never",
|
contentInsetAdjustmentBehavior: "never",
|
||||||
}}
|
}}
|
||||||
streamKey={`${scopeKey}:${terminalId}`}
|
streamKey={`${scopeKey}:${terminalId}`}
|
||||||
initialOutputText={selectedOutputState.snapshotText}
|
initialSnapshot={initialSnapshot}
|
||||||
initialOutputChunkSequence={selectedOutputState.snapshotSequence}
|
|
||||||
outputChunkText={selectedOutputState.chunkText}
|
|
||||||
outputChunkSequence={selectedOutputState.chunkSequence}
|
|
||||||
outputChunkReplay={selectedOutputState.chunkReplay}
|
|
||||||
testId="terminal-surface"
|
testId="terminal-surface"
|
||||||
xtermTheme={xtermTheme}
|
xtermTheme={xtermTheme}
|
||||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||||
@@ -624,7 +569,6 @@ export function TerminalPane({
|
|||||||
onResize={handleTerminalResize}
|
onResize={handleTerminalResize}
|
||||||
onTerminalKey={handleTerminalKey}
|
onTerminalKey={handleTerminalKey}
|
||||||
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
||||||
onOutputChunkConsumed={handleOutputChunkConsumed}
|
|
||||||
pendingModifiers={modifiers}
|
pendingModifiers={modifiers}
|
||||||
focusRequestToken={focusRequestToken}
|
focusRequestToken={focusRequestToken}
|
||||||
resizeRequestToken={resizeRequestToken}
|
resizeRequestToken={resizeRequestToken}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
|||||||
type StubTerminal = {
|
type StubTerminal = {
|
||||||
write: (text: string, callback?: () => void) => void;
|
write: (text: string, callback?: () => void) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
|
resize?: (cols: number, rows: number) => void;
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
refresh?: (start: number, end: number) => void;
|
refresh?: (start: number, end: number) => void;
|
||||||
options?: { theme?: unknown };
|
options?: { theme?: unknown };
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
cols?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createRuntimeWithTerminal(): {
|
function createRuntimeWithTerminal(): {
|
||||||
@@ -35,10 +37,12 @@ function createRuntimeWithTerminal(): {
|
|||||||
resetCalls += 1;
|
resetCalls += 1;
|
||||||
terminal.resetCalls = resetCalls;
|
terminal.resetCalls = resetCalls;
|
||||||
},
|
},
|
||||||
|
resize: () => {},
|
||||||
focus: () => {},
|
focus: () => {},
|
||||||
refresh: () => {},
|
refresh: () => {},
|
||||||
options: { theme: undefined },
|
options: { theme: undefined },
|
||||||
rows: 0,
|
rows: 0,
|
||||||
|
cols: 0,
|
||||||
resetCalls,
|
resetCalls,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,12 +192,16 @@ describe("terminal-emulator-runtime", () => {
|
|||||||
refresh,
|
refresh,
|
||||||
options: { theme: { background: "before" } },
|
options: { theme: { background: "before" } },
|
||||||
rows: 12,
|
rows: 12,
|
||||||
|
cols: 40,
|
||||||
};
|
};
|
||||||
(runtime as unknown as { terminal: StubTerminal }).terminal = terminal;
|
(runtime as unknown as { terminal: StubTerminal }).terminal = terminal;
|
||||||
|
|
||||||
runtime.setTheme({ theme: { background: "after" } as never });
|
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);
|
expect(refresh).toHaveBeenCalledWith(0, 11);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
|||||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import { Terminal, type ITheme } from "@xterm/xterm";
|
import { Terminal, type ITheme } from "@xterm/xterm";
|
||||||
|
import type { TerminalState } from "@server/shared/messages";
|
||||||
import {
|
import {
|
||||||
type PendingTerminalModifiers,
|
type PendingTerminalModifiers,
|
||||||
isTerminalModifierDomKey,
|
isTerminalModifierDomKey,
|
||||||
@@ -10,11 +11,12 @@ import {
|
|||||||
normalizeTerminalTransportKey,
|
normalizeTerminalTransportKey,
|
||||||
shouldInterceptDomTerminalKey,
|
shouldInterceptDomTerminalKey,
|
||||||
} from "@/utils/terminal-keys";
|
} from "@/utils/terminal-keys";
|
||||||
|
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
|
||||||
|
|
||||||
export type TerminalEmulatorRuntimeMountInput = {
|
export type TerminalEmulatorRuntimeMountInput = {
|
||||||
root: HTMLDivElement;
|
root: HTMLDivElement;
|
||||||
host: HTMLDivElement;
|
host: HTMLDivElement;
|
||||||
initialOutputText: string;
|
initialSnapshot: TerminalState | null;
|
||||||
theme: ITheme;
|
theme: ITheme;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,8 +52,10 @@ type TerminalEmulatorRuntimeDisposables = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type TerminalOutputOperation = {
|
type TerminalOutputOperation = {
|
||||||
type: "write" | "clear";
|
type: "write" | "clear" | "snapshot";
|
||||||
text: string;
|
text: string;
|
||||||
|
rows?: number;
|
||||||
|
cols?: number;
|
||||||
suppressInput?: boolean;
|
suppressInput?: boolean;
|
||||||
onCommitted?: () => void;
|
onCommitted?: () => void;
|
||||||
};
|
};
|
||||||
@@ -332,8 +336,8 @@ export class TerminalEmulatorRuntime {
|
|||||||
fitAndEmitResize(true);
|
fitAndEmitResize(true);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
if (input.initialOutputText.length > 0) {
|
if (input.initialSnapshot) {
|
||||||
this.write({ text: input.initialOutputText, suppressInput: true });
|
this.renderSnapshot({ state: input.initialSnapshot });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.processOutputQueue();
|
this.processOutputQueue();
|
||||||
@@ -434,6 +438,22 @@ export class TerminalEmulatorRuntime {
|
|||||||
this.processOutputQueue();
|
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 {
|
resize(input?: { force?: boolean }): void {
|
||||||
this.fitAndEmitResize?.(input?.force ?? false);
|
this.fitAndEmitResize?.(input?.force ?? false);
|
||||||
}
|
}
|
||||||
@@ -523,6 +543,22 @@ export class TerminalEmulatorRuntime {
|
|||||||
return;
|
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;
|
const text = operation.text;
|
||||||
this.inFlightOutputOperationTimeout = setTimeout(() => {
|
this.inFlightOutputOperationTimeout = setTimeout(() => {
|
||||||
finalizeOperation(operation);
|
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 {
|
import {
|
||||||
TerminalStreamController,
|
TerminalStreamController,
|
||||||
type TerminalStreamControllerAttachPayload,
|
|
||||||
type TerminalStreamControllerChunk,
|
|
||||||
type TerminalStreamControllerClient,
|
type TerminalStreamControllerClient,
|
||||||
type TerminalStreamControllerResumeOffsets,
|
|
||||||
type TerminalStreamControllerStatus,
|
type TerminalStreamControllerStatus,
|
||||||
} from "./terminal-stream-controller";
|
} 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 {
|
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
||||||
private readonly streamSubscribers = new Map<number, Set<FakeStreamSubscriber>>();
|
private readonly listeners = new Set<(event: TerminalStreamEvent) => void>();
|
||||||
private readonly pendingChunksByStreamId = new Map<number, TerminalStreamControllerChunk[]>();
|
public subscribeCalls: string[] = [];
|
||||||
public attachCalls: Array<{
|
public unsubscribeCalls: string[] = [];
|
||||||
|
public resizeCalls: Array<{ terminalId: string; rows: number; cols: number }> = [];
|
||||||
|
public nextSubscribeResponses: Array<{
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
options?: {
|
state: {
|
||||||
resumeOffset?: number;
|
rows: number;
|
||||||
rows?: number;
|
cols: 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(
|
async subscribeTerminal(terminalId: string) {
|
||||||
terminalId: string,
|
this.subscribeCalls.push(terminalId);
|
||||||
options?: {
|
const response = this.nextSubscribeResponses.shift();
|
||||||
resumeOffset?: number;
|
|
||||||
rows?: number;
|
|
||||||
cols?: number;
|
|
||||||
},
|
|
||||||
): Promise<TerminalStreamControllerAttachPayload> {
|
|
||||||
this.attachCalls.push({ terminalId, options });
|
|
||||||
const response = this.nextAttachResponses.shift();
|
|
||||||
if (!response) {
|
if (!response) {
|
||||||
throw new Error("Missing fake attach response");
|
throw new Error("Missing fake subscribe response");
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async detachTerminalStream(streamId: number): Promise<void> {
|
unsubscribeTerminal(terminalId: string): void {
|
||||||
this.detachCalls.push(streamId);
|
this.unsubscribeCalls.push(terminalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
onTerminalStreamData(
|
sendTerminalInput(
|
||||||
streamId: number,
|
terminalId: string,
|
||||||
handler: (chunk: TerminalStreamControllerChunk) => void,
|
message: { type: "resize"; rows: number; cols: number },
|
||||||
): () => void {
|
): void {
|
||||||
const pendingChunks = this.pendingChunksByStreamId.get(streamId);
|
this.resizeCalls.push({
|
||||||
if (pendingChunks && pendingChunks.length > 0) {
|
terminalId,
|
||||||
for (const chunk of pendingChunks) {
|
rows: message.rows,
|
||||||
handler(chunk);
|
cols: message.cols,
|
||||||
}
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
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?: {
|
function createControllerHarness(input?: {
|
||||||
client?: FakeTerminalStreamClient;
|
client?: FakeTerminalStreamClient;
|
||||||
resumeOffsets?: TerminalStreamControllerResumeOffsets;
|
|
||||||
}): {
|
}): {
|
||||||
client: FakeTerminalStreamClient;
|
client: FakeTerminalStreamClient;
|
||||||
chunks: Array<{ terminalId: string; text: string }>;
|
outputs: Array<{ terminalId: string; text: string }>;
|
||||||
|
snapshots: Array<{ terminalId: string; text: string }>;
|
||||||
statuses: TerminalStreamControllerStatus[];
|
statuses: TerminalStreamControllerStatus[];
|
||||||
resets: string[];
|
|
||||||
controller: TerminalStreamController;
|
controller: TerminalStreamController;
|
||||||
} {
|
} {
|
||||||
const client = input?.client ?? new FakeTerminalStreamClient();
|
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 statuses: TerminalStreamControllerStatus[] = [];
|
||||||
const resets: string[] = [];
|
|
||||||
|
|
||||||
const controller = new TerminalStreamController({
|
const controller = new TerminalStreamController({
|
||||||
client,
|
client,
|
||||||
getPreferredSize: () => ({ rows: 24, cols: 80 }),
|
getPreferredSize: () => ({ rows: 24, cols: 80 }),
|
||||||
resumeOffsets: input?.resumeOffsets,
|
onOutput: ({ terminalId, text }) => {
|
||||||
onChunk: (chunk) => {
|
outputs.push({ terminalId, text });
|
||||||
chunks.push({
|
},
|
||||||
terminalId: chunk.terminalId,
|
onSnapshot: ({ terminalId, state }) => {
|
||||||
text: chunk.text,
|
snapshots.push({
|
||||||
|
terminalId,
|
||||||
|
text: state.grid
|
||||||
|
.map((row) => row.map((cell) => cell.char).join(""))
|
||||||
|
.join("\n"),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onStatusChange: (status) => {
|
onStatusChange: (status) => {
|
||||||
statuses.push(status);
|
statuses.push(status);
|
||||||
},
|
},
|
||||||
onReset: ({ terminalId }) => {
|
|
||||||
resets.push(terminalId);
|
|
||||||
},
|
|
||||||
waitForDelay: async () => {},
|
waitForDelay: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
chunks,
|
outputs,
|
||||||
|
snapshots,
|
||||||
statuses,
|
statuses,
|
||||||
resets,
|
|
||||||
controller,
|
controller,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -163,425 +127,122 @@ async function flushAsyncWork(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("terminal-stream-controller", () => {
|
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();
|
const harness = createControllerHarness();
|
||||||
harness.client.nextAttachResponses.push({
|
harness.client.nextSubscribeResponses.push({
|
||||||
streamId: 7,
|
terminalId: "term-1",
|
||||||
currentOffset: 0,
|
state: null,
|
||||||
reset: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
harness.controller.setTerminal({ terminalId: "term-1" });
|
harness.controller.setTerminal({ terminalId: "term-1" });
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
|
|
||||||
harness.client.emitChunk({
|
harness.client.emit({
|
||||||
streamId: 7,
|
terminalId: "term-1",
|
||||||
endOffset: 1,
|
type: "snapshot",
|
||||||
data: "a",
|
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({
|
harness.client.emit({
|
||||||
streamId: 7,
|
terminalId: "term-1",
|
||||||
endOffset: 2,
|
type: "output",
|
||||||
data: "b",
|
data: new TextEncoder().encode(" world"),
|
||||||
});
|
|
||||||
harness.client.emitChunk({
|
|
||||||
streamId: 7,
|
|
||||||
endOffset: 3,
|
|
||||||
data: "c",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(harness.chunks).toEqual([
|
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
|
||||||
{ terminalId: "term-1", text: "a" },
|
expect(harness.client.resizeCalls).toEqual([{ terminalId: "term-1", rows: 24, cols: 80 }]);
|
||||||
{ terminalId: "term-1", text: "b" },
|
expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]);
|
||||||
{ terminalId: "term-1", text: "c" },
|
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();
|
const harness = createControllerHarness();
|
||||||
harness.client.nextAttachResponses.push({
|
harness.client.nextSubscribeResponses.push({
|
||||||
streamId: null,
|
terminalId: "term-1",
|
||||||
currentOffset: 0,
|
state: null,
|
||||||
reset: false,
|
|
||||||
error: "network disconnected",
|
error: "network disconnected",
|
||||||
});
|
});
|
||||||
harness.client.nextAttachResponses.push({
|
harness.client.nextSubscribeResponses.push({
|
||||||
streamId: 9,
|
terminalId: "term-1",
|
||||||
currentOffset: 5,
|
state: null,
|
||||||
reset: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
harness.controller.setTerminal({ terminalId: "term-1" });
|
harness.controller.setTerminal({ terminalId: "term-1" });
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(harness.client.attachCalls.length).toBe(2);
|
expect(harness.client.subscribeCalls).toEqual(["term-1", "term-1"]);
|
||||||
expect(harness.client.attachCalls[1]?.options).toEqual({
|
|
||||||
resumeOffset: 0,
|
|
||||||
rows: 24,
|
|
||||||
cols: 80,
|
|
||||||
});
|
|
||||||
expect(harness.controller.getActiveStreamId()).toBe(9);
|
|
||||||
expect(harness.statuses.at(-1)).toEqual({
|
expect(harness.statuses.at(-1)).toEqual({
|
||||||
terminalId: "term-1",
|
terminalId: "term-1",
|
||||||
streamId: 9,
|
|
||||||
isAttaching: false,
|
isAttaching: false,
|
||||||
error: null,
|
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();
|
const harness = createControllerHarness();
|
||||||
harness.client.nextAttachResponses.push({
|
harness.client.nextSubscribeResponses.push({
|
||||||
streamId: 3,
|
terminalId: "term-1",
|
||||||
currentOffset: 0,
|
state: null,
|
||||||
reset: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
harness.client.nextAttachResponses.push({
|
harness.client.nextSubscribeResponses.push({
|
||||||
streamId: 4,
|
terminalId: "term-1",
|
||||||
currentOffset: 2,
|
state: null,
|
||||||
reset: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
harness.controller.setTerminal({ terminalId: "term-1" });
|
harness.controller.setTerminal({ terminalId: "term-1" });
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
|
|
||||||
harness.client.emitChunk({
|
harness.controller.handleStreamExit({ terminalId: "term-1" });
|
||||||
streamId: 3,
|
|
||||||
endOffset: 2,
|
|
||||||
data: "hi",
|
|
||||||
});
|
|
||||||
harness.controller.handleStreamExit({
|
|
||||||
terminalId: "term-1",
|
|
||||||
streamId: 3,
|
|
||||||
});
|
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(harness.client.attachCalls.length).toBe(2);
|
expect(harness.client.subscribeCalls).toEqual(["term-1", "term-1"]);
|
||||||
expect(harness.client.attachCalls[1]?.options).toEqual({
|
|
||||||
resumeOffset: 2,
|
|
||||||
rows: 24,
|
|
||||||
cols: 80,
|
|
||||||
});
|
|
||||||
expect(harness.controller.getActiveStreamId()).toBe(4);
|
|
||||||
expect(harness.statuses.at(-1)).toEqual({
|
expect(harness.statuses.at(-1)).toEqual({
|
||||||
terminalId: "term-1",
|
terminalId: "term-1",
|
||||||
streamId: 4,
|
|
||||||
isAttaching: false,
|
isAttaching: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recovers when stream exits before initial attach completes", async () => {
|
it("unsubscribes when switching terminals and on dispose", async () => {
|
||||||
const harness = createControllerHarness();
|
const harness = createControllerHarness();
|
||||||
harness.client.nextAttachResponses.push({
|
harness.client.nextSubscribeResponses.push({
|
||||||
streamId: 1,
|
terminalId: "term-1",
|
||||||
currentOffset: 1200,
|
state: null,
|
||||||
reset: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
harness.client.nextAttachResponses.push({
|
harness.client.nextSubscribeResponses.push({
|
||||||
streamId: 2,
|
terminalId: "term-2",
|
||||||
currentOffset: 1200,
|
state: null,
|
||||||
reset: false,
|
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
harness.controller.setTerminal({ terminalId: "term-attach-exit" });
|
harness.controller.setTerminal({ terminalId: "term-1" });
|
||||||
harness.controller.handleStreamExit({
|
|
||||||
terminalId: "term-attach-exit",
|
|
||||||
streamId: 1,
|
|
||||||
});
|
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(harness.client.attachCalls).toHaveLength(2);
|
harness.controller.setTerminal({ terminalId: "term-2" });
|
||||||
expect(harness.client.attachCalls[1]?.options).toEqual({
|
await flushAsyncWork();
|
||||||
resumeOffset: 1200,
|
|
||||||
rows: 24,
|
|
||||||
cols: 80,
|
|
||||||
});
|
|
||||||
expect(harness.client.detachCalls).toContain(1);
|
|
||||||
expect(harness.controller.getActiveStreamId()).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits reset callback when attach indicates output reset", async () => {
|
harness.controller.dispose();
|
||||||
const harness = createControllerHarness();
|
|
||||||
harness.client.nextAttachResponses.push({
|
expect(harness.client.unsubscribeCalls).toEqual(["term-1", "term-2"]);
|
||||||
streamId: 12,
|
expect(harness.statuses.at(-1)).toEqual({
|
||||||
currentOffset: 0,
|
terminalId: null,
|
||||||
reset: true,
|
isAttaching: false,
|
||||||
error: null,
|
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 {
|
import {
|
||||||
getTerminalAttachRetryDelayMs,
|
getTerminalAttachRetryDelayMs,
|
||||||
getTerminalResumeOffset,
|
|
||||||
isTerminalAttachRetryableError,
|
isTerminalAttachRetryableError,
|
||||||
type TerminalResumeOffsetStore,
|
|
||||||
updateTerminalResumeOffset,
|
|
||||||
waitForDuration,
|
waitForDuration,
|
||||||
withPromiseTimeout,
|
withPromiseTimeout,
|
||||||
} from "@/utils/terminal-attach";
|
} 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 = {
|
export type TerminalStreamControllerClient = {
|
||||||
attachTerminalStream: (
|
subscribeTerminal: (terminalId: string) => Promise<{
|
||||||
|
terminalId: string;
|
||||||
|
state: TerminalState | null;
|
||||||
|
error?: string | null;
|
||||||
|
}>;
|
||||||
|
unsubscribeTerminal: (terminalId: string) => void;
|
||||||
|
sendTerminalInput: (
|
||||||
terminalId: string,
|
terminalId: string,
|
||||||
options?: {
|
message: { type: "resize"; rows: number; cols: number },
|
||||||
resumeOffset?: number;
|
) => void;
|
||||||
rows?: number;
|
onTerminalStreamEvent: (
|
||||||
cols?: number;
|
handler: (
|
||||||
},
|
event:
|
||||||
) => Promise<TerminalStreamControllerAttachPayload>;
|
| { terminalId: string; type: "output"; data: Uint8Array }
|
||||||
detachTerminalStream: (streamId: number) => Promise<unknown>;
|
| { terminalId: string; type: "snapshot"; state: TerminalState },
|
||||||
onTerminalStreamData: (
|
) => void,
|
||||||
streamId: number,
|
|
||||||
handler: (chunk: TerminalStreamControllerChunk) => void,
|
|
||||||
) => () => void;
|
) => () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,22 +33,15 @@ export type TerminalStreamControllerSize = {
|
|||||||
|
|
||||||
export type TerminalStreamControllerStatus = {
|
export type TerminalStreamControllerStatus = {
|
||||||
terminalId: string | null;
|
terminalId: string | null;
|
||||||
streamId: number | null;
|
|
||||||
isAttaching: boolean;
|
isAttaching: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TerminalStreamControllerResumeOffsets = TerminalResumeOffsetStore & {
|
|
||||||
clear: (input: { terminalId: string }) => void;
|
|
||||||
prune: (input: { terminalIds: string[] }) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TerminalStreamControllerOptions = {
|
export type TerminalStreamControllerOptions = {
|
||||||
client: TerminalStreamControllerClient;
|
client: TerminalStreamControllerClient;
|
||||||
getPreferredSize: () => TerminalStreamControllerSize | null;
|
getPreferredSize: () => TerminalStreamControllerSize | null;
|
||||||
resumeOffsets?: TerminalStreamControllerResumeOffsets;
|
onOutput: (input: { terminalId: string; text: string }) => void;
|
||||||
onChunk: (input: { terminalId: string; text: string; replay: boolean }) => void;
|
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
|
||||||
onReset?: (input: { terminalId: string }) => void;
|
|
||||||
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
|
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
|
||||||
maxAttachAttempts?: number;
|
maxAttachAttempts?: number;
|
||||||
attachTimeoutMs?: number;
|
attachTimeoutMs?: number;
|
||||||
@@ -76,37 +56,40 @@ export type TerminalStreamControllerOptions = {
|
|||||||
getRetryDelayMs?: (input: { attempt: number }) => number;
|
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_MAX_ATTEMPTS = 4;
|
||||||
const DEFAULT_ATTACH_TIMEOUT_MS = 12_000;
|
const DEFAULT_ATTACH_TIMEOUT_MS = 12_000;
|
||||||
const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…";
|
const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…";
|
||||||
|
|
||||||
export class TerminalStreamController {
|
export class TerminalStreamController {
|
||||||
private readonly exitedStreamsWhileAttaching = new Set<number>();
|
private readonly unsubscribeStreamEvents: () => void;
|
||||||
private readonly internalResumeOffsetByTerminalId = new Map<string, number>();
|
private readonly decoder = new TextDecoder();
|
||||||
private selectedTerminalId: string | null = null;
|
private selectedTerminalId: string | null = null;
|
||||||
private activeStream: TerminalStreamControllerActiveStream | null = null;
|
|
||||||
private attachGeneration = 0;
|
private attachGeneration = 0;
|
||||||
private isDisposed = false;
|
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 {
|
const text = this.decoder.decode(event.data, { stream: true });
|
||||||
return this.activeStream?.streamId ?? null;
|
if (text.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.options.onOutput({
|
||||||
|
terminalId: event.terminalId,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setTerminal(input: { terminalId: string | null }): void {
|
setTerminal(input: { terminalId: string | null }): void {
|
||||||
@@ -115,26 +98,23 @@ export class TerminalStreamController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextTerminalId = input.terminalId;
|
const nextTerminalId = input.terminalId;
|
||||||
const previousTerminalId = this.selectedTerminalId;
|
if (this.selectedTerminalId === nextTerminalId) {
|
||||||
const isSameTerminal = previousTerminalId === nextTerminalId;
|
|
||||||
const hasActiveStreamForSelection =
|
|
||||||
isSameTerminal &&
|
|
||||||
this.activeStream?.terminalId === nextTerminalId &&
|
|
||||||
typeof this.activeStream.streamId === "number";
|
|
||||||
if (hasActiveStreamForSelection) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousTerminalId = this.selectedTerminalId;
|
||||||
this.selectedTerminalId = nextTerminalId;
|
this.selectedTerminalId = nextTerminalId;
|
||||||
this.attachGeneration += 1;
|
this.attachGeneration += 1;
|
||||||
const generation = this.attachGeneration;
|
const generation = this.attachGeneration;
|
||||||
|
|
||||||
void this.detachActiveStream({ shouldDetach: true });
|
this.decoder.decode();
|
||||||
|
if (previousTerminalId) {
|
||||||
|
this.options.client.unsubscribeTerminal(previousTerminalId);
|
||||||
|
}
|
||||||
|
|
||||||
if (!nextTerminalId) {
|
if (!nextTerminalId) {
|
||||||
this.updateStatus({
|
this.updateStatus({
|
||||||
terminalId: null,
|
terminalId: null,
|
||||||
streamId: null,
|
|
||||||
isAttaching: false,
|
isAttaching: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -143,7 +123,6 @@ export class TerminalStreamController {
|
|||||||
|
|
||||||
this.updateStatus({
|
this.updateStatus({
|
||||||
terminalId: nextTerminalId,
|
terminalId: nextTerminalId,
|
||||||
streamId: null,
|
|
||||||
isAttaching: true,
|
isAttaching: true,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -153,53 +132,23 @@ export class TerminalStreamController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleStreamExit(input: { terminalId: string; streamId: number }): void {
|
handleStreamExit(input: { terminalId: string }): void {
|
||||||
if (this.isDisposed) {
|
if (this.isDisposed || this.selectedTerminalId !== input.terminalId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.selectedTerminalId !== input.terminalId) {
|
this.attachGeneration += 1;
|
||||||
return;
|
const generation = this.attachGeneration;
|
||||||
}
|
this.decoder.decode();
|
||||||
|
this.updateStatus({
|
||||||
const activeStream = this.activeStream;
|
terminalId: input.terminalId,
|
||||||
const activeMatches =
|
isAttaching: true,
|
||||||
Boolean(activeStream) &&
|
error: this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE,
|
||||||
activeStream!.terminalId === input.terminalId &&
|
});
|
||||||
activeStream!.streamId === input.streamId;
|
void this.attachTerminal({
|
||||||
if (activeMatches) {
|
terminalId: input.terminalId,
|
||||||
this.attachGeneration += 1;
|
generation,
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
@@ -208,12 +157,15 @@ export class TerminalStreamController {
|
|||||||
}
|
}
|
||||||
this.isDisposed = true;
|
this.isDisposed = true;
|
||||||
this.attachGeneration += 1;
|
this.attachGeneration += 1;
|
||||||
|
this.decoder.decode();
|
||||||
|
const selectedTerminalId = this.selectedTerminalId;
|
||||||
this.selectedTerminalId = null;
|
this.selectedTerminalId = null;
|
||||||
void this.detachActiveStream({ shouldDetach: true });
|
if (selectedTerminalId) {
|
||||||
this.internalResumeOffsetByTerminalId.clear();
|
this.options.client.unsubscribeTerminal(selectedTerminalId);
|
||||||
|
}
|
||||||
|
this.unsubscribeStreamEvents();
|
||||||
this.updateStatus({
|
this.updateStatus({
|
||||||
terminalId: null,
|
terminalId: null,
|
||||||
streamId: null,
|
|
||||||
isAttaching: false,
|
isAttaching: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -229,53 +181,27 @@ export class TerminalStreamController {
|
|||||||
getRetryDelayMs = getTerminalAttachRetryDelayMs,
|
getRetryDelayMs = getTerminalAttachRetryDelayMs,
|
||||||
} = this.options;
|
} = this.options;
|
||||||
|
|
||||||
let lastErrorMessage = "Unable to attach terminal stream";
|
let lastErrorMessage = "Unable to subscribe to terminal";
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxAttachAttempts; attempt += 1) {
|
for (let attempt = 0; attempt < maxAttachAttempts; attempt += 1) {
|
||||||
if (
|
if (!this.isAttachGenerationCurrent(input)) {
|
||||||
!this.isAttachGenerationCurrent({
|
|
||||||
generation: input.generation,
|
|
||||||
terminalId: input.terminalId,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const preferredSize = this.options.getPreferredSize();
|
const payload = await withTimeout({
|
||||||
const resumeOffset = getTerminalResumeOffset({
|
promise: this.options.client.subscribeTerminal(input.terminalId),
|
||||||
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 } : {}),
|
|
||||||
}),
|
|
||||||
timeoutMs: attachTimeoutMs,
|
timeoutMs: attachTimeoutMs,
|
||||||
timeoutMessage: "Timed out attaching terminal stream",
|
timeoutMessage: "Timed out subscribing to terminal",
|
||||||
});
|
});
|
||||||
|
|
||||||
updateTerminalResumeOffset({
|
if (!this.isAttachGenerationCurrent(input)) {
|
||||||
terminalId: input.terminalId,
|
this.options.client.unsubscribeTerminal(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(() => {});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
|
if (payload.error) {
|
||||||
lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream";
|
lastErrorMessage = payload.error;
|
||||||
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
|
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
|
||||||
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
|
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
|
||||||
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
|
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
|
||||||
@@ -284,82 +210,30 @@ export class TerminalStreamController {
|
|||||||
|
|
||||||
this.updateStatus({
|
this.updateStatus({
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
streamId: null,
|
|
||||||
isAttaching: false,
|
isAttaching: false,
|
||||||
error: lastErrorMessage,
|
error: lastErrorMessage,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.exitedStreamsWhileAttaching.has(attachPayload.streamId)) {
|
const preferredSize = this.options.getPreferredSize();
|
||||||
this.exitedStreamsWhileAttaching.delete(attachPayload.streamId);
|
if (preferredSize) {
|
||||||
void this.options.client.detachTerminalStream(attachPayload.streamId).catch(() => {});
|
this.options.client.sendTerminalInput(input.terminalId, {
|
||||||
this.attachGeneration += 1;
|
type: "resize",
|
||||||
void this.attachTerminal({
|
rows: preferredSize.rows,
|
||||||
terminalId: input.terminalId,
|
cols: preferredSize.cols,
|
||||||
generation: this.attachGeneration,
|
|
||||||
});
|
});
|
||||||
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({
|
this.updateStatus({
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
streamId,
|
|
||||||
isAttaching: false,
|
isAttaching: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastErrorMessage =
|
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;
|
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
|
||||||
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
|
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
|
||||||
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
|
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
|
||||||
@@ -368,7 +242,6 @@ export class TerminalStreamController {
|
|||||||
|
|
||||||
this.updateStatus({
|
this.updateStatus({
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
streamId: null,
|
|
||||||
isAttaching: false,
|
isAttaching: false,
|
||||||
error: lastErrorMessage,
|
error: lastErrorMessage,
|
||||||
});
|
});
|
||||||
@@ -378,187 +251,19 @@ export class TerminalStreamController {
|
|||||||
|
|
||||||
this.updateStatus({
|
this.updateStatus({
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
streamId: null,
|
|
||||||
isAttaching: false,
|
isAttaching: false,
|
||||||
error: lastErrorMessage,
|
error: lastErrorMessage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleChunk(input: {
|
private isAttachGenerationCurrent(input: { terminalId: string; generation: number }): boolean {
|
||||||
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 {
|
|
||||||
if (this.isDisposed) {
|
if (this.isDisposed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (
|
return this.attachGeneration === input.generation && this.selectedTerminalId === input.terminalId;
|
||||||
this.attachGeneration === input.generation && this.selectedTerminalId === input.terminalId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateStatus(status: TerminalStreamControllerStatus): void {
|
private updateStatus(status: TerminalStreamControllerStatus): void {
|
||||||
this.status = status;
|
|
||||||
this.options.onStatusChange?.(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", () => {
|
it("returns the same workspace session instance for the same scope", () => {
|
||||||
const first = getWorkspaceTerminalSession({
|
const first = getWorkspaceTerminalSession({
|
||||||
scopeKey: "workspace-a",
|
scopeKey: "workspace-a",
|
||||||
maxOutputChars: 1_000,
|
|
||||||
});
|
});
|
||||||
const second = getWorkspaceTerminalSession({
|
const second = getWorkspaceTerminalSession({
|
||||||
scopeKey: "workspace-a",
|
scopeKey: "workspace-a",
|
||||||
maxOutputChars: 50,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(second).toBe(first);
|
expect(second).toBe(first);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves resume offsets across repeated lookups", () => {
|
it("preserves snapshots across repeated lookups", () => {
|
||||||
const first = getWorkspaceTerminalSession({
|
const first = getWorkspaceTerminalSession({
|
||||||
scopeKey: "workspace-resume",
|
scopeKey: "workspace-snapshots",
|
||||||
maxOutputChars: 1_000,
|
|
||||||
});
|
});
|
||||||
first.resumeOffsets.set({
|
first.snapshots.set({
|
||||||
terminalId: "term-1",
|
terminalId: "term-1",
|
||||||
offset: 42,
|
state: {
|
||||||
|
rows: 1,
|
||||||
|
cols: 1,
|
||||||
|
grid: [[{ char: "A" }]],
|
||||||
|
scrollback: [],
|
||||||
|
cursor: { row: 0, col: 0 },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const second = getWorkspaceTerminalSession({
|
const second = getWorkspaceTerminalSession({
|
||||||
scopeKey: "workspace-resume",
|
scopeKey: "workspace-snapshots",
|
||||||
maxOutputChars: 1_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 scopeKey = "workspace-release";
|
||||||
const first = getWorkspaceTerminalSession({
|
const first = getWorkspaceTerminalSession({
|
||||||
scopeKey,
|
scopeKey,
|
||||||
maxOutputChars: 1_000,
|
|
||||||
});
|
});
|
||||||
first.resumeOffsets.set({
|
first.snapshots.set({
|
||||||
terminalId: "term-1",
|
terminalId: "term-1",
|
||||||
offset: 128,
|
state: {
|
||||||
|
rows: 1,
|
||||||
|
cols: 1,
|
||||||
|
grid: [[{ char: "A" }]],
|
||||||
|
scrollback: [],
|
||||||
|
cursor: { row: 0, col: 0 },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
retainWorkspaceTerminalSession({ scopeKey });
|
retainWorkspaceTerminalSession({ scopeKey });
|
||||||
@@ -54,10 +67,9 @@ describe("workspace-terminal-session", () => {
|
|||||||
|
|
||||||
const second = getWorkspaceTerminalSession({
|
const second = getWorkspaceTerminalSession({
|
||||||
scopeKey,
|
scopeKey,
|
||||||
maxOutputChars: 1_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(second).not.toBe(first);
|
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 {
|
import type { TerminalState } from "@server/shared/messages";
|
||||||
getTerminalOutputSession,
|
|
||||||
releaseTerminalOutputSession,
|
|
||||||
retainTerminalOutputSession,
|
|
||||||
type TerminalOutputSession,
|
|
||||||
} from "./terminal-output-session";
|
|
||||||
|
|
||||||
export type WorkspaceTerminalResumeOffsets = {
|
export type WorkspaceTerminalSnapshots = {
|
||||||
get: (input: { terminalId: string }) => number | undefined;
|
get: (input: { terminalId: string }) => TerminalState | null;
|
||||||
set: (input: { terminalId: string; offset: number }) => void;
|
set: (input: { terminalId: string; state: TerminalState }) => void;
|
||||||
clear: (input: { terminalId: string }) => void;
|
clear: (input: { terminalId: string }) => void;
|
||||||
prune: (input: { terminalIds: string[] }) => void;
|
prune: (input: { terminalIds: string[] }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceTerminalSession = {
|
export type WorkspaceTerminalSession = {
|
||||||
scopeKey: string;
|
scopeKey: string;
|
||||||
outputSession: TerminalOutputSession;
|
snapshots: WorkspaceTerminalSnapshots;
|
||||||
resumeOffsets: WorkspaceTerminalResumeOffsets;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkspaceTerminalSessionRecord = {
|
type WorkspaceTerminalSessionRecord = {
|
||||||
resumeOffsetByTerminalId: Map<string, number>;
|
snapshotByTerminalId: Map<string, TerminalState>;
|
||||||
session: WorkspaceTerminalSession;
|
session: WorkspaceTerminalSession;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessionsByScopeKey = new Map<string, WorkspaceTerminalSessionRecord>();
|
const sessionsByScopeKey = new Map<string, WorkspaceTerminalSessionRecord>();
|
||||||
const refCountByScopeKey = new Map<string, number>();
|
const refCountByScopeKey = new Map<string, number>();
|
||||||
|
|
||||||
function createResumeOffsets(input: {
|
function createSnapshots(input: {
|
||||||
resumeOffsetByTerminalId: Map<string, number>;
|
snapshotByTerminalId: Map<string, TerminalState>;
|
||||||
}): WorkspaceTerminalResumeOffsets {
|
}): WorkspaceTerminalSnapshots {
|
||||||
return {
|
return {
|
||||||
get: ({ terminalId }) => {
|
get: ({ terminalId }) => input.snapshotByTerminalId.get(terminalId) ?? null,
|
||||||
const offset = input.resumeOffsetByTerminalId.get(terminalId);
|
set: ({ terminalId, state }) => {
|
||||||
if (typeof offset !== "number" || !Number.isFinite(offset)) {
|
input.snapshotByTerminalId.set(terminalId, state);
|
||||||
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)));
|
|
||||||
},
|
},
|
||||||
clear: ({ terminalId }) => {
|
clear: ({ terminalId }) => {
|
||||||
input.resumeOffsetByTerminalId.delete(terminalId);
|
input.snapshotByTerminalId.delete(terminalId);
|
||||||
},
|
},
|
||||||
prune: ({ terminalIds }) => {
|
prune: ({ terminalIds }) => {
|
||||||
const terminalIdSet = new Set(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)) {
|
if (!terminalIdSet.has(terminalId)) {
|
||||||
input.resumeOffsetByTerminalId.delete(terminalId);
|
input.snapshotByTerminalId.delete(terminalId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWorkspaceTerminalSession(input: {
|
export function getWorkspaceTerminalSession(input: { scopeKey: string }): WorkspaceTerminalSession {
|
||||||
scopeKey: string;
|
|
||||||
maxOutputChars: number;
|
|
||||||
}): WorkspaceTerminalSession {
|
|
||||||
const existing = sessionsByScopeKey.get(input.scopeKey);
|
const existing = sessionsByScopeKey.get(input.scopeKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing.session;
|
return existing.session;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resumeOffsetByTerminalId = new Map<string, number>();
|
const snapshotByTerminalId = new Map<string, TerminalState>();
|
||||||
const session: WorkspaceTerminalSession = {
|
const session: WorkspaceTerminalSession = {
|
||||||
scopeKey: input.scopeKey,
|
scopeKey: input.scopeKey,
|
||||||
outputSession: getTerminalOutputSession({
|
snapshots: createSnapshots({
|
||||||
scopeKey: input.scopeKey,
|
snapshotByTerminalId,
|
||||||
maxOutputChars: input.maxOutputChars,
|
|
||||||
}),
|
|
||||||
resumeOffsets: createResumeOffsets({
|
|
||||||
resumeOffsetByTerminalId,
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
sessionsByScopeKey.set(input.scopeKey, {
|
sessionsByScopeKey.set(input.scopeKey, {
|
||||||
resumeOffsetByTerminalId,
|
snapshotByTerminalId,
|
||||||
session,
|
session,
|
||||||
});
|
});
|
||||||
return session;
|
return session;
|
||||||
@@ -88,11 +66,9 @@ export function getWorkspaceTerminalSession(input: {
|
|||||||
export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void {
|
export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void {
|
||||||
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
|
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
|
||||||
refCountByScopeKey.set(input.scopeKey, current + 1);
|
refCountByScopeKey.set(input.scopeKey, current + 1);
|
||||||
retainTerminalOutputSession(input);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void {
|
export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void {
|
||||||
releaseTerminalOutputSession(input);
|
|
||||||
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
|
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
|
||||||
if (current > 1) {
|
if (current > 1) {
|
||||||
refCountByScopeKey.set(input.scopeKey, current - 1);
|
refCountByScopeKey.set(input.scopeKey, current - 1);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getTerminalResumeOffset,
|
|
||||||
getTerminalAttachRetryDelayMs,
|
getTerminalAttachRetryDelayMs,
|
||||||
isTerminalAttachRetryableError,
|
isTerminalAttachRetryableError,
|
||||||
updateTerminalResumeOffset,
|
|
||||||
withPromiseTimeout,
|
withPromiseTimeout,
|
||||||
} from "./terminal-attach";
|
} from "./terminal-attach";
|
||||||
|
|
||||||
@@ -24,53 +22,12 @@ describe("terminal-attach", () => {
|
|||||||
expect(isTerminalAttachRetryableError({ message: "Network disconnected during attach" })).toBe(
|
expect(isTerminalAttachRetryableError({ message: "Network disconnected during attach" })).toBe(
|
||||||
true,
|
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);
|
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 () => {
|
it("resolves before timeout when promise completes", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
withPromiseTimeout({
|
withPromiseTimeout({
|
||||||
|
|||||||
@@ -8,49 +8,6 @@ const TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS = [
|
|||||||
"stream ended",
|
"stream ended",
|
||||||
] as const;
|
] 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 {
|
export function getTerminalAttachRetryDelayMs(input: { attempt: number }): number {
|
||||||
const clampedAttempt = Math.max(0, input.attempt);
|
const clampedAttempt = Math.max(0, input.attempt);
|
||||||
const exponentialDelay = 250 * 2 ** clampedAttempt;
|
const exponentialDelay = 250 * 2 ** clampedAttempt;
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
TerminalStreamManager,
|
|
||||||
type TerminalStreamChunk,
|
|
||||||
} from "./daemon-client-terminal-stream-manager.js";
|
|
||||||
|
|
||||||
function createChunk(input: {
|
|
||||||
streamId: number;
|
|
||||||
offset: number;
|
|
||||||
data: string;
|
|
||||||
}): TerminalStreamChunk {
|
|
||||||
const bytes = new TextEncoder().encode(input.data);
|
|
||||||
return {
|
|
||||||
streamId: input.streamId,
|
|
||||||
offset: input.offset,
|
|
||||||
endOffset: input.offset + bytes.byteLength,
|
|
||||||
replay: false,
|
|
||||||
data: bytes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("TerminalStreamManager", () => {
|
|
||||||
test("buffers chunks and flushes with ack when handler subscribes", () => {
|
|
||||||
const sendAck = vi.fn();
|
|
||||||
const manager = new TerminalStreamManager({ sendAck });
|
|
||||||
const seen: string[] = [];
|
|
||||||
|
|
||||||
manager.receiveChunk({
|
|
||||||
chunk: createChunk({ streamId: 7, offset: 4, data: "hello" }),
|
|
||||||
});
|
|
||||||
expect(sendAck).toHaveBeenCalledTimes(1);
|
|
||||||
expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 });
|
|
||||||
|
|
||||||
manager.subscribe({
|
|
||||||
streamId: 7,
|
|
||||||
handler: (chunk) => {
|
|
||||||
seen.push(new TextDecoder().decode(chunk.data));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(seen).toEqual(["hello"]);
|
|
||||||
expect(sendAck).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("acks buffered chunks even while no subscriber is attached", () => {
|
|
||||||
const sendAck = vi.fn();
|
|
||||||
const manager = new TerminalStreamManager({ sendAck });
|
|
||||||
|
|
||||||
manager.receiveChunk({
|
|
||||||
chunk: createChunk({ streamId: 2, offset: 0, data: "abc" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sendAck).toHaveBeenCalledTimes(1);
|
|
||||||
expect(sendAck).toHaveBeenCalledWith({ streamId: 2, offset: 3 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not ack when every handler throws", () => {
|
|
||||||
const sendAck = vi.fn();
|
|
||||||
const manager = new TerminalStreamManager({ sendAck });
|
|
||||||
|
|
||||||
manager.subscribe({
|
|
||||||
streamId: 11,
|
|
||||||
handler: () => {
|
|
||||||
throw new Error("boom");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
manager.receiveChunk({
|
|
||||||
chunk: createChunk({ streamId: 11, offset: 0, data: "x" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sendAck).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("evicts oldest buffered chunks when max buffered chunk count is exceeded", () => {
|
|
||||||
const sendAck = vi.fn();
|
|
||||||
const manager = new TerminalStreamManager({
|
|
||||||
sendAck,
|
|
||||||
maxBufferedChunks: 2,
|
|
||||||
maxBufferedBytes: 1024,
|
|
||||||
});
|
|
||||||
|
|
||||||
manager.receiveChunk({
|
|
||||||
chunk: createChunk({ streamId: 5, offset: 0, data: "A" }),
|
|
||||||
});
|
|
||||||
manager.receiveChunk({
|
|
||||||
chunk: createChunk({ streamId: 5, offset: 1, data: "B" }),
|
|
||||||
});
|
|
||||||
manager.receiveChunk({
|
|
||||||
chunk: createChunk({ streamId: 5, offset: 2, data: "C" }),
|
|
||||||
});
|
|
||||||
expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 1 });
|
|
||||||
expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 2 });
|
|
||||||
expect(sendAck).toHaveBeenNthCalledWith(3, { streamId: 5, offset: 3 });
|
|
||||||
|
|
||||||
const seen: string[] = [];
|
|
||||||
manager.subscribe({
|
|
||||||
streamId: 5,
|
|
||||||
handler: (chunk) => {
|
|
||||||
seen.push(new TextDecoder().decode(chunk.data));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(seen).toEqual(["B", "C"]);
|
|
||||||
expect(sendAck).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tracks explicit ack offsets and skips stale auto-acks", () => {
|
|
||||||
const sendAck = vi.fn();
|
|
||||||
const manager = new TerminalStreamManager({ sendAck });
|
|
||||||
|
|
||||||
manager.subscribe({
|
|
||||||
streamId: 13,
|
|
||||||
handler: () => {
|
|
||||||
// no-op
|
|
||||||
},
|
|
||||||
});
|
|
||||||
manager.noteAck({ streamId: 13, offset: 10 });
|
|
||||||
|
|
||||||
manager.receiveChunk({
|
|
||||||
chunk: createChunk({ streamId: 13, offset: 0, data: "abc" }),
|
|
||||||
});
|
|
||||||
expect(sendAck).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
manager.receiveChunk({
|
|
||||||
chunk: createChunk({ streamId: 13, offset: 11, data: "z" }),
|
|
||||||
});
|
|
||||||
expect(sendAck).toHaveBeenCalledTimes(1);
|
|
||||||
expect(sendAck).toHaveBeenCalledWith({ streamId: 13, offset: 12 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
export type TerminalStreamChunk = {
|
|
||||||
streamId: number;
|
|
||||||
offset: number;
|
|
||||||
endOffset: number;
|
|
||||||
replay: boolean;
|
|
||||||
data: Uint8Array;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TerminalChunkHandler = (chunk: TerminalStreamChunk) => void;
|
|
||||||
|
|
||||||
type BufferedTerminalStreamQueue = {
|
|
||||||
chunks: TerminalStreamChunk[];
|
|
||||||
bytes: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TerminalStreamAck = {
|
|
||||||
streamId: number;
|
|
||||||
offset: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TerminalStreamManagerConfig = {
|
|
||||||
sendAck: (ack: TerminalStreamAck) => void;
|
|
||||||
maxBufferedChunks?: number;
|
|
||||||
maxBufferedBytes?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_MAX_BUFFERED_CHUNKS = 2048;
|
|
||||||
const DEFAULT_MAX_BUFFERED_BYTES = 2 * 1024 * 1024;
|
|
||||||
|
|
||||||
export class TerminalStreamManager {
|
|
||||||
private readonly handlers: Map<number, Set<TerminalChunkHandler>> = new Map();
|
|
||||||
private readonly bufferedChunks: Map<number, BufferedTerminalStreamQueue> = new Map();
|
|
||||||
private readonly ackOffsets: Map<number, number> = new Map();
|
|
||||||
private readonly maxBufferedChunks: number;
|
|
||||||
private readonly maxBufferedBytes: number;
|
|
||||||
|
|
||||||
constructor(private readonly config: TerminalStreamManagerConfig) {
|
|
||||||
this.maxBufferedChunks = config.maxBufferedChunks ?? DEFAULT_MAX_BUFFERED_CHUNKS;
|
|
||||||
this.maxBufferedBytes = config.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAll(): void {
|
|
||||||
this.handlers.clear();
|
|
||||||
this.bufferedChunks.clear();
|
|
||||||
this.ackOffsets.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
clearStream(input: { streamId: number }): void {
|
|
||||||
this.handlers.delete(input.streamId);
|
|
||||||
this.bufferedChunks.delete(input.streamId);
|
|
||||||
this.ackOffsets.delete(input.streamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(input: { streamId: number; handler: TerminalChunkHandler }): () => void {
|
|
||||||
const { streamId, handler } = input;
|
|
||||||
if (!this.handlers.has(streamId)) {
|
|
||||||
this.handlers.set(streamId, new Set());
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamHandlers = this.handlers.get(streamId)!;
|
|
||||||
streamHandlers.add(handler);
|
|
||||||
this.flushBufferedChunks({ streamId, handler });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
streamHandlers.delete(handler);
|
|
||||||
if (streamHandlers.size === 0) {
|
|
||||||
this.handlers.delete(streamId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
receiveChunk(input: { chunk: TerminalStreamChunk }): void {
|
|
||||||
const { chunk } = input;
|
|
||||||
const streamHandlers = this.handlers.get(chunk.streamId);
|
|
||||||
if (!streamHandlers || streamHandlers.size === 0) {
|
|
||||||
this.bufferChunk({ chunk });
|
|
||||||
this.maybeAckChunk({
|
|
||||||
streamId: chunk.streamId,
|
|
||||||
endOffset: chunk.endOffset,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let delivered = false;
|
|
||||||
for (const handler of streamHandlers) {
|
|
||||||
try {
|
|
||||||
handler(chunk);
|
|
||||||
delivered = true;
|
|
||||||
} catch {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (delivered) {
|
|
||||||
this.maybeAckChunk({
|
|
||||||
streamId: chunk.streamId,
|
|
||||||
endOffset: chunk.endOffset,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
noteAck(input: TerminalStreamAck): void {
|
|
||||||
const normalizedOffset = Math.max(0, Math.floor(input.offset));
|
|
||||||
const previousAck = this.ackOffsets.get(input.streamId) ?? -1;
|
|
||||||
if (normalizedOffset > previousAck) {
|
|
||||||
this.ackOffsets.set(input.streamId, normalizedOffset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private flushBufferedChunks(input: { streamId: number; handler: TerminalChunkHandler }): void {
|
|
||||||
const buffered = this.bufferedChunks.get(input.streamId);
|
|
||||||
if (!buffered || buffered.chunks.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const chunk of buffered.chunks) {
|
|
||||||
try {
|
|
||||||
input.handler(chunk);
|
|
||||||
this.maybeAckChunk({
|
|
||||||
streamId: input.streamId,
|
|
||||||
endOffset: chunk.endOffset,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.bufferedChunks.delete(input.streamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bufferChunk(input: { chunk: TerminalStreamChunk }): void {
|
|
||||||
const queue = this.bufferedChunks.get(input.chunk.streamId) ?? {
|
|
||||||
chunks: [],
|
|
||||||
bytes: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
queue.chunks.push(input.chunk);
|
|
||||||
queue.bytes += input.chunk.data.byteLength;
|
|
||||||
|
|
||||||
while (queue.chunks.length > this.maxBufferedChunks || queue.bytes > this.maxBufferedBytes) {
|
|
||||||
const removed = queue.chunks.shift();
|
|
||||||
if (!removed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
queue.bytes -= removed.data.byteLength;
|
|
||||||
if (queue.bytes < 0) {
|
|
||||||
queue.bytes = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.bufferedChunks.set(input.chunk.streamId, queue);
|
|
||||||
}
|
|
||||||
|
|
||||||
private maybeAckChunk(input: { streamId: number; endOffset: number }): void {
|
|
||||||
if (!Number.isFinite(input.endOffset) || input.endOffset < 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedEndOffset = Math.floor(input.endOffset);
|
|
||||||
const previousAck = this.ackOffsets.get(input.streamId) ?? -1;
|
|
||||||
if (normalizedEndOffset <= previousAck) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ackOffsets.set(input.streamId, normalizedEndOffset);
|
|
||||||
try {
|
|
||||||
this.config.sendAck({
|
|
||||||
streamId: input.streamId,
|
|
||||||
offset: normalizedEndOffset,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
|
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
|
||||||
import { DaemonClient, type DaemonTransport } from "./daemon-client";
|
import { DaemonClient, type DaemonTransport } from "./daemon-client";
|
||||||
import {
|
import {
|
||||||
BinaryMuxChannel,
|
|
||||||
TerminalBinaryMessageType,
|
|
||||||
asUint8Array,
|
asUint8Array,
|
||||||
decodeBinaryMuxFrame,
|
decodeTerminalResizePayload,
|
||||||
encodeBinaryMuxFrame,
|
decodeTerminalSnapshotPayload,
|
||||||
} from "../shared/binary-mux.js";
|
decodeTerminalStreamFrame,
|
||||||
|
encodeTerminalSnapshotPayload,
|
||||||
|
encodeTerminalStreamFrame,
|
||||||
|
TerminalStreamOpcode,
|
||||||
|
} from "../shared/terminal-stream-protocol.js";
|
||||||
|
|
||||||
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
|
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
|
||||||
expectTypeOf<
|
expectTypeOf<
|
||||||
@@ -1224,7 +1226,7 @@ describe("DaemonClient", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("auto-acks terminal stream chunks after delivery", async () => {
|
test("emits output events for the active terminal stream", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|
||||||
@@ -1242,36 +1244,40 @@ describe("DaemonClient", () => {
|
|||||||
await connectPromise;
|
await connectPromise;
|
||||||
|
|
||||||
const seen: string[] = [];
|
const seen: string[] = [];
|
||||||
const decoder = new TextDecoder();
|
const unsubscribe = client.onTerminalStreamEvent((event) => {
|
||||||
const unsubscribe = client.onTerminalStreamData(7, (chunk) => {
|
if (event.type !== "output") {
|
||||||
seen.push(decoder.decode(chunk.data));
|
return;
|
||||||
|
}
|
||||||
|
seen.push(new TextDecoder().decode(event.data));
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = new TextEncoder().encode("hello");
|
const subscribePromise = client.subscribeTerminal("term-1", "sub-1");
|
||||||
mock.triggerMessage(
|
mock.triggerMessage(
|
||||||
encodeBinaryMuxFrame({
|
wrapSessionMessage({
|
||||||
channel: BinaryMuxChannel.Terminal,
|
type: "subscribe_terminal_response",
|
||||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
payload: {
|
||||||
streamId: 7,
|
terminalId: "term-1",
|
||||||
offset: 10,
|
state: null,
|
||||||
payload,
|
error: null,
|
||||||
|
requestId: "sub-1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await subscribePromise;
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
encodeTerminalStreamFrame({
|
||||||
|
opcode: TerminalStreamOpcode.Output,
|
||||||
|
payload: new TextEncoder().encode("hello"),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(seen).toEqual(["hello"]);
|
expect(seen).toEqual(["hello"]);
|
||||||
expect(mock.sent).toHaveLength(1);
|
expect(mock.sent).toHaveLength(1);
|
||||||
const ackBytes = asUint8Array(mock.sent[0]);
|
|
||||||
expect(ackBytes).not.toBeNull();
|
|
||||||
const ackFrame = decodeBinaryMuxFrame(ackBytes!);
|
|
||||||
expect(ackFrame).not.toBeNull();
|
|
||||||
expect(ackFrame!.channel).toBe(BinaryMuxChannel.Terminal);
|
|
||||||
expect(ackFrame!.messageType).toBe(TerminalBinaryMessageType.Ack);
|
|
||||||
expect(ackFrame!.streamId).toBe(7);
|
|
||||||
expect(ackFrame!.offset).toBe(15);
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles terminal binary frames delivered as UTF-8 strings", async () => {
|
test("emits snapshot events for the active terminal stream", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|
||||||
@@ -1288,76 +1294,100 @@ describe("DaemonClient", () => {
|
|||||||
mock.triggerOpen();
|
mock.triggerOpen();
|
||||||
await connectPromise;
|
await connectPromise;
|
||||||
|
|
||||||
const seen: string[] = [];
|
const snapshots: unknown[] = [];
|
||||||
client.onTerminalStreamData(11, (chunk) => {
|
client.onTerminalStreamEvent((event) => {
|
||||||
seen.push(new TextDecoder().decode(chunk.data));
|
if (event.type !== "snapshot") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
snapshots.push(event.state);
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = new TextEncoder().encode("ls\r\n");
|
const subscribePromise = client.subscribeTerminal("term-1", "sub-2");
|
||||||
const frame = encodeBinaryMuxFrame({
|
|
||||||
channel: BinaryMuxChannel.Terminal,
|
|
||||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
|
||||||
streamId: 11,
|
|
||||||
offset: 0,
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
const frameAsString = new TextDecoder("utf-8", { fatal: true }).decode(frame);
|
|
||||||
|
|
||||||
mock.triggerMessage(frameAsString);
|
|
||||||
|
|
||||||
expect(seen).toEqual(["ls\r\n"]);
|
|
||||||
expect(mock.sent).toHaveLength(1);
|
|
||||||
const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
|
|
||||||
expect(ackFrame?.channel).toBe(BinaryMuxChannel.Terminal);
|
|
||||||
expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack);
|
|
||||||
expect(ackFrame?.streamId).toBe(11);
|
|
||||||
expect(ackFrame?.offset).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("acks buffered terminal chunks when handler is attached", async () => {
|
|
||||||
const logger = createMockLogger();
|
|
||||||
const mock = createMockTransport();
|
|
||||||
|
|
||||||
const client = new DaemonClient({
|
|
||||||
url: "ws://test",
|
|
||||||
clientId: "clsk_unit_test",
|
|
||||||
logger,
|
|
||||||
reconnect: { enabled: false },
|
|
||||||
transportFactory: () => mock.transport,
|
|
||||||
});
|
|
||||||
clients.push(client);
|
|
||||||
|
|
||||||
const connectPromise = client.connect();
|
|
||||||
mock.triggerOpen();
|
|
||||||
await connectPromise;
|
|
||||||
|
|
||||||
mock.triggerMessage(
|
mock.triggerMessage(
|
||||||
encodeBinaryMuxFrame({
|
wrapSessionMessage({
|
||||||
channel: BinaryMuxChannel.Terminal,
|
type: "subscribe_terminal_response",
|
||||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
payload: {
|
||||||
streamId: 19,
|
terminalId: "term-1",
|
||||||
offset: 0,
|
state: null,
|
||||||
payload: new TextEncoder().encode("buffered"),
|
error: null,
|
||||||
|
requestId: "sub-2",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await subscribePromise;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
rows: 1,
|
||||||
|
cols: 5,
|
||||||
|
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
|
||||||
|
scrollback: [],
|
||||||
|
cursor: { row: 0, col: 5 },
|
||||||
|
};
|
||||||
|
mock.triggerMessage(
|
||||||
|
encodeTerminalStreamFrame({
|
||||||
|
opcode: TerminalStreamOpcode.Snapshot,
|
||||||
|
payload: encodeTerminalSnapshotPayload(state),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mock.sent).toHaveLength(1);
|
expect(snapshots).toEqual([state]);
|
||||||
const bufferedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
|
|
||||||
expect(bufferedAck?.messageType).toBe(TerminalBinaryMessageType.Ack);
|
|
||||||
expect(bufferedAck?.streamId).toBe(19);
|
|
||||||
expect(bufferedAck?.offset).toBe(8);
|
|
||||||
mock.sent.length = 0;
|
|
||||||
|
|
||||||
const seen: string[] = [];
|
|
||||||
client.onTerminalStreamData(19, (chunk) => {
|
|
||||||
seen.push(new TextDecoder().decode(chunk.data));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(seen.join("")).toContain("buffered");
|
|
||||||
expect(mock.sent).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stops delivering and acking after terminal_stream_exit", async () => {
|
test("sends input and resize frames for the active terminal", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_unit_test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const subscribePromise = client.subscribeTerminal("term-1", "sub-3");
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "subscribe_terminal_response",
|
||||||
|
payload: {
|
||||||
|
terminalId: "term-1",
|
||||||
|
state: null,
|
||||||
|
error: null,
|
||||||
|
requestId: "sub-3",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await subscribePromise;
|
||||||
|
mock.sent.length = 0;
|
||||||
|
|
||||||
|
client.sendTerminalInput("term-1", {
|
||||||
|
type: "input",
|
||||||
|
data: "echo hello\r",
|
||||||
|
});
|
||||||
|
client.sendTerminalInput("term-1", {
|
||||||
|
type: "resize",
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!);
|
||||||
|
const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!);
|
||||||
|
|
||||||
|
expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input);
|
||||||
|
expect(new TextDecoder().decode(inputFrame?.payload ?? new Uint8Array())).toBe("echo hello\r");
|
||||||
|
expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize);
|
||||||
|
expect(decodeTerminalResizePayload(resizeFrame?.payload ?? new Uint8Array())).toEqual({
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores terminal stream frames after terminal_stream_exit", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|
||||||
@@ -1375,114 +1405,55 @@ describe("DaemonClient", () => {
|
|||||||
await connectPromise;
|
await connectPromise;
|
||||||
|
|
||||||
const seen: string[] = [];
|
const seen: string[] = [];
|
||||||
const unsubscribe = client.onTerminalStreamData(23, (chunk) => {
|
const unsubscribe = client.onTerminalStreamEvent((event) => {
|
||||||
seen.push(new TextDecoder().decode(chunk.data));
|
if (event.type !== "output") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.push(new TextDecoder().decode(event.data));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const subscribePromise = client.subscribeTerminal("term-1", "sub-4");
|
||||||
mock.triggerMessage(
|
mock.triggerMessage(
|
||||||
encodeBinaryMuxFrame({
|
wrapSessionMessage({
|
||||||
channel: BinaryMuxChannel.Terminal,
|
type: "subscribe_terminal_response",
|
||||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
payload: {
|
||||||
streamId: 23,
|
terminalId: "term-1",
|
||||||
offset: 0,
|
state: null,
|
||||||
|
error: null,
|
||||||
|
requestId: "sub-4",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await subscribePromise;
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
encodeTerminalStreamFrame({
|
||||||
|
opcode: TerminalStreamOpcode.Output,
|
||||||
payload: new TextEncoder().encode("before-exit"),
|
payload: new TextEncoder().encode("before-exit"),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(seen).toEqual(["before-exit"]);
|
expect(seen).toEqual(["before-exit"]);
|
||||||
expect(mock.sent).toHaveLength(1);
|
|
||||||
mock.sent.length = 0;
|
|
||||||
|
|
||||||
mock.triggerMessage(
|
mock.triggerMessage(
|
||||||
wrapSessionMessage({
|
wrapSessionMessage({
|
||||||
type: "terminal_stream_exit",
|
type: "terminal_stream_exit",
|
||||||
payload: {
|
payload: {
|
||||||
streamId: 23,
|
|
||||||
terminalId: "term-1",
|
terminalId: "term-1",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
mock.triggerMessage(
|
mock.triggerMessage(
|
||||||
encodeBinaryMuxFrame({
|
encodeTerminalStreamFrame({
|
||||||
channel: BinaryMuxChannel.Terminal,
|
opcode: TerminalStreamOpcode.Output,
|
||||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
|
||||||
streamId: 23,
|
|
||||||
offset: 12,
|
|
||||||
payload: new TextEncoder().encode("after-exit"),
|
payload: new TextEncoder().encode("after-exit"),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(seen).toEqual(["before-exit"]);
|
expect(seen).toEqual(["before-exit"]);
|
||||||
expect(mock.sent).toHaveLength(1);
|
|
||||||
const postExitAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
|
|
||||||
expect(postExitAck?.messageType).toBe(TerminalBinaryMessageType.Ack);
|
|
||||||
expect(postExitAck?.streamId).toBe(23);
|
|
||||||
expect(postExitAck?.offset).toBe(22);
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cleans local stream state even when detach reports success=false", async () => {
|
|
||||||
const logger = createMockLogger();
|
|
||||||
const mock = createMockTransport();
|
|
||||||
|
|
||||||
const client = new DaemonClient({
|
|
||||||
url: "ws://test",
|
|
||||||
clientId: "clsk_unit_test",
|
|
||||||
logger,
|
|
||||||
reconnect: { enabled: false },
|
|
||||||
transportFactory: () => mock.transport,
|
|
||||||
});
|
|
||||||
clients.push(client);
|
|
||||||
|
|
||||||
const connectPromise = client.connect();
|
|
||||||
mock.triggerOpen();
|
|
||||||
await connectPromise;
|
|
||||||
|
|
||||||
const seen: string[] = [];
|
|
||||||
client.onTerminalStreamData(31, (chunk) => {
|
|
||||||
seen.push(new TextDecoder().decode(chunk.data));
|
|
||||||
});
|
|
||||||
|
|
||||||
const detachPromise = client.detachTerminalStream(31, "detach-31");
|
|
||||||
const detachRequest = JSON.parse(String(mock.sent[0])) as {
|
|
||||||
type: "session";
|
|
||||||
message: { type: string; streamId: number; requestId: string };
|
|
||||||
};
|
|
||||||
expect(detachRequest.message.type).toBe("detach_terminal_stream_request");
|
|
||||||
expect(detachRequest.message.streamId).toBe(31);
|
|
||||||
|
|
||||||
mock.triggerMessage(
|
|
||||||
wrapSessionMessage({
|
|
||||||
type: "detach_terminal_stream_response",
|
|
||||||
payload: {
|
|
||||||
streamId: 31,
|
|
||||||
success: false,
|
|
||||||
requestId: "detach-31",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const payload = await detachPromise;
|
|
||||||
expect(payload.success).toBe(false);
|
|
||||||
|
|
||||||
mock.sent.length = 0;
|
|
||||||
mock.triggerMessage(
|
|
||||||
encodeBinaryMuxFrame({
|
|
||||||
channel: BinaryMuxChannel.Terminal,
|
|
||||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
|
||||||
streamId: 31,
|
|
||||||
offset: 0,
|
|
||||||
payload: new TextEncoder().encode("after-detach"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(seen).toEqual([]);
|
|
||||||
expect(mock.sent).toHaveLength(1);
|
|
||||||
const detachedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
|
|
||||||
expect(detachedAck?.messageType).toBe(TerminalBinaryMessageType.Ack);
|
|
||||||
expect(detachedAck?.streamId).toBe(31);
|
|
||||||
expect(detachedAck?.offset).toBe(12);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses canonical agent_stream tool_call payloads without crashing", async () => {
|
test("parses canonical agent_stream tool_call payloads without crashing", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -44,10 +44,8 @@ import type {
|
|||||||
ListTerminalsResponse,
|
ListTerminalsResponse,
|
||||||
CreateTerminalResponse,
|
CreateTerminalResponse,
|
||||||
SubscribeTerminalResponse,
|
SubscribeTerminalResponse,
|
||||||
TerminalOutput,
|
TerminalState,
|
||||||
KillTerminalResponse,
|
KillTerminalResponse,
|
||||||
AttachTerminalStreamResponse,
|
|
||||||
DetachTerminalStreamResponse,
|
|
||||||
TerminalInput,
|
TerminalInput,
|
||||||
SessionInboundMessage,
|
SessionInboundMessage,
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
@@ -63,18 +61,14 @@ import { getAgentProviderDefinition } from "../server/agent/provider-manifest.js
|
|||||||
import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js";
|
import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js";
|
||||||
import {
|
import {
|
||||||
asUint8Array,
|
asUint8Array,
|
||||||
decodeBinaryMuxFrame,
|
decodeTerminalSnapshotPayload,
|
||||||
encodeBinaryMuxFrame,
|
decodeTerminalStreamFrame,
|
||||||
BinaryMuxChannel,
|
encodeTerminalResizePayload,
|
||||||
TerminalBinaryFlags,
|
encodeTerminalStreamFrame,
|
||||||
TerminalBinaryMessageType,
|
TerminalStreamOpcode,
|
||||||
type BinaryMuxFrame,
|
type TerminalStreamFrame,
|
||||||
} from "../shared/binary-mux.js";
|
} from "../shared/terminal-stream-protocol.js";
|
||||||
import { encodeTerminalKeyInput, type TerminalKeyInput } from "../shared/terminal-key-input.js";
|
import { encodeTerminalKeyInput, type TerminalKeyInput } from "../shared/terminal-key-input.js";
|
||||||
import {
|
|
||||||
TerminalStreamManager,
|
|
||||||
type TerminalStreamChunk,
|
|
||||||
} from "./daemon-client-terminal-stream-manager.js";
|
|
||||||
import {
|
import {
|
||||||
createRelayE2eeTransportFactory,
|
createRelayE2eeTransportFactory,
|
||||||
createWebSocketTransportFactory,
|
createWebSocketTransportFactory,
|
||||||
@@ -108,7 +102,10 @@ export type {
|
|||||||
WebSocketFactory,
|
WebSocketFactory,
|
||||||
WebSocketLike,
|
WebSocketLike,
|
||||||
} from "./daemon-client-transport.js";
|
} from "./daemon-client-transport.js";
|
||||||
export type { TerminalStreamChunk } from "./daemon-client-terminal-stream-manager.js";
|
|
||||||
|
export type TerminalStreamEvent =
|
||||||
|
| { terminalId: string; type: "output"; data: Uint8Array }
|
||||||
|
| { terminalId: string; type: "snapshot"; state: TerminalState };
|
||||||
|
|
||||||
export type ConnectionState =
|
export type ConnectionState =
|
||||||
| { status: "idle" }
|
| { status: "idle" }
|
||||||
@@ -244,10 +241,7 @@ type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
|
|||||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||||
type CreateTerminalPayload = CreateTerminalResponse["payload"];
|
type CreateTerminalPayload = CreateTerminalResponse["payload"];
|
||||||
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
|
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
|
||||||
type TerminalOutputPayload = TerminalOutput["payload"];
|
|
||||||
type KillTerminalPayload = KillTerminalResponse["payload"];
|
type KillTerminalPayload = KillTerminalResponse["payload"];
|
||||||
type AttachTerminalStreamPayload = AttachTerminalStreamResponse["payload"];
|
|
||||||
type DetachTerminalStreamPayload = DetachTerminalStreamResponse["payload"];
|
|
||||||
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
|
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
|
||||||
|
|
||||||
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
|
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
|
||||||
@@ -426,9 +420,10 @@ export class DaemonClient {
|
|||||||
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
|
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
|
||||||
>();
|
>();
|
||||||
private terminalDirectorySubscriptions = new Set<string>();
|
private terminalDirectorySubscriptions = new Set<string>();
|
||||||
|
private activeTerminalId: string | null = null;
|
||||||
|
private readonly terminalStreamListeners = new Set<(event: TerminalStreamEvent) => void>();
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private pendingSendQueue: PendingSend[] = [];
|
private pendingSendQueue: PendingSend[] = [];
|
||||||
private terminalStreams: TerminalStreamManager;
|
|
||||||
private readonly logConnectionPath: "direct" | "relay";
|
private readonly logConnectionPath: "direct" | "relay";
|
||||||
private readonly logServerId: string | null;
|
private readonly logServerId: string | null;
|
||||||
private readonly logClientIdHash: string;
|
private readonly logClientIdHash: string;
|
||||||
@@ -457,17 +452,6 @@ export class DaemonClient {
|
|||||||
Number.isFinite(this.config.runtimeGeneration)
|
Number.isFinite(this.config.runtimeGeneration)
|
||||||
? this.config.runtimeGeneration
|
? this.config.runtimeGeneration
|
||||||
: null;
|
: null;
|
||||||
this.terminalStreams = new TerminalStreamManager({
|
|
||||||
sendAck: (ack) => {
|
|
||||||
this.sendBinaryFrame({
|
|
||||||
channel: BinaryMuxChannel.Terminal,
|
|
||||||
messageType: TerminalBinaryMessageType.Ack,
|
|
||||||
streamId: ack.streamId,
|
|
||||||
offset: ack.offset,
|
|
||||||
payload: new Uint8Array(0),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -676,7 +660,7 @@ export class DaemonClient {
|
|||||||
this.disposeTransport(1000, "Client closed");
|
this.disposeTransport(1000, "Client closed");
|
||||||
this.clearWaiters(new Error("Daemon client closed"));
|
this.clearWaiters(new Error("Daemon client closed"));
|
||||||
this.rejectPendingSendQueue(new Error("Daemon client closed"));
|
this.rejectPendingSendQueue(new Error("Daemon client closed"));
|
||||||
this.terminalStreams.clearAll();
|
this.activeTerminalId = null;
|
||||||
this.lastServerInfoMessage = null;
|
this.lastServerInfoMessage = null;
|
||||||
this.updateConnectionState(
|
this.updateConnectionState(
|
||||||
{ status: "disposed" },
|
{ status: "disposed" },
|
||||||
@@ -800,7 +784,7 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendBinaryFrame(frame: BinaryMuxFrame): void {
|
private sendBinaryFrame(frame: Uint8Array): void {
|
||||||
if (!this.transport || this.connectionState.status !== "connected") {
|
if (!this.transport || this.connectionState.status !== "connected") {
|
||||||
if (this.config.suppressSendErrors) {
|
if (this.config.suppressSendErrors) {
|
||||||
return;
|
return;
|
||||||
@@ -808,7 +792,7 @@ export class DaemonClient {
|
|||||||
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
|
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.transport.send(encodeBinaryMuxFrame(frame));
|
this.transport.send(frame);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.config.suppressSendErrors) {
|
if (this.config.suppressSendErrors) {
|
||||||
return;
|
return;
|
||||||
@@ -2622,16 +2606,23 @@ export class DaemonClient {
|
|||||||
terminalId,
|
terminalId,
|
||||||
requestId: resolvedRequestId,
|
requestId: resolvedRequestId,
|
||||||
});
|
});
|
||||||
return this.sendCorrelatedRequest({
|
const payload = await this.sendCorrelatedRequest({
|
||||||
requestId: resolvedRequestId,
|
requestId: resolvedRequestId,
|
||||||
message,
|
message,
|
||||||
responseType: "subscribe_terminal_response",
|
responseType: "subscribe_terminal_response",
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
options: { skipQueue: true },
|
options: { skipQueue: true },
|
||||||
});
|
});
|
||||||
|
if (!payload.error) {
|
||||||
|
this.activeTerminalId = terminalId;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
unsubscribeTerminal(terminalId: string): void {
|
unsubscribeTerminal(terminalId: string): void {
|
||||||
|
if (this.activeTerminalId === terminalId) {
|
||||||
|
this.activeTerminalId = null;
|
||||||
|
}
|
||||||
this.sendSessionMessage({
|
this.sendSessionMessage({
|
||||||
type: "unsubscribe_terminal_request",
|
type: "unsubscribe_terminal_request",
|
||||||
terminalId,
|
terminalId,
|
||||||
@@ -2639,6 +2630,29 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendTerminalInput(terminalId: string, message: TerminalInput["message"]): void {
|
sendTerminalInput(terminalId: string, message: TerminalInput["message"]): void {
|
||||||
|
if (this.activeTerminalId === terminalId) {
|
||||||
|
if (message.type === "input") {
|
||||||
|
this.sendBinaryFrame(
|
||||||
|
encodeTerminalStreamFrame({
|
||||||
|
opcode: TerminalStreamOpcode.Input,
|
||||||
|
payload: encodeUtf8String(message.data),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type === "resize") {
|
||||||
|
this.sendBinaryFrame(
|
||||||
|
encodeTerminalStreamFrame({
|
||||||
|
opcode: TerminalStreamOpcode.Resize,
|
||||||
|
payload: encodeTerminalResizePayload({
|
||||||
|
rows: message.rows,
|
||||||
|
cols: message.cols,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
this.sendSessionMessage({
|
this.sendSessionMessage({
|
||||||
type: "terminal_input",
|
type: "terminal_input",
|
||||||
terminalId,
|
terminalId,
|
||||||
@@ -2662,127 +2676,44 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async attachTerminalStream(
|
onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void {
|
||||||
terminalId: string,
|
this.terminalStreamListeners.add(handler);
|
||||||
options?: {
|
return () => {
|
||||||
resumeOffset?: number;
|
this.terminalStreamListeners.delete(handler);
|
||||||
rows?: number;
|
};
|
||||||
cols?: number;
|
|
||||||
},
|
|
||||||
requestId?: string,
|
|
||||||
): Promise<AttachTerminalStreamPayload> {
|
|
||||||
const resolvedRequestId = this.createRequestId(requestId);
|
|
||||||
const message = SessionInboundMessageSchema.parse({
|
|
||||||
type: "attach_terminal_stream_request",
|
|
||||||
terminalId,
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
...(options?.resumeOffset !== undefined ? { resumeOffset: options.resumeOffset } : {}),
|
|
||||||
...(options?.rows !== undefined ? { rows: options.rows } : {}),
|
|
||||||
...(options?.cols !== undefined ? { cols: options.cols } : {}),
|
|
||||||
});
|
|
||||||
return this.sendCorrelatedRequest({
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
message,
|
|
||||||
responseType: "attach_terminal_stream_response",
|
|
||||||
timeout: 10000,
|
|
||||||
options: { skipQueue: true },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async detachTerminalStream(
|
async waitForTerminalStreamEvent(
|
||||||
streamId: number,
|
predicate: (event: TerminalStreamEvent) => boolean,
|
||||||
requestId?: string,
|
|
||||||
): Promise<DetachTerminalStreamPayload> {
|
|
||||||
const resolvedRequestId = this.createRequestId(requestId);
|
|
||||||
const message = SessionInboundMessageSchema.parse({
|
|
||||||
type: "detach_terminal_stream_request",
|
|
||||||
streamId,
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
});
|
|
||||||
const payload = await this.sendCorrelatedRequest({
|
|
||||||
requestId: resolvedRequestId,
|
|
||||||
message,
|
|
||||||
responseType: "detach_terminal_stream_response",
|
|
||||||
timeout: 10000,
|
|
||||||
options: { skipQueue: true },
|
|
||||||
});
|
|
||||||
this.terminalStreams.clearStream({ streamId });
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
onTerminalStreamData(
|
|
||||||
streamId: number,
|
|
||||||
handler: (chunk: TerminalStreamChunk) => void,
|
|
||||||
): () => void {
|
|
||||||
return this.terminalStreams.subscribe({ streamId, handler });
|
|
||||||
}
|
|
||||||
|
|
||||||
async waitForTerminalStreamData(
|
|
||||||
streamId: number,
|
|
||||||
predicate: (chunk: TerminalStreamChunk) => boolean,
|
|
||||||
timeout = 5000,
|
timeout = 5000,
|
||||||
): Promise<TerminalStreamChunk> {
|
): Promise<TerminalStreamEvent> {
|
||||||
return new Promise<TerminalStreamChunk>((resolve, reject) => {
|
return new Promise<TerminalStreamEvent>((resolve, reject) => {
|
||||||
const timeoutHandle = setTimeout(() => {
|
const timeoutHandle = setTimeout(() => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
reject(new Error(`Timeout waiting for terminal stream data (${timeout}ms)`));
|
reject(new Error(`Timeout waiting for terminal stream event (${timeout}ms)`));
|
||||||
}, timeout);
|
}, timeout);
|
||||||
|
|
||||||
const unsubscribe = this.onTerminalStreamData(streamId, (chunk) => {
|
const unsubscribe = this.onTerminalStreamEvent((event) => {
|
||||||
if (!predicate(chunk)) {
|
if (!predicate(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearTimeout(timeoutHandle);
|
clearTimeout(timeoutHandle);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
resolve(chunk);
|
resolve(event);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sendTerminalStreamInput(streamId: number, data: string | Uint8Array): void {
|
sendTerminalStreamKey(input: TerminalKeyInput): void {
|
||||||
const payload = typeof data === "string" ? encodeUtf8String(data) : data;
|
|
||||||
this.sendBinaryFrame({
|
|
||||||
channel: BinaryMuxChannel.Terminal,
|
|
||||||
messageType: TerminalBinaryMessageType.InputUtf8,
|
|
||||||
streamId,
|
|
||||||
offset: 0,
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
sendTerminalStreamKey(streamId: number, input: TerminalKeyInput): void {
|
|
||||||
const encoded = encodeTerminalKeyInput(input);
|
const encoded = encodeTerminalKeyInput(input);
|
||||||
if (!encoded) {
|
if (!encoded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.sendTerminalStreamInput(streamId, encoded);
|
this.sendBinaryFrame(
|
||||||
}
|
encodeTerminalStreamFrame({
|
||||||
|
opcode: TerminalStreamOpcode.Input,
|
||||||
sendTerminalStreamAck(streamId: number, offset: number): void {
|
payload: encodeUtf8String(encoded),
|
||||||
const normalizedOffset = Math.max(0, Math.floor(offset));
|
}),
|
||||||
this.terminalStreams.noteAck({ streamId, offset: normalizedOffset });
|
|
||||||
this.sendBinaryFrame({
|
|
||||||
channel: BinaryMuxChannel.Terminal,
|
|
||||||
messageType: TerminalBinaryMessageType.Ack,
|
|
||||||
streamId,
|
|
||||||
offset: normalizedOffset,
|
|
||||||
payload: new Uint8Array(0),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async waitForTerminalOutput(terminalId: string, timeout = 5000): Promise<TerminalOutputPayload> {
|
|
||||||
return this.waitFor(
|
|
||||||
(msg) => {
|
|
||||||
if (msg.type !== "terminal_output") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (msg.payload.terminalId !== terminalId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return msg.payload;
|
|
||||||
},
|
|
||||||
timeout,
|
|
||||||
{ skipQueue: true },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2890,7 +2821,7 @@ export class DaemonClient {
|
|||||||
|
|
||||||
const rawBytes = asUint8Array(rawData);
|
const rawBytes = asUint8Array(rawData);
|
||||||
if (rawBytes) {
|
if (rawBytes) {
|
||||||
const frame = decodeBinaryMuxFrame(rawBytes);
|
const frame = decodeTerminalStreamFrame(rawBytes);
|
||||||
if (frame) {
|
if (frame) {
|
||||||
this.handleBinaryFrame(frame);
|
this.handleBinaryFrame(frame);
|
||||||
return;
|
return;
|
||||||
@@ -2922,21 +2853,42 @@ export class DaemonClient {
|
|||||||
this.handleSessionMessage(parsed.data.message);
|
this.handleSessionMessage(parsed.data.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleBinaryFrame(frame: BinaryMuxFrame): void {
|
private handleBinaryFrame(frame: TerminalStreamFrame): void {
|
||||||
if (
|
const terminalId = this.activeTerminalId;
|
||||||
frame.channel === BinaryMuxChannel.Terminal &&
|
if (!terminalId) {
|
||||||
frame.messageType === TerminalBinaryMessageType.OutputUtf8
|
|
||||||
) {
|
|
||||||
const chunk: TerminalStreamChunk = {
|
|
||||||
streamId: frame.streamId,
|
|
||||||
offset: frame.offset,
|
|
||||||
endOffset: frame.offset + (frame.payload?.byteLength ?? 0),
|
|
||||||
replay: Boolean((frame.flags ?? 0) & TerminalBinaryFlags.Replay),
|
|
||||||
data: frame.payload ?? new Uint8Array(0),
|
|
||||||
};
|
|
||||||
this.terminalStreams.receiveChunk({ chunk });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (frame.opcode === TerminalStreamOpcode.Output) {
|
||||||
|
this.emitTerminalStreamEvent({
|
||||||
|
terminalId,
|
||||||
|
type: "output",
|
||||||
|
data: frame.payload,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.opcode === TerminalStreamOpcode.Snapshot) {
|
||||||
|
const state = decodeTerminalSnapshotPayload(frame.payload);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emitTerminalStreamEvent({
|
||||||
|
terminalId,
|
||||||
|
type: "snapshot",
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitTerminalStreamEvent(event: TerminalStreamEvent): void {
|
||||||
|
for (const listener of this.terminalStreamListeners) {
|
||||||
|
try {
|
||||||
|
listener(event);
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateConnectionState(
|
private updateConnectionState(
|
||||||
@@ -2996,7 +2948,7 @@ export class DaemonClient {
|
|||||||
// and responses from the previous connection will never arrive.
|
// and responses from the previous connection will never arrive.
|
||||||
this.clearWaiters(new Error(reason ?? "Connection lost"));
|
this.clearWaiters(new Error(reason ?? "Connection lost"));
|
||||||
this.rejectPendingSendQueue(new Error(reason ?? "Connection lost"));
|
this.rejectPendingSendQueue(new Error(reason ?? "Connection lost"));
|
||||||
this.terminalStreams.clearAll();
|
this.activeTerminalId = null;
|
||||||
this.lastServerInfoMessage = null;
|
this.lastServerInfoMessage = null;
|
||||||
|
|
||||||
if (wasDisposed) {
|
if (wasDisposed) {
|
||||||
@@ -3051,7 +3003,9 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === "terminal_stream_exit") {
|
if (msg.type === "terminal_stream_exit") {
|
||||||
this.terminalStreams.clearStream({ streamId: msg.payload.streamId });
|
if (this.activeTerminalId === msg.payload.terminalId) {
|
||||||
|
this.activeTerminalId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.rawMessageListeners.size > 0) {
|
if (this.rawMessageListeners.size > 0) {
|
||||||
@@ -3153,14 +3107,6 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitFor<T>(
|
|
||||||
predicate: (msg: SessionOutboundMessage) => T | null,
|
|
||||||
timeout = 30000,
|
|
||||||
_options?: { skipQueue?: boolean },
|
|
||||||
): Promise<T> {
|
|
||||||
return this.waitForWithCancel(predicate, timeout, _options).promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private waitForWithCancel<T>(
|
private waitForWithCancel<T>(
|
||||||
predicate: (msg: SessionOutboundMessage) => T | null,
|
predicate: (msg: SessionOutboundMessage) => T | null,
|
||||||
timeout = 30000,
|
timeout = 30000,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,6 @@ import {
|
|||||||
type UnsubscribeTerminalRequest,
|
type UnsubscribeTerminalRequest,
|
||||||
type TerminalInput,
|
type TerminalInput,
|
||||||
type KillTerminalRequest,
|
type KillTerminalRequest,
|
||||||
type AttachTerminalStreamRequest,
|
|
||||||
type DetachTerminalStreamRequest,
|
|
||||||
type SubscribeCheckoutDiffRequest,
|
type SubscribeCheckoutDiffRequest,
|
||||||
type UnsubscribeCheckoutDiffRequest,
|
type UnsubscribeCheckoutDiffRequest,
|
||||||
type DirectorySuggestionsRequest,
|
type DirectorySuggestionsRequest,
|
||||||
@@ -35,11 +33,12 @@ import {
|
|||||||
import type { TerminalManager, TerminalsChangedEvent } from "../terminal/terminal-manager.js";
|
import type { TerminalManager, TerminalsChangedEvent } from "../terminal/terminal-manager.js";
|
||||||
import type { TerminalSession } from "../terminal/terminal.js";
|
import type { TerminalSession } from "../terminal/terminal.js";
|
||||||
import {
|
import {
|
||||||
BinaryMuxChannel,
|
TerminalStreamOpcode,
|
||||||
TerminalBinaryFlags,
|
encodeTerminalSnapshotPayload,
|
||||||
TerminalBinaryMessageType,
|
encodeTerminalStreamFrame,
|
||||||
type BinaryMuxFrame,
|
decodeTerminalResizePayload,
|
||||||
} from "../shared/binary-mux.js";
|
type TerminalStreamFrame,
|
||||||
|
} from "../shared/terminal-stream-protocol.js";
|
||||||
import { TTSManager } from "./agent/tts-manager.js";
|
import { TTSManager } from "./agent/tts-manager.js";
|
||||||
import { STTManager } from "./agent/stt-manager.js";
|
import { STTManager } from "./agent/stt-manager.js";
|
||||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||||
@@ -191,9 +190,8 @@ const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
|
|||||||
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
|
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
|
||||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
||||||
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
|
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
|
||||||
const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024;
|
const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
|
||||||
const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024;
|
const TERMINAL_STREAM_LOW_WATER_BYTES = 16 * 1024;
|
||||||
const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048;
|
|
||||||
|
|
||||||
function deriveInitialAgentTitle(prompt: string): string | null {
|
function deriveInitialAgentTitle(prompt: string): string | null {
|
||||||
const firstContentLine = prompt
|
const firstContentLine = prompt
|
||||||
@@ -277,11 +275,12 @@ type CheckoutErrorPayload = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TerminalStreamPendingChunk = {
|
type ActiveTerminalStream = {
|
||||||
data: string;
|
terminalId: string;
|
||||||
startOffset: number;
|
unsubscribe: () => void;
|
||||||
endOffset: number;
|
needsSnapshot: boolean;
|
||||||
replay: boolean;
|
primed: boolean;
|
||||||
|
snapshotTimer: ReturnType<typeof setTimeout> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionRuntimeMetrics = {
|
export type SessionRuntimeMetrics = {
|
||||||
@@ -395,7 +394,8 @@ type VoiceTranscriptionResultPayload = {
|
|||||||
export type SessionOptions = {
|
export type SessionOptions = {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
onMessage: (msg: SessionOutboundMessage) => void;
|
onMessage: (msg: SessionOutboundMessage) => void;
|
||||||
onBinaryMessage?: (frame: BinaryMuxFrame) => void;
|
onBinaryMessage?: (frame: Uint8Array) => void;
|
||||||
|
getBinaryBufferedAmount?: () => number;
|
||||||
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
|
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
downloadTokenStore: DownloadTokenStore;
|
downloadTokenStore: DownloadTokenStore;
|
||||||
@@ -546,7 +546,8 @@ export class Session {
|
|||||||
private readonly clientId: string;
|
private readonly clientId: string;
|
||||||
private readonly sessionId: string;
|
private readonly sessionId: string;
|
||||||
private readonly onMessage: (msg: SessionOutboundMessage) => void;
|
private readonly onMessage: (msg: SessionOutboundMessage) => void;
|
||||||
private readonly onBinaryMessage: ((frame: BinaryMuxFrame) => void) | null;
|
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
|
||||||
|
private readonly getBinaryBufferedAmount: (() => number) | null;
|
||||||
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
|
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
|
||||||
private readonly sessionLogger: pino.Logger;
|
private readonly sessionLogger: pino.Logger;
|
||||||
private readonly paseoHome: string;
|
private readonly paseoHome: string;
|
||||||
@@ -605,21 +606,8 @@ export class Session {
|
|||||||
private readonly terminalManager: TerminalManager | null;
|
private readonly terminalManager: TerminalManager | null;
|
||||||
private readonly subscribedTerminalDirectories = new Set<string>();
|
private readonly subscribedTerminalDirectories = new Set<string>();
|
||||||
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
||||||
private terminalSubscriptions: Map<string, () => void> = new Map();
|
|
||||||
private terminalExitSubscriptions: Map<string, () => void> = new Map();
|
private terminalExitSubscriptions: Map<string, () => void> = new Map();
|
||||||
private readonly terminalStreams = new Map<
|
private activeTerminalStream: ActiveTerminalStream | null = null;
|
||||||
number,
|
|
||||||
{
|
|
||||||
terminalId: string;
|
|
||||||
unsubscribe: () => void;
|
|
||||||
lastOutputOffset: number;
|
|
||||||
lastAckOffset: number;
|
|
||||||
pendingChunks: TerminalStreamPendingChunk[];
|
|
||||||
pendingBytes: number;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
private readonly terminalStreamByTerminalId = new Map<string, number>();
|
|
||||||
private nextTerminalStreamId = 1;
|
|
||||||
private readonly checkoutDiffSubscriptions = new Map<string, { targetKey: string }>();
|
private readonly checkoutDiffSubscriptions = new Map<string, { targetKey: string }>();
|
||||||
private readonly checkoutDiffTargets = new Map<string, CheckoutDiffWatchTarget>();
|
private readonly checkoutDiffTargets = new Map<string, CheckoutDiffWatchTarget>();
|
||||||
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
||||||
@@ -648,6 +636,7 @@ export class Session {
|
|||||||
clientId,
|
clientId,
|
||||||
onMessage,
|
onMessage,
|
||||||
onBinaryMessage,
|
onBinaryMessage,
|
||||||
|
getBinaryBufferedAmount,
|
||||||
onLifecycleIntent,
|
onLifecycleIntent,
|
||||||
logger,
|
logger,
|
||||||
downloadTokenStore,
|
downloadTokenStore,
|
||||||
@@ -670,6 +659,7 @@ export class Session {
|
|||||||
this.sessionId = uuidv4();
|
this.sessionId = uuidv4();
|
||||||
this.onMessage = onMessage;
|
this.onMessage = onMessage;
|
||||||
this.onBinaryMessage = onBinaryMessage ?? null;
|
this.onBinaryMessage = onBinaryMessage ?? null;
|
||||||
|
this.getBinaryBufferedAmount = getBinaryBufferedAmount ?? null;
|
||||||
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
||||||
this.downloadTokenStore = downloadTokenStore;
|
this.downloadTokenStore = downloadTokenStore;
|
||||||
this.pushTokenStore = pushTokenStore;
|
this.pushTokenStore = pushTokenStore;
|
||||||
@@ -761,8 +751,8 @@ export class Session {
|
|||||||
checkoutDiffWatcherCount,
|
checkoutDiffWatcherCount,
|
||||||
checkoutDiffFallbackRefreshTargetCount,
|
checkoutDiffFallbackRefreshTargetCount,
|
||||||
terminalDirectorySubscriptionCount: this.subscribedTerminalDirectories.size,
|
terminalDirectorySubscriptionCount: this.subscribedTerminalDirectories.size,
|
||||||
terminalSubscriptionCount: this.terminalSubscriptions.size,
|
terminalSubscriptionCount: this.activeTerminalStream ? 1 : 0,
|
||||||
terminalStreamCount: this.terminalStreams.size,
|
terminalStreamCount: this.activeTerminalStream ? 1 : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1764,13 +1754,6 @@ export class Session {
|
|||||||
await this.handleKillTerminalRequest(msg);
|
await this.handleKillTerminalRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "attach_terminal_stream_request":
|
|
||||||
await this.handleAttachTerminalStreamRequest(msg);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "detach_terminal_stream_request":
|
|
||||||
this.handleDetachTerminalStreamRequest(msg);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
@@ -1805,73 +1788,43 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleBinaryFrame(frame: BinaryMuxFrame): void {
|
public handleBinaryFrame(frame: TerminalStreamFrame): void {
|
||||||
switch (frame.channel) {
|
const activeStream = this.activeTerminalStream;
|
||||||
case BinaryMuxChannel.Terminal:
|
if (!activeStream || !this.terminalManager) {
|
||||||
this.handleTerminalBinaryFrame(frame);
|
return;
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.sessionLogger.warn(
|
|
||||||
{ channel: frame.channel, messageType: frame.messageType },
|
|
||||||
"Unhandled binary mux channel",
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
const terminal = this.terminalManager.getTerminal(activeStream.terminalId);
|
||||||
|
if (!terminal) {
|
||||||
private handleTerminalBinaryFrame(frame: BinaryMuxFrame): void {
|
this.detachActiveTerminalStream({ emitExit: true });
|
||||||
if (frame.messageType === TerminalBinaryMessageType.InputUtf8) {
|
|
||||||
const binding = this.terminalStreams.get(frame.streamId);
|
|
||||||
if (!binding) {
|
|
||||||
this.sessionLogger.warn(
|
|
||||||
{ streamId: frame.streamId },
|
|
||||||
"Terminal stream not found for input",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.terminalManager) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const session = this.terminalManager.getTerminal(binding.terminalId);
|
|
||||||
if (!session) {
|
|
||||||
this.detachTerminalStream(frame.streamId, { emitExit: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = frame.payload ?? new Uint8Array(0);
|
|
||||||
if (payload.byteLength === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const text = Buffer.from(payload).toString("utf8");
|
|
||||||
if (!text) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
session.send({ type: "input", data: text });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frame.messageType === TerminalBinaryMessageType.Ack) {
|
switch (frame.opcode) {
|
||||||
const binding = this.terminalStreams.get(frame.streamId);
|
case TerminalStreamOpcode.Input: {
|
||||||
if (binding) {
|
if (frame.payload.byteLength === 0) {
|
||||||
if (!Number.isFinite(frame.offset) || frame.offset < 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextAckOffset = Math.max(
|
const text = Buffer.from(frame.payload).toString("utf8");
|
||||||
binding.lastAckOffset,
|
if (!text) {
|
||||||
Math.min(Math.floor(frame.offset), binding.lastOutputOffset),
|
return;
|
||||||
);
|
|
||||||
if (nextAckOffset > binding.lastAckOffset) {
|
|
||||||
binding.lastAckOffset = nextAckOffset;
|
|
||||||
this.flushPendingTerminalStreamChunks(frame.streamId, binding);
|
|
||||||
}
|
}
|
||||||
|
terminal.send({ type: "input", data: text });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sessionLogger.warn(
|
case TerminalStreamOpcode.Resize: {
|
||||||
{ streamId: frame.streamId, messageType: frame.messageType },
|
const resize = decodeTerminalResizePayload(frame.payload);
|
||||||
"Unhandled terminal binary frame",
|
if (!resize) {
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
terminal.send({ type: "resize", rows: resize.rows, cols: resize.cols });
|
||||||
|
this.queueTerminalSnapshot(activeStream, terminal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleRestartServerRequest(requestId: string, reason?: string): Promise<void> {
|
private async handleRestartServerRequest(requestId: string, reason?: string): Promise<void> {
|
||||||
@@ -2032,7 +1985,21 @@ export class Session {
|
|||||||
attentionTimestamp: null,
|
attentionTimestamp: null,
|
||||||
};
|
};
|
||||||
await this.agentStorage.upsert(nextRecord);
|
await this.agentStorage.upsert(nextRecord);
|
||||||
this.agentManager.notifyAgentState(agentId);
|
|
||||||
|
// Unload the agent from memory — the storage record is the source of truth now.
|
||||||
|
// This tears down the provider session and drops the hydrated timeline,
|
||||||
|
// freeing memory. ensureAgentLoaded will re-initialize if needed later.
|
||||||
|
if (this.agentManager.getAgent(agentId)) {
|
||||||
|
try {
|
||||||
|
await this.agentManager.closeAgent(agentId);
|
||||||
|
} catch (error) {
|
||||||
|
this.sessionLogger.warn(
|
||||||
|
{ err: error, agentId },
|
||||||
|
"Failed to close agent during archive",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { archivedAt, archivedRecord: nextRecord };
|
return { archivedAt, archivedRecord: nextRecord };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7447,7 +7414,7 @@ export class Session {
|
|||||||
this.onMessage(msg);
|
this.onMessage(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitBinary(frame: BinaryMuxFrame): void {
|
private emitBinary(frame: Uint8Array): void {
|
||||||
if (!this.onBinaryMessage) {
|
if (!this.onBinaryMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -7506,15 +7473,11 @@ export class Session {
|
|||||||
}
|
}
|
||||||
this.subscribedTerminalDirectories.clear();
|
this.subscribedTerminalDirectories.clear();
|
||||||
|
|
||||||
for (const unsubscribe of this.terminalSubscriptions.values()) {
|
|
||||||
unsubscribe();
|
|
||||||
}
|
|
||||||
this.terminalSubscriptions.clear();
|
|
||||||
for (const unsubscribeExit of this.terminalExitSubscriptions.values()) {
|
for (const unsubscribeExit of this.terminalExitSubscriptions.values()) {
|
||||||
unsubscribeExit();
|
unsubscribeExit();
|
||||||
}
|
}
|
||||||
this.terminalExitSubscriptions.clear();
|
this.terminalExitSubscriptions.clear();
|
||||||
this.detachAllTerminalStreams({ emitExit: false });
|
this.detachActiveTerminalStream({ emitExit: false });
|
||||||
|
|
||||||
for (const target of this.checkoutDiffTargets.values()) {
|
for (const target of this.checkoutDiffTargets.values()) {
|
||||||
this.closeCheckoutDiffWatchTarget(target);
|
this.closeCheckoutDiffWatchTarget(target);
|
||||||
@@ -7550,22 +7513,8 @@ export class Session {
|
|||||||
this.terminalExitSubscriptions.delete(terminalId);
|
this.terminalExitSubscriptions.delete(terminalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribe = this.terminalSubscriptions.get(terminalId);
|
if (this.activeTerminalStream?.terminalId === terminalId) {
|
||||||
if (unsubscribe) {
|
this.detachActiveTerminalStream({ emitExit: true });
|
||||||
try {
|
|
||||||
unsubscribe();
|
|
||||||
} catch (error) {
|
|
||||||
this.sessionLogger.warn(
|
|
||||||
{ err: error, terminalId },
|
|
||||||
"Failed to unsubscribe terminal after process exit",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.terminalSubscriptions.delete(terminalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamId = this.terminalStreamByTerminalId.get(terminalId);
|
|
||||||
if (typeof streamId === "number") {
|
|
||||||
this.detachTerminalStream(streamId, { emitExit: true });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7740,25 +7689,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
this.ensureTerminalExitSubscription(session);
|
this.ensureTerminalExitSubscription(session);
|
||||||
|
|
||||||
// Unsubscribe from previous subscription if any
|
const activeStream = this.bindActiveTerminalStream(session);
|
||||||
const existing = this.terminalSubscriptions.get(msg.terminalId);
|
|
||||||
if (existing) {
|
|
||||||
existing();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to terminal updates
|
|
||||||
const unsubscribe = session.subscribe((serverMsg) => {
|
|
||||||
if (serverMsg.type === "full") {
|
|
||||||
this.emit({
|
|
||||||
type: "terminal_output",
|
|
||||||
payload: {
|
|
||||||
terminalId: msg.terminalId,
|
|
||||||
state: serverMsg.state,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.terminalSubscriptions.set(msg.terminalId, unsubscribe);
|
|
||||||
|
|
||||||
// Send initial state
|
// Send initial state
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -7770,13 +7701,16 @@ export class Session {
|
|||||||
requestId: msg.requestId,
|
requestId: msg.requestId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (activeStream) {
|
||||||
|
this.sendTerminalSnapshot(activeStream, session);
|
||||||
|
activeStream.primed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void {
|
private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void {
|
||||||
const unsubscribe = this.terminalSubscriptions.get(msg.terminalId);
|
if (this.activeTerminalStream?.terminalId === msg.terminalId) {
|
||||||
if (unsubscribe) {
|
this.detachActiveTerminalStream({ emitExit: false });
|
||||||
unsubscribe();
|
|
||||||
this.terminalSubscriptions.delete(msg.terminalId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7796,15 +7730,8 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private killTrackedTerminal(terminalId: string, options?: { emitExit: boolean }): void {
|
private killTrackedTerminal(terminalId: string, options?: { emitExit: boolean }): void {
|
||||||
const unsubscribe = this.terminalSubscriptions.get(terminalId);
|
if (this.activeTerminalStream?.terminalId === terminalId) {
|
||||||
if (unsubscribe) {
|
this.detachActiveTerminalStream({ emitExit: options?.emitExit ?? true });
|
||||||
unsubscribe();
|
|
||||||
this.terminalSubscriptions.delete(terminalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamId = this.terminalStreamByTerminalId.get(terminalId);
|
|
||||||
if (typeof streamId === "number") {
|
|
||||||
this.detachTerminalStream(streamId, { emitExit: options?.emitExit ?? true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.terminalManager?.killTerminal(terminalId);
|
this.terminalManager?.killTerminal(terminalId);
|
||||||
@@ -7867,277 +7794,156 @@ export class Session {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleAttachTerminalStreamRequest(msg: AttachTerminalStreamRequest): Promise<void> {
|
private bindActiveTerminalStream(terminal: TerminalSession): ActiveTerminalStream | null {
|
||||||
if (!this.terminalManager || !this.onBinaryMessage) {
|
this.detachActiveTerminalStream({ emitExit: false });
|
||||||
this.emit({
|
if (!this.onBinaryMessage) {
|
||||||
type: "attach_terminal_stream_response",
|
return null;
|
||||||
payload: {
|
|
||||||
terminalId: msg.terminalId,
|
|
||||||
streamId: null,
|
|
||||||
replayedFrom: 0,
|
|
||||||
currentOffset: 0,
|
|
||||||
earliestAvailableOffset: 0,
|
|
||||||
reset: true,
|
|
||||||
error: "Terminal streaming not available",
|
|
||||||
requestId: msg.requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = this.terminalManager.getTerminal(msg.terminalId);
|
const activeStream: ActiveTerminalStream = {
|
||||||
if (!session) {
|
terminalId: terminal.id,
|
||||||
this.emit({
|
|
||||||
type: "attach_terminal_stream_response",
|
|
||||||
payload: {
|
|
||||||
terminalId: msg.terminalId,
|
|
||||||
streamId: null,
|
|
||||||
replayedFrom: 0,
|
|
||||||
currentOffset: 0,
|
|
||||||
earliestAvailableOffset: 0,
|
|
||||||
reset: true,
|
|
||||||
error: "Terminal not found",
|
|
||||||
requestId: msg.requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.rows || msg.cols) {
|
|
||||||
const state = session.getState();
|
|
||||||
session.send({
|
|
||||||
type: "resize",
|
|
||||||
rows: msg.rows ?? state.rows,
|
|
||||||
cols: msg.cols ?? state.cols,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingStreamId = this.terminalStreamByTerminalId.get(msg.terminalId);
|
|
||||||
if (typeof existingStreamId === "number") {
|
|
||||||
// Replacing an active stream can happen when multiple UI surfaces attach to the
|
|
||||||
// same terminal. Emit exit for the replaced stream so stale listeners reconnect
|
|
||||||
// instead of continuing to send input to an invalid stream id.
|
|
||||||
this.detachTerminalStream(existingStreamId, { emitExit: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamId = this.allocateTerminalStreamId();
|
|
||||||
const requestedResumeOffset = typeof msg.resumeOffset === "number" ? msg.resumeOffset : 0;
|
|
||||||
const initialOffset = Math.max(0, Math.floor(requestedResumeOffset));
|
|
||||||
const binding: {
|
|
||||||
terminalId: string;
|
|
||||||
unsubscribe: () => void;
|
|
||||||
lastOutputOffset: number;
|
|
||||||
lastAckOffset: number;
|
|
||||||
pendingChunks: TerminalStreamPendingChunk[];
|
|
||||||
pendingBytes: number;
|
|
||||||
} = {
|
|
||||||
terminalId: msg.terminalId,
|
|
||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
lastOutputOffset: initialOffset,
|
needsSnapshot: false,
|
||||||
lastAckOffset: initialOffset,
|
primed: false,
|
||||||
pendingChunks: [],
|
snapshotTimer: null,
|
||||||
pendingBytes: 0,
|
|
||||||
};
|
};
|
||||||
this.terminalStreams.set(streamId, binding);
|
activeStream.unsubscribe = terminal.subscribe((message) => {
|
||||||
this.terminalStreamByTerminalId.set(msg.terminalId, streamId);
|
if (this.activeTerminalStream !== activeStream) {
|
||||||
|
|
||||||
let rawSub;
|
|
||||||
try {
|
|
||||||
rawSub = session.subscribeRaw(
|
|
||||||
(chunk) => {
|
|
||||||
const currentBinding = this.terminalStreams.get(streamId);
|
|
||||||
if (!currentBinding) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.enqueueOrEmitTerminalStreamChunk(streamId, currentBinding, {
|
|
||||||
data: chunk.data,
|
|
||||||
startOffset: chunk.startOffset,
|
|
||||||
endOffset: chunk.endOffset,
|
|
||||||
replay: chunk.replay,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ fromOffset: requestedResumeOffset },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.terminalStreams.delete(streamId);
|
|
||||||
this.terminalStreamByTerminalId.delete(msg.terminalId);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.unsubscribe = rawSub.unsubscribe;
|
|
||||||
binding.lastAckOffset = rawSub.replayedFrom;
|
|
||||||
if (binding.lastOutputOffset < rawSub.replayedFrom) {
|
|
||||||
binding.lastOutputOffset = rawSub.replayedFrom;
|
|
||||||
}
|
|
||||||
this.flushPendingTerminalStreamChunks(streamId, binding);
|
|
||||||
|
|
||||||
this.emit({
|
|
||||||
type: "attach_terminal_stream_response",
|
|
||||||
payload: {
|
|
||||||
terminalId: msg.terminalId,
|
|
||||||
streamId,
|
|
||||||
replayedFrom: rawSub.replayedFrom,
|
|
||||||
currentOffset: rawSub.currentOffset,
|
|
||||||
earliestAvailableOffset: rawSub.earliestAvailableOffset,
|
|
||||||
reset: rawSub.reset,
|
|
||||||
error: null,
|
|
||||||
requestId: msg.requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTerminalStreamChunkByteLength(chunk: TerminalStreamPendingChunk): number {
|
|
||||||
return Math.max(0, chunk.endOffset - chunk.startOffset);
|
|
||||||
}
|
|
||||||
|
|
||||||
private canEmitTerminalStreamChunk(
|
|
||||||
binding: {
|
|
||||||
lastAckOffset: number;
|
|
||||||
},
|
|
||||||
chunk: TerminalStreamPendingChunk,
|
|
||||||
): boolean {
|
|
||||||
return chunk.startOffset < binding.lastAckOffset + TERMINAL_STREAM_WINDOW_BYTES;
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitTerminalStreamChunk(
|
|
||||||
streamId: number,
|
|
||||||
binding: {
|
|
||||||
lastOutputOffset: number;
|
|
||||||
},
|
|
||||||
chunk: TerminalStreamPendingChunk,
|
|
||||||
): void {
|
|
||||||
const payload = new Uint8Array(Buffer.from(chunk.data, "utf8"));
|
|
||||||
this.emitBinary({
|
|
||||||
channel: BinaryMuxChannel.Terminal,
|
|
||||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
|
||||||
streamId,
|
|
||||||
offset: chunk.startOffset,
|
|
||||||
flags: chunk.replay ? TerminalBinaryFlags.Replay : 0,
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
binding.lastOutputOffset = chunk.endOffset;
|
|
||||||
}
|
|
||||||
|
|
||||||
private enqueueOrEmitTerminalStreamChunk(
|
|
||||||
streamId: number,
|
|
||||||
binding: {
|
|
||||||
lastAckOffset: number;
|
|
||||||
lastOutputOffset: number;
|
|
||||||
pendingChunks: TerminalStreamPendingChunk[];
|
|
||||||
pendingBytes: number;
|
|
||||||
},
|
|
||||||
chunk: TerminalStreamPendingChunk,
|
|
||||||
): void {
|
|
||||||
const chunkBytes = this.getTerminalStreamChunkByteLength(chunk);
|
|
||||||
|
|
||||||
if (binding.pendingChunks.length > 0 || !this.canEmitTerminalStreamChunk(binding, chunk)) {
|
|
||||||
if (
|
|
||||||
binding.pendingChunks.length >= TERMINAL_STREAM_MAX_PENDING_CHUNKS ||
|
|
||||||
binding.pendingBytes + chunkBytes > TERMINAL_STREAM_MAX_PENDING_BYTES
|
|
||||||
) {
|
|
||||||
this.sessionLogger.warn(
|
|
||||||
{
|
|
||||||
streamId,
|
|
||||||
pendingChunks: binding.pendingChunks.length,
|
|
||||||
pendingBytes: binding.pendingBytes,
|
|
||||||
chunkBytes,
|
|
||||||
},
|
|
||||||
"Terminal stream pending buffer overflow; closing stream",
|
|
||||||
);
|
|
||||||
this.detachTerminalStream(streamId, { emitExit: true });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
binding.pendingChunks.push(chunk);
|
if (message.type === "output") {
|
||||||
binding.pendingBytes += chunkBytes;
|
this.handleActiveTerminalOutput(activeStream, terminal, message.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type === "snapshot") {
|
||||||
|
this.maybeSendQueuedTerminalSnapshot(activeStream, terminal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.activeTerminalStream = activeStream;
|
||||||
|
return activeStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleActiveTerminalOutput(
|
||||||
|
activeStream: ActiveTerminalStream,
|
||||||
|
terminal: TerminalSession,
|
||||||
|
data: string,
|
||||||
|
): void {
|
||||||
|
if (!activeStream.primed || data.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (activeStream.needsSnapshot) {
|
||||||
this.emitTerminalStreamChunk(streamId, binding, chunk);
|
this.maybeSendQueuedTerminalSnapshot(activeStream, terminal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_HIGH_WATER_BYTES) {
|
||||||
|
activeStream.needsSnapshot = true;
|
||||||
|
this.scheduleTerminalSnapshotRetry(activeStream, terminal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emitBinary(
|
||||||
|
encodeTerminalStreamFrame({
|
||||||
|
opcode: TerminalStreamOpcode.Output,
|
||||||
|
payload: new Uint8Array(Buffer.from(data, "utf8")),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_HIGH_WATER_BYTES) {
|
||||||
|
activeStream.needsSnapshot = true;
|
||||||
|
this.scheduleTerminalSnapshotRetry(activeStream, terminal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private flushPendingTerminalStreamChunks(
|
private queueTerminalSnapshot(
|
||||||
streamId: number,
|
activeStream: ActiveTerminalStream,
|
||||||
binding: {
|
terminal: TerminalSession,
|
||||||
lastAckOffset: number;
|
|
||||||
lastOutputOffset: number;
|
|
||||||
pendingChunks: TerminalStreamPendingChunk[];
|
|
||||||
pendingBytes: number;
|
|
||||||
},
|
|
||||||
): void {
|
): void {
|
||||||
while (binding.pendingChunks.length > 0) {
|
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_HIGH_WATER_BYTES) {
|
||||||
const next = binding.pendingChunks[0];
|
activeStream.needsSnapshot = true;
|
||||||
if (!next || !this.canEmitTerminalStreamChunk(binding, next)) {
|
this.scheduleTerminalSnapshotRetry(activeStream, terminal);
|
||||||
break;
|
return;
|
||||||
}
|
|
||||||
binding.pendingChunks.shift();
|
|
||||||
binding.pendingBytes -= this.getTerminalStreamChunkByteLength(next);
|
|
||||||
if (binding.pendingBytes < 0) {
|
|
||||||
binding.pendingBytes = 0;
|
|
||||||
}
|
|
||||||
this.emitTerminalStreamChunk(streamId, binding, next);
|
|
||||||
}
|
}
|
||||||
|
this.sendTerminalSnapshot(activeStream, terminal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleDetachTerminalStreamRequest(msg: DetachTerminalStreamRequest): void {
|
private maybeSendQueuedTerminalSnapshot(
|
||||||
const success = this.detachTerminalStream(msg.streamId, { emitExit: false });
|
activeStream: ActiveTerminalStream,
|
||||||
this.emit({
|
terminal: TerminalSession,
|
||||||
type: "detach_terminal_stream_response",
|
): void {
|
||||||
payload: {
|
if (!activeStream.needsSnapshot) {
|
||||||
streamId: msg.streamId,
|
return;
|
||||||
success,
|
|
||||||
requestId: msg.requestId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private detachAllTerminalStreams(options?: { emitExit: boolean }): void {
|
|
||||||
for (const streamId of Array.from(this.terminalStreams.keys())) {
|
|
||||||
this.detachTerminalStream(streamId, options);
|
|
||||||
}
|
}
|
||||||
|
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_LOW_WATER_BYTES) {
|
||||||
|
this.scheduleTerminalSnapshotRetry(activeStream, terminal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sendTerminalSnapshot(activeStream, terminal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private detachTerminalStream(streamId: number, options?: { emitExit: boolean }): boolean {
|
private sendTerminalSnapshot(
|
||||||
const binding = this.terminalStreams.get(streamId);
|
activeStream: ActiveTerminalStream,
|
||||||
if (!binding) {
|
terminal: TerminalSession,
|
||||||
|
): void {
|
||||||
|
this.clearTerminalSnapshotRetry(activeStream);
|
||||||
|
activeStream.needsSnapshot = false;
|
||||||
|
this.emitBinary(
|
||||||
|
encodeTerminalStreamFrame({
|
||||||
|
opcode: TerminalStreamOpcode.Snapshot,
|
||||||
|
payload: encodeTerminalSnapshotPayload(terminal.getState()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleTerminalSnapshotRetry(
|
||||||
|
activeStream: ActiveTerminalStream,
|
||||||
|
terminal: TerminalSession,
|
||||||
|
): void {
|
||||||
|
if (activeStream.snapshotTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeStream.snapshotTimer = setTimeout(() => {
|
||||||
|
activeStream.snapshotTimer = null;
|
||||||
|
if (this.activeTerminalStream !== activeStream || !activeStream.needsSnapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.maybeSendQueuedTerminalSnapshot(activeStream, terminal);
|
||||||
|
}, 33);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTerminalSnapshotRetry(activeStream: ActiveTerminalStream): void {
|
||||||
|
if (!activeStream.snapshotTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(activeStream.snapshotTimer);
|
||||||
|
activeStream.snapshotTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachActiveTerminalStream(options?: { emitExit: boolean }): boolean {
|
||||||
|
const activeStream = this.activeTerminalStream;
|
||||||
|
if (!activeStream) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
this.activeTerminalStream = null;
|
||||||
|
this.clearTerminalSnapshotRetry(activeStream);
|
||||||
try {
|
try {
|
||||||
binding.unsubscribe();
|
activeStream.unsubscribe();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.sessionLogger.warn({ err: error, streamId }, "Failed to unsubscribe terminal stream");
|
this.sessionLogger.warn({ err: error }, "Failed to unsubscribe terminal stream");
|
||||||
}
|
}
|
||||||
this.terminalStreams.delete(streamId);
|
|
||||||
if (this.terminalStreamByTerminalId.get(binding.terminalId) === streamId) {
|
|
||||||
this.terminalStreamByTerminalId.delete(binding.terminalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.emitExit) {
|
if (options?.emitExit) {
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "terminal_stream_exit",
|
type: "terminal_stream_exit",
|
||||||
payload: {
|
payload: {
|
||||||
streamId,
|
terminalId: activeStream.terminalId,
|
||||||
terminalId: binding.terminalId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private allocateTerminalStreamId(): number {
|
private getCurrentBinaryBufferedAmount(): number {
|
||||||
let attempts = 0;
|
const bufferedAmount = this.getBinaryBufferedAmount?.() ?? 0;
|
||||||
while (attempts < 0xffffffff) {
|
if (!Number.isFinite(bufferedAmount) || bufferedAmount < 0) {
|
||||||
const candidate = this.nextTerminalStreamId >>> 0;
|
return 0;
|
||||||
this.nextTerminalStreamId = ((this.nextTerminalStreamId + 1) & 0xffffffff) >>> 0;
|
|
||||||
if (candidate === 0) {
|
|
||||||
attempts += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!this.terminalStreams.has(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
attempts += 1;
|
|
||||||
}
|
}
|
||||||
throw new Error("Unable to allocate terminal stream id");
|
return Math.floor(bufferedAmount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
BinaryMuxChannel,
|
|
||||||
TerminalBinaryMessageType,
|
|
||||||
asUint8Array,
|
asUint8Array,
|
||||||
decodeBinaryMuxFrame,
|
decodeTerminalStreamFrame,
|
||||||
encodeBinaryMuxFrame,
|
encodeTerminalStreamFrame,
|
||||||
} from "../shared/binary-mux.js";
|
TerminalStreamOpcode,
|
||||||
|
} from "../shared/terminal-stream-protocol.js";
|
||||||
|
|
||||||
const wsModuleMock = vi.hoisted(() => {
|
const wsModuleMock = vi.hoisted(() => {
|
||||||
class MockWebSocketServer {
|
class MockWebSocketServer {
|
||||||
@@ -89,6 +88,7 @@ const TEST_DAEMON_VERSION = "1.2.3-test";
|
|||||||
|
|
||||||
class MockSocket {
|
class MockSocket {
|
||||||
readyState = 1;
|
readyState = 1;
|
||||||
|
bufferedAmount = 0;
|
||||||
sent: unknown[] = [];
|
sent: unknown[] = [];
|
||||||
private listeners = new Map<string, Array<(...args: any[]) => void>>();
|
private listeners = new Map<string, Array<(...args: any[]) => void>>();
|
||||||
|
|
||||||
@@ -623,7 +623,7 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("routes inbound binary mux frames to session.handleBinaryFrame", async () => {
|
test("routes inbound terminal frames to session.handleBinaryFrame", async () => {
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
|
|
||||||
const socket = new MockSocket();
|
const socket = new MockSocket();
|
||||||
@@ -638,11 +638,8 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
socket.emit(
|
socket.emit(
|
||||||
"message",
|
"message",
|
||||||
Buffer.from(
|
Buffer.from(
|
||||||
encodeBinaryMuxFrame({
|
encodeTerminalStreamFrame({
|
||||||
channel: BinaryMuxChannel.Terminal,
|
opcode: TerminalStreamOpcode.Input,
|
||||||
messageType: TerminalBinaryMessageType.InputUtf8,
|
|
||||||
streamId: 9,
|
|
||||||
offset: 0,
|
|
||||||
payload: new TextEncoder().encode("ls\r"),
|
payload: new TextEncoder().encode("ls\r"),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -651,20 +648,16 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
|
|
||||||
expect(session.handleBinaryFrame).toHaveBeenCalledTimes(1);
|
expect(session.handleBinaryFrame).toHaveBeenCalledTimes(1);
|
||||||
const frame = session.handleBinaryFrame.mock.calls[0]?.[0] as {
|
const frame = session.handleBinaryFrame.mock.calls[0]?.[0] as {
|
||||||
channel: number;
|
opcode: number;
|
||||||
messageType: number;
|
payload: Uint8Array;
|
||||||
streamId: number;
|
|
||||||
offset: number;
|
|
||||||
};
|
};
|
||||||
expect(frame.channel).toBe(BinaryMuxChannel.Terminal);
|
expect(frame.opcode).toBe(TerminalStreamOpcode.Input);
|
||||||
expect(frame.messageType).toBe(TerminalBinaryMessageType.InputUtf8);
|
expect(new TextDecoder().decode(frame.payload)).toBe("ls\r");
|
||||||
expect(frame.streamId).toBe(9);
|
|
||||||
expect(frame.offset).toBe(0);
|
|
||||||
|
|
||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sends outbound binary mux frames from session over websocket", async () => {
|
test("sends outbound terminal frames from session over websocket", async () => {
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
|
|
||||||
const socket = new MockSocket();
|
const socket = new MockSocket();
|
||||||
@@ -677,33 +670,18 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
const session = sessionMock.instances[0]!;
|
const session = sessionMock.instances[0]!;
|
||||||
|
|
||||||
const onBinaryMessage = session.args.onBinaryMessage as
|
const onBinaryMessage = session.args.onBinaryMessage as
|
||||||
| ((frame: {
|
| ((frame: Uint8Array) => void)
|
||||||
channel: number;
|
|
||||||
messageType: number;
|
|
||||||
streamId: number;
|
|
||||||
offset: number;
|
|
||||||
payload?: Uint8Array;
|
|
||||||
}) => void)
|
|
||||||
| undefined;
|
| undefined;
|
||||||
expect(onBinaryMessage).toBeTypeOf("function");
|
expect(onBinaryMessage).toBeTypeOf("function");
|
||||||
|
|
||||||
onBinaryMessage?.({
|
onBinaryMessage?.(new Uint8Array([TerminalStreamOpcode.Output, 0x6f, 0x6b]));
|
||||||
channel: BinaryMuxChannel.Terminal,
|
|
||||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
|
||||||
streamId: 11,
|
|
||||||
offset: 42,
|
|
||||||
payload: new TextEncoder().encode("ok"),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(socket.sent).toHaveLength(2);
|
expect(socket.sent).toHaveLength(2);
|
||||||
const binaryPayload = asUint8Array(socket.sent[1]);
|
const binaryPayload = asUint8Array(socket.sent[1]);
|
||||||
expect(binaryPayload).not.toBeNull();
|
expect(binaryPayload).not.toBeNull();
|
||||||
const frame = decodeBinaryMuxFrame(binaryPayload!);
|
const frame = decodeTerminalStreamFrame(binaryPayload!);
|
||||||
expect(frame).not.toBeNull();
|
expect(frame).not.toBeNull();
|
||||||
expect(frame!.channel).toBe(BinaryMuxChannel.Terminal);
|
expect(frame!.opcode).toBe(TerminalStreamOpcode.Output);
|
||||||
expect(frame!.messageType).toBe(TerminalBinaryMessageType.OutputUtf8);
|
|
||||||
expect(frame!.streamId).toBe(11);
|
|
||||||
expect(frame!.offset).toBe(42);
|
|
||||||
expect(new TextDecoder().decode(frame!.payload ?? new Uint8Array())).toBe("ok");
|
expect(new TextDecoder().decode(frame!.payload ?? new Uint8Array())).toBe("ok");
|
||||||
|
|
||||||
await server.close();
|
await server.close();
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import {
|
|||||||
type WSOutboundMessage,
|
type WSOutboundMessage,
|
||||||
wrapSessionMessage,
|
wrapSessionMessage,
|
||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
import { asUint8Array, decodeBinaryMuxFrame, encodeBinaryMuxFrame } from "../shared/binary-mux.js";
|
import {
|
||||||
|
asUint8Array,
|
||||||
|
decodeTerminalStreamFrame,
|
||||||
|
} from "../shared/terminal-stream-protocol.js";
|
||||||
import type { AllowedHostsConfig } from "./allowed-hosts.js";
|
import type { AllowedHostsConfig } from "./allowed-hosts.js";
|
||||||
import { isHostAllowed } from "./allowed-hosts.js";
|
import { isHostAllowed } from "./allowed-hosts.js";
|
||||||
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
|
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
|
||||||
@@ -161,6 +164,7 @@ function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffe
|
|||||||
|
|
||||||
type WebSocketLike = {
|
type WebSocketLike = {
|
||||||
readyState: number;
|
readyState: number;
|
||||||
|
bufferedAmount?: number;
|
||||||
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
||||||
close: (code?: number, reason?: string) => void;
|
close: (code?: number, reason?: string) => void;
|
||||||
on: (event: "message" | "close" | "error", listener: (...args: any[]) => void) => void;
|
on: (event: "message" | "close" | "error", listener: (...args: any[]) => void) => void;
|
||||||
@@ -492,12 +496,12 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
|
|
||||||
private sendBinaryToClient(
|
private sendBinaryToClient(
|
||||||
ws: WebSocketLike,
|
ws: WebSocketLike,
|
||||||
frame: Parameters<typeof encodeBinaryMuxFrame>[0],
|
frame: Uint8Array,
|
||||||
): void {
|
): void {
|
||||||
if (ws.readyState !== 1) {
|
if (ws.readyState !== 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ws.send(encodeBinaryMuxFrame(frame));
|
ws.send(frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void {
|
private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void {
|
||||||
@@ -508,7 +512,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
|
|
||||||
private sendBinaryToConnection(
|
private sendBinaryToConnection(
|
||||||
connection: SessionConnection,
|
connection: SessionConnection,
|
||||||
frame: Parameters<typeof encodeBinaryMuxFrame>[0],
|
frame: Uint8Array,
|
||||||
): void {
|
): void {
|
||||||
for (const ws of connection.sockets) {
|
for (const ws of connection.sockets) {
|
||||||
this.sendBinaryToClient(ws, frame);
|
this.sendBinaryToClient(ws, frame);
|
||||||
@@ -595,6 +599,16 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}
|
}
|
||||||
this.sendBinaryToConnection(connection, frame);
|
this.sendBinaryToConnection(connection, frame);
|
||||||
},
|
},
|
||||||
|
getBinaryBufferedAmount: () => {
|
||||||
|
if (!connection) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let bufferedAmount = 0;
|
||||||
|
for (const socket of connection.sockets) {
|
||||||
|
bufferedAmount = Math.max(bufferedAmount, socket.bufferedAmount ?? 0);
|
||||||
|
}
|
||||||
|
return bufferedAmount;
|
||||||
|
},
|
||||||
onLifecycleIntent: (intent) => {
|
onLifecycleIntent: (intent) => {
|
||||||
this.onLifecycleIntent?.(intent);
|
this.onLifecycleIntent?.(intent);
|
||||||
},
|
},
|
||||||
@@ -900,7 +914,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
const buffer = bufferFromWsData(data);
|
const buffer = bufferFromWsData(data);
|
||||||
const asBytes = asUint8Array(buffer);
|
const asBytes = asUint8Array(buffer);
|
||||||
if (asBytes) {
|
if (asBytes) {
|
||||||
const frame = decodeBinaryMuxFrame(asBytes);
|
const frame = decodeTerminalStreamFrame(asBytes);
|
||||||
if (frame) {
|
if (frame) {
|
||||||
if (!activeConnection) {
|
if (!activeConnection) {
|
||||||
this.incrementRuntimeCounter("binaryBeforeHelloRejected");
|
this.incrementRuntimeCounter("binaryBeforeHelloRejected");
|
||||||
|
|||||||
@@ -297,27 +297,13 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
send: () => {
|
send: () => {
|
||||||
sendAt = Date.now();
|
sendAt = Date.now();
|
||||||
},
|
},
|
||||||
subscribe: () => () => {},
|
subscribe: (listener) => {
|
||||||
onExit: () => () => {},
|
outputListener = (chunk) => listener({ type: "output", data: chunk.data });
|
||||||
subscribeRaw: (listener) => {
|
return () => {
|
||||||
outputListener = (chunk) =>
|
outputListener = null;
|
||||||
listener({
|
|
||||||
data: chunk.data,
|
|
||||||
startOffset: 0,
|
|
||||||
endOffset: chunk.data.length,
|
|
||||||
replay: false,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
unsubscribe: () => {
|
|
||||||
outputListener = null;
|
|
||||||
},
|
|
||||||
replayedFrom: 0,
|
|
||||||
currentOffset: 0,
|
|
||||||
earliestAvailableOffset: 0,
|
|
||||||
reset: false,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getOutputOffset: () => 0,
|
onExit: () => () => {},
|
||||||
getState: () => ({
|
getState: () => ({
|
||||||
rows: 0,
|
rows: 0,
|
||||||
cols: 0,
|
cols: 0,
|
||||||
@@ -398,18 +384,10 @@ describe("runAsyncWorktreeBootstrap", () => {
|
|||||||
send: () => {},
|
send: () => {},
|
||||||
subscribe: () => () => {},
|
subscribe: () => () => {},
|
||||||
onExit: () => () => {},
|
onExit: () => () => {},
|
||||||
subscribeRaw: () => ({
|
|
||||||
unsubscribe: () => {},
|
|
||||||
replayedFrom: 0,
|
|
||||||
currentOffset: 0,
|
|
||||||
earliestAvailableOffset: 0,
|
|
||||||
reset: false,
|
|
||||||
}),
|
|
||||||
getOutputOffset: () => 1,
|
|
||||||
getState: () => ({
|
getState: () => ({
|
||||||
rows: 0,
|
rows: 1,
|
||||||
cols: 0,
|
cols: 1,
|
||||||
grid: [],
|
grid: [[{ char: "$" }]],
|
||||||
scrollback: [],
|
scrollback: [],
|
||||||
cursor: { row: 0, col: 0 },
|
cursor: { row: 0, col: 0 },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -383,9 +383,9 @@ function buildTerminalTimelineItem(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function waitForTerminalBootstrapReadiness(
|
async function waitForTerminalBootstrapReadiness(
|
||||||
terminal: Pick<TerminalSession, "getOutputOffset" | "subscribeRaw">,
|
terminal: Pick<TerminalSession, "getState" | "subscribe">,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (terminal.getOutputOffset() > 0) {
|
if (terminalHasOutput(terminal.getState())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,12 +410,14 @@ async function waitForTerminalBootstrapReadiness(
|
|||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
const rawSubscription = terminal.subscribeRaw(() => {
|
unsubscribe = terminal.subscribe((message) => {
|
||||||
|
if (message.type !== "output") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
finish();
|
finish();
|
||||||
});
|
});
|
||||||
unsubscribe = rawSubscription.unsubscribe;
|
|
||||||
|
|
||||||
if (terminal.getOutputOffset() > 0) {
|
if (terminalHasOutput(terminal.getState())) {
|
||||||
finish();
|
finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -424,6 +426,17 @@ async function waitForTerminalBootstrapReadiness(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function terminalHasOutput(state: ReturnType<TerminalSession["getState"]>): boolean {
|
||||||
|
for (const row of [...state.scrollback, ...state.grid]) {
|
||||||
|
for (const cell of row) {
|
||||||
|
if (cell.char.trim().length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function runWorktreeTerminalBootstrap(
|
async function runWorktreeTerminalBootstrap(
|
||||||
options: RunAsyncWorktreeBootstrapOptions,
|
options: RunAsyncWorktreeBootstrapOptions,
|
||||||
runtimeEnv: WorktreeRuntimeEnv,
|
runtimeEnv: WorktreeRuntimeEnv,
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
BinaryMuxChannel,
|
|
||||||
TerminalBinaryFlags,
|
|
||||||
TerminalBinaryMessageType,
|
|
||||||
asUint8Array,
|
|
||||||
decodeBinaryMuxFrame,
|
|
||||||
encodeBinaryMuxFrame,
|
|
||||||
} from "./binary-mux.js";
|
|
||||||
|
|
||||||
describe("binary mux frame codec", () => {
|
|
||||||
it("encodes and decodes round trip", () => {
|
|
||||||
const payload = new TextEncoder().encode("hello");
|
|
||||||
const encoded = encodeBinaryMuxFrame({
|
|
||||||
channel: BinaryMuxChannel.Terminal,
|
|
||||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
|
||||||
streamId: 42,
|
|
||||||
offset: 1234,
|
|
||||||
flags: TerminalBinaryFlags.Replay,
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
|
|
||||||
const decoded = decodeBinaryMuxFrame(encoded);
|
|
||||||
expect(decoded).not.toBeNull();
|
|
||||||
expect(decoded!.channel).toBe(BinaryMuxChannel.Terminal);
|
|
||||||
expect(decoded!.messageType).toBe(TerminalBinaryMessageType.OutputUtf8);
|
|
||||||
expect(decoded!.streamId).toBe(42);
|
|
||||||
expect(decoded!.offset).toBe(1234);
|
|
||||||
expect(decoded!.flags).toBe(TerminalBinaryFlags.Replay);
|
|
||||||
expect(Array.from(decoded!.payload ?? [])).toEqual(Array.from(payload));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects malformed frame payload length", () => {
|
|
||||||
const encoded = encodeBinaryMuxFrame({
|
|
||||||
channel: BinaryMuxChannel.Terminal,
|
|
||||||
messageType: TerminalBinaryMessageType.InputUtf8,
|
|
||||||
streamId: 1,
|
|
||||||
offset: 0,
|
|
||||||
payload: new Uint8Array([1, 2, 3]),
|
|
||||||
});
|
|
||||||
const tampered = encoded.slice(0, encoded.byteLength - 1);
|
|
||||||
expect(decodeBinaryMuxFrame(tampered)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts UTF-8 string payloads to bytes", () => {
|
|
||||||
const bytes = asUint8Array("hello");
|
|
||||||
expect(bytes).not.toBeNull();
|
|
||||||
expect(Array.from(bytes ?? [])).toEqual(Array.from(new TextEncoder().encode("hello")));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
const MUX_MAGIC_1 = 0x50; // 'P'
|
|
||||||
const MUX_MAGIC_2 = 0x58; // 'X'
|
|
||||||
const MUX_VERSION = 1;
|
|
||||||
const HEADER_SIZE = 24;
|
|
||||||
const UINT32_MAX = 0xffffffff;
|
|
||||||
const TWO_POW_32 = 0x1_0000_0000;
|
|
||||||
|
|
||||||
export const BinaryMuxConstants = {
|
|
||||||
magic1: MUX_MAGIC_1,
|
|
||||||
magic2: MUX_MAGIC_2,
|
|
||||||
version: MUX_VERSION,
|
|
||||||
headerSize: HEADER_SIZE,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const enum BinaryMuxChannel {
|
|
||||||
Terminal = 1,
|
|
||||||
FileTransfer = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const enum TerminalBinaryMessageType {
|
|
||||||
InputUtf8 = 1,
|
|
||||||
OutputUtf8 = 2,
|
|
||||||
Ack = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const enum TerminalBinaryFlags {
|
|
||||||
Replay = 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BinaryMuxFrame {
|
|
||||||
channel: number;
|
|
||||||
messageType: number;
|
|
||||||
streamId: number;
|
|
||||||
offset: number;
|
|
||||||
flags?: number;
|
|
||||||
payload?: Uint8Array;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function asUint8Array(data: unknown): Uint8Array | null {
|
|
||||||
if (typeof data === "string") {
|
|
||||||
if (typeof TextEncoder !== "undefined") {
|
|
||||||
return new TextEncoder().encode(data);
|
|
||||||
}
|
|
||||||
if (typeof Buffer !== "undefined") {
|
|
||||||
return new Uint8Array(Buffer.from(data, "utf8"));
|
|
||||||
}
|
|
||||||
const out = new Uint8Array(data.length);
|
|
||||||
for (let i = 0; i < data.length; i += 1) {
|
|
||||||
out[i] = data.charCodeAt(i) & 0xff;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
if (data instanceof Uint8Array) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
|
|
||||||
return new Uint8Array(data);
|
|
||||||
}
|
|
||||||
if (ArrayBuffer.isView(data)) {
|
|
||||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
||||||
}
|
|
||||||
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
|
|
||||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLikelyBinaryMuxFrame(data: Uint8Array): boolean {
|
|
||||||
if (data.byteLength < HEADER_SIZE) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return data[0] === MUX_MAGIC_1 && data[1] === MUX_MAGIC_2 && data[2] === MUX_VERSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeBinaryMuxFrame(frame: BinaryMuxFrame): Uint8Array {
|
|
||||||
const payload = frame.payload ?? new Uint8Array(0);
|
|
||||||
const out = new Uint8Array(HEADER_SIZE + payload.byteLength);
|
|
||||||
const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
|
|
||||||
const flags = frame.flags ?? 0;
|
|
||||||
|
|
||||||
if (!Number.isFinite(frame.streamId) || frame.streamId < 0 || frame.streamId > UINT32_MAX) {
|
|
||||||
throw new Error(`Invalid streamId: ${frame.streamId}`);
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(frame.offset) || frame.offset < 0) {
|
|
||||||
throw new Error(`Invalid offset: ${frame.offset}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const offsetHi = Math.floor(frame.offset / TWO_POW_32);
|
|
||||||
const offsetLo = frame.offset >>> 0;
|
|
||||||
if (offsetHi > UINT32_MAX) {
|
|
||||||
throw new Error(`Offset too large: ${frame.offset}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
view.setUint8(0, MUX_MAGIC_1);
|
|
||||||
view.setUint8(1, MUX_MAGIC_2);
|
|
||||||
view.setUint8(2, MUX_VERSION);
|
|
||||||
view.setUint8(3, frame.channel & 0xff);
|
|
||||||
view.setUint8(4, frame.messageType & 0xff);
|
|
||||||
view.setUint8(5, flags & 0xff);
|
|
||||||
view.setUint8(6, 0);
|
|
||||||
view.setUint8(7, 0);
|
|
||||||
view.setUint32(8, frame.streamId >>> 0);
|
|
||||||
view.setUint32(12, offsetHi >>> 0);
|
|
||||||
view.setUint32(16, offsetLo);
|
|
||||||
view.setUint32(20, payload.byteLength >>> 0);
|
|
||||||
|
|
||||||
out.set(payload, HEADER_SIZE);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeBinaryMuxFrame(data: Uint8Array): BinaryMuxFrame | null {
|
|
||||||
if (!isLikelyBinaryMuxFrame(data)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
||||||
const payloadLength = view.getUint32(20);
|
|
||||||
if (payloadLength !== data.byteLength - HEADER_SIZE) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const offsetHi = view.getUint32(12);
|
|
||||||
const offsetLo = view.getUint32(16);
|
|
||||||
const offset = offsetHi * TWO_POW_32 + offsetLo;
|
|
||||||
|
|
||||||
return {
|
|
||||||
channel: view.getUint8(3),
|
|
||||||
messageType: view.getUint8(4),
|
|
||||||
flags: view.getUint8(5),
|
|
||||||
streamId: view.getUint32(8),
|
|
||||||
offset,
|
|
||||||
payload: data.subarray(HEADER_SIZE),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1139,21 +1139,6 @@ export const KillTerminalRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AttachTerminalStreamRequestSchema = z.object({
|
|
||||||
type: z.literal("attach_terminal_stream_request"),
|
|
||||||
terminalId: z.string(),
|
|
||||||
resumeOffset: z.number().int().nonnegative().optional(),
|
|
||||||
rows: z.number().int().positive().optional(),
|
|
||||||
cols: z.number().int().positive().optional(),
|
|
||||||
requestId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const DetachTerminalStreamRequestSchema = z.object({
|
|
||||||
type: z.literal("detach_terminal_stream_request"),
|
|
||||||
streamId: z.number().int().nonnegative(),
|
|
||||||
requestId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||||
VoiceAudioChunkMessageSchema,
|
VoiceAudioChunkMessageSchema,
|
||||||
AbortRequestMessageSchema,
|
AbortRequestMessageSchema,
|
||||||
@@ -1219,8 +1204,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
UnsubscribeTerminalRequestSchema,
|
UnsubscribeTerminalRequestSchema,
|
||||||
TerminalInputSchema,
|
TerminalInputSchema,
|
||||||
KillTerminalRequestSchema,
|
KillTerminalRequestSchema,
|
||||||
AttachTerminalStreamRequestSchema,
|
|
||||||
DetachTerminalStreamRequestSchema,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
||||||
@@ -2112,7 +2095,7 @@ const TerminalInfoSchema = z.object({
|
|||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const TerminalCellSchema = z.object({
|
export const TerminalCellSchema = z.object({
|
||||||
char: z.string(),
|
char: z.string(),
|
||||||
fg: z.number().optional(),
|
fg: z.number().optional(),
|
||||||
bg: z.number().optional(),
|
bg: z.number().optional(),
|
||||||
@@ -2123,7 +2106,7 @@ const TerminalCellSchema = z.object({
|
|||||||
underline: z.boolean().optional(),
|
underline: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const TerminalStateSchema = z.object({
|
export const TerminalStateSchema = z.object({
|
||||||
rows: z.number(),
|
rows: z.number(),
|
||||||
cols: z.number(),
|
cols: z.number(),
|
||||||
grid: z.array(z.array(TerminalCellSchema)),
|
grid: z.array(z.array(TerminalCellSchema)),
|
||||||
@@ -2167,14 +2150,6 @@ export const SubscribeTerminalResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TerminalOutputSchema = z.object({
|
|
||||||
type: z.literal("terminal_output"),
|
|
||||||
payload: z.object({
|
|
||||||
terminalId: z.string(),
|
|
||||||
state: TerminalStateSchema,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const KillTerminalResponseSchema = z.object({
|
export const KillTerminalResponseSchema = z.object({
|
||||||
type: z.literal("kill_terminal_response"),
|
type: z.literal("kill_terminal_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -2184,33 +2159,9 @@ export const KillTerminalResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AttachTerminalStreamResponseSchema = z.object({
|
|
||||||
type: z.literal("attach_terminal_stream_response"),
|
|
||||||
payload: z.object({
|
|
||||||
terminalId: z.string(),
|
|
||||||
streamId: z.number().int().nonnegative().nullable(),
|
|
||||||
replayedFrom: z.number().int().nonnegative(),
|
|
||||||
currentOffset: z.number().int().nonnegative(),
|
|
||||||
earliestAvailableOffset: z.number().int().nonnegative(),
|
|
||||||
reset: z.boolean(),
|
|
||||||
error: z.string().nullable(),
|
|
||||||
requestId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const DetachTerminalStreamResponseSchema = z.object({
|
|
||||||
type: z.literal("detach_terminal_stream_response"),
|
|
||||||
payload: z.object({
|
|
||||||
streamId: z.number().int().nonnegative(),
|
|
||||||
success: z.boolean(),
|
|
||||||
requestId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const TerminalStreamExitSchema = z.object({
|
export const TerminalStreamExitSchema = z.object({
|
||||||
type: z.literal("terminal_stream_exit"),
|
type: z.literal("terminal_stream_exit"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
streamId: z.number().int().nonnegative(),
|
|
||||||
terminalId: z.string(),
|
terminalId: z.string(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -2278,10 +2229,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
TerminalsChangedSchema,
|
TerminalsChangedSchema,
|
||||||
CreateTerminalResponseSchema,
|
CreateTerminalResponseSchema,
|
||||||
SubscribeTerminalResponseSchema,
|
SubscribeTerminalResponseSchema,
|
||||||
TerminalOutputSchema,
|
|
||||||
KillTerminalResponseSchema,
|
KillTerminalResponseSchema,
|
||||||
AttachTerminalStreamResponseSchema,
|
|
||||||
DetachTerminalStreamResponseSchema,
|
|
||||||
TerminalStreamExitSchema,
|
TerminalStreamExitSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -2415,13 +2363,10 @@ export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSc
|
|||||||
export type SubscribeTerminalResponse = z.infer<typeof SubscribeTerminalResponseSchema>;
|
export type SubscribeTerminalResponse = z.infer<typeof SubscribeTerminalResponseSchema>;
|
||||||
export type UnsubscribeTerminalRequest = z.infer<typeof UnsubscribeTerminalRequestSchema>;
|
export type UnsubscribeTerminalRequest = z.infer<typeof UnsubscribeTerminalRequestSchema>;
|
||||||
export type TerminalInput = z.infer<typeof TerminalInputSchema>;
|
export type TerminalInput = z.infer<typeof TerminalInputSchema>;
|
||||||
export type TerminalOutput = z.infer<typeof TerminalOutputSchema>;
|
export type TerminalCell = z.infer<typeof TerminalCellSchema>;
|
||||||
|
export type TerminalState = z.infer<typeof TerminalStateSchema>;
|
||||||
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
|
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
|
||||||
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>;
|
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>;
|
||||||
export type AttachTerminalStreamRequest = z.infer<typeof AttachTerminalStreamRequestSchema>;
|
|
||||||
export type AttachTerminalStreamResponse = z.infer<typeof AttachTerminalStreamResponseSchema>;
|
|
||||||
export type DetachTerminalStreamRequest = z.infer<typeof DetachTerminalStreamRequestSchema>;
|
|
||||||
export type DetachTerminalStreamResponse = z.infer<typeof DetachTerminalStreamResponseSchema>;
|
|
||||||
export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
|
export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
TerminalStreamOpcode,
|
||||||
|
decodeTerminalResizePayload,
|
||||||
|
decodeTerminalSnapshotPayload,
|
||||||
|
decodeTerminalStreamFrame,
|
||||||
|
encodeTerminalResizePayload,
|
||||||
|
encodeTerminalSnapshotPayload,
|
||||||
|
encodeTerminalStreamFrame,
|
||||||
|
} from "./terminal-stream-protocol.js";
|
||||||
|
|
||||||
|
describe("terminal stream protocol", () => {
|
||||||
|
it("encodes output frames as a 1-byte opcode prefix plus payload", () => {
|
||||||
|
const payload = new TextEncoder().encode("hello");
|
||||||
|
const encoded = encodeTerminalStreamFrame({
|
||||||
|
opcode: TerminalStreamOpcode.Output,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(encoded[0]).toBe(TerminalStreamOpcode.Output);
|
||||||
|
expect(Array.from(encoded.subarray(1))).toEqual(Array.from(payload));
|
||||||
|
|
||||||
|
const decoded = decodeTerminalStreamFrame(encoded);
|
||||||
|
expect(decoded).toEqual({
|
||||||
|
opcode: TerminalStreamOpcode.Output,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips resize payloads", () => {
|
||||||
|
const payload = encodeTerminalResizePayload({
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decodeTerminalResizePayload(payload)).toEqual({
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips snapshot payloads", () => {
|
||||||
|
const state = {
|
||||||
|
rows: 1,
|
||||||
|
cols: 2,
|
||||||
|
grid: [[{ char: "A" }, { char: "B" }]],
|
||||||
|
scrollback: [],
|
||||||
|
cursor: { row: 0, col: 2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = encodeTerminalSnapshotPayload(state);
|
||||||
|
expect(decodeTerminalSnapshotPayload(payload)).toEqual(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown opcodes", () => {
|
||||||
|
expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01]))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { TerminalStateSchema } from "./messages.js";
|
||||||
|
|
||||||
|
export const TerminalStreamResizeSchema = z.object({
|
||||||
|
rows: z.number().int().positive(),
|
||||||
|
cols: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TerminalStreamOpcode = {
|
||||||
|
Output: 0x01,
|
||||||
|
Input: 0x02,
|
||||||
|
Resize: 0x03,
|
||||||
|
Snapshot: 0x04,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TerminalStreamOpcode =
|
||||||
|
(typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
|
||||||
|
|
||||||
|
export type TerminalStreamFrame = {
|
||||||
|
opcode: TerminalStreamOpcode;
|
||||||
|
payload: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function asUint8Array(data: unknown): Uint8Array | null {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
if (typeof TextEncoder !== "undefined") {
|
||||||
|
return new TextEncoder().encode(data);
|
||||||
|
}
|
||||||
|
if (typeof Buffer !== "undefined") {
|
||||||
|
return new Uint8Array(Buffer.from(data, "utf8"));
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(data.length);
|
||||||
|
for (let index = 0; index < data.length; index += 1) {
|
||||||
|
out[index] = data.charCodeAt(index) & 0xff;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (data instanceof Uint8Array) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
|
||||||
|
return new Uint8Array(data);
|
||||||
|
}
|
||||||
|
if (ArrayBuffer.isView(data)) {
|
||||||
|
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
}
|
||||||
|
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
|
||||||
|
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
|
||||||
|
return (
|
||||||
|
value === TerminalStreamOpcode.Output ||
|
||||||
|
value === TerminalStreamOpcode.Input ||
|
||||||
|
value === TerminalStreamOpcode.Resize ||
|
||||||
|
value === TerminalStreamOpcode.Snapshot
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeTerminalStreamFrame(input: {
|
||||||
|
opcode: TerminalStreamOpcode;
|
||||||
|
payload?: Uint8Array | ArrayBuffer | string;
|
||||||
|
}): Uint8Array {
|
||||||
|
const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0);
|
||||||
|
const bytes = new Uint8Array(1 + payload.byteLength);
|
||||||
|
bytes[0] = input.opcode;
|
||||||
|
bytes.set(payload, 1);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
|
||||||
|
if (bytes.byteLength < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const opcode = bytes[0];
|
||||||
|
if (!isTerminalStreamOpcode(opcode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
opcode,
|
||||||
|
payload: bytes.subarray(1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeTerminalSnapshotPayload(state: z.infer<typeof TerminalStateSchema>): Uint8Array {
|
||||||
|
return encodeJsonPayload(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTerminalSnapshotPayload(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): z.infer<typeof TerminalStateSchema> | null {
|
||||||
|
const parsed = decodeJsonPayload(bytes);
|
||||||
|
const result = TerminalStateSchema.safeParse(parsed);
|
||||||
|
return result.success ? result.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeTerminalResizePayload(
|
||||||
|
input: z.infer<typeof TerminalStreamResizeSchema>,
|
||||||
|
): Uint8Array {
|
||||||
|
return encodeJsonPayload(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTerminalResizePayload(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
): z.infer<typeof TerminalStreamResizeSchema> | null {
|
||||||
|
const parsed = decodeJsonPayload(bytes);
|
||||||
|
const result = TerminalStreamResizeSchema.safeParse(parsed);
|
||||||
|
return result.success ? result.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeJsonPayload(value: unknown): Uint8Array {
|
||||||
|
return new TextEncoder().encode(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJsonPayload(bytes: Uint8Array): unknown {
|
||||||
|
const text = new TextDecoder().decode(bytes);
|
||||||
|
return JSON.parse(text);
|
||||||
|
}
|
||||||
@@ -388,7 +388,7 @@ describe("Terminal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("subscribe", () => {
|
describe("subscribe", () => {
|
||||||
it("receives full state on initial subscription", async () => {
|
it("receives a snapshot on initial subscription", async () => {
|
||||||
const session = trackSession(
|
const session = trackSession(
|
||||||
await createTerminal({
|
await createTerminal({
|
||||||
cwd: "/tmp",
|
cwd: "/tmp",
|
||||||
@@ -405,12 +405,12 @@ describe("Terminal", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
expect(messages.length).toBeGreaterThan(0);
|
expect(messages.length).toBeGreaterThan(0);
|
||||||
expect(messages[0].type).toBe("full");
|
expect(messages[0].type).toBe("snapshot");
|
||||||
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("receives messages on updates", async () => {
|
it("receives output and snapshot messages on updates", async () => {
|
||||||
const session = trackSession(
|
const session = trackSession(
|
||||||
await createTerminal({
|
await createTerminal({
|
||||||
cwd: "/tmp",
|
cwd: "/tmp",
|
||||||
@@ -432,7 +432,8 @@ describe("Terminal", () => {
|
|||||||
|
|
||||||
await waitForLines(session, ["$ echo test", "test", "$"]);
|
await waitForLines(session, ["$ echo test", "test", "$"]);
|
||||||
|
|
||||||
expect(messages.length).toBeGreaterThan(0);
|
expect(messages.some((message) => message.type === "output")).toBe(true);
|
||||||
|
expect(messages.some((message) => message.type === "snapshot")).toBe(true);
|
||||||
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
@@ -463,8 +464,8 @@ describe("Terminal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("raw output stream", () => {
|
describe("stream snapshots", () => {
|
||||||
it("streams raw output chunks with byte offsets", async () => {
|
it("streams raw output messages without replay metadata", async () => {
|
||||||
const session = trackSession(
|
const session = trackSession(
|
||||||
await createTerminal({
|
await createTerminal({
|
||||||
cwd: "/tmp",
|
cwd: "/tmp",
|
||||||
@@ -475,28 +476,23 @@ describe("Terminal", () => {
|
|||||||
|
|
||||||
await waitForLines(session, ["$"]);
|
await waitForLines(session, ["$"]);
|
||||||
|
|
||||||
const chunks: Array<{ data: string; startOffset: number; endOffset: number }> = [];
|
const outputMessages: string[] = [];
|
||||||
const sub = session.subscribeRaw((chunk) => {
|
const unsubscribe = session.subscribe((message) => {
|
||||||
chunks.push({
|
if (message.type !== "output") {
|
||||||
data: chunk.data,
|
return;
|
||||||
startOffset: chunk.startOffset,
|
}
|
||||||
endOffset: chunk.endOffset,
|
outputMessages.push(message.data);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
session.send({ type: "input", data: "echo raw-stream\r" });
|
session.send({ type: "input", data: "echo raw-stream\r" });
|
||||||
await waitForLines(session, ["$ echo raw-stream", "raw-stream", "$"]);
|
await waitForLines(session, ["$ echo raw-stream", "raw-stream", "$"]);
|
||||||
|
|
||||||
expect(chunks.length).toBeGreaterThan(0);
|
expect(outputMessages.join("")).toContain("raw-stream");
|
||||||
expect(chunks[chunks.length - 1].endOffset).toBe(session.getOutputOffset());
|
|
||||||
for (let i = 1; i < chunks.length; i++) {
|
|
||||||
expect(chunks[i].startOffset).toBe(chunks[i - 1].endOffset);
|
|
||||||
}
|
|
||||||
|
|
||||||
sub.unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("replays buffered output from offset", async () => {
|
it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => {
|
||||||
const session = trackSession(
|
const session = trackSession(
|
||||||
await createTerminal({
|
await createTerminal({
|
||||||
cwd: "/tmp",
|
cwd: "/tmp",
|
||||||
@@ -507,21 +503,9 @@ describe("Terminal", () => {
|
|||||||
|
|
||||||
await waitForLines(session, ["$"]);
|
await waitForLines(session, ["$"]);
|
||||||
|
|
||||||
const seen: Array<{ data: string; endOffset: number; replay: boolean }> = [];
|
|
||||||
const firstSub = session.subscribeRaw((chunk) => {
|
|
||||||
seen.push({
|
|
||||||
data: chunk.data,
|
|
||||||
endOffset: chunk.endOffset,
|
|
||||||
replay: chunk.replay,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
session.send({ type: "input", data: "echo before-detach\r" });
|
session.send({ type: "input", data: "echo before-detach\r" });
|
||||||
await waitForLines(session, ["$ echo before-detach", "before-detach", "$"]);
|
await waitForLines(session, ["$ echo before-detach", "before-detach", "$"]);
|
||||||
|
|
||||||
const resumeOffset = seen[seen.length - 1]?.endOffset ?? 0;
|
|
||||||
firstSub.unsubscribe();
|
|
||||||
|
|
||||||
session.send({ type: "input", data: "echo after-detach\r" });
|
session.send({ type: "input", data: "echo after-detach\r" });
|
||||||
await waitForLines(session, [
|
await waitForLines(session, [
|
||||||
"$ echo before-detach",
|
"$ echo before-detach",
|
||||||
@@ -531,20 +515,26 @@ describe("Terminal", () => {
|
|||||||
"$",
|
"$",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const replayed: string[] = [];
|
let snapshotText = "";
|
||||||
const secondSub = session.subscribeRaw(
|
const unsubscribe = session.subscribe((message) => {
|
||||||
(chunk) => {
|
if (message.type !== "snapshot") {
|
||||||
if (chunk.replay) {
|
return;
|
||||||
replayed.push(chunk.data);
|
}
|
||||||
}
|
snapshotText = [...message.state.scrollback, ...message.state.grid]
|
||||||
},
|
.map((row) =>
|
||||||
{ fromOffset: resumeOffset },
|
row
|
||||||
);
|
.map((cell) => cell.char)
|
||||||
|
.join("")
|
||||||
|
.trimEnd(),
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
});
|
||||||
|
|
||||||
const replayText = replayed.join("");
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
expect(replayText).toContain("after-detach");
|
|
||||||
expect(secondSub.replayedFrom).toBeGreaterThanOrEqual(resumeOffset);
|
expect(snapshotText).toContain("before-detach");
|
||||||
secondSub.unsubscribe();
|
expect(snapshotText).toContain("after-detach");
|
||||||
|
unsubscribe();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,35 +34,14 @@ export interface TerminalState {
|
|||||||
cursor: Pos;
|
cursor: Pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CellChange {
|
|
||||||
row: number;
|
|
||||||
col: number;
|
|
||||||
cell: Cell;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TerminalRawChunk {
|
|
||||||
data: string;
|
|
||||||
startOffset: number;
|
|
||||||
endOffset: number;
|
|
||||||
replay: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TerminalRawSubscriptionResult {
|
|
||||||
unsubscribe: () => void;
|
|
||||||
replayedFrom: number;
|
|
||||||
currentOffset: number;
|
|
||||||
earliestAvailableOffset: number;
|
|
||||||
reset: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: "input"; data: string }
|
| { type: "input"; data: string }
|
||||||
| { type: "resize"; rows: number; cols: number }
|
| { type: "resize"; rows: number; cols: number }
|
||||||
| { type: "mouse"; row: number; col: number; button: number; action: "down" | "up" | "move" };
|
| { type: "mouse"; row: number; col: number; button: number; action: "down" | "up" | "move" };
|
||||||
|
|
||||||
export type ServerMessage =
|
export type ServerMessage =
|
||||||
| { type: "full"; state: TerminalState }
|
| { type: "output"; data: string }
|
||||||
| { type: "diff"; changes: CellChange[]; cursor: Pos };
|
| { type: "snapshot"; state: TerminalState };
|
||||||
|
|
||||||
export interface TerminalSession {
|
export interface TerminalSession {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -71,11 +50,6 @@ export interface TerminalSession {
|
|||||||
send(msg: ClientMessage): void;
|
send(msg: ClientMessage): void;
|
||||||
subscribe(listener: (msg: ServerMessage) => void): () => void;
|
subscribe(listener: (msg: ServerMessage) => void): () => void;
|
||||||
onExit(listener: () => void): () => void;
|
onExit(listener: () => void): () => void;
|
||||||
subscribeRaw(
|
|
||||||
listener: (chunk: TerminalRawChunk) => void,
|
|
||||||
options?: { fromOffset?: number },
|
|
||||||
): TerminalRawSubscriptionResult;
|
|
||||||
getOutputOffset(): number;
|
|
||||||
getState(): TerminalState;
|
getState(): TerminalState;
|
||||||
kill(): void;
|
kill(): void;
|
||||||
}
|
}
|
||||||
@@ -263,16 +237,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const listeners = new Set<(msg: ServerMessage) => void>();
|
const listeners = new Set<(msg: ServerMessage) => void>();
|
||||||
const rawListeners = new Set<(chunk: TerminalRawChunk) => void>();
|
|
||||||
const exitListeners = new Set<() => void>();
|
const exitListeners = new Set<() => void>();
|
||||||
const rawOutputChunks: Array<{
|
|
||||||
startOffset: number;
|
|
||||||
endOffset: number;
|
|
||||||
data: string;
|
|
||||||
}> = [];
|
|
||||||
const maxRawBufferBytes = 8 * 1024 * 1024;
|
|
||||||
let rawBufferBytes = 0;
|
|
||||||
let outputOffset = 0;
|
|
||||||
let killed = false;
|
let killed = false;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let exitEmitted = false;
|
let exitEmitted = false;
|
||||||
@@ -329,7 +294,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
stateBroadcastScheduled = false;
|
stateBroadcastScheduled = false;
|
||||||
terminal.dispose();
|
terminal.dispose();
|
||||||
listeners.clear();
|
listeners.clear();
|
||||||
rawListeners.clear();
|
|
||||||
exitListeners.clear();
|
exitListeners.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +304,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
}
|
}
|
||||||
const state = getState();
|
const state = getState();
|
||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
listener({ type: "full", state });
|
listener({ type: "snapshot", state });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,66 +319,12 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
}, STATE_BROADCAST_INTERVAL_MS);
|
}, STATE_BROADCAST_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendRawOutputChunk(data: string): void {
|
|
||||||
const chunkBytes = Buffer.byteLength(data);
|
|
||||||
if (chunkBytes <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startOffset = outputOffset;
|
|
||||||
const endOffset = startOffset + chunkBytes;
|
|
||||||
outputOffset = endOffset;
|
|
||||||
|
|
||||||
rawOutputChunks.push({
|
|
||||||
startOffset,
|
|
||||||
endOffset,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
rawBufferBytes += chunkBytes;
|
|
||||||
|
|
||||||
while (rawBufferBytes > maxRawBufferBytes && rawOutputChunks.length > 0) {
|
|
||||||
const removed = rawOutputChunks.shift();
|
|
||||||
if (!removed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
rawBufferBytes -= removed.endOffset - removed.startOffset;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunk: TerminalRawChunk = {
|
|
||||||
data,
|
|
||||||
startOffset,
|
|
||||||
endOffset,
|
|
||||||
replay: false,
|
|
||||||
};
|
|
||||||
for (const listener of rawListeners) {
|
|
||||||
listener(chunk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEarliestAvailableOffset(): number {
|
|
||||||
const earliest = rawOutputChunks[0];
|
|
||||||
return earliest ? earliest.startOffset : outputOffset;
|
|
||||||
}
|
|
||||||
|
|
||||||
function alignOffsetToChunkBoundary(offset: number): number {
|
|
||||||
if (offset === outputOffset) {
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
for (const chunk of rawOutputChunks) {
|
|
||||||
if (offset === chunk.startOffset || offset === chunk.endOffset) {
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
if (offset > chunk.startOffset && offset < chunk.endOffset) {
|
|
||||||
return chunk.startOffset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pipe PTY output to terminal emulator
|
// Pipe PTY output to terminal emulator
|
||||||
ptyProcess.onData((data) => {
|
ptyProcess.onData((data) => {
|
||||||
if (killed) return;
|
if (killed) return;
|
||||||
appendRawOutputChunk(data);
|
for (const listener of listeners) {
|
||||||
|
listener({ type: "output", data });
|
||||||
|
}
|
||||||
terminal.write(data, () => {
|
terminal.write(data, () => {
|
||||||
scheduleStateBroadcast();
|
scheduleStateBroadcast();
|
||||||
});
|
});
|
||||||
@@ -460,10 +370,9 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
function subscribe(listener: (msg: ServerMessage) => void): () => void {
|
function subscribe(listener: (msg: ServerMessage) => void): () => void {
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
|
|
||||||
// Send initial full state
|
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (listeners.has(listener)) {
|
if (listeners.has(listener)) {
|
||||||
listener({ type: "full", state: getState() });
|
listener({ type: "snapshot", state: getState() });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -490,45 +399,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribeRaw(
|
|
||||||
listener: (chunk: TerminalRawChunk) => void,
|
|
||||||
options?: { fromOffset?: number },
|
|
||||||
): TerminalRawSubscriptionResult {
|
|
||||||
const requestedOffset = Math.max(0, Math.floor(options?.fromOffset ?? 0));
|
|
||||||
const earliestAvailableOffset = getEarliestAvailableOffset();
|
|
||||||
const clampedOffset = Math.max(requestedOffset, earliestAvailableOffset);
|
|
||||||
const replayedFrom = alignOffsetToChunkBoundary(clampedOffset);
|
|
||||||
const reset = replayedFrom !== requestedOffset;
|
|
||||||
|
|
||||||
for (const chunk of rawOutputChunks) {
|
|
||||||
if (chunk.endOffset <= replayedFrom) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
listener({
|
|
||||||
data: chunk.data,
|
|
||||||
startOffset: chunk.startOffset,
|
|
||||||
endOffset: chunk.endOffset,
|
|
||||||
replay: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
rawListeners.add(listener);
|
|
||||||
|
|
||||||
return {
|
|
||||||
unsubscribe: () => {
|
|
||||||
rawListeners.delete(listener);
|
|
||||||
},
|
|
||||||
replayedFrom,
|
|
||||||
currentOffset: outputOffset,
|
|
||||||
earliestAvailableOffset,
|
|
||||||
reset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOutputOffset(): number {
|
|
||||||
return outputOffset;
|
|
||||||
}
|
|
||||||
|
|
||||||
function kill(): void {
|
function kill(): void {
|
||||||
if (!killed) {
|
if (!killed) {
|
||||||
killed = true;
|
killed = true;
|
||||||
@@ -548,8 +418,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
send,
|
send,
|
||||||
subscribe,
|
subscribe,
|
||||||
onExit,
|
onExit,
|
||||||
subscribeRaw,
|
|
||||||
getOutputOffset,
|
|
||||||
getState,
|
getState,
|
||||||
kill,
|
kill,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user