mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
feat(terminal): stream negotiated ANSI replay to clients
Restore configured client history up to host retention without collecting capable replay in the daemon. Preserve single-frame ANSI delivery for older clients and normalize older hosts at the SDK boundary.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf";
|
||||
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
|
||||
|
||||
interface AttachOverlayProbeWindow extends Window {
|
||||
@@ -59,4 +60,34 @@ test.describe("retained terminal tab streams", () => {
|
||||
|
||||
expect(await terminalAttachOverlayWasSeen(page)).toBe(false);
|
||||
});
|
||||
|
||||
for (const { count, retainedMarker } of [
|
||||
{ count: 400, retainedMarker: "history-0001界🙂" },
|
||||
{ count: 3000, retainedMarker: "history-2500界🙂" },
|
||||
]) {
|
||||
test(`reattach restores retained styled Unicode history from ${count} lines`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
const key = "@paseo:app-settings";
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
localStorage.setItem(key, JSON.stringify({ ...settings, terminalScrollbackLines: 10000 }));
|
||||
});
|
||||
const terminal = await harness.createTerminal({
|
||||
name: `history-${count}`,
|
||||
command: "/bin/sh",
|
||||
args: [
|
||||
"-lc",
|
||||
`i=1; while [ $i -le ${count} ]; do printf '\\033[31mhistory-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 120`,
|
||||
],
|
||||
});
|
||||
const last = `history-${String(count).padStart(4, "0")}界🙂`;
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
|
||||
expect(await getTerminalBufferText(page)).toContain(retainedMarker);
|
||||
await page.reload();
|
||||
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
|
||||
expect(await getTerminalBufferText(page)).toContain(retainedMarker);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-rea
|
||||
export interface TerminalEmulatorHandle {
|
||||
find?: TerminalFindHandle;
|
||||
writeOutput: (data: TerminalOutputData) => void;
|
||||
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
|
||||
restoreOutput: (data: TerminalOutputData) => void;
|
||||
renderSnapshot: (state: TerminalState | null) => void;
|
||||
paste: (text: string) => void;
|
||||
|
||||
@@ -361,10 +361,13 @@ function NativeTerminalEmulator({
|
||||
(terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain =>
|
||||
createNativeTerminalOutputDrain({
|
||||
write: async (chunk) => {
|
||||
inputModeTrackerRef.current.feed(chunk);
|
||||
await terminal.write(chunk);
|
||||
emitInputModeChange();
|
||||
},
|
||||
reset: () => {
|
||||
reset: (size) => {
|
||||
inputModeTrackerRef.current.reset();
|
||||
if (size) terminal.resize(size);
|
||||
terminal.reset();
|
||||
emitInputModeChange();
|
||||
},
|
||||
@@ -477,19 +480,12 @@ function NativeTerminalEmulator({
|
||||
],
|
||||
);
|
||||
|
||||
const enqueueOutputText = useCallback(
|
||||
(text: string) => {
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
const inputModeUpdate = inputModeTrackerRef.current.feed(text);
|
||||
if (inputModeUpdate.changed) {
|
||||
emitInputModeChange();
|
||||
}
|
||||
outputDrainRef.current?.enqueueText(text);
|
||||
},
|
||||
[emitInputModeChange],
|
||||
);
|
||||
const enqueueOutputText = useCallback((text: string) => {
|
||||
if (text.length === 0) {
|
||||
return;
|
||||
}
|
||||
outputDrainRef.current?.enqueueText(text);
|
||||
}, []);
|
||||
|
||||
const claimActiveTerminalSize = useCallback(() => {
|
||||
activeResizeClaimTokenRef.current += 1;
|
||||
@@ -512,6 +508,7 @@ function NativeTerminalEmulator({
|
||||
const text = outputDecoderRef.current.decode(data, { stream: true });
|
||||
enqueueOutputText(text);
|
||||
},
|
||||
replay: (event) => outputDrainRef.current?.replay(event),
|
||||
restoreOutput: (data: TerminalOutputData) => {
|
||||
outputDecoderRef.current.decode();
|
||||
resetInputModeTracker();
|
||||
|
||||
@@ -40,6 +40,7 @@ import { focusWithRetries } from "../utils/web-focus";
|
||||
export interface TerminalEmulatorHandle {
|
||||
find?: TerminalFindHandle;
|
||||
writeOutput: (data: TerminalOutputData) => void;
|
||||
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
|
||||
restoreOutput: (data: TerminalOutputData) => void;
|
||||
renderSnapshot: (state: TerminalState | null) => void;
|
||||
paste: (text: string) => void;
|
||||
@@ -253,6 +254,18 @@ export default function TerminalEmulator({
|
||||
const data = args[0];
|
||||
if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
|
||||
},
|
||||
replay: (event) => {
|
||||
if (!event || typeof event !== "object" || Array.isArray(event)) return;
|
||||
if (event.type === "end") runtimeRef.current?.replay({ type: "end" });
|
||||
if (event.type === "data" && event.data instanceof Uint8Array)
|
||||
runtimeRef.current?.replay({ type: "data", data: event.data });
|
||||
if (event.type === "begin")
|
||||
runtimeRef.current?.replay({
|
||||
type: "begin",
|
||||
rows: typeof event.rows === "number" ? event.rows : undefined,
|
||||
cols: typeof event.cols === "number" ? event.cols : undefined,
|
||||
});
|
||||
},
|
||||
restoreOutput: (...args) => {
|
||||
const data = args[0];
|
||||
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data });
|
||||
@@ -299,6 +312,7 @@ export default function TerminalEmulator({
|
||||
writeOutput: (data: TerminalOutputData) => {
|
||||
runtimeRef.current?.write({ data });
|
||||
},
|
||||
replay: (event) => runtimeRef.current?.replay(event),
|
||||
restoreOutput: (data: TerminalOutputData) => {
|
||||
runtimeRef.current?.restoreOutput({ data });
|
||||
},
|
||||
|
||||
@@ -15,7 +15,6 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native";
|
||||
import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
DEFAULT_TERMINAL_INPUT_MODE_STATE,
|
||||
type TerminalInputModeState,
|
||||
@@ -60,7 +59,6 @@ import { getDesktopHost } from "@/desktop/host";
|
||||
import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop";
|
||||
import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
|
||||
import {
|
||||
EMPTY_FOCUS_CLAIM_STATE,
|
||||
canRequestFocusClaim,
|
||||
@@ -88,7 +86,6 @@ import { isNative } from "@/constants/platform";
|
||||
import {
|
||||
applyTerminalRendererReadyChange,
|
||||
resolveTerminalStreamTarget,
|
||||
shouldReplayTerminalSnapshotForRenderer,
|
||||
shouldShowTerminalLoadingOverlay,
|
||||
type TerminalRendererReadyChange,
|
||||
} from "@/utils/terminal-renderer-readiness";
|
||||
@@ -316,10 +313,6 @@ export function TerminalPane({
|
||||
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||
const workspaceTerminalSession = useMemo(
|
||||
() => getWorkspaceTerminalSession({ scopeKey }),
|
||||
[scopeKey],
|
||||
);
|
||||
const [isAttaching, setIsAttaching] = useState(false);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
|
||||
@@ -383,7 +376,6 @@ export function TerminalPane({
|
||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
||||
const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE);
|
||||
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
|
||||
|
||||
useEffect(() => {
|
||||
terminalIdRef.current = terminalId;
|
||||
@@ -441,16 +433,8 @@ export function TerminalPane({
|
||||
(change: TerminalRendererReadyChange) => {
|
||||
if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset();
|
||||
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
|
||||
if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = workspaceTerminalSession.snapshots.get({ terminalId });
|
||||
if (snapshot) {
|
||||
emulatorRef.current?.renderSnapshot(snapshot);
|
||||
}
|
||||
},
|
||||
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
|
||||
[terminalStreamKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -564,7 +548,6 @@ export function TerminalPane({
|
||||
);
|
||||
|
||||
const handleStreamExit = useStableEvent((exitedTerminalId: string) => {
|
||||
workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId });
|
||||
if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear();
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
});
|
||||
@@ -604,29 +587,10 @@ export function TerminalPane({
|
||||
},
|
||||
);
|
||||
|
||||
const handleStreamRestore = useStableEvent(
|
||||
({ terminalId: restoreTerminalId, data }: { terminalId: string; data: Uint8Array }) => {
|
||||
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
|
||||
if (terminalIdRef.current !== restoreTerminalId) {
|
||||
return;
|
||||
}
|
||||
emulatorRef.current?.restoreOutput(data);
|
||||
},
|
||||
);
|
||||
|
||||
const handleStreamSnapshot = useStableEvent(
|
||||
({ terminalId: snapshotTerminalId, state }: { terminalId: string; state: TerminalState }) => {
|
||||
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
|
||||
if (terminalIdRef.current !== snapshotTerminalId) {
|
||||
return;
|
||||
}
|
||||
emulatorRef.current?.renderSnapshot(state);
|
||||
},
|
||||
);
|
||||
|
||||
const getStreamRestoreOptions = useStableEvent(() =>
|
||||
resolveTerminalRestoreOptions({
|
||||
supportsTerminalRestoreModes,
|
||||
scrollbackLines: settings.terminalScrollbackLines,
|
||||
canClaimSize: canRequestFocusClaim({
|
||||
isWorkspaceFocused: terminalPresentedRef.current,
|
||||
isPaneFocused,
|
||||
@@ -653,8 +617,9 @@ export function TerminalPane({
|
||||
client,
|
||||
getPreferredSize: getPreferredStreamSize,
|
||||
onOutput: handleStreamOutput,
|
||||
onRestore: handleStreamRestore,
|
||||
onSnapshot: handleStreamSnapshot,
|
||||
onReplay: ({ terminalId: id, replay }) => {
|
||||
if (terminalIdRef.current === id) emulatorRef.current?.replay(replay);
|
||||
},
|
||||
onExit: handleStreamExit,
|
||||
getRestoreOptions: getStreamRestoreOptions,
|
||||
onStatusChange: handleStreamControllerStatus,
|
||||
@@ -674,8 +639,6 @@ export function TerminalPane({
|
||||
getStreamRestoreOptions,
|
||||
handleStreamControllerStatus,
|
||||
handleStreamOutput,
|
||||
handleStreamRestore,
|
||||
handleStreamSnapshot,
|
||||
handleStreamExit,
|
||||
isConnected,
|
||||
]);
|
||||
@@ -1216,7 +1179,6 @@ export function TerminalPane({
|
||||
keyboardInset={keyboardInset}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||
initialSnapshot={initialSnapshot}
|
||||
onRendererReadyChange={handleRendererReadyChange}
|
||||
onSwipeRight={handleSwipeRight}
|
||||
onSwipeLeft={handleSwipeLeft}
|
||||
|
||||
@@ -127,7 +127,6 @@ import {
|
||||
useWorkspaceSetupStore,
|
||||
} from "@/stores/workspace-setup-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||
import type { CheckoutStatusPayload } from "@/git/use-status-query";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
@@ -881,13 +880,6 @@ function shouldShowWorkspaceScreenHeader(input: {
|
||||
return !input.isFocusModeEnabled || input.isMobile;
|
||||
}
|
||||
|
||||
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return `${serverId}:${workspaceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pane the user acted inside owns the tab: it opens there, and an existing tab
|
||||
* moves there. No pane means the open has no opinion beyond the focused pane.
|
||||
@@ -1066,14 +1058,6 @@ function WorkspaceScreenContent({
|
||||
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
|
||||
useWorkspaceRouteActions(normalizedServerId);
|
||||
|
||||
const workspaceTerminalScopeKey = useMemo(
|
||||
() => buildWorkspaceTerminalScopeKey(normalizedServerId, normalizedWorkspaceId),
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
);
|
||||
useWorkspaceTerminalSessionRetention({
|
||||
scopeKey: workspaceTerminalScopeKey,
|
||||
});
|
||||
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const supportsProvidersSnapshot = useSessionStore(
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
releaseWorkspaceTerminalSession,
|
||||
retainWorkspaceTerminalSession,
|
||||
} from "@/terminal/runtime/workspace-terminal-session";
|
||||
|
||||
export function useWorkspaceTerminalSessionRetention(input: { scopeKey: string | null }): void {
|
||||
useEffect(() => {
|
||||
if (!input.scopeKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
retainWorkspaceTerminalSession({ scopeKey: input.scopeKey });
|
||||
return () => {
|
||||
releaseWorkspaceTerminalSession({ scopeKey: input.scopeKey! });
|
||||
};
|
||||
}, [input.scopeKey]);
|
||||
}
|
||||
@@ -268,3 +268,89 @@ describe("native terminal output drain", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("replays split Unicode and ANSI with one reset then live output", async () => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 40, scrollbackLines: 1000 });
|
||||
let resets = 0;
|
||||
const drain = createNativeTerminalOutputDrain({
|
||||
write: (text) => terminal.write(text),
|
||||
reset: (size) => {
|
||||
resets++;
|
||||
if (size) terminal.resize(size);
|
||||
terminal.reset();
|
||||
},
|
||||
getViewportState: () => terminal.getViewportState(),
|
||||
onPaint: () => {},
|
||||
});
|
||||
try {
|
||||
drain.replay({ type: "begin", rows: 4, cols: 40, retainedHistoryLines: 400 });
|
||||
const bytes = new TextEncoder().encode("\x1b[31m界🙂 replay\x1b[0m\r\n");
|
||||
for (let i = 0; i < bytes.length; i++)
|
||||
drain.replay({ type: "data", data: bytes.subarray(i, i + 1) });
|
||||
drain.replay({ type: "end" });
|
||||
drain.enqueueText("live");
|
||||
await drain.flush();
|
||||
expect(resets).toBe(1);
|
||||
expect(
|
||||
terminal
|
||||
.getBufferWindow({ startRow: 0, rowCount: 4 })
|
||||
.rows.map((row) =>
|
||||
row
|
||||
.filter((cell) => cell.width !== 0)
|
||||
.map((cell) => cell.char)
|
||||
.join(""),
|
||||
)
|
||||
.join("\n"),
|
||||
).toContain("界🙂 replay");
|
||||
expect(
|
||||
terminal
|
||||
.getBufferWindow({ startRow: 0, rowCount: 4 })
|
||||
.rows.map((row) =>
|
||||
row
|
||||
.filter((cell) => cell.width !== 0)
|
||||
.map((cell) => cell.char)
|
||||
.join(""),
|
||||
)
|
||||
.join("\n"),
|
||||
).toContain("live");
|
||||
} finally {
|
||||
drain.dispose();
|
||||
terminal.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("an empty replacement replay resets after an in-flight write finishes", async () => {
|
||||
let finish!: () => void;
|
||||
let started!: () => void;
|
||||
const writing = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
let resets = 0;
|
||||
const drain = createNativeTerminalOutputDrain({
|
||||
write: async () => {
|
||||
started();
|
||||
await blocked;
|
||||
},
|
||||
reset: () => {
|
||||
resets++;
|
||||
},
|
||||
getViewportState: () => null,
|
||||
onPaint: () => {},
|
||||
});
|
||||
try {
|
||||
drain.enqueueText("old terminal");
|
||||
await writing;
|
||||
drain.replay({ type: "begin" });
|
||||
drain.replay({ type: "end" });
|
||||
finish();
|
||||
await drain.flush();
|
||||
expect(resets).toBe(1);
|
||||
expect(drain.getQueuedChars()).toBe(0);
|
||||
} finally {
|
||||
finish();
|
||||
drain.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface NativeTerminalParseSample {
|
||||
}
|
||||
|
||||
export interface NativeTerminalOutputDrain {
|
||||
replay(event: import("@getpaseo/client").TerminalReplayEvent): void;
|
||||
enqueueText(text: string): void;
|
||||
restoreText(text: string): void;
|
||||
restoreSnapshot(snapshot: TerminalState): void;
|
||||
@@ -25,7 +26,7 @@ export interface NativeTerminalOutputDrain {
|
||||
|
||||
export interface NativeTerminalOutputDrainOptions {
|
||||
write: (chunk: string) => Promise<void>;
|
||||
reset: () => void;
|
||||
reset: (size?: { rows: number; cols: number }) => void;
|
||||
getViewportState: () => TerminalViewportState | null;
|
||||
onPaint: (state: TerminalViewportState) => void;
|
||||
onParse?: (sample: NativeTerminalParseSample) => void;
|
||||
@@ -75,9 +76,11 @@ export function createNativeTerminalOutputDrain(
|
||||
let paintFrame: number | null = null;
|
||||
let generation = 0;
|
||||
let resetBeforeNextWrite = false;
|
||||
let replaySize: { rows: number; cols: number } | undefined;
|
||||
const replayDecoder = new TextDecoder();
|
||||
|
||||
function resolveIdleIfReady(): void {
|
||||
if (drainScheduled || draining || pendingText.length > 0) {
|
||||
if (drainScheduled || draining || pendingText.length > 0 || resetBeforeNextWrite) {
|
||||
return;
|
||||
}
|
||||
while (idleWaiters.length > 0) {
|
||||
@@ -138,14 +141,16 @@ export function createNativeTerminalOutputDrain(
|
||||
let parsedChunks = 0;
|
||||
|
||||
try {
|
||||
if (resetBeforeNextWrite) {
|
||||
options.reset(replaySize);
|
||||
replaySize = undefined;
|
||||
resetBeforeNextWrite = false;
|
||||
requestPaint();
|
||||
}
|
||||
while (pendingText.length > 0 && parsedChunks < chunksPerTurn) {
|
||||
if (disposed || activeGeneration !== generation) {
|
||||
return;
|
||||
}
|
||||
if (resetBeforeNextWrite) {
|
||||
options.reset();
|
||||
resetBeforeNextWrite = false;
|
||||
}
|
||||
const chunk = pendingText.slice(0, chunkChars);
|
||||
pendingText = pendingText.slice(chunk.length);
|
||||
const parseStart = nativeTerminalPerformanceNow();
|
||||
@@ -163,7 +168,7 @@ export function createNativeTerminalOutputDrain(
|
||||
}
|
||||
} finally {
|
||||
draining = false;
|
||||
if (!disposed && pendingText.length > 0) {
|
||||
if (!disposed && (pendingText.length > 0 || resetBeforeNextWrite)) {
|
||||
scheduleDrain();
|
||||
}
|
||||
resolveIdleIfReady();
|
||||
@@ -171,6 +176,17 @@ export function createNativeTerminalOutputDrain(
|
||||
}
|
||||
|
||||
return {
|
||||
replay(event): void {
|
||||
if (event.type === "begin") {
|
||||
replayDecoder.decode();
|
||||
replaySize = event.rows && event.cols ? { rows: event.rows, cols: event.cols } : undefined;
|
||||
replacePendingText("");
|
||||
} else if (event.type === "data") {
|
||||
this.enqueueText(replayDecoder.decode(event.data, { stream: true }));
|
||||
} else {
|
||||
this.enqueueText(replayDecoder.decode());
|
||||
}
|
||||
},
|
||||
enqueueText(text: string): void {
|
||||
if (disposed || text.length === 0) {
|
||||
return;
|
||||
@@ -202,7 +218,7 @@ export function createNativeTerminalOutputDrain(
|
||||
},
|
||||
|
||||
flush(): Promise<void> {
|
||||
if (!drainScheduled && !draining && pendingText.length === 0) {
|
||||
if (!drainScheduled && !draining && pendingText.length === 0 && !resetBeforeNextWrite) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
scheduleDrain();
|
||||
|
||||
@@ -795,3 +795,48 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("incremental replay lifecycle", () => {
|
||||
it.each(["clear", "remount"] as const)(
|
||||
"recovers live input after interrupted replay and %s",
|
||||
async (cleanup) => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
mounted.runtime.replay({ type: "begin" });
|
||||
mounted.runtime.replay({ type: "data", data: terminalOutput("interrupted") });
|
||||
if (cleanup === "clear")
|
||||
await new Promise<void>((resolve) => mounted.runtime.clear({ onCommitted: resolve }));
|
||||
else {
|
||||
mounted.runtime.unmount();
|
||||
mounted.runtime.mount({
|
||||
root: mounted.root,
|
||||
host: mounted.host,
|
||||
initialSnapshot: null,
|
||||
scrollback: 1000,
|
||||
theme: {},
|
||||
});
|
||||
}
|
||||
await new Promise<void>((resolve) =>
|
||||
mounted.runtime.write({ data: terminalOutput("live"), onCommitted: resolve }),
|
||||
);
|
||||
getBrowserTerminal().input("user-input", true);
|
||||
expect(mounted.inputs).toContain("user-input");
|
||||
},
|
||||
);
|
||||
|
||||
it("suppresses generated replies until queued replay chunks commit after end", async () => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
mounted.runtime.replay({ type: "begin" });
|
||||
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") });
|
||||
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") });
|
||||
mounted.runtime.replay({ type: "end" });
|
||||
await new Promise<void>((resolve) =>
|
||||
mounted.runtime.write({ data: terminalOutput("committed"), onCommitted: resolve }),
|
||||
);
|
||||
expect(mounted.inputs).toEqual([]);
|
||||
await new Promise<void>((resolve) =>
|
||||
mounted.runtime.write({ data: terminalOutput("\x1b]4;1;?\x07"), onCommitted: resolve }),
|
||||
);
|
||||
expect(mounted.inputs).toHaveLength(1);
|
||||
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -911,6 +911,14 @@ export class TerminalEmulatorRuntime {
|
||||
});
|
||||
}
|
||||
|
||||
replay(event: import("@getpaseo/client").TerminalReplayEvent): void {
|
||||
if (event.type === "begin") {
|
||||
this.restoreOutput({ data: EMPTY_TERMINAL_OUTPUT, rows: event.rows, cols: event.cols });
|
||||
} else if (event.type === "data") {
|
||||
this.write({ data: event.data, suppressInput: true });
|
||||
}
|
||||
}
|
||||
|
||||
restoreOutput(input: {
|
||||
data: TerminalOutputData;
|
||||
rows?: number;
|
||||
|
||||
@@ -6,6 +6,7 @@ describe("terminal restore options", () => {
|
||||
it("omits restore options for daemons without terminal restore modes", () => {
|
||||
expect(
|
||||
resolveTerminalRestoreOptions({
|
||||
scrollbackLines: 10000,
|
||||
supportsTerminalRestoreModes: false,
|
||||
canClaimSize: true,
|
||||
size: { rows: 24, cols: 80 },
|
||||
@@ -13,16 +14,17 @@ describe("terminal restore options", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requests visible snapshot restore with bounded scrollback for capable daemons", () => {
|
||||
it("requests visible snapshot restore with configured scrollback for capable daemons", () => {
|
||||
expect(
|
||||
resolveTerminalRestoreOptions({
|
||||
scrollbackLines: 10000,
|
||||
supportsTerminalRestoreModes: true,
|
||||
canClaimSize: true,
|
||||
size: { rows: 24, cols: 80 },
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "visible-snapshot",
|
||||
scrollbackLines: 200,
|
||||
scrollbackLines: 10000,
|
||||
size: { rows: 24, cols: 80 },
|
||||
});
|
||||
});
|
||||
@@ -30,26 +32,39 @@ describe("terminal restore options", () => {
|
||||
it("omits size until the terminal has been measured", () => {
|
||||
expect(
|
||||
resolveTerminalRestoreOptions({
|
||||
scrollbackLines: 10000,
|
||||
supportsTerminalRestoreModes: true,
|
||||
canClaimSize: true,
|
||||
size: null,
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "visible-snapshot",
|
||||
scrollbackLines: 200,
|
||||
scrollbackLines: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a background attach resize the PTY", () => {
|
||||
expect(
|
||||
resolveTerminalRestoreOptions({
|
||||
scrollbackLines: 10000,
|
||||
supportsTerminalRestoreModes: true,
|
||||
canClaimSize: false,
|
||||
size: { rows: 24, cols: 80 },
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "visible-snapshot",
|
||||
scrollbackLines: 200,
|
||||
scrollbackLines: 10000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([0, 5, 400, 10000])("requests exactly %s configured history rows", (scrollbackLines) => {
|
||||
expect(
|
||||
resolveTerminalRestoreOptions({
|
||||
supportsTerminalRestoreModes: true,
|
||||
scrollbackLines,
|
||||
canClaimSize: false,
|
||||
size: null,
|
||||
}),
|
||||
).toEqual({ mode: "visible-snapshot", scrollbackLines });
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
|
||||
|
||||
export const TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES = 200;
|
||||
|
||||
export interface ResolveTerminalRestoreOptionsInput {
|
||||
supportsTerminalRestoreModes: boolean;
|
||||
scrollbackLines: number;
|
||||
canClaimSize: boolean;
|
||||
size: { rows: number; cols: number } | null;
|
||||
}
|
||||
@@ -17,7 +16,7 @@ export function resolveTerminalRestoreOptions(
|
||||
|
||||
return {
|
||||
mode: "visible-snapshot",
|
||||
scrollbackLines: TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES,
|
||||
scrollbackLines: input.scrollbackLines,
|
||||
...(input.canClaimSize && input.size ? { size: input.size } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,34 +7,16 @@ import {
|
||||
type TerminalStreamControllerStatus,
|
||||
} from "./terminal-stream-controller";
|
||||
|
||||
interface TerminalSnapshot {
|
||||
rows: number;
|
||||
cols: number;
|
||||
grid: Array<Array<{ char: string }>>;
|
||||
scrollback: Array<Array<{ char: string }>>;
|
||||
cursor: { row: number; col: number };
|
||||
}
|
||||
|
||||
function terminalCellText(cell: { char: string }): string {
|
||||
return cell.char;
|
||||
}
|
||||
|
||||
function terminalRowText(row: Array<{ char: string }>): string {
|
||||
return row.map(terminalCellText).join("");
|
||||
}
|
||||
|
||||
function terminalSnapshotText(state: TerminalSnapshot): string {
|
||||
return state.grid.map(terminalRowText).join("\n");
|
||||
}
|
||||
|
||||
function terminalOutput(text: string): Uint8Array {
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
|
||||
type TerminalStreamEvent =
|
||||
| { terminalId: string; type: "output"; data: Uint8Array }
|
||||
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot }
|
||||
| { terminalId: string; type: "restore"; data: Uint8Array };
|
||||
type StreamEvent = import("@getpaseo/client").TerminalStreamEvent;
|
||||
type TerminalStreamEvent = StreamEvent extends infer Event
|
||||
? Event extends StreamEvent
|
||||
? Omit<Event, "subscriptionId">
|
||||
: never
|
||||
: never;
|
||||
|
||||
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
||||
private readonly updates = new Set<(message: SessionOutboundMessage) => void>();
|
||||
@@ -115,32 +97,23 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
||||
function createHarness(input?: { client?: FakeTerminalStreamClient }) {
|
||||
const client = input?.client ?? new FakeTerminalStreamClient();
|
||||
const outputs: Array<{ terminalId: string; data: Uint8Array }> = [];
|
||||
const restores: Array<{ terminalId: string; data: Uint8Array }> = [];
|
||||
const snapshots: Array<{ terminalId: string; text: string }> = [];
|
||||
const replays: import("@getpaseo/client").TerminalReplayEvent[] = [];
|
||||
const statuses: TerminalStreamControllerStatus[] = [];
|
||||
const exits: string[] = [];
|
||||
const controller = new TerminalStreamController({
|
||||
client,
|
||||
onReplay: ({ replay }) => replays.push(replay),
|
||||
onExit: (terminalId) => exits.push(terminalId),
|
||||
getPreferredSize: () => ({ rows: 24, cols: 80 }),
|
||||
onOutput: (output) => {
|
||||
outputs.push(output);
|
||||
},
|
||||
onRestore: (restore) => {
|
||||
restores.push(restore);
|
||||
},
|
||||
onSnapshot: ({ terminalId, state }) => {
|
||||
snapshots.push({
|
||||
terminalId,
|
||||
text: terminalSnapshotText(state),
|
||||
});
|
||||
},
|
||||
onStatusChange: (status) => {
|
||||
statuses.push(status);
|
||||
},
|
||||
});
|
||||
|
||||
return { client, controller, outputs, restores, snapshots, statuses, exits };
|
||||
return { client, controller, outputs, replays, statuses, exits };
|
||||
}
|
||||
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
@@ -149,24 +122,20 @@ async function flushAsyncWork(): Promise<void> {
|
||||
}
|
||||
|
||||
describe("terminal-stream-controller", () => {
|
||||
it("subscribes, resizes, and forwards snapshot/output events", async () => {
|
||||
it("subscribes, resizes, and forwards replay/output events", async () => {
|
||||
const harness = createHarness();
|
||||
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-1" });
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.client.emit({
|
||||
terminalId: "term-1",
|
||||
type: "snapshot",
|
||||
state: {
|
||||
rows: 1,
|
||||
cols: 5,
|
||||
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 5 },
|
||||
},
|
||||
});
|
||||
const replay: import("@getpaseo/client").TerminalReplayEvent[] = [
|
||||
{ type: "begin", rows: 24, cols: 80, retainedHistoryLines: 400 },
|
||||
{ type: "data", data: terminalOutput("hello") },
|
||||
{ type: "end" },
|
||||
];
|
||||
for (const event of replay)
|
||||
harness.client.emit({ terminalId: "term-1", type: "replay", replay: event });
|
||||
const outputData = terminalOutput(" world");
|
||||
harness.client.emit({
|
||||
terminalId: "term-1",
|
||||
@@ -178,7 +147,7 @@ describe("terminal-stream-controller", () => {
|
||||
expect(harness.client.resizeCalls).toEqual([
|
||||
{ terminalId: "term-1", rows: 24, cols: 80, intent: "claim" },
|
||||
]);
|
||||
expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]);
|
||||
expect(harness.replays).toEqual(replay);
|
||||
expect(harness.outputs[0]?.data).toBe(outputData);
|
||||
expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]);
|
||||
expect(harness.statuses.at(-1)).toEqual({
|
||||
@@ -236,23 +205,19 @@ describe("terminal-stream-controller", () => {
|
||||
size: { rows: 24, cols: 80 },
|
||||
}),
|
||||
onOutput: (output) => harness.outputs.push(output),
|
||||
onRestore: (restore) => harness.restores.push(restore),
|
||||
onSnapshot: ({ terminalId, state }) => {
|
||||
harness.snapshots.push({
|
||||
terminalId,
|
||||
text: terminalSnapshotText(state),
|
||||
});
|
||||
},
|
||||
onReplay: ({ replay }) => harness.replays.push(replay),
|
||||
});
|
||||
|
||||
controller.setTerminal({ terminalId: "term-1" });
|
||||
await flushAsyncWork();
|
||||
const restoreData = terminalOutput("restored");
|
||||
client.emit({
|
||||
terminalId: "term-1",
|
||||
type: "restore",
|
||||
data: restoreData,
|
||||
});
|
||||
const replay: import("@getpaseo/client").TerminalReplayEvent[] = [
|
||||
{ type: "begin" },
|
||||
{ type: "data", data: restoreData },
|
||||
{ type: "end" },
|
||||
];
|
||||
for (const event of replay)
|
||||
client.emit({ terminalId: "term-1", type: "replay", replay: event });
|
||||
controller.dispose();
|
||||
|
||||
expect(client.subscribeCalls).toEqual([
|
||||
@@ -270,8 +235,7 @@ describe("terminal-stream-controller", () => {
|
||||
expect(client.resizeCalls).toEqual([
|
||||
{ terminalId: "term-1", rows: 24, cols: 80, intent: "claim" },
|
||||
]);
|
||||
expect(harness.restores[0]?.data).toBe(restoreData);
|
||||
expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]);
|
||||
expect(harness.replays).toEqual(replay);
|
||||
expect(harness.outputs).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { SubscribeTerminalRequest, TerminalState } from "@getpaseo/protocol/messages";
|
||||
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalOutputData } from "./terminal-emulator-runtime";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -23,9 +23,11 @@ export interface TerminalStreamControllerOptions {
|
||||
client: TerminalStreamControllerClient;
|
||||
getPreferredSize: () => TerminalStreamControllerSize | null;
|
||||
onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void;
|
||||
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
|
||||
onExit?: (terminalId: string) => void;
|
||||
onRestore?: (input: { terminalId: string; data: TerminalOutputData }) => void;
|
||||
onReplay?: (input: {
|
||||
terminalId: string;
|
||||
replay: import("@getpaseo/client").TerminalReplayEvent;
|
||||
}) => void;
|
||||
getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined;
|
||||
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
|
||||
}
|
||||
@@ -43,17 +45,11 @@ export class TerminalStreamController {
|
||||
if (this.disposed || event.terminalId !== this.terminalId) {
|
||||
return;
|
||||
}
|
||||
if (event.type === "snapshot") {
|
||||
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state });
|
||||
if (event.type === "replay") {
|
||||
this.options.onReplay?.({ terminalId: event.terminalId, replay: event.replay });
|
||||
return;
|
||||
}
|
||||
if (event.type === "restore") {
|
||||
if (event.data.length > 0) {
|
||||
this.options.onRestore?.({ terminalId: event.terminalId, data: event.data });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.data.length > 0) {
|
||||
if (event.type === "output" && event.data.length > 0) {
|
||||
this.options.onOutput({ terminalId: event.terminalId, data: event.data });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getWorkspaceTerminalSession,
|
||||
releaseWorkspaceTerminalSession,
|
||||
retainWorkspaceTerminalSession,
|
||||
} from "./workspace-terminal-session";
|
||||
|
||||
describe("workspace-terminal-session", () => {
|
||||
it("returns the same workspace session instance for the same scope", () => {
|
||||
const first = getWorkspaceTerminalSession({
|
||||
scopeKey: "workspace-a",
|
||||
});
|
||||
const second = getWorkspaceTerminalSession({
|
||||
scopeKey: "workspace-a",
|
||||
});
|
||||
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("preserves snapshots across repeated lookups", () => {
|
||||
const first = getWorkspaceTerminalSession({
|
||||
scopeKey: "workspace-snapshots",
|
||||
});
|
||||
first.snapshots.set({
|
||||
terminalId: "term-1",
|
||||
state: {
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
const second = getWorkspaceTerminalSession({
|
||||
scopeKey: "workspace-snapshots",
|
||||
});
|
||||
|
||||
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 retain count returns to zero", () => {
|
||||
const scopeKey = "workspace-release";
|
||||
const first = getWorkspaceTerminalSession({
|
||||
scopeKey,
|
||||
});
|
||||
first.snapshots.set({
|
||||
terminalId: "term-1",
|
||||
state: {
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
retainWorkspaceTerminalSession({ scopeKey });
|
||||
releaseWorkspaceTerminalSession({ scopeKey });
|
||||
|
||||
const second = getWorkspaceTerminalSession({
|
||||
scopeKey,
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.snapshots.get({ terminalId: "term-1" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
|
||||
export interface WorkspaceTerminalSnapshots {
|
||||
get: (input: { terminalId: string }) => TerminalState | null;
|
||||
set: (input: { terminalId: string; state: TerminalState }) => void;
|
||||
clear: (input: { terminalId: string }) => void;
|
||||
prune: (input: { terminalIds: string[] }) => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceTerminalSession {
|
||||
scopeKey: string;
|
||||
snapshots: WorkspaceTerminalSnapshots;
|
||||
}
|
||||
|
||||
interface WorkspaceTerminalSessionRecord {
|
||||
snapshotByTerminalId: Map<string, TerminalState>;
|
||||
session: WorkspaceTerminalSession;
|
||||
}
|
||||
|
||||
const sessionsByScopeKey = new Map<string, WorkspaceTerminalSessionRecord>();
|
||||
const refCountByScopeKey = new Map<string, number>();
|
||||
|
||||
function createSnapshots(input: {
|
||||
snapshotByTerminalId: Map<string, TerminalState>;
|
||||
}): WorkspaceTerminalSnapshots {
|
||||
return {
|
||||
get: ({ terminalId }) => input.snapshotByTerminalId.get(terminalId) ?? null,
|
||||
set: ({ terminalId, state }) => {
|
||||
input.snapshotByTerminalId.set(terminalId, state);
|
||||
},
|
||||
clear: ({ terminalId }) => {
|
||||
input.snapshotByTerminalId.delete(terminalId);
|
||||
},
|
||||
prune: ({ terminalIds }) => {
|
||||
const terminalIdSet = new Set(terminalIds);
|
||||
for (const terminalId of Array.from(input.snapshotByTerminalId.keys())) {
|
||||
if (!terminalIdSet.has(terminalId)) {
|
||||
input.snapshotByTerminalId.delete(terminalId);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkspaceTerminalSession(input: { scopeKey: string }): WorkspaceTerminalSession {
|
||||
const existing = sessionsByScopeKey.get(input.scopeKey);
|
||||
if (existing) {
|
||||
return existing.session;
|
||||
}
|
||||
|
||||
const snapshotByTerminalId = new Map<string, TerminalState>();
|
||||
const session: WorkspaceTerminalSession = {
|
||||
scopeKey: input.scopeKey,
|
||||
snapshots: createSnapshots({
|
||||
snapshotByTerminalId,
|
||||
}),
|
||||
};
|
||||
|
||||
sessionsByScopeKey.set(input.scopeKey, {
|
||||
snapshotByTerminalId,
|
||||
session,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void {
|
||||
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
|
||||
refCountByScopeKey.set(input.scopeKey, current + 1);
|
||||
}
|
||||
|
||||
export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void {
|
||||
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
|
||||
if (current > 1) {
|
||||
refCountByScopeKey.set(input.scopeKey, current - 1);
|
||||
return;
|
||||
}
|
||||
refCountByScopeKey.delete(input.scopeKey);
|
||||
sessionsByScopeKey.delete(input.scopeKey);
|
||||
}
|
||||
@@ -125,6 +125,7 @@ export class ConnectionSubscriptions extends OwnedSubscriptions {
|
||||
// Protocol support belongs to the installed client. Only browser hosting needs
|
||||
// a resource supplied by the caller. Keep this exhaustive as the protocol evolves.
|
||||
export const DEFAULT_CLIENT_CAPABILITIES = {
|
||||
[CLIENT_CAPS.terminalChunkedReplay]: true,
|
||||
[CLIENT_CAPS.helloRejection]: true,
|
||||
[CLIENT_CAPS.ownedSubscriptions]: true,
|
||||
[CLIENT_CAPS.allProviders]: true,
|
||||
|
||||
@@ -1101,6 +1101,7 @@ test("advertises client capabilities in hello", async () => {
|
||||
projected_subagent_timeline: true,
|
||||
reasoning_merge_enum: true,
|
||||
terminal_reflowable_snapshot: true,
|
||||
terminal_chunked_replay: true,
|
||||
timeline_notifications: true,
|
||||
plugin_timeline_items: true,
|
||||
workspace_setup_blocked: true,
|
||||
@@ -6387,66 +6388,80 @@ test("emits output events for the active terminal stream", async () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("emits snapshot events for the subscribed terminal stream", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
test.each([5, 500, 10000])(
|
||||
"preserves explicit %s history demand on an old ANSI host and normalizes restore",
|
||||
async (history) => {
|
||||
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 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 connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const snapshots: unknown[] = [];
|
||||
client.onTerminalStreamEvent((event) => {
|
||||
if (event.type !== "snapshot") {
|
||||
return;
|
||||
}
|
||||
snapshots.push(event.state);
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const replayEvents: unknown[] = [];
|
||||
client.onTerminalStreamEvent((event) => {
|
||||
if (event.type === "replay") replayEvents.push(event.replay);
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-2" });
|
||||
await vi.waitFor(() =>
|
||||
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
||||
);
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "subscribe_terminal_response",
|
||||
payload: {
|
||||
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
||||
terminalId: "term-1",
|
||||
const subscribePromise = client.subscribeTerminal("term-1", {
|
||||
requestId: "sub-2",
|
||||
restore: { mode: "visible-snapshot", scrollbackLines: history },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
||||
);
|
||||
expect(parseSentFrame(mock.sent.at(-1)).restore).toEqual({
|
||||
mode: "visible-snapshot",
|
||||
scrollbackLines: history,
|
||||
});
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "subscribe_terminal_response",
|
||||
payload: {
|
||||
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
||||
terminalId: "term-1",
|
||||
slot: 12,
|
||||
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,
|
||||
slot: 12,
|
||||
error: null,
|
||||
requestId: "sub-2",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await subscribePromise;
|
||||
payload: encodeTerminalSnapshotPayload(state),
|
||||
}),
|
||||
);
|
||||
|
||||
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,
|
||||
slot: 12,
|
||||
payload: encodeTerminalSnapshotPayload(state),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(snapshots).toEqual([state]);
|
||||
});
|
||||
expect(events).toEqual([]);
|
||||
const data = new TextEncoder().encode("\x1b[31mold ANSI host界🙂");
|
||||
mock.triggerMessage(
|
||||
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 12, payload: data }),
|
||||
);
|
||||
expect(replayEvents).toEqual([{ type: "begin" }, { type: "data", data }, { type: "end" }]);
|
||||
},
|
||||
);
|
||||
|
||||
test("sends explicit terminal input and resize commands", async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
@@ -6414,7 +6414,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot/restore and output arrive in receive. Subscribe to the returned handle
|
||||
* Replay begin/data/end and live output arrive in receive. Subscribe to the returned handle
|
||||
* for terminal_stream_exit: payload.error means observation failure, not PTY exit.
|
||||
* Either outcome detaches this slot and releases the handle automatically.
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export type { TerminalReplayEvent, TerminalStreamEvent } from "./terminal-stream-router.js";
|
||||
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
|
||||
export type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, test } from "vitest";
|
||||
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
|
||||
|
||||
describe("terminal-stream-router", () => {
|
||||
test("routes restore frames as restore events", () => {
|
||||
test("normalizes old ANSI restore into the replay lifecycle", () => {
|
||||
const router = new TerminalStreamRouter();
|
||||
const events: TerminalStreamEvent[] = [];
|
||||
const payload = new TextEncoder().encode("restored screen");
|
||||
@@ -16,14 +16,14 @@ describe("terminal-stream-router", () => {
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
expect(events).toEqual(
|
||||
["begin", "data", "end"].map((type) => ({
|
||||
terminalId: "term-1",
|
||||
subscriptionId: "subscription-1",
|
||||
type: "restore",
|
||||
data: payload,
|
||||
},
|
||||
]);
|
||||
type: "replay",
|
||||
replay: type === "data" ? { type, data: payload } : { type },
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,3 +61,45 @@ test("legacy listeners share a terminal slot without releasing each other", () =
|
||||
router.handleFrame(frame);
|
||||
expect(received).toEqual(["first", "second", "second"]);
|
||||
});
|
||||
|
||||
test("streams chunks without joining and ignores released slots during replay", () => {
|
||||
const router = new TerminalStreamRouter();
|
||||
const events: TerminalStreamEvent[] = [];
|
||||
const release = router.attach("old", "terminal", 4, (event) => events.push(event));
|
||||
const payload = new TextEncoder().encode(
|
||||
JSON.stringify({ rows: 24, cols: 80, retainedHistoryLines: 1000 }),
|
||||
);
|
||||
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayBegin, slot: 4, payload });
|
||||
const data = new TextEncoder().encode("\x1b[31m界🙂");
|
||||
router.handleFrame({
|
||||
opcode: TerminalStreamOpcode.ReplayData,
|
||||
slot: 4,
|
||||
payload: data.subarray(0, 7),
|
||||
});
|
||||
release();
|
||||
router.attach("new", "terminal", 5, (event) => events.push(event));
|
||||
router.handleFrame({
|
||||
opcode: TerminalStreamOpcode.ReplayData,
|
||||
slot: 4,
|
||||
payload: data.subarray(7),
|
||||
});
|
||||
router.handleFrame({
|
||||
opcode: TerminalStreamOpcode.ReplayEnd,
|
||||
slot: 4,
|
||||
payload: new Uint8Array(),
|
||||
});
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[1]).toMatchObject({
|
||||
subscriptionId: "old",
|
||||
type: "replay",
|
||||
replay: { type: "data", data: data.subarray(0, 7) },
|
||||
});
|
||||
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayBegin, slot: 5, payload });
|
||||
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayData, slot: 5, payload: data });
|
||||
router.handleFrame({
|
||||
opcode: TerminalStreamOpcode.ReplayEnd,
|
||||
slot: 5,
|
||||
payload: new Uint8Array(),
|
||||
});
|
||||
expect(events.slice(2).map((event) => event.subscriptionId)).toEqual(["new", "new", "new"]);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {
|
||||
decodeTerminalSnapshotPayload,
|
||||
TerminalStreamOpcode,
|
||||
TerminalReplayMetadataSchema,
|
||||
type TerminalStreamFrame,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type TerminalReplayEvent =
|
||||
| { type: "begin"; rows?: number; cols?: number; retainedHistoryLines?: number }
|
||||
| { type: "data"; data: Uint8Array }
|
||||
| { type: "end" };
|
||||
|
||||
export type TerminalStreamEvent = { terminalId: string; subscriptionId: string } & (
|
||||
| { type: "output"; data: Uint8Array }
|
||||
| { type: "snapshot"; state: TerminalState }
|
||||
| { type: "restore"; data: Uint8Array }
|
||||
| { type: "replay"; replay: TerminalReplayEvent }
|
||||
);
|
||||
|
||||
export class TerminalStreamRouter {
|
||||
@@ -61,20 +64,42 @@ export class TerminalStreamRouter {
|
||||
let event: TerminalStreamEvent;
|
||||
if (frame.opcode === TerminalStreamOpcode.Output)
|
||||
event = { ...identity, type: "output", data: frame.payload };
|
||||
else if (frame.opcode === TerminalStreamOpcode.Restore)
|
||||
event = { ...identity, type: "restore", data: frame.payload };
|
||||
else if (frame.opcode === TerminalStreamOpcode.Snapshot) {
|
||||
const state = decodeTerminalSnapshotPayload(frame.payload);
|
||||
if (!state) return;
|
||||
event = { ...identity, type: "snapshot", state };
|
||||
} else return;
|
||||
registration.receive(event);
|
||||
for (const listener of this.listeners) {
|
||||
else if (frame.opcode === TerminalStreamOpcode.Restore) {
|
||||
// COMPAT(terminalSingleRestore): added after v0.14.0; remove when ANSI host floor supports chunked replay after 2027-04-30.
|
||||
for (const replay of [
|
||||
{ type: "begin" },
|
||||
{ type: "data", data: frame.payload },
|
||||
{ type: "end" },
|
||||
] as TerminalReplayEvent[])
|
||||
this.deliver(registration.receive, { ...identity, type: "replay", replay });
|
||||
continue;
|
||||
} else if (frame.opcode === TerminalStreamOpcode.ReplayBegin) {
|
||||
let raw: unknown;
|
||||
try {
|
||||
listener(event);
|
||||
raw = JSON.parse(new TextDecoder().decode(frame.payload));
|
||||
} catch {
|
||||
/* Passive diagnostics cannot interrupt delivery. */
|
||||
return;
|
||||
}
|
||||
const parsed = TerminalReplayMetadataSchema.safeParse(raw);
|
||||
if (!parsed.success) return;
|
||||
const metadata = parsed.data;
|
||||
event = { ...identity, type: "replay", replay: { type: "begin", ...metadata } };
|
||||
} else if (frame.opcode === TerminalStreamOpcode.ReplayData)
|
||||
event = { ...identity, type: "replay", replay: { type: "data", data: frame.payload } };
|
||||
else if (frame.opcode === TerminalStreamOpcode.ReplayEnd)
|
||||
event = { ...identity, type: "replay", replay: { type: "end" } };
|
||||
else return;
|
||||
this.deliver(registration.receive, event);
|
||||
}
|
||||
}
|
||||
|
||||
private deliver(receive: (event: TerminalStreamEvent) => void, event: TerminalStreamEvent): void {
|
||||
receive(event);
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
/* Passive diagnostics cannot interrupt delivery. */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,3 +89,18 @@ describe("host route binary framing", () => {
|
||||
).toThrow("Invalid host route ID");
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
TerminalStreamOpcode.ReplayBegin,
|
||||
TerminalStreamOpcode.ReplayData,
|
||||
TerminalStreamOpcode.ReplayEnd,
|
||||
])("decodes replay opcode %s directly and inside a host route", (opcode) => {
|
||||
const payload = new TextEncoder().encode("界 replay");
|
||||
const bytes = encodeTerminalStreamFrame({ opcode, slot: 12, payload });
|
||||
const expected = { kind: "terminal", frame: { opcode, slot: 12, payload } };
|
||||
expect(decodeBinaryFrame(bytes)).toEqual(expected);
|
||||
const routed = decodeBinaryFrame(encodeHostRouteFrame("host", bytes));
|
||||
expect(routed?.kind).toBe("host_route");
|
||||
if (routed?.kind !== "host_route") throw new Error("Missing host route");
|
||||
expect(decodeBinaryFrame(routed.frame.payload)).toEqual(expected);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,9 @@ export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
|
||||
case TerminalStreamOpcode.Input:
|
||||
case TerminalStreamOpcode.Resize:
|
||||
case TerminalStreamOpcode.Snapshot:
|
||||
case TerminalStreamOpcode.ReplayBegin:
|
||||
case TerminalStreamOpcode.ReplayData:
|
||||
case TerminalStreamOpcode.ReplayEnd:
|
||||
case TerminalStreamOpcode.Restore: {
|
||||
const frame = decodeTerminalStreamFrame(bytes);
|
||||
return frame ? { kind: "terminal", frame } : null;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { TerminalStateSchema } from "../messages.js";
|
||||
|
||||
export const TerminalReplayMetadataSchema = z.object({
|
||||
rows: z.number().int().positive(),
|
||||
cols: z.number().int().positive(),
|
||||
retainedHistoryLines: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const TerminalStreamResizeSchema = z.object({
|
||||
rows: z.number().int().positive(),
|
||||
cols: z.number().int().positive(),
|
||||
@@ -13,6 +19,9 @@ export const TerminalStreamOpcode = {
|
||||
Resize: 0x03,
|
||||
Snapshot: 0x04,
|
||||
Restore: 0x05,
|
||||
ReplayBegin: 0x06,
|
||||
ReplayData: 0x07,
|
||||
ReplayEnd: 0x08,
|
||||
} as const;
|
||||
|
||||
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
|
||||
@@ -58,6 +67,9 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
|
||||
value === TerminalStreamOpcode.Input ||
|
||||
value === TerminalStreamOpcode.Resize ||
|
||||
value === TerminalStreamOpcode.Snapshot ||
|
||||
value === TerminalStreamOpcode.ReplayBegin ||
|
||||
value === TerminalStreamOpcode.ReplayData ||
|
||||
value === TerminalStreamOpcode.ReplayEnd ||
|
||||
value === TerminalStreamOpcode.Restore
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const CLIENT_CAPS = {
|
||||
terminalChunkedReplay: "terminal_chunked_replay",
|
||||
helloRejection: "hello_rejection",
|
||||
// COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09.
|
||||
ownedSubscriptions: "owned_subscriptions",
|
||||
|
||||
@@ -3740,6 +3740,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
resumeOnStartupSetting: z.boolean().optional(),
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
"terminal-restore-modes": z.boolean().optional(),
|
||||
terminalChunkedReplay: z.boolean().optional(),
|
||||
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
|
||||
"terminal-input-mode-replay": z.boolean().optional(),
|
||||
// COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02.
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Terminal as HeadlessTerminal, type IBufferCell, type IBufferLine } from
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
|
||||
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
|
||||
import type { TerminalStreamEvent } from "@getpaseo/client/internal/daemon-client";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
@@ -46,7 +45,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac
|
||||
terminalId: terminal.id,
|
||||
headless: liveHeadless,
|
||||
});
|
||||
const initialSnapshot = waitForTerminalStreamEvent(client, terminal.id, "snapshot");
|
||||
const initialSnapshot = waitForReplayEnd(client, terminal.id);
|
||||
|
||||
const liveSubscription = await subscribeTerminal(client, terminal.id);
|
||||
await initialSnapshot;
|
||||
@@ -60,13 +59,13 @@ test("byte-stream headless terminal matches daemon state after high-output attac
|
||||
text: BYTE_DONE_MARKER,
|
||||
rows: BYTE_TEST_SIZE.rows,
|
||||
});
|
||||
const daemonSnapshot = await readTerminalSnapshot({
|
||||
const daemonSnapshot = await readReplayViewport({
|
||||
port: daemon.port,
|
||||
terminalId: terminal.id,
|
||||
});
|
||||
await liveHeadless.flush();
|
||||
|
||||
expect(liveHeadless.visibleLines()).toEqual(visibleTerminalLines(daemonSnapshot));
|
||||
expect(liveHeadless.visibleLines()).toEqual(daemonSnapshot.lines);
|
||||
expect(liveHeadless.cursor()).toEqual(daemonSnapshot.cursor);
|
||||
expect(liveHeadless.visibleLines()).toEqual(capturedViewport.lines);
|
||||
|
||||
@@ -79,7 +78,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac
|
||||
terminalId: terminal.id,
|
||||
headless: restoreHeadless,
|
||||
});
|
||||
const restoreFrame = waitForTerminalStreamEvent(client, terminal.id, "restore");
|
||||
const restoreFrame = waitForReplayEnd(client, terminal.id);
|
||||
|
||||
await subscribeTerminal(client, terminal.id, {
|
||||
restore: { mode: "visible-snapshot", scrollbackLines: 200, size: BYTE_TEST_SIZE },
|
||||
@@ -116,12 +115,10 @@ class ClientHeadlessTerminal {
|
||||
}
|
||||
|
||||
applyEvent(event: TerminalStreamEvent): void {
|
||||
if (event.type === "snapshot") {
|
||||
this.enqueueText(renderTerminalSnapshotToAnsi(event.state), { resetDecoder: true });
|
||||
return;
|
||||
}
|
||||
if (event.type === "restore") {
|
||||
this.enqueueText(this.decoder.decode(event.data, { stream: false }), { resetDecoder: true });
|
||||
if (event.type === "replay") {
|
||||
if (event.replay.type === "begin") this.enqueueText("\x1bc", { resetDecoder: true });
|
||||
if (event.replay.type === "data")
|
||||
this.enqueueText(this.decoder.decode(event.replay.data, { stream: true }));
|
||||
return;
|
||||
}
|
||||
this.enqueueText(this.decoder.decode(event.data, { stream: true }));
|
||||
@@ -187,35 +184,32 @@ async function subscribeTerminal(
|
||||
return response.subscription;
|
||||
}
|
||||
|
||||
async function readTerminalSnapshot(input: {
|
||||
port: number;
|
||||
terminalId: string;
|
||||
}): Promise<TerminalState> {
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${input.port}/ws`,
|
||||
appVersion: "0.1.96",
|
||||
clientType: "mobile",
|
||||
});
|
||||
async function readReplayViewport(input: { port: number; terminalId: string }): Promise<{
|
||||
lines: string[];
|
||||
cursor: TerminalState["cursor"];
|
||||
}> {
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${input.port}/ws`, clientType: "mobile" });
|
||||
const headless = new ClientHeadlessTerminal(BYTE_TEST_SIZE);
|
||||
const stop = pipeTerminalStreamToHeadless({ client, terminalId: input.terminalId, headless });
|
||||
try {
|
||||
await client.connect();
|
||||
const snapshot = waitForTerminalStreamEvent(client, input.terminalId, "snapshot");
|
||||
const completed = waitForReplayEnd(client, input.terminalId);
|
||||
await subscribeTerminal(client, input.terminalId);
|
||||
const event = await snapshot;
|
||||
return event.state;
|
||||
await completed;
|
||||
await headless.flush();
|
||||
return { lines: headless.visibleLines(), cursor: headless.cursor() };
|
||||
} finally {
|
||||
stop();
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
function waitForTerminalStreamEvent<TType extends TerminalStreamEvent["type"]>(
|
||||
client: DaemonClient,
|
||||
terminalId: string,
|
||||
type: TType,
|
||||
): Promise<Extract<TerminalStreamEvent, { type: TType }>> {
|
||||
return client.waitForTerminalStreamEvent(
|
||||
(event) => event.terminalId === terminalId && event.type === type,
|
||||
10_000,
|
||||
) as Promise<Extract<TerminalStreamEvent, { type: TType }>>;
|
||||
async function waitForReplayEnd(client: DaemonClient, terminalId: string): Promise<void> {
|
||||
await client.waitForTerminalStreamEvent(
|
||||
(event) =>
|
||||
event.terminalId === terminalId && event.type === "replay" && event.replay.type === "end",
|
||||
10000,
|
||||
);
|
||||
}
|
||||
|
||||
async function createPackageLockTerminalCwd(): Promise<PackageLockTerminalCwd> {
|
||||
@@ -287,24 +281,16 @@ async function waitForCondition<T>(
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for terminal byte parity`);
|
||||
}
|
||||
|
||||
function visibleTerminalLines(state: TerminalState): string[] {
|
||||
return state.grid.map(terminalRowText);
|
||||
}
|
||||
|
||||
function terminalRowText(row: TerminalState["grid"][number]): string {
|
||||
return row
|
||||
.map((cell) => cell.char)
|
||||
.join("")
|
||||
.trimEnd();
|
||||
}
|
||||
|
||||
function bufferLineText(line: IBufferLine | undefined, reusableCell: IBufferCell): string {
|
||||
const cells: TerminalCell[] = [];
|
||||
for (let col = 0; col < BYTE_TEST_SIZE.cols; col += 1) {
|
||||
const cell = line?.getCell(col, reusableCell);
|
||||
cells.push({ char: cell?.getChars() || " " });
|
||||
}
|
||||
return terminalRowText(cells);
|
||||
return cells
|
||||
.map((cell) => cell.char)
|
||||
.join("")
|
||||
.trimEnd();
|
||||
}
|
||||
|
||||
function packageLockStreamScript(): string {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Terminal as HeadlessTerminal } from "@xterm/headless";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
@@ -80,6 +81,24 @@ async function waitForCondition(
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
|
||||
}
|
||||
|
||||
function readHeadlessState(terminal: HeadlessTerminal): TerminalState {
|
||||
const buffer = terminal.buffer.active;
|
||||
const lines: TerminalState["grid"] = [];
|
||||
for (let row = 0; row < buffer.length; row++) {
|
||||
const cells: TerminalState["grid"][number] = [];
|
||||
for (let col = 0; col < terminal.cols; col++)
|
||||
cells.push({ char: buffer.getLine(row)?.getCell(col)?.getChars() ?? "" });
|
||||
lines.push(cells);
|
||||
}
|
||||
return {
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
grid: lines.slice(buffer.baseY),
|
||||
scrollback: lines.slice(0, buffer.baseY),
|
||||
cursor: { row: buffer.cursorY, col: buffer.cursorX },
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForTerminalSnapshot(
|
||||
client: DaemonClient,
|
||||
terminalId: string,
|
||||
@@ -92,16 +111,34 @@ async function waitForTerminalSnapshot(
|
||||
reject(new Error(`Timeout waiting for terminal snapshot (${timeout}ms)`));
|
||||
}, timeout);
|
||||
|
||||
let terminal: HeadlessTerminal | null = null;
|
||||
let writes = Promise.resolve();
|
||||
const unsubscribe = client.onTerminalStreamEvent((event) => {
|
||||
if (event.terminalId !== terminalId || event.type !== "snapshot") {
|
||||
return;
|
||||
if (event.terminalId !== terminalId || event.type !== "replay") return;
|
||||
const replay = event.replay;
|
||||
if (replay.type === "begin") {
|
||||
terminal?.dispose();
|
||||
terminal = new HeadlessTerminal({
|
||||
rows: replay.rows ?? 24,
|
||||
cols: replay.cols ?? 80,
|
||||
scrollback: 1000,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
} else if (replay.type === "data") {
|
||||
const active = terminal!;
|
||||
writes = writes.then(() => new Promise<void>((done) => active.write(replay.data, done)));
|
||||
} else {
|
||||
void writes.then(() => {
|
||||
const active = terminal!;
|
||||
const state = readHeadlessState(active);
|
||||
if (!predicate(state)) return null;
|
||||
clearTimeout(timeoutHandle);
|
||||
unsubscribe();
|
||||
active.dispose();
|
||||
resolve(state);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
if (!predicate(event.state)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeoutHandle);
|
||||
unsubscribe();
|
||||
resolve(event.state);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1836,3 +1873,68 @@ describe("list terminals across directories", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test.each([400, 3000])(
|
||||
"chunked SDK replay retains requested history for %s printed lines",
|
||||
async (count) => {
|
||||
const cwd = tmpCwd();
|
||||
tempDirs.push(cwd);
|
||||
const created = await createTerminalInWorkspace(ctx.client, {
|
||||
cwd,
|
||||
options: {
|
||||
command: "/bin/sh",
|
||||
args: [
|
||||
"-lc",
|
||||
`i=1; while [ $i -le ${count} ]; do printf '\\033[31mreplay-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 30`,
|
||||
],
|
||||
},
|
||||
});
|
||||
const id = created.terminal!.id;
|
||||
await waitForCondition(
|
||||
async () =>
|
||||
(await ctx.client.captureTerminal(id)).lines
|
||||
.join("\n")
|
||||
.includes(`replay-${String(count).padStart(4, "0")}`),
|
||||
10000,
|
||||
);
|
||||
const events: import("@getpaseo/client").TerminalStreamEvent[] = [];
|
||||
let end!: () => void;
|
||||
const completed = new Promise<void>((resolve) => {
|
||||
end = resolve;
|
||||
});
|
||||
const subscription = ctx.client.observeTerminal(
|
||||
id,
|
||||
(event) => {
|
||||
events.push(event);
|
||||
if (event.type === "replay" && event.replay.type === "end") end();
|
||||
},
|
||||
{ restore: { mode: "visible-snapshot", scrollbackLines: 10000 } },
|
||||
);
|
||||
try {
|
||||
await subscription.ready;
|
||||
await completed;
|
||||
const replay = events.flatMap((event) => (event.type === "replay" ? [event.replay] : []));
|
||||
expect(replay[0]).toMatchObject({
|
||||
type: "begin",
|
||||
retainedHistoryLines: count === 3000 ? 1000 : expect.any(Number),
|
||||
});
|
||||
expect(replay.at(-1)).toEqual({ type: "end" });
|
||||
const chunks = replay.flatMap((event) => (event.type === "data" ? [event.data] : []));
|
||||
expect(chunks.every((chunk) => chunk.length <= 65536)).toBe(true);
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
const begin = replay[0];
|
||||
if (begin?.type !== "begin" || begin.rows === undefined)
|
||||
throw new Error("Missing replay dimensions");
|
||||
expect(text.match(/replay-\d{4}/g)).toHaveLength(Math.min(count, 1000 + begin.rows - 1));
|
||||
expect(text).toContain(`replay-${String(count).padStart(4, "0")}界🙂`);
|
||||
if (count === 400) expect(text).toContain("replay-0001界🙂");
|
||||
else {
|
||||
expect(text).not.toContain("replay-0001");
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
}
|
||||
} finally {
|
||||
await subscription.release();
|
||||
}
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
@@ -1129,6 +1129,8 @@ export class Session {
|
||||
terminalManager,
|
||||
emit: (msg) => this.emit(msg),
|
||||
hasBinaryChannel: () => this.onBinaryMessage !== null,
|
||||
supportsChunkedReplay: (source) =>
|
||||
this.supportsForSource(CLIENT_CAPS.terminalChunkedReplay, source),
|
||||
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
|
||||
sessionLogger: this.sessionLogger,
|
||||
listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(),
|
||||
|
||||
@@ -2016,6 +2016,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
resumeOnStartupSetting: true,
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
"terminal-restore-modes": true,
|
||||
terminalChunkedReplay: true,
|
||||
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
|
||||
"terminal-input-mode-replay": true,
|
||||
// COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02.
|
||||
|
||||
@@ -21,7 +21,7 @@ export type TerminalReplayEvent =
|
||||
| { type: "begin"; metadata: TerminalReplayMetadata }
|
||||
| { type: "data"; data: Uint8Array }
|
||||
| { type: "end" };
|
||||
export type TerminalReplaySink = (event: TerminalReplayEvent) => void;
|
||||
export type TerminalReplaySink = (event: TerminalReplayEvent) => void | Promise<void>;
|
||||
export const TERMINAL_REPLAY_CHUNK_BYTES = 64 * 1024;
|
||||
|
||||
export function* createTerminalReplay(
|
||||
@@ -45,15 +45,15 @@ export function* createTerminalReplay(
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function replayTerminal(
|
||||
export async function replayTerminal(
|
||||
session: LocalTerminalSession,
|
||||
options: TerminalReplayOptions | undefined,
|
||||
sink: TerminalReplaySink,
|
||||
): TerminalReplayMetadata {
|
||||
): Promise<TerminalReplayMetadata> {
|
||||
const replay = createTerminalReplay(session, options);
|
||||
let next = replay.next();
|
||||
while (!next.done) {
|
||||
sink(next.value);
|
||||
await sink(next.value);
|
||||
next = replay.next();
|
||||
}
|
||||
return next.value;
|
||||
@@ -67,6 +67,7 @@ interface TerminalReplayPorts {
|
||||
/** Owns the worker's replay/live cut, bounded live tail and controller lifetime. */
|
||||
export function createTerminalReplayController(ports: TerminalReplayPorts) {
|
||||
let controller: FramedSocket | null = null;
|
||||
const acknowledgements = new Map<string, { resolve(): void; reject(error: Error): void }>();
|
||||
// Only the live tail of a replaying terminal is held. Replay chunks use the socket's
|
||||
// write completion directly; they never enter this buffer or fill the shared socket.
|
||||
interface ReplayTail {
|
||||
@@ -142,11 +143,18 @@ export function createTerminalReplayController(ports: TerminalReplayPorts) {
|
||||
while (!next.done) {
|
||||
if (tail.overflow)
|
||||
throw new Error("Terminal output exceeded the replay buffer; attach again");
|
||||
const acknowledged = new Promise<void>((resolve, reject) => {
|
||||
acknowledgements.set(message.requestId, { resolve, reject });
|
||||
});
|
||||
void acknowledged.catch(() => {});
|
||||
// Register before sending: even a synchronous controller cannot race the acknowledgement.
|
||||
await connection.sendAndWait({
|
||||
type: "terminalReplay",
|
||||
requestId: message.requestId,
|
||||
event: next.value,
|
||||
});
|
||||
await acknowledged;
|
||||
acknowledgements.delete(message.requestId);
|
||||
next = replay.next();
|
||||
}
|
||||
released = true;
|
||||
@@ -161,6 +169,8 @@ export function createTerminalReplayController(ports: TerminalReplayPorts) {
|
||||
tail.failure = error;
|
||||
if (!released) await releaseReplayTail(session, tail);
|
||||
throw error;
|
||||
} finally {
|
||||
acknowledgements.delete(message.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +240,11 @@ export function createTerminalReplayController(ports: TerminalReplayPorts) {
|
||||
}
|
||||
|
||||
return {
|
||||
acknowledge(requestId: string, error?: string): void {
|
||||
const pending = acknowledgements.get(requestId);
|
||||
if (error) pending?.reject(new Error(error));
|
||||
else pending?.resolve();
|
||||
},
|
||||
setConnection(connection: FramedSocket | null): void {
|
||||
const previous = controller;
|
||||
controller = connection;
|
||||
@@ -237,6 +252,9 @@ export function createTerminalReplayController(ports: TerminalReplayPorts) {
|
||||
// Old writes settle on their original socket; new output and replay belong
|
||||
// exclusively to the replacement controller.
|
||||
replayTails.clear();
|
||||
for (const pending of acknowledgements.values())
|
||||
pending.reject(new Error("Terminal replay controller disconnected"));
|
||||
acknowledgements.clear();
|
||||
},
|
||||
send: sendToController,
|
||||
async replay(
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
TerminalStreamOpcode,
|
||||
encodeTerminalStreamFrame,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { TerminalReplayOptions } from "./terminal-replay.js";
|
||||
import type { TerminalReplaySink, TerminalReplayOptions } from "./terminal-replay.js";
|
||||
|
||||
export const MAX_TERMINAL_OUTPUT_FRAME_BYTES = 256 * 1024;
|
||||
|
||||
@@ -64,3 +64,37 @@ function resolveVisibleRestoreScrollbackLines(value: number | undefined): number
|
||||
}
|
||||
return Math.min(Math.max(0, value), MAX_VISIBLE_RESTORE_SCROLLBACK_LINES);
|
||||
}
|
||||
|
||||
/** The daemon's only old-app adapter. Capable delivery never assembles the replay. */
|
||||
export function createTerminalRestoreDelivery(input: {
|
||||
slot: number;
|
||||
chunked: boolean;
|
||||
send(frame: Uint8Array): Promise<void>;
|
||||
}): TerminalReplaySink {
|
||||
// COMPAT(terminalSingleRestore): added after v0.14.0; remove after 2027-04-30 when client floor supports chunked replay.
|
||||
const legacyChunks: Uint8Array[] = [];
|
||||
return async (event) => {
|
||||
if (!input.chunked) {
|
||||
if (event.type === "data") legacyChunks.push(event.data);
|
||||
if (event.type === "end")
|
||||
await input.send(
|
||||
encodeTerminalRestoreFrame({
|
||||
slot: input.slot,
|
||||
data: Buffer.concat(legacyChunks),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const opcodes = {
|
||||
begin: TerminalStreamOpcode.ReplayBegin,
|
||||
data: TerminalStreamOpcode.ReplayData,
|
||||
end: TerminalStreamOpcode.ReplayEnd,
|
||||
};
|
||||
let payload: string | Uint8Array | undefined;
|
||||
if (event.type === "begin") payload = JSON.stringify(event.metadata);
|
||||
if (event.type === "data") payload = event.data;
|
||||
await input.send(
|
||||
encodeTerminalStreamFrame({ slot: input.slot, opcode: opcodes[event.type], payload }),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,9 +35,9 @@ function replayFixture(
|
||||
return async (_id, _options, sink) => {
|
||||
const replay = await read();
|
||||
if (!replay) return null;
|
||||
sink({ type: "begin", metadata: replay.metadata });
|
||||
sink({ type: "data", data: replay.data });
|
||||
sink({ type: "end" });
|
||||
await sink({ type: "begin", metadata: replay.metadata });
|
||||
await sink({ type: "data", data: replay.data });
|
||||
await sink({ type: "end" });
|
||||
return replay.metadata;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js";
|
||||
import {
|
||||
MAX_CLIENT_BUFFERED_BYTES,
|
||||
MAX_TERMINAL_OUTPUT_FRAME_BYTES,
|
||||
encodeTerminalRestoreFrame,
|
||||
createTerminalRestoreDelivery,
|
||||
resolveRestoreAfterOutputOverflow,
|
||||
resolveTerminalRestoreSnapshotOptions,
|
||||
resolveTerminalSubscriptionSnapshotMode,
|
||||
@@ -70,6 +70,7 @@ interface ActiveTerminalStream {
|
||||
readyRevision?: number;
|
||||
restore?: TerminalRestoreOptions;
|
||||
bufferedOutputs: BufferedTerminalOutput[];
|
||||
bufferedOutputBytes: number;
|
||||
outputBytesSinceSnapshot: number;
|
||||
outputCoalescer: TerminalOutputCoalescer;
|
||||
}
|
||||
@@ -83,6 +84,7 @@ export interface TerminalSessionControllerOptions {
|
||||
terminalManager: TerminalManager | null;
|
||||
emit: (msg: SessionOutboundMessage) => void;
|
||||
hasBinaryChannel: () => boolean;
|
||||
supportsChunkedReplay?: (source: object) => boolean;
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
sessionLogger: pino.Logger;
|
||||
listTerminalWorkspaceRefs?: () => Promise<readonly TerminalWorkspaceRef[]>;
|
||||
@@ -146,7 +148,7 @@ export class TerminalSessionController {
|
||||
private readonly activeStreams = new Map<number, ActiveTerminalStream>();
|
||||
private nextSlot = 0;
|
||||
|
||||
constructor(options: TerminalSessionControllerOptions) {
|
||||
constructor(private readonly options: TerminalSessionControllerOptions) {
|
||||
this.terminalManager = options.terminalManager;
|
||||
this.emit = options.emit;
|
||||
this.hasBinaryChannel = options.hasBinaryChannel;
|
||||
@@ -899,6 +901,7 @@ export class TerminalSessionController {
|
||||
readyRevision: undefined,
|
||||
restore: options?.restore,
|
||||
bufferedOutputs: [],
|
||||
bufferedOutputBytes: 0,
|
||||
outputBytesSinceSnapshot: 0,
|
||||
outputCoalescer: new TerminalOutputCoalescer({
|
||||
timers: { setTimeout, clearTimeout },
|
||||
@@ -971,6 +974,19 @@ export class TerminalSessionController {
|
||||
return;
|
||||
}
|
||||
if (activeStream.needsSnapshot || activeStream.snapshotInFlight) {
|
||||
activeStream.bufferedOutputBytes += Buffer.byteLength(message.data);
|
||||
if (activeStream.bufferedOutputBytes > MAX_CLIENT_BUFFERED_BYTES) {
|
||||
activeStream.owner.emit({
|
||||
type: "terminal_stream_exit",
|
||||
payload: {
|
||||
terminalId: activeStream.terminalId,
|
||||
error: "Terminal replay live tail exceeded the buffer; attach again",
|
||||
},
|
||||
});
|
||||
this.detachRegistration(activeStream);
|
||||
void activeStream.owner.release();
|
||||
return;
|
||||
}
|
||||
activeStream.bufferedOutputs.push({
|
||||
data: message.data,
|
||||
revision: message.revision,
|
||||
@@ -1075,19 +1091,45 @@ export class TerminalSessionController {
|
||||
terminalManager: TerminalManager,
|
||||
restore: TerminalRestoreOptions,
|
||||
): Promise<SnapshotSendResult> {
|
||||
const snapshotOptions = resolveTerminalRestoreSnapshotOptions(restore);
|
||||
const chunked = this.options.supportsChunkedReplay?.(activeStream.owner.source) === true;
|
||||
const snapshotOptions =
|
||||
chunked && restore.mode !== "live"
|
||||
? { scrollbackLines: restore.scrollbackLines }
|
||||
: resolveTerminalRestoreSnapshotOptions(restore);
|
||||
if (snapshotOptions === null) {
|
||||
return { shouldContinue: true };
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
// COMPAT(terminalSingleRestore): ANSI clients through v0.14 collect one restore;
|
||||
// remove after the supported client floor implements chunked replay.
|
||||
const deliver = createTerminalRestoreDelivery({
|
||||
slot: activeStream.slot,
|
||||
chunked,
|
||||
send: (frame) => activeStream.owner.emitBinaryAndWait(frame),
|
||||
});
|
||||
const snapshot = await terminalManager.replayTerminal(
|
||||
activeStream.terminalId,
|
||||
snapshotOptions,
|
||||
(event) => {
|
||||
if (event.type === "data") chunks.push(event.data);
|
||||
async (event) => {
|
||||
if (this.activeStreams.get(activeStream.slot) !== activeStream) return;
|
||||
const buffered = this.options.getClientBufferedAmount?.(activeStream.owner.source);
|
||||
if (
|
||||
chunked &&
|
||||
buffered !== null &&
|
||||
buffered !== undefined &&
|
||||
buffered > MAX_CLIENT_BUFFERED_BYTES
|
||||
) {
|
||||
activeStream.owner.emit({
|
||||
type: "terminal_stream_exit",
|
||||
payload: {
|
||||
terminalId: activeStream.terminalId,
|
||||
error: "Terminal replay transport overflow; attach again",
|
||||
},
|
||||
});
|
||||
this.detachRegistration(activeStream);
|
||||
void activeStream.owner.release();
|
||||
|
||||
return;
|
||||
}
|
||||
await deliver(event);
|
||||
},
|
||||
);
|
||||
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
|
||||
@@ -1098,12 +1140,6 @@ export class TerminalSessionController {
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
activeStream.owner.emitBinary(
|
||||
encodeTerminalRestoreFrame({
|
||||
slot: activeStream.slot,
|
||||
data: Buffer.concat(chunks),
|
||||
}),
|
||||
);
|
||||
// The restore frame went out-of-band; keep the replay that follows on the
|
||||
// coalescer's trailing path so it doesn't flush back-to-back with it.
|
||||
activeStream.outputCoalescer.markFlushed();
|
||||
@@ -1120,6 +1156,7 @@ export class TerminalSessionController {
|
||||
activeStream.outputCoalescer.handle(replayPreamble);
|
||||
}
|
||||
|
||||
activeStream.bufferedOutputBytes = 0;
|
||||
const bufferedOutputs = activeStream.bufferedOutputs.splice(
|
||||
0,
|
||||
activeStream.bufferedOutputs.length,
|
||||
|
||||
@@ -552,6 +552,11 @@ function runWorker(argv: string[]): void {
|
||||
return;
|
||||
}
|
||||
|
||||
case "replayAcknowledged": {
|
||||
replayController.acknowledge(message.requestId, message.error);
|
||||
return;
|
||||
}
|
||||
|
||||
case "replayTerminal": {
|
||||
await replayController.replay(message);
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { classifyTerminalWorkerProtocol } from "./terminal-worker-protocol.js";
|
||||
describe("classifyTerminalWorkerProtocol", () => {
|
||||
it("rejects the unreleased cell protocol without a draining adapter", () => {
|
||||
expect(classifyTerminalWorkerProtocol(1, 2)).toBe("obsolete");
|
||||
expect(classifyTerminalWorkerProtocol(2, 3)).toBe("obsolete");
|
||||
});
|
||||
it.each([
|
||||
[5, "current"],
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface WorkerCreateTerminalOptions {
|
||||
// `shutdown`. A daemon drives a worker of its own version, drains one a version older (no new
|
||||
// terminals; it exits after its last terminal), shuts down anything older, and leaves a newer
|
||||
// worker running untouched. See docs/terminal-worker.md.
|
||||
export const TERMINAL_WORKER_PROTOCOL_VERSION = 2;
|
||||
export const TERMINAL_WORKER_PROTOCOL_VERSION = 3;
|
||||
|
||||
// Test-only: the protocol version a worker reports, so tests can stand up older and newer
|
||||
// workers from one build.
|
||||
@@ -59,8 +59,8 @@ export function classifyTerminalWorkerProtocol(
|
||||
// A version this daemon cannot read comes from a Paseo it does not know: treat it as newer.
|
||||
if (typeof reported !== "number" || !Number.isSafeInteger(reported)) return "newer";
|
||||
if (reported === current) return "current";
|
||||
// Protocol 1 was unreleased and transported cells; it has no draining adapter.
|
||||
if (reported === current - 1 && reported >= 2) return "previous";
|
||||
// Protocols 1 and 2 were unreleased; they have no draining adapter.
|
||||
if (reported === current - 1 && reported >= 3) return "previous";
|
||||
return reported < current ? "obsolete" : "newer";
|
||||
}
|
||||
|
||||
@@ -122,6 +122,11 @@ export type TerminalWorkerRequest =
|
||||
terminalId: string;
|
||||
options?: WorkerKillAndWaitOptions;
|
||||
}
|
||||
| {
|
||||
type: "replayAcknowledged";
|
||||
requestId: string;
|
||||
error?: string;
|
||||
}
|
||||
| {
|
||||
type: "replayTerminal";
|
||||
requestId: string;
|
||||
|
||||
@@ -1570,18 +1570,18 @@ it("hard cuts the unreleased cell transport, closing its terminals", async () =>
|
||||
expect(manager.getTerminal(old.terminalId)).toBeUndefined();
|
||||
const workers = await workerRecords(paseoHome);
|
||||
expect(workers).toHaveLength(1);
|
||||
expect(workers[0]!.protocolVersion).toBe(2);
|
||||
expect(workers[0]!.protocolVersion).toBe(3);
|
||||
});
|
||||
|
||||
it("leaves a worker from a newer Paseo running, reports it incompatible, and closes it on a kill stop", async () => {
|
||||
const paseoHome = createTemporaryHome();
|
||||
const newer = await leaveWorkerWithTerminal(paseoHome, 3);
|
||||
const newer = await leaveWorkerWithTerminal(paseoHome, 4);
|
||||
|
||||
manager = await startRealWorkerManager({ paseoHome });
|
||||
|
||||
const unavailable = manager.getUnavailableError?.();
|
||||
expect(unavailable).toBeInstanceOf(TerminalWorkerIncompatibleError);
|
||||
expect(unavailable?.message).toContain("worker protocol 3");
|
||||
expect(unavailable?.message).toContain("worker protocol 4");
|
||||
await expect(manager.getTerminals(newer.cwd)).rejects.toBeInstanceOf(
|
||||
TerminalWorkerIncompatibleError,
|
||||
);
|
||||
@@ -1799,9 +1799,12 @@ it("completes a replay larger than the socket budget while a sibling stays usabl
|
||||
if (event.type === "output") seen.push(event.data);
|
||||
});
|
||||
let bytes = 0;
|
||||
const metadata = await manager.replayTerminal(terminal.id, undefined, (event) => {
|
||||
const metadata = await manager.replayTerminal(terminal.id, undefined, async (event) => {
|
||||
if (event.type === "data") bytes += event.data.byteLength;
|
||||
if (event.type === "begin") sibling.send({ type: "input", data: "go\r" });
|
||||
if (event.type === "begin") {
|
||||
sibling.send({ type: "input", data: "go\r" });
|
||||
await waitForCondition(() => seen.join("").includes("SIBLING-ALIVE"), 10000);
|
||||
}
|
||||
});
|
||||
expect(bytes).toBeGreaterThan(4 * 1024 * 1024);
|
||||
expect(metadata?.cols).toBe(1024);
|
||||
@@ -1840,6 +1843,7 @@ it("completes a replay larger than the socket budget while a sibling stays usabl
|
||||
if (message.type === "helloAck") {
|
||||
framed.send({ type: "replayTerminal", terminalId: terminal.id, requestId: "stalled-replay" });
|
||||
} else if (message.type === "terminalReplay") {
|
||||
framed.send({ type: "replayAcknowledged", requestId: message.requestId });
|
||||
if (message.event.type === "begin") {
|
||||
began = true;
|
||||
socket.pause();
|
||||
@@ -1853,7 +1857,7 @@ it("completes a replay larger than the socket budget while a sibling stays usabl
|
||||
completed = message.ok;
|
||||
});
|
||||
try {
|
||||
framed.send({ type: "hello", secret: record!.secret, protocolVersion: 2 });
|
||||
framed.send({ type: "hello", secret: record!.secret, protocolVersion: 3 });
|
||||
await waitForCondition(() => began, 10000);
|
||||
framed.send({
|
||||
type: "send",
|
||||
@@ -1903,6 +1907,8 @@ it("completes a replay larger than the socket budget while a sibling stays usabl
|
||||
terminalId: terminal.id,
|
||||
requestId: "overflow-replay",
|
||||
});
|
||||
if (message.type === "terminalReplay")
|
||||
stalledFrames.send({ type: "replayAcknowledged", requestId: message.requestId });
|
||||
if (message.type === "terminalReplay" && message.event.type === "begin") {
|
||||
overflowBegan = true;
|
||||
stalled.pause();
|
||||
@@ -1911,7 +1917,7 @@ it("completes a replay larger than the socket budget while a sibling stays usabl
|
||||
overflowError = message.error;
|
||||
});
|
||||
try {
|
||||
stalledFrames.send({ type: "hello", secret: record!.secret, protocolVersion: 2 });
|
||||
stalledFrames.send({ type: "hello", secret: record!.secret, protocolVersion: 3 });
|
||||
await waitForCondition(() => overflowBegan, 10000);
|
||||
stalledFrames.send({
|
||||
type: "send",
|
||||
|
||||
@@ -599,7 +599,22 @@ export function createWorkerTerminalManager(
|
||||
function attachWorker(): void {
|
||||
worker.on("message", (message: TerminalWorkerToParentMessage) => {
|
||||
if (message.type === "terminalReplay") {
|
||||
replaySinks.get(message.requestId)?.(message.event);
|
||||
const sink = replaySinks.get(message.requestId);
|
||||
void Promise.resolve()
|
||||
.then(() => sink?.(message.event))
|
||||
.then(
|
||||
() =>
|
||||
worker.send({ type: "replayAcknowledged", requestId: message.requestId }, () => {}),
|
||||
(error: unknown) =>
|
||||
worker.send(
|
||||
{
|
||||
type: "replayAcknowledged",
|
||||
requestId: message.requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isResponse(message)) {
|
||||
@@ -678,6 +693,11 @@ export function createWorkerTerminalManager(
|
||||
const request = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRequests.delete(requestId);
|
||||
if (sink)
|
||||
worker.send(
|
||||
{ type: "replayAcknowledged", requestId, error: "Terminal replay delivery timed out" },
|
||||
() => {},
|
||||
);
|
||||
reject(new Error(`Terminal worker request timed out: ${input.type}`));
|
||||
}, requestTimeoutMs);
|
||||
pendingRequests.set(requestId, { resolve, reject, timeout });
|
||||
|
||||
Reference in New Issue
Block a user