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:
@@ -52,6 +52,14 @@ On capable daemons, connecting creates no timeline or event demand. Client subsc
|
||||
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
|
||||
without requesting streams. Application caches and which agents are visible remain caller-owned.
|
||||
|
||||
## Terminal ANSI replay
|
||||
|
||||
`terminal_chunked_replay` is SDK-owned delivery behavior, separate from the daemon's
|
||||
`features.terminalChunkedReplay`. The daemon sends new binary opcodes only to a source
|
||||
advertising the capability. The SDK keeps the established subscribe request against older
|
||||
ANSI hosts and normalizes their single Restore frame into begin/data/end. JSON snapshot
|
||||
wire declarations remain accepted, but JSON snapshot frames have no rendering runtime.
|
||||
|
||||
## Owned observations
|
||||
|
||||
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
|
||||
|
||||
@@ -19,6 +19,7 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
|
||||
|
||||
## Invariants (the easy-to-break ones)
|
||||
|
||||
- **Replay is incremental across the daemon and SDK.** The worker retains 1,000 history rows. Capable clients request their configured history; the worker bounds it by retained history. Older apps retain their own 200-row request. Explicit SDK history demand is unchanged against older ANSI hosts, which apply their existing 500-row ceiling. Begin resets once, chunks keep a streaming UTF-8 decoder/parser, and each renderer suppresses generated replies while parsing replay. Never concatenate capable replay chunks to feed the renderer.
|
||||
- **Coalescers are leading+trailing throttles.** The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo.
|
||||
- **Output coalescing happens in the worker, before IPC.** One socket frame per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
|
||||
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
|
||||
|
||||
@@ -40,7 +40,7 @@ The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its
|
||||
|
||||
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
|
||||
|
||||
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 2 hard cuts the unreleased protocol 1 cell transport: protocol 1 workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
|
||||
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 3 hard cuts unreleased protocols 1 (cell transport) and 2 (socket-paced replay without consumption acknowledgements): those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
|
||||
|
||||
Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only.
|
||||
|
||||
@@ -48,7 +48,7 @@ Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, re
|
||||
|
||||
The worker keeps the headless xterm model and generates ANSI. A replay request emits begin metadata (dimensions, retained history, revision and input modes), at most 64 KiB per data chunk, and end before later live output. Capture is synchronous with PTY callbacks; flush the output coalescer before the cut so a later batch never includes bytes already in replay. Retained-history metadata counts the worker history before the request limit.
|
||||
|
||||
Replay waits for each socket write to complete before sending the next chunk. PTYs keep parsing while writes wait. The replaying terminal holds at most 4 MiB of live output until replay ends; exceeding that bound fails the replay and ends every display stream of that terminal with an error requiring a fresh attach. Existing viewers share the worker live stream, so they must also stop before bytes resume after a discarded tail. Sibling terminals continue streaming. Live traffic that fills the shared socket beyond 4 MiB disconnects the stalled controller; the daemon reconnects and requests fresh replay. Existing ANSI clients receive one collected Restore frame from the daemon. Capture stays a worker request; bootstrap readiness uses `hasOutput`, not a transported grid.
|
||||
Replay waits for the daemon to acknowledge each event after its sink finishes before sending the next chunk. The acknowledgement belongs to the replay request, so a slow viewer does not pause the shared worker socket or block sibling terminals. PTYs keep parsing while writes wait. The replaying terminal holds at most 4 MiB of live output until replay ends; exceeding that bound fails the replay and ends every display stream of that terminal with an error requiring a fresh attach. Existing viewers share the worker live stream, so they must also stop before bytes resume after a discarded tail. Sibling terminals continue streaming. Live traffic that fills the shared socket beyond 4 MiB disconnects the stalled controller; the daemon reconnects and requests fresh replay. The SDK advertises `terminal_chunked_replay` only because it decodes begin/data/end. Capable clients receive these events directly; the daemon collects one Restore frame only for older ANSI clients. The SDK normalizes an older host's single Restore into the same lifecycle. Capture stays a worker request; bootstrap readiness uses `hasOutput`, not a transported grid.
|
||||
|
||||
## Running from its own copy
|
||||
|
||||
|
||||
@@ -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