From 50ea5a76fb0d2dfb22c2b69ab01512992c31ae85 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 22 Mar 2026 22:31:23 +0700 Subject: [PATCH] Simplify terminal streaming protocol --- .../app/src/components/terminal-emulator.tsx | 83 +- packages/app/src/components/terminal-pane.tsx | 116 +- .../runtime/terminal-emulator-runtime.test.ts | 10 +- .../runtime/terminal-emulator-runtime.ts | 44 +- .../terminal-output-delivery-queue.test.ts | 141 --- .../runtime/terminal-output-delivery-queue.ts | 113 -- .../runtime/terminal-output-pump.test.ts | 99 -- .../terminal/runtime/terminal-output-pump.ts | 175 --- .../runtime/terminal-output-session.test.ts | 97 -- .../runtime/terminal-output-session.ts | 184 --- .../runtime/terminal-snapshot.test.ts | 117 ++ .../src/terminal/runtime/terminal-snapshot.ts | 158 +++ .../terminal-stream-controller.test.ts | 595 ++------- .../runtime/terminal-stream-controller.ts | 467 ++----- .../workspace-terminal-session.test.ts | 44 +- .../runtime/workspace-terminal-session.ts | 64 +- .../app/src/utils/terminal-attach.test.ts | 49 +- packages/app/src/utils/terminal-attach.ts | 43 - ...mon-client-terminal-stream-manager.test.ts | 131 -- .../daemon-client-terminal-stream-manager.ts | 173 --- .../server/src/client/daemon-client.test.ts | 311 +++-- packages/server/src/client/daemon-client.ts | 266 ++-- .../server/daemon-e2e/terminal.e2e.test.ts | 1068 +++++++---------- packages/server/src/server/session.ts | 590 +++------ .../websocket-server.relay-reconnect.test.ts | 56 +- .../server/src/server/websocket-server.ts | 24 +- .../src/server/worktree-bootstrap.test.ts | 38 +- .../server/src/server/worktree-bootstrap.ts | 23 +- packages/server/src/shared/binary-mux.test.ts | 50 - packages/server/src/shared/binary-mux.ts | 133 -- packages/server/src/shared/messages.ts | 63 +- .../shared/terminal-stream-protocol.test.ts | 59 + .../src/shared/terminal-stream-protocol.ts | 120 ++ packages/server/src/terminal/terminal.test.ts | 80 +- packages/server/src/terminal/terminal.ts | 146 +-- 35 files changed, 1809 insertions(+), 4121 deletions(-) delete mode 100644 packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts delete mode 100644 packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts delete mode 100644 packages/app/src/terminal/runtime/terminal-output-pump.test.ts delete mode 100644 packages/app/src/terminal/runtime/terminal-output-pump.ts delete mode 100644 packages/app/src/terminal/runtime/terminal-output-session.test.ts delete mode 100644 packages/app/src/terminal/runtime/terminal-output-session.ts create mode 100644 packages/app/src/terminal/runtime/terminal-snapshot.test.ts create mode 100644 packages/app/src/terminal/runtime/terminal-snapshot.ts delete mode 100644 packages/server/src/client/daemon-client-terminal-stream-manager.test.ts delete mode 100644 packages/server/src/client/daemon-client-terminal-stream-manager.ts delete mode 100644 packages/server/src/shared/binary-mux.test.ts delete mode 100644 packages/server/src/shared/binary-mux.ts create mode 100644 packages/server/src/shared/terminal-stream-protocol.test.ts create mode 100644 packages/server/src/shared/terminal-stream-protocol.ts diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 6ce999ed7..d75f9af76 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -1,9 +1,10 @@ "use dom"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import type { DOMProps } from "expo/dom"; import "@xterm/xterm/css/xterm.css"; import type { ITheme } from "@xterm/xterm"; +import type { TerminalState } from "@server/shared/messages"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime"; import { focusWithRetries } from "../utils/web-focus"; @@ -64,11 +65,7 @@ function buildXtermThemeKey(theme: ITheme): string { interface TerminalEmulatorProps { dom?: DOMProps; streamKey: string; - initialOutputText: string; - initialOutputChunkSequence?: number; - outputChunkText: string; - outputChunkSequence: number; - outputChunkReplay?: boolean; + initialSnapshot: TerminalState | null; testId?: string; xtermTheme?: ITheme; swipeGesturesEnabled?: boolean; @@ -84,12 +81,17 @@ interface TerminalEmulatorProps { meta: boolean; }) => Promise | void; onPendingModifiersConsumed?: () => Promise | void; - onOutputChunkConsumed?: (sequence: number) => Promise | void; pendingModifiers?: PendingTerminalModifiers; focusRequestToken?: number; resizeRequestToken?: number; } +export type TerminalEmulatorHandle = { + writeOutput: (text: string) => void; + renderSnapshot: (state: TerminalState | null) => void; + clear: () => void; +}; + declare global { interface Window {} } @@ -122,13 +124,9 @@ function ensureTerminalScrollbarStyle(): void { document.head.appendChild(styleElement); } -export default function TerminalEmulator({ +const TerminalEmulator = forwardRef(function TerminalEmulator({ streamKey, - initialOutputText, - initialOutputChunkSequence = 0, - outputChunkText, - outputChunkSequence, - outputChunkReplay = false, + initialSnapshot, testId = "terminal-surface", xtermTheme = { background: "#0b0b0b", @@ -142,15 +140,13 @@ export default function TerminalEmulator({ onResize, onTerminalKey, onPendingModifiersConsumed, - onOutputChunkConsumed, pendingModifiers = { ctrl: false, shift: false, alt: false }, focusRequestToken = 0, resizeRequestToken = 0, -}: TerminalEmulatorProps) { +}, ref) { const rootRef = useRef(null); const hostRef = useRef(null); const runtimeRef = useRef(null); - const appliedChunkSequenceRef = useRef(0); const mountedThemeRef = useRef(xtermTheme); const viewportRef = useRef(null); const dragStartOffsetRef = useRef(0); @@ -315,17 +311,15 @@ export default function TerminalEmulator({ runtime.mount({ root, host, - initialOutputText, + initialSnapshot, theme: mountedThemeRef.current, }); - appliedChunkSequenceRef.current = Math.max(0, Math.floor(initialOutputChunkSequence)); return () => { runtime.unmount(); if (runtimeRef.current === runtime) { runtimeRef.current = null; } - appliedChunkSequenceRef.current = 0; }; }, [streamKey]); @@ -344,40 +338,21 @@ export default function TerminalEmulator({ runtimeRef.current?.setPendingModifiers({ pendingModifiers }); }, [pendingModifiers]); - useEffect(() => { - const runtime = runtimeRef.current; - if (outputChunkSequence <= 0) { - return; - } - - if (outputChunkSequence <= appliedChunkSequenceRef.current) { - onOutputChunkConsumed?.(outputChunkSequence); - return; - } - - if (!runtime) { - onOutputChunkConsumed?.(outputChunkSequence); - return; - } - - appliedChunkSequenceRef.current = outputChunkSequence; - - if (outputChunkText.length === 0) { - runtime.clear({ - onCommitted: () => { - onOutputChunkConsumed?.(outputChunkSequence); - }, - }); - return; - } - runtime.write({ - text: outputChunkText, - suppressInput: outputChunkReplay, - onCommitted: () => { - onOutputChunkConsumed?.(outputChunkSequence); + useImperativeHandle( + ref, + () => ({ + writeOutput: (text: string) => { + runtimeRef.current?.write({ text }); }, - }); - }, [onOutputChunkConsumed, outputChunkReplay, outputChunkSequence, outputChunkText]); + renderSnapshot: (state: TerminalState | null) => { + runtimeRef.current?.renderSnapshot({ state }); + }, + clear: () => { + runtimeRef.current?.clear(); + }, + }), + [], + ); useEffect(() => { if (focusRequestToken <= 0) { @@ -694,4 +669,6 @@ export default function TerminalEmulator({ ) : null} ); -} +}); + +export default TerminalEmulator; diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index d98edc09d..6f411a13e 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useFocusEffect, useIsFocused } from "@react-navigation/native"; import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; @@ -18,7 +18,7 @@ import { } from "@/terminal/runtime/terminal-stream-controller"; import { usePanelStore } from "@/stores/panel-store"; import { toXtermTheme } from "@/utils/to-xterm-theme"; -import TerminalEmulator from "./terminal-emulator"; +import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator"; interface TerminalPaneProps { serverId: string; @@ -26,7 +26,6 @@ interface TerminalPaneProps { terminalId: string; } -const MAX_OUTPUT_CHARS = 200_000; const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320]; const MODIFIER_LABELS = { @@ -103,44 +102,8 @@ export function TerminalPane({ const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null); const streamControllerRef = useRef(null); - const workspaceTerminalSession = useMemo( - () => - getWorkspaceTerminalSession({ - scopeKey, - maxOutputChars: MAX_OUTPUT_CHARS, - }), - [scopeKey], - ); - const outputSession = workspaceTerminalSession.outputSession; - const subscribeOutputSession = useCallback( - (listener: () => void) => outputSession.subscribe(listener), - [outputSession], - ); - const getOutputSessionState = useCallback(() => outputSession.getState(), [outputSession]); - const outputState = useSyncExternalStore( - subscribeOutputSession, - getOutputSessionState, - getOutputSessionState, - ); - const selectedOutputState = useMemo(() => { - if (outputState.selectedTerminalId === terminalId) { - return outputState; - } - - return { - ...outputState, - selectedTerminalId: terminalId, - snapshotText: outputSession.readSnapshot({ terminalId }), - snapshotSequence: 0, - chunkText: "", - chunkSequence: 0, - chunkReplay: false, - }; - }, [outputSession, outputState, terminalId]); - const [activeStream, setActiveStream] = useState<{ - terminalId: string; - streamId: number; - } | null>(null); + const emulatorRef = useRef(null); + const workspaceTerminalSession = useMemo(() => getWorkspaceTerminalSession({ scopeKey }), [scopeKey]); const [isAttaching, setIsAttaching] = useState(false); const [streamError, setStreamError] = useState(null); const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS); @@ -150,6 +113,7 @@ export function TerminalPane({ const pendingTerminalInputRef = useRef([]); const keyboardRefitTimeoutsRef = useRef>>([]); const lastAutoFocusKeyRef = useRef(null); + const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId }); useEffect(() => { terminalIdRef.current = terminalId; @@ -248,13 +212,16 @@ export function TerminalPane({ return; } + workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId }); + if (terminalIdRef.current === exitedTerminalId) { + emulatorRef.current?.clear(); + } streamControllerRef.current?.handleStreamExit({ terminalId: exitedTerminalId, - streamId: message.payload.streamId, }); setModifiers({ ...EMPTY_MODIFIERS }); }); - }, [client, isConnected]); + }, [client, isConnected, workspaceTerminalSession.snapshots]); useEffect(() => { lastReportedSizeRef.current = null; @@ -263,20 +230,11 @@ export function TerminalPane({ const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => { setIsAttaching(status.isAttaching); setStreamError(status.error); - if (status.terminalId && typeof status.streamId === "number") { - setActiveStream({ - terminalId: status.terminalId, - streamId: status.streamId, - }); - return; - } - setActiveStream(null); }, []); useEffect(() => { streamControllerRef.current?.dispose(); streamControllerRef.current = null; - setActiveStream(null); setIsAttaching(false); setStreamError(null); @@ -287,12 +245,18 @@ export function TerminalPane({ const controller = new TerminalStreamController({ client, getPreferredSize: () => lastReportedSizeRef.current, - resumeOffsets: workspaceTerminalSession.resumeOffsets, - onChunk: ({ terminalId, text, replay }) => { - outputSession.append({ terminalId, text, replay }); + onOutput: ({ terminalId, text }) => { + if (!isScreenFocused || terminalIdRef.current !== terminalId) { + return; + } + emulatorRef.current?.writeOutput(text); }, - onReset: ({ terminalId }) => { - outputSession.clearTerminal({ terminalId }); + onSnapshot: ({ terminalId, state }) => { + workspaceTerminalSession.snapshots.set({ terminalId, state }); + if (!isScreenFocused || terminalIdRef.current !== terminalId) { + return; + } + emulatorRef.current?.renderSnapshot(state); }, onStatusChange: handleStreamControllerStatus, }); @@ -313,26 +277,16 @@ export function TerminalPane({ handleStreamControllerStatus, isConnected, isScreenFocused, - outputSession, - workspaceTerminalSession.resumeOffsets, + workspaceTerminalSession.snapshots, ]); useEffect(() => { pendingTerminalInputRef.current = []; const nextTerminalId = isScreenFocused ? terminalId : null; - outputSession.setSelectedTerminal({ - terminalId: nextTerminalId, - }); streamControllerRef.current?.setTerminal({ terminalId: nextTerminalId, }); - }, [isScreenFocused, outputSession, terminalId]); - - const activeStreamId = - activeStream && activeStream.terminalId === terminalId ? activeStream.streamId : null; - const getCurrentActiveStreamId = useCallback(() => { - return streamControllerRef.current?.getActiveStreamId() ?? null; - }, []); + }, [isScreenFocused, terminalId]); const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => { const queue = pendingTerminalInputRef.current; @@ -398,8 +352,10 @@ export function TerminalPane({ }, [dispatchTerminalInputEntry]); useEffect(() => { - flushPendingTerminalInput(); - }, [activeStreamId, flushPendingTerminalInput]); + if (!isAttaching && !streamError) { + flushPendingTerminalInput(); + } + }, [flushPendingTerminalInput, isAttaching, streamError]); const clearPendingModifiers = useCallback(() => { setModifiers({ ...EMPTY_MODIFIERS }); @@ -443,7 +399,7 @@ export function TerminalPane({ } return true; }, - [client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, getCurrentActiveStreamId], + [client, dispatchTerminalInputEntry, enqueuePendingTerminalInput], ); const handleTerminalData = useCallback( @@ -496,7 +452,6 @@ export function TerminalPane({ clearPendingModifiers, client, dispatchTerminalInputEntry, - getCurrentActiveStreamId, modifiers.alt, modifiers.ctrl, modifiers.shift, @@ -538,13 +493,6 @@ export function TerminalPane({ clearPendingModifiers(); }, [clearPendingModifiers]); - const handleOutputChunkConsumed = useCallback( - (sequence: number) => { - outputSession.consume({ sequence }); - }, - [outputSession], - ); - const toggleModifier = useCallback( (modifier: keyof ModifierState) => { setModifiers((current) => ({ ...current, [modifier]: !current[modifier] })); @@ -589,6 +537,7 @@ export function TerminalPane({ {isScreenFocused ? ( void) => void; reset: () => void; + resize?: (cols: number, rows: number) => void; focus: () => void; refresh?: (start: number, end: number) => void; options?: { theme?: unknown }; rows?: number; + cols?: number; }; function createRuntimeWithTerminal(): { @@ -35,10 +37,12 @@ function createRuntimeWithTerminal(): { resetCalls += 1; terminal.resetCalls = resetCalls; }, + resize: () => {}, focus: () => {}, refresh: () => {}, options: { theme: undefined }, rows: 0, + cols: 0, resetCalls, }; @@ -188,12 +192,16 @@ describe("terminal-emulator-runtime", () => { refresh, options: { theme: { background: "before" } }, rows: 12, + cols: 40, }; (runtime as unknown as { terminal: StubTerminal }).terminal = terminal; runtime.setTheme({ theme: { background: "after" } as never }); - expect(terminal.options?.theme).toEqual({ background: "after" }); + expect(terminal.options?.theme).toEqual({ + background: "after", + overviewRulerBorder: "after", + }); expect(refresh).toHaveBeenCalledWith(0, 11); }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index ba8727298..368fa4c9c 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -2,6 +2,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebglAddon } from "@xterm/addon-webgl"; import { Terminal, type ITheme } from "@xterm/xterm"; +import type { TerminalState } from "@server/shared/messages"; import { type PendingTerminalModifiers, isTerminalModifierDomKey, @@ -10,11 +11,12 @@ import { normalizeTerminalTransportKey, shouldInterceptDomTerminalKey, } from "@/utils/terminal-keys"; +import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; export type TerminalEmulatorRuntimeMountInput = { root: HTMLDivElement; host: HTMLDivElement; - initialOutputText: string; + initialSnapshot: TerminalState | null; theme: ITheme; }; @@ -50,8 +52,10 @@ type TerminalEmulatorRuntimeDisposables = { }; type TerminalOutputOperation = { - type: "write" | "clear"; + type: "write" | "clear" | "snapshot"; text: string; + rows?: number; + cols?: number; suppressInput?: boolean; onCommitted?: () => void; }; @@ -332,8 +336,8 @@ export class TerminalEmulatorRuntime { fitAndEmitResize(true); }, 0); - if (input.initialOutputText.length > 0) { - this.write({ text: input.initialOutputText, suppressInput: true }); + if (input.initialSnapshot) { + this.renderSnapshot({ state: input.initialSnapshot }); } this.processOutputQueue(); @@ -434,6 +438,22 @@ export class TerminalEmulatorRuntime { this.processOutputQueue(); } + renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void { + if (!input.state) { + this.clear(input); + return; + } + this.outputOperations.push({ + type: "snapshot", + text: renderTerminalSnapshotToAnsi(input.state), + rows: input.state.rows, + cols: input.state.cols, + suppressInput: true, + ...(input.onCommitted ? { onCommitted: input.onCommitted } : {}), + }); + this.processOutputQueue(); + } + resize(input?: { force?: boolean }): void { this.fitAndEmitResize?.(input?.force ?? false); } @@ -523,6 +543,22 @@ export class TerminalEmulatorRuntime { return; } + if (operation.type === "snapshot") { + try { + if ( + typeof operation.cols === "number" && + typeof operation.rows === "number" && + (terminal.cols !== operation.cols || terminal.rows !== operation.rows) + ) { + terminal.resize(operation.cols, operation.rows); + } + terminal.reset(); + } catch { + finalizeOperation(operation); + return; + } + } + const text = operation.text; this.inFlightOutputOperationTimeout = setTimeout(() => { finalizeOperation(operation); diff --git a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts deleted file mode 100644 index f709349bc..000000000 --- a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { TerminalOutputDeliveryQueue } from "./terminal-output-delivery-queue"; - -describe("terminal-output-delivery-queue", () => { - it("retries in-flight delivery when consume is missing", () => { - vi.useFakeTimers(); - const delivered: Array<{ sequence: number; text: string; replay: boolean }> = []; - const queue = new TerminalOutputDeliveryQueue({ - onDeliver: (chunk) => { - delivered.push(chunk); - }, - deliveryTimeoutMs: 100, - }); - - queue.enqueue({ sequence: 1, text: "a", replay: false }); - queue.enqueue({ sequence: 2, text: "b", replay: false }); - - expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]); - - vi.advanceTimersByTime(100); - expect(delivered).toEqual([ - { sequence: 1, text: "a", replay: false }, - { sequence: 1, text: "a", replay: false }, - ]); - - queue.consume({ sequence: 1 }); - expect(delivered).toEqual([ - { sequence: 1, text: "a", replay: false }, - { sequence: 1, text: "a", replay: false }, - { sequence: 2, text: "b", replay: false }, - ]); - - vi.useRealTimers(); - }); - - it("delivers first chunk immediately and blocks later chunks until consumed", () => { - const delivered: Array<{ sequence: number; text: string; replay: boolean }> = []; - const queue = new TerminalOutputDeliveryQueue({ - onDeliver: (chunk) => { - delivered.push(chunk); - }, - }); - - queue.enqueue({ sequence: 1, text: "a", replay: false }); - queue.enqueue({ sequence: 2, text: "b", replay: false }); - queue.enqueue({ sequence: 3, text: "c", replay: false }); - - expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]); - - queue.consume({ sequence: 1 }); - expect(delivered).toEqual([ - { sequence: 1, text: "a", replay: false }, - { sequence: 3, text: "bc", replay: false }, - ]); - }); - - it("ignores stale consume acknowledgements", () => { - const delivered = vi.fn(); - const queue = new TerminalOutputDeliveryQueue({ onDeliver: delivered }); - - queue.enqueue({ sequence: 1, text: "x", replay: false }); - queue.consume({ sequence: 99 }); - queue.enqueue({ sequence: 2, text: "y", replay: false }); - - expect(delivered).toHaveBeenCalledTimes(1); - expect(delivered).toHaveBeenNthCalledWith(1, { sequence: 1, text: "x", replay: false }); - - queue.consume({ sequence: 1 }); - - expect(delivered).toHaveBeenCalledTimes(2); - expect(delivered).toHaveBeenNthCalledWith(2, { sequence: 2, text: "y", replay: false }); - }); - - it("resets in-flight and pending chunks", () => { - const delivered: Array<{ sequence: number; text: string; replay: boolean }> = []; - const queue = new TerminalOutputDeliveryQueue({ - onDeliver: (chunk) => { - delivered.push(chunk); - }, - }); - - queue.enqueue({ sequence: 1, text: "hello", replay: false }); - queue.enqueue({ sequence: 2, text: " world", replay: false }); - - queue.reset(); - queue.enqueue({ sequence: 3, text: "next", replay: false }); - - expect(delivered).toEqual([ - { sequence: 1, text: "hello", replay: false }, - { sequence: 3, text: "next", replay: false }, - ]); - }); - - it("preserves empty chunk payloads for authoritative clears", () => { - const delivered: Array<{ sequence: number; text: string; replay: boolean }> = []; - const queue = new TerminalOutputDeliveryQueue({ - onDeliver: (chunk) => { - delivered.push(chunk); - }, - }); - - queue.enqueue({ sequence: 1, text: "abc", replay: false }); - queue.consume({ sequence: 1 }); - queue.enqueue({ sequence: 2, text: "", replay: false }); - - expect(delivered).toEqual([ - { sequence: 1, text: "abc", replay: false }, - { sequence: 2, text: "", replay: false }, - ]); - }); - - it("treats clear chunks as a delivery barrier and drops pending stale text", () => { - const delivered: Array<{ sequence: number; text: string; replay: boolean }> = []; - const queue = new TerminalOutputDeliveryQueue({ - onDeliver: (chunk) => { - delivered.push(chunk); - }, - }); - - queue.enqueue({ sequence: 1, text: "a", replay: false }); - queue.enqueue({ sequence: 2, text: "b", replay: false }); - queue.enqueue({ sequence: 3, text: "", replay: false }); - queue.enqueue({ sequence: 4, text: "c", replay: false }); - - expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]); - - queue.consume({ sequence: 1 }); - expect(delivered).toEqual([ - { sequence: 1, text: "a", replay: false }, - { sequence: 3, text: "", replay: false }, - ]); - - queue.consume({ sequence: 3 }); - expect(delivered).toEqual([ - { sequence: 1, text: "a", replay: false }, - { sequence: 3, text: "", replay: false }, - { sequence: 4, text: "c", replay: false }, - ]); - }); -}); diff --git a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts deleted file mode 100644 index 7bbd2f7fe..000000000 --- a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts +++ /dev/null @@ -1,113 +0,0 @@ -export type TerminalOutputDeliveryChunk = { - sequence: number; - text: string; - replay: boolean; -}; - -export type TerminalOutputDeliveryQueueOptions = { - onDeliver: (chunk: TerminalOutputDeliveryChunk) => void; - deliveryTimeoutMs?: number; -}; - -const DEFAULT_DELIVERY_TIMEOUT_MS = 8_000; - -export class TerminalOutputDeliveryQueue { - private readonly pendingChunks: TerminalOutputDeliveryChunk[] = []; - private inFlightChunk: TerminalOutputDeliveryChunk | null = null; - private inFlightTimeout: ReturnType | null = null; - private lastSeenSequence = 0; - private readonly deliveryTimeoutMs: number; - - constructor(private readonly options: TerminalOutputDeliveryQueueOptions) { - this.deliveryTimeoutMs = options.deliveryTimeoutMs ?? DEFAULT_DELIVERY_TIMEOUT_MS; - } - - enqueue(chunk: TerminalOutputDeliveryChunk): void { - if (chunk.sequence <= 0) { - return; - } - if (chunk.sequence <= this.lastSeenSequence) { - return; - } - this.lastSeenSequence = chunk.sequence; - - if (chunk.text.length === 0) { - this.pendingChunks.length = 0; - this.pendingChunks.push(chunk); - this.tryDeliver(); - return; - } - - const lastPendingChunk = this.pendingChunks[this.pendingChunks.length - 1]; - if ( - lastPendingChunk && - lastPendingChunk.text.length > 0 && - lastPendingChunk.replay === chunk.replay - ) { - lastPendingChunk.sequence = chunk.sequence; - lastPendingChunk.text += chunk.text; - } else { - this.pendingChunks.push(chunk); - } - this.tryDeliver(); - } - - consume(input: { sequence: number }): void { - if (this.inFlightChunk?.sequence !== input.sequence) { - return; - } - - this.clearInFlightTimeout(); - this.inFlightChunk = null; - this.tryDeliver(); - } - - reset(): void { - this.clearInFlightTimeout(); - this.pendingChunks.length = 0; - this.inFlightChunk = null; - this.lastSeenSequence = 0; - } - - private tryDeliver(): void { - if (this.inFlightChunk) { - return; - } - const nextChunk = this.pendingChunks.shift(); - if (!nextChunk) { - return; - } - - this.inFlightChunk = nextChunk; - this.deliverInFlightChunk(); - } - - private deliverInFlightChunk(): void { - const chunk = this.inFlightChunk; - if (!chunk) { - return; - } - - this.clearInFlightTimeout(); - this.inFlightTimeout = setTimeout(() => { - if (!this.inFlightChunk) { - return; - } - this.deliverInFlightChunk(); - }, this.deliveryTimeoutMs); - - this.options.onDeliver({ - sequence: chunk.sequence, - text: chunk.text, - replay: chunk.replay, - }); - } - - private clearInFlightTimeout(): void { - if (!this.inFlightTimeout) { - return; - } - clearTimeout(this.inFlightTimeout); - this.inFlightTimeout = null; - } -} diff --git a/packages/app/src/terminal/runtime/terminal-output-pump.test.ts b/packages/app/src/terminal/runtime/terminal-output-pump.test.ts deleted file mode 100644 index e32d83455..000000000 --- a/packages/app/src/terminal/runtime/terminal-output-pump.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { TerminalOutputPump } from "./terminal-output-pump"; - -describe("terminal-output-pump", () => { - it("batches selected-terminal chunk bursts into ordered flushes", () => { - vi.useFakeTimers(); - const chunks: Array<{ sequence: number; text: string; replay: boolean }> = []; - const pump = new TerminalOutputPump({ - maxOutputChars: 100, - onSelectedOutputChunk: (chunk) => { - chunks.push(chunk); - }, - }); - - pump.setSelectedTerminal({ terminalId: "term-1" }); - pump.append({ terminalId: "term-1", text: "a", replay: false }); - pump.append({ terminalId: "term-1", text: "b", replay: false }); - pump.append({ terminalId: "term-1", text: "c", replay: false }); - - expect(chunks).toEqual([]); - - vi.runOnlyPendingTimers(); - - expect(chunks).toEqual([{ sequence: 1, text: "abc", replay: false }]); - - vi.useRealTimers(); - }); - - it("keeps per-terminal snapshots and switches selected stream deterministically", () => { - vi.useFakeTimers(); - const chunks: Array<{ sequence: number; text: string; replay: boolean }> = []; - const pump = new TerminalOutputPump({ - maxOutputChars: 10, - onSelectedOutputChunk: (chunk) => { - chunks.push(chunk); - }, - }); - - pump.setSelectedTerminal({ terminalId: "term-1" }); - pump.append({ terminalId: "term-1", text: "hello", replay: false }); - vi.runOnlyPendingTimers(); - expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("hello"); - - pump.append({ terminalId: "term-2", text: "world", replay: false }); - vi.runOnlyPendingTimers(); - expect(pump.readSnapshot({ terminalId: "term-2" })).toBe("world"); - - pump.setSelectedTerminal({ terminalId: "term-2" }); - pump.append({ terminalId: "term-2", text: "!", replay: false }); - vi.runOnlyPendingTimers(); - - expect(chunks).toEqual([ - { sequence: 1, text: "hello", replay: false }, - { sequence: 2, text: "!", replay: false }, - ]); - - vi.useRealTimers(); - }); - - it("resets selected output when clearing selected terminal", () => { - vi.useFakeTimers(); - const chunks: Array<{ sequence: number; text: string; replay: boolean }> = []; - const pump = new TerminalOutputPump({ - maxOutputChars: 10, - onSelectedOutputChunk: (chunk) => { - chunks.push(chunk); - }, - }); - - pump.setSelectedTerminal({ terminalId: "term-1" }); - pump.append({ terminalId: "term-1", text: "abc", replay: false }); - vi.runOnlyPendingTimers(); - - pump.clearTerminal({ terminalId: "term-1" }); - - expect(pump.readSnapshot({ terminalId: "term-1" })).toBe(""); - expect(chunks).toEqual([ - { sequence: 1, text: "abc", replay: false }, - { sequence: 2, text: "", replay: false }, - ]); - - vi.useRealTimers(); - }); - - it("prunes orphaned terminal buffers", () => { - const pump = new TerminalOutputPump({ - maxOutputChars: 100, - onSelectedOutputChunk: () => {}, - }); - - pump.append({ terminalId: "a", text: "one", replay: false }); - pump.append({ terminalId: "b", text: "two", replay: false }); - pump.prune({ terminalIds: ["b"] }); - - expect(pump.readSnapshot({ terminalId: "a" })).toBe(""); - expect(pump.readSnapshot({ terminalId: "b" })).toBe("two"); - }); -}); diff --git a/packages/app/src/terminal/runtime/terminal-output-pump.ts b/packages/app/src/terminal/runtime/terminal-output-pump.ts deleted file mode 100644 index 22b80abfe..000000000 --- a/packages/app/src/terminal/runtime/terminal-output-pump.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { - appendTerminalOutputBuffer, - createTerminalOutputBuffer, - readTerminalOutputBuffer, - type TerminalOutputBuffer, -} from "@/utils/terminal-output-buffer"; - -export type TerminalOutputChunk = { - sequence: number; - text: string; - replay: boolean; -}; - -export type TerminalOutputPumpOptions = { - maxOutputChars: number; - onSelectedOutputChunk: (chunk: TerminalOutputChunk) => void; -}; - -export type TerminalOutputPumpSetSelectedInput = { - terminalId: string | null; -}; - -export type TerminalOutputPumpAppendInput = { - terminalId: string; - text: string; - replay: boolean; -}; - -export type TerminalOutputPumpReadInput = { - terminalId: string | null; -}; - -export type TerminalOutputPumpClearInput = { - terminalId: string; -}; - -export type TerminalOutputPumpPruneInput = { - terminalIds: string[]; -}; - -export class TerminalOutputPump { - private readonly buffersByTerminalId = new Map(); - private selectedTerminalId: string | null = null; - private selectedChunkSequence = 0; - private selectedChunkAccumulator = ""; - private selectedChunkAccumulatorReplay: boolean | null = null; - private selectedChunkFlushTimer: ReturnType | null = null; - - constructor(private readonly options: TerminalOutputPumpOptions) {} - - setSelectedTerminal(input: TerminalOutputPumpSetSelectedInput): void { - if (this.selectedTerminalId === input.terminalId) { - return; - } - - this.clearSelectedChunkFlushTimer(); - this.selectedChunkAccumulator = ""; - this.selectedChunkAccumulatorReplay = null; - this.selectedTerminalId = input.terminalId; - } - - append(input: TerminalOutputPumpAppendInput): void { - if (input.text.length === 0) { - return; - } - - let buffer = this.buffersByTerminalId.get(input.terminalId); - if (!buffer) { - buffer = createTerminalOutputBuffer(); - this.buffersByTerminalId.set(input.terminalId, buffer); - } - - appendTerminalOutputBuffer({ - buffer, - text: input.text, - maxChars: this.options.maxOutputChars, - }); - - if (this.selectedTerminalId !== input.terminalId) { - return; - } - - if ( - this.selectedChunkAccumulator.length > 0 && - typeof this.selectedChunkAccumulatorReplay === "boolean" && - this.selectedChunkAccumulatorReplay !== input.replay - ) { - this.flushSelectedChunkAccumulator(); - } - - if (this.selectedChunkAccumulator.length === 0) { - this.selectedChunkAccumulatorReplay = input.replay; - } - - this.selectedChunkAccumulator += input.text; - this.scheduleSelectedChunkFlush(); - } - - clearTerminal(input: TerminalOutputPumpClearInput): void { - this.buffersByTerminalId.delete(input.terminalId); - if (this.selectedTerminalId === input.terminalId) { - this.clearSelectedChunkFlushTimer(); - this.selectedChunkAccumulator = ""; - this.selectedChunkAccumulatorReplay = null; - this.emitSelectedChunk({ text: "", replay: false }); - } - } - - prune(input: TerminalOutputPumpPruneInput): void { - const terminalIdSet = new Set(input.terminalIds); - for (const terminalId of Array.from(this.buffersByTerminalId.keys())) { - if (!terminalIdSet.has(terminalId)) { - this.buffersByTerminalId.delete(terminalId); - } - } - } - - readSnapshot(input: TerminalOutputPumpReadInput): string { - if (!input.terminalId) { - return ""; - } - const buffer = this.buffersByTerminalId.get(input.terminalId); - if (!buffer) { - return ""; - } - return readTerminalOutputBuffer({ buffer }); - } - - dispose(): void { - this.clearSelectedChunkFlushTimer(); - this.selectedChunkAccumulator = ""; - this.selectedTerminalId = null; - this.buffersByTerminalId.clear(); - } - - private scheduleSelectedChunkFlush(): void { - if (this.selectedChunkFlushTimer) { - return; - } - - this.selectedChunkFlushTimer = setTimeout(() => { - this.selectedChunkFlushTimer = null; - this.flushSelectedChunkAccumulator(); - }, 0); - } - - private flushSelectedChunkAccumulator(): void { - if (this.selectedChunkAccumulator.length === 0) { - return; - } - - const text = this.selectedChunkAccumulator; - const replay = this.selectedChunkAccumulatorReplay ?? false; - this.selectedChunkAccumulator = ""; - this.selectedChunkAccumulatorReplay = null; - this.emitSelectedChunk({ text, replay }); - } - - private emitSelectedChunk(input: { text: string; replay: boolean }): void { - this.selectedChunkSequence += 1; - this.options.onSelectedOutputChunk({ - sequence: this.selectedChunkSequence, - text: input.text, - replay: input.replay, - }); - } - - private clearSelectedChunkFlushTimer(): void { - if (!this.selectedChunkFlushTimer) { - return; - } - clearTimeout(this.selectedChunkFlushTimer); - this.selectedChunkFlushTimer = null; - } -} diff --git a/packages/app/src/terminal/runtime/terminal-output-session.test.ts b/packages/app/src/terminal/runtime/terminal-output-session.test.ts deleted file mode 100644 index 6b8744a15..000000000 --- a/packages/app/src/terminal/runtime/terminal-output-session.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - getTerminalOutputSession, - releaseTerminalOutputSession, - retainTerminalOutputSession, -} from "./terminal-output-session"; - -describe("terminal-output-session", () => { - async function flushAsyncWork(): Promise { - await Promise.resolve(); - await new Promise((resolve) => { - setTimeout(() => resolve(), 0); - }); - await Promise.resolve(); - } - - it("returns the same session instance for the same scope key", () => { - const a = getTerminalOutputSession({ - scopeKey: "scope-a", - maxOutputChars: 1_000, - }); - const b = getTerminalOutputSession({ - scopeKey: "scope-a", - maxOutputChars: 50, - }); - - expect(a).toBe(b); - }); - - it("evicts a scope session when the retain count returns to zero", () => { - const scopeKey = "scope-retain-release"; - const first = getTerminalOutputSession({ - scopeKey, - maxOutputChars: 1_000, - }); - - retainTerminalOutputSession({ scopeKey }); - releaseTerminalOutputSession({ scopeKey }); - - const second = getTerminalOutputSession({ - scopeKey, - maxOutputChars: 1_000, - }); - - expect(second).not.toBe(first); - }); - - it("publishes selected terminal snapshot and chunk state via delivery/consume", async () => { - const session = getTerminalOutputSession({ - scopeKey: "scope-session-state", - maxOutputChars: 1_000, - }); - - session.setSelectedTerminal({ terminalId: null }); - session.setSelectedTerminal({ terminalId: "term-1" }); - - session.append({ - terminalId: "term-1", - text: "abc", - replay: false, - }); - await flushAsyncWork(); - - const stateAfterAppend = session.getState(); - expect(stateAfterAppend.selectedTerminalId).toBe("term-1"); - expect(stateAfterAppend.chunkSequence).toBeGreaterThan(0); - expect(stateAfterAppend.chunkText).toBe("abc"); - expect(stateAfterAppend.snapshotText).toBe("abc"); - expect(stateAfterAppend.snapshotSequence).toBe(stateAfterAppend.chunkSequence); - - session.consume({ sequence: stateAfterAppend.chunkSequence }); - - const stateAfterConsume = session.getState(); - expect(stateAfterConsume.snapshotText).toBe("abc"); - }); - - it("keeps chunks for non-selected terminals out of selected state", async () => { - const session = getTerminalOutputSession({ - scopeKey: "scope-other-terminal", - maxOutputChars: 1_000, - }); - - session.setSelectedTerminal({ terminalId: "term-1" }); - session.append({ - terminalId: "term-2", - text: "hidden", - replay: false, - }); - await flushAsyncWork(); - - const state = session.getState(); - expect(state.selectedTerminalId).toBe("term-1"); - expect(state.chunkText).toBe(""); - expect(state.snapshotText).toBe(""); - }); -}); diff --git a/packages/app/src/terminal/runtime/terminal-output-session.ts b/packages/app/src/terminal/runtime/terminal-output-session.ts deleted file mode 100644 index adda84cdb..000000000 --- a/packages/app/src/terminal/runtime/terminal-output-session.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { TerminalOutputPump, type TerminalOutputChunk } from "./terminal-output-pump"; -import { - TerminalOutputDeliveryQueue, - type TerminalOutputDeliveryChunk, -} from "./terminal-output-delivery-queue"; - -type TerminalOutputState = { - selectedTerminalId: string | null; - snapshotText: string; - snapshotSequence: number; - chunkText: string; - chunkSequence: number; - chunkReplay: boolean; -}; - -const EMPTY_STATE: TerminalOutputState = { - selectedTerminalId: null, - snapshotText: "", - snapshotSequence: 0, - chunkText: "", - chunkSequence: 0, - chunkReplay: false, -}; - -export type TerminalOutputSessionAppendInput = { - terminalId: string; - text: string; - replay: boolean; -}; - -export type TerminalOutputSessionSetSelectedInput = { - terminalId: string | null; -}; - -export type TerminalOutputSessionPruneInput = { - terminalIds: string[]; -}; - -export type TerminalOutputSessionConsumeInput = { - sequence: number; -}; - -export type TerminalOutputSessionReadSnapshotInput = { - terminalId: string | null; -}; - -export class TerminalOutputSession { - private readonly listeners = new Set<() => void>(); - private readonly outputPump: TerminalOutputPump; - private readonly deliveryQueue: TerminalOutputDeliveryQueue; - private state: TerminalOutputState = EMPTY_STATE; - - constructor(input: { maxOutputChars: number }) { - this.outputPump = new TerminalOutputPump({ - maxOutputChars: input.maxOutputChars, - onSelectedOutputChunk: (chunk: TerminalOutputChunk) => { - this.deliveryQueue.enqueue(chunk); - }, - }); - - this.deliveryQueue = new TerminalOutputDeliveryQueue({ - onDeliver: (chunk: TerminalOutputDeliveryChunk) => { - const snapshotText = this.outputPump.readSnapshot({ - terminalId: this.state.selectedTerminalId, - }); - this.state = { - ...this.state, - snapshotText, - snapshotSequence: chunk.sequence, - chunkText: chunk.text, - chunkSequence: chunk.sequence, - chunkReplay: chunk.replay, - }; - this.emit(); - }, - }); - } - - subscribe(listener: () => void): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - getState(): TerminalOutputState { - return this.state; - } - - setSelectedTerminal(input: TerminalOutputSessionSetSelectedInput): void { - if (this.state.selectedTerminalId === input.terminalId) { - return; - } - - this.deliveryQueue.reset(); - this.outputPump.setSelectedTerminal({ terminalId: input.terminalId }); - const snapshotText = this.outputPump.readSnapshot({ - terminalId: input.terminalId, - }); - this.state = { - selectedTerminalId: input.terminalId, - snapshotText, - snapshotSequence: this.state.snapshotSequence, - chunkText: "", - chunkSequence: 0, - chunkReplay: false, - }; - this.emit(); - } - - append(input: TerminalOutputSessionAppendInput): void { - this.outputPump.append(input); - } - - readSnapshot(input: TerminalOutputSessionReadSnapshotInput): string { - return this.outputPump.readSnapshot(input); - } - - clearTerminal(input: { terminalId: string }): void { - this.outputPump.clearTerminal(input); - if (this.state.selectedTerminalId !== input.terminalId) { - return; - } - - this.deliveryQueue.reset(); - this.state = { - ...this.state, - snapshotText: "", - chunkText: "", - chunkSequence: 0, - chunkReplay: false, - }; - this.emit(); - } - - prune(input: TerminalOutputSessionPruneInput): void { - this.outputPump.prune(input); - } - - consume(input: TerminalOutputSessionConsumeInput): void { - this.deliveryQueue.consume(input); - } - - private emit(): void { - for (const listener of this.listeners) { - listener(); - } - } -} - -const sessionsByScopeKey = new Map(); -const sessionRefCountByScopeKey = new Map(); - -export function getTerminalOutputSession(input: { - scopeKey: string; - maxOutputChars: number; -}): TerminalOutputSession { - const existing = sessionsByScopeKey.get(input.scopeKey); - if (existing) { - return existing; - } - - const session = new TerminalOutputSession({ - maxOutputChars: input.maxOutputChars, - }); - sessionsByScopeKey.set(input.scopeKey, session); - return session; -} - -export function retainTerminalOutputSession(input: { scopeKey: string }): void { - const current = sessionRefCountByScopeKey.get(input.scopeKey) ?? 0; - sessionRefCountByScopeKey.set(input.scopeKey, current + 1); -} - -export function releaseTerminalOutputSession(input: { scopeKey: string }): void { - const current = sessionRefCountByScopeKey.get(input.scopeKey) ?? 0; - if (current <= 1) { - sessionRefCountByScopeKey.delete(input.scopeKey); - sessionsByScopeKey.delete(input.scopeKey); - return; - } - - sessionRefCountByScopeKey.set(input.scopeKey, current - 1); -} diff --git a/packages/app/src/terminal/runtime/terminal-snapshot.test.ts b/packages/app/src/terminal/runtime/terminal-snapshot.test.ts new file mode 100644 index 000000000..7dd8ba5f6 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-snapshot.test.ts @@ -0,0 +1,117 @@ +import { Terminal as HeadlessTerminal } from "@xterm/headless"; +import { Terminal as ClientTerminal } from "@xterm/xterm"; +import { describe, expect, it } from "vitest"; + +import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; + +type SnapshotCell = { + char: string; + fg: number | undefined; + bg: number | undefined; + fgMode?: number; + bgMode?: number; + bold?: boolean; + italic?: boolean; + underline?: boolean; +}; + +type SnapshotState = { + rows: number; + cols: number; + grid: SnapshotCell[][]; + scrollback: SnapshotCell[][]; + cursor: { row: number; col: number }; +}; + +async function writeToTerminal( + terminal: Pick, + text: string, +): Promise { + await new Promise((resolve) => { + terminal.write(text, () => resolve()); + }); +} + +function extractState(terminal: ClientTerminal | HeadlessTerminal): SnapshotState { + const grid: SnapshotCell[][] = []; + const scrollback: SnapshotCell[][] = []; + const buffer = terminal.buffer.active; + const baseY = buffer.baseY; + + for (let row = 0; row < baseY; row += 1) { + scrollback.push(extractRow(terminal, row)); + } + for (let row = 0; row < terminal.rows; row += 1) { + grid.push(extractRow(terminal, baseY + row)); + } + + return { + rows: terminal.rows, + cols: terminal.cols, + grid, + scrollback, + cursor: { + row: buffer.cursorY, + col: buffer.cursorX, + }, + }; +} + +function extractRow(terminal: ClientTerminal | HeadlessTerminal, row: number): SnapshotCell[] { + const cells: SnapshotCell[] = []; + const line = terminal.buffer.active.getLine(row); + + for (let col = 0; col < terminal.cols; col += 1) { + const cell = line?.getCell(col); + if (!cell) { + cells.push({ char: " ", fg: undefined, bg: undefined }); + continue; + } + const fgModeRaw = cell.getFgColorMode(); + const bgModeRaw = cell.getBgColorMode(); + const fgMode = fgModeRaw >> 24; + const bgMode = bgModeRaw >> 24; + cells.push({ + char: cell.getChars() || " ", + fg: fgMode !== 0 ? cell.getFgColor() : undefined, + bg: bgMode !== 0 ? cell.getBgColor() : undefined, + fgMode: fgMode !== 0 ? fgMode : undefined, + bgMode: bgMode !== 0 ? bgMode : undefined, + bold: cell.isBold() !== 0, + italic: cell.isItalic() !== 0, + underline: cell.isUnderline() !== 0, + }); + } + + return cells; +} + +describe("terminal-snapshot", () => { + it("replays extracted terminal state into a client xterm with matching grid, scrollback, and cursor", async () => { + const source = new HeadlessTerminal({ + rows: 4, + cols: 12, + allowProposedApi: true, + scrollback: 100, + }); + + await writeToTerminal(source, "plain\r\n"); + await writeToTerminal(source, "\u001b[31mred\u001b[0m\r\n"); + await writeToTerminal(source, "\u001b[1mbold\u001b[0m\r\n"); + await writeToTerminal(source, "cursor"); + await writeToTerminal(source, "\u001b[2D"); + + const snapshot = extractState(source); + + const client = new ClientTerminal({ + rows: snapshot.rows, + cols: snapshot.cols, + allowProposedApi: true, + scrollback: 100, + }); + + await writeToTerminal(client, renderTerminalSnapshotToAnsi(snapshot)); + + expect(extractState(client)).toEqual(snapshot); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-snapshot.ts b/packages/app/src/terminal/runtime/terminal-snapshot.ts new file mode 100644 index 000000000..346565551 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-snapshot.ts @@ -0,0 +1,158 @@ +import type { TerminalCell, TerminalState } from "@server/shared/messages"; + +type TerminalStyle = { + fg: number | undefined; + bg: number | undefined; + fgMode: number | undefined; + bgMode: number | undefined; + bold: boolean; + italic: boolean; + underline: boolean; +}; + +const DEFAULT_STYLE: TerminalStyle = { + fg: undefined, + bg: undefined, + fgMode: undefined, + bgMode: undefined, + bold: false, + italic: false, + underline: false, +}; + +export function renderTerminalSnapshotToAnsi(state: TerminalState): string { + const rows = [...state.scrollback, ...state.grid]; + const lines: string[] = ["\u001b[?7l"]; + + for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { + const row = rows[rowIndex] ?? []; + lines.push(renderTerminalRow(row)); + if (rowIndex < rows.length - 1) { + lines.push("\r\n"); + } + } + + lines.push("\u001b[0m"); + lines.push(`\u001b[${state.cursor.row + 1};${state.cursor.col + 1}H`); + lines.push("\u001b[?7h"); + return lines.join(""); +} + +function renderTerminalRow(row: TerminalCell[]): string { + const output: string[] = []; + const length = getTerminalRowLength(row); + let previousStyle = DEFAULT_STYLE; + + for (let index = 0; index < length; index += 1) { + const cell = row[index] ?? { char: " " }; + const nextStyle = getTerminalStyle(cell); + if (!terminalStylesEqual(previousStyle, nextStyle)) { + output.push(styleToAnsi(nextStyle)); + previousStyle = nextStyle; + } + output.push(cell.char || " "); + } + + if (!terminalStylesEqual(previousStyle, DEFAULT_STYLE)) { + output.push("\u001b[0m"); + } + + return output.join(""); +} + +function getTerminalRowLength(row: TerminalCell[]): number { + for (let index = row.length - 1; index >= 0; index -= 1) { + const cell = row[index]; + if (!cell) { + continue; + } + if (cell.char !== " ") { + return index + 1; + } + if ( + cell.fg !== undefined || + cell.bg !== undefined || + cell.fgMode !== undefined || + cell.bgMode !== undefined || + cell.bold || + cell.italic || + cell.underline + ) { + return index + 1; + } + } + return 0; +} + +function getTerminalStyle(cell: TerminalCell): TerminalStyle { + return { + fg: cell.fg, + bg: cell.bg, + fgMode: cell.fgMode, + bgMode: cell.bgMode, + bold: Boolean(cell.bold), + italic: Boolean(cell.italic), + underline: Boolean(cell.underline), + }; +} + +function terminalStylesEqual(left: TerminalStyle, right: TerminalStyle): boolean { + return ( + left.fg === right.fg && + left.bg === right.bg && + left.fgMode === right.fgMode && + left.bgMode === right.bgMode && + left.bold === right.bold && + left.italic === right.italic && + left.underline === right.underline + ); +} + +function styleToAnsi(style: TerminalStyle): string { + const codes = ["0"]; + + if (style.bold) { + codes.push("1"); + } + if (style.italic) { + codes.push("3"); + } + if (style.underline) { + codes.push("4"); + } + + if (style.fg !== undefined && style.fgMode !== undefined) { + codes.push(...colorToSgr(style.fgMode, style.fg, false)); + } + + if (style.bg !== undefined && style.bgMode !== undefined) { + codes.push(...colorToSgr(style.bgMode, style.bg, true)); + } + + return `\u001b[${codes.join(";")}m`; +} + +function colorToSgr(mode: number, value: number, background: boolean): string[] { + if (mode === 1) { + if (value >= 8) { + return [String((background ? 100 : 90) + (value - 8))]; + } + return [String((background ? 40 : 30) + value)]; + } + + if (mode === 2) { + return [background ? "48" : "38", "5", String(value)]; + } + + if (mode === 3) { + return [ + background ? "48" : "38", + "2", + String((value >> 16) & 0xff), + String((value >> 8) & 0xff), + String(value & 0xff), + ]; + } + + return []; +} diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts index 34b3a362f..34543b97f 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts @@ -2,154 +2,118 @@ import { describe, expect, it } from "vitest"; import { TerminalStreamController, - type TerminalStreamControllerAttachPayload, - type TerminalStreamControllerChunk, type TerminalStreamControllerClient, - type TerminalStreamControllerResumeOffsets, type TerminalStreamControllerStatus, } from "./terminal-stream-controller"; -type FakeStreamSubscriber = (chunk: TerminalStreamControllerChunk) => void; +type TerminalStreamEvent = + | { terminalId: string; type: "output"; data: Uint8Array } + | { + terminalId: string; + type: "snapshot"; + state: { + rows: number; + cols: number; + grid: Array>; + scrollback: Array>; + cursor: { row: number; col: number }; + }; + }; class FakeTerminalStreamClient implements TerminalStreamControllerClient { - private readonly streamSubscribers = new Map>(); - private readonly pendingChunksByStreamId = new Map(); - public attachCalls: Array<{ + private readonly listeners = new Set<(event: TerminalStreamEvent) => void>(); + public subscribeCalls: string[] = []; + public unsubscribeCalls: string[] = []; + public resizeCalls: Array<{ terminalId: string; rows: number; cols: number }> = []; + public nextSubscribeResponses: Array<{ terminalId: string; - options?: { - resumeOffset?: number; - rows?: number; - cols?: number; - }; + state: { + rows: number; + cols: number; + grid: Array>; + scrollback: Array>; + cursor: { row: number; col: number }; + } | null; + error?: string | null; }> = []; - public detachCalls: number[] = []; - public nextAttachResponses: TerminalStreamControllerAttachPayload[] = []; - async attachTerminalStream( - terminalId: string, - options?: { - resumeOffset?: number; - rows?: number; - cols?: number; - }, - ): Promise { - this.attachCalls.push({ terminalId, options }); - const response = this.nextAttachResponses.shift(); + async subscribeTerminal(terminalId: string) { + this.subscribeCalls.push(terminalId); + const response = this.nextSubscribeResponses.shift(); if (!response) { - throw new Error("Missing fake attach response"); + throw new Error("Missing fake subscribe response"); } return response; } - async detachTerminalStream(streamId: number): Promise { - this.detachCalls.push(streamId); + unsubscribeTerminal(terminalId: string): void { + this.unsubscribeCalls.push(terminalId); } - onTerminalStreamData( - streamId: number, - handler: (chunk: TerminalStreamControllerChunk) => void, - ): () => void { - const pendingChunks = this.pendingChunksByStreamId.get(streamId); - if (pendingChunks && pendingChunks.length > 0) { - for (const chunk of pendingChunks) { - handler(chunk); - } - this.pendingChunksByStreamId.delete(streamId); - } - - const subscribers = this.streamSubscribers.get(streamId) ?? new Set(); - subscribers.add(handler); - this.streamSubscribers.set(streamId, subscribers); - return () => { - const current = this.streamSubscribers.get(streamId); - current?.delete(handler); - if (current && current.size === 0) { - this.streamSubscribers.delete(streamId); - } - }; - } - - emitChunk(input: { - streamId: number; - offset?: number; - endOffset: number; - replay?: boolean; - data: string; - }): void { - const subscribers = this.streamSubscribers.get(input.streamId); - if (!subscribers || subscribers.size === 0) { - return; - } - const bytes = new TextEncoder().encode(input.data); - const chunk: TerminalStreamControllerChunk = { - offset: input.offset ?? input.endOffset - bytes.byteLength, - endOffset: input.endOffset, - replay: input.replay, - data: bytes, - }; - for (const subscriber of subscribers) { - subscriber(chunk); - } - } - - bufferChunk(input: { - streamId: number; - offset?: number; - endOffset: number; - replay?: boolean; - data: string; - }): void { - const chunks = this.pendingChunksByStreamId.get(input.streamId) ?? []; - const bytes = new TextEncoder().encode(input.data); - chunks.push({ - offset: input.offset ?? input.endOffset - bytes.byteLength, - endOffset: input.endOffset, - replay: input.replay, - data: bytes, + sendTerminalInput( + terminalId: string, + message: { type: "resize"; rows: number; cols: number }, + ): void { + this.resizeCalls.push({ + terminalId, + rows: message.rows, + cols: message.cols, }); - this.pendingChunksByStreamId.set(input.streamId, chunks); + } + + onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void { + this.listeners.add(handler); + return () => { + this.listeners.delete(handler); + }; + } + + emit(event: TerminalStreamEvent): void { + for (const listener of this.listeners) { + listener(event); + } } } function createControllerHarness(input?: { client?: FakeTerminalStreamClient; - resumeOffsets?: TerminalStreamControllerResumeOffsets; }): { client: FakeTerminalStreamClient; - chunks: Array<{ terminalId: string; text: string }>; + outputs: Array<{ terminalId: string; text: string }>; + snapshots: Array<{ terminalId: string; text: string }>; statuses: TerminalStreamControllerStatus[]; - resets: string[]; controller: TerminalStreamController; } { const client = input?.client ?? new FakeTerminalStreamClient(); - const chunks: Array<{ terminalId: string; text: string }> = []; + const outputs: Array<{ terminalId: string; text: string }> = []; + const snapshots: Array<{ terminalId: string; text: string }> = []; const statuses: TerminalStreamControllerStatus[] = []; - const resets: string[] = []; const controller = new TerminalStreamController({ client, getPreferredSize: () => ({ rows: 24, cols: 80 }), - resumeOffsets: input?.resumeOffsets, - onChunk: (chunk) => { - chunks.push({ - terminalId: chunk.terminalId, - text: chunk.text, + onOutput: ({ terminalId, text }) => { + outputs.push({ terminalId, text }); + }, + onSnapshot: ({ terminalId, state }) => { + snapshots.push({ + terminalId, + text: state.grid + .map((row) => row.map((cell) => cell.char).join("")) + .join("\n"), }); }, onStatusChange: (status) => { statuses.push(status); }, - onReset: ({ terminalId }) => { - resets.push(terminalId); - }, waitForDelay: async () => {}, }); return { client, - chunks, + outputs, + snapshots, statuses, - resets, controller, }; } @@ -163,425 +127,122 @@ async function flushAsyncWork(): Promise { } describe("terminal-stream-controller", () => { - it("streams burst chunks in order without dropping intermediate chunks", async () => { + it("subscribes to a terminal, resizes it, and forwards snapshot/output events", async () => { const harness = createControllerHarness(); - harness.client.nextAttachResponses.push({ - streamId: 7, - currentOffset: 0, - reset: false, + harness.client.nextSubscribeResponses.push({ + terminalId: "term-1", + state: null, error: null, }); harness.controller.setTerminal({ terminalId: "term-1" }); await flushAsyncWork(); - harness.client.emitChunk({ - streamId: 7, - endOffset: 1, - data: "a", + harness.client.emit({ + terminalId: "term-1", + type: "snapshot", + state: { + rows: 1, + cols: 5, + grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]], + scrollback: [], + cursor: { row: 0, col: 5 }, + }, }); - harness.client.emitChunk({ - streamId: 7, - endOffset: 2, - data: "b", - }); - harness.client.emitChunk({ - streamId: 7, - endOffset: 3, - data: "c", + harness.client.emit({ + terminalId: "term-1", + type: "output", + data: new TextEncoder().encode(" world"), }); - expect(harness.chunks).toEqual([ - { terminalId: "term-1", text: "a" }, - { terminalId: "term-1", text: "b" }, - { terminalId: "term-1", text: "c" }, - ]); + expect(harness.client.subscribeCalls).toEqual(["term-1"]); + expect(harness.client.resizeCalls).toEqual([{ terminalId: "term-1", rows: 24, cols: 80 }]); + expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]); + expect(harness.outputs).toEqual([{ terminalId: "term-1", text: " world" }]); + expect(harness.statuses.at(-1)).toEqual({ + terminalId: "term-1", + isAttaching: false, + error: null, + }); }); - it("retries retryable attach failures and then attaches", async () => { + it("retries retryable subscribe failures and then attaches", async () => { const harness = createControllerHarness(); - harness.client.nextAttachResponses.push({ - streamId: null, - currentOffset: 0, - reset: false, + harness.client.nextSubscribeResponses.push({ + terminalId: "term-1", + state: null, error: "network disconnected", }); - harness.client.nextAttachResponses.push({ - streamId: 9, - currentOffset: 5, - reset: false, + harness.client.nextSubscribeResponses.push({ + terminalId: "term-1", + state: null, error: null, }); harness.controller.setTerminal({ terminalId: "term-1" }); await flushAsyncWork(); - expect(harness.client.attachCalls.length).toBe(2); - expect(harness.client.attachCalls[1]?.options).toEqual({ - resumeOffset: 0, - rows: 24, - cols: 80, - }); - expect(harness.controller.getActiveStreamId()).toBe(9); + expect(harness.client.subscribeCalls).toEqual(["term-1", "term-1"]); expect(harness.statuses.at(-1)).toEqual({ terminalId: "term-1", - streamId: 9, isAttaching: false, error: null, }); }); - it("handles stream exit by reconnecting on the same terminal", async () => { + it("reconnects to the selected terminal when the stream exits", async () => { const harness = createControllerHarness(); - harness.client.nextAttachResponses.push({ - streamId: 3, - currentOffset: 0, - reset: false, + harness.client.nextSubscribeResponses.push({ + terminalId: "term-1", + state: null, error: null, }); - harness.client.nextAttachResponses.push({ - streamId: 4, - currentOffset: 2, - reset: false, + harness.client.nextSubscribeResponses.push({ + terminalId: "term-1", + state: null, error: null, }); harness.controller.setTerminal({ terminalId: "term-1" }); await flushAsyncWork(); - harness.client.emitChunk({ - streamId: 3, - endOffset: 2, - data: "hi", - }); - harness.controller.handleStreamExit({ - terminalId: "term-1", - streamId: 3, - }); + harness.controller.handleStreamExit({ terminalId: "term-1" }); await flushAsyncWork(); - expect(harness.client.attachCalls.length).toBe(2); - expect(harness.client.attachCalls[1]?.options).toEqual({ - resumeOffset: 2, - rows: 24, - cols: 80, - }); - expect(harness.controller.getActiveStreamId()).toBe(4); + expect(harness.client.subscribeCalls).toEqual(["term-1", "term-1"]); expect(harness.statuses.at(-1)).toEqual({ terminalId: "term-1", - streamId: 4, isAttaching: false, error: null, }); }); - it("recovers when stream exits before initial attach completes", async () => { + it("unsubscribes when switching terminals and on dispose", async () => { const harness = createControllerHarness(); - harness.client.nextAttachResponses.push({ - streamId: 1, - currentOffset: 1200, - reset: false, + harness.client.nextSubscribeResponses.push({ + terminalId: "term-1", + state: null, error: null, }); - harness.client.nextAttachResponses.push({ - streamId: 2, - currentOffset: 1200, - reset: false, + harness.client.nextSubscribeResponses.push({ + terminalId: "term-2", + state: null, error: null, }); - harness.controller.setTerminal({ terminalId: "term-attach-exit" }); - harness.controller.handleStreamExit({ - terminalId: "term-attach-exit", - streamId: 1, - }); + harness.controller.setTerminal({ terminalId: "term-1" }); await flushAsyncWork(); - expect(harness.client.attachCalls).toHaveLength(2); - expect(harness.client.attachCalls[1]?.options).toEqual({ - resumeOffset: 1200, - rows: 24, - cols: 80, - }); - expect(harness.client.detachCalls).toContain(1); - expect(harness.controller.getActiveStreamId()).toBe(2); - }); + harness.controller.setTerminal({ terminalId: "term-2" }); + await flushAsyncWork(); - it("emits reset callback when attach indicates output reset", async () => { - const harness = createControllerHarness(); - harness.client.nextAttachResponses.push({ - streamId: 12, - currentOffset: 0, - reset: true, + harness.controller.dispose(); + + expect(harness.client.unsubscribeCalls).toEqual(["term-1", "term-2"]); + expect(harness.statuses.at(-1)).toEqual({ + terminalId: null, + isAttaching: false, error: null, }); - - harness.controller.setTerminal({ terminalId: "term-reset" }); - await flushAsyncWork(); - - expect(harness.resets).toEqual(["term-reset"]); - expect(harness.controller.getActiveStreamId()).toBe(12); - }); - - it("delivers buffered replay chunks flushed synchronously during subscribe", async () => { - const harness = createControllerHarness(); - harness.client.nextAttachResponses.push({ - streamId: 17, - replayedFrom: 0, - currentOffset: 10, - reset: false, - error: null, - }); - harness.client.bufferChunk({ - streamId: 17, - offset: 0, - endOffset: 10, - replay: true, - data: "buffered-replay", - }); - - harness.controller.setTerminal({ terminalId: "term-buffered" }); - await flushAsyncWork(); - - expect(harness.chunks).toEqual([{ terminalId: "term-buffered", text: "buffered-replay" }]); - }); - - it("clears stale selected output when bootstrap replay starts before current offset", async () => { - const harness = createControllerHarness(); - harness.client.nextAttachResponses.push({ - streamId: 61, - replayedFrom: 0, - currentOffset: 20, - reset: false, - error: null, - }); - - harness.controller.setTerminal({ terminalId: "term-bootstrap-reset" }); - await flushAsyncWork(); - - expect(harness.resets).toEqual(["term-bootstrap-reset"]); - }); - - it("reattaches and replays from last contiguous offset when chunk offsets gap", async () => { - const harness = createControllerHarness(); - harness.client.nextAttachResponses.push({ - streamId: 31, - currentOffset: 0, - reset: false, - error: null, - }); - harness.client.nextAttachResponses.push({ - streamId: 32, - currentOffset: 8, - reset: false, - error: null, - }); - - harness.controller.setTerminal({ terminalId: "term-gap" }); - await flushAsyncWork(); - - harness.client.emitChunk({ - streamId: 31, - offset: 0, - endOffset: 2, - data: "ok", - }); - harness.client.emitChunk({ - streamId: 31, - offset: 4, - endOffset: 8, - data: "miss", - }); - await flushAsyncWork(); - - expect(harness.chunks).toEqual([{ terminalId: "term-gap", text: "ok" }]); - expect(harness.client.detachCalls).toContain(31); - expect(harness.client.attachCalls.length).toBe(2); - expect(harness.client.attachCalls[1]?.options).toEqual({ - resumeOffset: 2, - rows: 24, - cols: 80, - }); - expect(harness.controller.getActiveStreamId()).toBe(32); - }); - - it("does not treat replay range before currentOffset as a live gap", async () => { - const harness = createControllerHarness(); - harness.client.nextAttachResponses.push({ - streamId: 41, - replayedFrom: 50, - currentOffset: 100, - reset: false, - error: null, - }); - harness.client.bufferChunk({ - streamId: 41, - offset: 80, - endOffset: 100, - data: "replay-tail", - }); - - harness.controller.setTerminal({ terminalId: "term-replay" }); - await flushAsyncWork(); - - harness.client.emitChunk({ - streamId: 41, - offset: 100, - endOffset: 102, - data: "ok", - }); - - expect(harness.client.detachCalls).toEqual([]); - expect(harness.client.attachCalls).toHaveLength(1); - expect(harness.controller.getActiveStreamId()).toBe(41); - expect(harness.chunks).toEqual([ - { terminalId: "term-replay", text: "replay-tail" }, - { terminalId: "term-replay", text: "ok" }, - ]); - }); - - it("accepts clamped replay start after reconnect without entering a reconnect loop", async () => { - const harness = createControllerHarness(); - harness.client.nextAttachResponses.push({ - streamId: 71, - replayedFrom: 205, - currentOffset: 205, - reset: false, - error: null, - }); - harness.client.nextAttachResponses.push({ - streamId: 72, - replayedFrom: 396, - currentOffset: 978, - reset: true, - error: null, - }); - - harness.controller.setTerminal({ terminalId: "term-clamped-replay" }); - await flushAsyncWork(); - - harness.client.emitChunk({ - streamId: 71, - offset: 205, - endOffset: 299, - data: "first", - }); - harness.client.emitChunk({ - streamId: 71, - offset: 396, - endOffset: 493, - data: "gap", - }); - await flushAsyncWork(); - - expect(harness.client.attachCalls).toHaveLength(2); - expect(harness.client.attachCalls[1]?.options).toEqual({ - resumeOffset: 299, - rows: 24, - cols: 80, - }); - expect(harness.controller.getActiveStreamId()).toBe(72); - - harness.client.emitChunk({ - streamId: 72, - replay: true, - offset: 396, - endOffset: 493, - data: "replay-1", - }); - harness.client.emitChunk({ - streamId: 72, - replay: true, - offset: 493, - endOffset: 590, - data: "replay-2", - }); - harness.client.emitChunk({ - streamId: 72, - replay: true, - offset: 687, - endOffset: 784, - data: "replay-3", - }); - harness.client.emitChunk({ - streamId: 72, - replay: false, - offset: 978, - endOffset: 1075, - data: "live", - }); - await flushAsyncWork(); - - expect(harness.client.attachCalls).toHaveLength(2); - expect(harness.client.detachCalls.filter((streamId) => streamId === 71)).toHaveLength(1); - expect(harness.chunks.at(-1)).toEqual({ - terminalId: "term-clamped-replay", - text: "live", - }); - }); - - it("reuses external resume offsets across controller recreation", async () => { - const client = new FakeTerminalStreamClient(); - const resumeOffsetByTerminalId = new Map(); - const resumeOffsets: TerminalStreamControllerResumeOffsets = { - get: ({ terminalId }) => resumeOffsetByTerminalId.get(terminalId), - set: ({ terminalId, offset }) => { - resumeOffsetByTerminalId.set(terminalId, offset); - }, - clear: ({ terminalId }) => { - resumeOffsetByTerminalId.delete(terminalId); - }, - prune: ({ terminalIds }) => { - const terminalIdSet = new Set(terminalIds); - for (const terminalId of Array.from(resumeOffsetByTerminalId.keys())) { - if (!terminalIdSet.has(terminalId)) { - resumeOffsetByTerminalId.delete(terminalId); - } - } - }, - }; - - client.nextAttachResponses.push({ - streamId: 81, - currentOffset: 0, - reset: false, - error: null, - }); - - const firstHarness = createControllerHarness({ - client, - resumeOffsets, - }); - firstHarness.controller.setTerminal({ terminalId: "term-shared-resume" }); - await flushAsyncWork(); - - client.emitChunk({ - streamId: 81, - offset: 0, - endOffset: 2, - data: "ok", - }); - await flushAsyncWork(); - firstHarness.controller.dispose(); - - client.nextAttachResponses.push({ - streamId: 82, - currentOffset: 2, - reset: false, - error: null, - }); - - const secondHarness = createControllerHarness({ - client, - resumeOffsets, - }); - secondHarness.controller.setTerminal({ terminalId: "term-shared-resume" }); - await flushAsyncWork(); - - expect(client.attachCalls.at(-1)?.options).toEqual({ - resumeOffset: 2, - rows: 24, - cols: 80, - }); }); }); diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts index b2a21ca64..7e2f9e025 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts @@ -1,41 +1,28 @@ +import type { TerminalState } from "@server/shared/messages"; import { getTerminalAttachRetryDelayMs, - getTerminalResumeOffset, isTerminalAttachRetryableError, - type TerminalResumeOffsetStore, - updateTerminalResumeOffset, waitForDuration, withPromiseTimeout, } from "@/utils/terminal-attach"; -export type TerminalStreamControllerAttachPayload = { - streamId: number | null; - replayedFrom?: number; - currentOffset: number; - reset: boolean; - error?: string | null; -}; - -export type TerminalStreamControllerChunk = { - offset: number; - endOffset: number; - replay?: boolean; - data: Uint8Array; -}; - export type TerminalStreamControllerClient = { - attachTerminalStream: ( + subscribeTerminal: (terminalId: string) => Promise<{ + terminalId: string; + state: TerminalState | null; + error?: string | null; + }>; + unsubscribeTerminal: (terminalId: string) => void; + sendTerminalInput: ( terminalId: string, - options?: { - resumeOffset?: number; - rows?: number; - cols?: number; - }, - ) => Promise; - detachTerminalStream: (streamId: number) => Promise; - onTerminalStreamData: ( - streamId: number, - handler: (chunk: TerminalStreamControllerChunk) => void, + message: { type: "resize"; rows: number; cols: number }, + ) => void; + onTerminalStreamEvent: ( + handler: ( + event: + | { terminalId: string; type: "output"; data: Uint8Array } + | { terminalId: string; type: "snapshot"; state: TerminalState }, + ) => void, ) => () => void; }; @@ -46,22 +33,15 @@ export type TerminalStreamControllerSize = { export type TerminalStreamControllerStatus = { terminalId: string | null; - streamId: number | null; isAttaching: boolean; error: string | null; }; -export type TerminalStreamControllerResumeOffsets = TerminalResumeOffsetStore & { - clear: (input: { terminalId: string }) => void; - prune: (input: { terminalIds: string[] }) => void; -}; - export type TerminalStreamControllerOptions = { client: TerminalStreamControllerClient; getPreferredSize: () => TerminalStreamControllerSize | null; - resumeOffsets?: TerminalStreamControllerResumeOffsets; - onChunk: (input: { terminalId: string; text: string; replay: boolean }) => void; - onReset?: (input: { terminalId: string }) => void; + onOutput: (input: { terminalId: string; text: string }) => void; + onSnapshot: (input: { terminalId: string; state: TerminalState }) => void; onStatusChange?: (status: TerminalStreamControllerStatus) => void; maxAttachAttempts?: number; attachTimeoutMs?: number; @@ -76,37 +56,40 @@ export type TerminalStreamControllerOptions = { getRetryDelayMs?: (input: { attempt: number }) => number; }; -type TerminalStreamControllerActiveStream = { - terminalId: string; - streamId: number; - decoder: TextDecoder; - nextExpectedOffset: number | null; - catchUpEndOffset: number | null; - unsubscribe: () => void; -}; - const DEFAULT_ATTACH_MAX_ATTEMPTS = 4; const DEFAULT_ATTACH_TIMEOUT_MS = 12_000; const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…"; export class TerminalStreamController { - private readonly exitedStreamsWhileAttaching = new Set(); - private readonly internalResumeOffsetByTerminalId = new Map(); + private readonly unsubscribeStreamEvents: () => void; + private readonly decoder = new TextDecoder(); private selectedTerminalId: string | null = null; - private activeStream: TerminalStreamControllerActiveStream | null = null; private attachGeneration = 0; private isDisposed = false; - private status: TerminalStreamControllerStatus = { - terminalId: null, - streamId: null, - isAttaching: false, - error: null, - }; - constructor(private readonly options: TerminalStreamControllerOptions) {} + constructor(private readonly options: TerminalStreamControllerOptions) { + this.unsubscribeStreamEvents = this.options.client.onTerminalStreamEvent((event) => { + if (this.isDisposed || event.terminalId !== this.selectedTerminalId) { + return; + } + if (event.type === "snapshot") { + this.decoder.decode(); + this.options.onSnapshot({ + terminalId: event.terminalId, + state: event.state, + }); + return; + } - getActiveStreamId(): number | null { - return this.activeStream?.streamId ?? null; + const text = this.decoder.decode(event.data, { stream: true }); + if (text.length === 0) { + return; + } + this.options.onOutput({ + terminalId: event.terminalId, + text, + }); + }); } setTerminal(input: { terminalId: string | null }): void { @@ -115,26 +98,23 @@ export class TerminalStreamController { } const nextTerminalId = input.terminalId; - const previousTerminalId = this.selectedTerminalId; - const isSameTerminal = previousTerminalId === nextTerminalId; - const hasActiveStreamForSelection = - isSameTerminal && - this.activeStream?.terminalId === nextTerminalId && - typeof this.activeStream.streamId === "number"; - if (hasActiveStreamForSelection) { + if (this.selectedTerminalId === nextTerminalId) { return; } + const previousTerminalId = this.selectedTerminalId; this.selectedTerminalId = nextTerminalId; this.attachGeneration += 1; const generation = this.attachGeneration; - void this.detachActiveStream({ shouldDetach: true }); + this.decoder.decode(); + if (previousTerminalId) { + this.options.client.unsubscribeTerminal(previousTerminalId); + } if (!nextTerminalId) { this.updateStatus({ terminalId: null, - streamId: null, isAttaching: false, error: null, }); @@ -143,7 +123,6 @@ export class TerminalStreamController { this.updateStatus({ terminalId: nextTerminalId, - streamId: null, isAttaching: true, error: null, }); @@ -153,53 +132,23 @@ export class TerminalStreamController { }); } - handleStreamExit(input: { terminalId: string; streamId: number }): void { - if (this.isDisposed) { + handleStreamExit(input: { terminalId: string }): void { + if (this.isDisposed || this.selectedTerminalId !== input.terminalId) { return; } - if (this.selectedTerminalId !== input.terminalId) { - return; - } - - const activeStream = this.activeStream; - const activeMatches = - Boolean(activeStream) && - activeStream!.terminalId === input.terminalId && - activeStream!.streamId === input.streamId; - if (activeMatches) { - this.attachGeneration += 1; - const generation = this.attachGeneration; - void this.detachActiveStream({ shouldDetach: false }); - this.updateStatus({ - terminalId: input.terminalId, - streamId: null, - isAttaching: true, - error: this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE, - }); - void this.attachTerminal({ - terminalId: input.terminalId, - generation, - }); - return; - } - - if (this.status.isAttaching && this.status.terminalId === input.terminalId) { - this.exitedStreamsWhileAttaching.add(input.streamId); - } - } - - pruneResumeOffsets(input: { terminalIds: string[] }): void { - if (this.options.resumeOffsets) { - this.options.resumeOffsets.prune(input); - return; - } - const terminalIdSet = new Set(input.terminalIds); - for (const terminalId of Array.from(this.internalResumeOffsetByTerminalId.keys())) { - if (!terminalIdSet.has(terminalId)) { - this.internalResumeOffsetByTerminalId.delete(terminalId); - } - } + this.attachGeneration += 1; + const generation = this.attachGeneration; + this.decoder.decode(); + this.updateStatus({ + terminalId: input.terminalId, + isAttaching: true, + error: this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE, + }); + void this.attachTerminal({ + terminalId: input.terminalId, + generation, + }); } dispose(): void { @@ -208,12 +157,15 @@ export class TerminalStreamController { } this.isDisposed = true; this.attachGeneration += 1; + this.decoder.decode(); + const selectedTerminalId = this.selectedTerminalId; this.selectedTerminalId = null; - void this.detachActiveStream({ shouldDetach: true }); - this.internalResumeOffsetByTerminalId.clear(); + if (selectedTerminalId) { + this.options.client.unsubscribeTerminal(selectedTerminalId); + } + this.unsubscribeStreamEvents(); this.updateStatus({ terminalId: null, - streamId: null, isAttaching: false, error: null, }); @@ -229,53 +181,27 @@ export class TerminalStreamController { getRetryDelayMs = getTerminalAttachRetryDelayMs, } = this.options; - let lastErrorMessage = "Unable to attach terminal stream"; + let lastErrorMessage = "Unable to subscribe to terminal"; for (let attempt = 0; attempt < maxAttachAttempts; attempt += 1) { - if ( - !this.isAttachGenerationCurrent({ - generation: input.generation, - terminalId: input.terminalId, - }) - ) { + if (!this.isAttachGenerationCurrent(input)) { return; } try { - const preferredSize = this.options.getPreferredSize(); - const resumeOffset = getTerminalResumeOffset({ - terminalId: input.terminalId, - resumeOffsetStore: this.getResumeOffsetStore(), - }); - const attachPayload = await withTimeout({ - promise: this.options.client.attachTerminalStream(input.terminalId, { - ...(resumeOffset !== undefined ? { resumeOffset } : {}), - ...(preferredSize ? { rows: preferredSize.rows, cols: preferredSize.cols } : {}), - }), + const payload = await withTimeout({ + promise: this.options.client.subscribeTerminal(input.terminalId), timeoutMs: attachTimeoutMs, - timeoutMessage: "Timed out attaching terminal stream", + timeoutMessage: "Timed out subscribing to terminal", }); - updateTerminalResumeOffset({ - terminalId: input.terminalId, - offset: attachPayload.currentOffset, - resumeOffsetStore: this.getResumeOffsetStore(), - }); - - if ( - !this.isAttachGenerationCurrent({ - generation: input.generation, - terminalId: input.terminalId, - }) - ) { - if (typeof attachPayload.streamId === "number") { - void this.options.client.detachTerminalStream(attachPayload.streamId).catch(() => {}); - } + if (!this.isAttachGenerationCurrent(input)) { + this.options.client.unsubscribeTerminal(input.terminalId); return; } - if (attachPayload.error || typeof attachPayload.streamId !== "number") { - lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream"; + if (payload.error) { + lastErrorMessage = payload.error; const hasRemainingAttempts = attempt < maxAttachAttempts - 1; if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) { await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) }); @@ -284,82 +210,30 @@ export class TerminalStreamController { this.updateStatus({ terminalId: input.terminalId, - streamId: null, isAttaching: false, error: lastErrorMessage, }); return; } - if (this.exitedStreamsWhileAttaching.has(attachPayload.streamId)) { - this.exitedStreamsWhileAttaching.delete(attachPayload.streamId); - void this.options.client.detachTerminalStream(attachPayload.streamId).catch(() => {}); - this.attachGeneration += 1; - void this.attachTerminal({ - terminalId: input.terminalId, - generation: this.attachGeneration, + const preferredSize = this.options.getPreferredSize(); + if (preferredSize) { + this.options.client.sendTerminalInput(input.terminalId, { + type: "resize", + rows: preferredSize.rows, + cols: preferredSize.cols, }); - return; } - if (attachPayload.reset) { - this.options.resumeOffsets?.clear({ terminalId: input.terminalId }); - this.options.onReset?.({ terminalId: input.terminalId }); - } - - const decoder = new TextDecoder(); - const streamId = attachPayload.streamId; - const replayedFromOffset = - typeof attachPayload.replayedFrom === "number" - ? Math.max(0, Math.floor(attachPayload.replayedFrom)) - : null; - const currentOffset = Math.max(0, Math.floor(attachPayload.currentOffset)); - const shouldResetForReplayBootstrap = - typeof resumeOffset !== "number" && - typeof replayedFromOffset === "number" && - replayedFromOffset < currentOffset; - if (shouldResetForReplayBootstrap) { - this.options.onReset?.({ terminalId: input.terminalId }); - } - const startExpectedOffset = - replayedFromOffset ?? - (typeof resumeOffset === "number" - ? Math.max(0, Math.floor(resumeOffset)) - : currentOffset); - const catchUpEndOffset = currentOffset; - - const activeStream: TerminalStreamControllerActiveStream = { - terminalId: input.terminalId, - streamId, - decoder, - nextExpectedOffset: startExpectedOffset, - catchUpEndOffset, - unsubscribe: () => {}, - }; - this.activeStream = activeStream; - const unsubscribe = this.options.client.onTerminalStreamData(streamId, (chunk) => { - this.handleChunk({ - terminalId: input.terminalId, - streamId, - chunk, - decoder, - }); - }); - if (this.activeStream === activeStream) { - activeStream.unsubscribe = unsubscribe; - } else { - unsubscribe(); - } this.updateStatus({ terminalId: input.terminalId, - streamId, isAttaching: false, error: null, }); return; } catch (error) { lastErrorMessage = - error instanceof Error ? error.message : "Unable to attach terminal stream"; + error instanceof Error ? error.message : "Unable to subscribe to terminal"; const hasRemainingAttempts = attempt < maxAttachAttempts - 1; if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) { await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) }); @@ -368,7 +242,6 @@ export class TerminalStreamController { this.updateStatus({ terminalId: input.terminalId, - streamId: null, isAttaching: false, error: lastErrorMessage, }); @@ -378,187 +251,19 @@ export class TerminalStreamController { this.updateStatus({ terminalId: input.terminalId, - streamId: null, isAttaching: false, error: lastErrorMessage, }); } - private handleChunk(input: { - terminalId: string; - streamId: number; - chunk: TerminalStreamControllerChunk; - decoder: TextDecoder; - }): void { - const activeStream = this.activeStream; - if (!activeStream) { - return; - } - if (activeStream.streamId !== input.streamId || activeStream.terminalId !== input.terminalId) { - return; - } - - const chunkOffset = Number.isFinite(input.chunk.offset) - ? Math.max(0, Math.floor(input.chunk.offset)) - : 0; - const chunkEndOffset = Number.isFinite(input.chunk.endOffset) - ? Math.max(0, Math.floor(input.chunk.endOffset)) - : chunkOffset; - if (chunkEndOffset < chunkOffset) { - return; - } - - const expectedOffset = activeStream.nextExpectedOffset; - if (typeof expectedOffset === "number") { - if (chunkEndOffset <= expectedOffset) { - return; - } - if (chunkOffset !== expectedOffset) { - const catchUpEndOffset = activeStream.catchUpEndOffset; - const canSkipReplayGap = - chunkOffset > expectedOffset && - typeof catchUpEndOffset === "number" && - expectedOffset < catchUpEndOffset && - chunkOffset <= catchUpEndOffset; - - if (canSkipReplayGap) { - activeStream.nextExpectedOffset = chunkOffset; - } else { - this.recoverFromStreamGap({ - terminalId: input.terminalId, - streamId: input.streamId, - expectedOffset, - observedOffset: chunkOffset, - }); - return; - } - } - } - - if ( - typeof activeStream.catchUpEndOffset === "number" && - chunkEndOffset >= activeStream.catchUpEndOffset - ) { - activeStream.catchUpEndOffset = null; - } - - activeStream.nextExpectedOffset = chunkEndOffset; - updateTerminalResumeOffset({ - terminalId: input.terminalId, - offset: chunkEndOffset, - resumeOffsetStore: this.getResumeOffsetStore(), - }); - - const text = input.decoder.decode(input.chunk.data, { stream: true }); - if (text.length === 0) { - return; - } - this.options.onChunk({ - terminalId: input.terminalId, - text, - replay: Boolean(input.chunk.replay), - }); - } - - private recoverFromStreamGap(input: { - terminalId: string; - streamId: number; - expectedOffset: number; - observedOffset: number; - }): void { - const activeStream = this.activeStream; - if (!activeStream) { - return; - } - if (activeStream.streamId !== input.streamId || activeStream.terminalId !== input.terminalId) { - return; - } - if (this.selectedTerminalId !== input.terminalId) { - return; - } - - updateTerminalResumeOffset({ - terminalId: input.terminalId, - offset: input.expectedOffset, - resumeOffsetStore: this.getResumeOffsetStore(), - }); - - this.attachGeneration += 1; - const generation = this.attachGeneration; - - void this.detachActiveStream({ shouldDetach: true }); - this.updateStatus({ - terminalId: input.terminalId, - streamId: null, - isAttaching: true, - error: this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE, - }); - void this.attachTerminal({ - terminalId: input.terminalId, - generation, - }); - } - - private async detachActiveStream(input: { shouldDetach: boolean }): Promise { - const activeStream = this.activeStream; - if (!activeStream) { - return; - } - this.activeStream = null; - - try { - const tail = activeStream.decoder.decode(); - if (tail.length > 0) { - this.options.onChunk({ - terminalId: activeStream.terminalId, - text: tail, - replay: false, - }); - } - } catch { - // no-op - } - - try { - activeStream.unsubscribe(); - } catch { - // no-op - } - - if (!input.shouldDetach) { - return; - } - - try { - await this.options.client.detachTerminalStream(activeStream.streamId); - } catch { - // no-op - } - } - - private isAttachGenerationCurrent(input: { generation: number; terminalId: string }): boolean { + private isAttachGenerationCurrent(input: { terminalId: string; generation: number }): boolean { if (this.isDisposed) { return false; } - return ( - this.attachGeneration === input.generation && this.selectedTerminalId === input.terminalId - ); + return this.attachGeneration === input.generation && this.selectedTerminalId === input.terminalId; } private updateStatus(status: TerminalStreamControllerStatus): void { - this.status = status; this.options.onStatusChange?.(status); } - - private getResumeOffsetStore(): TerminalResumeOffsetStore { - if (this.options.resumeOffsets) { - return this.options.resumeOffsets; - } - return { - get: ({ terminalId }) => this.internalResumeOffsetByTerminalId.get(terminalId), - set: ({ terminalId, offset }) => { - this.internalResumeOffsetByTerminalId.set(terminalId, offset); - }, - }; - } } diff --git a/packages/app/src/terminal/runtime/workspace-terminal-session.test.ts b/packages/app/src/terminal/runtime/workspace-terminal-session.test.ts index 36cae2b62..97063539d 100644 --- a/packages/app/src/terminal/runtime/workspace-terminal-session.test.ts +++ b/packages/app/src/terminal/runtime/workspace-terminal-session.test.ts @@ -10,43 +10,56 @@ describe("workspace-terminal-session", () => { it("returns the same workspace session instance for the same scope", () => { const first = getWorkspaceTerminalSession({ scopeKey: "workspace-a", - maxOutputChars: 1_000, }); const second = getWorkspaceTerminalSession({ scopeKey: "workspace-a", - maxOutputChars: 50, }); expect(second).toBe(first); }); - it("preserves resume offsets across repeated lookups", () => { + it("preserves snapshots across repeated lookups", () => { const first = getWorkspaceTerminalSession({ - scopeKey: "workspace-resume", - maxOutputChars: 1_000, + scopeKey: "workspace-snapshots", }); - first.resumeOffsets.set({ + first.snapshots.set({ terminalId: "term-1", - offset: 42, + state: { + rows: 1, + cols: 1, + grid: [[{ char: "A" }]], + scrollback: [], + cursor: { row: 0, col: 0 }, + }, }); const second = getWorkspaceTerminalSession({ - scopeKey: "workspace-resume", - maxOutputChars: 1_000, + scopeKey: "workspace-snapshots", }); - expect(second.resumeOffsets.get({ terminalId: "term-1" })).toBe(42); + expect(second.snapshots.get({ terminalId: "term-1" })).toEqual({ + rows: 1, + cols: 1, + grid: [[{ char: "A" }]], + scrollback: [], + cursor: { row: 0, col: 0 }, + }); }); - it("evicts workspace terminal session state when the workspace retain count returns to zero", () => { + it("evicts workspace terminal session state when the retain count returns to zero", () => { const scopeKey = "workspace-release"; const first = getWorkspaceTerminalSession({ scopeKey, - maxOutputChars: 1_000, }); - first.resumeOffsets.set({ + first.snapshots.set({ terminalId: "term-1", - offset: 128, + state: { + rows: 1, + cols: 1, + grid: [[{ char: "A" }]], + scrollback: [], + cursor: { row: 0, col: 0 }, + }, }); retainWorkspaceTerminalSession({ scopeKey }); @@ -54,10 +67,9 @@ describe("workspace-terminal-session", () => { const second = getWorkspaceTerminalSession({ scopeKey, - maxOutputChars: 1_000, }); expect(second).not.toBe(first); - expect(second.resumeOffsets.get({ terminalId: "term-1" })).toBeUndefined(); + expect(second.snapshots.get({ terminalId: "term-1" })).toBeNull(); }); }); diff --git a/packages/app/src/terminal/runtime/workspace-terminal-session.ts b/packages/app/src/terminal/runtime/workspace-terminal-session.ts index ba2d53b38..d45c16468 100644 --- a/packages/app/src/terminal/runtime/workspace-terminal-session.ts +++ b/packages/app/src/terminal/runtime/workspace-terminal-session.ts @@ -1,85 +1,63 @@ -import { - getTerminalOutputSession, - releaseTerminalOutputSession, - retainTerminalOutputSession, - type TerminalOutputSession, -} from "./terminal-output-session"; +import type { TerminalState } from "@server/shared/messages"; -export type WorkspaceTerminalResumeOffsets = { - get: (input: { terminalId: string }) => number | undefined; - set: (input: { terminalId: string; offset: number }) => void; +export type WorkspaceTerminalSnapshots = { + get: (input: { terminalId: string }) => TerminalState | null; + set: (input: { terminalId: string; state: TerminalState }) => void; clear: (input: { terminalId: string }) => void; prune: (input: { terminalIds: string[] }) => void; }; export type WorkspaceTerminalSession = { scopeKey: string; - outputSession: TerminalOutputSession; - resumeOffsets: WorkspaceTerminalResumeOffsets; + snapshots: WorkspaceTerminalSnapshots; }; type WorkspaceTerminalSessionRecord = { - resumeOffsetByTerminalId: Map; + snapshotByTerminalId: Map; session: WorkspaceTerminalSession; }; const sessionsByScopeKey = new Map(); const refCountByScopeKey = new Map(); -function createResumeOffsets(input: { - resumeOffsetByTerminalId: Map; -}): WorkspaceTerminalResumeOffsets { +function createSnapshots(input: { + snapshotByTerminalId: Map; +}): WorkspaceTerminalSnapshots { return { - get: ({ terminalId }) => { - const offset = input.resumeOffsetByTerminalId.get(terminalId); - if (typeof offset !== "number" || !Number.isFinite(offset)) { - return undefined; - } - return Math.max(0, Math.floor(offset)); - }, - set: ({ terminalId, offset }) => { - if (!Number.isFinite(offset)) { - return; - } - input.resumeOffsetByTerminalId.set(terminalId, Math.max(0, Math.floor(offset))); + get: ({ terminalId }) => input.snapshotByTerminalId.get(terminalId) ?? null, + set: ({ terminalId, state }) => { + input.snapshotByTerminalId.set(terminalId, state); }, clear: ({ terminalId }) => { - input.resumeOffsetByTerminalId.delete(terminalId); + input.snapshotByTerminalId.delete(terminalId); }, prune: ({ terminalIds }) => { const terminalIdSet = new Set(terminalIds); - for (const terminalId of Array.from(input.resumeOffsetByTerminalId.keys())) { + for (const terminalId of Array.from(input.snapshotByTerminalId.keys())) { if (!terminalIdSet.has(terminalId)) { - input.resumeOffsetByTerminalId.delete(terminalId); + input.snapshotByTerminalId.delete(terminalId); } } }, }; } -export function getWorkspaceTerminalSession(input: { - scopeKey: string; - maxOutputChars: number; -}): WorkspaceTerminalSession { +export function getWorkspaceTerminalSession(input: { scopeKey: string }): WorkspaceTerminalSession { const existing = sessionsByScopeKey.get(input.scopeKey); if (existing) { return existing.session; } - const resumeOffsetByTerminalId = new Map(); + const snapshotByTerminalId = new Map(); const session: WorkspaceTerminalSession = { scopeKey: input.scopeKey, - outputSession: getTerminalOutputSession({ - scopeKey: input.scopeKey, - maxOutputChars: input.maxOutputChars, - }), - resumeOffsets: createResumeOffsets({ - resumeOffsetByTerminalId, + snapshots: createSnapshots({ + snapshotByTerminalId, }), }; sessionsByScopeKey.set(input.scopeKey, { - resumeOffsetByTerminalId, + snapshotByTerminalId, session, }); return session; @@ -88,11 +66,9 @@ export function getWorkspaceTerminalSession(input: { export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void { const current = refCountByScopeKey.get(input.scopeKey) ?? 0; refCountByScopeKey.set(input.scopeKey, current + 1); - retainTerminalOutputSession(input); } export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void { - releaseTerminalOutputSession(input); const current = refCountByScopeKey.get(input.scopeKey) ?? 0; if (current > 1) { refCountByScopeKey.set(input.scopeKey, current - 1); diff --git a/packages/app/src/utils/terminal-attach.test.ts b/packages/app/src/utils/terminal-attach.test.ts index e9ec22d92..ecdbff4c7 100644 --- a/packages/app/src/utils/terminal-attach.test.ts +++ b/packages/app/src/utils/terminal-attach.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from "vitest"; import { - getTerminalResumeOffset, getTerminalAttachRetryDelayMs, isTerminalAttachRetryableError, - updateTerminalResumeOffset, withPromiseTimeout, } from "./terminal-attach"; @@ -24,53 +22,12 @@ describe("terminal-attach", () => { expect(isTerminalAttachRetryableError({ message: "Network disconnected during attach" })).toBe( true, ); - expect(isTerminalAttachRetryableError({ message: "stream ended before ack" })).toBe(true); + expect(isTerminalAttachRetryableError({ message: "stream ended before snapshot" })).toBe( + true, + ); expect(isTerminalAttachRetryableError({ message: "permission denied" })).toBe(false); }); - it("reads and updates resume offsets monotonically", () => { - const offsets = new Map(); - const terminalId = "term-1"; - const resumeOffsetStore = { - get: ({ terminalId }: { terminalId: string }) => offsets.get(terminalId), - set: ({ terminalId, offset }: { terminalId: string; offset: number }) => { - offsets.set(terminalId, offset); - }, - }; - - expect( - getTerminalResumeOffset({ - terminalId, - resumeOffsetStore, - }), - ).toBeUndefined(); - - updateTerminalResumeOffset({ - terminalId, - offset: 8, - resumeOffsetStore, - }); - expect( - getTerminalResumeOffset({ - terminalId, - resumeOffsetStore, - }), - ).toBe(8); - - // Stale offsets must not move resume backwards. - updateTerminalResumeOffset({ - terminalId, - offset: 3, - resumeOffsetStore, - }); - expect( - getTerminalResumeOffset({ - terminalId, - resumeOffsetStore, - }), - ).toBe(8); - }); - it("resolves before timeout when promise completes", async () => { await expect( withPromiseTimeout({ diff --git a/packages/app/src/utils/terminal-attach.ts b/packages/app/src/utils/terminal-attach.ts index fb3a75657..698aa0a30 100644 --- a/packages/app/src/utils/terminal-attach.ts +++ b/packages/app/src/utils/terminal-attach.ts @@ -8,49 +8,6 @@ const TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS = [ "stream ended", ] as const; -export type TerminalResumeOffsetStore = { - get: (input: { terminalId: string }) => number | undefined; - set: (input: { terminalId: string; offset: number }) => void; -}; - -export function getTerminalResumeOffset(input: { - terminalId: string; - resumeOffsetStore: TerminalResumeOffsetStore; -}): number | undefined { - const offset = input.resumeOffsetStore.get({ terminalId: input.terminalId }); - if (typeof offset !== "number" || !Number.isFinite(offset)) { - return undefined; - } - - const normalizedOffset = Math.max(0, Math.floor(offset)); - return normalizedOffset; -} - -export function updateTerminalResumeOffset(input: { - terminalId: string; - offset: number; - resumeOffsetStore: TerminalResumeOffsetStore; -}): void { - if (!Number.isFinite(input.offset)) { - return; - } - - const normalizedOffset = Math.max(0, Math.floor(input.offset)); - const previousOffset = - getTerminalResumeOffset({ - terminalId: input.terminalId, - resumeOffsetStore: input.resumeOffsetStore, - }) ?? -1; - if (normalizedOffset <= previousOffset) { - return; - } - - input.resumeOffsetStore.set({ - terminalId: input.terminalId, - offset: normalizedOffset, - }); -} - export function getTerminalAttachRetryDelayMs(input: { attempt: number }): number { const clampedAttempt = Math.max(0, input.attempt); const exponentialDelay = 250 * 2 ** clampedAttempt; diff --git a/packages/server/src/client/daemon-client-terminal-stream-manager.test.ts b/packages/server/src/client/daemon-client-terminal-stream-manager.test.ts deleted file mode 100644 index e6b170e63..000000000 --- a/packages/server/src/client/daemon-client-terminal-stream-manager.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { - TerminalStreamManager, - type TerminalStreamChunk, -} from "./daemon-client-terminal-stream-manager.js"; - -function createChunk(input: { - streamId: number; - offset: number; - data: string; -}): TerminalStreamChunk { - const bytes = new TextEncoder().encode(input.data); - return { - streamId: input.streamId, - offset: input.offset, - endOffset: input.offset + bytes.byteLength, - replay: false, - data: bytes, - }; -} - -describe("TerminalStreamManager", () => { - test("buffers chunks and flushes with ack when handler subscribes", () => { - const sendAck = vi.fn(); - const manager = new TerminalStreamManager({ sendAck }); - const seen: string[] = []; - - manager.receiveChunk({ - chunk: createChunk({ streamId: 7, offset: 4, data: "hello" }), - }); - expect(sendAck).toHaveBeenCalledTimes(1); - expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 }); - - manager.subscribe({ - streamId: 7, - handler: (chunk) => { - seen.push(new TextDecoder().decode(chunk.data)); - }, - }); - - expect(seen).toEqual(["hello"]); - expect(sendAck).toHaveBeenCalledTimes(1); - }); - - test("acks buffered chunks even while no subscriber is attached", () => { - const sendAck = vi.fn(); - const manager = new TerminalStreamManager({ sendAck }); - - manager.receiveChunk({ - chunk: createChunk({ streamId: 2, offset: 0, data: "abc" }), - }); - - expect(sendAck).toHaveBeenCalledTimes(1); - expect(sendAck).toHaveBeenCalledWith({ streamId: 2, offset: 3 }); - }); - - test("does not ack when every handler throws", () => { - const sendAck = vi.fn(); - const manager = new TerminalStreamManager({ sendAck }); - - manager.subscribe({ - streamId: 11, - handler: () => { - throw new Error("boom"); - }, - }); - - manager.receiveChunk({ - chunk: createChunk({ streamId: 11, offset: 0, data: "x" }), - }); - - expect(sendAck).not.toHaveBeenCalled(); - }); - - test("evicts oldest buffered chunks when max buffered chunk count is exceeded", () => { - const sendAck = vi.fn(); - const manager = new TerminalStreamManager({ - sendAck, - maxBufferedChunks: 2, - maxBufferedBytes: 1024, - }); - - manager.receiveChunk({ - chunk: createChunk({ streamId: 5, offset: 0, data: "A" }), - }); - manager.receiveChunk({ - chunk: createChunk({ streamId: 5, offset: 1, data: "B" }), - }); - manager.receiveChunk({ - chunk: createChunk({ streamId: 5, offset: 2, data: "C" }), - }); - expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 1 }); - expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 2 }); - expect(sendAck).toHaveBeenNthCalledWith(3, { streamId: 5, offset: 3 }); - - const seen: string[] = []; - manager.subscribe({ - streamId: 5, - handler: (chunk) => { - seen.push(new TextDecoder().decode(chunk.data)); - }, - }); - - expect(seen).toEqual(["B", "C"]); - expect(sendAck).toHaveBeenCalledTimes(3); - }); - - test("tracks explicit ack offsets and skips stale auto-acks", () => { - const sendAck = vi.fn(); - const manager = new TerminalStreamManager({ sendAck }); - - manager.subscribe({ - streamId: 13, - handler: () => { - // no-op - }, - }); - manager.noteAck({ streamId: 13, offset: 10 }); - - manager.receiveChunk({ - chunk: createChunk({ streamId: 13, offset: 0, data: "abc" }), - }); - expect(sendAck).not.toHaveBeenCalled(); - - manager.receiveChunk({ - chunk: createChunk({ streamId: 13, offset: 11, data: "z" }), - }); - expect(sendAck).toHaveBeenCalledTimes(1); - expect(sendAck).toHaveBeenCalledWith({ streamId: 13, offset: 12 }); - }); -}); diff --git a/packages/server/src/client/daemon-client-terminal-stream-manager.ts b/packages/server/src/client/daemon-client-terminal-stream-manager.ts deleted file mode 100644 index a9474b578..000000000 --- a/packages/server/src/client/daemon-client-terminal-stream-manager.ts +++ /dev/null @@ -1,173 +0,0 @@ -export type TerminalStreamChunk = { - streamId: number; - offset: number; - endOffset: number; - replay: boolean; - data: Uint8Array; -}; - -type TerminalChunkHandler = (chunk: TerminalStreamChunk) => void; - -type BufferedTerminalStreamQueue = { - chunks: TerminalStreamChunk[]; - bytes: number; -}; - -type TerminalStreamAck = { - streamId: number; - offset: number; -}; - -export type TerminalStreamManagerConfig = { - sendAck: (ack: TerminalStreamAck) => void; - maxBufferedChunks?: number; - maxBufferedBytes?: number; -}; - -const DEFAULT_MAX_BUFFERED_CHUNKS = 2048; -const DEFAULT_MAX_BUFFERED_BYTES = 2 * 1024 * 1024; - -export class TerminalStreamManager { - private readonly handlers: Map> = new Map(); - private readonly bufferedChunks: Map = new Map(); - private readonly ackOffsets: Map = new Map(); - private readonly maxBufferedChunks: number; - private readonly maxBufferedBytes: number; - - constructor(private readonly config: TerminalStreamManagerConfig) { - this.maxBufferedChunks = config.maxBufferedChunks ?? DEFAULT_MAX_BUFFERED_CHUNKS; - this.maxBufferedBytes = config.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES; - } - - clearAll(): void { - this.handlers.clear(); - this.bufferedChunks.clear(); - this.ackOffsets.clear(); - } - - clearStream(input: { streamId: number }): void { - this.handlers.delete(input.streamId); - this.bufferedChunks.delete(input.streamId); - this.ackOffsets.delete(input.streamId); - } - - subscribe(input: { streamId: number; handler: TerminalChunkHandler }): () => void { - const { streamId, handler } = input; - if (!this.handlers.has(streamId)) { - this.handlers.set(streamId, new Set()); - } - - const streamHandlers = this.handlers.get(streamId)!; - streamHandlers.add(handler); - this.flushBufferedChunks({ streamId, handler }); - - return () => { - streamHandlers.delete(handler); - if (streamHandlers.size === 0) { - this.handlers.delete(streamId); - } - }; - } - - receiveChunk(input: { chunk: TerminalStreamChunk }): void { - const { chunk } = input; - const streamHandlers = this.handlers.get(chunk.streamId); - if (!streamHandlers || streamHandlers.size === 0) { - this.bufferChunk({ chunk }); - this.maybeAckChunk({ - streamId: chunk.streamId, - endOffset: chunk.endOffset, - }); - return; - } - - let delivered = false; - for (const handler of streamHandlers) { - try { - handler(chunk); - delivered = true; - } catch { - // no-op - } - } - if (delivered) { - this.maybeAckChunk({ - streamId: chunk.streamId, - endOffset: chunk.endOffset, - }); - } - } - - noteAck(input: TerminalStreamAck): void { - const normalizedOffset = Math.max(0, Math.floor(input.offset)); - const previousAck = this.ackOffsets.get(input.streamId) ?? -1; - if (normalizedOffset > previousAck) { - this.ackOffsets.set(input.streamId, normalizedOffset); - } - } - - private flushBufferedChunks(input: { streamId: number; handler: TerminalChunkHandler }): void { - const buffered = this.bufferedChunks.get(input.streamId); - if (!buffered || buffered.chunks.length === 0) { - return; - } - - for (const chunk of buffered.chunks) { - try { - input.handler(chunk); - this.maybeAckChunk({ - streamId: input.streamId, - endOffset: chunk.endOffset, - }); - } catch { - // no-op - } - } - this.bufferedChunks.delete(input.streamId); - } - - private bufferChunk(input: { chunk: TerminalStreamChunk }): void { - const queue = this.bufferedChunks.get(input.chunk.streamId) ?? { - chunks: [], - bytes: 0, - }; - - queue.chunks.push(input.chunk); - queue.bytes += input.chunk.data.byteLength; - - while (queue.chunks.length > this.maxBufferedChunks || queue.bytes > this.maxBufferedBytes) { - const removed = queue.chunks.shift(); - if (!removed) { - break; - } - queue.bytes -= removed.data.byteLength; - if (queue.bytes < 0) { - queue.bytes = 0; - } - } - - this.bufferedChunks.set(input.chunk.streamId, queue); - } - - private maybeAckChunk(input: { streamId: number; endOffset: number }): void { - if (!Number.isFinite(input.endOffset) || input.endOffset < 0) { - return; - } - - const normalizedEndOffset = Math.floor(input.endOffset); - const previousAck = this.ackOffsets.get(input.streamId) ?? -1; - if (normalizedEndOffset <= previousAck) { - return; - } - - this.ackOffsets.set(input.streamId, normalizedEndOffset); - try { - this.config.sendAck({ - streamId: input.streamId, - offset: normalizedEndOffset, - }); - } catch { - // no-op - } - } -} diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 3394ff2ec..14399fc26 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -1,12 +1,14 @@ import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest"; import { DaemonClient, type DaemonTransport } from "./daemon-client"; import { - BinaryMuxChannel, - TerminalBinaryMessageType, asUint8Array, - decodeBinaryMuxFrame, - encodeBinaryMuxFrame, -} from "../shared/binary-mux.js"; + decodeTerminalResizePayload, + decodeTerminalSnapshotPayload, + decodeTerminalStreamFrame, + encodeTerminalSnapshotPayload, + encodeTerminalStreamFrame, + TerminalStreamOpcode, +} from "../shared/terminal-stream-protocol.js"; expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf(); expectTypeOf< @@ -1224,7 +1226,7 @@ describe("DaemonClient", () => { }); }); - test("auto-acks terminal stream chunks after delivery", async () => { + test("emits output events for the active terminal stream", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -1242,36 +1244,40 @@ describe("DaemonClient", () => { await connectPromise; const seen: string[] = []; - const decoder = new TextDecoder(); - const unsubscribe = client.onTerminalStreamData(7, (chunk) => { - seen.push(decoder.decode(chunk.data)); + const unsubscribe = client.onTerminalStreamEvent((event) => { + if (event.type !== "output") { + return; + } + seen.push(new TextDecoder().decode(event.data)); }); - const payload = new TextEncoder().encode("hello"); + const subscribePromise = client.subscribeTerminal("term-1", "sub-1"); mock.triggerMessage( - encodeBinaryMuxFrame({ - channel: BinaryMuxChannel.Terminal, - messageType: TerminalBinaryMessageType.OutputUtf8, - streamId: 7, - offset: 10, - payload, + wrapSessionMessage({ + type: "subscribe_terminal_response", + payload: { + terminalId: "term-1", + state: null, + error: null, + requestId: "sub-1", + }, + }), + ); + await subscribePromise; + + mock.triggerMessage( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + payload: new TextEncoder().encode("hello"), }), ); expect(seen).toEqual(["hello"]); expect(mock.sent).toHaveLength(1); - const ackBytes = asUint8Array(mock.sent[0]); - expect(ackBytes).not.toBeNull(); - const ackFrame = decodeBinaryMuxFrame(ackBytes!); - expect(ackFrame).not.toBeNull(); - expect(ackFrame!.channel).toBe(BinaryMuxChannel.Terminal); - expect(ackFrame!.messageType).toBe(TerminalBinaryMessageType.Ack); - expect(ackFrame!.streamId).toBe(7); - expect(ackFrame!.offset).toBe(15); unsubscribe(); }); - test("handles terminal binary frames delivered as UTF-8 strings", async () => { + test("emits snapshot events for the active terminal stream", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -1288,76 +1294,100 @@ describe("DaemonClient", () => { mock.triggerOpen(); await connectPromise; - const seen: string[] = []; - client.onTerminalStreamData(11, (chunk) => { - seen.push(new TextDecoder().decode(chunk.data)); + const snapshots: unknown[] = []; + client.onTerminalStreamEvent((event) => { + if (event.type !== "snapshot") { + return; + } + snapshots.push(event.state); }); - const payload = new TextEncoder().encode("ls\r\n"); - const frame = encodeBinaryMuxFrame({ - channel: BinaryMuxChannel.Terminal, - messageType: TerminalBinaryMessageType.OutputUtf8, - streamId: 11, - offset: 0, - payload, - }); - const frameAsString = new TextDecoder("utf-8", { fatal: true }).decode(frame); - - mock.triggerMessage(frameAsString); - - expect(seen).toEqual(["ls\r\n"]); - expect(mock.sent).toHaveLength(1); - const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!); - expect(ackFrame?.channel).toBe(BinaryMuxChannel.Terminal); - expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack); - expect(ackFrame?.streamId).toBe(11); - expect(ackFrame?.offset).toBe(4); - }); - - test("acks buffered terminal chunks when handler is attached", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); - - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); - - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; - + const subscribePromise = client.subscribeTerminal("term-1", "sub-2"); mock.triggerMessage( - encodeBinaryMuxFrame({ - channel: BinaryMuxChannel.Terminal, - messageType: TerminalBinaryMessageType.OutputUtf8, - streamId: 19, - offset: 0, - payload: new TextEncoder().encode("buffered"), + wrapSessionMessage({ + type: "subscribe_terminal_response", + payload: { + terminalId: "term-1", + state: null, + error: null, + requestId: "sub-2", + }, + }), + ); + await subscribePromise; + + const state = { + rows: 1, + cols: 5, + grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]], + scrollback: [], + cursor: { row: 0, col: 5 }, + }; + mock.triggerMessage( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Snapshot, + payload: encodeTerminalSnapshotPayload(state), }), ); - expect(mock.sent).toHaveLength(1); - const bufferedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!); - expect(bufferedAck?.messageType).toBe(TerminalBinaryMessageType.Ack); - expect(bufferedAck?.streamId).toBe(19); - expect(bufferedAck?.offset).toBe(8); - mock.sent.length = 0; - - const seen: string[] = []; - client.onTerminalStreamData(19, (chunk) => { - seen.push(new TextDecoder().decode(chunk.data)); - }); - - expect(seen.join("")).toContain("buffered"); - expect(mock.sent).toHaveLength(0); + expect(snapshots).toEqual([state]); }); - test("stops delivering and acking after terminal_stream_exit", async () => { + test("sends input and resize frames for the active terminal", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const subscribePromise = client.subscribeTerminal("term-1", "sub-3"); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_terminal_response", + payload: { + terminalId: "term-1", + state: null, + error: null, + requestId: "sub-3", + }, + }), + ); + await subscribePromise; + mock.sent.length = 0; + + client.sendTerminalInput("term-1", { + type: "input", + data: "echo hello\r", + }); + client.sendTerminalInput("term-1", { + type: "resize", + rows: 24, + cols: 80, + }); + + const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!); + const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!); + + expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input); + expect(new TextDecoder().decode(inputFrame?.payload ?? new Uint8Array())).toBe("echo hello\r"); + expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize); + expect(decodeTerminalResizePayload(resizeFrame?.payload ?? new Uint8Array())).toEqual({ + rows: 24, + cols: 80, + }); + }); + + test("ignores terminal stream frames after terminal_stream_exit", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -1375,114 +1405,55 @@ describe("DaemonClient", () => { await connectPromise; const seen: string[] = []; - const unsubscribe = client.onTerminalStreamData(23, (chunk) => { - seen.push(new TextDecoder().decode(chunk.data)); + const unsubscribe = client.onTerminalStreamEvent((event) => { + if (event.type !== "output") { + return; + } + seen.push(new TextDecoder().decode(event.data)); }); + + const subscribePromise = client.subscribeTerminal("term-1", "sub-4"); mock.triggerMessage( - encodeBinaryMuxFrame({ - channel: BinaryMuxChannel.Terminal, - messageType: TerminalBinaryMessageType.OutputUtf8, - streamId: 23, - offset: 0, + wrapSessionMessage({ + type: "subscribe_terminal_response", + payload: { + terminalId: "term-1", + state: null, + error: null, + requestId: "sub-4", + }, + }), + ); + await subscribePromise; + + mock.triggerMessage( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, payload: new TextEncoder().encode("before-exit"), }), ); expect(seen).toEqual(["before-exit"]); - expect(mock.sent).toHaveLength(1); - mock.sent.length = 0; mock.triggerMessage( wrapSessionMessage({ type: "terminal_stream_exit", payload: { - streamId: 23, terminalId: "term-1", }, }), ); mock.triggerMessage( - encodeBinaryMuxFrame({ - channel: BinaryMuxChannel.Terminal, - messageType: TerminalBinaryMessageType.OutputUtf8, - streamId: 23, - offset: 12, + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, payload: new TextEncoder().encode("after-exit"), }), ); expect(seen).toEqual(["before-exit"]); - expect(mock.sent).toHaveLength(1); - const postExitAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!); - expect(postExitAck?.messageType).toBe(TerminalBinaryMessageType.Ack); - expect(postExitAck?.streamId).toBe(23); - expect(postExitAck?.offset).toBe(22); unsubscribe(); }); - test("cleans local stream state even when detach reports success=false", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); - - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); - - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; - - const seen: string[] = []; - client.onTerminalStreamData(31, (chunk) => { - seen.push(new TextDecoder().decode(chunk.data)); - }); - - const detachPromise = client.detachTerminalStream(31, "detach-31"); - const detachRequest = JSON.parse(String(mock.sent[0])) as { - type: "session"; - message: { type: string; streamId: number; requestId: string }; - }; - expect(detachRequest.message.type).toBe("detach_terminal_stream_request"); - expect(detachRequest.message.streamId).toBe(31); - - mock.triggerMessage( - wrapSessionMessage({ - type: "detach_terminal_stream_response", - payload: { - streamId: 31, - success: false, - requestId: "detach-31", - }, - }), - ); - - const payload = await detachPromise; - expect(payload.success).toBe(false); - - mock.sent.length = 0; - mock.triggerMessage( - encodeBinaryMuxFrame({ - channel: BinaryMuxChannel.Terminal, - messageType: TerminalBinaryMessageType.OutputUtf8, - streamId: 31, - offset: 0, - payload: new TextEncoder().encode("after-detach"), - }), - ); - - expect(seen).toEqual([]); - expect(mock.sent).toHaveLength(1); - const detachedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!); - expect(detachedAck?.messageType).toBe(TerminalBinaryMessageType.Ack); - expect(detachedAck?.streamId).toBe(31); - expect(detachedAck?.offset).toBe(12); - }); - test("parses canonical agent_stream tool_call payloads without crashing", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 1f678bb01..b20c58aed 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -44,10 +44,8 @@ import type { ListTerminalsResponse, CreateTerminalResponse, SubscribeTerminalResponse, - TerminalOutput, + TerminalState, KillTerminalResponse, - AttachTerminalStreamResponse, - DetachTerminalStreamResponse, TerminalInput, SessionInboundMessage, SessionOutboundMessage, @@ -63,18 +61,14 @@ import { getAgentProviderDefinition } from "../server/agent/provider-manifest.js import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js"; import { asUint8Array, - decodeBinaryMuxFrame, - encodeBinaryMuxFrame, - BinaryMuxChannel, - TerminalBinaryFlags, - TerminalBinaryMessageType, - type BinaryMuxFrame, -} from "../shared/binary-mux.js"; + decodeTerminalSnapshotPayload, + decodeTerminalStreamFrame, + encodeTerminalResizePayload, + encodeTerminalStreamFrame, + TerminalStreamOpcode, + type TerminalStreamFrame, +} from "../shared/terminal-stream-protocol.js"; import { encodeTerminalKeyInput, type TerminalKeyInput } from "../shared/terminal-key-input.js"; -import { - TerminalStreamManager, - type TerminalStreamChunk, -} from "./daemon-client-terminal-stream-manager.js"; import { createRelayE2eeTransportFactory, createWebSocketTransportFactory, @@ -108,7 +102,10 @@ export type { WebSocketFactory, WebSocketLike, } from "./daemon-client-transport.js"; -export type { TerminalStreamChunk } from "./daemon-client-terminal-stream-manager.js"; + +export type TerminalStreamEvent = + | { terminalId: string; type: "output"; data: Uint8Array } + | { terminalId: string; type: "snapshot"; state: TerminalState }; export type ConnectionState = | { status: "idle" } @@ -244,10 +241,7 @@ type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"]; type ListTerminalsPayload = ListTerminalsResponse["payload"]; type CreateTerminalPayload = CreateTerminalResponse["payload"]; type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"]; -type TerminalOutputPayload = TerminalOutput["payload"]; type KillTerminalPayload = KillTerminalResponse["payload"]; -type AttachTerminalStreamPayload = AttachTerminalStreamResponse["payload"]; -type DetachTerminalStreamPayload = DetachTerminalStreamResponse["payload"]; export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"]; export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"]; @@ -426,9 +420,10 @@ export class DaemonClient { { cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } } >(); private terminalDirectorySubscriptions = new Set(); + private activeTerminalId: string | null = null; + private readonly terminalStreamListeners = new Set<(event: TerminalStreamEvent) => void>(); private logger: Logger; private pendingSendQueue: PendingSend[] = []; - private terminalStreams: TerminalStreamManager; private readonly logConnectionPath: "direct" | "relay"; private readonly logServerId: string | null; private readonly logClientIdHash: string; @@ -457,17 +452,6 @@ export class DaemonClient { Number.isFinite(this.config.runtimeGeneration) ? this.config.runtimeGeneration : null; - this.terminalStreams = new TerminalStreamManager({ - sendAck: (ack) => { - this.sendBinaryFrame({ - channel: BinaryMuxChannel.Terminal, - messageType: TerminalBinaryMessageType.Ack, - streamId: ack.streamId, - offset: ack.offset, - payload: new Uint8Array(0), - }); - }, - }); } // ============================================================================ @@ -676,7 +660,7 @@ export class DaemonClient { this.disposeTransport(1000, "Client closed"); this.clearWaiters(new Error("Daemon client closed")); this.rejectPendingSendQueue(new Error("Daemon client closed")); - this.terminalStreams.clearAll(); + this.activeTerminalId = null; this.lastServerInfoMessage = null; this.updateConnectionState( { status: "disposed" }, @@ -800,7 +784,7 @@ export class DaemonClient { } } - private sendBinaryFrame(frame: BinaryMuxFrame): void { + private sendBinaryFrame(frame: Uint8Array): void { if (!this.transport || this.connectionState.status !== "connected") { if (this.config.suppressSendErrors) { return; @@ -808,7 +792,7 @@ export class DaemonClient { throw new Error(`Transport not connected (status: ${this.connectionState.status})`); } try { - this.transport.send(encodeBinaryMuxFrame(frame)); + this.transport.send(frame); } catch (error) { if (this.config.suppressSendErrors) { return; @@ -2622,16 +2606,23 @@ export class DaemonClient { terminalId, requestId: resolvedRequestId, }); - return this.sendCorrelatedRequest({ + const payload = await this.sendCorrelatedRequest({ requestId: resolvedRequestId, message, responseType: "subscribe_terminal_response", timeout: 10000, options: { skipQueue: true }, }); + if (!payload.error) { + this.activeTerminalId = terminalId; + } + return payload; } unsubscribeTerminal(terminalId: string): void { + if (this.activeTerminalId === terminalId) { + this.activeTerminalId = null; + } this.sendSessionMessage({ type: "unsubscribe_terminal_request", terminalId, @@ -2639,6 +2630,29 @@ export class DaemonClient { } sendTerminalInput(terminalId: string, message: TerminalInput["message"]): void { + if (this.activeTerminalId === terminalId) { + if (message.type === "input") { + this.sendBinaryFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + payload: encodeUtf8String(message.data), + }), + ); + return; + } + if (message.type === "resize") { + this.sendBinaryFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Resize, + payload: encodeTerminalResizePayload({ + rows: message.rows, + cols: message.cols, + }), + }), + ); + return; + } + } this.sendSessionMessage({ type: "terminal_input", terminalId, @@ -2662,127 +2676,44 @@ export class DaemonClient { }); } - async attachTerminalStream( - terminalId: string, - options?: { - resumeOffset?: number; - rows?: number; - cols?: number; - }, - requestId?: string, - ): Promise { - const resolvedRequestId = this.createRequestId(requestId); - const message = SessionInboundMessageSchema.parse({ - type: "attach_terminal_stream_request", - terminalId, - requestId: resolvedRequestId, - ...(options?.resumeOffset !== undefined ? { resumeOffset: options.resumeOffset } : {}), - ...(options?.rows !== undefined ? { rows: options.rows } : {}), - ...(options?.cols !== undefined ? { cols: options.cols } : {}), - }); - return this.sendCorrelatedRequest({ - requestId: resolvedRequestId, - message, - responseType: "attach_terminal_stream_response", - timeout: 10000, - options: { skipQueue: true }, - }); + onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void { + this.terminalStreamListeners.add(handler); + return () => { + this.terminalStreamListeners.delete(handler); + }; } - async detachTerminalStream( - streamId: number, - requestId?: string, - ): Promise { - const resolvedRequestId = this.createRequestId(requestId); - const message = SessionInboundMessageSchema.parse({ - type: "detach_terminal_stream_request", - streamId, - requestId: resolvedRequestId, - }); - const payload = await this.sendCorrelatedRequest({ - requestId: resolvedRequestId, - message, - responseType: "detach_terminal_stream_response", - timeout: 10000, - options: { skipQueue: true }, - }); - this.terminalStreams.clearStream({ streamId }); - return payload; - } - - onTerminalStreamData( - streamId: number, - handler: (chunk: TerminalStreamChunk) => void, - ): () => void { - return this.terminalStreams.subscribe({ streamId, handler }); - } - - async waitForTerminalStreamData( - streamId: number, - predicate: (chunk: TerminalStreamChunk) => boolean, + async waitForTerminalStreamEvent( + predicate: (event: TerminalStreamEvent) => boolean, timeout = 5000, - ): Promise { - return new Promise((resolve, reject) => { + ): Promise { + return new Promise((resolve, reject) => { const timeoutHandle = setTimeout(() => { unsubscribe(); - reject(new Error(`Timeout waiting for terminal stream data (${timeout}ms)`)); + reject(new Error(`Timeout waiting for terminal stream event (${timeout}ms)`)); }, timeout); - const unsubscribe = this.onTerminalStreamData(streamId, (chunk) => { - if (!predicate(chunk)) { + const unsubscribe = this.onTerminalStreamEvent((event) => { + if (!predicate(event)) { return; } clearTimeout(timeoutHandle); unsubscribe(); - resolve(chunk); + resolve(event); }); }); } - sendTerminalStreamInput(streamId: number, data: string | Uint8Array): void { - const payload = typeof data === "string" ? encodeUtf8String(data) : data; - this.sendBinaryFrame({ - channel: BinaryMuxChannel.Terminal, - messageType: TerminalBinaryMessageType.InputUtf8, - streamId, - offset: 0, - payload, - }); - } - - sendTerminalStreamKey(streamId: number, input: TerminalKeyInput): void { + sendTerminalStreamKey(input: TerminalKeyInput): void { const encoded = encodeTerminalKeyInput(input); if (!encoded) { return; } - this.sendTerminalStreamInput(streamId, encoded); - } - - sendTerminalStreamAck(streamId: number, offset: number): void { - const normalizedOffset = Math.max(0, Math.floor(offset)); - this.terminalStreams.noteAck({ streamId, offset: normalizedOffset }); - this.sendBinaryFrame({ - channel: BinaryMuxChannel.Terminal, - messageType: TerminalBinaryMessageType.Ack, - streamId, - offset: normalizedOffset, - payload: new Uint8Array(0), - }); - } - - async waitForTerminalOutput(terminalId: string, timeout = 5000): Promise { - return this.waitFor( - (msg) => { - if (msg.type !== "terminal_output") { - return null; - } - if (msg.payload.terminalId !== terminalId) { - return null; - } - return msg.payload; - }, - timeout, - { skipQueue: true }, + this.sendBinaryFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + payload: encodeUtf8String(encoded), + }), ); } @@ -2890,7 +2821,7 @@ export class DaemonClient { const rawBytes = asUint8Array(rawData); if (rawBytes) { - const frame = decodeBinaryMuxFrame(rawBytes); + const frame = decodeTerminalStreamFrame(rawBytes); if (frame) { this.handleBinaryFrame(frame); return; @@ -2922,21 +2853,42 @@ export class DaemonClient { this.handleSessionMessage(parsed.data.message); } - private handleBinaryFrame(frame: BinaryMuxFrame): void { - if ( - frame.channel === BinaryMuxChannel.Terminal && - frame.messageType === TerminalBinaryMessageType.OutputUtf8 - ) { - const chunk: TerminalStreamChunk = { - streamId: frame.streamId, - offset: frame.offset, - endOffset: frame.offset + (frame.payload?.byteLength ?? 0), - replay: Boolean((frame.flags ?? 0) & TerminalBinaryFlags.Replay), - data: frame.payload ?? new Uint8Array(0), - }; - this.terminalStreams.receiveChunk({ chunk }); + private handleBinaryFrame(frame: TerminalStreamFrame): void { + const terminalId = this.activeTerminalId; + if (!terminalId) { return; } + + if (frame.opcode === TerminalStreamOpcode.Output) { + this.emitTerminalStreamEvent({ + terminalId, + type: "output", + data: frame.payload, + }); + return; + } + + if (frame.opcode === TerminalStreamOpcode.Snapshot) { + const state = decodeTerminalSnapshotPayload(frame.payload); + if (!state) { + return; + } + this.emitTerminalStreamEvent({ + terminalId, + type: "snapshot", + state, + }); + } + } + + private emitTerminalStreamEvent(event: TerminalStreamEvent): void { + for (const listener of this.terminalStreamListeners) { + try { + listener(event); + } catch { + // no-op + } + } } private updateConnectionState( @@ -2996,7 +2948,7 @@ export class DaemonClient { // and responses from the previous connection will never arrive. this.clearWaiters(new Error(reason ?? "Connection lost")); this.rejectPendingSendQueue(new Error(reason ?? "Connection lost")); - this.terminalStreams.clearAll(); + this.activeTerminalId = null; this.lastServerInfoMessage = null; if (wasDisposed) { @@ -3051,7 +3003,9 @@ export class DaemonClient { } if (msg.type === "terminal_stream_exit") { - this.terminalStreams.clearStream({ streamId: msg.payload.streamId }); + if (this.activeTerminalId === msg.payload.terminalId) { + this.activeTerminalId = null; + } } if (this.rawMessageListeners.size > 0) { @@ -3153,14 +3107,6 @@ export class DaemonClient { } } - private async waitFor( - predicate: (msg: SessionOutboundMessage) => T | null, - timeout = 30000, - _options?: { skipQueue?: boolean }, - ): Promise { - return this.waitForWithCancel(predicate, timeout, _options).promise; - } - private waitForWithCancel( predicate: (msg: SessionOutboundMessage) => T | null, timeout = 30000, diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index 15f45e3f1..7961528de 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -1,22 +1,44 @@ -import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "fs"; import { tmpdir } from "os"; import path from "path"; import WebSocket from "ws"; -import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; +import { DaemonClient } from "../../client/daemon-client.js"; import { - BinaryMuxChannel, - TerminalBinaryMessageType, - decodeBinaryMuxFrame, - encodeBinaryMuxFrame, -} from "../../shared/binary-mux.js"; - -const decoder = new TextDecoder(); + decodeTerminalStreamFrame, + TerminalStreamOpcode, + type TerminalStreamFrame, +} from "../../shared/terminal-stream-protocol.js"; +import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-terminal-e2e-")); } +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }; +} + +function extractStateText(state: { + grid: Array>; + scrollback: Array>; +}): string { + return [...state.scrollback, ...state.grid] + .map((row) => + row + .map((cell) => cell.char) + .join("") + .trimEnd(), + ) + .filter((line) => line.length > 0) + .join("\n"); +} + async function waitForCondition( predicate: () => boolean, timeoutMs: number, @@ -32,53 +54,267 @@ async function waitForCondition( throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); } -async function waitForStableNumber( - read: () => number, - input: { - stableMs: number; - timeoutMs: number; - intervalMs?: number; - }, -): Promise { - const intervalMs = input.intervalMs ?? 25; - const start = Date.now(); - let lastValue = read(); - let stableSince = Date.now(); +async function waitForTerminalSnapshot( + client: DaemonClient, + terminalId: string, + predicate: (state: { + rows: number; + cols: number; + grid: Array>; + scrollback: Array>; + cursor: { row: number; col: number }; + }) => boolean, + timeout = 10000, +): Promise<{ + rows: number; + cols: number; + grid: Array>; + scrollback: Array>; + cursor: { row: number; col: number }; +}> { + return new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timeout waiting for terminal snapshot (${timeout}ms)`)); + }, timeout); - while (Date.now() - start < input.timeoutMs) { - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - const nextValue = read(); - if (nextValue !== lastValue) { - lastValue = nextValue; - stableSince = Date.now(); - continue; - } - if (Date.now() - stableSince >= input.stableMs) { - return nextValue; - } + const unsubscribe = client.onTerminalStreamEvent((event) => { + if (event.terminalId !== terminalId || event.type !== "snapshot") { + return; + } + if (!predicate(event.state)) { + return; + } + clearTimeout(timeoutHandle); + unsubscribe(); + resolve(event.state); + }); + }); +} + +async function waitForTerminalOutput( + client: DaemonClient, + terminalId: string, + predicate: (text: string) => boolean, + timeout = 10000, +): Promise { + return new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timeout waiting for terminal output (${timeout}ms)`)); + }, timeout); + + const unsubscribe = client.onTerminalStreamEvent((event) => { + if (event.terminalId !== terminalId || event.type !== "output") { + return; + } + const text = new TextDecoder().decode(event.data); + if (!predicate(text)) { + return; + } + clearTimeout(timeoutHandle); + unsubscribe(); + resolve(text); + }); + }); +} + +async function connectClient(port: number, clientId: string): Promise { + const client = new DaemonClient({ + url: `ws://127.0.0.1:${port}/ws`, + clientId, + logger: createLogger(), + reconnect: { enabled: false }, + }); + await client.connect(); + return client; +} + +function toWsBuffer(raw: WebSocket.RawData): Buffer | null { + if (Buffer.isBuffer(raw)) { + return raw; } - - throw new Error(`Timed out after ${input.timeoutMs}ms waiting for value to stabilize`); -} - -function percentile(values: number[], p: number): number { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); - const index = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1); - return sorted[index]; -} - -function mean(values: number[]): number { - if (values.length === 0) { - return 0; + if (Array.isArray(raw)) { + return Buffer.concat(raw.map((part) => (Buffer.isBuffer(part) ? part : Buffer.from(part)))); } - const total = values.reduce((sum, value) => sum + value, 0); - return total / values.length; + if (raw instanceof ArrayBuffer) { + return Buffer.from(raw); + } + if (ArrayBuffer.isView(raw)) { + return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength); + } + return null; } -const shouldRun = !process.env.CI; +async function connectRawWebSocket(port: number): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); + await new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); -(shouldRun ? describe : describe.skip)("daemon E2E terminal", () => { + const helloReady = waitForRawSessionMessage( + ws, + (message) => + message.type === "session" && + message.message?.type === "status" && + message.message.payload?.status === "server_info", + 10000, + ); + + ws.send( + JSON.stringify({ + type: "hello", + clientId: `terminal-raw-${Date.now()}-${Math.random().toString(36).slice(2)}`, + clientType: "cli", + protocolVersion: 1, + }), + ); + + await helloReady; + return ws; +} + +async function closeWebSocket(ws: WebSocket, timeout = 5000): Promise { + if (ws.readyState === WebSocket.CLOSED) { + return; + } + await new Promise((resolve) => { + const timeoutHandle = setTimeout(() => { + ws.terminate(); + }, timeout); + const cleanup = () => { + clearTimeout(timeoutHandle); + ws.off("close", onClose); + resolve(); + }; + const onClose = () => { + cleanup(); + }; + ws.on("close", onClose); + ws.close(); + }); +} + +async function waitForRawSessionMessage( + ws: WebSocket, + predicate: (message: { + type?: string; + message?: { type?: string; payload?: Record }; + }) => boolean, + timeout = 10000, +): Promise<{ + type?: string; + message?: { type?: string; payload?: Record }; +}> { + return new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + cleanup(); + reject(new Error(`Timeout waiting for raw websocket message (${timeout}ms)`)); + }, timeout); + + const onMessage = (raw: WebSocket.RawData) => { + const buffer = toWsBuffer(raw); + const text = typeof raw === "string" ? raw : buffer?.toString("utf8"); + if (!text) { + return; + } + try { + const parsed = JSON.parse(text) as { + type?: string; + message?: { type?: string; payload?: Record }; + }; + if (!predicate(parsed)) { + return; + } + cleanup(); + resolve(parsed); + } catch { + // ignore binary terminal frames + } + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const cleanup = () => { + clearTimeout(timeoutHandle); + ws.off("message", onMessage); + ws.off("error", onError); + }; + + ws.on("message", onMessage); + ws.on("error", onError); + }); +} + +async function waitForRawBinaryFrame( + ws: WebSocket, + predicate: (frame: TerminalStreamFrame) => boolean, + timeout = 10000, +): Promise { + return new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + cleanup(); + reject(new Error(`Timeout waiting for terminal frame (${timeout}ms)`)); + }, timeout); + + const onMessage = (raw: WebSocket.RawData) => { + const buffer = toWsBuffer(raw); + if (!buffer) { + return; + } + const frame = decodeTerminalStreamFrame(new Uint8Array(buffer)); + if (!frame || !predicate(frame)) { + return; + } + cleanup(); + resolve(frame); + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const cleanup = () => { + clearTimeout(timeoutHandle); + ws.off("message", onMessage); + ws.off("error", onError); + }; + + ws.on("message", onMessage); + ws.on("error", onError); + }); +} + +async function subscribeRawTerminal(ws: WebSocket, terminalId: string, requestId: string): Promise { + const ready = waitForRawSessionMessage( + ws, + (message) => + message.type === "session" && + message.message?.type === "subscribe_terminal_response" && + message.message.payload?.requestId === requestId, + 10000, + ); + + ws.send( + JSON.stringify({ + type: "session", + message: { + type: "subscribe_terminal_request", + terminalId, + requestId, + }, + }), + ); + + await ready; +} + +describe("daemon E2E terminal", () => { let ctx: DaemonTestContext; beforeEach(async () => { @@ -89,651 +325,203 @@ const shouldRun = !process.env.CI; await ctx.cleanup(); }, 60000); - test("lists terminals for a directory (empty when none exist)", async () => { + test("lists terminals for a directory", async () => { const cwd = tmpCwd(); - const result = await ctx.client.listTerminals(cwd); - - expect(result.cwd).toBe(cwd); - expect(result.terminals).toHaveLength(0); - - rmSync(cwd, { recursive: true, force: true }); - }, 30000); - - test("creates terminal with custom name", async () => { - const cwd = tmpCwd(); - - // Create a terminal with custom name - const result = await ctx.client.createTerminal(cwd, "Dev Server"); - - expect(result.error).toBeNull(); - expect(result.terminal).toBeTruthy(); - expect(result.terminal!.name).toBe("Dev Server"); - expect(result.terminal!.cwd).toBe(cwd); - - // Verify list now shows one terminal const list = await ctx.client.listTerminals(cwd); - expect(list.terminals).toHaveLength(1); + + expect(list.cwd).toBe(cwd); + expect(list.terminals).toEqual([]); rmSync(cwd, { recursive: true, force: true }); }, 30000); - test("emits terminals_changed for subscribed cwd when terminals are created", async () => { + test("client connects and receives a snapshot of the current terminal state", async () => { const cwd = tmpCwd(); - - const snapshots: Array<{ cwd: string; names: string[] }> = []; - const unsubscribe = ctx.client.on("terminals_changed", (message) => { - if (message.type !== "terminals_changed") { - return; - } - snapshots.push({ - cwd: message.payload.cwd, - names: message.payload.terminals.map((terminal) => terminal.name), - }); - }); - - ctx.client.subscribeTerminals({ cwd }); - await ctx.client.createTerminal(cwd, "Dev Server"); - - await waitForCondition( - () => - snapshots.some((snapshot) => snapshot.cwd === cwd && snapshot.names.includes("Dev Server")), - 10000, - ); - - ctx.client.unsubscribeTerminals({ cwd }); - unsubscribe(); - rmSync(cwd, { recursive: true, force: true }); - }, 30000); - - test("subscribes to terminal and receives state", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); const terminalId = created.terminal!.id; - // Subscribe to terminal - const subscribeResult = await ctx.client.subscribeTerminal(terminalId); - - expect(subscribeResult.error).toBeNull(); - expect(subscribeResult.terminalId).toBe(terminalId); - expect(subscribeResult.state).toBeTruthy(); - expect(subscribeResult.state!.rows).toBeGreaterThan(0); - expect(subscribeResult.state!.cols).toBeGreaterThan(0); - expect(subscribeResult.state!.grid).toBeTruthy(); - expect(subscribeResult.state!.cursor).toBeTruthy(); - - // Unsubscribe - ctx.client.unsubscribeTerminal(terminalId); - - rmSync(cwd, { recursive: true, force: true }); - }, 30000); - - test("sends input to terminal and receives output", async () => { - const cwd = tmpCwd(); - - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - - // Subscribe to terminal - await ctx.client.subscribeTerminal(terminalId); - - // Send input - ctx.client.sendTerminalInput(terminalId, { type: "input", data: "echo hello\r" }); - - // Wait for output containing "hello" - may need multiple updates - let foundHello = false; - const start = Date.now(); - const timeout = 10000; - - while (!foundHello && Date.now() - start < timeout) { - try { - const output = await ctx.client.waitForTerminalOutput(terminalId, 2000); - expect(output.terminalId).toBe(terminalId); - expect(output.state).toBeTruthy(); - - // Extract text from grid - const gridText = output.state.grid - .map((row) => - row - .map((cell) => cell.char) - .join("") - .trimEnd(), - ) - .filter((line) => line.length > 0) - .join("\n"); - - if (gridText.includes("hello")) { - foundHello = true; - } - } catch { - // Timeout waiting for output, try again - } - } - - expect(foundHello).toBe(true); - - // Cleanup - ctx.client.unsubscribeTerminal(terminalId); - rmSync(cwd, { recursive: true, force: true }); - }, 30000); - - test("kills terminal", async () => { - const cwd = tmpCwd(); - - // Create terminal - const createResult = await ctx.client.createTerminal(cwd, "To Kill"); - expect(createResult.terminal).toBeTruthy(); - const terminalId = createResult.terminal!.id; - - // Kill terminal - const killResult = await ctx.client.killTerminal(terminalId); - expect(killResult.success).toBe(true); - expect(killResult.terminalId).toBe(terminalId); - - // Verify terminal is gone by trying to subscribe - const subscribeResult = await ctx.client.subscribeTerminal(terminalId); - expect(subscribeResult.error).toBe("Terminal not found"); - - rmSync(cwd, { recursive: true, force: true }); - }, 30000); - - test("returns error for relative path", async () => { - // Try to list terminals with relative path - const list = await ctx.client.listTerminals("relative/path"); - - // Should return empty terminals (error case) - expect(list.terminals).toHaveLength(0); - }, 30000); - - test("preserves color mode in terminal output (fgMode/bgMode)", async () => { - const cwd = tmpCwd(); - - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - - // Subscribe to terminal - await ctx.client.subscribeTerminal(terminalId); - - // Send printf with ANSI red color (mode 1) ctx.client.sendTerminalInput(terminalId, { type: "input", - data: "printf '\\033[31mRED\\033[0m\\n'\r", + data: "printf 'hello\\n'\r", + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + + const snapshotPromise = waitForTerminalSnapshot( + ctx.client, + terminalId, + (state) => extractStateText(state).includes("hello"), + ); + await ctx.client.subscribeTerminal(terminalId); + const snapshot = await snapshotPromise; + + expect(extractStateText(snapshot)).toContain("hello"); + + rmSync(cwd, { recursive: true, force: true }); + }, 30000); + + test("client sends input and receives output as raw bytes", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + + const outputPromise = waitForTerminalOutput( + ctx.client, + terminalId, + (text) => text.includes("binary-stream"), + ); + await ctx.client.subscribeTerminal(terminalId); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo binary-stream\r", }); - // Wait for output with colored text - let foundColoredCell = false; - let lastState: any = null; - const start = Date.now(); - const timeout = 20000; + expect(await outputPromise).toContain("binary-stream"); - while (!foundColoredCell && Date.now() - start < timeout) { - try { - const output = await ctx.client.waitForTerminalOutput(terminalId, 2000); - lastState = output.state; + rmSync(cwd, { recursive: true, force: true }); + }, 30000); - const buffers = [output.state.grid, output.state.scrollback]; - for (const buffer of buffers) { - for (const row of buffer) { - for (const cell of row) { - if (cell.fg === 1 || (cell.fgMode !== undefined && cell.fgMode > 0)) { - foundColoredCell = true; - // Mode is optional; fg should still indicate ANSI red. - if (cell.fgMode !== undefined) { - // 1 = 16 ANSI colors - expect(cell.fgMode).toBe(1); - } - expect(cell.fg).toBe(1); // ANSI red - break; - } - } - if (foundColoredCell) break; - } - if (foundColoredCell) break; - } - } catch { - // Timeout waiting for output, try again - } - } - - // Always assert that the command output made it through. - const state = lastState; - if (state) { - const text = [...state.scrollback, ...state.grid] - .map((row) => row.map((cell) => cell.char).join("")) - .join("\n"); - expect(text).toContain("RED"); - } + test("disconnect and reconnect both receive the current snapshot", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + await ctx.client.subscribeTerminal(terminalId); ctx.client.unsubscribeTerminal(terminalId); + + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo while-detached\r", + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + + const snapshotPromise = waitForTerminalSnapshot( + ctx.client, + terminalId, + (state) => extractStateText(state).includes("while-detached"), + ); + await ctx.client.subscribeTerminal(terminalId); + + expect(extractStateText(await snapshotPromise)).toContain("while-detached"); + rmSync(cwd, { recursive: true, force: true }); }, 30000); - test("streams terminal output over binary mux and supports modifier keys", async () => { + test("fast output to a slow websocket client falls back to a snapshot", async () => { const cwd = tmpCwd(); const created = await ctx.client.createTerminal(cwd); const terminalId = created.terminal!.id; + const ws = await connectRawWebSocket(ctx.daemon.port); - const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 }); - expect(attach.error).toBeNull(); - expect(attach.streamId).toBeTypeOf("number"); - const streamId = attach.streamId!; + await subscribeRawTerminal(ws, terminalId, "sub-raw"); + await waitForRawBinaryFrame(ws, (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, 10000); - let text = ""; - const unsubscribe = ctx.client.onTerminalStreamData(streamId, (chunk) => { - text += decoder.decode(chunk.data, { stream: true }); + const rawSocket = (ws as WebSocket & { _socket?: { pause: () => void; resume: () => void } })._socket; + expect(rawSocket).toBeDefined(); + + rawSocket!.pause(); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: `node -e 'process.stdout.write("A".repeat(${8 * 1024 * 1024}))'\r`, }); - ctx.client.sendTerminalStreamInput(streamId, "echo binary-stream\r"); - await waitForCondition(() => text.includes("binary-stream"), 10000); + await new Promise((resolve) => setTimeout(resolve, 750)); + rawSocket!.resume(); - ctx.client.sendTerminalStreamInput(streamId, "cat -v\r"); - await waitForCondition(() => text.includes("cat -v"), 10000); + const catchUpFrame = await waitForRawBinaryFrame( + ws, + (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + 15000, + ); + expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Snapshot); - // Ctrl+B is the default tmux prefix; cat -v should render it as ^B. - ctx.client.sendTerminalStreamKey(streamId, { key: "b", ctrl: true }); - ctx.client.sendTerminalStreamInput(streamId, "\r"); - await waitForCondition(() => text.includes("^B"), 10000); + await closeWebSocket(ws); + rmSync(cwd, { recursive: true, force: true }); + }, 40000); - // Stop cat - ctx.client.sendTerminalStreamKey(streamId, { key: "c", ctrl: true }); + test("multiple clients on the same terminal each receive output independently", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + const secondClient = await connectClient( + ctx.daemon.port, + `terminal-secondary-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + + try { + const firstOutput = waitForTerminalOutput( + ctx.client, + terminalId, + (text) => text.includes("fanout"), + ); + const secondOutput = waitForTerminalOutput( + secondClient, + terminalId, + (text) => text.includes("fanout"), + ); + + await ctx.client.subscribeTerminal(terminalId); + await secondClient.subscribeTerminal(terminalId); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo fanout\r", + }); + + expect(await firstOutput).toContain("fanout"); + expect(await secondOutput).toContain("fanout"); + } finally { + await secondClient.close(); + } - const detach = await ctx.client.detachTerminalStream(streamId); - expect(detach.success).toBe(true); - unsubscribe(); rmSync(cwd, { recursive: true, force: true }); }, 30000); - test("emits terminal_stream_exit and removes terminal when shell exits", async () => { + test("resize sends a new snapshot with the updated dimensions", async () => { const cwd = tmpCwd(); const created = await ctx.client.createTerminal(cwd); const terminalId = created.terminal!.id; - const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 }); - expect(attach.error).toBeNull(); - const streamId = attach.streamId!; + const resizedSnapshot = waitForTerminalSnapshot( + ctx.client, + terminalId, + (state) => state.rows === 10 && state.cols === 40, + ); + await ctx.client.subscribeTerminal(terminalId); + ctx.client.sendTerminalInput(terminalId, { + type: "resize", + rows: 10, + cols: 40, + }); + + const snapshot = await resizedSnapshot; + expect(snapshot.rows).toBe(10); + expect(snapshot.cols).toBe(40); + + rmSync(cwd, { recursive: true, force: true }); + }, 30000); + + test("terminal exits notify the client", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; let sawExit = false; - const unsubscribeExit = ctx.client.on("terminal_stream_exit", (message) => { + const unsubscribe = ctx.client.on("terminal_stream_exit", (message) => { if (message.type !== "terminal_stream_exit") { return; } - if (message.payload.terminalId === terminalId && message.payload.streamId === streamId) { + if (message.payload.terminalId === terminalId) { sawExit = true; } }); + await ctx.client.subscribeTerminal(terminalId); const kill = await ctx.client.killTerminal(terminalId); expect(kill.success).toBe(true); await waitForCondition(() => sawExit, 10000); - - const next = await ctx.client.listTerminals(cwd); - expect(next.terminals).toHaveLength(0); - - unsubscribeExit(); - rmSync(cwd, { recursive: true, force: true }); - }, 30000); - - test("replays detached terminal output from resume offset (scrollback continuity)", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - - const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 }); - expect(attach.error).toBeNull(); - const streamId = attach.streamId!; - - let output = ""; - let latestOffset = attach.currentOffset; - const unsubscribe = ctx.client.onTerminalStreamData(streamId, (chunk) => { - output += decoder.decode(chunk.data, { stream: true }); - latestOffset = chunk.endOffset; - }); - - ctx.client.sendTerminalStreamInput(streamId, "echo before-detach\r"); - await waitForCondition(() => output.includes("before-detach"), 10000); - - const firstDetach = await ctx.client.detachTerminalStream(streamId); - expect(firstDetach.success).toBe(true); unsubscribe(); - // Terminal keeps running while hidden, stream is detached. - ctx.client.sendTerminalInput(terminalId, { type: "input", data: "echo while-detached\r" }); - await new Promise((resolve) => setTimeout(resolve, 300)); - - const resumed = await ctx.client.attachTerminalStream(terminalId, { - resumeOffset: latestOffset, - }); - expect(resumed.error).toBeNull(); - expect(resumed.streamId).toBeTypeOf("number"); - expect(resumed.reset).toBe(false); - - let replayedText = ""; - const resumedUnsub = ctx.client.onTerminalStreamData(resumed.streamId!, (chunk) => { - if (chunk.replay) { - replayedText += decoder.decode(chunk.data, { stream: true }); - } - }); - - await waitForCondition(() => replayedText.includes("while-detached"), 10000); - - const secondDetach = await ctx.client.detachTerminalStream(resumed.streamId!); - expect(secondDetach.success).toBe(true); - resumedUnsub(); rmSync(cwd, { recursive: true, force: true }); }, 30000); - - test("caps terminal stream output within the backpressure window", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - - const ws = new WebSocket(`ws://127.0.0.1:${ctx.daemon.port}/ws`); - await new Promise((resolve, reject) => { - ws.once("open", () => resolve()); - ws.once("error", reject); - }); - - const helloReady = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - cleanup(); - reject(new Error("Timed out waiting for initial websocket server_info")); - }, 10000); - - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - - const onMessage = (raw: WebSocket.RawData) => { - if (Array.isArray(raw)) { - raw = Buffer.concat( - raw.map((part) => (Buffer.isBuffer(part) ? part : Buffer.from(part))), - ); - } - if (typeof raw !== "string" && !Buffer.isBuffer(raw)) { - return; - } - const text = typeof raw === "string" ? raw : raw.toString("utf8"); - try { - const parsed = JSON.parse(text) as { - type?: string; - message?: { type?: string; payload?: { status?: string } }; - }; - if ( - parsed.type === "session" && - parsed.message?.type === "status" && - parsed.message.payload?.status === "server_info" - ) { - cleanup(); - resolve(); - } - } catch { - // Ignore non-JSON payloads (binary mux frames). - } - }; - - const cleanup = () => { - clearTimeout(timeout); - ws.off("message", onMessage); - ws.off("error", onError); - }; - - ws.on("message", onMessage); - ws.on("error", onError); - }); - - ws.send( - JSON.stringify({ - type: "hello", - clientId: `terminal-backpressure-${Date.now()}-${Math.random().toString(36).slice(2)}`, - clientType: "cli", - protocolVersion: 1, - }), - ); - await helloReady; - - const attachRequestId = `attach-${Date.now()}`; - const detachRequestId = `detach-${Date.now()}`; - let streamId: number | null = null; - let latestEndOffset = 0; - let outputBytes = 0; - - const streamReady = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Timed out waiting for attach_terminal_stream_response")); - }, 10000); - - ws.on("message", (raw) => { - if (Array.isArray(raw)) { - raw = Buffer.concat( - raw.map((part) => (Buffer.isBuffer(part) ? part : Buffer.from(part))), - ); - } - if (typeof raw !== "string" && !Buffer.isBuffer(raw)) { - return; - } - const text = typeof raw === "string" ? raw : raw.toString("utf8"); - try { - const parsed = JSON.parse(text) as { - type?: string; - message?: { - type?: string; - payload?: { streamId?: number | null; requestId?: string }; - }; - }; - if ( - parsed.type === "session" && - parsed.message?.type === "attach_terminal_stream_response" && - parsed.message.payload?.requestId === attachRequestId - ) { - const nextStreamId = parsed.message.payload.streamId; - if (typeof nextStreamId === "number") { - streamId = nextStreamId; - clearTimeout(timeout); - resolve(); - } - } - } catch { - // Ignore non-JSON payloads (binary mux frames). - } - }); - }); - - ws.on("message", (raw) => { - if (typeof raw === "string") { - return; - } - if (Array.isArray(raw)) { - raw = Buffer.concat(raw.map((part) => (Buffer.isBuffer(part) ? part : Buffer.from(part)))); - } - const bytes = Buffer.isBuffer(raw) - ? new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength) - : new Uint8Array(raw as ArrayBuffer); - const frame = decodeBinaryMuxFrame(bytes); - if ( - !frame || - frame.channel !== BinaryMuxChannel.Terminal || - frame.messageType !== TerminalBinaryMessageType.OutputUtf8 - ) { - return; - } - const chunkBytes = frame.payload?.byteLength ?? 0; - outputBytes += chunkBytes; - latestEndOffset = frame.offset + chunkBytes; - }); - - ws.send( - JSON.stringify({ - type: "session", - message: { - type: "attach_terminal_stream_request", - terminalId, - requestId: attachRequestId, - }, - }), - ); - await streamReady; - expect(streamId).toBeTypeOf("number"); - - ws.send( - JSON.stringify({ - type: "session", - message: { - type: "terminal_input", - terminalId, - message: { - type: "input", - data: "node -e 'process.stdout.write(\"A\".repeat(1048576))'\r", - }, - }, - }), - ); - - await waitForCondition(() => outputBytes >= 32 * 1024, 10000); - const beforeAckBytes = await waitForStableNumber(() => outputBytes, { - stableMs: 1000, - timeoutMs: 10000, - }); - expect(beforeAckBytes).toBeGreaterThan(32 * 1024); - expect(beforeAckBytes).toBeLessThan(320 * 1024); - expect(latestEndOffset).toBeGreaterThan(0); - - ws.send( - JSON.stringify({ - type: "session", - message: { - type: "detach_terminal_stream_request", - streamId, - requestId: detachRequestId, - }, - }), - ); - - ws.close(); - rmSync(cwd, { recursive: true, force: true }); - }, 30000); - - test("profiles keystroke-to-echo latency via daemon terminal stream under burst load", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - - const attach = await ctx.client.attachTerminalStream(terminalId); - expect(attach.error).toBeNull(); - const streamId = attach.streamId!; - - let output = ""; - const unsubscribe = ctx.client.onTerminalStreamData(streamId, (chunk) => { - output += decoder.decode(chunk.data, { stream: true }); - }); - - type Trial = { - phase: "stream-only" | "stream+state-sub"; - iteration: number; - payloadBytes: number; - latencyMs: number; - }; - - async function runBurstLoop(input: { - phase: Trial["phase"]; - iterations: number; - burstRepeat: number; - }): Promise { - const trials: Trial[] = []; - let searchStart = 0; - - for (let i = 0; i < input.iterations; i += 1) { - const marker = `PASEO_LAT_${input.phase}_${i}_${Date.now()}_${Math.random() - .toString(36) - .slice(2, 8)}`; - const burstBody = "abcdefghijklmnopqrstuvwxyz0123456789".repeat(input.burstRepeat); - const payload = `${marker}_${burstBody}`; - - const start = performance.now(); - ctx.client.sendTerminalStreamInput(streamId, `${payload}\r`); - - await waitForCondition(() => output.indexOf(marker, searchStart) !== -1, 15000); - - const markerOffset = output.indexOf(marker, searchStart); - if (markerOffset !== -1) { - searchStart = markerOffset + marker.length; - } - - trials.push({ - phase: input.phase, - iteration: i + 1, - payloadBytes: payload.length, - latencyMs: performance.now() - start, - }); - - await new Promise((resolve) => setTimeout(resolve, 150)); - } - - return trials; - } - - // Warm-up: establish prompt/output activity before timed loops. - ctx.client.sendTerminalStreamInput(streamId, "echo PASEO_LAT_WARMUP\r"); - await waitForCondition(() => output.includes("PASEO_LAT_WARMUP"), 10000); - - const streamOnlyTrials = await runBurstLoop({ - phase: "stream-only", - iterations: 10, - burstRepeat: 12, - }); - - await ctx.client.subscribeTerminal(terminalId); - const withStateSubscriptionTrials = await runBurstLoop({ - phase: "stream+state-sub", - iterations: 10, - burstRepeat: 12, - }); - ctx.client.unsubscribeTerminal(terminalId); - - const allTrials = [...streamOnlyTrials, ...withStateSubscriptionTrials]; - const streamOnlyLatencies = streamOnlyTrials.map((trial) => trial.latencyMs); - const withStateLatencies = withStateSubscriptionTrials.map((trial) => trial.latencyMs); - - const streamOnlyP50 = percentile(streamOnlyLatencies, 50); - const streamOnlyP95 = percentile(streamOnlyLatencies, 95); - const streamOnlyMax = percentile(streamOnlyLatencies, 100); - const withStateP50 = percentile(withStateLatencies, 50); - const withStateP95 = percentile(withStateLatencies, 95); - const withStateMax = percentile(withStateLatencies, 100); - - expect(allTrials).toHaveLength(20); - expect(streamOnlyLatencies.length).toBe(10); - expect(withStateLatencies.length).toBe(10); - - // Diagnostic output: used as verification/measurement loop in local debugging. - console.log( - `[terminal-latency-loop] stream-only samples=${streamOnlyLatencies - .map((n) => n.toFixed(1)) - .join(",")} mean=${mean(streamOnlyLatencies).toFixed(1)}ms p50=${streamOnlyP50.toFixed( - 1, - )}ms p95=${streamOnlyP95.toFixed(1)}ms max=${streamOnlyMax.toFixed(1)}ms`, - ); - console.log( - `[terminal-latency-loop] stream+state-sub samples=${withStateLatencies - .map((n) => n.toFixed(1)) - .join(",")} mean=${mean(withStateLatencies).toFixed(1)}ms p50=${withStateP50.toFixed( - 1, - )}ms p95=${withStateP95.toFixed(1)}ms max=${withStateMax.toFixed(1)}ms`, - ); - console.log( - `[terminal-latency-loop] trial-details=${allTrials - .map( - (trial) => - `${trial.phase}#${trial.iteration}:${trial.latencyMs.toFixed(1)}ms(${trial.payloadBytes}b)`, - ) - .join(" ")}`, - ); - - const detach = await ctx.client.detachTerminalStream(streamId); - expect(detach.success).toBe(true); - unsubscribe(); - rmSync(cwd, { recursive: true, force: true }); - }, 90000); }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9347e1079..aecdbba87 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -23,8 +23,6 @@ import { type UnsubscribeTerminalRequest, type TerminalInput, type KillTerminalRequest, - type AttachTerminalStreamRequest, - type DetachTerminalStreamRequest, type SubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest, type DirectorySuggestionsRequest, @@ -35,11 +33,12 @@ import { import type { TerminalManager, TerminalsChangedEvent } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; import { - BinaryMuxChannel, - TerminalBinaryFlags, - TerminalBinaryMessageType, - type BinaryMuxFrame, -} from "../shared/binary-mux.js"; + TerminalStreamOpcode, + encodeTerminalSnapshotPayload, + encodeTerminalStreamFrame, + decodeTerminalResizePayload, + type TerminalStreamFrame, +} from "../shared/terminal-stream-protocol.js"; import { TTSManager } from "./agent/tts-manager.js"; import { STTManager } from "./agent/stt-manager.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js"; @@ -191,9 +190,8 @@ const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; -const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024; -const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024; -const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048; +const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024; +const TERMINAL_STREAM_LOW_WATER_BYTES = 16 * 1024; function deriveInitialAgentTitle(prompt: string): string | null { const firstContentLine = prompt @@ -277,11 +275,12 @@ type CheckoutErrorPayload = { message: string; }; -type TerminalStreamPendingChunk = { - data: string; - startOffset: number; - endOffset: number; - replay: boolean; +type ActiveTerminalStream = { + terminalId: string; + unsubscribe: () => void; + needsSnapshot: boolean; + primed: boolean; + snapshotTimer: ReturnType | null; }; export type SessionRuntimeMetrics = { @@ -395,7 +394,8 @@ type VoiceTranscriptionResultPayload = { export type SessionOptions = { clientId: string; onMessage: (msg: SessionOutboundMessage) => void; - onBinaryMessage?: (frame: BinaryMuxFrame) => void; + onBinaryMessage?: (frame: Uint8Array) => void; + getBinaryBufferedAmount?: () => number; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; logger: pino.Logger; downloadTokenStore: DownloadTokenStore; @@ -546,7 +546,8 @@ export class Session { private readonly clientId: string; private readonly sessionId: string; private readonly onMessage: (msg: SessionOutboundMessage) => void; - private readonly onBinaryMessage: ((frame: BinaryMuxFrame) => void) | null; + private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null; + private readonly getBinaryBufferedAmount: (() => number) | null; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private readonly sessionLogger: pino.Logger; private readonly paseoHome: string; @@ -605,21 +606,8 @@ export class Session { private readonly terminalManager: TerminalManager | null; private readonly subscribedTerminalDirectories = new Set(); private unsubscribeTerminalsChanged: (() => void) | null = null; - private terminalSubscriptions: Map void> = new Map(); private terminalExitSubscriptions: Map void> = new Map(); - private readonly terminalStreams = new Map< - number, - { - terminalId: string; - unsubscribe: () => void; - lastOutputOffset: number; - lastAckOffset: number; - pendingChunks: TerminalStreamPendingChunk[]; - pendingBytes: number; - } - >(); - private readonly terminalStreamByTerminalId = new Map(); - private nextTerminalStreamId = 1; + private activeTerminalStream: ActiveTerminalStream | null = null; private readonly checkoutDiffSubscriptions = new Map(); private readonly checkoutDiffTargets = new Map(); private readonly workspaceGitWatchTargets = new Map(); @@ -648,6 +636,7 @@ export class Session { clientId, onMessage, onBinaryMessage, + getBinaryBufferedAmount, onLifecycleIntent, logger, downloadTokenStore, @@ -670,6 +659,7 @@ export class Session { this.sessionId = uuidv4(); this.onMessage = onMessage; this.onBinaryMessage = onBinaryMessage ?? null; + this.getBinaryBufferedAmount = getBinaryBufferedAmount ?? null; this.onLifecycleIntent = onLifecycleIntent ?? null; this.downloadTokenStore = downloadTokenStore; this.pushTokenStore = pushTokenStore; @@ -761,8 +751,8 @@ export class Session { checkoutDiffWatcherCount, checkoutDiffFallbackRefreshTargetCount, terminalDirectorySubscriptionCount: this.subscribedTerminalDirectories.size, - terminalSubscriptionCount: this.terminalSubscriptions.size, - terminalStreamCount: this.terminalStreams.size, + terminalSubscriptionCount: this.activeTerminalStream ? 1 : 0, + terminalStreamCount: this.activeTerminalStream ? 1 : 0, }; } @@ -1764,13 +1754,6 @@ export class Session { await this.handleKillTerminalRequest(msg); break; - case "attach_terminal_stream_request": - await this.handleAttachTerminalStreamRequest(msg); - break; - - case "detach_terminal_stream_request": - this.handleDetachTerminalStreamRequest(msg); - break; } } catch (error: any) { const err = error instanceof Error ? error : new Error(String(error)); @@ -1805,73 +1788,43 @@ export class Session { } } - public handleBinaryFrame(frame: BinaryMuxFrame): void { - switch (frame.channel) { - case BinaryMuxChannel.Terminal: - this.handleTerminalBinaryFrame(frame); - break; - default: - this.sessionLogger.warn( - { channel: frame.channel, messageType: frame.messageType }, - "Unhandled binary mux channel", - ); - break; + public handleBinaryFrame(frame: TerminalStreamFrame): void { + const activeStream = this.activeTerminalStream; + if (!activeStream || !this.terminalManager) { + return; } - } - - private handleTerminalBinaryFrame(frame: BinaryMuxFrame): void { - if (frame.messageType === TerminalBinaryMessageType.InputUtf8) { - const binding = this.terminalStreams.get(frame.streamId); - if (!binding) { - this.sessionLogger.warn( - { streamId: frame.streamId }, - "Terminal stream not found for input", - ); - return; - } - if (!this.terminalManager) { - return; - } - const session = this.terminalManager.getTerminal(binding.terminalId); - if (!session) { - this.detachTerminalStream(frame.streamId, { emitExit: true }); - return; - } - - const payload = frame.payload ?? new Uint8Array(0); - if (payload.byteLength === 0) { - return; - } - const text = Buffer.from(payload).toString("utf8"); - if (!text) { - return; - } - session.send({ type: "input", data: text }); + const terminal = this.terminalManager.getTerminal(activeStream.terminalId); + if (!terminal) { + this.detachActiveTerminalStream({ emitExit: true }); return; } - if (frame.messageType === TerminalBinaryMessageType.Ack) { - const binding = this.terminalStreams.get(frame.streamId); - if (binding) { - if (!Number.isFinite(frame.offset) || frame.offset < 0) { + switch (frame.opcode) { + case TerminalStreamOpcode.Input: { + if (frame.payload.byteLength === 0) { return; } - const nextAckOffset = Math.max( - binding.lastAckOffset, - Math.min(Math.floor(frame.offset), binding.lastOutputOffset), - ); - if (nextAckOffset > binding.lastAckOffset) { - binding.lastAckOffset = nextAckOffset; - this.flushPendingTerminalStreamChunks(frame.streamId, binding); + const text = Buffer.from(frame.payload).toString("utf8"); + if (!text) { + return; } + terminal.send({ type: "input", data: text }); + return; } - return; - } - this.sessionLogger.warn( - { streamId: frame.streamId, messageType: frame.messageType }, - "Unhandled terminal binary frame", - ); + case TerminalStreamOpcode.Resize: { + const resize = decodeTerminalResizePayload(frame.payload); + if (!resize) { + return; + } + terminal.send({ type: "resize", rows: resize.rows, cols: resize.cols }); + this.queueTerminalSnapshot(activeStream, terminal); + return; + } + + default: + return; + } } private async handleRestartServerRequest(requestId: string, reason?: string): Promise { @@ -2032,7 +1985,21 @@ export class Session { attentionTimestamp: null, }; await this.agentStorage.upsert(nextRecord); - this.agentManager.notifyAgentState(agentId); + + // Unload the agent from memory — the storage record is the source of truth now. + // This tears down the provider session and drops the hydrated timeline, + // freeing memory. ensureAgentLoaded will re-initialize if needed later. + if (this.agentManager.getAgent(agentId)) { + try { + await this.agentManager.closeAgent(agentId); + } catch (error) { + this.sessionLogger.warn( + { err: error, agentId }, + "Failed to close agent during archive", + ); + } + } + return { archivedAt, archivedRecord: nextRecord }; } @@ -7447,7 +7414,7 @@ export class Session { this.onMessage(msg); } - private emitBinary(frame: BinaryMuxFrame): void { + private emitBinary(frame: Uint8Array): void { if (!this.onBinaryMessage) { return; } @@ -7506,15 +7473,11 @@ export class Session { } this.subscribedTerminalDirectories.clear(); - for (const unsubscribe of this.terminalSubscriptions.values()) { - unsubscribe(); - } - this.terminalSubscriptions.clear(); for (const unsubscribeExit of this.terminalExitSubscriptions.values()) { unsubscribeExit(); } this.terminalExitSubscriptions.clear(); - this.detachAllTerminalStreams({ emitExit: false }); + this.detachActiveTerminalStream({ emitExit: false }); for (const target of this.checkoutDiffTargets.values()) { this.closeCheckoutDiffWatchTarget(target); @@ -7550,22 +7513,8 @@ export class Session { this.terminalExitSubscriptions.delete(terminalId); } - const unsubscribe = this.terminalSubscriptions.get(terminalId); - if (unsubscribe) { - try { - unsubscribe(); - } catch (error) { - this.sessionLogger.warn( - { err: error, terminalId }, - "Failed to unsubscribe terminal after process exit", - ); - } - this.terminalSubscriptions.delete(terminalId); - } - - const streamId = this.terminalStreamByTerminalId.get(terminalId); - if (typeof streamId === "number") { - this.detachTerminalStream(streamId, { emitExit: true }); + if (this.activeTerminalStream?.terminalId === terminalId) { + this.detachActiveTerminalStream({ emitExit: true }); } } @@ -7740,25 +7689,7 @@ export class Session { } this.ensureTerminalExitSubscription(session); - // Unsubscribe from previous subscription if any - const existing = this.terminalSubscriptions.get(msg.terminalId); - if (existing) { - existing(); - } - - // Subscribe to terminal updates - const unsubscribe = session.subscribe((serverMsg) => { - if (serverMsg.type === "full") { - this.emit({ - type: "terminal_output", - payload: { - terminalId: msg.terminalId, - state: serverMsg.state, - }, - }); - } - }); - this.terminalSubscriptions.set(msg.terminalId, unsubscribe); + const activeStream = this.bindActiveTerminalStream(session); // Send initial state this.emit({ @@ -7770,13 +7701,16 @@ export class Session { requestId: msg.requestId, }, }); + + if (activeStream) { + this.sendTerminalSnapshot(activeStream, session); + activeStream.primed = true; + } } private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void { - const unsubscribe = this.terminalSubscriptions.get(msg.terminalId); - if (unsubscribe) { - unsubscribe(); - this.terminalSubscriptions.delete(msg.terminalId); + if (this.activeTerminalStream?.terminalId === msg.terminalId) { + this.detachActiveTerminalStream({ emitExit: false }); } } @@ -7796,15 +7730,8 @@ export class Session { } private killTrackedTerminal(terminalId: string, options?: { emitExit: boolean }): void { - const unsubscribe = this.terminalSubscriptions.get(terminalId); - if (unsubscribe) { - unsubscribe(); - this.terminalSubscriptions.delete(terminalId); - } - - const streamId = this.terminalStreamByTerminalId.get(terminalId); - if (typeof streamId === "number") { - this.detachTerminalStream(streamId, { emitExit: options?.emitExit ?? true }); + if (this.activeTerminalStream?.terminalId === terminalId) { + this.detachActiveTerminalStream({ emitExit: options?.emitExit ?? true }); } this.terminalManager?.killTerminal(terminalId); @@ -7867,277 +7794,156 @@ export class Session { }); } - private async handleAttachTerminalStreamRequest(msg: AttachTerminalStreamRequest): Promise { - if (!this.terminalManager || !this.onBinaryMessage) { - this.emit({ - type: "attach_terminal_stream_response", - payload: { - terminalId: msg.terminalId, - streamId: null, - replayedFrom: 0, - currentOffset: 0, - earliestAvailableOffset: 0, - reset: true, - error: "Terminal streaming not available", - requestId: msg.requestId, - }, - }); - return; + private bindActiveTerminalStream(terminal: TerminalSession): ActiveTerminalStream | null { + this.detachActiveTerminalStream({ emitExit: false }); + if (!this.onBinaryMessage) { + return null; } - const session = this.terminalManager.getTerminal(msg.terminalId); - if (!session) { - this.emit({ - type: "attach_terminal_stream_response", - payload: { - terminalId: msg.terminalId, - streamId: null, - replayedFrom: 0, - currentOffset: 0, - earliestAvailableOffset: 0, - reset: true, - error: "Terminal not found", - requestId: msg.requestId, - }, - }); - return; - } - - if (msg.rows || msg.cols) { - const state = session.getState(); - session.send({ - type: "resize", - rows: msg.rows ?? state.rows, - cols: msg.cols ?? state.cols, - }); - } - - const existingStreamId = this.terminalStreamByTerminalId.get(msg.terminalId); - if (typeof existingStreamId === "number") { - // Replacing an active stream can happen when multiple UI surfaces attach to the - // same terminal. Emit exit for the replaced stream so stale listeners reconnect - // instead of continuing to send input to an invalid stream id. - this.detachTerminalStream(existingStreamId, { emitExit: true }); - } - - const streamId = this.allocateTerminalStreamId(); - const requestedResumeOffset = typeof msg.resumeOffset === "number" ? msg.resumeOffset : 0; - const initialOffset = Math.max(0, Math.floor(requestedResumeOffset)); - const binding: { - terminalId: string; - unsubscribe: () => void; - lastOutputOffset: number; - lastAckOffset: number; - pendingChunks: TerminalStreamPendingChunk[]; - pendingBytes: number; - } = { - terminalId: msg.terminalId, + const activeStream: ActiveTerminalStream = { + terminalId: terminal.id, unsubscribe: () => {}, - lastOutputOffset: initialOffset, - lastAckOffset: initialOffset, - pendingChunks: [], - pendingBytes: 0, + needsSnapshot: false, + primed: false, + snapshotTimer: null, }; - this.terminalStreams.set(streamId, binding); - this.terminalStreamByTerminalId.set(msg.terminalId, streamId); - - let rawSub; - try { - rawSub = session.subscribeRaw( - (chunk) => { - const currentBinding = this.terminalStreams.get(streamId); - if (!currentBinding) { - return; - } - this.enqueueOrEmitTerminalStreamChunk(streamId, currentBinding, { - data: chunk.data, - startOffset: chunk.startOffset, - endOffset: chunk.endOffset, - replay: chunk.replay, - }); - }, - { fromOffset: requestedResumeOffset }, - ); - } catch (error) { - this.terminalStreams.delete(streamId); - this.terminalStreamByTerminalId.delete(msg.terminalId); - throw error; - } - - binding.unsubscribe = rawSub.unsubscribe; - binding.lastAckOffset = rawSub.replayedFrom; - if (binding.lastOutputOffset < rawSub.replayedFrom) { - binding.lastOutputOffset = rawSub.replayedFrom; - } - this.flushPendingTerminalStreamChunks(streamId, binding); - - this.emit({ - type: "attach_terminal_stream_response", - payload: { - terminalId: msg.terminalId, - streamId, - replayedFrom: rawSub.replayedFrom, - currentOffset: rawSub.currentOffset, - earliestAvailableOffset: rawSub.earliestAvailableOffset, - reset: rawSub.reset, - error: null, - requestId: msg.requestId, - }, - }); - } - - private getTerminalStreamChunkByteLength(chunk: TerminalStreamPendingChunk): number { - return Math.max(0, chunk.endOffset - chunk.startOffset); - } - - private canEmitTerminalStreamChunk( - binding: { - lastAckOffset: number; - }, - chunk: TerminalStreamPendingChunk, - ): boolean { - return chunk.startOffset < binding.lastAckOffset + TERMINAL_STREAM_WINDOW_BYTES; - } - - private emitTerminalStreamChunk( - streamId: number, - binding: { - lastOutputOffset: number; - }, - chunk: TerminalStreamPendingChunk, - ): void { - const payload = new Uint8Array(Buffer.from(chunk.data, "utf8")); - this.emitBinary({ - channel: BinaryMuxChannel.Terminal, - messageType: TerminalBinaryMessageType.OutputUtf8, - streamId, - offset: chunk.startOffset, - flags: chunk.replay ? TerminalBinaryFlags.Replay : 0, - payload, - }); - binding.lastOutputOffset = chunk.endOffset; - } - - private enqueueOrEmitTerminalStreamChunk( - streamId: number, - binding: { - lastAckOffset: number; - lastOutputOffset: number; - pendingChunks: TerminalStreamPendingChunk[]; - pendingBytes: number; - }, - chunk: TerminalStreamPendingChunk, - ): void { - const chunkBytes = this.getTerminalStreamChunkByteLength(chunk); - - if (binding.pendingChunks.length > 0 || !this.canEmitTerminalStreamChunk(binding, chunk)) { - if ( - binding.pendingChunks.length >= TERMINAL_STREAM_MAX_PENDING_CHUNKS || - binding.pendingBytes + chunkBytes > TERMINAL_STREAM_MAX_PENDING_BYTES - ) { - this.sessionLogger.warn( - { - streamId, - pendingChunks: binding.pendingChunks.length, - pendingBytes: binding.pendingBytes, - chunkBytes, - }, - "Terminal stream pending buffer overflow; closing stream", - ); - this.detachTerminalStream(streamId, { emitExit: true }); + activeStream.unsubscribe = terminal.subscribe((message) => { + if (this.activeTerminalStream !== activeStream) { return; } - binding.pendingChunks.push(chunk); - binding.pendingBytes += chunkBytes; + if (message.type === "output") { + this.handleActiveTerminalOutput(activeStream, terminal, message.data); + return; + } + if (message.type === "snapshot") { + this.maybeSendQueuedTerminalSnapshot(activeStream, terminal); + } + }); + this.activeTerminalStream = activeStream; + return activeStream; + } + + private handleActiveTerminalOutput( + activeStream: ActiveTerminalStream, + terminal: TerminalSession, + data: string, + ): void { + if (!activeStream.primed || data.length === 0) { return; } - - this.emitTerminalStreamChunk(streamId, binding, chunk); + if (activeStream.needsSnapshot) { + this.maybeSendQueuedTerminalSnapshot(activeStream, terminal); + return; + } + if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_HIGH_WATER_BYTES) { + activeStream.needsSnapshot = true; + this.scheduleTerminalSnapshotRetry(activeStream, terminal); + return; + } + this.emitBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + payload: new Uint8Array(Buffer.from(data, "utf8")), + }), + ); + if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_HIGH_WATER_BYTES) { + activeStream.needsSnapshot = true; + this.scheduleTerminalSnapshotRetry(activeStream, terminal); + } } - private flushPendingTerminalStreamChunks( - streamId: number, - binding: { - lastAckOffset: number; - lastOutputOffset: number; - pendingChunks: TerminalStreamPendingChunk[]; - pendingBytes: number; - }, + private queueTerminalSnapshot( + activeStream: ActiveTerminalStream, + terminal: TerminalSession, ): void { - while (binding.pendingChunks.length > 0) { - const next = binding.pendingChunks[0]; - if (!next || !this.canEmitTerminalStreamChunk(binding, next)) { - break; - } - binding.pendingChunks.shift(); - binding.pendingBytes -= this.getTerminalStreamChunkByteLength(next); - if (binding.pendingBytes < 0) { - binding.pendingBytes = 0; - } - this.emitTerminalStreamChunk(streamId, binding, next); + if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_HIGH_WATER_BYTES) { + activeStream.needsSnapshot = true; + this.scheduleTerminalSnapshotRetry(activeStream, terminal); + return; } + this.sendTerminalSnapshot(activeStream, terminal); } - private handleDetachTerminalStreamRequest(msg: DetachTerminalStreamRequest): void { - const success = this.detachTerminalStream(msg.streamId, { emitExit: false }); - this.emit({ - type: "detach_terminal_stream_response", - payload: { - streamId: msg.streamId, - success, - requestId: msg.requestId, - }, - }); - } - - private detachAllTerminalStreams(options?: { emitExit: boolean }): void { - for (const streamId of Array.from(this.terminalStreams.keys())) { - this.detachTerminalStream(streamId, options); + private maybeSendQueuedTerminalSnapshot( + activeStream: ActiveTerminalStream, + terminal: TerminalSession, + ): void { + if (!activeStream.needsSnapshot) { + return; } + if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_LOW_WATER_BYTES) { + this.scheduleTerminalSnapshotRetry(activeStream, terminal); + return; + } + this.sendTerminalSnapshot(activeStream, terminal); } - private detachTerminalStream(streamId: number, options?: { emitExit: boolean }): boolean { - const binding = this.terminalStreams.get(streamId); - if (!binding) { + private sendTerminalSnapshot( + activeStream: ActiveTerminalStream, + terminal: TerminalSession, + ): void { + this.clearTerminalSnapshotRetry(activeStream); + activeStream.needsSnapshot = false; + this.emitBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Snapshot, + payload: encodeTerminalSnapshotPayload(terminal.getState()), + }), + ); + } + + private scheduleTerminalSnapshotRetry( + activeStream: ActiveTerminalStream, + terminal: TerminalSession, + ): void { + if (activeStream.snapshotTimer) { + return; + } + activeStream.snapshotTimer = setTimeout(() => { + activeStream.snapshotTimer = null; + if (this.activeTerminalStream !== activeStream || !activeStream.needsSnapshot) { + return; + } + this.maybeSendQueuedTerminalSnapshot(activeStream, terminal); + }, 33); + } + + private clearTerminalSnapshotRetry(activeStream: ActiveTerminalStream): void { + if (!activeStream.snapshotTimer) { + return; + } + clearTimeout(activeStream.snapshotTimer); + activeStream.snapshotTimer = null; + } + + private detachActiveTerminalStream(options?: { emitExit: boolean }): boolean { + const activeStream = this.activeTerminalStream; + if (!activeStream) { return false; } - + this.activeTerminalStream = null; + this.clearTerminalSnapshotRetry(activeStream); try { - binding.unsubscribe(); + activeStream.unsubscribe(); } catch (error) { - this.sessionLogger.warn({ err: error, streamId }, "Failed to unsubscribe terminal stream"); + this.sessionLogger.warn({ err: error }, "Failed to unsubscribe terminal stream"); } - this.terminalStreams.delete(streamId); - if (this.terminalStreamByTerminalId.get(binding.terminalId) === streamId) { - this.terminalStreamByTerminalId.delete(binding.terminalId); - } - if (options?.emitExit) { this.emit({ type: "terminal_stream_exit", payload: { - streamId, - terminalId: binding.terminalId, + terminalId: activeStream.terminalId, }, }); } return true; } - private allocateTerminalStreamId(): number { - let attempts = 0; - while (attempts < 0xffffffff) { - const candidate = this.nextTerminalStreamId >>> 0; - this.nextTerminalStreamId = ((this.nextTerminalStreamId + 1) & 0xffffffff) >>> 0; - if (candidate === 0) { - attempts += 1; - continue; - } - if (!this.terminalStreams.has(candidate)) { - return candidate; - } - attempts += 1; + private getCurrentBinaryBufferedAmount(): number { + const bufferedAmount = this.getBinaryBufferedAmount?.() ?? 0; + if (!Number.isFinite(bufferedAmount) || bufferedAmount < 0) { + return 0; } - throw new Error("Unable to allocate terminal stream id"); + return Math.floor(bufferedAmount); } } diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 1dd39da6c..ad2142cfc 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1,11 +1,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { - BinaryMuxChannel, - TerminalBinaryMessageType, asUint8Array, - decodeBinaryMuxFrame, - encodeBinaryMuxFrame, -} from "../shared/binary-mux.js"; + decodeTerminalStreamFrame, + encodeTerminalStreamFrame, + TerminalStreamOpcode, +} from "../shared/terminal-stream-protocol.js"; const wsModuleMock = vi.hoisted(() => { class MockWebSocketServer { @@ -89,6 +88,7 @@ const TEST_DAEMON_VERSION = "1.2.3-test"; class MockSocket { readyState = 1; + bufferedAmount = 0; sent: unknown[] = []; private listeners = new Map void>>(); @@ -623,7 +623,7 @@ describe("relay external socket reconnect behavior", () => { await server.close(); }); - test("routes inbound binary mux frames to session.handleBinaryFrame", async () => { + test("routes inbound terminal frames to session.handleBinaryFrame", async () => { const server = createServer(); const socket = new MockSocket(); @@ -638,11 +638,8 @@ describe("relay external socket reconnect behavior", () => { socket.emit( "message", Buffer.from( - encodeBinaryMuxFrame({ - channel: BinaryMuxChannel.Terminal, - messageType: TerminalBinaryMessageType.InputUtf8, - streamId: 9, - offset: 0, + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, payload: new TextEncoder().encode("ls\r"), }), ), @@ -651,20 +648,16 @@ describe("relay external socket reconnect behavior", () => { expect(session.handleBinaryFrame).toHaveBeenCalledTimes(1); const frame = session.handleBinaryFrame.mock.calls[0]?.[0] as { - channel: number; - messageType: number; - streamId: number; - offset: number; + opcode: number; + payload: Uint8Array; }; - expect(frame.channel).toBe(BinaryMuxChannel.Terminal); - expect(frame.messageType).toBe(TerminalBinaryMessageType.InputUtf8); - expect(frame.streamId).toBe(9); - expect(frame.offset).toBe(0); + expect(frame.opcode).toBe(TerminalStreamOpcode.Input); + expect(new TextDecoder().decode(frame.payload)).toBe("ls\r"); await server.close(); }); - test("sends outbound binary mux frames from session over websocket", async () => { + test("sends outbound terminal frames from session over websocket", async () => { const server = createServer(); const socket = new MockSocket(); @@ -677,33 +670,18 @@ describe("relay external socket reconnect behavior", () => { const session = sessionMock.instances[0]!; const onBinaryMessage = session.args.onBinaryMessage as - | ((frame: { - channel: number; - messageType: number; - streamId: number; - offset: number; - payload?: Uint8Array; - }) => void) + | ((frame: Uint8Array) => void) | undefined; expect(onBinaryMessage).toBeTypeOf("function"); - onBinaryMessage?.({ - channel: BinaryMuxChannel.Terminal, - messageType: TerminalBinaryMessageType.OutputUtf8, - streamId: 11, - offset: 42, - payload: new TextEncoder().encode("ok"), - }); + onBinaryMessage?.(new Uint8Array([TerminalStreamOpcode.Output, 0x6f, 0x6b])); expect(socket.sent).toHaveLength(2); const binaryPayload = asUint8Array(socket.sent[1]); expect(binaryPayload).not.toBeNull(); - const frame = decodeBinaryMuxFrame(binaryPayload!); + const frame = decodeTerminalStreamFrame(binaryPayload!); expect(frame).not.toBeNull(); - expect(frame!.channel).toBe(BinaryMuxChannel.Terminal); - expect(frame!.messageType).toBe(TerminalBinaryMessageType.OutputUtf8); - expect(frame!.streamId).toBe(11); - expect(frame!.offset).toBe(42); + expect(frame!.opcode).toBe(TerminalStreamOpcode.Output); expect(new TextDecoder().decode(frame!.payload ?? new Uint8Array())).toBe("ok"); await server.close(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 3b59480e6..241235627 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -18,7 +18,10 @@ import { type WSOutboundMessage, wrapSessionMessage, } from "./messages.js"; -import { asUint8Array, decodeBinaryMuxFrame, encodeBinaryMuxFrame } from "../shared/binary-mux.js"; +import { + asUint8Array, + decodeTerminalStreamFrame, +} from "../shared/terminal-stream-protocol.js"; import type { AllowedHostsConfig } from "./allowed-hosts.js"; import { isHostAllowed } from "./allowed-hosts.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; @@ -161,6 +164,7 @@ function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffe type WebSocketLike = { readyState: number; + bufferedAmount?: number; send: (data: string | Uint8Array | ArrayBuffer) => void; close: (code?: number, reason?: string) => void; on: (event: "message" | "close" | "error", listener: (...args: any[]) => void) => void; @@ -492,12 +496,12 @@ export class VoiceAssistantWebSocketServer { private sendBinaryToClient( ws: WebSocketLike, - frame: Parameters[0], + frame: Uint8Array, ): void { if (ws.readyState !== 1) { return; } - ws.send(encodeBinaryMuxFrame(frame)); + ws.send(frame); } private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void { @@ -508,7 +512,7 @@ export class VoiceAssistantWebSocketServer { private sendBinaryToConnection( connection: SessionConnection, - frame: Parameters[0], + frame: Uint8Array, ): void { for (const ws of connection.sockets) { this.sendBinaryToClient(ws, frame); @@ -595,6 +599,16 @@ export class VoiceAssistantWebSocketServer { } this.sendBinaryToConnection(connection, frame); }, + getBinaryBufferedAmount: () => { + if (!connection) { + return 0; + } + let bufferedAmount = 0; + for (const socket of connection.sockets) { + bufferedAmount = Math.max(bufferedAmount, socket.bufferedAmount ?? 0); + } + return bufferedAmount; + }, onLifecycleIntent: (intent) => { this.onLifecycleIntent?.(intent); }, @@ -900,7 +914,7 @@ export class VoiceAssistantWebSocketServer { const buffer = bufferFromWsData(data); const asBytes = asUint8Array(buffer); if (asBytes) { - const frame = decodeBinaryMuxFrame(asBytes); + const frame = decodeTerminalStreamFrame(asBytes); if (frame) { if (!activeConnection) { this.incrementRuntimeCounter("binaryBeforeHelloRejected"); diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index e1f3c1e29..e52fef018 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -297,27 +297,13 @@ describe("runAsyncWorktreeBootstrap", () => { send: () => { sendAt = Date.now(); }, - subscribe: () => () => {}, - onExit: () => () => {}, - subscribeRaw: (listener) => { - outputListener = (chunk) => - listener({ - data: chunk.data, - startOffset: 0, - endOffset: chunk.data.length, - replay: false, - }); - return { - unsubscribe: () => { - outputListener = null; - }, - replayedFrom: 0, - currentOffset: 0, - earliestAvailableOffset: 0, - reset: false, + subscribe: (listener) => { + outputListener = (chunk) => listener({ type: "output", data: chunk.data }); + return () => { + outputListener = null; }; }, - getOutputOffset: () => 0, + onExit: () => () => {}, getState: () => ({ rows: 0, cols: 0, @@ -398,18 +384,10 @@ describe("runAsyncWorktreeBootstrap", () => { send: () => {}, subscribe: () => () => {}, onExit: () => () => {}, - subscribeRaw: () => ({ - unsubscribe: () => {}, - replayedFrom: 0, - currentOffset: 0, - earliestAvailableOffset: 0, - reset: false, - }), - getOutputOffset: () => 1, getState: () => ({ - rows: 0, - cols: 0, - grid: [], + rows: 1, + cols: 1, + grid: [[{ char: "$" }]], scrollback: [], cursor: { row: 0, col: 0 }, }), diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 7600b78ff..afeec0c29 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -383,9 +383,9 @@ function buildTerminalTimelineItem(input: { } async function waitForTerminalBootstrapReadiness( - terminal: Pick, + terminal: Pick, ): Promise { - if (terminal.getOutputOffset() > 0) { + if (terminalHasOutput(terminal.getState())) { return; } @@ -410,12 +410,14 @@ async function waitForTerminalBootstrapReadiness( resolve(); }; - const rawSubscription = terminal.subscribeRaw(() => { + unsubscribe = terminal.subscribe((message) => { + if (message.type !== "output") { + return; + } finish(); }); - unsubscribe = rawSubscription.unsubscribe; - if (terminal.getOutputOffset() > 0) { + if (terminalHasOutput(terminal.getState())) { finish(); return; } @@ -424,6 +426,17 @@ async function waitForTerminalBootstrapReadiness( }); } +function terminalHasOutput(state: ReturnType): boolean { + for (const row of [...state.scrollback, ...state.grid]) { + for (const cell of row) { + if (cell.char.trim().length > 0) { + return true; + } + } + } + return false; +} + async function runWorktreeTerminalBootstrap( options: RunAsyncWorktreeBootstrapOptions, runtimeEnv: WorktreeRuntimeEnv, diff --git a/packages/server/src/shared/binary-mux.test.ts b/packages/server/src/shared/binary-mux.test.ts deleted file mode 100644 index a246639bd..000000000 --- a/packages/server/src/shared/binary-mux.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - BinaryMuxChannel, - TerminalBinaryFlags, - TerminalBinaryMessageType, - asUint8Array, - decodeBinaryMuxFrame, - encodeBinaryMuxFrame, -} from "./binary-mux.js"; - -describe("binary mux frame codec", () => { - it("encodes and decodes round trip", () => { - const payload = new TextEncoder().encode("hello"); - const encoded = encodeBinaryMuxFrame({ - channel: BinaryMuxChannel.Terminal, - messageType: TerminalBinaryMessageType.OutputUtf8, - streamId: 42, - offset: 1234, - flags: TerminalBinaryFlags.Replay, - payload, - }); - - const decoded = decodeBinaryMuxFrame(encoded); - expect(decoded).not.toBeNull(); - expect(decoded!.channel).toBe(BinaryMuxChannel.Terminal); - expect(decoded!.messageType).toBe(TerminalBinaryMessageType.OutputUtf8); - expect(decoded!.streamId).toBe(42); - expect(decoded!.offset).toBe(1234); - expect(decoded!.flags).toBe(TerminalBinaryFlags.Replay); - expect(Array.from(decoded!.payload ?? [])).toEqual(Array.from(payload)); - }); - - it("rejects malformed frame payload length", () => { - const encoded = encodeBinaryMuxFrame({ - channel: BinaryMuxChannel.Terminal, - messageType: TerminalBinaryMessageType.InputUtf8, - streamId: 1, - offset: 0, - payload: new Uint8Array([1, 2, 3]), - }); - const tampered = encoded.slice(0, encoded.byteLength - 1); - expect(decodeBinaryMuxFrame(tampered)).toBeNull(); - }); - - it("converts UTF-8 string payloads to bytes", () => { - const bytes = asUint8Array("hello"); - expect(bytes).not.toBeNull(); - expect(Array.from(bytes ?? [])).toEqual(Array.from(new TextEncoder().encode("hello"))); - }); -}); diff --git a/packages/server/src/shared/binary-mux.ts b/packages/server/src/shared/binary-mux.ts deleted file mode 100644 index 6b52c03c2..000000000 --- a/packages/server/src/shared/binary-mux.ts +++ /dev/null @@ -1,133 +0,0 @@ -const MUX_MAGIC_1 = 0x50; // 'P' -const MUX_MAGIC_2 = 0x58; // 'X' -const MUX_VERSION = 1; -const HEADER_SIZE = 24; -const UINT32_MAX = 0xffffffff; -const TWO_POW_32 = 0x1_0000_0000; - -export const BinaryMuxConstants = { - magic1: MUX_MAGIC_1, - magic2: MUX_MAGIC_2, - version: MUX_VERSION, - headerSize: HEADER_SIZE, -} as const; - -export const enum BinaryMuxChannel { - Terminal = 1, - FileTransfer = 2, -} - -export const enum TerminalBinaryMessageType { - InputUtf8 = 1, - OutputUtf8 = 2, - Ack = 3, -} - -export const enum TerminalBinaryFlags { - Replay = 1, -} - -export interface BinaryMuxFrame { - channel: number; - messageType: number; - streamId: number; - offset: number; - flags?: number; - payload?: Uint8Array; -} - -export function asUint8Array(data: unknown): Uint8Array | null { - if (typeof data === "string") { - if (typeof TextEncoder !== "undefined") { - return new TextEncoder().encode(data); - } - if (typeof Buffer !== "undefined") { - return new Uint8Array(Buffer.from(data, "utf8")); - } - const out = new Uint8Array(data.length); - for (let i = 0; i < data.length; i += 1) { - out[i] = data.charCodeAt(i) & 0xff; - } - return out; - } - if (data instanceof Uint8Array) { - return data; - } - if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) { - return new Uint8Array(data); - } - if (ArrayBuffer.isView(data)) { - return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - } - if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) { - return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - } - return null; -} - -export function isLikelyBinaryMuxFrame(data: Uint8Array): boolean { - if (data.byteLength < HEADER_SIZE) { - return false; - } - return data[0] === MUX_MAGIC_1 && data[1] === MUX_MAGIC_2 && data[2] === MUX_VERSION; -} - -export function encodeBinaryMuxFrame(frame: BinaryMuxFrame): Uint8Array { - const payload = frame.payload ?? new Uint8Array(0); - const out = new Uint8Array(HEADER_SIZE + payload.byteLength); - const view = new DataView(out.buffer, out.byteOffset, out.byteLength); - const flags = frame.flags ?? 0; - - if (!Number.isFinite(frame.streamId) || frame.streamId < 0 || frame.streamId > UINT32_MAX) { - throw new Error(`Invalid streamId: ${frame.streamId}`); - } - if (!Number.isFinite(frame.offset) || frame.offset < 0) { - throw new Error(`Invalid offset: ${frame.offset}`); - } - - const offsetHi = Math.floor(frame.offset / TWO_POW_32); - const offsetLo = frame.offset >>> 0; - if (offsetHi > UINT32_MAX) { - throw new Error(`Offset too large: ${frame.offset}`); - } - - view.setUint8(0, MUX_MAGIC_1); - view.setUint8(1, MUX_MAGIC_2); - view.setUint8(2, MUX_VERSION); - view.setUint8(3, frame.channel & 0xff); - view.setUint8(4, frame.messageType & 0xff); - view.setUint8(5, flags & 0xff); - view.setUint8(6, 0); - view.setUint8(7, 0); - view.setUint32(8, frame.streamId >>> 0); - view.setUint32(12, offsetHi >>> 0); - view.setUint32(16, offsetLo); - view.setUint32(20, payload.byteLength >>> 0); - - out.set(payload, HEADER_SIZE); - return out; -} - -export function decodeBinaryMuxFrame(data: Uint8Array): BinaryMuxFrame | null { - if (!isLikelyBinaryMuxFrame(data)) { - return null; - } - const view = new DataView(data.buffer, data.byteOffset, data.byteLength); - const payloadLength = view.getUint32(20); - if (payloadLength !== data.byteLength - HEADER_SIZE) { - return null; - } - - const offsetHi = view.getUint32(12); - const offsetLo = view.getUint32(16); - const offset = offsetHi * TWO_POW_32 + offsetLo; - - return { - channel: view.getUint8(3), - messageType: view.getUint8(4), - flags: view.getUint8(5), - streamId: view.getUint32(8), - offset, - payload: data.subarray(HEADER_SIZE), - }; -} diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 27d32a3b1..39a248f10 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1139,21 +1139,6 @@ export const KillTerminalRequestSchema = z.object({ requestId: z.string(), }); -export const AttachTerminalStreamRequestSchema = z.object({ - type: z.literal("attach_terminal_stream_request"), - terminalId: z.string(), - resumeOffset: z.number().int().nonnegative().optional(), - rows: z.number().int().positive().optional(), - cols: z.number().int().positive().optional(), - requestId: z.string(), -}); - -export const DetachTerminalStreamRequestSchema = z.object({ - type: z.literal("detach_terminal_stream_request"), - streamId: z.number().int().nonnegative(), - requestId: z.string(), -}); - export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ VoiceAudioChunkMessageSchema, AbortRequestMessageSchema, @@ -1219,8 +1204,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ UnsubscribeTerminalRequestSchema, TerminalInputSchema, KillTerminalRequestSchema, - AttachTerminalStreamRequestSchema, - DetachTerminalStreamRequestSchema, ]); export type SessionInboundMessage = z.infer; @@ -2112,7 +2095,7 @@ const TerminalInfoSchema = z.object({ cwd: z.string(), }); -const TerminalCellSchema = z.object({ +export const TerminalCellSchema = z.object({ char: z.string(), fg: z.number().optional(), bg: z.number().optional(), @@ -2123,7 +2106,7 @@ const TerminalCellSchema = z.object({ underline: z.boolean().optional(), }); -const TerminalStateSchema = z.object({ +export const TerminalStateSchema = z.object({ rows: z.number(), cols: z.number(), grid: z.array(z.array(TerminalCellSchema)), @@ -2167,14 +2150,6 @@ export const SubscribeTerminalResponseSchema = z.object({ }), }); -export const TerminalOutputSchema = z.object({ - type: z.literal("terminal_output"), - payload: z.object({ - terminalId: z.string(), - state: TerminalStateSchema, - }), -}); - export const KillTerminalResponseSchema = z.object({ type: z.literal("kill_terminal_response"), payload: z.object({ @@ -2184,33 +2159,9 @@ export const KillTerminalResponseSchema = z.object({ }), }); -export const AttachTerminalStreamResponseSchema = z.object({ - type: z.literal("attach_terminal_stream_response"), - payload: z.object({ - terminalId: z.string(), - streamId: z.number().int().nonnegative().nullable(), - replayedFrom: z.number().int().nonnegative(), - currentOffset: z.number().int().nonnegative(), - earliestAvailableOffset: z.number().int().nonnegative(), - reset: z.boolean(), - error: z.string().nullable(), - requestId: z.string(), - }), -}); - -export const DetachTerminalStreamResponseSchema = z.object({ - type: z.literal("detach_terminal_stream_response"), - payload: z.object({ - streamId: z.number().int().nonnegative(), - success: z.boolean(), - requestId: z.string(), - }), -}); - export const TerminalStreamExitSchema = z.object({ type: z.literal("terminal_stream_exit"), payload: z.object({ - streamId: z.number().int().nonnegative(), terminalId: z.string(), }), }); @@ -2278,10 +2229,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ TerminalsChangedSchema, CreateTerminalResponseSchema, SubscribeTerminalResponseSchema, - TerminalOutputSchema, KillTerminalResponseSchema, - AttachTerminalStreamResponseSchema, - DetachTerminalStreamResponseSchema, TerminalStreamExitSchema, ]); @@ -2415,13 +2363,10 @@ export type SubscribeTerminalRequest = z.infer; export type UnsubscribeTerminalRequest = z.infer; export type TerminalInput = z.infer; -export type TerminalOutput = z.infer; +export type TerminalCell = z.infer; +export type TerminalState = z.infer; export type KillTerminalRequest = z.infer; export type KillTerminalResponse = z.infer; -export type AttachTerminalStreamRequest = z.infer; -export type AttachTerminalStreamResponse = z.infer; -export type DetachTerminalStreamRequest = z.infer; -export type DetachTerminalStreamResponse = z.infer; export type TerminalStreamExit = z.infer; // ============================================================================ diff --git a/packages/server/src/shared/terminal-stream-protocol.test.ts b/packages/server/src/shared/terminal-stream-protocol.test.ts new file mode 100644 index 000000000..89db47595 --- /dev/null +++ b/packages/server/src/shared/terminal-stream-protocol.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { + TerminalStreamOpcode, + decodeTerminalResizePayload, + decodeTerminalSnapshotPayload, + decodeTerminalStreamFrame, + encodeTerminalResizePayload, + encodeTerminalSnapshotPayload, + encodeTerminalStreamFrame, +} from "./terminal-stream-protocol.js"; + +describe("terminal stream protocol", () => { + it("encodes output frames as a 1-byte opcode prefix plus payload", () => { + const payload = new TextEncoder().encode("hello"); + const encoded = encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + payload, + }); + + expect(encoded[0]).toBe(TerminalStreamOpcode.Output); + expect(Array.from(encoded.subarray(1))).toEqual(Array.from(payload)); + + const decoded = decodeTerminalStreamFrame(encoded); + expect(decoded).toEqual({ + opcode: TerminalStreamOpcode.Output, + payload, + }); + }); + + it("round-trips resize payloads", () => { + const payload = encodeTerminalResizePayload({ + rows: 24, + cols: 80, + }); + + expect(decodeTerminalResizePayload(payload)).toEqual({ + rows: 24, + cols: 80, + }); + }); + + it("round-trips snapshot payloads", () => { + const state = { + rows: 1, + cols: 2, + grid: [[{ char: "A" }, { char: "B" }]], + scrollback: [], + cursor: { row: 0, col: 2 }, + }; + + const payload = encodeTerminalSnapshotPayload(state); + expect(decodeTerminalSnapshotPayload(payload)).toEqual(state); + }); + + it("rejects unknown opcodes", () => { + expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01]))).toBeNull(); + }); +}); diff --git a/packages/server/src/shared/terminal-stream-protocol.ts b/packages/server/src/shared/terminal-stream-protocol.ts new file mode 100644 index 000000000..2c5c3ec51 --- /dev/null +++ b/packages/server/src/shared/terminal-stream-protocol.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; +import { TerminalStateSchema } from "./messages.js"; + +export const TerminalStreamResizeSchema = z.object({ + rows: z.number().int().positive(), + cols: z.number().int().positive(), +}); + +export const TerminalStreamOpcode = { + Output: 0x01, + Input: 0x02, + Resize: 0x03, + Snapshot: 0x04, +} as const; + +export type TerminalStreamOpcode = + (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode]; + +export type TerminalStreamFrame = { + opcode: TerminalStreamOpcode; + payload: Uint8Array; +}; + +export function asUint8Array(data: unknown): Uint8Array | null { + if (typeof data === "string") { + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(data); + } + if (typeof Buffer !== "undefined") { + return new Uint8Array(Buffer.from(data, "utf8")); + } + const out = new Uint8Array(data.length); + for (let index = 0; index < data.length; index += 1) { + out[index] = data.charCodeAt(index) & 0xff; + } + return out; + } + if (data instanceof Uint8Array) { + return data; + } + if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + return null; +} + +function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { + return ( + value === TerminalStreamOpcode.Output || + value === TerminalStreamOpcode.Input || + value === TerminalStreamOpcode.Resize || + value === TerminalStreamOpcode.Snapshot + ); +} + +export function encodeTerminalStreamFrame(input: { + opcode: TerminalStreamOpcode; + payload?: Uint8Array | ArrayBuffer | string; +}): Uint8Array { + const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0); + const bytes = new Uint8Array(1 + payload.byteLength); + bytes[0] = input.opcode; + bytes.set(payload, 1); + return bytes; +} + +export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null { + if (bytes.byteLength < 1) { + return null; + } + const opcode = bytes[0]; + if (!isTerminalStreamOpcode(opcode)) { + return null; + } + return { + opcode, + payload: bytes.subarray(1), + }; +} + +export function encodeTerminalSnapshotPayload(state: z.infer): Uint8Array { + return encodeJsonPayload(state); +} + +export function decodeTerminalSnapshotPayload( + bytes: Uint8Array, +): z.infer | null { + const parsed = decodeJsonPayload(bytes); + const result = TerminalStateSchema.safeParse(parsed); + return result.success ? result.data : null; +} + +export function encodeTerminalResizePayload( + input: z.infer, +): Uint8Array { + return encodeJsonPayload(input); +} + +export function decodeTerminalResizePayload( + bytes: Uint8Array, +): z.infer | null { + const parsed = decodeJsonPayload(bytes); + const result = TerminalStreamResizeSchema.safeParse(parsed); + return result.success ? result.data : null; +} + +function encodeJsonPayload(value: unknown): Uint8Array { + return new TextEncoder().encode(JSON.stringify(value)); +} + +function decodeJsonPayload(bytes: Uint8Array): unknown { + const text = new TextDecoder().decode(bytes); + return JSON.parse(text); +} diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 069541979..04926991a 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -388,7 +388,7 @@ describe("Terminal", () => { }); describe("subscribe", () => { - it("receives full state on initial subscription", async () => { + it("receives a snapshot on initial subscription", async () => { const session = trackSession( await createTerminal({ cwd: "/tmp", @@ -405,12 +405,12 @@ describe("Terminal", () => { await new Promise((resolve) => setTimeout(resolve, 100)); expect(messages.length).toBeGreaterThan(0); - expect(messages[0].type).toBe("full"); + expect(messages[0].type).toBe("snapshot"); unsubscribe(); }); - it("receives messages on updates", async () => { + it("receives output and snapshot messages on updates", async () => { const session = trackSession( await createTerminal({ cwd: "/tmp", @@ -432,7 +432,8 @@ describe("Terminal", () => { await waitForLines(session, ["$ echo test", "test", "$"]); - expect(messages.length).toBeGreaterThan(0); + expect(messages.some((message) => message.type === "output")).toBe(true); + expect(messages.some((message) => message.type === "snapshot")).toBe(true); unsubscribe(); }); @@ -463,8 +464,8 @@ describe("Terminal", () => { }); }); - describe("raw output stream", () => { - it("streams raw output chunks with byte offsets", async () => { + describe("stream snapshots", () => { + it("streams raw output messages without replay metadata", async () => { const session = trackSession( await createTerminal({ cwd: "/tmp", @@ -475,28 +476,23 @@ describe("Terminal", () => { await waitForLines(session, ["$"]); - const chunks: Array<{ data: string; startOffset: number; endOffset: number }> = []; - const sub = session.subscribeRaw((chunk) => { - chunks.push({ - data: chunk.data, - startOffset: chunk.startOffset, - endOffset: chunk.endOffset, - }); + const outputMessages: string[] = []; + const unsubscribe = session.subscribe((message) => { + if (message.type !== "output") { + return; + } + outputMessages.push(message.data); }); session.send({ type: "input", data: "echo raw-stream\r" }); await waitForLines(session, ["$ echo raw-stream", "raw-stream", "$"]); - expect(chunks.length).toBeGreaterThan(0); - expect(chunks[chunks.length - 1].endOffset).toBe(session.getOutputOffset()); - for (let i = 1; i < chunks.length; i++) { - expect(chunks[i].startOffset).toBe(chunks[i - 1].endOffset); - } + expect(outputMessages.join("")).toContain("raw-stream"); - sub.unsubscribe(); + unsubscribe(); }); - it("replays buffered output from offset", async () => { + it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => { const session = trackSession( await createTerminal({ cwd: "/tmp", @@ -507,21 +503,9 @@ describe("Terminal", () => { await waitForLines(session, ["$"]); - const seen: Array<{ data: string; endOffset: number; replay: boolean }> = []; - const firstSub = session.subscribeRaw((chunk) => { - seen.push({ - data: chunk.data, - endOffset: chunk.endOffset, - replay: chunk.replay, - }); - }); - session.send({ type: "input", data: "echo before-detach\r" }); await waitForLines(session, ["$ echo before-detach", "before-detach", "$"]); - const resumeOffset = seen[seen.length - 1]?.endOffset ?? 0; - firstSub.unsubscribe(); - session.send({ type: "input", data: "echo after-detach\r" }); await waitForLines(session, [ "$ echo before-detach", @@ -531,20 +515,26 @@ describe("Terminal", () => { "$", ]); - const replayed: string[] = []; - const secondSub = session.subscribeRaw( - (chunk) => { - if (chunk.replay) { - replayed.push(chunk.data); - } - }, - { fromOffset: resumeOffset }, - ); + let snapshotText = ""; + const unsubscribe = session.subscribe((message) => { + if (message.type !== "snapshot") { + return; + } + snapshotText = [...message.state.scrollback, ...message.state.grid] + .map((row) => + row + .map((cell) => cell.char) + .join("") + .trimEnd(), + ) + .join("\n"); + }); - const replayText = replayed.join(""); - expect(replayText).toContain("after-detach"); - expect(secondSub.replayedFrom).toBeGreaterThanOrEqual(resumeOffset); - secondSub.unsubscribe(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(snapshotText).toContain("before-detach"); + expect(snapshotText).toContain("after-detach"); + unsubscribe(); }); }); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index d028cf457..aa5cfaf53 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -34,35 +34,14 @@ export interface TerminalState { cursor: Pos; } -export interface CellChange { - row: number; - col: number; - cell: Cell; -} - -export interface TerminalRawChunk { - data: string; - startOffset: number; - endOffset: number; - replay: boolean; -} - -export interface TerminalRawSubscriptionResult { - unsubscribe: () => void; - replayedFrom: number; - currentOffset: number; - earliestAvailableOffset: number; - reset: boolean; -} - export type ClientMessage = | { type: "input"; data: string } | { type: "resize"; rows: number; cols: number } | { type: "mouse"; row: number; col: number; button: number; action: "down" | "up" | "move" }; export type ServerMessage = - | { type: "full"; state: TerminalState } - | { type: "diff"; changes: CellChange[]; cursor: Pos }; + | { type: "output"; data: string } + | { type: "snapshot"; state: TerminalState }; export interface TerminalSession { id: string; @@ -71,11 +50,6 @@ export interface TerminalSession { send(msg: ClientMessage): void; subscribe(listener: (msg: ServerMessage) => void): () => void; onExit(listener: () => void): () => void; - subscribeRaw( - listener: (chunk: TerminalRawChunk) => void, - options?: { fromOffset?: number }, - ): TerminalRawSubscriptionResult; - getOutputOffset(): number; getState(): TerminalState; kill(): void; } @@ -263,16 +237,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void>(); - const rawListeners = new Set<(chunk: TerminalRawChunk) => void>(); const exitListeners = new Set<() => void>(); - const rawOutputChunks: Array<{ - startOffset: number; - endOffset: number; - data: string; - }> = []; - const maxRawBufferBytes = 8 * 1024 * 1024; - let rawBufferBytes = 0; - let outputOffset = 0; let killed = false; let disposed = false; let exitEmitted = false; @@ -329,7 +294,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise maxRawBufferBytes && rawOutputChunks.length > 0) { - const removed = rawOutputChunks.shift(); - if (!removed) { - break; - } - rawBufferBytes -= removed.endOffset - removed.startOffset; - } - - const chunk: TerminalRawChunk = { - data, - startOffset, - endOffset, - replay: false, - }; - for (const listener of rawListeners) { - listener(chunk); - } - } - - function getEarliestAvailableOffset(): number { - const earliest = rawOutputChunks[0]; - return earliest ? earliest.startOffset : outputOffset; - } - - function alignOffsetToChunkBoundary(offset: number): number { - if (offset === outputOffset) { - return offset; - } - for (const chunk of rawOutputChunks) { - if (offset === chunk.startOffset || offset === chunk.endOffset) { - return offset; - } - if (offset > chunk.startOffset && offset < chunk.endOffset) { - return chunk.startOffset; - } - } - return offset; - } - // Pipe PTY output to terminal emulator ptyProcess.onData((data) => { if (killed) return; - appendRawOutputChunk(data); + for (const listener of listeners) { + listener({ type: "output", data }); + } terminal.write(data, () => { scheduleStateBroadcast(); }); @@ -460,10 +370,9 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void): () => void { listeners.add(listener); - // Send initial full state queueMicrotask(() => { if (listeners.has(listener)) { - listener({ type: "full", state: getState() }); + listener({ type: "snapshot", state: getState() }); } }); @@ -490,45 +399,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void, - options?: { fromOffset?: number }, - ): TerminalRawSubscriptionResult { - const requestedOffset = Math.max(0, Math.floor(options?.fromOffset ?? 0)); - const earliestAvailableOffset = getEarliestAvailableOffset(); - const clampedOffset = Math.max(requestedOffset, earliestAvailableOffset); - const replayedFrom = alignOffsetToChunkBoundary(clampedOffset); - const reset = replayedFrom !== requestedOffset; - - for (const chunk of rawOutputChunks) { - if (chunk.endOffset <= replayedFrom) { - continue; - } - listener({ - data: chunk.data, - startOffset: chunk.startOffset, - endOffset: chunk.endOffset, - replay: true, - }); - } - - rawListeners.add(listener); - - return { - unsubscribe: () => { - rawListeners.delete(listener); - }, - replayedFrom, - currentOffset: outputOffset, - earliestAvailableOffset, - reset, - }; - } - - function getOutputOffset(): number { - return outputOffset; - } - function kill(): void { if (!killed) { killed = true; @@ -548,8 +418,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise