Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 88f644b53b docs(terminal): state the minimum ANSI replay app version
Document the intentional JSON-era app compatibility cut and its missing restored screen/history behavior, verified against the v0.1.80 and v0.1.81 decoder and request paths.
2026-10-30 12:28:32 +01:00
Mohamed Boudra 5f43ce0bae fix(terminal): preserve current modes and default ANSI restores
Keep capture-time input modes local to the restoring viewer so replay completion cannot rewind newer shared modes. Publish initial modes after terminal creation and request explicit full restores for default SDK observations on ANSI hosts.

Derive worker test ownership from terminal records and apply the short replay deadline only after program setup.
2026-10-30 12:15:51 +01:00
Mohamed Boudra f5830143f0 fix(terminal): project replay metadata onto the wire contract
Keep worker revision and input-mode preamble internal while preserving the required retained-history metadata delivered to clients.
2026-10-30 11:45:21 +01:00
Mohamed Boudra 7cef8ef43b fix(app): run isolated daemon cleanup outside the test loader
Importing the server lifecycle into the browser test runner exposes its
transitive plugin exports to the runner's transforming loader. Use the
supported CLI in ordinary Node so confirmed shutdown and terminal reaping
complete before removing the owned home.
2026-10-30 11:31:30 +01:00
Mohamed Boudra 4c2bd3527f fix(terminal): preserve replay across slow delivery and resizing
Keep app network delivery outside worker capture deadlines and hold captured geometry until parsing commits. Reconnect readiness is independent of the capture revision used for live-output deduplication.

Remove redundant replay acknowledgements and hard cut the unreleased worker contract at protocol 5 while preserving bounded transports.
2026-10-30 11:28:22 +01:00
Mohamed Boudra bdef9e9fbb fix(app): stop isolated daemon writers before removing test homes
Waiting only for the supervisor lets a surviving daemon worker recreate
its deleted home during teardown. Reuse daemon lifecycle shutdown and
terminal reaping before removing owned state, including forced cleanup.
2026-10-30 10:59:40 +01:00
Mohamed Boudra d14619e41a fix(terminal): recover paced replay and viewer geometry
Catch-up must drain through its paced sink rather than reject on the congestion that requested it. Restore measured renderer geometry after replay commits and before live writes. Require an ANSI-capable host explicitly and remove the retired snapshot entry points.
2026-10-30 10:58:50 +01:00
Mohamed Boudra fe3d65ffea fix(terminal): isolate replay failures to each viewer
A terminal-wide replay tail paused healthy viewers and discarded their live output when another viewer failed or timed out. Keep buffering and revision deduplication with each daemon stream, and pin acknowledgements to their original worker connection.
2026-10-30 10:20:25 +01:00
Mohamed Boudra bfe2b10cef feat(terminal): stream negotiated ANSI replay to clients
Restore configured client history up to host retention without collecting capable replay in the daemon. Preserve single-frame ANSI delivery for older clients and normalize older hosts at the SDK boundary.
2026-10-30 09:56:54 +01:00
Mohamed Boudra 9ba63a30fa refactor(server): stream terminal replay from the worker
Move ANSI generation and replay/live ordering to the worker while preserving
existing ANSI clients through the daemon's single-frame restore adapter.
Keep 1,000 rows of retention and hard cut the unreleased cell transport.

Pace replay writes so a healthy large restore cannot fill the shared socket.
If the bounded live tail overflows, end every viewer of that terminal with
an explicit error; a fresh attach restores current state and the PTY survives.

Credit Manav Dhindsa (DrakenKor), #6139, and Lennart Schoch, #6743, for the
prior worker-side ANSI and scrollback work informing this change.
2026-10-30 09:17:42 +01:00
75 changed files with 2895 additions and 1582 deletions
+14
View File
@@ -52,6 +52,20 @@ On capable daemons, connecting creates no timeline or event demand. Client subsc
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
without requesting streams. Application caches and which agents are visible remain caller-owned.
## Terminal ANSI replay
Terminal replay intentionally drops JSON-era apps, an exception to the general cross-version policy:
use app v0.1.81 or newer. Earlier apps omit `restore` and reject ANSI Restore (`0x05`), so they show
only subsequent live output, without the existing screen or history.
`terminal_chunked_replay` is SDK-owned delivery behavior. The daemon sends new binary opcodes only to a source
advertising the capability. The SDK keeps the established subscribe request against older
ANSI hosts and normalizes their single Restore frame into begin/data/end. `observeTerminal` defaults
to an explicit full snapshot: omitting restore on an ANSI-era host selects its discarded JSON path.
Explicit caller restore modes and history counts pass through unchanged. JSON snapshot
wire declarations remain accepted, but JSON snapshot frames have no rendering runtime.
Terminals require `terminal-restore-modes`; older hosts show an update message without attaching.
## Owned observations
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
+8 -5
View File
@@ -6,9 +6,9 @@ How terminal output stays low-latency, what the invariants are, and how to measu
```
pty (node-pty, forked worker process)
→ headless xterm parse (worker, snapshot fidelity)
→ TerminalOutputCoalescer (worker, ≤1 IPC message per 5ms per terminal)
→ process.send IPC → daemon main process
→ headless xterm parse (worker, ANSI replay fidelity)
→ TerminalOutputCoalescer (worker, ≤1 socket frame per 5ms per terminal)
→ framed local socket → daemon main process
→ TerminalOutputCoalescer (per client stream, terminal-session-controller.ts)
→ binary ws frame (2-byte header + raw bytes)
→ client decode (daemon-client.ts) → stream router → emulator runtime
@@ -19,10 +19,13 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
## Invariants (the easy-to-break ones)
- **Replay is incremental across the daemon and SDK.** The worker retains 1,000 history rows. Capable clients request their configured history; the worker bounds it by retained history. Older apps retain their own 200-row request. Explicit SDK history demand is unchanged against older ANSI hosts, which apply their existing 500-row ceiling. Begin resets once, chunks keep a streaming UTF-8 decoder/parser, and each renderer suppresses generated replies while parsing replay. Never concatenate capable replay chunks to feed the renderer.
- **Coalescers are leading+trailing throttles.** The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo.
- **Output coalescing happens in the worker, before IPC.** One `process.send` per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
- **Output coalescing happens in the worker, before IPC.** One socket frame per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
- **Replay delivery is isolated per viewer.** Live worker output never waits for app delivery. Each daemon stream owns its bounded live tail and revision deduplication; a slow or failed replay cannot pause another viewer of the same terminal.
- **Replay geometry belongs to the parser queue.** Native measurements and web fits must leave the captured dimensions intact through every chunk's commit. Apply the latest measured size after replay, before live bytes; clear, replacement and disposal must release that geometry ownership. A layout effect must not resize the renderer directly during replay.
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches current modes from ordered worker events and adoption. A replay response carries capture-time modes for its viewer; it must not overwrite the shared cache after newer live output. Apply that viewer's preamble before its buffered live bytes. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
+11 -1
View File
@@ -40,10 +40,20 @@ The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. When you bump the version, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 5 hard cuts unreleased protocols 1 through 4, including the acknowledgement-based replay contract: those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only.
## Display replay
The worker keeps the headless xterm model and generates ANSI. A replay request emits begin metadata (dimensions, retained history, revision and input modes), at most 64 KiB per data chunk, and end. Live output keeps flowing independently during replay. Capture is synchronous with PTY callbacks; flush the output coalescer before the cut so a later batch never includes bytes already in replay. Retained-history metadata counts the worker history before the request limit.
The worker paces replay writes on its local socket; the daemon synchronously queues each event into the application's bounded transport. App network delivery does not extend the worker's request deadline. There is no per-chunk acknowledgement or second replay delivery queue in the daemon. The physical application transport owns its 64 MiB high-water close, so a backed-up app cannot hold worker capture open. Each daemon viewer still holds at most 4 MiB of live output during capture, then drops revisions already present in the worker replay. Capture failure and tail overflow affect only that viewer; PTYs and healthy viewers keep running.
Live traffic that fills the shared worker socket beyond 4 MiB disconnects the stalled controller. Reconnection adopts the surviving sessions and requests fresh replay for existing streams. Readiness does not require a revision: adoption announces readiness without one, while the replay capture supplies the revision used for deduplication.
The SDK advertises `terminal_chunked_replay` only because it decodes begin/data/end. Capable clients receive these events directly; the daemon collects one Restore frame only for older ANSI clients. The SDK normalizes an older host's single Restore into the same lifecycle. Capture stays a worker request; bootstrap readiness uses `hasOutput`, not a transported grid.
## Running from its own copy
A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's `spawn-helper` and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install:
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, writeFile } from "node:fs/promises";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { expect, test, daemonTest as cleanupTest } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
openDaemonOverview,
@@ -13,6 +14,10 @@ import {
readWorkerPid,
openLongLivedTerminal,
listTerminalIds,
suspendDaemonProcess,
resumeDaemonProcess,
daemonProcessCanWrite,
readTerminalProcessIds,
} from "../support/helpers/daemon-lifecycle";
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
@@ -119,3 +124,51 @@ async function createHomeWithSlowProvider(versionDelayMs: number): Promise<strin
);
return paseoHome;
}
// Cleanup has no renderer demand: drive the same isolated daemon owner directly.
// A stopped worker deterministically outlives the old helper's five-second
// supervisor-only kill and logs into its deleted home when resumed.
cleanupTest(
"isolated cleanup prevents a stopped worker from recreating its deleted home",
async () => {
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
const daemon = await startIsolatedHostDaemon(randomUUID());
const workerPid = await readWorkerPid(daemon);
try {
suspendDaemonProcess(workerPid);
await daemon.close();
resumeDaemonProcess(workerPid);
await expect.poll(() => daemonProcessCanWrite(workerPid)).toBe(false);
expect(existsSync(daemon.paseoHome)).toBe(false);
} finally {
resumeDaemonProcess(workerPid);
await daemon.close();
await rm(daemon.paseoHome, { recursive: true, force: true });
}
},
);
cleanupTest(
"isolated cleanup force-stops a frozen supervisor and its detached terminal writers",
async () => {
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
const daemon = await startIsolatedHostDaemon(randomUUID());
const supervisorPid = daemon.getPid()!;
const workerPid = await readWorkerPid(daemon);
try {
await openLongLivedTerminal(daemon);
const terminalProcesses = await readTerminalProcessIds(daemon);
expect(terminalProcesses.length).toBeGreaterThanOrEqual(2);
suspendDaemonProcess(supervisorPid);
await daemon.close();
const writerStates = await Promise.all(
[supervisorPid, workerPid, ...terminalProcesses].map(daemonProcessCanWrite),
);
expect(writerStates).toEqual(Array(terminalProcesses.length + 2).fill(false));
expect(existsSync(daemon.paseoHome)).toBe(false);
} finally {
resumeDaemonProcess(supervisorPid);
await daemon.close();
}
},
);
@@ -1,5 +1,6 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
interface AttachOverlayProbeWindow extends Window {
@@ -59,4 +60,34 @@ test.describe("retained terminal tab streams", () => {
expect(await terminalAttachOverlayWasSeen(page)).toBe(false);
});
for (const { count, retainedMarker } of [
{ count: 400, retainedMarker: "history-0001界🙂" },
{ count: 3000, retainedMarker: "history-2500界🙂" },
]) {
test(`reattach restores retained styled Unicode history from ${count} lines`, async ({
page,
}) => {
await page.addInitScript(() => {
const key = "@paseo:app-settings";
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
localStorage.setItem(key, JSON.stringify({ ...settings, terminalScrollbackLines: 10000 }));
});
const terminal = await harness.createTerminal({
name: `history-${count}`,
command: "/bin/sh",
args: [
"-lc",
`i=1; while [ $i -le ${count} ]; do printf '\\033[31mhistory-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 120`,
],
});
const last = `history-${String(count).padStart(4, "0")}界🙂`;
await harness.openTerminal(page, { terminalId: terminal.id });
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
expect(await getTerminalBufferText(page)).toContain(retainedMarker);
await page.reload();
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
expect(await getTerminalBufferText(page)).toContain(retainedMarker);
});
}
});
@@ -1,5 +1,5 @@
import { execFile, spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { writeFile, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { expect, type Page } from "@playwright/test";
@@ -154,3 +154,42 @@ export async function plantUnclosableTerminalProgram(
throw error;
}
}
/** Fault injection and liveness checks for the isolated daemon cleanup journey. */
export function suspendDaemonProcess(pid: number): void {
process.kill(pid, "SIGSTOP");
}
export function resumeDaemonProcess(pid: number): void {
try {
process.kill(pid, "SIGCONT");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
}
}
export async function daemonProcessCanWrite(pid: number): Promise<boolean> {
try {
const state = await psField(pid, "stat");
return state.length > 0 && !state.startsWith("Z");
} catch (error) {
if ((error as { code?: unknown }).code === 1) return false;
throw error;
}
}
export async function readTerminalProcessIds(daemon: IsolatedHostDaemon): Promise<number[]> {
const directory = path.join(daemon.paseoHome, "terminal-worker");
const records = await Promise.all(
(await readdir(directory))
.filter((name) => name.endsWith(".json"))
.map(async (name) => {
const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as {
pid: number;
terminals: Array<{ pid: number }>;
};
return [record.pid].concat(record.terminals.map((terminal) => terminal.pid));
}),
);
return records.flat();
}
@@ -1,4 +1,11 @@
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
import { promisify } from "node:util";
import {
spawn,
execFile,
execFileSync,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
@@ -6,6 +13,8 @@ import path from "node:path";
import { withDisabledE2ESpeechEnv } from "./speech-env";
import { killProcessTree, spawnTsx } from "./spawn-node";
const execFileAsync = promisify(execFile);
export interface IsolatedHostDaemon {
serverId: string;
port: number;
@@ -152,6 +161,24 @@ export async function startIsolatedHostDaemon(
const serverDir = publishedPackageRoot
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
: path.resolve(__dirname, "../../../../server");
let child: ChildProcess | null = null;
const stopDaemon = async (): Promise<void> => {
// The CLI owns graceful shutdown, forced cleanup and detached terminal
// reaping. Keep its ESM graph out of Playwright's transforming loader.
await execFileAsync(
process.execPath,
[
path.resolve(__dirname, "../../../../cli/dist/index.js"),
"daemon",
"stop",
"--home",
paseoHome,
"--force",
],
{ timeout: 30_000 },
);
await killProcessTree(child);
};
const spawnDaemon = async (): Promise<ChildProcess> => {
const spawnOptions: SpawnOptions = {
cwd: serverDir,
@@ -169,21 +196,21 @@ export async function startIsolatedHostDaemon(
stdio: ["ignore", "ignore", "pipe"],
detached: false,
};
const child = publishedPackageRoot
const spawned = publishedPackageRoot
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
child = spawned;
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
spawned.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
stderr = stderr.split("\n").slice(-40).join("\n");
});
try {
await waitForServer(port, child);
return child;
await waitForServer(port, spawned);
return spawned;
} catch (error) {
await killProcessTree(child);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
@@ -191,10 +218,10 @@ export async function startIsolatedHostDaemon(
}
};
let child: ChildProcess;
try {
child = await spawnDaemon();
await spawnDaemon();
} catch (error) {
await stopDaemon();
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
@@ -209,22 +236,22 @@ export async function startIsolatedHostDaemon(
serverId,
port,
paseoHome,
getPid: () => child.pid,
getPid: () => child?.pid,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await killProcessTree(child);
child = await spawnDaemon();
await stopDaemon();
await spawnDaemon();
},
close: async () => {
if (closed) return;
closed = true;
await killProcessTree(child);
await stopDaemon();
if (!options.preserveHome) {
await rm(paseoHome, { recursive: true, force: true });
}
if (publishedPackageRoot) {
await rm(publishedPackageRoot, { recursive: true, force: true });
}
closed = true;
},
};
}
@@ -4,7 +4,6 @@ import type {
} from "../terminal/runtime/terminal-emulator-runtime";
import type { Ref } from "react";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import type {
@@ -19,8 +18,7 @@ import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-rea
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void;
@@ -44,7 +42,6 @@ export interface TerminalEmulatorProps {
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void;
@@ -13,7 +13,6 @@ import {
type ViewStyle,
} from "react-native";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
TerminalInputModeTracker,
terminalInputModeStatesEqual,
@@ -120,43 +119,8 @@ function resolvePanResponderPoint(
};
}
function initialNativeTerminalSize(snapshot: TerminalState | null): TerminalMeasuredSize {
return {
rows: snapshot?.rows ?? MIN_NATIVE_TERMINAL_ROWS,
cols: snapshot?.cols ?? MIN_NATIVE_TERMINAL_COLS,
};
}
function screenStateFromViewport(viewport: TerminalViewportState): TerminalScreenState {
const visibleRows = viewport.grid.length;
const currentViewport = {
firstRow: viewport.firstRow,
lastRow: Math.min(viewport.newestRow, viewport.firstRow + Math.max(1, visibleRows) - 1),
};
return {
viewport,
scroll: {
mode: "following",
firstRow: viewport.firstRow,
visibleRows,
oldestRow: viewport.oldestRow,
newestRow: viewport.newestRow,
currentViewport,
bottomViewport: currentViewport,
},
};
}
function viewportStateFromSnapshot(state: TerminalState): TerminalViewportState {
return {
rows: state.grid.length,
cols: state.cols,
firstRow: 0,
oldestRow: 0,
newestRow: Math.max(0, state.grid.length - 1),
grid: state.grid,
cursor: state.cursor,
};
function initialNativeTerminalSize(): TerminalMeasuredSize {
return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS };
}
function NativeTerminalEmulator({
@@ -169,7 +133,6 @@ function NativeTerminalEmulator({
fontSize,
keyboardInset = 0,
isKeyboardVisible = false,
initialSnapshot = null,
onInput,
onTerminalKey,
onResize,
@@ -181,20 +144,18 @@ function NativeTerminalEmulator({
resizeRequestToken = 0,
}: TerminalEmulatorProps) {
const terminalRef = useRef<NativeHeadlessTerminal | null>(null);
const mountInputRef = useRef({ streamKey, initialSnapshot });
const mountInputRef = useRef({ streamKey });
if (
resolveNativeTerminalMountAction({
mountedStreamKey: mountInputRef.current.streamKey,
nextStreamKey: streamKey,
}) === "mount"
) {
mountInputRef.current = { streamKey, initialSnapshot };
mountInputRef.current = { streamKey };
}
const inputRef = useRef<TerminalInputHandle>(null);
const terminalSizeRef = useRef<TerminalMeasuredSize>(initialNativeTerminalSize(initialSnapshot));
const resizePolicyRef = useRef(
createTerminalResizePolicy(initialNativeTerminalSize(initialSnapshot)),
);
const terminalSizeRef = useRef<TerminalMeasuredSize>(initialNativeTerminalSize());
const resizePolicyRef = useRef(createTerminalResizePolicy(initialNativeTerminalSize()));
const activeResizeClaimTokenRef = useRef(0);
const layoutRef = useRef<TerminalMeasuredLayout | null>(null);
const metricsRef = useRef<TerminalGridCellMetrics | null>(null);
@@ -220,11 +181,7 @@ function NativeTerminalEmulator({
...inputModeTrackerRef.current.getState(),
applicationCursorKeys: false,
});
const [terminalScreen, setTerminalScreen] = useState<TerminalScreenState | null>(() => {
return initialSnapshot
? screenStateFromViewport(viewportStateFromSnapshot(initialSnapshot))
: null;
});
const [terminalScreen, setTerminalScreen] = useState<TerminalScreenState | null>(null);
const [selectionRange, setSelectionRange] = useState<TerminalSelectionRange | null>(null);
const selectionRangeRef = useRef<TerminalSelectionRange | null>(null);
const hasNotifiedSelectionRef = useRef(false);
@@ -361,10 +318,14 @@ function NativeTerminalEmulator({
(terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain =>
createNativeTerminalOutputDrain({
write: async (chunk) => {
inputModeTrackerRef.current.feed(chunk);
await terminal.write(chunk);
emitInputModeChange();
},
reset: () => {
restoreSize: () => terminal.resize(terminalSizeRef.current),
reset: (size) => {
inputModeTrackerRef.current.reset();
if (size) terminal.resize(size);
terminal.reset();
emitInputModeChange();
},
@@ -418,8 +379,7 @@ function NativeTerminalEmulator({
hasNotifiedMeasuredSizeRef.current = true;
terminalSizeRef.current = policyResult.measuredSize;
if (policyResult.measuredSizeChanged) {
terminalRef.current?.resize(policyResult.measuredSize);
schedulePaint();
outputDrainRef.current?.resize();
}
callbacksRef.current.onResize?.({
rows: policyResult.measuredSize.rows,
@@ -428,68 +388,51 @@ function NativeTerminalEmulator({
forceClaim: policyResult.resizeClaim?.force,
});
},
[schedulePaint],
[],
);
const resetTerminal = useCallback(
(snapshot: TerminalState | null) => {
outputDrainRef.current?.dispose();
outputDrainRef.current = null;
terminalRef.current?.dispose();
screenModelRef.current = null;
latestResolvedScreenRef.current = null;
clearSelection();
const measuredSize = resolveMeasuredNativeTerminalSize({
layout: layoutRef.current,
metrics: metricsRef.current,
});
const size =
measuredSize ??
(snapshot ? { rows: snapshot.rows, cols: snapshot.cols } : terminalSizeRef.current);
terminalSizeRef.current = size;
resizePolicyRef.current = createTerminalResizePolicy(size);
outputDecoderRef.current.decode();
const terminal = createNativeHeadlessTerminal({
rows: size.rows,
cols: size.cols,
scrollbackLines,
});
terminalRef.current = terminal;
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
const outputDrain = createOutputDrain(terminal);
outputDrainRef.current = outputDrain;
const screen = snapshot
? screenStateFromViewport(viewportStateFromSnapshot(snapshot))
: resolveTerminalScreen(terminal);
commitTerminalScreen(screen);
resetInputModeTracker();
if (snapshot) {
outputDrain.restoreSnapshot(snapshot);
}
},
[
commitTerminalScreen,
createOutputDrain,
resetInputModeTracker,
resolveTerminalScreen,
const resetTerminal = useCallback(() => {
outputDrainRef.current?.dispose();
outputDrainRef.current = null;
terminalRef.current?.dispose();
screenModelRef.current = null;
latestResolvedScreenRef.current = null;
clearSelection();
const measuredSize = resolveMeasuredNativeTerminalSize({
layout: layoutRef.current,
metrics: metricsRef.current,
});
const size = measuredSize ?? terminalSizeRef.current;
terminalSizeRef.current = size;
resizePolicyRef.current = createTerminalResizePolicy(size);
outputDecoderRef.current.decode();
const terminal = createNativeHeadlessTerminal({
rows: size.rows,
cols: size.cols,
scrollbackLines,
clearSelection,
],
);
});
terminalRef.current = terminal;
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
const outputDrain = createOutputDrain(terminal);
outputDrainRef.current = outputDrain;
const screen = resolveTerminalScreen(terminal);
commitTerminalScreen(screen);
resetInputModeTracker();
}, [
commitTerminalScreen,
createOutputDrain,
resetInputModeTracker,
resolveTerminalScreen,
scrollbackLines,
clearSelection,
]);
const enqueueOutputText = useCallback(
(text: string) => {
if (text.length === 0) {
return;
}
const inputModeUpdate = inputModeTrackerRef.current.feed(text);
if (inputModeUpdate.changed) {
emitInputModeChange();
}
outputDrainRef.current?.enqueueText(text);
},
[emitInputModeChange],
);
const enqueueOutputText = useCallback((text: string) => {
if (text.length === 0) {
return;
}
outputDrainRef.current?.enqueueText(text);
}, []);
const claimActiveTerminalSize = useCallback(() => {
activeResizeClaimTokenRef.current += 1;
@@ -512,27 +455,7 @@ function NativeTerminalEmulator({
const text = outputDecoderRef.current.decode(data, { stream: true });
enqueueOutputText(text);
},
restoreOutput: (data: TerminalOutputData) => {
outputDecoderRef.current.decode();
resetInputModeTracker();
const text = outputDecoderRef.current.decode(data, { stream: false });
outputDrainRef.current?.restoreText(text);
},
renderSnapshot: (state: TerminalState | null) => {
outputDecoderRef.current.decode();
resetInputModeTracker();
if (!state) {
resetTerminal(null);
return;
}
clearSelection();
const outputDrain = outputDrainRef.current;
if (!outputDrain) {
resetTerminal(state);
return;
}
outputDrain.restoreSnapshot(state);
},
replay: (event) => outputDrainRef.current?.replay(event),
paste: (text: string) => {
if (text.length === 0) {
return;
@@ -563,7 +486,6 @@ function NativeTerminalEmulator({
screenModelRef.current?.reset();
clearSelection();
resetInputModeTracker();
terminalRef.current?.reset();
schedulePaint();
},
claimSize: claimActiveTerminalSize,
@@ -582,14 +504,13 @@ function NativeTerminalEmulator({
claimActiveTerminalSizeForAction,
clearSelection,
resetInputModeTracker,
resetTerminal,
schedulePaint,
],
);
useEffect(() => {
hasNotifiedMeasuredSizeRef.current = false;
resetTerminal(mountInputRef.current.initialSnapshot);
resetTerminal();
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: true });
return () => {
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
@@ -19,7 +19,6 @@ import type { DOMProps } from "expo/dom";
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import {
@@ -40,8 +39,7 @@ import { focusWithRetries } from "../utils/web-focus";
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void;
paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void;
@@ -108,7 +106,6 @@ interface TerminalEmulatorProps {
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void;
@@ -147,16 +144,6 @@ declare global {
interface Window {}
}
function isTerminalState(value: unknown): value is TerminalState {
return (
typeof value === "object" &&
value !== null &&
"rows" in value &&
"cols" in value &&
"grid" in value
);
}
export default function TerminalEmulator({
ref,
streamKey,
@@ -172,7 +159,6 @@ export default function TerminalEmulator({
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onFindRequest,
onFindResult,
onInput,
@@ -228,8 +214,6 @@ export default function TerminalEmulator({
onOpenLocalFileLink,
onFilePaste,
};
const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot;
const pendingModifiersRef = useRef(pendingModifiers);
pendingModifiersRef.current = pendingModifiers;
@@ -253,17 +237,17 @@ export default function TerminalEmulator({
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
},
restoreOutput: (...args) => {
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (...args) => {
const state = args[0];
if (state === null) {
runtimeRef.current?.renderSnapshot({ state: null });
} else if (isTerminalState(state)) {
runtimeRef.current?.renderSnapshot({ state });
}
replay: (event) => {
if (!event || typeof event !== "object" || Array.isArray(event)) return;
if (event.type === "end") runtimeRef.current?.replay({ type: "end" });
if (event.type === "data" && event.data instanceof Uint8Array)
runtimeRef.current?.replay({ type: "data", data: event.data });
if (event.type === "begin")
runtimeRef.current?.replay({
type: "begin",
rows: typeof event.rows === "number" ? event.rows : undefined,
cols: typeof event.cols === "number" ? event.cols : undefined,
});
},
paste: (...args) => {
const text = args[0];
@@ -299,12 +283,7 @@ export default function TerminalEmulator({
writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ data });
},
restoreOutput: (data: TerminalOutputData) => {
runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
replay: (event) => runtimeRef.current?.replay(event),
paste: (text: string) => {
pasteText(text);
},
@@ -471,7 +450,6 @@ export default function TerminalEmulator({
runtime.mount({
root,
host,
initialSnapshot: initialSnapshotRef.current,
scrollback: scrollbackLinesRef.current,
theme: mountedThemeRef.current,
fontFamily: fontFamilyRef.current,
+5 -43
View File
@@ -15,7 +15,6 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native";
import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
DEFAULT_TERMINAL_INPUT_MODE_STATE,
type TerminalInputModeState,
@@ -60,7 +59,6 @@ import { getDesktopHost } from "@/desktop/host";
import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop";
import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path";
import { toErrorMessage } from "@/utils/error-messages";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
EMPTY_FOCUS_CLAIM_STATE,
canRequestFocusClaim,
@@ -88,7 +86,6 @@ import { isNative } from "@/constants/platform";
import {
applyTerminalRendererReadyChange,
resolveTerminalStreamTarget,
shouldReplayTerminalSnapshotForRenderer,
shouldShowTerminalLoadingOverlay,
type TerminalRendererReadyChange,
} from "@/utils/terminal-renderer-readiness";
@@ -316,10 +313,6 @@ export function TerminalPane({
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const workspaceTerminalSession = useMemo(
() => getWorkspaceTerminalSession({ scopeKey }),
[scopeKey],
);
const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null);
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
@@ -383,7 +376,6 @@ export function TerminalPane({
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
useEffect(() => {
terminalIdRef.current = terminalId;
@@ -441,16 +433,8 @@ export function TerminalPane({
(change: TerminalRendererReadyChange) => {
if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset();
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) {
return;
}
const snapshot = workspaceTerminalSession.snapshots.get({ terminalId });
if (snapshot) {
emulatorRef.current?.renderSnapshot(snapshot);
}
},
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
[terminalStreamKey],
);
useEffect(() => {
@@ -564,7 +548,6 @@ export function TerminalPane({
);
const handleStreamExit = useStableEvent((exitedTerminalId: string) => {
workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId });
if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear();
setModifiers({ ...EMPTY_MODIFIERS });
});
@@ -604,29 +587,10 @@ export function TerminalPane({
},
);
const handleStreamRestore = useStableEvent(
({ terminalId: restoreTerminalId, data }: { terminalId: string; data: Uint8Array }) => {
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
if (terminalIdRef.current !== restoreTerminalId) {
return;
}
emulatorRef.current?.restoreOutput(data);
},
);
const handleStreamSnapshot = useStableEvent(
({ terminalId: snapshotTerminalId, state }: { terminalId: string; state: TerminalState }) => {
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
if (terminalIdRef.current !== snapshotTerminalId) {
return;
}
emulatorRef.current?.renderSnapshot(state);
},
);
const getStreamRestoreOptions = useStableEvent(() =>
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
scrollbackLines: settings.terminalScrollbackLines,
canClaimSize: canRequestFocusClaim({
isWorkspaceFocused: terminalPresentedRef.current,
isPaneFocused,
@@ -653,8 +617,9 @@ export function TerminalPane({
client,
getPreferredSize: getPreferredStreamSize,
onOutput: handleStreamOutput,
onRestore: handleStreamRestore,
onSnapshot: handleStreamSnapshot,
onReplay: ({ terminalId: id, replay }) => {
if (terminalIdRef.current === id) emulatorRef.current?.replay(replay);
},
onExit: handleStreamExit,
getRestoreOptions: getStreamRestoreOptions,
onStatusChange: handleStreamControllerStatus,
@@ -674,8 +639,6 @@ export function TerminalPane({
getStreamRestoreOptions,
handleStreamControllerStatus,
handleStreamOutput,
handleStreamRestore,
handleStreamSnapshot,
handleStreamExit,
isConnected,
]);
@@ -1216,7 +1179,6 @@ export function TerminalPane({
keyboardInset={keyboardInset}
isKeyboardVisible={isKeyboardVisible}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
onSwipeRight={handleSwipeRight}
onSwipeLeft={handleSwipeLeft}
+1
View File
@@ -617,6 +617,7 @@ export const ar: TranslationResources = {
terminal: {
hostDisconnected: "Host غير متصل",
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
restoreRequiresUpdate: "حدّث المضيف لعرض الطرفيات.",
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
fileUploaded: "تم رفع الملف",
pasteFileFailed: "تعذر لصق الملف: {{reason}}",
+1
View File
@@ -614,6 +614,7 @@ export const en = {
terminal: {
hostDisconnected: "Host is not connected",
updateHost: "Update the host to use the native terminal renderer.",
restoreRequiresUpdate: "Update the host to view terminals.",
unableToSubscribe: "Unable to subscribe to terminal",
fileUploaded: "File uploaded",
pasteFileFailed: "Failed to paste file: {{reason}}",
+1
View File
@@ -623,6 +623,7 @@ export const es: TranslationResources = {
terminal: {
hostDisconnected: "Hostno está conectado",
updateHost: "Actualiza el host para usar el renderizador de terminal nativo.",
restoreRequiresUpdate: "Actualiza el host para ver las terminales.",
unableToSubscribe: "No se puede suscribir al terminal",
fileUploaded: "Archivo subido",
pasteFileFailed: "No se pudo pegar el archivo: {{reason}}",
+1
View File
@@ -623,6 +623,7 @@ export const fr: TranslationResources = {
terminal: {
hostDisconnected: "L’hôte n’est pas connecté",
updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.",
restoreRequiresUpdate: "Mettez à jour l’hôte pour afficher les terminaux.",
unableToSubscribe: "Impossible de s’abonner au terminal",
fileUploaded: "Fichier envoyé",
pasteFileFailed: "Impossible de coller le fichier : {{reason}}",
+1
View File
@@ -623,6 +623,7 @@ export const ja: TranslationResources = {
terminal: {
hostDisconnected: "ホストが接続されていません",
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
restoreRequiresUpdate: "ターミナルを表示するにはホストを更新してください。",
unableToSubscribe: "ターミナルに接続できません",
fileUploaded: "ファイルをアップロードしました",
pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}",
+1
View File
@@ -618,6 +618,7 @@ export const ko: TranslationResources = {
terminal: {
hostDisconnected: "호스트가 연결되어 있지 않습니다",
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
restoreRequiresUpdate: "터미널을 보려면 호스트를 업데이트하세요.",
unableToSubscribe: "터미널을 구독할 수 없습니다",
fileUploaded: "파일을 업로드했습니다",
pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}",
+1
View File
@@ -622,6 +622,7 @@ export const ptBR: TranslationResources = {
terminal: {
hostDisconnected: "Host não está conectado",
updateHost: "Atualize o host para usar o renderizador de terminal nativo.",
restoreRequiresUpdate: "Atualize o host para visualizar os terminais.",
unableToSubscribe: "Não foi possível assinar o terminal",
fileUploaded: "Arquivo enviado",
pasteFileFailed: "Falha ao colar o arquivo: {{reason}}",
+1
View File
@@ -624,6 +624,7 @@ export const ru: TranslationResources = {
terminal: {
hostDisconnected: "Хост не подключён",
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
restoreRequiresUpdate: "Обновите хост, чтобы просматривать терминалы.",
unableToSubscribe: "Не удалось подключиться к терминалу",
fileUploaded: "Файл загружен",
pasteFileFailed: "Не удалось вставить файл: {{reason}}",
+1
View File
@@ -617,6 +617,7 @@ export const zhCN: TranslationResources = {
terminal: {
hostDisconnected: "Host 未连接",
updateHost: "请更新主机以使用原生终端渲染器。",
restoreRequiresUpdate: "更新主机以查看终端。",
unableToSubscribe: "无法订阅 Terminal",
fileUploaded: "文件已上传",
pasteFileFailed: "无法粘贴文件:{{reason}}",
@@ -127,7 +127,6 @@ import {
useWorkspaceSetupStore,
} from "@/stores/workspace-setup-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
@@ -881,13 +880,6 @@ function shouldShowWorkspaceScreenHeader(input: {
return !input.isFocusModeEnabled || input.isMobile;
}
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
if (!serverId || !workspaceId) {
return null;
}
return `${serverId}:${workspaceId}`;
}
/**
* A pane the user acted inside owns the tab: it opens there, and an existing tab
* moves there. No pane means the open has no opinion beyond the focused pane.
@@ -1066,14 +1058,6 @@ function WorkspaceScreenContent({
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
useWorkspaceRouteActions(normalizedServerId);
const workspaceTerminalScopeKey = useMemo(
() => buildWorkspaceTerminalScopeKey(normalizedServerId, normalizedWorkspaceId),
[normalizedServerId, normalizedWorkspaceId],
);
useWorkspaceTerminalSessionRetention({
scopeKey: workspaceTerminalScopeKey,
});
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const supportsProvidersSnapshot = useSessionStore(
@@ -1,18 +0,0 @@
import { useEffect } from "react";
import {
releaseWorkspaceTerminalSession,
retainWorkspaceTerminalSession,
} from "@/terminal/runtime/workspace-terminal-session";
export function useWorkspaceTerminalSessionRetention(input: { scopeKey: string | null }): void {
useEffect(() => {
if (!input.scopeKey) {
return;
}
retainWorkspaceTerminalSession({ scopeKey: input.scopeKey });
return () => {
releaseWorkspaceTerminalSession({ scopeKey: input.scopeKey! });
};
}, [input.scopeKey]);
}
@@ -1,3 +1,4 @@
import { createTerminalResizePolicy, updateTerminalResizePolicy } from "./terminal-resize-policy";
import { describe, expect, test } from "vitest";
import {
@@ -11,7 +12,7 @@ import {
NATIVE_TERMINAL_PARSE_CHUNK_CHARS,
} from "./headless-terminal-output-drain";
import { createNativeTerminalScreenModel, type TerminalScreenState } from "./terminal-screen-model";
import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
const ROWS = 12;
const COLS = 80;
@@ -94,6 +95,7 @@ function createScreenRestorer(
const frameRecorder = createFrameRecorder();
let screen: TerminalScreenState | null = null;
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (chunk) => terminal.write(chunk),
reset: () => terminal.reset(),
getViewportState: () => {
@@ -107,7 +109,12 @@ function createScreenRestorer(
return {
async restore(snapshot: Awaited<ReturnType<typeof benchmarkSnapshot>>) {
drain.restoreSnapshot(snapshot);
drain.replay({ type: "begin", rows: snapshot.rows, cols: snapshot.cols });
drain.replay({
type: "data",
data: new TextEncoder().encode(renderTerminalSnapshotToAnsi(snapshot)),
});
drain.replay({ type: "end" });
await drain.flush();
frameRecorder.frames[0]?.();
return screen;
@@ -122,6 +129,7 @@ describe("native terminal output drain", () => {
const frameRecorder = createFrameRecorder();
const payload = "x".repeat(NATIVE_TERMINAL_PARSE_CHUNK_CHARS * 2);
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: async (chunk) => {
writes.push(chunk);
},
@@ -170,6 +178,7 @@ describe("native terminal output drain", () => {
const paintedStates: TerminalViewportState[] = [];
const frameRecorder = createFrameRecorder();
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (chunk) => terminal.write(chunk),
reset: () => terminal.reset(),
getViewportState: () => terminal.getViewportState(),
@@ -202,6 +211,7 @@ describe("native terminal output drain", () => {
const scheduledWork: Array<() => void> = [];
const frameRecorder = createFrameRecorder();
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (chunk) => terminal.write(chunk),
reset: () => terminal.reset(),
getViewportState: () => terminal.getViewportState(),
@@ -219,7 +229,12 @@ describe("native terminal output drain", () => {
await terminal.write("old-one\r\nold-two\r\nold-three\r\n");
drain.enqueueText("stale-queued\r\n");
drain.restoreText(renderTerminalSnapshotToAnsi(restoredSnapshot));
drain.replay({ type: "begin" });
drain.replay({
type: "data",
data: new TextEncoder().encode(renderTerminalSnapshotToAnsi(restoredSnapshot)),
});
drain.replay({ type: "end" });
await runScheduledWork({ callbacks: scheduledWork, flush: drain.flush() });
const state = terminal.getViewportState();
@@ -268,3 +283,238 @@ describe("native terminal output drain", () => {
});
});
});
test("replays split Unicode and ANSI with one reset then live output", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 40, scrollbackLines: 1000 });
let resets = 0;
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: (text) => terminal.write(text),
reset: (size) => {
resets++;
if (size) terminal.resize(size);
terminal.reset();
},
getViewportState: () => terminal.getViewportState(),
onPaint: () => {},
});
try {
drain.replay({ type: "begin", rows: 4, cols: 40, retainedHistoryLines: 400 });
const bytes = new TextEncoder().encode("\x1b[31m界🙂 replay\x1b[0m\r\n");
for (let i = 0; i < bytes.length; i++)
drain.replay({ type: "data", data: bytes.subarray(i, i + 1) });
drain.replay({ type: "end" });
drain.enqueueText("live");
await drain.flush();
expect(resets).toBe(1);
expect(
terminal
.getBufferWindow({ startRow: 0, rowCount: 4 })
.rows.map((row) =>
row
.filter((cell) => cell.width !== 0)
.map((cell) => cell.char)
.join(""),
)
.join("\n"),
).toContain("界🙂 replay");
expect(
terminal
.getBufferWindow({ startRow: 0, rowCount: 4 })
.rows.map((row) =>
row
.filter((cell) => cell.width !== 0)
.map((cell) => cell.char)
.join(""),
)
.join("\n"),
).toContain("live");
} finally {
drain.dispose();
terminal.dispose();
}
});
test("an empty replacement replay resets after an in-flight write finishes", async () => {
let finish!: () => void;
let started!: () => void;
const writing = new Promise<void>((resolve) => {
started = resolve;
});
const blocked = new Promise<void>((resolve) => {
finish = resolve;
});
let resets = 0;
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {},
write: async () => {
started();
await blocked;
},
reset: () => {
resets++;
},
getViewportState: () => null,
onPaint: () => {},
});
try {
drain.enqueueText("old terminal");
await writing;
drain.replay({ type: "begin" });
drain.replay({ type: "end" });
finish();
await drain.flush();
expect(resets).toBe(1);
expect(drain.getQueuedChars()).toBe(0);
} finally {
finish();
drain.dispose();
}
});
test("replay geometry returns to measured layout before live writes and unchanged-layout focus", async () => {
const measured = { rows: 24, cols: 40 };
const terminal = createNativeHeadlessTerminal({ ...measured, scrollbackLines: 1000 });
const policy = createTerminalResizePolicy(measured);
const sizes: number[] = [];
const drain = createNativeTerminalOutputDrain({
write: async (text) => {
sizes.push(terminal.getState().cols);
await terminal.write(text);
},
reset: (size) => {
if (size) terminal.resize(size);
terminal.reset();
},
restoreSize: () => terminal.resize(measured),
getViewportState: () => terminal.getViewportState(),
onPaint: () => {},
});
try {
drain.replay({ type: "begin", rows: 30, cols: 120 });
drain.replay({ type: "data", data: new TextEncoder().encode("x".repeat(100) + "\r\n") });
drain.replay({ type: "end" });
drain.enqueueText("LIVE-" + "y".repeat(60));
await drain.flush();
expect(sizes).toEqual([120, 40]);
const focus = updateTerminalResizePolicy(policy, {
source: "claim",
size: measured,
claimToken: 1,
});
expect(focus.measuredSizeChanged).toBe(false);
expect(focus.resizeClaim).toMatchObject(measured);
expect(terminal.getState()).toMatchObject(measured);
expect(terminal.getBufferBounds().cols).toBe(40);
const lines = terminal
.getState()
.grid.map((row) =>
row
.map((cell) => cell.char)
.join("")
.trimEnd(),
)
.join("\n");
expect(lines).toContain("LIVE-" + "y".repeat(35) + "\n" + "y".repeat(25));
} finally {
drain.dispose();
terminal.dispose();
}
});
test.each(["clear", "replacement", "dispose"] as const)(
"cancels queued replay geometry on %s",
async (action) => {
const local = { rows: 24, cols: 40 };
const terminal = createNativeHeadlessTerminal({ ...local, scrollbackLines: 1000 });
let finish!: () => void;
const block = new Promise<void>((resolve) => {
finish = resolve;
});
let started!: () => void;
const writing = new Promise<void>((resolve) => {
started = resolve;
});
let first = true;
let restores = 0;
const drain = createNativeTerminalOutputDrain({
restoreSize: () => {
restores++;
terminal.resize(local);
},
reset: (size) => {
if (size) terminal.resize(size);
terminal.reset();
},
write: async (text) => {
await terminal.write(text);
if (first) {
first = false;
started();
await block;
}
},
getViewportState: () => terminal.getViewportState(),
onPaint: () => {},
});
try {
drain.replay({ type: "begin", rows: 30, cols: 120 });
drain.replay({ type: "data", data: new TextEncoder().encode("old") });
await writing;
local.cols = 60;
drain.resize();
expect(terminal.getState().cols).toBe(120);
drain.replay({ type: "end" });
if (action === "clear") drain.clear();
else if (action === "dispose") {
drain.dispose();
drain.clear();
} else {
drain.replay({ type: "begin", rows: 20, cols: 80 });
drain.replay({ type: "data", data: new TextEncoder().encode("new") });
drain.replay({ type: "end" });
}
finish();
await drain.flush();
expect(restores).toBe(action === "dispose" ? 0 : 1);
if (action !== "dispose") expect(terminal.getState()).toMatchObject(local);
} finally {
finish();
drain.dispose();
terminal.dispose();
}
},
);
test("holds captured geometry across mid-replay measurement changes", async () => {
const source = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 });
await source.write("ABCDEFGH\r\n12345678\r\nabcdefgh\r\nxyz\x1b[2;8H");
const ansi = new TextEncoder().encode(renderTerminalSnapshotToAnsi(source.getState()));
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 });
const expected = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 });
await expected.write(new TextDecoder().decode(ansi));
expected.resize({ rows: 4, cols: 4 });
let measured = { rows: 4, cols: 8 };
const drain = createNativeTerminalOutputDrain({
write: (chunk) => terminal.write(chunk),
reset: (size) => {
if (size) terminal.resize(size);
terminal.reset();
},
restoreSize: () => terminal.resize(measured),
getViewportState: () => terminal.getViewportState(),
onPaint: () => {},
});
try {
drain.replay({ type: "begin", rows: 4, cols: 8 });
await drain.flush();
measured = { rows: 4, cols: 4 };
drain.resize();
drain.replay({ type: "data", data: ansi });
drain.replay({ type: "end" });
await drain.flush();
expect(terminal.getState()).toEqual(expected.getState());
} finally {
drain.dispose();
}
});
@@ -1,5 +1,3 @@
import type { TerminalState } from "@getpaseo/protocol/messages";
import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot";
import type { TerminalViewportState } from "./headless-terminal-state";
import { nativeTerminalPerformanceNow } from "./terminal-performance";
@@ -14,9 +12,9 @@ export interface NativeTerminalParseSample {
}
export interface NativeTerminalOutputDrain {
replay(event: import("@getpaseo/client").TerminalReplayEvent): void;
enqueueText(text: string): void;
restoreText(text: string): void;
restoreSnapshot(snapshot: TerminalState): void;
resize(): void;
clear(): void;
flush(): Promise<void>;
dispose(): void;
@@ -25,7 +23,8 @@ export interface NativeTerminalOutputDrain {
export interface NativeTerminalOutputDrainOptions {
write: (chunk: string) => Promise<void>;
reset: () => void;
restoreSize: () => void;
reset: (size?: { rows: number; cols: number }) => void;
getViewportState: () => TerminalViewportState | null;
onPaint: (state: TerminalViewportState) => void;
onParse?: (sample: NativeTerminalParseSample) => void;
@@ -68,16 +67,22 @@ export function createNativeTerminalOutputDrain(
const chunkChars = options.chunkChars ?? NATIVE_TERMINAL_PARSE_CHUNK_CHARS;
const chunksPerTurn = options.chunksPerTurn ?? NATIVE_TERMINAL_PARSE_CHUNKS_PER_TURN;
const idleWaiters: Array<() => void> = [];
let pendingText = "";
type Operation =
| { type: "write"; text: string }
| { type: "reset"; size?: { rows: number; cols: number } }
| { type: "restore-size" };
let operations: Operation[] = [];
let queuedChars = 0;
let drainScheduled = false;
let draining = false;
let disposed = false;
let replaying = false;
let paintFrame: number | null = null;
let generation = 0;
let resetBeforeNextWrite = false;
const replayDecoder = new TextDecoder();
function resolveIdleIfReady(): void {
if (drainScheduled || draining || pendingText.length > 0) {
if (drainScheduled || draining || operations.length > 0) {
return;
}
while (idleWaiters.length > 0) {
@@ -119,15 +124,6 @@ export function createNativeTerminalOutputDrain(
});
}
function replacePendingText(text: string): void {
generation += 1;
pendingText = text;
resetBeforeNextWrite = true;
cancelPendingPaint();
scheduleDrain();
resolveIdleIfReady();
}
async function drainPending(): Promise<void> {
if (disposed || draining) {
return;
@@ -138,16 +134,23 @@ export function createNativeTerminalOutputDrain(
let parsedChunks = 0;
try {
while (pendingText.length > 0 && parsedChunks < chunksPerTurn) {
if (disposed || activeGeneration !== generation) {
return;
while (operations.length > 0 && parsedChunks < chunksPerTurn) {
if (disposed || activeGeneration !== generation) return;
const operation = operations[0]!;
if (operation.type !== "write") {
operations.shift();
if (operation.type === "reset") options.reset(operation.size);
else {
options.restoreSize();
replaying = false;
}
requestPaint();
continue;
}
if (resetBeforeNextWrite) {
options.reset();
resetBeforeNextWrite = false;
}
const chunk = pendingText.slice(0, chunkChars);
pendingText = pendingText.slice(chunk.length);
const chunk = operation.text.slice(0, chunkChars);
operation.text = operation.text.slice(chunk.length);
queuedChars -= chunk.length;
if (!operation.text) operations.shift();
const parseStart = nativeTerminalPerformanceNow();
await options.write(chunk);
if (disposed || activeGeneration !== generation) {
@@ -156,14 +159,14 @@ export function createNativeTerminalOutputDrain(
options.onParse?.({
durationMs: nativeTerminalPerformanceNow() - parseStart,
parsedChars: chunk.length,
queuedChars: pendingText.length,
queuedChars,
});
requestPaint();
parsedChunks += 1;
}
} finally {
draining = false;
if (!disposed && pendingText.length > 0) {
if (!disposed && operations.length > 0) {
scheduleDrain();
}
resolveIdleIfReady();
@@ -171,38 +174,56 @@ export function createNativeTerminalOutputDrain(
}
return {
enqueueText(text: string): void {
if (disposed || text.length === 0) {
return;
replay(event): void {
if (disposed) return;
if (event.type === "begin") {
replaying = true;
generation++;
replayDecoder.decode();
operations = [
{
type: "reset",
size: event.rows && event.cols ? { rows: event.rows, cols: event.cols } : undefined,
},
];
queuedChars = 0;
cancelPendingPaint();
} else if (event.type === "data") {
this.enqueueText(replayDecoder.decode(event.data, { stream: true }));
} else {
this.enqueueText(replayDecoder.decode());
operations.push({ type: "restore-size" });
}
pendingText += text;
scheduleDrain();
},
enqueueText(text: string): void {
if (disposed || !text) return;
const last = operations.at(-1);
if (last?.type === "write") last.text += text;
else operations.push({ type: "write", text });
queuedChars += text.length;
scheduleDrain();
},
restoreText(text: string): void {
if (disposed) {
return;
}
replacePendingText(text);
},
restoreSnapshot(snapshot: TerminalState): void {
if (disposed) {
return;
}
replacePendingText(renderTerminalSnapshotToAnsi(snapshot));
resize(): void {
if (disposed || replaying) return;
operations.push({ type: "restore-size" });
scheduleDrain();
},
clear(): void {
if (disposed) return;
generation += 1;
pendingText = "";
resetBeforeNextWrite = false;
operations = [{ type: "restore-size" }, { type: "reset" }];
queuedChars = 0;
replayDecoder.decode();
cancelPendingPaint();
scheduleDrain();
resolveIdleIfReady();
},
flush(): Promise<void> {
if (!drainScheduled && !draining && pendingText.length === 0) {
if (!drainScheduled && !draining && operations.length === 0) {
return Promise.resolve();
}
scheduleDrain();
@@ -214,8 +235,8 @@ export function createNativeTerminalOutputDrain(
dispose(): void {
disposed = true;
generation += 1;
pendingText = "";
resetBeforeNextWrite = false;
operations = [];
queuedChars = 0;
cancelPendingPaint();
while (idleWaiters.length > 0) {
idleWaiters.shift()?.();
@@ -223,7 +244,7 @@ export function createNativeTerminalOutputDrain(
},
getQueuedChars(): number {
return pendingText.length;
return queuedChars;
},
};
}
@@ -166,7 +166,6 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: input.scrollback ?? 10_000,
theme: {
background: "#0b0b0b",
@@ -750,30 +749,11 @@ describe("terminal emulator runtime in a real browser", () => {
const reset = vi.fn(originalReset);
terminal.reset = reset;
mounted.runtime.renderSnapshot({
state: {
rows: terminal.rows,
cols: terminal.cols,
scrollback: [],
grid: [
[
{ char: "p" },
{ char: "r" },
{ char: "o" },
{ char: "m" },
{ char: "p" },
{ char: "t" },
],
],
cursor: {
row: 0,
col: 6,
},
},
});
await nextFrame();
mounted.runtime.replay({ type: "begin", rows: terminal.rows, cols: terminal.cols });
mounted.runtime.replay({ type: "data", data: terminalOutput("prompt") });
mounted.runtime.replay({ type: "end" });
expect(reset).not.toHaveBeenCalled();
await nextFrame();
});
it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => {
@@ -795,3 +775,95 @@ describe("terminal emulator runtime in a real browser", () => {
expect(confirm).not.toHaveBeenCalled();
});
});
describe("incremental replay lifecycle", () => {
it.each(["clear", "remount"] as const)(
"recovers live input after interrupted replay and %s",
async (cleanup) => {
const mounted = createTerminalHost({ width: 720, height: 360 });
mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 });
mounted.root.style.width = "360px";
mounted.runtime.resize({ shouldClaim: false });
mounted.runtime.replay({ type: "data", data: terminalOutput("interrupted") });
if (cleanup === "clear")
await new Promise<void>((resolve) => mounted.runtime.clear({ onCommitted: resolve }));
else {
mounted.runtime.unmount();
mounted.runtime.mount({
root: mounted.root,
host: mounted.host,
scrollback: 1000,
theme: {},
});
}
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("live"), onCommitted: resolve }),
);
getBrowserTerminal().input("user-input", true);
expect(mounted.inputs).toContain("user-input");
expect(getBrowserTerminal().cols).toBeLessThan(60);
},
);
it("suppresses generated replies until queued replay chunks commit after end", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
mounted.runtime.replay({ type: "begin" });
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") });
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") });
mounted.runtime.replay({ type: "end" });
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("committed"), onCommitted: resolve }),
);
expect(mounted.inputs).toEqual([]);
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("\x1b]4;1;?\x07"), onCommitted: resolve }),
);
expect(mounted.inputs).toHaveLength(1);
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
});
});
it("restores fitted web geometry after replay before live output", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
await settleMountRefits();
const local = latestSize(mounted.sizes);
mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 });
mounted.runtime.replay({ type: "data", data: terminalOutput("x".repeat(100)) });
mounted.runtime.replay({ type: "end" });
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("LIVE"), onCommitted: resolve }),
);
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
expect(getBrowserTerminal().cols).toBeLessThan(120);
expect(getBrowserTerminal().rows).toBe(local.rows);
mounted.runtime.resize({ forceClaim: true, shouldClaim: true });
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
});
it("defers layout fit until all replay chunks commit", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
await settleMountRefits();
mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 });
await new Promise<void>((resolve) =>
mounted.runtime.write({
data: terminalOutput("first"),
suppressInput: true,
onCommitted: resolve,
}),
);
expect(getBrowserTerminal().cols).toBe(120);
mounted.root.style.width = "360px";
mounted.runtime.resize({ shouldClaim: true, forceClaim: true, forceRefresh: true });
await nextFrame();
expect(getBrowserTerminal().cols).toBe(120);
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b[2;110Hsecond") });
mounted.runtime.replay({ type: "end" });
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("LIVE"), onCommitted: resolve }),
);
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
expect(getBrowserTerminal().cols).toBeLessThan(60);
expect(latestSize(mounted.sizes)).toMatchObject({ shouldClaim: true, forceClaim: true });
mounted.runtime.resize({ shouldClaim: true, forceClaim: true });
expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols);
});
@@ -302,25 +302,14 @@ describe("terminal-emulator-runtime", () => {
expect(committed).toEqual(["first", "second", "clear", "third"]);
});
it("falls back to timeout commit for a barrier op when the gate sentinel never fires", () => {
it("unblocks queued replay writes if a begin barrier never commits", () => {
vi.useFakeTimers();
const { runtime, terminal } = createRuntimeWithTerminal();
const onCommitted = vi.fn();
// restoreOutput is a barrier; if xterm never commits the gate sentinel, the barrier still
// applies (and commits) after the 5s safety timeout instead of stalling forever.
runtime.restoreOutput({
data: terminalOutput("stuck"),
onCommitted,
});
expect(onCommitted).not.toHaveBeenCalled();
expect(terminal.resetCalls).toBe(0);
vi.advanceTimersByTime(5_000);
// The barrier now runs (write submitted) but its own commit still waits on xterm; advance
// again to fire the barrier's own write timeout.
vi.advanceTimersByTime(5_000);
expect(onCommitted).toHaveBeenCalledTimes(1);
const { runtime, writeTexts } = createRuntimeWithTerminal();
runtime.replay({ type: "begin" });
runtime.replay({ type: "data", data: terminalOutput("stuck") });
expect(writeTexts).toEqual(["\x1bc"]);
vi.advanceTimersByTime(5000);
expect(writeTexts).toEqual(["\x1bc", "stuck"]);
});
it("reports input mode changes from terminal output and resets them on snapshots", () => {
@@ -343,18 +332,7 @@ describe("terminal-emulator-runtime", () => {
});
runtime.write({ data: terminalOutput("\x1b[>7u") });
runtime.renderSnapshot({
state: {
rows: 2,
cols: 8,
scrollback: [],
grid: [[{ char: "$" }, { char: " " }]],
cursor: {
row: 0,
col: 2,
},
},
});
runtime.replay({ type: "begin", rows: 2, cols: 8 });
// The plain write reports kitty flags synchronously during drain; the snapshot resets
// them only after its barrier gate (the sentinel write callback) resolves.
expect(inputModeChanges).toEqual([
@@ -410,40 +388,18 @@ describe("terminal-emulator-runtime", () => {
expect(committed).toEqual(["second", "first"]);
});
it("applies a snapshot after the writes ahead of it, never interleaving the reset", () => {
it("keeps replay reset and data after preceding writes, without interleaving", () => {
const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
const committed: string[] = [];
runtime.write({
data: terminalOutput("before-a"),
onCommitted: () => {
committed.push("before-a");
},
});
runtime.write({
data: terminalOutput("before-b"),
onCommitted: () => {
committed.push("before-b");
},
});
runtime.restoreOutput({
data: terminalOutput("snap"),
onCommitted: () => {
committed.push("snap");
},
});
// Plain writes are submitted up front; the snapshot's reset+write has not run yet.
runtime.write({ data: terminalOutput("before-a") });
runtime.write({ data: terminalOutput("before-b") });
runtime.replay({ type: "begin" });
runtime.replay({ type: "data", data: terminalOutput("snap") });
runtime.replay({ type: "end" });
expect(writeTexts).toEqual(["before-a", "before-b"]);
// writeCallbacks: [0]=before-a, [1]=before-b, [2]=barrier gate sentinel.
// The snapshot is gated behind the prior writes; resolving the gate runs it after them.
writeCallbacks[2]?.();
expect(writeTexts).toEqual(["before-a", "before-b", "csnap"]);
// Snapshot commit fires only after its own write callback, strictly after the writes.
expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc"]);
writeCallbacks[3]?.();
expect(committed).toEqual(["snap"]);
expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc", "snap"]);
expect(terminal.resetCalls).toBe(0);
});
@@ -456,7 +412,7 @@ describe("terminal-emulator-runtime", () => {
// No plain writes precede this barrier (mount), so there is nothing to gate: it starts
// at once with no sentinel, flipping suppressInput synchronously.
runtime.restoreOutput({ data: terminalOutput("snapshot") });
runtime.replay({ type: "begin" });
expect(readSuppressInput()).toBe(true);
// writeCallbacks[0] is the barrier's own snapshot write; committing it restores input.
@@ -471,7 +427,7 @@ describe("terminal-emulator-runtime", () => {
// A plain write is now ungated, so the following barrier must wait on the sentinel.
runtime.write({ data: terminalOutput("output") });
runtime.restoreOutput({ data: terminalOutput("snapshot") });
runtime.replay({ type: "begin" });
// The plain write commits first. Only the following sentinel opens the barrier.
expect(readSuppressInput()).toBe(false);
@@ -505,57 +461,40 @@ describe("terminal-emulator-runtime", () => {
expect(onCommittedB).toHaveBeenCalledTimes(1);
});
it("clears ungated writes on unmount so remount barriers apply immediately", () => {
it("clears ungated writes on unmount so remount replay begins immediately", () => {
const { runtime } = createRuntimeWithTerminal();
runtime.write({ data: terminalOutput("before unmount") });
runtime.unmount();
const remounted = attachStubTerminal(runtime);
runtime.restoreOutput({ data: terminalOutput("restored screen") });
expect(remounted.writeTexts).toEqual(["\u001bcrestored screen"]);
expect(remounted.writeCallbacks).toHaveLength(1);
runtime.replay({ type: "begin" });
runtime.replay({ type: "data", data: terminalOutput("restored screen") });
expect(remounted.writeTexts).toEqual(["\x1bc"]);
remounted.writeCallbacks[0]?.();
expect(remounted.writeTexts).toEqual(["\x1bc", "restored screen"]);
});
it("replays snapshots through a single write without first painting a reset terminal", () => {
it("parses replay chunks serially without synchronously resetting the terminal", () => {
const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
runtime.renderSnapshot({
state: {
rows: 2,
cols: 8,
scrollback: [],
grid: [
[{ char: "h" }, { char: "i" }],
[{ char: "$" }, { char: " " }],
],
cursor: {
row: 1,
col: 2,
},
},
});
// The snapshot is a barrier; its write applies after the gate sentinel (writeCallbacks[0]).
writeCallbacks[0]?.();
runtime.replay({ type: "begin", rows: 2, cols: 8 });
runtime.replay({ type: "data", data: terminalOutput("hi") });
runtime.replay({ type: "data", data: terminalOutput("\r\n$") });
runtime.replay({ type: "end" });
expect(terminal.resetCalls).toBe(0);
expect(writeTexts).toHaveLength(1);
expect(writeTexts[0]?.startsWith("\u001bc")).toBe(true);
expect(writeTexts[0]).toContain("hi");
expect(writeTexts).toEqual(["\x1bc"]);
writeCallbacks[0]?.();
expect(writeTexts).toEqual(["\x1bc", "hi"]);
writeCallbacks[1]?.();
expect(writeTexts).toEqual(["\x1bc", "hi", "\r\n$"]);
});
it("restores server-rendered ANSI snapshots through the snapshot write path", () => {
it("restores worker-rendered ANSI through the replay lifecycle", () => {
const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal();
runtime.restoreOutput({ data: terminalOutput("restored screen") });
// restoreOutput is a barrier (suppressInput); it applies after the gate sentinel.
runtime.replay({ type: "begin" });
runtime.replay({ type: "data", data: terminalOutput("restored screen") });
runtime.replay({ type: "end" });
writeCallbacks[0]?.();
expect(terminal.resetCalls).toBe(0);
expect(writeTexts).toEqual(["\u001bcrestored screen"]);
expect(writeTexts).toEqual(["\x1bc", "restored screen"]);
});
it("forces a refit when resize is requested", () => {
@@ -7,7 +7,6 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
import { Terminal, type ITheme, type IMarker } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
type TerminalInputModeState,
TerminalInputModeTracker,
@@ -24,7 +23,6 @@ import {
resolveMacTerminalEditingShortcut,
shouldInterceptDomTerminalKey,
} from "@/utils/terminal-keys";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
import {
createTerminalLocalFileLinkProvider,
type TerminalLocalFileLinkSource,
@@ -55,7 +53,6 @@ const FIND_HIGHLIGHT_LIMIT = 20_000;
export interface TerminalEmulatorRuntimeMountInput {
root: HTMLDivElement;
host: HTMLDivElement;
initialSnapshot: TerminalState | null;
scrollback: number;
theme: ITheme;
fontFamily?: string;
@@ -134,7 +131,7 @@ interface TerminalEmulatorRuntimeDisposables {
}
interface TerminalOutputOperation {
type: "write" | "clear" | "snapshot";
type: "write" | "clear" | "snapshot" | "fit";
data: TerminalOutputData;
rows?: number;
cols?: number;
@@ -280,6 +277,7 @@ export class TerminalEmulatorRuntime {
},
};
private replayResize: TerminalResizeRequest | null = null;
private fitAndEmitResize: ((input?: TerminalResizeRequest) => void) | null = null;
private lastSize: { rows: number; cols: number } | null = null;
private cleanup: (() => void) | null = null;
@@ -652,6 +650,7 @@ export class TerminalEmulatorRuntime {
window.__paseoTerminal = terminal;
const fitAndEmitResize = (resizeInput?: TerminalResizeRequest): void => {
if (this.deferReplayResize(resizeInput)) return;
const forceRefresh = resizeInput?.forceRefresh ?? false;
const shouldClaim = resizeInput?.shouldClaim ?? true;
const forceClaim = resizeInput?.forceClaim ?? false;
@@ -777,10 +776,6 @@ export class TerminalEmulatorRuntime {
fitAndEmitResize({ forceRefresh: true, shouldClaim: false });
}, 0);
if (input.initialSnapshot) {
this.renderSnapshot({ state: input.initialSnapshot });
}
this.processOutputQueue();
const disposables: TerminalEmulatorRuntimeDisposables = {
@@ -898,20 +893,19 @@ export class TerminalEmulatorRuntime {
onFilePaste?.(file);
}
renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void {
if (!input.state) {
this.clear(input);
return;
replay(event: import("@getpaseo/client").TerminalReplayEvent): void {
if (event.type === "begin") {
this.replayResize ??= { shouldClaim: false };
this.restoreOutput({ data: EMPTY_TERMINAL_OUTPUT, rows: event.rows, cols: event.cols });
} else if (event.type === "data") {
this.write({ data: event.data, suppressInput: true });
} else {
this.outputOperations.push({ type: "fit", data: EMPTY_TERMINAL_OUTPUT });
this.processOutputQueue();
}
this.restoreOutput({
data: encodeTerminalOutput(renderTerminalSnapshotToAnsi(input.state)),
rows: input.state.rows,
cols: input.state.cols,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
}
restoreOutput(input: {
private restoreOutput(input: {
data: TerminalOutputData;
rows?: number;
cols?: number;
@@ -928,6 +922,16 @@ export class TerminalEmulatorRuntime {
this.processOutputQueue();
}
private deferReplayResize(input?: TerminalResizeRequest): boolean {
if (!this.replayResize) return false;
this.replayResize = {
forceRefresh: this.replayResize.forceRefresh || input?.forceRefresh,
shouldClaim: this.replayResize.shouldClaim || (input?.shouldClaim ?? true),
forceClaim: this.replayResize.forceClaim || input?.forceClaim,
};
return true;
}
resize(input?: TerminalResizeRequest): void {
this.fitAndEmitResize?.(input);
}
@@ -1047,6 +1051,7 @@ export class TerminalEmulatorRuntime {
commit();
}
this.hasUngatedWrites = false;
this.replayResize = null;
this.cleanup?.();
this.cleanup = null;
@@ -1176,16 +1181,40 @@ export class TerminalEmulatorRuntime {
this.processOutputQueue();
};
if (operation.type === "fit") {
// xterm resize flushes its write buffer synchronously. Leave the parser's
// commit callback before fitting, or that flush consumes the next write.
queueMicrotask(() => {
if (this.inFlightOutputOperation !== operation || this.terminal !== terminal) return;
const resize = this.replayResize ?? { shouldClaim: false };
this.replayResize = null;
this.fitAndEmitResize?.(resize);
finalizeOperation(operation);
});
return;
}
if (operation.type === "clear") {
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
terminal.reset();
finalizeOperation(operation);
if (!this.replayResize) {
finalizeOperation(operation);
return;
}
queueMicrotask(() => {
if (this.inFlightOutputOperation !== operation || this.terminal !== terminal) return;
const resize = this.replayResize ?? { shouldClaim: false };
this.replayResize = null;
this.fitAndEmitResize?.(resize);
finalizeOperation(operation);
});
return;
}
if (operation.type === "snapshot") {
this.replayResize ??= { shouldClaim: false };
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
@@ -31,7 +31,6 @@ function mount(options?: { isMac: boolean }) {
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: 1000,
theme: { background: "#ffffff", foreground: "#111111" },
});
@@ -38,7 +38,6 @@ function mountTerminal(left: number): TerminalView {
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: 1000,
fontFamily: "monospace",
fontSize: 16,
@@ -1,3 +1,4 @@
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
import { page } from "@vitest/browser/context";
import { afterEach, describe, expect, it } from "vitest";
import type { TerminalState } from "@getpaseo/protocol/messages";
@@ -57,7 +58,6 @@ function mount(width: number, height: number): Mounted {
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: 10_000,
theme: { background: "#0b0b0b", foreground: "#e6e6e6", cursor: "#e6e6e6" },
});
@@ -90,9 +90,15 @@ function someRowContains(term: InspectableTerminal, text: string): boolean {
return dumpRows(term).some((r) => r.text.includes(text));
}
function renderSnapshotCommitted(runtime: TerminalEmulatorRuntime, state: TerminalState) {
function replayCommitted(runtime: TerminalEmulatorRuntime, state: TerminalState) {
return new Promise<void>((resolve) => {
runtime.renderSnapshot({ state, onCommitted: resolve });
runtime.replay({ type: "begin", rows: state.rows, cols: state.cols });
runtime.replay({
type: "data",
data: new TextEncoder().encode(renderTerminalSnapshotToAnsi(state)),
});
runtime.replay({ type: "end" });
runtime.write({ data: new Uint8Array([0]), onCommitted: resolve });
});
}
@@ -149,7 +155,7 @@ describe("terminal resize reflow repro (Paseo terminal)", () => {
// 1) Mid-stream snapshot arrives (server overflowed 256KB). It carries the
// long line wrapped at the server width as separate grid rows.
await renderSnapshotCommitted(m.runtime, buildWrappedSnapshot(narrowCols));
await replayCommitted(m.runtime, buildWrappedSnapshot(narrowCols));
await waitFor(() => someRowContains(m.terminal, "[15:30:00.123]"));
// 2) Normal post-snapshot streaming resumes (autowrap on -> soft-wrapped).
@@ -4,25 +4,27 @@ import { resolveTerminalRestoreOptions } from "./terminal-restore-options";
describe("terminal restore options", () => {
it("omits restore options for daemons without terminal restore modes", () => {
expect(
expect(() =>
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: false,
canClaimSize: true,
size: { rows: 24, cols: 80 },
}),
).toBeUndefined();
).toThrow("Update the host to view terminals.");
});
it("requests visible snapshot restore with bounded scrollback for capable daemons", () => {
it("requests visible snapshot restore with configured scrollback for capable daemons", () => {
expect(
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true,
canClaimSize: true,
size: { rows: 24, cols: 80 },
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
scrollbackLines: 10000,
size: { rows: 24, cols: 80 },
});
});
@@ -30,26 +32,39 @@ describe("terminal restore options", () => {
it("omits size until the terminal has been measured", () => {
expect(
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true,
canClaimSize: true,
size: null,
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
scrollbackLines: 10000,
});
});
it("does not let a background attach resize the PTY", () => {
expect(
resolveTerminalRestoreOptions({
scrollbackLines: 10000,
supportsTerminalRestoreModes: true,
canClaimSize: false,
size: { rows: 24, cols: 80 },
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
scrollbackLines: 10000,
});
});
});
it.each([0, 5, 400, 10000])("requests exactly %s configured history rows", (scrollbackLines) => {
expect(
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: true,
scrollbackLines,
canClaimSize: false,
size: null,
}),
).toEqual({ mode: "visible-snapshot", scrollbackLines });
});
@@ -1,23 +1,23 @@
import { i18n } from "@/i18n/i18next";
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
export const TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES = 200;
export interface ResolveTerminalRestoreOptionsInput {
supportsTerminalRestoreModes: boolean;
scrollbackLines: number;
canClaimSize: boolean;
size: { rows: number; cols: number } | null;
}
export function resolveTerminalRestoreOptions(
input: ResolveTerminalRestoreOptionsInput,
): SubscribeTerminalRequest["restore"] | undefined {
): NonNullable<SubscribeTerminalRequest["restore"]> {
if (!input.supportsTerminalRestoreModes) {
return undefined;
throw new Error(i18n.t("workspace.terminal.restoreRequiresUpdate"));
}
return {
mode: "visible-snapshot",
scrollbackLines: TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES,
scrollbackLines: input.scrollbackLines,
...(input.canClaimSize && input.size ? { size: input.size } : {}),
};
}
@@ -2,7 +2,7 @@ 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";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
interface SnapshotCell {
char: string;
@@ -1 +0,0 @@
export { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
@@ -1,3 +1,4 @@
import { resolveTerminalRestoreOptions } from "./terminal-restore-options";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
@@ -7,34 +8,16 @@ import {
type TerminalStreamControllerStatus,
} from "./terminal-stream-controller";
interface TerminalSnapshot {
rows: number;
cols: number;
grid: Array<Array<{ char: string }>>;
scrollback: Array<Array<{ char: string }>>;
cursor: { row: number; col: number };
}
function terminalCellText(cell: { char: string }): string {
return cell.char;
}
function terminalRowText(row: Array<{ char: string }>): string {
return row.map(terminalCellText).join("");
}
function terminalSnapshotText(state: TerminalSnapshot): string {
return state.grid.map(terminalRowText).join("\n");
}
function terminalOutput(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot }
| { terminalId: string; type: "restore"; data: Uint8Array };
type StreamEvent = import("@getpaseo/client").TerminalStreamEvent;
type TerminalStreamEvent = StreamEvent extends infer Event
? Event extends StreamEvent
? Omit<Event, "subscriptionId">
: never
: never;
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
private readonly updates = new Set<(message: SessionOutboundMessage) => void>();
@@ -115,32 +98,23 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
function createHarness(input?: { client?: FakeTerminalStreamClient }) {
const client = input?.client ?? new FakeTerminalStreamClient();
const outputs: Array<{ terminalId: string; data: Uint8Array }> = [];
const restores: Array<{ terminalId: string; data: Uint8Array }> = [];
const snapshots: Array<{ terminalId: string; text: string }> = [];
const replays: import("@getpaseo/client").TerminalReplayEvent[] = [];
const statuses: TerminalStreamControllerStatus[] = [];
const exits: string[] = [];
const controller = new TerminalStreamController({
client,
onReplay: ({ replay }) => replays.push(replay),
onExit: (terminalId) => exits.push(terminalId),
getPreferredSize: () => ({ rows: 24, cols: 80 }),
onOutput: (output) => {
outputs.push(output);
},
onRestore: (restore) => {
restores.push(restore);
},
onSnapshot: ({ terminalId, state }) => {
snapshots.push({
terminalId,
text: terminalSnapshotText(state),
});
},
onStatusChange: (status) => {
statuses.push(status);
},
});
return { client, controller, outputs, restores, snapshots, statuses, exits };
return { client, controller, outputs, replays, statuses, exits };
}
async function flushAsyncWork(): Promise<void> {
@@ -149,24 +123,20 @@ async function flushAsyncWork(): Promise<void> {
}
describe("terminal-stream-controller", () => {
it("subscribes, resizes, and forwards snapshot/output events", async () => {
it("subscribes, resizes, and forwards replay/output events", async () => {
const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
harness.client.emit({
terminalId: "term-1",
type: "snapshot",
state: {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
},
});
const replay: import("@getpaseo/client").TerminalReplayEvent[] = [
{ type: "begin", rows: 24, cols: 80, retainedHistoryLines: 400 },
{ type: "data", data: terminalOutput("hello") },
{ type: "end" },
];
for (const event of replay)
harness.client.emit({ terminalId: "term-1", type: "replay", replay: event });
const outputData = terminalOutput(" world");
harness.client.emit({
terminalId: "term-1",
@@ -178,7 +148,7 @@ describe("terminal-stream-controller", () => {
expect(harness.client.resizeCalls).toEqual([
{ terminalId: "term-1", rows: 24, cols: 80, intent: "claim" },
]);
expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]);
expect(harness.replays).toEqual(replay);
expect(harness.outputs[0]?.data).toBe(outputData);
expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]);
expect(harness.statuses.at(-1)).toEqual({
@@ -236,23 +206,19 @@ describe("terminal-stream-controller", () => {
size: { rows: 24, cols: 80 },
}),
onOutput: (output) => harness.outputs.push(output),
onRestore: (restore) => harness.restores.push(restore),
onSnapshot: ({ terminalId, state }) => {
harness.snapshots.push({
terminalId,
text: terminalSnapshotText(state),
});
},
onReplay: ({ replay }) => harness.replays.push(replay),
});
controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
const restoreData = terminalOutput("restored");
client.emit({
terminalId: "term-1",
type: "restore",
data: restoreData,
});
const replay: import("@getpaseo/client").TerminalReplayEvent[] = [
{ type: "begin" },
{ type: "data", data: restoreData },
{ type: "end" },
];
for (const event of replay)
client.emit({ terminalId: "term-1", type: "replay", replay: event });
controller.dispose();
expect(client.subscribeCalls).toEqual([
@@ -270,8 +236,7 @@ describe("terminal-stream-controller", () => {
expect(client.resizeCalls).toEqual([
{ terminalId: "term-1", rows: 24, cols: 80, intent: "claim" },
]);
expect(harness.restores[0]?.data).toBe(restoreData);
expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]);
expect(harness.replays).toEqual(replay);
expect(harness.outputs).toEqual([]);
});
@@ -319,3 +284,28 @@ it("reports observation failure without declaring the PTY exited, then allows re
expect(h.statuses.at(-1)?.error).toBe(null);
h.controller.dispose();
});
it("does not resize or attach on a JSON-era host and reports the required update", () => {
const client = new FakeTerminalStreamClient();
const statuses: TerminalStreamControllerStatus[] = [];
const controller = new TerminalStreamController({
client,
getPreferredSize: () => ({ rows: 24, cols: 40 }),
onOutput: () => {},
getRestoreOptions: () =>
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: false,
scrollbackLines: 10000,
canClaimSize: true,
size: { rows: 24, cols: 40 },
}),
onStatusChange: (status) => statuses.push(status),
});
controller.setTerminal({ terminalId: "old-host-terminal" });
expect(client.subscribeCalls).toEqual([]);
expect(client.resizeCalls).toEqual([]);
expect(statuses.at(-1)).toMatchObject({
isAttaching: false,
error: "Update the host to view terminals.",
});
});
@@ -1,5 +1,5 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { SubscribeTerminalRequest, TerminalState } from "@getpaseo/protocol/messages";
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
import type { TerminalOutputData } from "./terminal-emulator-runtime";
import { i18n } from "@/i18n/i18next";
@@ -23,9 +23,11 @@ export interface TerminalStreamControllerOptions {
client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null;
onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void;
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
onExit?: (terminalId: string) => void;
onRestore?: (input: { terminalId: string; data: TerminalOutputData }) => void;
onReplay?: (input: {
terminalId: string;
replay: import("@getpaseo/client").TerminalReplayEvent;
}) => void;
getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined;
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
}
@@ -43,17 +45,11 @@ export class TerminalStreamController {
if (this.disposed || event.terminalId !== this.terminalId) {
return;
}
if (event.type === "snapshot") {
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state });
if (event.type === "replay") {
this.options.onReplay?.({ terminalId: event.terminalId, replay: event.replay });
return;
}
if (event.type === "restore") {
if (event.data.length > 0) {
this.options.onRestore?.({ terminalId: event.terminalId, data: event.data });
}
return;
}
if (event.data.length > 0) {
if (event.type === "output" && event.data.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, data: event.data });
}
};
@@ -70,10 +66,10 @@ export class TerminalStreamController {
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
return;
}
const restore = this.options.getRestoreOptions?.();
this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null });
let subscription: ReturnType<DaemonClient["observeTerminal"]>;
try {
const restore = this.options.getRestoreOptions?.();
const preferredSize = restore?.size ?? this.options.getPreferredSize();
if (preferredSize)
this.options.client.sendTerminalInput(nextTerminalId, {
@@ -1,75 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getWorkspaceTerminalSession,
releaseWorkspaceTerminalSession,
retainWorkspaceTerminalSession,
} from "./workspace-terminal-session";
describe("workspace-terminal-session", () => {
it("returns the same workspace session instance for the same scope", () => {
const first = getWorkspaceTerminalSession({
scopeKey: "workspace-a",
});
const second = getWorkspaceTerminalSession({
scopeKey: "workspace-a",
});
expect(second).toBe(first);
});
it("preserves snapshots across repeated lookups", () => {
const first = getWorkspaceTerminalSession({
scopeKey: "workspace-snapshots",
});
first.snapshots.set({
terminalId: "term-1",
state: {
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
},
});
const second = getWorkspaceTerminalSession({
scopeKey: "workspace-snapshots",
});
expect(second.snapshots.get({ terminalId: "term-1" })).toEqual({
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
});
});
it("evicts workspace terminal session state when the retain count returns to zero", () => {
const scopeKey = "workspace-release";
const first = getWorkspaceTerminalSession({
scopeKey,
});
first.snapshots.set({
terminalId: "term-1",
state: {
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
},
});
retainWorkspaceTerminalSession({ scopeKey });
releaseWorkspaceTerminalSession({ scopeKey });
const second = getWorkspaceTerminalSession({
scopeKey,
});
expect(second).not.toBe(first);
expect(second.snapshots.get({ terminalId: "term-1" })).toBeNull();
});
});
@@ -1,79 +0,0 @@
import type { TerminalState } from "@getpaseo/protocol/messages";
export interface WorkspaceTerminalSnapshots {
get: (input: { terminalId: string }) => TerminalState | null;
set: (input: { terminalId: string; state: TerminalState }) => void;
clear: (input: { terminalId: string }) => void;
prune: (input: { terminalIds: string[] }) => void;
}
export interface WorkspaceTerminalSession {
scopeKey: string;
snapshots: WorkspaceTerminalSnapshots;
}
interface WorkspaceTerminalSessionRecord {
snapshotByTerminalId: Map<string, TerminalState>;
session: WorkspaceTerminalSession;
}
const sessionsByScopeKey = new Map<string, WorkspaceTerminalSessionRecord>();
const refCountByScopeKey = new Map<string, number>();
function createSnapshots(input: {
snapshotByTerminalId: Map<string, TerminalState>;
}): WorkspaceTerminalSnapshots {
return {
get: ({ terminalId }) => input.snapshotByTerminalId.get(terminalId) ?? null,
set: ({ terminalId, state }) => {
input.snapshotByTerminalId.set(terminalId, state);
},
clear: ({ terminalId }) => {
input.snapshotByTerminalId.delete(terminalId);
},
prune: ({ terminalIds }) => {
const terminalIdSet = new Set(terminalIds);
for (const terminalId of Array.from(input.snapshotByTerminalId.keys())) {
if (!terminalIdSet.has(terminalId)) {
input.snapshotByTerminalId.delete(terminalId);
}
}
},
};
}
export function getWorkspaceTerminalSession(input: { scopeKey: string }): WorkspaceTerminalSession {
const existing = sessionsByScopeKey.get(input.scopeKey);
if (existing) {
return existing.session;
}
const snapshotByTerminalId = new Map<string, TerminalState>();
const session: WorkspaceTerminalSession = {
scopeKey: input.scopeKey,
snapshots: createSnapshots({
snapshotByTerminalId,
}),
};
sessionsByScopeKey.set(input.scopeKey, {
snapshotByTerminalId,
session,
});
return session;
}
export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void {
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
refCountByScopeKey.set(input.scopeKey, current + 1);
}
export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void {
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
if (current > 1) {
refCountByScopeKey.set(input.scopeKey, current - 1);
return;
}
refCountByScopeKey.delete(input.scopeKey);
sessionsByScopeKey.delete(input.scopeKey);
}
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import {
applyTerminalRendererReadyChange,
resolveTerminalStreamTarget,
shouldReplayTerminalSnapshotForRenderer,
shouldShowTerminalLoadingOverlay,
} from "./terminal-renderer-readiness";
@@ -74,27 +73,6 @@ describe("terminal-renderer-readiness", () => {
expect(current).toBeNull();
});
it("replays snapshots only for ready events from the current renderer", () => {
expect(
shouldReplayTerminalSnapshotForRenderer({
change: { streamKey: "scope:terminal-1", isReady: true },
terminalStreamKey: "scope:terminal-1",
}),
).toBe(true);
expect(
shouldReplayTerminalSnapshotForRenderer({
change: { streamKey: "scope:terminal-1", isReady: false },
terminalStreamKey: "scope:terminal-1",
}),
).toBe(false);
expect(
shouldReplayTerminalSnapshotForRenderer({
change: { streamKey: "scope:terminal-1", isReady: true },
terminalStreamKey: "scope:terminal-2",
}),
).toBe(false);
});
it("does not attach the stream before the matching renderer is ready", () => {
expect(
resolveTerminalStreamTarget({
@@ -14,13 +14,6 @@ export function applyTerminalRendererReadyChange(
return currentReadyStreamKey === change.streamKey ? null : currentReadyStreamKey;
}
export function shouldReplayTerminalSnapshotForRenderer(input: {
change: TerminalRendererReadyChange;
terminalStreamKey: string;
}): boolean {
return input.change.isReady && input.change.streamKey === input.terminalStreamKey;
}
export function resolveTerminalStreamTarget(input: {
terminalId: string;
terminalStreamKey: string;
+1
View File
@@ -125,6 +125,7 @@ export class ConnectionSubscriptions extends OwnedSubscriptions {
// Protocol support belongs to the installed client. Only browser hosting needs
// a resource supplied by the caller. Keep this exhaustive as the protocol evolves.
export const DEFAULT_CLIENT_CAPABILITIES = {
[CLIENT_CAPS.terminalChunkedReplay]: true,
[CLIENT_CAPS.helloRejection]: true,
[CLIENT_CAPS.ownedSubscriptions]: true,
[CLIENT_CAPS.allProviders]: true,
+155 -54
View File
@@ -1101,6 +1101,7 @@ test("advertises client capabilities in hello", async () => {
projected_subagent_timeline: true,
reasoning_merge_enum: true,
terminal_reflowable_snapshot: true,
terminal_chunked_replay: true,
timeline_notifications: true,
plugin_timeline_items: true,
workspace_setup_blocked: true,
@@ -6387,66 +6388,80 @@ test("emits output events for the active terminal stream", async () => {
unsubscribe();
});
test("emits snapshot events for the subscribed terminal stream", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
test.each([5, 500, 10000])(
"preserves explicit %s history demand on an old ANSI host and normalizes restore",
async (history) => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const snapshots: unknown[] = [];
client.onTerminalStreamEvent((event) => {
if (event.type !== "snapshot") {
return;
}
snapshots.push(event.state);
});
const events: unknown[] = [];
const replayEvents: unknown[] = [];
client.onTerminalStreamEvent((event) => {
if (event.type === "replay") replayEvents.push(event.replay);
events.push(event);
});
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-2" });
await vi.waitFor(() =>
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
terminalId: "term-1",
const subscribePromise = client.subscribeTerminal("term-1", {
requestId: "sub-2",
restore: { mode: "visible-snapshot", scrollbackLines: history },
});
await vi.waitFor(() =>
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
expect(parseSentFrame(mock.sent.at(-1)).restore).toEqual({
mode: "visible-snapshot",
scrollbackLines: history,
});
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
terminalId: "term-1",
slot: 12,
error: null,
requestId: "sub-2",
},
}),
);
await subscribePromise;
const state = {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
};
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: 12,
error: null,
requestId: "sub-2",
},
}),
);
await subscribePromise;
payload: encodeTerminalSnapshotPayload(state),
}),
);
const state = {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
};
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: 12,
payload: encodeTerminalSnapshotPayload(state),
}),
);
expect(snapshots).toEqual([state]);
});
expect(events).toEqual([]);
const data = new TextEncoder().encode("\x1b[31mold ANSI host界🙂");
mock.triggerMessage(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 12, payload: data }),
);
expect(replayEvents).toEqual([{ type: "begin" }, { type: "data", data }, { type: "end" }]);
},
);
test("sends explicit terminal input and resize commands", async () => {
const logger = createMockLogger();
@@ -8471,3 +8486,89 @@ test("closes before admission when a protected-capable daemon is reached in lega
expect(daemonReceived).toHaveLength(1);
expect(JSON.parse(daemonReceived[0] as string)).toMatchObject({ type: "hello" });
});
test.each([false, true])(
"default terminal observation requests ANSI replay (chunked=%s)",
async (chunked) => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen({
features: { ownedSubscriptions: true, "terminal-restore-modes": true },
});
await connecting;
const events: import("./terminal-stream-router").TerminalStreamEvent[] = [];
const observation = client.observeTerminal("default-terminal", (event) => events.push(event));
await vi.waitFor(() =>
expect(parseSentFrame(transport.sent.at(-1)).type).toBe("subscribe_terminal_request"),
);
const request = parseSentFrame(transport.sent.at(-1));
expect(request.restore).toEqual({ mode: "full-snapshot" });
transport.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
terminalId: "default-terminal",
requestId: request.requestId,
subscriptionId: "default-subscription",
slot: 2,
error: null,
},
}),
);
await observation.ready;
const data = new TextEncoder().encode("\x1b[31mretained 界🙂\x1b[0m");
const metadata = { rows: 24, cols: 80, retainedHistoryLines: 1000 };
if (chunked) {
transport.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.ReplayBegin,
slot: 2,
payload: JSON.stringify(metadata),
}),
);
transport.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.ReplayData,
slot: 2,
payload: data,
}),
);
transport.triggerMessage(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.ReplayEnd, slot: 2 }),
);
} else {
// An ANSI-era host selects its existing Restore path for this explicit request.
transport.triggerMessage(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 2, payload: data }),
);
}
expect(events).toEqual([
{
terminalId: "default-terminal",
subscriptionId: "default-subscription",
type: "replay",
replay: chunked ? { type: "begin", ...metadata } : { type: "begin" },
},
{
terminalId: "default-terminal",
subscriptionId: "default-subscription",
type: "replay",
replay: { type: "data", data },
},
{
terminalId: "default-terminal",
subscriptionId: "default-subscription",
type: "replay",
replay: { type: "end" },
},
]);
},
);
+3 -2
View File
@@ -6414,7 +6414,8 @@ export class DaemonClient {
}
/**
* Snapshot/restore and output arrive in receive. Subscribe to the returned handle
* Defaults to a full ANSI replay. Replay begin/data/end and live output arrive in receive.
* Subscribe to the returned handle
* for terminal_stream_exit: payload.error means observation failure, not PTY exit.
* Either outcome detaches this slot and releases the handle automatically.
*/
@@ -6433,7 +6434,7 @@ export class DaemonClient {
{
type: "subscribe_terminal_request",
terminalId,
...(options?.restore ? { restore: options.restore } : {}),
restore: options?.restore ?? { mode: "full-snapshot" },
},
options,
);
+1
View File
@@ -1,3 +1,4 @@
export type { TerminalReplayEvent, TerminalStreamEvent } from "./terminal-stream-router.js";
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
export type { AgentAddress } from "@getpaseo/protocol/addresses";
@@ -4,7 +4,7 @@ import { describe, expect, test } from "vitest";
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
describe("terminal-stream-router", () => {
test("routes restore frames as restore events", () => {
test("normalizes old ANSI restore into the replay lifecycle", () => {
const router = new TerminalStreamRouter();
const events: TerminalStreamEvent[] = [];
const payload = new TextEncoder().encode("restored screen");
@@ -16,14 +16,14 @@ describe("terminal-stream-router", () => {
payload,
});
expect(events).toEqual([
{
expect(events).toEqual(
["begin", "data", "end"].map((type) => ({
terminalId: "term-1",
subscriptionId: "subscription-1",
type: "restore",
data: payload,
},
]);
type: "replay",
replay: type === "data" ? { type, data: payload } : { type },
})),
);
});
});
@@ -61,3 +61,45 @@ test("legacy listeners share a terminal slot without releasing each other", () =
router.handleFrame(frame);
expect(received).toEqual(["first", "second", "second"]);
});
test("streams chunks without joining and ignores released slots during replay", () => {
const router = new TerminalStreamRouter();
const events: TerminalStreamEvent[] = [];
const release = router.attach("old", "terminal", 4, (event) => events.push(event));
const payload = new TextEncoder().encode(
JSON.stringify({ rows: 24, cols: 80, retainedHistoryLines: 1000 }),
);
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayBegin, slot: 4, payload });
const data = new TextEncoder().encode("\x1b[31m界🙂");
router.handleFrame({
opcode: TerminalStreamOpcode.ReplayData,
slot: 4,
payload: data.subarray(0, 7),
});
release();
router.attach("new", "terminal", 5, (event) => events.push(event));
router.handleFrame({
opcode: TerminalStreamOpcode.ReplayData,
slot: 4,
payload: data.subarray(7),
});
router.handleFrame({
opcode: TerminalStreamOpcode.ReplayEnd,
slot: 4,
payload: new Uint8Array(),
});
expect(events).toHaveLength(2);
expect(events[1]).toMatchObject({
subscriptionId: "old",
type: "replay",
replay: { type: "data", data: data.subarray(0, 7) },
});
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayBegin, slot: 5, payload });
router.handleFrame({ opcode: TerminalStreamOpcode.ReplayData, slot: 5, payload: data });
router.handleFrame({
opcode: TerminalStreamOpcode.ReplayEnd,
slot: 5,
payload: new Uint8Array(),
});
expect(events.slice(2).map((event) => event.subscriptionId)).toEqual(["new", "new", "new"]);
});
+40 -15
View File
@@ -1,14 +1,17 @@
import {
decodeTerminalSnapshotPayload,
TerminalStreamOpcode,
TerminalReplayMetadataSchema,
type TerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type { TerminalState } from "@getpaseo/protocol/messages";
export type TerminalReplayEvent =
| { type: "begin"; rows?: number; cols?: number; retainedHistoryLines?: number }
| { type: "data"; data: Uint8Array }
| { type: "end" };
export type TerminalStreamEvent = { terminalId: string; subscriptionId: string } & (
| { type: "output"; data: Uint8Array }
| { type: "snapshot"; state: TerminalState }
| { type: "restore"; data: Uint8Array }
| { type: "replay"; replay: TerminalReplayEvent }
);
export class TerminalStreamRouter {
@@ -61,20 +64,42 @@ export class TerminalStreamRouter {
let event: TerminalStreamEvent;
if (frame.opcode === TerminalStreamOpcode.Output)
event = { ...identity, type: "output", data: frame.payload };
else if (frame.opcode === TerminalStreamOpcode.Restore)
event = { ...identity, type: "restore", data: frame.payload };
else if (frame.opcode === TerminalStreamOpcode.Snapshot) {
const state = decodeTerminalSnapshotPayload(frame.payload);
if (!state) return;
event = { ...identity, type: "snapshot", state };
} else return;
registration.receive(event);
for (const listener of this.listeners) {
else if (frame.opcode === TerminalStreamOpcode.Restore) {
// COMPAT(terminalSingleRestore): added after v0.14.0; remove when ANSI host floor supports chunked replay after 2027-04-30.
for (const replay of [
{ type: "begin" },
{ type: "data", data: frame.payload },
{ type: "end" },
] as TerminalReplayEvent[])
this.deliver(registration.receive, { ...identity, type: "replay", replay });
continue;
} else if (frame.opcode === TerminalStreamOpcode.ReplayBegin) {
let raw: unknown;
try {
listener(event);
raw = JSON.parse(new TextDecoder().decode(frame.payload));
} catch {
/* Passive diagnostics cannot interrupt delivery. */
return;
}
const parsed = TerminalReplayMetadataSchema.safeParse(raw);
if (!parsed.success) return;
const metadata = parsed.data;
event = { ...identity, type: "replay", replay: { type: "begin", ...metadata } };
} else if (frame.opcode === TerminalStreamOpcode.ReplayData)
event = { ...identity, type: "replay", replay: { type: "data", data: frame.payload } };
else if (frame.opcode === TerminalStreamOpcode.ReplayEnd)
event = { ...identity, type: "replay", replay: { type: "end" } };
else return;
this.deliver(registration.receive, event);
}
}
private deliver(receive: (event: TerminalStreamEvent) => void, event: TerminalStreamEvent): void {
receive(event);
for (const listener of this.listeners) {
try {
listener(event);
} catch {
/* Passive diagnostics cannot interrupt delivery. */
}
}
}
@@ -89,3 +89,18 @@ describe("host route binary framing", () => {
).toThrow("Invalid host route ID");
});
});
it.each([
TerminalStreamOpcode.ReplayBegin,
TerminalStreamOpcode.ReplayData,
TerminalStreamOpcode.ReplayEnd,
])("decodes replay opcode %s directly and inside a host route", (opcode) => {
const payload = new TextEncoder().encode("界 replay");
const bytes = encodeTerminalStreamFrame({ opcode, slot: 12, payload });
const expected = { kind: "terminal", frame: { opcode, slot: 12, payload } };
expect(decodeBinaryFrame(bytes)).toEqual(expected);
const routed = decodeBinaryFrame(encodeHostRouteFrame("host", bytes));
expect(routed?.kind).toBe("host_route");
if (routed?.kind !== "host_route") throw new Error("Missing host route");
expect(decodeBinaryFrame(routed.frame.payload)).toEqual(expected);
});
@@ -25,6 +25,9 @@ export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
case TerminalStreamOpcode.Input:
case TerminalStreamOpcode.Resize:
case TerminalStreamOpcode.Snapshot:
case TerminalStreamOpcode.ReplayBegin:
case TerminalStreamOpcode.ReplayData:
case TerminalStreamOpcode.ReplayEnd:
case TerminalStreamOpcode.Restore: {
const frame = decodeTerminalStreamFrame(bytes);
return frame ? { kind: "terminal", frame } : null;
@@ -1,6 +1,12 @@
import { z } from "zod";
import { TerminalStateSchema } from "../messages.js";
export const TerminalReplayMetadataSchema = z.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
retainedHistoryLines: z.number().int().nonnegative(),
});
export const TerminalStreamResizeSchema = z.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
@@ -13,6 +19,9 @@ export const TerminalStreamOpcode = {
Resize: 0x03,
Snapshot: 0x04,
Restore: 0x05,
ReplayBegin: 0x06,
ReplayData: 0x07,
ReplayEnd: 0x08,
} as const;
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
@@ -58,6 +67,9 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
value === TerminalStreamOpcode.Input ||
value === TerminalStreamOpcode.Resize ||
value === TerminalStreamOpcode.Snapshot ||
value === TerminalStreamOpcode.ReplayBegin ||
value === TerminalStreamOpcode.ReplayData ||
value === TerminalStreamOpcode.ReplayEnd ||
value === TerminalStreamOpcode.Restore
);
}
@@ -1,4 +1,5 @@
export const CLIENT_CAPS = {
terminalChunkedReplay: "terminal_chunked_replay",
helloRejection: "hello_rejection",
// COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09.
ownedSubscriptions: "owned_subscriptions",
@@ -1493,7 +1493,6 @@ describe("terminal MCP tools", () => {
id: "term-1",
name: "daemon",
cwd: process.cwd(),
getState: vi.fn().mockReturnValue({ scrollback: [], grid: [[]] }),
}),
captureTerminal,
});
@@ -7,7 +7,6 @@ import { Terminal as HeadlessTerminal, type IBufferCell, type IBufferLine } from
import { expect, test } from "vitest";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
import type { TerminalStreamEvent } from "@getpaseo/client/internal/daemon-client";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -46,7 +45,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac
terminalId: terminal.id,
headless: liveHeadless,
});
const initialSnapshot = waitForTerminalStreamEvent(client, terminal.id, "snapshot");
const initialSnapshot = waitForReplayEnd(client, terminal.id);
const liveSubscription = await subscribeTerminal(client, terminal.id);
await initialSnapshot;
@@ -60,13 +59,13 @@ test("byte-stream headless terminal matches daemon state after high-output attac
text: BYTE_DONE_MARKER,
rows: BYTE_TEST_SIZE.rows,
});
const daemonSnapshot = await readTerminalSnapshot({
const daemonSnapshot = await readReplayViewport({
port: daemon.port,
terminalId: terminal.id,
});
await liveHeadless.flush();
expect(liveHeadless.visibleLines()).toEqual(visibleTerminalLines(daemonSnapshot));
expect(liveHeadless.visibleLines()).toEqual(daemonSnapshot.lines);
expect(liveHeadless.cursor()).toEqual(daemonSnapshot.cursor);
expect(liveHeadless.visibleLines()).toEqual(capturedViewport.lines);
@@ -79,7 +78,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac
terminalId: terminal.id,
headless: restoreHeadless,
});
const restoreFrame = waitForTerminalStreamEvent(client, terminal.id, "restore");
const restoreFrame = waitForReplayEnd(client, terminal.id);
await subscribeTerminal(client, terminal.id, {
restore: { mode: "visible-snapshot", scrollbackLines: 200, size: BYTE_TEST_SIZE },
@@ -116,12 +115,10 @@ class ClientHeadlessTerminal {
}
applyEvent(event: TerminalStreamEvent): void {
if (event.type === "snapshot") {
this.enqueueText(renderTerminalSnapshotToAnsi(event.state), { resetDecoder: true });
return;
}
if (event.type === "restore") {
this.enqueueText(this.decoder.decode(event.data, { stream: false }), { resetDecoder: true });
if (event.type === "replay") {
if (event.replay.type === "begin") this.enqueueText("\x1bc", { resetDecoder: true });
if (event.replay.type === "data")
this.enqueueText(this.decoder.decode(event.replay.data, { stream: true }));
return;
}
this.enqueueText(this.decoder.decode(event.data, { stream: true }));
@@ -187,35 +184,32 @@ async function subscribeTerminal(
return response.subscription;
}
async function readTerminalSnapshot(input: {
port: number;
terminalId: string;
}): Promise<TerminalState> {
const client = new DaemonClient({
url: `ws://127.0.0.1:${input.port}/ws`,
appVersion: "0.1.96",
clientType: "mobile",
});
async function readReplayViewport(input: { port: number; terminalId: string }): Promise<{
lines: string[];
cursor: TerminalState["cursor"];
}> {
const client = new DaemonClient({ url: `ws://127.0.0.1:${input.port}/ws`, clientType: "mobile" });
const headless = new ClientHeadlessTerminal(BYTE_TEST_SIZE);
const stop = pipeTerminalStreamToHeadless({ client, terminalId: input.terminalId, headless });
try {
await client.connect();
const snapshot = waitForTerminalStreamEvent(client, input.terminalId, "snapshot");
const completed = waitForReplayEnd(client, input.terminalId);
await subscribeTerminal(client, input.terminalId);
const event = await snapshot;
return event.state;
await completed;
await headless.flush();
return { lines: headless.visibleLines(), cursor: headless.cursor() };
} finally {
stop();
await client.close();
}
}
function waitForTerminalStreamEvent<TType extends TerminalStreamEvent["type"]>(
client: DaemonClient,
terminalId: string,
type: TType,
): Promise<Extract<TerminalStreamEvent, { type: TType }>> {
return client.waitForTerminalStreamEvent(
(event) => event.terminalId === terminalId && event.type === type,
10_000,
) as Promise<Extract<TerminalStreamEvent, { type: TType }>>;
async function waitForReplayEnd(client: DaemonClient, terminalId: string): Promise<void> {
await client.waitForTerminalStreamEvent(
(event) =>
event.terminalId === terminalId && event.type === "replay" && event.replay.type === "end",
10000,
);
}
async function createPackageLockTerminalCwd(): Promise<PackageLockTerminalCwd> {
@@ -287,24 +281,16 @@ async function waitForCondition<T>(
throw new Error(`Timed out after ${timeoutMs}ms waiting for terminal byte parity`);
}
function visibleTerminalLines(state: TerminalState): string[] {
return state.grid.map(terminalRowText);
}
function terminalRowText(row: TerminalState["grid"][number]): string {
return row
.map((cell) => cell.char)
.join("")
.trimEnd();
}
function bufferLineText(line: IBufferLine | undefined, reusableCell: IBufferCell): string {
const cells: TerminalCell[] = [];
for (let col = 0; col < BYTE_TEST_SIZE.cols; col += 1) {
const cell = line?.getCell(col, reusableCell);
cells.push({ char: cell?.getChars() || " " });
}
return terminalRowText(cells);
return cells
.map((cell) => cell.char)
.join("")
.trimEnd();
}
function packageLockStreamScript(): string {
@@ -1,3 +1,4 @@
import { Terminal as HeadlessTerminal } from "@xterm/headless";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
@@ -10,7 +11,6 @@ import {
type WSOutboundMessage,
} from "@getpaseo/protocol/messages";
import {
decodeTerminalSnapshotPayload,
decodeTerminalStreamFrame,
encodeTerminalStreamFrame,
TerminalStreamOpcode,
@@ -80,6 +80,24 @@ async function waitForCondition(
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
}
function readHeadlessState(terminal: HeadlessTerminal): TerminalState {
const buffer = terminal.buffer.active;
const lines: TerminalState["grid"] = [];
for (let row = 0; row < buffer.length; row++) {
const cells: TerminalState["grid"][number] = [];
for (let col = 0; col < terminal.cols; col++)
cells.push({ char: buffer.getLine(row)?.getCell(col)?.getChars() ?? "" });
lines.push(cells);
}
return {
rows: terminal.rows,
cols: terminal.cols,
grid: lines.slice(buffer.baseY),
scrollback: lines.slice(0, buffer.baseY),
cursor: { row: buffer.cursorY, col: buffer.cursorX },
};
}
async function waitForTerminalSnapshot(
client: DaemonClient,
terminalId: string,
@@ -92,16 +110,34 @@ async function waitForTerminalSnapshot(
reject(new Error(`Timeout waiting for terminal snapshot (${timeout}ms)`));
}, timeout);
let terminal: HeadlessTerminal | null = null;
let writes = Promise.resolve();
const unsubscribe = client.onTerminalStreamEvent((event) => {
if (event.terminalId !== terminalId || event.type !== "snapshot") {
return;
if (event.terminalId !== terminalId || event.type !== "replay") return;
const replay = event.replay;
if (replay.type === "begin") {
terminal?.dispose();
terminal = new HeadlessTerminal({
rows: replay.rows ?? 24,
cols: replay.cols ?? 80,
scrollback: 1000,
allowProposedApi: true,
});
} else if (replay.type === "data") {
const active = terminal!;
writes = writes.then(() => new Promise<void>((done) => active.write(replay.data, done)));
} else {
void writes.then(() => {
const active = terminal!;
const state = readHeadlessState(active);
if (!predicate(state)) return null;
clearTimeout(timeoutHandle);
unsubscribe();
active.dispose();
resolve(state);
return null;
});
}
if (!predicate(event.state)) {
return;
}
clearTimeout(timeoutHandle);
unsubscribe();
resolve(event.state);
});
});
}
@@ -386,17 +422,24 @@ async function collectRawBinaryFrames(
});
}
async function decodeRestoreState(
frame: TerminalStreamFrame,
terminalId: string,
): Promise<TerminalState> {
const size = ctx.daemon.daemon.terminalManager!.getTerminal(terminalId)!.getSize();
const terminal = new HeadlessTerminal({ ...size, scrollback: 1000, allowProposedApi: true });
try {
await new Promise<void>((resolve) => terminal.write(frame.payload, resolve));
return readHeadlessState(terminal);
} finally {
terminal.dispose();
}
}
function getFrameText(frame: TerminalStreamFrame): string {
if (frame.opcode === TerminalStreamOpcode.Output) {
return new TextDecoder().decode(frame.payload);
}
if (frame.opcode === TerminalStreamOpcode.Snapshot) {
const state = decodeTerminalSnapshotPayload(frame.payload);
if (!state) {
return "";
}
return extractStateText(state);
}
if (frame.opcode === TerminalStreamOpcode.Restore) {
return new TextDecoder().decode(frame.payload);
}
@@ -446,7 +489,7 @@ function startTerminalOutputCapture(ws: WebSocket, slot: number): TerminalOutput
if (!frame || frame.slot !== slot) {
return;
}
if (frame.opcode === TerminalStreamOpcode.Snapshot) {
if (frame.opcode === TerminalStreamOpcode.Restore) {
snapshotCount += 1;
return;
}
@@ -634,7 +677,7 @@ async function measureRepeatCadenceForTerminal(input: {
const slot = await subscribeRawTerminal(ws, terminalId, `sub-repeat-cadence-${input.name}`);
await waitForRawBinaryFrame(
ws,
(frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Restore,
10000,
);
await new Promise((resolve) => setTimeout(resolve, 1000));
@@ -817,13 +860,7 @@ test("live terminal restore skips the initial snapshot", async () => {
expect(outputText).toContain("after-live");
expect(outputText).not.toContain("before-live");
expect(
outputFrames.some(
(frame) =>
frame.opcode === TerminalStreamOpcode.Snapshot ||
frame.opcode === TerminalStreamOpcode.Restore,
),
).toBe(false);
expect(outputFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(false);
} finally {
await closeWebSocket(ws);
}
@@ -901,15 +938,12 @@ test("propagates debounced terminal titles through list responses and snapshots"
}
expect(listedTitle).toBe("Build Output");
const snapshotPromise = waitForTerminalSnapshot(
ctx.client,
terminalId,
(state) => state.title === "Build Output",
const attached = await ctx.client.subscribeTerminal(terminalId);
expect(attached.error).toBeNull();
const afterAttach = await ctx.client.listTerminals(cwd);
expect(afterAttach.terminals.find((terminal) => terminal.id === terminalId)?.title).toBe(
"Build Output",
);
await ctx.client.subscribeTerminal(terminalId);
const snapshot = await snapshotPromise;
expect(snapshot.title).toBe("Build Output");
rmSync(cwd, { recursive: true, force: true });
}, 30000);
@@ -973,13 +1007,10 @@ test("subscribe response is sent before the initial snapshot frame", async () =>
return;
}
const frame = decodeTerminalStreamFrame(new Uint8Array(buffer));
if (frame?.opcode !== TerminalStreamOpcode.Snapshot) {
return;
}
const state = decodeTerminalSnapshotPayload(frame.payload);
if (!state || !extractStateText(state).includes("hello-ordering")) {
if (frame?.opcode !== TerminalStreamOpcode.Restore) {
return;
}
if (!getFrameText(frame).includes("hello-ordering")) return;
events.push("snapshot");
maybeResolve();
};
@@ -1113,7 +1144,7 @@ test("reconnected terminal streams replay active input modes after the snapshot"
const firstSlot = await subscribeRawTerminal(firstWs, terminalId, "sub-input-mode-first");
await waitForRawBinaryFrame(
firstWs,
(frame) => frame.slot === firstSlot && frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.slot === firstSlot && frame.opcode === TerminalStreamOpcode.Restore,
);
sendRawTerminalInput(firstWs, firstSlot, "e");
await waitForRawBinaryFrame(
@@ -1140,7 +1171,7 @@ test("reconnected terminal streams replay active input modes after the snapshot"
const secondSlot = await subscribeRawTerminal(secondWs, terminalId, "sub-input-mode-second");
await waitForRawBinaryFrame(
secondWs,
(frame) => frame.slot === secondSlot && frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.slot === secondSlot && frame.opcode === TerminalStreamOpcode.Restore,
);
const replay = await waitForRawBinaryFrame(
secondWs,
@@ -1172,7 +1203,7 @@ test("reconnected terminal streams do not replay input modes before they are ena
const slot = await subscribeRawTerminal(ws, terminalId, "sub-input-mode-inactive");
await waitForRawBinaryFrame(
ws,
(frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Restore,
);
await waitForNoRawBinaryFrame(ws, 300);
} finally {
@@ -1188,7 +1219,7 @@ test("fast output to a slow websocket client falls back to a snapshot", async ()
const ws = await connectRawWebSocket(ctx.daemon.port);
await subscribeRawTerminal(ws, terminalId, "sub-raw");
await waitForRawBinaryFrame(ws, (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, 10000);
await waitForRawBinaryFrame(ws, (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000);
const rawSocket = (ws as WebSocket & { _socket?: { pause: () => void; resume: () => void } })
._socket;
@@ -1203,12 +1234,12 @@ test("fast output to a slow websocket client falls back to a snapshot", async ()
await new Promise((resolve) => setTimeout(resolve, 750));
const catchUpFramePromise = waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.opcode === TerminalStreamOpcode.Restore,
15000,
);
rawSocket!.resume();
const catchUpFrame = await catchUpFramePromise;
expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Snapshot);
expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Restore);
await closeWebSocket(ws);
rmSync(cwd, { recursive: true, force: true });
@@ -1260,7 +1291,7 @@ test("resize updates server dimensions without sending a live snapshot", async (
await new Promise((resolve) => setTimeout(resolve, 300));
const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getState();
const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getSize();
expect(state?.rows).toBe(10);
expect(state?.cols).toBe(40);
@@ -1277,7 +1308,7 @@ test("the latest resize from any attached client owns the PTY size", async () =>
);
const readSize = () => {
const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getState();
const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getSize();
return state ? { rows: state.rows, cols: state.cols } : null;
};
const expectSize = async (size: { rows: number; cols: number }) => {
@@ -1403,10 +1434,10 @@ test("websocket terminate then new connection gets snapshot with all prior outpu
await subscribeRawTerminal(firstSocket, terminalId, "sub-drop-a");
const initialSnapshot = await waitForRawBinaryFrame(
firstSocket,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000,
);
expect(initialSnapshot.opcode).toBe(TerminalStreamOpcode.Snapshot);
expect(initialSnapshot.opcode).toBe(TerminalStreamOpcode.Restore);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
@@ -1442,9 +1473,9 @@ test("websocket terminate then new connection gets snapshot with all prior outpu
try {
await subscribeRawTerminal(secondSocket, terminalId, "sub-drop-b");
const reconnectFrame = await waitForRawBinaryFrame(secondSocket, () => true, 10000);
expect(reconnectFrame.opcode).toBe(TerminalStreamOpcode.Snapshot);
expect(reconnectFrame.opcode).toBe(TerminalStreamOpcode.Restore);
const state = decodeTerminalSnapshotPayload(reconnectFrame.payload);
const state = await decodeRestoreState(reconnectFrame, terminalId);
expect(state).not.toBeNull();
expect(extractStateText(state!)).toContain("before-drop");
expect(extractStateText(state!)).toContain("while-dead");
@@ -1513,11 +1544,11 @@ test("snapshot fidelity through websocket decode preserves dimensions and visibl
await subscribeRawTerminal(ws, terminalId, "sub-fidelity");
const snapshotFrame = await waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000,
);
const state = decodeTerminalSnapshotPayload(snapshotFrame.payload);
const state = await decodeRestoreState(snapshotFrame, terminalId);
expect(state).not.toBeNull();
expect(state).toMatchObject({
rows: 24,
@@ -1545,7 +1576,7 @@ test("terminal exit prevents resubscribe and sends no frames after exit", async
await subscribeRawTerminal(ws, terminalId, "sub-exit");
await waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000,
);
@@ -1561,8 +1592,7 @@ test("terminal exit prevents resubscribe and sends no frames after exit", async
const exitMessage = await exitMessagePromise;
expect(exitMessage.message.type).toBe("terminal_stream_exit");
const subscribeResult = await ctx.client.subscribeTerminal(terminalId);
expect(subscribeResult.error).toBe("Terminal not found");
await expect(ctx.client.subscribeTerminal(terminalId)).rejects.toThrow("Terminal not found");
await waitForNoRawBinaryFrame(ws, 750);
} finally {
await closeWebSocket(ws);
@@ -1580,7 +1610,7 @@ test("empty input frame does not crash the server", async () => {
const slot = await subscribeRawTerminal(ws, terminalId, "sub-empty-input");
await waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000,
);
@@ -1619,7 +1649,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as
await subscribeRawTerminal(ws, terminalId, "sub-large-output");
await waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
(frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000,
);
@@ -1636,7 +1666,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as
.reduce((sum, frame) => sum + frame.payload.byteLength, 0);
return (
outputBytes >= 1024 * 1024 ||
collectedFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)
collectedFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)
);
},
20000,
@@ -1646,7 +1676,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as
for (const frame of frames) {
expect(
frame.opcode === TerminalStreamOpcode.Output ||
frame.opcode === TerminalStreamOpcode.Snapshot,
frame.opcode === TerminalStreamOpcode.Restore,
).toBe(true);
}
@@ -1836,3 +1866,132 @@ describe("list terminals across directories", () => {
]);
});
});
test.each([400, 3000])(
"chunked SDK replay retains requested history for %s printed lines",
async (count) => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await createTerminalInWorkspace(ctx.client, {
cwd,
options: {
command: "/bin/sh",
args: [
"-lc",
`i=1; while [ $i -le ${count} ]; do printf '\\033[31mreplay-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 30`,
],
},
});
const id = created.terminal!.id;
await waitForCondition(
async () =>
(await ctx.client.captureTerminal(id)).lines
.join("\n")
.includes(`replay-${String(count).padStart(4, "0")}`),
10000,
);
const events: import("@getpaseo/client").TerminalStreamEvent[] = [];
let end!: () => void;
const completed = new Promise<void>((resolve) => {
end = resolve;
});
const subscription = ctx.client.observeTerminal(
id,
(event) => {
events.push(event);
if (event.type === "replay" && event.replay.type === "end") end();
},
{ restore: { mode: "visible-snapshot", scrollbackLines: 10000 } },
);
try {
await subscription.ready;
await completed;
const replay = events.flatMap((event) => (event.type === "replay" ? [event.replay] : []));
expect(replay[0]).toMatchObject({
type: "begin",
retainedHistoryLines: count === 3000 ? 1000 : expect.any(Number),
});
expect(replay.at(-1)).toEqual({ type: "end" });
const chunks = replay.flatMap((event) => (event.type === "data" ? [event.data] : []));
expect(chunks.every((chunk) => chunk.length <= 65536)).toBe(true);
const text = Buffer.concat(chunks).toString("utf8");
const begin = replay[0];
if (begin?.type !== "begin" || begin.rows === undefined)
throw new Error("Missing replay dimensions");
expect(text.match(/replay-\d{4}/g)).toHaveLength(Math.min(count, 1000 + begin.rows - 1));
expect(text).toContain(`replay-${String(count).padStart(4, "0")}界🙂`);
if (count === 400) expect(text).toContain("replay-0001界🙂");
else {
expect(text).not.toContain("replay-0001");
expect(chunks.length).toBeGreaterThan(1);
}
} finally {
await subscription.release();
}
},
30000,
);
test.each([false, true])(
"default SDK observation replays a terminal without waiting for output (quietScreen=%s)",
async (quietScreen) => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await createTerminalInWorkspace(ctx.client, {
cwd,
options: {
command: process.execPath,
args: [
"-e",
`${quietScreen ? 'process.stdout.write("QUIET-SCREEN\\r\\n");' : ""} process.stdin.on("data", data => process.stdout.write("LIVE:" + data)); setInterval(() => {}, 1000);`,
],
},
});
const id = created.terminal!.id;
if (quietScreen)
await waitForCondition(
async () => (await ctx.client.captureTerminal(id)).lines.join("").includes("QUIET-SCREEN"),
10000,
);
const events: import("@getpaseo/client").TerminalStreamEvent[] = [];
const observation = ctx.client.observeTerminal(id, (event) => events.push(event));
try {
await observation.ready;
await waitForCondition(
() => events.some((event) => event.type === "replay" && event.replay.type === "end"),
10000,
);
const replay = events.flatMap((event) => (event.type === "replay" ? [event.replay] : []));
const begin = replay[0];
if (begin?.type !== "begin" || !begin.rows || !begin.cols)
throw new Error("Missing replay begin geometry");
expect(replay.at(-1)).toEqual({ type: "end" });
const terminal = new HeadlessTerminal({
rows: begin.rows,
cols: begin.cols,
allowProposedApi: true,
});
try {
for (const event of replay)
if (event.type === "data")
await new Promise<void>((resolve) => terminal.write(event.data, resolve));
const text = extractStateText(readHeadlessState(terminal));
expect(text).toBe(quietScreen ? "QUIET-SCREEN" : "");
} finally {
terminal.dispose();
}
ctx.client.sendTerminalInput(id, { type: "input", data: "next\r" });
await waitForCondition(
() =>
events.some(
(event) =>
event.type === "output" && new TextDecoder().decode(event.data).includes("LIVE:next"),
),
10000,
);
} finally {
await observation.release();
}
},
30000,
);
@@ -822,86 +822,100 @@ test("same-workspace terminal directory observers do not share a teardown slot",
}
});
test("terminal output has separate server IDs and binary slots, including in a shared session", async () => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
const peers: SubscriptionPeer[] = [];
try {
await admin.connect();
const workspace = (
await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } })
).workspace!;
const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, {
workspaceId: workspace.id,
command: process.execPath,
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
test.each([false, true])(
"terminal output has separate server IDs and binary slots, including in a shared session (chunked=%s)",
async (chunked) => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const admin = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.8.0",
});
expect(result.error).toBe(null);
const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session");
peers.push(idle);
const peer = await SubscriptionPeer.connect(daemon.port, "terminal-output-session");
peers.push(peer);
for (const requestId of ["output-a", "output-b"]) {
await peer.request({
type: "subscribe_terminal_request",
requestId,
terminalId: result.terminal!.id,
const peers: SubscriptionPeer[] = [];
try {
await admin.connect();
const workspace = (
await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } })
).workspace!;
const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, {
workspaceId: workspace.id,
command: process.execPath,
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
});
}
const replies = peer.frames.flatMap((frame) =>
frame.type === "session" &&
frame.message.type === "subscribe_terminal_response" &&
frame.message.payload.error === null
? [frame.message.payload]
: [],
);
expect(replies).toHaveLength(2);
const ids = replies.map(
(reply) => (reply as typeof reply & { subscriptionId: string }).subscriptionId,
);
expect(ids.every((id) => typeof id === "string")).toBe(true);
expect(ids[0]).not.toBe(ids[1]);
expect(replies[0].slot).not.toBe(replies[1].slot);
await expect
.poll(() =>
peer.binaryFrames
.map(decodeTerminalStreamFrame)
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Snapshot)
.map((frame) => frame!.slot),
)
.toEqual(expect.arrayContaining(replies.map((reply) => reply.slot)));
expect(idle.binaryFrames).toEqual([]);
await peer.request({
type: "subscription.release.request",
requestId: "release-output-a",
subscriptionId: ids[0],
});
peer.binaryFrames.length = 0;
admin.sendTerminalInput(result.terminal!.id, { type: "input", data: "remaining-observer\n" });
await expect
.poll(
() =>
expect(result.error).toBe(null);
const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session", {
terminal_chunked_replay: chunked,
});
peers.push(idle);
const peer = await SubscriptionPeer.connect(daemon.port, "terminal-output-session", {
terminal_chunked_replay: chunked,
});
peers.push(peer);
for (const requestId of ["output-a", "output-b"]) {
await peer.request({
type: "subscribe_terminal_request",
requestId,
terminalId: result.terminal!.id,
});
}
const replies = peer.frames.flatMap((frame) =>
frame.type === "session" &&
frame.message.type === "subscribe_terminal_response" &&
frame.message.payload.error === null
? [frame.message.payload]
: [],
);
expect(replies).toHaveLength(2);
const ids = replies.map(
(reply) => (reply as typeof reply & { subscriptionId: string }).subscriptionId,
);
expect(ids.every((id) => typeof id === "string")).toBe(true);
expect(ids[0]).not.toBe(ids[1]);
expect(replies[0].slot).not.toBe(replies[1].slot);
await expect
.poll(() =>
peer.binaryFrames
.map(decodeTerminalStreamFrame)
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
.map((frame) => Buffer.from(frame!.payload).toString())
.join(""),
{ timeout: 5000 },
)
.toContain("remaining-observer");
expect(
peer.binaryFrames
.map(decodeTerminalStreamFrame)
.every((frame) => frame?.slot === replies[1].slot),
).toBe(true);
expect(idle.binaryFrames).toEqual([]);
await admin.killTerminal(result.terminal!.id);
} finally {
for (const peer of peers) peer.close();
await admin.close();
await daemon.close();
}
});
.filter(
(frame) =>
frame?.opcode ===
(chunked ? TerminalStreamOpcode.ReplayEnd : TerminalStreamOpcode.Restore),
)
.map((frame) => frame!.slot),
)
.toEqual(expect.arrayContaining(replies.map((reply) => reply.slot)));
expect(idle.binaryFrames).toEqual([]);
await peer.request({
type: "subscription.release.request",
requestId: "release-output-a",
subscriptionId: ids[0],
});
peer.binaryFrames.length = 0;
admin.sendTerminalInput(result.terminal!.id, { type: "input", data: "remaining-observer\n" });
await expect
.poll(
() =>
peer.binaryFrames
.map(decodeTerminalStreamFrame)
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
.map((frame) => Buffer.from(frame!.payload).toString())
.join(""),
{ timeout: 5000 },
)
.toContain("remaining-observer");
expect(
peer.binaryFrames
.map(decodeTerminalStreamFrame)
.every((frame) => frame?.slot === replies[1].slot),
).toBe(true);
expect(idle.binaryFrames).toEqual([]);
await admin.killTerminal(result.terminal!.id);
} finally {
for (const peer of peers) peer.close();
await admin.close();
await daemon.close();
}
},
);
test("a modern connection and pure reads retain no client observation producers", async () => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
@@ -68,13 +68,7 @@ function createStubTerminalManager(
subscribe: () => () => {},
onExit: () => () => {},
onCommandFinished: () => () => {},
getState: () => ({
rows: 1,
cols: 1,
grid: [[{ char: "$" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
hasOutput: () => true,
kill: () => {},
onTitleChange: () => () => {},
getSize: () => ({ rows: 1, cols: 1 }),
+2 -2
View File
@@ -1129,11 +1129,11 @@ export class Session {
terminalManager,
emit: (msg) => this.emit(msg),
hasBinaryChannel: () => this.onBinaryMessage !== null,
supportsChunkedReplay: (source) =>
this.supportsForSource(CLIENT_CAPS.terminalChunkedReplay, source),
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
sessionLogger: this.sessionLogger,
listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(),
clientSupportsWrapReflow: (source) =>
this.supportsForSource(CLIENT_CAPS.terminalReflowableSnapshot, source),
getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source),
});
this.agentDirectory = new AgentDirectory({
@@ -209,13 +209,8 @@ describe("runWorktreeAutoTerminals", () => {
getSize: () => ({ rows: 0, cols: 0 }),
getTitle: () => undefined,
getExitInfo: () => null,
getState: () => ({
rows: 0,
cols: 0,
grid: [],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
hasOutput: () => false,
kill: () => {},
killAndWait: async () => {},
};
@@ -312,13 +307,8 @@ describe("runWorktreeAutoTerminals", () => {
}
};
},
getState: () => ({
rows: 1,
cols: 1,
grid: [[{ char: "$" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
hasOutput: () => true,
kill: () => {},
onTitleChange: () => () => {},
onActivityChange: () => () => {},
@@ -269,9 +269,9 @@ export function buildWorktreeSetupDetail(input: {
}
async function waitForTerminalBootstrapReadiness(
terminal: Pick<TerminalSession, "getState" | "subscribe">,
terminal: Pick<TerminalSession, "hasOutput" | "subscribe">,
): Promise<void> {
if (terminalHasOutput(terminal.getState())) {
if (terminal.hasOutput()) {
return;
}
@@ -304,7 +304,7 @@ async function waitForTerminalBootstrapReadiness(
finish();
});
if (terminalHasOutput(terminal.getState())) {
if (terminal.hasOutput()) {
finish();
return;
}
@@ -313,17 +313,6 @@ async function waitForTerminalBootstrapReadiness(
});
}
function terminalHasOutput(state: ReturnType<TerminalSession["getState"]>): boolean {
for (const row of [...state.scrollback, ...state.grid]) {
for (const cell of row) {
if (cell.char.trim().length > 0) {
return true;
}
}
}
return false;
}
export async function runWorktreeAutoTerminals(options: {
workspaceId: string;
worktree: WorktreeConfig;
@@ -212,13 +212,7 @@ function createTerminalManagerStub(options?: {
id: `terminal-${terminals.length + 1}`,
name: input.name ?? "Terminal",
cwd: input.cwd,
getState: () => ({
rows: 1,
cols: 1,
scrollback: [[{ char: "$" }]],
grid: [],
cursor: { row: 0, col: 0 },
}),
hasOutput: () => true,
subscribe: () => () => {},
onExit: () => () => {},
onCommandFinished: () => () => {},
@@ -1,6 +1,6 @@
import stripAnsi from "strip-ansi";
import type { TerminalCell } from "@getpaseo/protocol/messages";
import type { TerminalSession } from "./terminal.js";
import type { LocalTerminalSession } from "./terminal.js";
export interface CaptureTerminalLinesOptions {
start?: number;
@@ -46,7 +46,7 @@ function resolveCaptureLineIndex(
}
export function captureTerminalLines(
terminal: TerminalSession,
terminal: Pick<LocalTerminalSession, "getState">,
options: CaptureTerminalLinesOptions = {},
): CaptureTerminalLinesResult {
const state = terminal.getState();
@@ -1,10 +1,14 @@
import {
replayTerminal,
type TerminalReplaySink,
type TerminalReplayMetadata,
type TerminalReplayOptions,
} from "./terminal-replay.js";
import {
createTerminal,
type LocalTerminalSession,
type TerminalActivityTransition,
type TerminalSession,
type TerminalStateSnapshot,
type TerminalStateSnapshotOptions,
} from "./terminal.js";
import { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js";
import { randomBytes, randomUUID } from "node:crypto";
@@ -76,10 +80,11 @@ export interface TerminalManager {
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
validateTerminalActivityToken(terminalId: string, token: string): "valid" | "unknown" | "invalid";
getTerminal(id: string): TerminalSession | undefined;
getTerminalState(
replayTerminal(
id: string,
options?: TerminalStateSnapshotOptions,
): Promise<TerminalStateSnapshot | null>;
options: TerminalReplayOptions | undefined,
sink: TerminalReplaySink,
): Promise<TerminalReplayMetadata | null>;
setTerminalTitle(id: string, title: string): boolean;
setTerminalActivity(id: string, state: TerminalActivityState): Promise<boolean>;
clearTerminalAttention(id: string): Promise<boolean>;
@@ -107,6 +112,10 @@ export interface TerminalManagerOptions {
/** The in-process manager the terminal worker runs: it owns each PTY, so it knows its pid. */
export interface LocalTerminalManager extends TerminalManager {
createTerminal(
options: Parameters<TerminalManager["createTerminal"]>[0],
): Promise<LocalTerminalSession>;
getTerminal(id: string): LocalTerminalSession | undefined;
getTerminalProcessId(id: string): number | null;
getTerminalActivityToken(id: string): string | null;
}
@@ -337,7 +346,7 @@ export function createTerminalManager(
activityUrl?: string | null;
baseEnv?: Record<string, string>;
paseoCliPath?: string | null;
}): Promise<TerminalSession> {
}): Promise<LocalTerminalSession> {
assertAbsolutePath(options.cwd);
const terminals = terminalsByCwd.get(options.cwd) ?? [];
@@ -357,7 +366,7 @@ export function createTerminalManager(
...(terminalActivityUrl ? { PASEO_TERMINAL_ACTIVITY_URL: terminalActivityUrl } : {}),
};
terminalActivityTokenById.set(terminalId, activityToken);
let session: TerminalSession;
let session: LocalTerminalSession;
try {
session = registerSession(
await createTerminal({
@@ -404,7 +413,7 @@ export function createTerminalManager(
return expected === token ? "valid" : "invalid";
},
getTerminal(id: string): TerminalSession | undefined {
getTerminal(id: string): LocalTerminalSession | undefined {
return terminalsById.get(id);
},
@@ -416,11 +425,9 @@ export function createTerminalManager(
return terminalActivityTokenById.get(id) ?? null;
},
async getTerminalState(
id: string,
options?: TerminalStateSnapshotOptions,
): Promise<TerminalStateSnapshot | null> {
return terminalsById.get(id)?.getStateSnapshot(options) ?? null;
async replayTerminal(id, options, sink) {
const session = terminalsById.get(id);
return session ? replayTerminal(session, options, sink) : null;
},
setTerminalTitle(id: string, title: string): boolean {
@@ -0,0 +1,144 @@
import type { FramedSocket } from "./terminal-worker-frames.js";
import type {
TerminalWorkerRequest,
TerminalWorkerToParentMessage,
} from "./terminal-worker-protocol.js";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
import type { LocalTerminalSession } from "./terminal.js";
export interface TerminalReplayOptions {
scrollbackLines?: number;
}
export interface TerminalReplayMetadata {
rows: number;
cols: number;
retainedHistoryLines: number;
revision: number;
replayPreamble: string;
}
export type TerminalReplayEvent =
| { type: "begin"; metadata: TerminalReplayMetadata }
| { type: "data"; data: Uint8Array }
| { type: "end" };
export type TerminalReplaySink = (event: TerminalReplayEvent) => void;
export const TERMINAL_REPLAY_CHUNK_BYTES = 64 * 1024;
export function* createTerminalReplay(
session: LocalTerminalSession,
options: TerminalReplayOptions | undefined,
): Generator<TerminalReplayEvent, TerminalReplayMetadata> {
const snapshot = session.getStateSnapshot({ ...options, includeWrapFlags: true });
const metadata = {
rows: snapshot.state.rows,
cols: snapshot.state.cols,
retainedHistoryLines: session.getRetainedHistoryLines(),
revision: snapshot.revision,
replayPreamble: session.getReplayPreamble(),
};
const data = Buffer.from(renderTerminalSnapshotToAnsi(snapshot.state), "utf8");
yield { type: "begin", metadata };
for (let offset = 0; offset < data.length; offset += TERMINAL_REPLAY_CHUNK_BYTES) {
yield { type: "data", data: data.subarray(offset, offset + TERMINAL_REPLAY_CHUNK_BYTES) };
}
yield { type: "end" };
return metadata;
}
export async function replayTerminal(
session: LocalTerminalSession,
options: TerminalReplayOptions | undefined,
sink: TerminalReplaySink,
): Promise<TerminalReplayMetadata> {
const replay = createTerminalReplay(session, options);
let next = replay.next();
while (!next.done) {
sink(next.value);
next = replay.next();
}
return next.value;
}
interface TerminalReplayPorts {
getSession(id: string): LocalTerminalSession | undefined;
flushOutput(id: string): void;
}
/** Worker-owned ANSI capture and paced delivery; live observations never wait for a replay. */
export function createTerminalReplayController(ports: TerminalReplayPorts) {
let controller: FramedSocket | null = null;
function sendToController(message: TerminalWorkerToParentMessage): void {
// A stalled physical controller is different from a stalled viewer: all its
// observations must reconnect to the surviving worker after this socket closes.
if (controller && controller.socket.writableLength > 4 * 1024 * 1024) {
controller.socket.destroy();
return;
}
controller?.send(message);
}
async function deliverReplay(
connection: FramedSocket,
message: Extract<TerminalWorkerRequest, { type: "replayTerminal" }>,
): Promise<void> {
ports.flushOutput(message.terminalId);
const session = ports.getSession(message.terminalId);
if (!session) {
await connection.sendAndWait({
type: "response",
requestId: message.requestId,
ok: true,
result: null,
});
return;
}
// Flush and capture share one synchronous turn. Each daemon viewer buffers
// its own live tail and discards revisions already included in this cut.
const replay = createTerminalReplay(session, message.options);
let next = replay.next();
while (!next.done) {
if (controller !== connection) throw new Error("Terminal replay controller disconnected");
await connection.sendAndWait({
type: "terminalReplay",
requestId: message.requestId,
event: next.value,
});
next = replay.next();
}
await connection.sendAndWait({
type: "response",
requestId: message.requestId,
ok: true,
result: next.value,
});
}
return {
setConnection(connection: FramedSocket | null): void {
if (controller === connection) return;
const previous = controller;
controller = connection;
previous?.socket.destroy();
},
send: sendToController,
async replay(
request: Extract<TerminalWorkerRequest, { type: "replayTerminal" }>,
): Promise<void> {
const connection = controller;
if (!connection) return;
try {
await deliverReplay(connection, request);
} catch (error) {
await connection
.sendAndWait({
type: "response",
requestId: request.requestId,
ok: false,
error: error instanceof Error ? error.message : String(error),
})
.catch(() => {});
}
},
};
}
@@ -4,30 +4,14 @@ import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import {
createTerminalRestoreDelivery,
encodeTerminalRestoreFrame,
resolveRestoreAfterOutputOverflow,
resolveTerminalRestoreSnapshotOptions,
resolveTerminalSubscriptionSnapshotMode,
} from "./terminal-restore.js";
function terminalRow(text: string, cols = 80): TerminalCell[] {
return Array.from({ length: cols }, (_, index) => ({
char: text[index] ?? " ",
}));
}
function terminalState(text: string): TerminalState {
return {
rows: 1,
cols: 80,
grid: [terminalRow(text)],
scrollback: [],
cursor: { row: 0, col: text.length },
};
}
describe("terminal restore policy", () => {
test("uses ready snapshots only for restore-aware subscriptions", () => {
expect(resolveTerminalSubscriptionSnapshotMode(undefined)).toBe("state");
@@ -61,10 +45,7 @@ describe("terminal restore policy", () => {
const frame = decodeTerminalStreamFrame(
encodeTerminalRestoreFrame({
slot: 4,
snapshot: {
state: terminalState("restored"),
revision: 1,
},
data: new TextEncoder().encode("restored"),
}),
);
@@ -73,3 +54,40 @@ describe("terminal restore policy", () => {
expect(new TextDecoder().decode(frame?.payload ?? new Uint8Array())).toContain("restored");
});
});
test("projects chunked replay begin onto the declared wire metadata", () => {
const frames: Uint8Array[] = [];
const deliver = createTerminalRestoreDelivery({
slot: 7,
chunked: true,
send: (frame) => {
frames.push(frame);
},
});
deliver({
type: "begin",
metadata: {
rows: 30,
cols: 120,
retainedHistoryLines: 842,
revision: 5123,
replayPreamble: "\x1b[?2004h",
},
});
const data = new TextEncoder().encode("\x1b[31m界 replay\x1b[0m");
deliver({ type: "data", data });
deliver({ type: "end" });
const decoded = frames.map((frame) => decodeTerminalStreamFrame(frame)!);
expect(decoded.map((frame) => ({ slot: frame.slot, opcode: frame.opcode }))).toEqual([
{ slot: 7, opcode: TerminalStreamOpcode.ReplayBegin },
{ slot: 7, opcode: TerminalStreamOpcode.ReplayData },
{ slot: 7, opcode: TerminalStreamOpcode.ReplayEnd },
]);
expect(JSON.parse(new TextDecoder().decode(decoded[0]!.payload))).toEqual({
rows: 30,
cols: 120,
retainedHistoryLines: 842,
});
expect(decoded[1]!.payload).toEqual(data);
expect(decoded[2]!.payload.byteLength).toBe(0);
});
@@ -1,11 +1,11 @@
import type { z } from "zod";
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
import {
TerminalStreamOpcode,
encodeTerminalSnapshotPayload,
type TerminalReplayMetadataSchema,
encodeTerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
import type { TerminalStateSnapshot, TerminalStateSnapshotOptions } from "./terminal.js";
import type { TerminalReplaySink, TerminalReplayOptions } from "./terminal-replay.js";
export const MAX_TERMINAL_OUTPUT_FRAME_BYTES = 256 * 1024;
@@ -40,7 +40,7 @@ export function resolveRestoreAfterOutputOverflow(
export function resolveTerminalRestoreSnapshotOptions(
restore: TerminalRestoreOptions,
): TerminalStateSnapshotOptions | null | undefined {
): TerminalReplayOptions | null | undefined {
if (restore.mode === "live") {
return null;
}
@@ -52,25 +52,11 @@ export function resolveTerminalRestoreSnapshotOptions(
return undefined;
}
export function encodeLegacyTerminalSnapshotFrame(input: {
slot: number;
snapshot: TerminalStateSnapshot;
}): Uint8Array {
return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: input.slot,
payload: encodeTerminalSnapshotPayload(input.snapshot.state),
});
}
export function encodeTerminalRestoreFrame(input: {
slot: number;
snapshot: TerminalStateSnapshot;
}): Uint8Array {
export function encodeTerminalRestoreFrame(input: { slot: number; data: Uint8Array }): Uint8Array {
return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Restore,
slot: input.slot,
payload: renderTerminalSnapshotToAnsi(input.snapshot.state),
payload: input.data,
});
}
@@ -80,3 +66,44 @@ function resolveVisibleRestoreScrollbackLines(value: number | undefined): number
}
return Math.min(Math.max(0, value), MAX_VISIBLE_RESTORE_SCROLLBACK_LINES);
}
/** The daemon's only old-app adapter. Capable delivery never assembles the replay. */
export function createTerminalRestoreDelivery(input: {
slot: number;
chunked: boolean;
send(frame: Uint8Array): void;
}): TerminalReplaySink {
// COMPAT(terminalSingleRestore): added after v0.14.0; remove after 2027-04-30 when client floor supports chunked replay.
const legacyChunks: Uint8Array[] = [];
return (event) => {
if (!input.chunked) {
if (event.type === "data") legacyChunks.push(event.data);
if (event.type === "end")
input.send(
encodeTerminalRestoreFrame({
slot: input.slot,
data: Buffer.concat(legacyChunks),
}),
);
return;
}
const opcodes = {
begin: TerminalStreamOpcode.ReplayBegin,
data: TerminalStreamOpcode.ReplayData,
end: TerminalStreamOpcode.ReplayEnd,
};
let payload: string | Uint8Array | undefined;
if (event.type === "begin") {
const begin = {
rows: event.metadata.rows,
cols: event.metadata.cols,
retainedHistoryLines: event.metadata.retainedHistoryLines,
} satisfies z.infer<typeof TerminalReplayMetadataSchema>;
payload = JSON.stringify(begin);
}
if (event.type === "data") payload = event.data;
input.send(
encodeTerminalStreamFrame({ slot: input.slot, opcode: opcodes[event.type], payload }),
);
};
}
@@ -1,3 +1,4 @@
import type { TerminalReplayMetadata } from "./terminal-replay.js";
import { describe, expect, test, vi } from "vitest";
import pino from "pino";
@@ -7,8 +8,7 @@ import {
decodeTerminalStreamFrame,
type TerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js";
import type { ServerMessage, TerminalSession } from "./terminal.js";
import {
TerminalSessionController,
type TerminalSessionControllerOptions,
@@ -19,8 +19,33 @@ import { isSameOrDescendantPath } from "../server/path-utils.js";
import { PluginSessionSocket } from "../server/plugins/session-socket.js";
import { TerminalWorkerIncompatibleError } from "./terminal-worker-pool.js";
interface ReplayFixture {
data: Uint8Array;
metadata: TerminalReplayMetadata;
}
function terminalReplay(text: string, revision: number, replayPreamble = ""): ReplayFixture {
return {
data: Buffer.from(text),
metadata: { rows: 1, cols: 80, retainedHistoryLines: 0, revision, replayPreamble },
};
}
function replayFixture(
read: () => Promise<ReplayFixture | null>,
): TerminalManager["replayTerminal"] {
return async (_id, _options, sink) => {
const replay = await read();
if (!replay) return null;
await sink({ type: "begin", metadata: replay.metadata });
await sink({ type: "data", data: replay.data });
await sink({ type: "end" });
return replay.metadata;
};
}
function createController(
options: TerminalSessionControllerOptions & { emitBinary(frame: Uint8Array): void },
options: TerminalSessionControllerOptions & {
emitBinary(frame: Uint8Array): void | Promise<void>;
},
) {
const source = {};
const ownership = new SessionDelivery(
@@ -59,22 +84,6 @@ function deferred<T>(): {
return { promise, resolve, reject };
}
function terminalRow(text: string, cols = 80): TerminalCell[] {
return Array.from({ length: cols }, (_, index) => ({
char: text[index] ?? " ",
}));
}
function terminalState(text: string): TerminalState {
return {
rows: 1,
cols: 80,
grid: [terminalRow(text)],
scrollback: [],
cursor: { row: 0, col: text.length },
};
}
function createLogger(): pino.Logger {
return {
warn: vi.fn(),
@@ -85,7 +94,7 @@ function createLogger(): pino.Logger {
describe("terminal-session-controller restore", () => {
test("delivers output produced while restore is in flight after the restore frame", async () => {
let terminalListener: ((message: ServerMessage) => void) | null = null;
const snapshot = deferred<TerminalStateSnapshot | null>();
const snapshot = deferred<ReplayFixture | null>();
const binaryFrames: TerminalStreamFrame[] = [];
const outboundMessages: SessionOutboundMessage[] = [];
const terminal: TerminalSession = {
@@ -104,8 +113,7 @@ describe("terminal-session-controller restore", () => {
onTitleChange: () => vi.fn(),
onActivityChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("restore-before"),
getStateSnapshot: () => ({ state: terminalState("restore-before"), revision: 1 }),
hasOutput: () => true,
getReplayPreamble: () => "\x1b[?1h\x1b[?2004h",
getTitle: () => undefined,
getActivity: () => null,
@@ -121,7 +129,7 @@ describe("terminal-session-controller restore", () => {
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(() => terminal),
getTerminalState: vi.fn(() => snapshot.promise),
replayTerminal: replayFixture(() => snapshot.promise),
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
killTerminal: vi.fn(),
@@ -157,10 +165,10 @@ describe("terminal-session-controller restore", () => {
},
});
await Promise.resolve();
expect(terminalManager.getTerminalState).toHaveBeenCalledTimes(1);
expect(binaryFrames).toEqual([]);
terminalListener?.({ type: "output", data: "restore-after\n", revision: 2 });
snapshot.resolve({ state: terminalState("restore-before"), revision: 1 });
snapshot.resolve(terminalReplay("restore-before", 1, "\x1b[?1h\x1b[?2004h"));
await snapshot.promise;
await new Promise((resolve) => setTimeout(resolve, 20));
@@ -202,8 +210,7 @@ function listSession(input: {
onTitleChange: () => vi.fn(),
onActivityChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState(""),
getStateSnapshot: () => ({ state: terminalState(""), revision: 0 }),
hasOutput: () => true,
getReplayPreamble: () => "",
getTitle: () => undefined,
getActivity: () => null,
@@ -236,7 +243,7 @@ describe("terminal-session-controller legacy terminal creation", () => {
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
replayTerminal: async () => null,
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
clearTerminalAttention: vi.fn(),
@@ -311,7 +318,7 @@ describe("terminal-session-controller legacy terminal creation", () => {
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
replayTerminal: async () => null,
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
clearTerminalAttention: vi.fn(),
@@ -362,7 +369,7 @@ describe("terminal-session-controller with terminals held by a newer Paseo", ()
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
replayTerminal: async () => null,
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
clearTerminalAttention: vi.fn(),
@@ -428,99 +435,6 @@ async function waitForCoalescerFlush(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 30));
}
describe("terminal-session-controller wrap-flag gating", () => {
function setup(clientSupportsWrapReflow?: () => boolean): {
controller: ReturnType<typeof createController>;
getTerminalState: ReturnType<typeof vi.fn>;
} {
const terminal: TerminalSession = {
id: "term-1",
name: "Terminal",
cwd: "/tmp",
workspaceId: "ws-test",
send: vi.fn(),
subscribe: (listener) => {
queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 }));
return vi.fn();
},
onExit: () => vi.fn(),
onCommandFinished: () => vi.fn(),
onTitleChange: () => vi.fn(),
onActivityChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("hello"),
getStateSnapshot: () => ({ state: terminalState("hello"), revision: 1 }),
getReplayPreamble: () => "",
getTitle: () => undefined,
getActivity: () => null,
setActivity: vi.fn(),
setTitle: vi.fn(),
getExitInfo: () => null,
kill: vi.fn(),
killAndWait: vi.fn(),
};
const getTerminalState = vi.fn(() =>
Promise.resolve<TerminalStateSnapshot>({ state: terminalState("hello"), revision: 1 }),
);
const terminalManager = {
getTerminals: vi.fn(),
createTerminal: vi.fn(),
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(() => terminal),
getTerminalState,
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
killTerminal: vi.fn(),
killTerminalAndWait: vi.fn(),
captureTerminal: vi.fn(),
listDirectories: vi.fn(() => []),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => vi.fn()),
subscribeTerminalActivity: vi.fn(() => vi.fn()),
subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()),
} as unknown as TerminalManager;
const controller = createController({
terminalManager,
emit: vi.fn(),
emitBinary: vi.fn(),
hasBinaryChannel: () => true,
isPathWithinRoot: () => false,
sessionLogger: createLogger(),
...(clientSupportsWrapReflow ? { clientSupportsWrapReflow } : {}),
});
return { controller, getTerminalState };
}
async function subscribe(controller: ReturnType<typeof createController>): Promise<void> {
await controller.dispatch({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
restore: { mode: "visible-snapshot", scrollbackLines: 200 },
});
await flushMicrotasks();
}
test("requests wrap flags when the client supports reflowable snapshots", async () => {
const { controller, getTerminalState } = setup(() => true);
await subscribe(controller);
expect(getTerminalState).toHaveBeenCalledWith(
"term-1",
expect.objectContaining({ includeWrapFlags: true }),
);
});
test("omits wrap flags when the client does not advertise support", async () => {
const { controller, getTerminalState } = setup();
await subscribe(controller);
expect(getTerminalState).toHaveBeenCalledWith(
"term-1",
expect.objectContaining({ includeWrapFlags: false }),
);
});
});
describe("terminal-session-controller subdirectory aggregation", () => {
test("delivers a subdirectory change to a root subscriber as an aggregated, root-keyed snapshot", async () => {
const rootCwd = "/work/repo";
@@ -541,7 +455,7 @@ describe("terminal-session-controller subdirectory aggregation", () => {
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
replayTerminal: async () => null,
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
killTerminal: vi.fn(),
@@ -609,7 +523,7 @@ describe("terminal-session-controller subdirectory aggregation", () => {
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
replayTerminal: async () => null,
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
killTerminal: vi.fn(),
@@ -701,7 +615,7 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => {
registerCwdEnv: vi.fn(),
validateTerminalActivityToken: vi.fn(() => "unknown"),
getTerminal: vi.fn(),
getTerminalState: vi.fn(),
replayTerminal: async () => null,
setTerminalTitle: vi.fn(),
setTerminalActivity: vi.fn(),
killTerminal: vi.fn(),
@@ -764,7 +678,12 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => {
});
describe("terminal-session-controller backpressure snapshot fallback", () => {
async function setup(getClientBufferedAmount: () => number | null): Promise<{
async function setup(
getClientBufferedAmount: () => number | null,
chunked = false,
pace?: () => Promise<void>,
): Promise<{
messages: SessionOutboundMessage[];
pushOutput: (data: string) => void;
frames: TerminalStreamFrame[];
}> {
@@ -777,19 +696,15 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
send: vi.fn(),
subscribe: (listener) => {
terminalListener = listener;
// Legacy stream: a snapshot arrives on subscribe (one Snapshot frame),
// after which output streams through the coalescer as Output frames.
queueMicrotask(() =>
listener({ type: "snapshot", state: terminalState("live"), revision: 1 }),
);
// Readiness starts ANSI replay; later bytes use Output frames.
queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 }));
return vi.fn();
},
onExit: () => vi.fn(),
onCommandFinished: () => vi.fn(),
onTitleChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("live"),
getStateSnapshot: () => ({ state: terminalState("live"), revision: 1 }),
hasOutput: () => true,
getReplayPreamble: () => "",
getTitle: () => undefined,
setTitle: vi.fn(),
@@ -797,14 +712,12 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
kill: vi.fn(),
killAndWait: vi.fn(),
};
const terminalManager = {
const terminalManager: TerminalManager = {
getTerminals: vi.fn(),
createTerminal: vi.fn(),
registerCwdEnv: vi.fn(),
getTerminal: vi.fn(() => terminal),
getTerminalState: vi.fn(() =>
Promise.resolve<TerminalStateSnapshot>({ state: terminalState("live"), revision: 1 }),
),
replayTerminal: replayFixture(async () => terminalReplay("live", 1)),
setTerminalTitle: vi.fn(),
killTerminal: vi.fn(),
killTerminalAndWait: vi.fn(),
@@ -812,16 +725,26 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
listDirectories: vi.fn(() => []),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => vi.fn()),
} as unknown as TerminalManager;
validateTerminalActivityToken: () => "unknown",
setTerminalActivity: async () => true,
clearTerminalAttention: async () => false,
subscribeTerminalActivity: () => () => {},
subscribeTerminalWorkspaceContributionChanged: () => () => {},
};
const frames: TerminalStreamFrame[] = [];
const messages: SessionOutboundMessage[] = [];
const controller = createController({
terminalManager,
emit: vi.fn(),
emit: (message) => {
messages.push(message);
},
supportsChunkedReplay: () => chunked,
emitBinary: (bytes) => {
const frame = decodeTerminalStreamFrame(bytes);
if (frame) {
frames.push(frame);
if (frame.opcode === TerminalStreamOpcode.ReplayBegin) return pace?.();
}
},
hasBinaryChannel: () => true,
@@ -843,6 +766,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
return {
pushOutput: (data) => terminalListener?.({ type: "output", data, revision: 2 }),
frames,
messages,
};
}
@@ -853,7 +777,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
pushOutput(chunk);
await waitForCoalescerFlush();
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(false);
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(false);
const outputFrames = frames.filter((frame) => frame.opcode === TerminalStreamOpcode.Output);
expect(outputFrames.length).toBeGreaterThan(0);
const receivedBytes = outputFrames.reduce(
@@ -869,7 +793,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
pushOutput("y".repeat(300 * 1024));
await waitForCoalescerFlush();
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true);
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(true);
// After the snapshot the byte counter is reset, so a small follow-up chunk
// streams as Output rather than tripping the fallback again.
@@ -877,7 +801,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
pushOutput("z".repeat(1024));
await waitForCoalescerFlush();
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(false);
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(false);
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Output)).toBe(true);
});
@@ -893,7 +817,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
pushOutput("p".repeat(300 * 1024));
await waitForCoalescerFlush();
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true);
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(true);
});
test("falls back to a snapshot at the byte threshold when no backpressure signal exists", async () => {
@@ -905,11 +829,41 @@ describe("terminal-session-controller backpressure snapshot fallback", () => {
pushOutput("r".repeat(300 * 1024));
await waitForCoalescerFlush();
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true);
expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(true);
});
test("queues capable catch-up without waiting for the direct socket to drain", async () => {
let buffered = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const { pushOutput, frames, messages } = await setup(
() => buffered,
true,
() => (buffered ? gate : Promise.resolve()),
);
buffered = 8 * 1024 * 1024;
pushOutput("y".repeat(300 * 1024));
await waitForCoalescerFlush();
expect(messages.filter((message) => message.type === "terminal_stream_exit")).toEqual([]);
expect(frames.map((frame) => frame.opcode)).toEqual([
TerminalStreamOpcode.ReplayBegin,
TerminalStreamOpcode.ReplayData,
TerminalStreamOpcode.ReplayEnd,
]);
buffered = 0;
release();
await waitForCoalescerFlush();
expect(frames.map((frame) => frame.opcode)).toContain(TerminalStreamOpcode.ReplayEnd);
frames.length = 0;
buffered = 8 * 1024 * 1024; // Small output must stay live after the replay resets its byte counter.
pushOutput("live-after-recovery");
await waitForCoalescerFlush();
expect(frames.map((frame) => frame.opcode)).toEqual([TerminalStreamOpcode.Output]);
});
});
function exitFixture(readSnapshot: () => Promise<TerminalStateSnapshot | null>, backedUp = false) {
function exitFixture(readReplay: () => Promise<ReplayFixture | null>, backedUp = false) {
const outputs = new Set<(message: ServerMessage) => void>();
const exits = new Set<Parameters<TerminalSession["onExit"]>[0]>();
const terminal: TerminalSession = {
@@ -934,8 +888,7 @@ function exitFixture(readSnapshot: () => Promise<TerminalStateSnapshot | null>,
onTitleChange: () => () => {},
onActivityChange: () => () => {},
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("snapshot"),
getStateSnapshot: () => ({ state: terminalState("snapshot"), revision: 0 }),
hasOutput: () => true,
getReplayPreamble: () => "",
getTitle: () => undefined,
getActivity: () => null,
@@ -948,7 +901,7 @@ function exitFixture(readSnapshot: () => Promise<TerminalStateSnapshot | null>,
};
const manager: TerminalManager = {
getTerminal: () => terminal,
getTerminalState: readSnapshot,
replayTerminal: replayFixture(readReplay),
getTerminals: async () => [terminal],
createTerminal: async () => terminal,
registerCwdEnv() {},
@@ -1026,7 +979,7 @@ describe("terminal output before natural exit", () => {
"flushes each observer's final bytes before exit (modern=%s)",
async (modern) => {
vi.useFakeTimers();
const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 }));
const f = exitFixture(async () => terminalReplay("snapshot", 0));
try {
const source = {};
const owners = [
@@ -1048,7 +1001,7 @@ describe("terminal output before natural exit", () => {
row.message.payload.subscriptionId === owner.id)),
);
expect(ordered.map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([
TerminalStreamOpcode.Snapshot,
TerminalStreamOpcode.Restore,
TerminalStreamOpcode.Output,
TerminalStreamOpcode.Output,
"terminal_stream_exit",
@@ -1068,7 +1021,7 @@ describe("terminal output before natural exit", () => {
test("waits for bootstrap snapshot and flushes buffered bytes without starting backpressure work", async () => {
vi.useFakeTimers();
const snapshot = deferred<TerminalStateSnapshot | null>();
const snapshot = deferred<ReplayFixture | null>();
let reads = 0;
const f = exitFixture(() => {
reads++;
@@ -1081,10 +1034,10 @@ describe("terminal output before natural exit", () => {
f.exit();
await vi.advanceTimersByTimeAsync(0);
expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]);
snapshot.resolve({ state: terminalState("snapshot"), revision: 0 });
snapshot.resolve(terminalReplay("snapshot", 0));
await vi.advanceTimersByTimeAsync(0);
expect(f.frames.slice(1).map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([
TerminalStreamOpcode.Snapshot,
TerminalStreamOpcode.Restore,
TerminalStreamOpcode.Output,
"terminal_stream_exit",
]);
@@ -1104,11 +1057,10 @@ describe("terminal output before natural exit", () => {
"finishes an in-flight backpressure snapshot (available=%s)",
async (available) => {
vi.useFakeTimers();
const snapshot = deferred<TerminalStateSnapshot | null>();
const snapshot = deferred<ReplayFixture | null>();
let reads = 0;
const f = exitFixture(
async () =>
++reads === 1 ? { state: terminalState("initial"), revision: 0 } : snapshot.promise,
async () => (++reads === 1 ? terminalReplay("initial", 0) : snapshot.promise),
true,
);
try {
@@ -1122,10 +1074,7 @@ describe("terminal output before natural exit", () => {
await vi.advanceTimersByTimeAsync(0);
expect(f.frames.some((row) => row.message?.type === "terminal_stream_exit")).toBe(false);
if (available === "rejected") snapshot.reject(new Error("Snapshot failed during exit"));
else
snapshot.resolve(
available ? { state: terminalState("pressure-snapshot"), revision: 1 } : null,
);
else snapshot.resolve(available ? terminalReplay("pressure-snapshot", 1) : null);
await vi.advanceTimersByTimeAsync(0);
const output = f.frames
.filter((row) => row.binary?.opcode === TerminalStreamOpcode.Output)
@@ -1145,7 +1094,7 @@ describe("terminal output before natural exit", () => {
test("release during exit's pending snapshot suppresses late delivery", async () => {
vi.useFakeTimers();
const snapshot = deferred<TerminalStateSnapshot | null>();
const snapshot = deferred<ReplayFixture | null>();
const f = exitFixture(() => snapshot.promise);
const source = {};
try {
@@ -1158,7 +1107,7 @@ describe("terminal output before natural exit", () => {
subscriptionId: owner.id!,
};
const release = f.delivery.request(source, request, () => f.delivery.release(owner.id!));
snapshot.resolve({ state: terminalState("snapshot"), revision: 0 });
snapshot.resolve(terminalReplay("snapshot", 0));
await release;
await vi.advanceTimersByTimeAsync(0);
expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]);
@@ -1173,7 +1122,7 @@ describe("terminal output before natural exit", () => {
test("explicit release discards only that observer's trailing output", async () => {
vi.useFakeTimers();
const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 }));
const f = exitFixture(async () => terminalReplay("snapshot", 0));
const source = {};
try {
const a = await f.subscribe(source, true, "a");
@@ -1221,8 +1170,8 @@ describe("terminal snapshot failure", () => {
let reads = 0;
const f = exitFixture(async () => {
if (++reads === (phase === "backpressure" ? 2 : 1))
throw new Error("Terminal worker request timed out: getTerminalState");
return { state: terminalState("healthy"), revision: 0 };
throw new Error("Terminal worker request timed out: replayTerminal");
return terminalReplay("healthy", 0);
}, phase === "backpressure");
try {
const source = {};
@@ -1250,7 +1199,7 @@ describe("terminal snapshot failure", () => {
payload: {
terminalId: "exit-terminal",
subscriptionId: failed.id,
error: "Terminal worker request timed out: getTerminalState",
error: "Terminal worker request timed out: replayTerminal",
},
},
]);
@@ -1281,7 +1230,7 @@ describe("terminal snapshot failure", () => {
);
test("release during pending rejection suppresses the failure and completes cleanup", async () => {
const snapshot = deferred<TerminalStateSnapshot | null>();
const snapshot = deferred<ReplayFixture | null>();
const f = exitFixture(() => snapshot.promise);
const source = {};
try {
@@ -1293,7 +1242,7 @@ describe("terminal snapshot failure", () => {
subscriptionId: owner.id!,
};
const release = f.delivery.request(source, request, () => f.delivery.release(owner.id!));
snapshot.reject(new Error("Terminal worker request timed out: getTerminalState"));
snapshot.reject(new Error("Terminal worker request timed out: replayTerminal"));
await release;
expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]);
expect(f.delivery.registrationCount).toBe(0);
@@ -1313,7 +1262,7 @@ test.each([false, true])(
let reads = 0;
const f = exitFixture(async () => {
if (++reads === (backpressure ? 2 : 1)) throw new Error("Snapshot unavailable");
return { state: terminalState("recovered"), revision: 0 };
return terminalReplay("recovered", 0);
}, backpressure);
try {
await f.subscribe({}, false, "legacy-recovery");
@@ -1327,7 +1276,7 @@ test.each([false, true])(
for (const listener of f.outputs) listener({ type: "snapshotReady", revision: 2 });
await vi.advanceTimersByTimeAsync(5);
expect(reads).toBe(backpressure ? 3 : 2);
expect(f.frames.some((row) => row.binary?.opcode === TerminalStreamOpcode.Snapshot)).toBe(
expect(f.frames.some((row) => row.binary?.opcode === TerminalStreamOpcode.Restore)).toBe(
true,
);
f.exit();
@@ -1347,3 +1296,51 @@ test.each([false, true])(
}
},
);
test("bounds a capturing viewer tail without ending a healthy viewer", async () => {
const pending = deferred<ReplayFixture | null>();
let reads = 0;
const f = exitFixture(() =>
++reads === 2 ? pending.promise : Promise.resolve(terminalReplay("retained", 0)),
);
const desktop = {},
phone = {};
try {
await f.subscribe(desktop, true, "desktop");
await waitForCoalescerFlush();
await f.subscribe(phone, true, "phone");
f.output("x".repeat(4 * 1024 * 1024 + 1), 1);
await waitForCoalescerFlush();
expect(
f.frames.filter(
(row) => row.source === phone && row.message?.type === "terminal_stream_exit",
),
).toHaveLength(1);
expect(
f.frames.filter(
(row) => row.source === desktop && row.message?.type === "terminal_stream_exit",
),
).toHaveLength(0);
expect(
f.frames.some(
(row) => row.source === desktop && row.binary?.opcode === TerminalStreamOpcode.Output,
),
).toBe(true);
pending.resolve(terminalReplay("phone", 1));
await waitForCoalescerFlush();
f.output("AFTER-OVERFLOW", 2);
await waitForCoalescerFlush();
expect(
f.frames.some(
(row) =>
row.source === desktop &&
row.binary?.opcode === TerminalStreamOpcode.Output &&
Buffer.from(row.binary.payload).toString().includes("AFTER-OVERFLOW"),
),
).toBe(true);
expect(f.delivery.registrationCount).toBe(1);
} finally {
pending.resolve(null);
await f.delivery.close();
}
});
@@ -28,13 +28,13 @@ import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js";
import {
MAX_CLIENT_BUFFERED_BYTES,
MAX_TERMINAL_OUTPUT_FRAME_BYTES,
encodeLegacyTerminalSnapshotFrame,
encodeTerminalRestoreFrame,
createTerminalRestoreDelivery,
resolveRestoreAfterOutputOverflow,
resolveTerminalRestoreSnapshotOptions,
resolveTerminalSubscriptionSnapshotMode,
type TerminalRestoreOptions,
} from "./terminal-restore.js";
import type { TerminalReplayMetadata } from "./terminal-replay.js";
import type { TerminalSession } from "./terminal.js";
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
import { applyTerminalSize } from "./terminal-size-ownership.js";
@@ -68,31 +68,28 @@ interface ActiveTerminalStream {
needsSnapshot: boolean;
snapshotInFlight: boolean;
retrySnapshotErrors: boolean;
readyRevision?: number;
// Readiness is independent of revision: reconnect adoption has no revision.
snapshotReady: boolean;
restore?: TerminalRestoreOptions;
bufferedOutputs: BufferedTerminalOutput[];
bufferedOutputBytes: number;
outputBytesSinceSnapshot: number;
outputCoalescer: TerminalOutputCoalescer;
}
interface SnapshotSendResult {
shouldContinue: boolean;
replayRevision?: number;
}
type SnapshotSendResult =
| { shouldContinue: false }
| { shouldContinue: true; replay: TerminalReplayMetadata | null };
export interface TerminalSessionControllerOptions {
terminalManager: TerminalManager | null;
emit: (msg: SessionOutboundMessage) => void;
hasBinaryChannel: () => boolean;
supportsChunkedReplay?: (source: object) => boolean;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
sessionLogger: pino.Logger;
listTerminalWorkspaceRefs?: () => Promise<readonly TerminalWorkspaceRef[]>;
listTerminalWorkspaceRoots?: () => Promise<readonly string[]>;
// Whether the connected client can reflow restored snapshots. When true the
// daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them
// so old (strict-schema) clients still parse the snapshot.
clientSupportsWrapReflow?: (source: object) => boolean;
// Current max bytes queued on the client's transport(s) but not yet sent.
// Drives the snapshot catch-up fallback: a keeping-up client reports ~0 and
// keeps streaming; a backed-up client trips the snapshot path. Defaults to a
// constant 0 (no backpressure signal) so callers without a transport always
@@ -145,7 +142,6 @@ export class TerminalSessionController {
private readonly sessionLogger: pino.Logger;
private readonly listTerminalWorkspaceRefs: () => Promise<readonly TerminalWorkspaceRef[]>;
private readonly listTerminalWorkspaceRoots: () => Promise<readonly string[]>;
private readonly clientSupportsWrapReflow: (source: object) => boolean;
private readonly getClientBufferedAmount: (source: object) => number | null;
private readonly subscribedDirectories = new Map<string, TerminalDirectorySubscription>();
@@ -153,7 +149,7 @@ export class TerminalSessionController {
private readonly activeStreams = new Map<number, ActiveTerminalStream>();
private nextSlot = 0;
constructor(options: TerminalSessionControllerOptions) {
constructor(private readonly options: TerminalSessionControllerOptions) {
this.terminalManager = options.terminalManager;
this.emit = options.emit;
this.hasBinaryChannel = options.hasBinaryChannel;
@@ -163,7 +159,6 @@ export class TerminalSessionController {
this.listTerminalWorkspaceRoots =
options.listTerminalWorkspaceRoots ??
(async () => (await this.listTerminalWorkspaceRefs()).map((workspace) => workspace.cwd));
this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false);
this.getClientBufferedAmount = options.getClientBufferedAmount ?? (() => 0);
}
@@ -904,9 +899,10 @@ export class TerminalSessionController {
snapshotInFlight: false,
retrySnapshotErrors: options.retrySnapshotErrors,
exiting: false,
readyRevision: undefined,
snapshotReady: false,
restore: options?.restore,
bufferedOutputs: [],
bufferedOutputBytes: 0,
outputBytesSinceSnapshot: 0,
outputCoalescer: new TerminalOutputCoalescer({
timers: { setTimeout, clearTimeout },
@@ -958,7 +954,7 @@ export class TerminalSessionController {
return;
}
if (message.type === "snapshot" || message.type === "snapshotReady") {
activeStream.readyRevision = message.revision;
activeStream.snapshotReady = true;
activeStream.outputCoalescer.flush();
activeStream.needsSnapshot = true;
void this.trySendSnapshot(activeStream);
@@ -971,6 +967,19 @@ export class TerminalSessionController {
return;
}
if (activeStream.needsSnapshot || activeStream.snapshotInFlight) {
activeStream.bufferedOutputBytes += Buffer.byteLength(message.data);
if (activeStream.bufferedOutputBytes > MAX_CLIENT_BUFFERED_BYTES) {
activeStream.owner.emit({
type: "terminal_stream_exit",
payload: {
terminalId: activeStream.terminalId,
error: "Terminal replay live tail exceeded the buffer; attach again",
},
});
this.detachRegistration(activeStream);
void activeStream.owner.release();
return;
}
activeStream.bufferedOutputs.push({
data: message.data,
revision: message.revision,
@@ -1017,7 +1026,7 @@ export class TerminalSessionController {
this.detachStream(activeStream.terminalId, { emitExit: true });
return;
}
if (activeStream.restore && activeStream.readyRevision === undefined) {
if (activeStream.restore && !activeStream.snapshotReady) {
return;
}
@@ -1027,12 +1036,12 @@ export class TerminalSessionController {
const restore = activeStream.restore;
const snapshotResult = restore
? await this.emitRestoreSnapshot(activeStream, terminalManager, restore)
: await this.emitLegacySnapshot(activeStream, terminalManager);
: await this.emitRestoreSnapshot(activeStream, terminalManager, { mode: "full-snapshot" });
if (!snapshotResult.shouldContinue) {
return;
}
activeStream.snapshotOutput = undefined;
this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replayRevision);
this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replay);
activeStream.needsSnapshot = false;
activeStream.outputBytesSinceSnapshot = 0;
} catch (error) {
@@ -1070,47 +1079,34 @@ export class TerminalSessionController {
}
}
private async emitLegacySnapshot(
activeStream: ActiveTerminalStream,
terminalManager: TerminalManager,
): Promise<SnapshotSendResult> {
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, {
includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source),
});
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
return { shouldContinue: false };
}
if (!snapshot) {
this.detachStream(activeStream.terminalId, { emitExit: true });
return { shouldContinue: false };
}
activeStream.owner.emitBinary(
encodeLegacyTerminalSnapshotFrame({
slot: activeStream.slot,
snapshot,
}),
);
// The snapshot frame went out-of-band; keep the replay that follows on the
// coalescer's trailing path so it doesn't flush back-to-back with it.
activeStream.outputCoalescer.markFlushed();
return { shouldContinue: true, replayRevision: snapshot.revision };
}
private async emitRestoreSnapshot(
activeStream: ActiveTerminalStream,
terminalManager: TerminalManager,
restore: TerminalRestoreOptions,
): Promise<SnapshotSendResult> {
const snapshotOptions = resolveTerminalRestoreSnapshotOptions(restore);
const chunked = this.options.supportsChunkedReplay?.(activeStream.owner.source) === true;
const snapshotOptions =
chunked && restore.mode !== "live"
? { scrollbackLines: restore.scrollbackLines }
: resolveTerminalRestoreSnapshotOptions(restore);
if (snapshotOptions === null) {
return { shouldContinue: true };
return { shouldContinue: true, replay: null };
}
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, {
...snapshotOptions,
includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source),
const deliver = createTerminalRestoreDelivery({
slot: activeStream.slot,
chunked,
// Network delivery belongs to the bounded application transport, not the worker request.
send: (frame) => activeStream.owner.emitBinary(frame),
});
const snapshot = await terminalManager.replayTerminal(
activeStream.terminalId,
snapshotOptions,
(event) => {
if (this.activeStreams.get(activeStream.slot) !== activeStream) return;
deliver(event);
},
);
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
return { shouldContinue: false };
}
@@ -1119,38 +1115,31 @@ export class TerminalSessionController {
return { shouldContinue: false };
}
activeStream.owner.emitBinary(
encodeTerminalRestoreFrame({
slot: activeStream.slot,
snapshot,
}),
);
// The restore frame went out-of-band; keep the replay that follows on the
// coalescer's trailing path so it doesn't flush back-to-back with it.
activeStream.outputCoalescer.markFlushed();
return { shouldContinue: true, replayRevision: snapshot.revision };
return { shouldContinue: true, replay: snapshot };
}
private replayTerminalOutputAfterSnapshot(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
replayRevision: number | undefined,
replay: TerminalReplayMetadata | null,
): void {
const replayPreamble = terminal.getReplayPreamble();
// A captured viewer must start from its cut, then apply the buffered mode changes.
// Live-only subscriptions have no capture and start from the current shared modes.
const replayPreamble = replay === null ? terminal.getReplayPreamble() : replay.replayPreamble;
if (replayPreamble.length > 0) {
activeStream.outputCoalescer.handle(replayPreamble);
}
activeStream.bufferedOutputBytes = 0;
const bufferedOutputs = activeStream.bufferedOutputs.splice(
0,
activeStream.bufferedOutputs.length,
);
for (const output of bufferedOutputs) {
if (
replayRevision !== undefined &&
output.revision !== undefined &&
output.revision <= replayRevision
) {
if (replay !== null && output.revision !== undefined && output.revision <= replay.revision) {
continue;
}
activeStream.outputCoalescer.handle(output.data);
@@ -72,3 +72,46 @@ describe("terminal worker authentication", () => {
await waitUntil(() => !existsSync(recordPath), 10_000);
});
});
it("bounds a stalled framed writer to one chunk and resumes in order", async () => {
const { createServer, createConnection } = await import("node:net");
const { attachFramedSocket } = await import("./terminal-worker-frames.js");
const server = createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing test address");
const accepted = new Promise<import("node:net").Socket>((resolve) =>
server.once("connection", resolve),
);
const reader = createConnection(address.port, "127.0.0.1");
reader.pause();
const writer = await accepted;
const framed = attachFramedSocket(writer, () => {});
let completed = 0;
let peakQueuedBytes = 0;
const received: number[] = [];
attachFramedSocket(reader, (value) => received.push((value as { index: number }).index));
const sending = (async () => {
for (let index = 0; index < 512; index++) {
const pending = framed.sendAndWait({ index, data: Buffer.alloc(64 * 1024) });
peakQueuedBytes = Math.max(peakQueuedBytes, writer.writableLength);
await pending;
completed++;
}
})();
try {
await waitUntil(() => writer.writableLength > 0, 10000);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(completed).toBeLessThan(512);
expect(peakQueuedBytes).toBeLessThan(65 * 1024);
reader.resume();
await sending;
await waitUntil(() => received.length === 512, 10000);
expect(received).toEqual(Array.from({ length: 512 }, (_, index) => index));
expect(writer.destroyed).toBe(false);
} finally {
reader.destroy();
writer.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
@@ -50,6 +50,7 @@ export class FrameDecoder {
export interface FramedSocket {
readonly socket: Socket;
send(message: unknown, callback?: (error: Error | null) => void): boolean;
sendAndWait(message: unknown): Promise<void>;
close(): void;
}
@@ -91,6 +92,17 @@ export function attachFramedSocket(
}
return socket.write(frame, (error) => callback?.(error ?? null));
},
sendAndWait(message) {
return new Promise<void>((resolve, reject) => {
this.send(message, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
},
close() {
socket.end(() => socket.destroy());
},
@@ -5,7 +5,7 @@ import type {
TerminalsChangedListener,
TerminalWorkspaceContributionChangedListener,
} from "./terminal-manager.js";
import type { TerminalSession, TerminalStateSnapshot } from "./terminal.js";
import type { TerminalSession } from "./terminal.js";
import type { WorkerCreateTerminalOptions } from "./terminal-worker-protocol.js";
import type { DaemonTerminalManager } from "./worker-terminal-manager.js";
@@ -217,8 +217,8 @@ export function createTerminalWorkerPool(
return undefined;
},
async getTerminalState(id, stateOptions): Promise<TerminalStateSnapshot | null> {
return routed(id).getTerminalState(id, stateOptions);
async replayTerminal(id, replayOptions, sink) {
return routed(id).replayTerminal(id, replayOptions, sink);
},
setTerminalTitle(id, title) {
@@ -1,3 +1,4 @@
import { createTerminalReplayController } from "./terminal-replay.js";
import { spawn } from "node:child_process";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { chmodSync, closeSync, existsSync, openSync, rmSync, writeSync } from "node:fs";
@@ -8,11 +9,7 @@ import { PRIVATE_FILE_MODE } from "../server/private-files.js";
import { createTerminalManager } from "./terminal-manager.js";
import { captureTerminalLines } from "./terminal-capture.js";
import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js";
import type {
TerminalExitInfo,
TerminalSession,
TerminalStateSnapshotOptions,
} from "./terminal.js";
import type { TerminalExitInfo, LocalTerminalSession } from "./terminal.js";
import { attachFramedSocket, type FramedSocket } from "./terminal-worker-frames.js";
import { createTerminalWorkerProcessInspector } from "./terminal-worker-processes.js";
import {
@@ -30,7 +27,6 @@ import {
type TerminalWorkerHelloAck,
type TerminalWorkerListResult,
type TerminalWorkerRequest,
type TerminalWorkerStateResult,
type TerminalWorkerToParentMessage,
type WorkerTerminalInfo,
} from "./terminal-worker-protocol.js";
@@ -177,7 +173,7 @@ function runWorker(argv: string[]): void {
const protocolVersion = reportedProtocolVersion();
const manager = createTerminalManager();
const inspector = createTerminalWorkerProcessInspector();
const sessionsById = new Map<string, TerminalSession>();
const sessionsById = new Map<string, LocalTerminalSession>();
const unsubscribeByTerminalId = new Map<string, Array<() => void>>();
const outputCoalescerByTerminalId = new Map<string, TerminalOutputCoalescer>();
// A terminal is published (terminalCreated) only once its program is recorded. An exit that
@@ -211,10 +207,12 @@ function runWorker(argv: string[]): void {
reportInFlightTerminalCreateFailure(error);
});
const replayController = createTerminalReplayController({
getSession: (id) => sessionsById.get(id),
flushOutput: (id) => outputCoalescerByTerminalId.get(id)?.flush(),
});
function sendToController(message: TerminalWorkerToParentMessage): void {
// With no controller the event is dropped; the headless terminal keeps the screen, and the
// next daemon reads it through listTerminals.
controller?.send(message);
replayController.send(message);
}
function persistRecord(): void {
@@ -259,17 +257,11 @@ function runWorker(argv: string[]): void {
}
}
function buildTerminalStateResult(
session: TerminalSession | undefined,
options?: TerminalStateSnapshotOptions,
): TerminalWorkerStateResult {
if (!session) {
return null;
}
return { ...session.getStateSnapshot(options), replayPreamble: session.getReplayPreamble() };
function terminalMetadata(session: LocalTerminalSession) {
return { ...session.getSize(), hasOutput: session.hasOutput(), title: session.getTitle() };
}
function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo {
function toTerminalInfo(session: LocalTerminalSession): WorkerTerminalInfo {
return {
id: session.id,
name: session.name,
@@ -316,7 +308,7 @@ function runWorker(argv: string[]): void {
}
}
function watchTerminal(session: TerminalSession): void {
function watchTerminal(session: LocalTerminalSession): void {
clearTerminalSubscriptions(session.id);
// Coalesce pty output chunks into a single frame per ~5ms window so a burst of small
@@ -340,6 +332,7 @@ function runWorker(argv: string[]): void {
const unsubscribeMessage = session.subscribe(
(message) => {
if (message.type === "snapshot") return;
if (message.type === "output") {
pendingOutputRevision = message.revision;
outputCoalescer.handle(message.data);
@@ -413,7 +406,7 @@ function runWorker(argv: string[]): void {
// A terminal enters the record only with its program's birth time, so cleanup can always
// tell the program from a later process that reuses its pid. A program that already exited
// needs no entry; one whose birth time cannot be read is killed and the create fails.
async function recordTerminalProgram(session: TerminalSession): Promise<void> {
async function recordTerminalProgram(session: LocalTerminalSession): Promise<void> {
if (session.getExitInfo()) return;
// The manager drops a session as soon as its program exits, so only a live one has a pid.
const pid = manager.getTerminalProcessId(session.id);
@@ -476,11 +469,19 @@ function runWorker(argv: string[]): void {
await session.killAndWait().catch(() => undefined);
throw error;
}
const initialSnapshot = session.getStateSnapshot();
const state = terminalMetadata(session);
sendToController({
type: "terminalCreated",
terminal: toTerminalInfo(session),
state: initialSnapshot.state,
state,
});
// Startup output/readiness may have arrived while recording the program,
// before the daemon knew this terminal. Publish its current modes after creation.
sendToController({
type: "terminalMessage",
terminalId: session.id,
message: { type: "snapshotReady" },
replayPreamble: session.getReplayPreamble(),
});
sendToController({
type: "response",
@@ -488,7 +489,7 @@ function runWorker(argv: string[]): void {
ok: true,
result: {
terminal: toTerminalInfo(session),
state: initialSnapshot.state,
state,
},
});
const heldExit = exitsHeldDuringCreation.get(session.id);
@@ -513,7 +514,7 @@ function runWorker(argv: string[]): void {
outputCoalescerByTerminalId.get(session.id)?.flush();
return {
terminal: toTerminalInfo(session),
state: session.getStateSnapshot().state,
state: terminalMetadata(session),
activityToken: manager.getTerminalActivityToken(session.id),
replayPreamble: session.getReplayPreamble(),
};
@@ -559,21 +560,8 @@ function runWorker(argv: string[]): void {
return;
}
case "getTerminalState": {
// Flush buffered output before snapshotting: the headless state already includes it,
// so if the coalescer emitted it afterward (in a batch carrying a revision past the
// snapshot's) the controller's revision dedup wouldn't drop it and the client would
// see the bytes twice. Flushing first sends them with a revision <= the snapshot's.
outputCoalescerByTerminalId.get(message.terminalId)?.flush();
sendToController({
type: "response",
requestId: message.requestId,
ok: true,
result: buildTerminalStateResult(
manager.getTerminal(message.terminalId),
message.options,
),
});
case "replayTerminal": {
await replayController.replay(message);
return;
}
@@ -659,10 +647,9 @@ function runWorker(argv: string[]): void {
}
authenticated = true;
// One controller at a time: a new authenticated connection replaces the old one.
const previous = controller;
controller = framed;
replayController.setConnection(framed);
everControlled = true;
previous?.socket.destroy();
const ack: TerminalWorkerHelloAck = {
type: "helloAck",
pid: process.pid,
@@ -688,6 +675,7 @@ function runWorker(argv: string[]): void {
clearTimeout(helloTimer);
if (controller === framed) {
controller = null;
replayController.setConnection(null);
exitIfIdle();
}
});
@@ -2,18 +2,24 @@ import { describe, expect, it } from "vitest";
import { classifyTerminalWorkerProtocol } from "./terminal-worker-protocol.js";
describe("classifyTerminalWorkerProtocol", () => {
it("rejects the unreleased cell protocol without a draining adapter", () => {
expect(classifyTerminalWorkerProtocol(1, 2)).toBe("obsolete");
expect(classifyTerminalWorkerProtocol(2, 3)).toBe("obsolete");
expect(classifyTerminalWorkerProtocol(3, 4)).toBe("obsolete");
expect(classifyTerminalWorkerProtocol(4, 5)).toBe("obsolete");
});
it.each([
[5, "current"],
[4, "previous"],
[3, "obsolete"],
[6, "current"],
[5, "previous"],
[4, "obsolete"],
[-1, "obsolete"],
[6, "newer"],
[7, "newer"],
// A version this daemon cannot read comes from a Paseo it does not know.
["6", "newer"],
[4.5, "newer"],
[Number.NaN, "newer"],
[undefined, "newer"],
])("a worker reporting %s to a daemon at 5 is %s", (reported, fit) => {
expect(classifyTerminalWorkerProtocol(reported, 5)).toBe(fit);
])("a worker reporting %s to a daemon at 6 is %s", (reported, fit) => {
expect(classifyTerminalWorkerProtocol(reported, 6)).toBe(fit);
});
});
@@ -1,11 +1,11 @@
import type {
TerminalExitInfo,
ServerMessage,
ClientMessage,
TerminalStateSnapshot,
TerminalStateSnapshotOptions,
} from "./terminal.js";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalExitInfo, ServerMessage, ClientMessage } from "./terminal.js";
import type { TerminalReplayEvent, TerminalReplayOptions } from "./terminal-replay.js";
export interface WorkerTerminalState {
rows: number;
cols: number;
hasOutput: boolean;
title?: string;
}
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
import type { CaptureTerminalLinesResult } from "./terminal-capture.js";
@@ -43,7 +43,7 @@ export interface WorkerCreateTerminalOptions {
// `shutdown`. A daemon drives a worker of its own version, drains one a version older (no new
// terminals; it exits after its last terminal), shuts down anything older, and leaves a newer
// worker running untouched. See docs/terminal-worker.md.
export const TERMINAL_WORKER_PROTOCOL_VERSION = 1;
export const TERMINAL_WORKER_PROTOCOL_VERSION = 5;
// Test-only: the protocol version a worker reports, so tests can stand up older and newer
// workers from one build.
@@ -59,7 +59,8 @@ export function classifyTerminalWorkerProtocol(
// A version this daemon cannot read comes from a Paseo it does not know: treat it as newer.
if (typeof reported !== "number" || !Number.isSafeInteger(reported)) return "newer";
if (reported === current) return "current";
if (reported === current - 1) return "previous";
// Protocols 1 through 4 were unreleased; they have no draining adapter.
if (reported === current - 1 && reported >= 5) return "previous";
return reported < current ? "obsolete" : "newer";
}
@@ -77,7 +78,7 @@ export interface TerminalWorkerHelloAck {
export interface WorkerListedTerminal {
terminal: WorkerTerminalInfo;
state: TerminalState;
state: WorkerTerminalState;
activityToken: string | null;
replayPreamble: string;
}
@@ -122,10 +123,10 @@ export type TerminalWorkerRequest =
options?: WorkerKillAndWaitOptions;
}
| {
type: "getTerminalState";
type: "replayTerminal";
requestId: string;
terminalId: string;
options?: TerminalStateSnapshotOptions;
options?: TerminalReplayOptions;
}
| {
type: "captureTerminal";
@@ -166,15 +167,16 @@ export type TerminalWorkerResponse =
};
export type TerminalWorkerEvent =
| { type: "terminalReplay"; requestId: string; event: TerminalReplayEvent }
| {
type: "terminalCreated";
terminal: WorkerTerminalInfo;
state: TerminalState;
state: WorkerTerminalState;
}
| {
type: "terminalMessage";
terminalId: string;
message: ServerMessage;
message: Exclude<ServerMessage, { type: "snapshot" }>;
replayPreamble: string;
}
| {
@@ -206,6 +208,3 @@ export type TerminalWorkerToParentMessage = TerminalWorkerResponse | TerminalWor
export type TerminalWorkerListResult = WorkerListedTerminal[];
export type TerminalWorkerCaptureResult = CaptureTerminalLinesResult;
// The worker fills TerminalStateSnapshot.replayPreamble on getTerminalState so
// the parent can cache the input-mode preamble instead of re-deriving it.
export type TerminalWorkerStateResult = TerminalStateSnapshot | null;
+9 -2
View File
@@ -114,8 +114,7 @@ export interface TerminalSession {
onTitleChange(listener: (title?: string) => void): () => void;
onActivityChange(listener: (transition: TerminalActivityTransition) => void): () => void;
getSize(): { rows: number; cols: number };
getState(): TerminalState;
getStateSnapshot(options?: TerminalStateSnapshotOptions): TerminalStateSnapshot;
hasOutput(): boolean;
getReplayPreamble(): string;
getTitle(): string | undefined;
getActivity(): TerminalActivity | null;
@@ -130,6 +129,9 @@ export interface TerminalSession {
/** A session that owns its PTY in this process, so it knows the program's pid. */
export interface LocalTerminalSession extends TerminalSession {
readonly pid: number;
getRetainedHistoryLines(): number;
getState(): TerminalState;
getStateSnapshot(options?: TerminalStateSnapshotOptions): TerminalStateSnapshot;
}
function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | null {
@@ -1606,6 +1608,11 @@ export async function createTerminal(
getSize,
getState,
getStateSnapshot,
getRetainedHistoryLines: () => terminal.buffer.active.baseY,
hasOutput: () =>
[...getState().scrollback, ...getState().grid].some((row) =>
row.some((cell) => cell.char.trim().length > 0),
),
getReplayPreamble,
getTitle,
getActivity,
@@ -1,3 +1,17 @@
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages";
import { TerminalSessionController } from "./terminal-session-controller.js";
import { SessionDelivery } from "../server/session/owned-subscriptions/index.js";
import {
decodeTerminalStreamFrame,
TerminalStreamOpcode,
type TerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index";
import type { SessionOutboundMessage } from "../server/messages.js";
import { createConnection } from "node:net";
import { attachFramedSocket } from "./terminal-worker-frames.js";
import xterm from "@xterm/headless";
import { afterEach, expect, it } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import { EventEmitter } from "node:events";
@@ -77,8 +91,35 @@ async function waitForCondition(
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
}
function getVisibleText(session: TerminalSession): string {
return getVisibleTextFromState(session.getState());
async function readReplay(manager: DaemonTerminalManager, id: string) {
const chunks: Uint8Array[] = [];
const metadata = await manager.replayTerminal(id, undefined, (event) => {
if (event.type === "data") chunks.push(event.data);
});
if (!metadata) return null;
const terminal = new xterm.Terminal({
rows: metadata.rows,
cols: metadata.cols,
scrollback: 1000,
allowProposedApi: true,
});
await new Promise<void>((resolve) => terminal.write(Buffer.concat(chunks), resolve));
const buffer = terminal.buffer.active;
const rows = Array.from({ length: buffer.length }, (_, i) => {
const line = buffer.getLine(i)!;
return Array.from({ length: metadata.cols }, (_cell, col) => ({
char: line.getCell(col)?.getChars() || " ",
}));
});
const state: TerminalState = {
rows: metadata.rows,
cols: metadata.cols,
scrollback: rows.slice(0, buffer.baseY),
grid: rows.slice(buffer.baseY),
cursor: { row: buffer.cursorY, col: buffer.cursorX },
};
terminal.dispose();
return { state, revision: metadata.revision };
}
function rowToText(row: TerminalRow): string {
@@ -144,7 +185,11 @@ const temporaryDirs: string[] = [];
// A real detached worker in a throwaway home; afterEach closes its terminals and the worker.
async function startRealWorkerManager(
options: { getTerminalActivityUrl?: () => string | null; paseoHome?: string } = {},
options: {
getTerminalActivityUrl?: () => string | null;
requestTimeoutMs?: number;
paseoHome?: string;
} = {},
): Promise<DaemonTerminalManager> {
const { paseoHome = createTemporaryHome(), ...rest } = options;
return startWorkerTerminalManager({
@@ -277,7 +322,7 @@ it("reconnects a live worker without replacing terminal sessions or processes",
await waitForCondition(() => !competitor.connected, 10000);
await waitForCondition(async () => {
try {
return (await manager!.getTerminalState(terminal.id)) !== null;
return (await readReplay(manager!, terminal.id)) !== null;
} catch {
return false;
}
@@ -343,26 +388,36 @@ it.each(["current", "draining"] as const)(
"keeps the peer usable when the %s worker dies",
async (target) => {
const paseoHome = createTemporaryHome();
const old = await leaveWorkerWithTerminal(paseoHome, 0);
const seeds = [
await launchDetachedWorkerWithTerminal(paseoHome),
await launchDetachedWorkerWithTerminal(paseoHome),
];
// Exercise an existing seed adopted as current; launch age does not imply draining ownership.
const [firstAdopted] = await workerRecords(paseoHome);
const seed = seeds.find((candidate) => candidate.workerPid === firstAdopted!.pid)!;
manager = await startRealWorkerManager({ paseoHome });
const added = await manager.createTerminal({
workspaceId: "ws-peer",
cwd: old.cwd,
cwd: seed.cwd,
...nodeTerminalCommand("setInterval(() => {}, 1000)"),
});
const current = (await workerRecords(paseoHome)).find(
(record) => record.pid !== old.workerPid,
const records = await workerRecords(paseoHome);
const current = records.find((record) =>
record.terminals.some((terminal) => terminal.id === added.id),
)!;
const deadId = target === "current" ? added.id : old.terminalId;
const healthyId = target === "current" ? old.terminalId : added.id;
const draining = records.find((record) => record.pid !== current.pid)!;
const drainingId = draining.terminals[0]!.id;
const deadId = target === "current" ? added.id : drainingId;
const healthyId = target === "current" ? drainingId : added.id;
const healthy = manager.getTerminal(healthyId);
process.kill(target === "current" ? current.pid : old.workerPid, "SIGKILL");
expect(healthy).toBeDefined();
process.kill(target === "current" ? current.pid : draining.pid, "SIGKILL");
await waitForCondition(() => !manager!.getTerminal(deadId), 10000);
expect(manager.getTerminal(healthyId)).toBe(healthy);
expect(await manager.getTerminalState(healthyId)).not.toBeNull();
expect(await readReplay(manager, healthyId)).not.toBeNull();
await manager.createTerminal({
workspaceId: "ws-peer",
cwd: old.cwd,
cwd: seed.cwd,
...nodeTerminalCommand("setInterval(() => {}, 1000)"),
});
expect((await workerRecords(paseoHome)).length).toBeLessThanOrEqual(2);
@@ -401,16 +456,11 @@ it("creates a terminal through the worker and streams output", async () => {
session.send({ type: "input", data: "hello\r" });
await waitForCondition(
() =>
messages.join("").includes("worker-output:hello") ||
getVisibleText(session).includes("worker-output:hello"),
10000,
);
await waitForCondition(() => messages.join("").includes("worker-output:hello"), 10000);
await new Promise((resolve) => setTimeout(resolve, 100));
unsubscribe();
expect(messages.join("") + getVisibleText(session)).toContain("worker-output:hello");
expect(messages.join("")).toContain("worker-output:hello");
expect(snapshots).toBe(snapshotsBeforeOutput);
});
@@ -459,7 +509,7 @@ it("delivers rapid small writes complete and in order through worker coalescing"
const expected = Array.from({ length: 500 }, (_, index) => index);
let received: number[] = [];
await waitForCondition(async () => {
const snapshot = await manager!.getTerminalState(session.id);
const snapshot = await readReplay(manager!, session.id);
received = snapshot ? readRenderedTokenIndexesFromState(snapshot.state) : [];
return expected.every((value, index) => received[index] === value);
}, 10000);
@@ -495,7 +545,7 @@ it("pulls fresh terminal state from the worker authority", async () => {
let visibleText = "";
await waitForCondition(async () => {
const snapshot = await manager!.getTerminalState(session.id);
const snapshot = await readReplay(manager!, session.id);
visibleText = snapshot ? getVisibleTextFromState(snapshot.state) : "";
return visibleText.includes("worker-state-ready");
}, 10000);
@@ -508,7 +558,7 @@ it("pulls fresh terminal state from the worker authority", async () => {
// preamble-caching contract is verified on Linux/macOS; the daemon's input-mode
// handling runs identically on every platform once the escape is observed.
it.skipIf(isPlatform("win32"))(
"caches the input-mode replay preamble from the worker after getTerminalState",
"caches input modes from worker output independently of replay capture",
async () => {
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-preamble-"));
temporaryDirs.push(cwd);
@@ -526,11 +576,10 @@ it.skipIf(isPlatform("win32"))(
}),
);
await waitForCondition(async () => {
const snapshot = await manager!.getTerminalState(session.id);
return snapshot !== null && session.getReplayPreamble() === "\x1b[=1;1u";
}, 10000);
await waitForCondition(() => session.getReplayPreamble() === "\x1b[=1;1u", 10000);
// Initial shared modes must be ready without a replay response repairing the cache.
expect(session.getReplayPreamble()).toBe("\x1b[=1;1u");
expect(await readReplay(manager!, session.id)).not.toBeNull();
expect(session.getReplayPreamble()).toBe("\x1b[=1;1u");
},
);
@@ -552,7 +601,7 @@ it("refreshes cached terminal title after worker title changes", async () => {
await waitForCondition(() => session.getTitle() === "Build Output", 10000);
expect(session.getState().title).toBe("Build Output");
expect(session.getTitle()).toBe("Build Output");
});
it("refreshes cached terminal size after worker resize", async () => {
@@ -562,12 +611,12 @@ it("refreshes cached terminal size after worker resize", async () => {
const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" }));
session.send({ type: "resize", rows: 10, cols: 40 });
const snapshot = await manager.getTerminalState(session.id);
const snapshot = await readReplay(manager, session.id);
expect(snapshot?.state.rows).toBe(10);
expect(snapshot?.state.cols).toBe(40);
expect(session.getState().rows).toBe(10);
expect(session.getState().cols).toBe(40);
expect(session.getSize().rows).toBe(10);
expect(session.getSize().cols).toBe(40);
});
it("captures terminal output from the worker authority", async () => {
@@ -817,6 +866,7 @@ it("never adopts a live worker's terminals after a kill cleanup it could not fin
// the shim; the PATH is restored as soon as it is up.
async function startWorkerWithSlowProcessTable(
delaySeconds: number,
options: { paseoHome?: string; requestTimeoutMs?: number } = {},
): Promise<DaemonTerminalManager> {
const realPs = (process.env.PATH ?? "")
.split(delimiter)
@@ -830,7 +880,7 @@ async function startWorkerWithSlowProcessTable(
const originalPath = process.env.PATH;
process.env.PATH = `${shimDir}${delimiter}${originalPath ?? ""}`;
try {
return await startRealWorkerManager();
return await startRealWorkerManager(options);
} finally {
process.env.PATH = originalPath;
}
@@ -854,7 +904,7 @@ async function expectCompletedShortCommand(
expect(session.getExitInfo()?.exitCode).toBe(0);
expect(terminalManager.getTerminal(session.id)).toBeUndefined();
expect(await terminalManager.getTerminals(cwd)).toEqual([]);
expect(await terminalManager.getTerminalState(session.id)).toBeNull();
expect(await readReplay(terminalManager, session.id)).toBeNull();
}
it.skipIf(isPlatform("win32"))(
@@ -1531,63 +1581,34 @@ async function leaveWorkerWithTerminal(
};
}
it("drains a worker one protocol older: it keeps its terminal, new ones open in a second worker, and it exits after its last", async () => {
const paseoHome = createTemporaryHome();
const old = await leaveWorkerWithTerminal(paseoHome, 0);
it.each([1, 4])(
"hard cuts unreleased worker protocol %s, closing its terminals",
async (version) => {
const paseoHome = createTemporaryHome();
const old = await leaveWorkerWithTerminal(paseoHome, version);
manager = await startRealWorkerManager({ paseoHome });
manager = await startRealWorkerManager({ paseoHome });
expect(manager.getTerminal(old.terminalId)).toBeDefined();
const workers = await workerRecords(paseoHome);
expect(workers).toHaveLength(2);
const current = workers.find((record) => record.pid !== old.workerPid)!;
expect(current.protocolVersion).toBe(1);
const added = await manager.createTerminal({
workspaceId: "ws-protocol",
cwd: old.cwd,
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
});
trackTerminal(added);
const currentTerminals = (
await readTerminalWorkerRecord(join(terminalWorkerDirectory(paseoHome), `${current.pid}.json`))
).terminals.map((entry) => entry.id);
expect(currentTerminals).toEqual([added.id]);
expect((await manager.getTerminals(old.cwd)).map((session) => session.id).sort()).toEqual(
[old.terminalId, added.id].sort(),
);
await manager.killTerminalAndWait(old.terminalId);
await waitForCondition(() => !isProcessAlive(old.workerPid), 10000);
expect((await workerRecords(paseoHome)).map((record) => record.pid)).toEqual([current.pid]);
expect(manager.getTerminal(added.id)).toBeDefined();
});
it("shuts down a worker two protocol versions old, closing its terminals", async () => {
const paseoHome = createTemporaryHome();
const old = await leaveWorkerWithTerminal(paseoHome, -1);
manager = await startRealWorkerManager({ paseoHome });
await waitForCondition(
() => !isProcessAlive(old.workerPid) && !isProcessAlive(old.programPid),
10000,
);
expect(manager.getTerminal(old.terminalId)).toBeUndefined();
const workers = await workerRecords(paseoHome);
expect(workers).toHaveLength(1);
expect(workers[0]!.protocolVersion).toBe(1);
});
await waitForCondition(
() => !isProcessAlive(old.workerPid) && !isProcessAlive(old.programPid),
10000,
);
expect(manager.getTerminal(old.terminalId)).toBeUndefined();
const workers = await workerRecords(paseoHome);
expect(workers).toHaveLength(1);
expect(workers[0]!.protocolVersion).toBe(5);
},
);
it("leaves a worker from a newer Paseo running, reports it incompatible, and closes it on a kill stop", async () => {
const paseoHome = createTemporaryHome();
const newer = await leaveWorkerWithTerminal(paseoHome, 2);
const newer = await leaveWorkerWithTerminal(paseoHome, 6);
manager = await startRealWorkerManager({ paseoHome });
const unavailable = manager.getUnavailableError?.();
expect(unavailable).toBeInstanceOf(TerminalWorkerIncompatibleError);
expect(unavailable?.message).toContain("worker protocol 2");
expect(unavailable?.message).toContain("worker protocol 6");
await expect(manager.getTerminals(newer.cwd)).rejects.toBeInstanceOf(
TerminalWorkerIncompatibleError,
);
@@ -1694,45 +1715,628 @@ it.skipIf(isPlatform("win32"))(
},
);
it("opens new terminals in a new worker once one of two draining workers exits", async () => {
const paseoHome = createTemporaryHome();
const first = await launchDetachedWorkerWithTerminal(paseoHome, 0);
const second = await launchDetachedWorkerWithTerminal(paseoHome, 0);
manager = await startRealWorkerManager({ paseoHome });
expect(await liveWorkerPids(paseoHome)).toEqual([first.workerPid, second.workerPid].sort());
await expect(
manager.createTerminal({ workspaceId: "ws-detached", cwd: first.cwd }),
).rejects.toBeInstanceOf(NoTerminalWorkerAvailableError);
await manager.killTerminalAndWait(first.terminalId);
await waitForCondition(() => !isProcessAlive(first.workerPid), 10000);
// Concurrent creates start one worker between them.
const created = await Promise.all(
[0, 1].map(() =>
manager!.createTerminal({
workspaceId: "ws-detached",
cwd: first.cwd,
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
}),
),
it("replays bounded ANSI chunks before concurrent live output and reconstructs retained text", async () => {
const cwd = createTemporaryHome();
manager = await startRealWorkerManager();
const session = trackTerminal(
await manager.createTerminal({
cwd,
workspaceId: "ws-replay",
rows: 8,
cols: 120,
...nodeTerminalCommand(`
for (let i = 0; i < 800; i++) process.stdout.write("\\x1b[31m" + String(i).padStart(4, "0") + ":" + "x".repeat(100) + "\\x1b[0m\\r\\n");
process.stdin.on("data", () => process.stdout.write("LIVE-AFTER-REPLAY\\r\\n"));
setInterval(() => {}, 1000);
`),
}),
);
created.forEach(trackTerminal);
const workers = await workerRecords(paseoHome);
expect(workers.map((record) => record.pid).sort()).toEqual(await liveWorkerPids(paseoHome));
expect(workers).toHaveLength(2);
const started = workers.find((record) => record.pid !== second.workerPid)!;
expect(started.protocolVersion).toBe(1);
expect(
(
await readTerminalWorkerRecord(
join(terminalWorkerDirectory(paseoHome), `${started.pid}.json`),
)
).terminals
.map((entry) => entry.id)
.sort(),
).toEqual(created.map((session) => session.id).sort());
expect(manager.getTerminal(second.terminalId)).toBeDefined();
await waitForCondition(
async () =>
(await manager!.captureTerminal(session.id)).lines.some((line) => line.startsWith("0799:")),
10000,
);
const expected = await manager.captureTerminal(session.id);
const events: string[] = [];
const chunks: Uint8Array[] = [];
let live = "";
const unsubscribe = session.subscribe(
(event) => {
if (event.type === "output") {
events.push("live");
live += event.data;
}
},
{ initialSnapshot: "ready" },
);
const metadata = await manager.replayTerminal(session.id, undefined, (event) => {
events.push(event.type);
if (event.type === "begin") session.send({ type: "input", data: "go\r" });
if (event.type === "data") chunks.push(event.data);
});
await waitForCondition(() => live.includes("LIVE-AFTER-REPLAY"), 10000);
unsubscribe();
expect(events[0]).toBe("begin");
expect(events.lastIndexOf("end")).toBeGreaterThan(events.lastIndexOf("data"));
expect(live.match(/LIVE-AFTER-REPLAY/g)).toHaveLength(1);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.byteLength <= 64 * 1024)).toBe(true);
expect(metadata?.rows).toBe(8);
expect(metadata?.cols).toBe(120);
const terminal = new xterm.Terminal({
rows: 8,
cols: 120,
scrollback: 1000,
allowProposedApi: true,
});
try {
await new Promise<void>((resolve) => terminal.write(Buffer.concat(chunks), resolve));
const buffer = terminal.buffer.active;
expect(
Array.from({ length: buffer.length }, (_, i) => buffer.getLine(i)!.translateToString(true)),
).toEqual(expected.lines);
expect(buffer.getLine(0)!.getCell(0)!.getFgColor()).toBe(1);
} finally {
terminal.dispose();
}
});
it("completes a replay larger than the socket budget while a sibling stays usable", async () => {
const paseoHome = createTemporaryHome();
const overflowProgress = join(paseoHome, "overflow-progress");
manager = await startRealWorkerManager({ paseoHome });
const terminal = trackTerminal(
await manager.createTerminal({
cwd: paseoHome,
workspaceId: "ws-wide",
rows: 4,
cols: 1024,
...nodeTerminalCommand(`
const row = Array.from({length: 1000}, (_, i) => "\\x1b[" + (31 + i % 2) + "mX").join("");
for (let i = 0; i < 900; i++) process.stdout.write(row + "\\r\\n");
process.stdout.write("READY-WIDE\\r\\n");
process.stdin.on("data", data => { if (data.toString().includes("small")) { process.stdout.write("SAME-TERMINAL-LIVE\\r\\n"); return; } process.stdout.write("q".repeat(8 * 1024 * 1024) + "\\r\\nRECOVERED-LIVE\\r\\n", () => require("node:fs").writeFileSync(${JSON.stringify(overflowProgress)}, "done")); });
setInterval(() => {}, 1000);
`),
}),
);
const siblingProgress = join(paseoHome, "sibling-progress");
const sibling = trackTerminal(
await manager.createTerminal({
cwd: paseoHome,
workspaceId: "ws-wide",
...nodeTerminalCommand(`
let count = 0;
process.stdin.on("data", () => {
count++;
process.stdout.write((count > 1 ? "z".repeat(1024 * 1024) : "") + "SIBLING-ALIVE\\r\\n", () => require("node:fs").writeFileSync(${JSON.stringify(siblingProgress)}, String(count)));
});
setInterval(() => {}, 1000);
`),
}),
);
await waitForCondition(
async () =>
(await manager!.captureTerminal(terminal.id)).lines.some((line) =>
line.includes("READY-WIDE"),
),
20000,
);
const seen: string[] = [];
const unsubscribe = sibling.subscribe((event) => {
if (event.type === "output") seen.push(event.data);
});
let bytes = 0;
const metadata = await manager.replayTerminal(terminal.id, undefined, (event) => {
if (event.type === "data") bytes += event.data.byteLength;
if (event.type === "begin") {
sibling.send({ type: "input", data: "go\r" });
}
});
expect(bytes).toBeGreaterThan(4 * 1024 * 1024);
expect(metadata?.cols).toBe(1024);
await waitForCondition(() => seen.join("").includes("SIBLING-ALIVE"), 10000);
unsubscribe();
expect(manager.getTerminal(sibling.id)).toBe(sibling);
const truncated = await manager.replayTerminal(terminal.id, { scrollbackLines: 200 }, () => {});
expect(metadata!.retainedHistoryLines).toBeGreaterThan(200);
expect(truncated!.retainedHistoryLines).toBe(metadata!.retainedHistoryLines);
const concurrent = await Promise.all(
[0, 1].map(() => manager!.replayTerminal(terminal.id, { scrollbackLines: 200 }, () => {})),
);
expect(concurrent.map((replay) => replay!.retainedHistoryLines)).toEqual([
metadata!.retainedHistoryLines,
metadata!.retainedHistoryLines,
]);
await manager.detach();
const [record] = await workerRecords(paseoHome);
const socket = createConnection(record!.socketPath);
let began = false;
let ended = false;
let completed = false;
let resumedBytes = 0;
let cancelledReplayBegan = false;
let existingLiveOutput = "";
const replayOrder: string[] = [];
const framed = attachFramedSocket(socket, (value) => {
const message = value as TerminalWorkerToParentMessage | { type: "helloAck" };
if (
message.type === "terminalMessage" &&
message.terminalId === terminal.id &&
message.message.type === "output"
) {
existingLiveOutput += message.message.data;
replayOrder.push("live");
}
if (message.type === "helloAck") {
framed.send({ type: "replayTerminal", terminalId: terminal.id, requestId: "stalled-replay" });
} else if (message.type === "terminalReplay") {
if (message.requestId === "cancelled-replay") {
if (message.event.type === "begin") {
cancelledReplayBegan = true;
socket.pause();
}
return;
}
if (message.event.type === "begin") {
began = true;
socket.pause();
}
if (message.event.type === "data") resumedBytes += message.event.data.byteLength;
if (message.event.type === "end") {
ended = true;
replayOrder.push("end");
}
} else if (message.type === "response" && message.requestId === "stalled-replay")
completed = message.ok;
});
try {
framed.send({ type: "hello", secret: record!.secret, protocolVersion: 5 });
await waitForCondition(() => began, 10000);
framed.send({
type: "send",
terminalId: terminal.id,
requestId: "same-terminal-input",
message: { type: "input", data: "small\r" },
});
framed.send({
type: "send",
terminalId: sibling.id,
requestId: "sibling-input",
message: { type: "input", data: "again\r" },
});
await waitForCondition(
() => existsSync(siblingProgress) && readFileSync(siblingProgress, "utf8") === "2",
10000,
);
await new Promise((resolve) => setTimeout(resolve, 150));
expect(completed).toBe(false);
expect(socket.destroyed).toBe(false);
socket.resume();
await waitForCondition(() => completed, 10000);
expect(ended).toBe(true);
expect(resumedBytes).toBe(bytes);
await waitForCondition(() => existingLiveOutput.includes("SAME-TERMINAL-LIVE"), 10000);
expect(existingLiveOutput.match(/SAME-TERMINAL-LIVE/g)).toHaveLength(1);
expect(replayOrder.indexOf("live")).toBeLessThan(replayOrder.indexOf("end"));
framed.send({ type: "replayTerminal", terminalId: terminal.id, requestId: "cancelled-replay" });
await waitForCondition(() => cancelledReplayBegan, 10000);
// Replace a controller while its large replay is socket-paced. The original
// request must never leak frames or completion onto the replacement connection.
manager = await startRealWorkerManager({ paseoHome });
socket.resume();
await waitForCondition(() => socket.destroyed, 10000);
expect((await readReplay(manager, terminal.id))?.state.cols).toBe(1024);
} finally {
socket.destroy();
}
expect(
(await manager!.captureTerminal(terminal.id)).lines.some((line) => line.includes("READY-WIDE")),
).toBe(true);
const exitingReplay = await manager.replayTerminal(
terminal.id,
{ scrollbackLines: 200 },
(event) => {
if (event.type === "begin") manager!.killTerminal(terminal.id);
},
);
expect(exitingReplay!.cols).toBe(1024);
await waitForCondition(() => manager!.getTerminal(terminal.id) === undefined, 10000);
expect(manager.getTerminal(sibling.id)?.id).toBe(sibling.id);
}, 60000);
function terminalViewers(
terminalManager: DaemonTerminalManager,
terminalId: string,
chunked = true,
) {
const frames: Array<{ source: object; frame: TerminalStreamFrame }> = [];
const messages: Array<{ source: object; message: SessionOutboundMessage }> = [];
let blockedSource: object | null = null;
let started = false;
let finish!: (error?: Error) => void;
const block = new Promise<void>((resolve, reject) => {
finish = (error) => (error ? reject(error) : resolve());
});
const delivery = new SessionDelivery(
(source, message) => {
messages.push({ source, message });
},
async (source, bytes) => {
const frame = decodeTerminalStreamFrame(bytes)!;
frames.push({ source, frame });
if (
source === blockedSource &&
frame.opcode === (chunked ? TerminalStreamOpcode.ReplayBegin : TerminalStreamOpcode.Restore)
) {
started = true;
await block;
}
},
);
const controller = new TerminalSessionController({
terminalManager,
emit: (message) => {
delivery.reply(message);
},
hasBinaryChannel: () => true,
supportsChunkedReplay: () => chunked,
isPathWithinRoot: () => true,
sessionLogger: pino({ level: "silent" }),
});
return {
frames,
messages,
delivery,
controller,
block(source: object) {
blockedSource = source;
},
started: () => started,
finish,
async attach(
source: object,
requestId: string,
restore: NonNullable<SubscribeTerminalRequest["restore"]> = {
mode: "visible-snapshot",
scrollbackLines: 1000,
},
) {
delivery.attach(source, true);
const request = {
type: "subscribe_terminal_request" as const,
terminalId,
requestId,
restore,
};
await delivery.request(source, request, () => controller.dispatch(request, delivery));
},
text(source: object) {
return frames
.filter((row) => row.source === source && row.frame.opcode === TerminalStreamOpcode.Output)
.map((row) => Buffer.from(row.frame.payload).toString("utf8"))
.join("");
},
ended(source: object) {
return messages.filter(
(row) => row.source === source && row.message.type === "terminal_stream_exit",
);
},
replayed(source: object) {
return frames.some(
(row) => row.source === source && row.frame.opcode === TerminalStreamOpcode.ReplayEnd,
);
},
};
}
it("isolates a failed app transport while another viewer of the same terminal stays live", async () => {
manager = await startRealWorkerManager();
const session = trackTerminal(
await manager.createTerminal({
cwd: createTemporaryHome(),
workspaceId: "two-viewers",
...nodeTerminalCommand(
'process.stdout.write("READY\\r\\n"); process.stdin.on("data", data => process.stdout.write("ECHO:" + data)); setInterval(() => {}, 1000);',
),
}),
);
await waitForCondition(
async () =>
(await manager!.captureTerminal(session.id)).lines.some((line) => line.includes("READY")),
10000,
);
const viewers = terminalViewers(manager, session.id);
const desktop = {},
phone = {},
fresh = {};
try {
await viewers.attach(desktop, "desktop");
await waitForCondition(() => viewers.replayed(desktop), 10000);
await expect(
manager.replayTerminal(session.id, undefined, () => {
throw new Error("Consumer cancelled replay");
}),
).rejects.toThrow("Consumer cancelled replay");
viewers.block(phone);
await viewers.attach(phone, "phone");
await waitForCondition(viewers.started, 10000);
session.send({ type: "input", data: "BEFORE-FAILURE\r" });
await waitForCondition(() => viewers.text(desktop).includes("ECHO:BEFORE-FAILURE"), 1500);
expect(viewers.ended(desktop)).toHaveLength(0);
expect(viewers.ended(phone)).toHaveLength(0);
viewers.finish(new Error("Phone socket closed"));
await viewers.delivery.detach(phone);
expect(viewers.ended(desktop)).toHaveLength(0);
session.send({ type: "input", data: "AFTER-FAILURE\r" });
await waitForCondition(() => viewers.text(desktop).includes("ECHO:AFTER-FAILURE"), 10000);
expect(viewers.ended(desktop)).toHaveLength(0);
await viewers.attach(fresh, "fresh");
await waitForCondition(() => viewers.replayed(fresh), 10000);
expect(manager.getTerminal(session.id)).toBe(session);
} finally {
viewers.finish();
await viewers.delivery.close();
}
}, 20000);
it("keeps concurrent viewer replays and live output ordered behind slow app delivery", async () => {
manager = await startRealWorkerManager();
const session = trackTerminal(
await manager.createTerminal({
cwd: createTemporaryHome(),
workspaceId: "concurrent-delivery",
...nodeTerminalCommand(
'process.stdout.write("READY\\r\\n"); process.stdin.on("data", data => process.stdout.write("ECHO:" + data)); setInterval(() => {}, 1000);',
),
}),
);
await waitForCondition(
async () => (await manager!.captureTerminal(session.id)).lines.join("").includes("READY"),
10000,
);
const viewers = terminalViewers(manager, session.id);
const desktop = {},
phone = {},
concurrent = {};
try {
await viewers.attach(desktop, "desktop");
await waitForCondition(() => viewers.replayed(desktop), 10000);
viewers.block(phone);
await viewers.attach(phone, "phone");
await waitForCondition(viewers.started, 10000);
await viewers.attach(concurrent, "concurrent");
await waitForCondition(() => viewers.replayed(concurrent) && viewers.replayed(phone), 10000);
session.send({ type: "input", data: "DURING-DELIVERY\r" });
await waitForCondition(
() =>
viewers.text(phone).includes("ECHO:DURING-DELIVERY") &&
viewers.text(desktop).includes("ECHO:DURING-DELIVERY"),
3000,
);
expect(viewers.text(phone).match(/ECHO:DURING-DELIVERY/g)).toHaveLength(1);
const order = viewers.frames
.filter((row) => row.source === phone)
.map((row) => row.frame.opcode);
expect(order.indexOf(TerminalStreamOpcode.Output)).toBeGreaterThan(
order.indexOf(TerminalStreamOpcode.ReplayEnd),
);
expect(viewers.ended(phone)).toHaveLength(0);
expect(viewers.ended(desktop)).toHaveLength(0);
} finally {
viewers.finish();
await viewers.delivery.close();
}
}, 20000);
it.each([true, false])(
"queues replay independently of app delivery past the worker deadline (chunked=%s)",
async (chunked) => {
const paseoHome = createTemporaryHome();
// Slow program identification belongs to setup, not the replay request deadline.
// The POSIX shim deterministically exceeds 800 ms; Windows keeps its real OS path.
manager = isPlatform("win32")
? await startRealWorkerManager({ paseoHome })
: await startWorkerWithSlowProcessTable(1, { paseoHome });
const created = await manager.createTerminal({
cwd: paseoHome,
workspaceId: "slow-delivery",
...nodeTerminalCommand(
'process.stdout.write("READY\\r\\n"); process.stdin.on("data", data => process.stdout.write("ECHO:" + data)); setInterval(() => {}, 1000);',
),
});
await waitForCondition(
async () => (await manager!.captureTerminal(created.id)).lines.join("").includes("READY"),
10000,
);
await manager.detach();
manager = await startRealWorkerManager({ paseoHome, requestTimeoutMs: 800 });
const session = manager.getTerminal(created.id);
if (!session) throw new Error("Replay fixture did not adopt its prepared terminal");
trackTerminal(session);
const viewers = terminalViewers(manager, session.id, chunked);
const phone = {};
viewers.block(phone);
try {
await viewers.attach(phone, "slow-phone");
await waitForCondition(viewers.started, 10000);
await new Promise((resolve) => setTimeout(resolve, 1100));
expect(viewers.ended(phone)).toHaveLength(0);
expect((await manager.captureTerminal(session.id)).lines.join("")).toContain("READY");
session.send({ type: "input", data: "AFTER-DEADLINE\r" });
await waitForCondition(() => viewers.text(phone).includes("ECHO:AFTER-DEADLINE"), 3000);
} finally {
viewers.finish();
await viewers.delivery.close();
}
},
20000,
);
it.each(["replacement", "socket-overflow"] as const)(
"recovers an attached restore viewer after worker %s",
async (loss) => {
const paseoHome = createTemporaryHome();
const drained = join(paseoHome, "burst-drained");
manager = await startRealWorkerManager({ paseoHome });
const session = trackTerminal(
await manager.createTerminal({
cwd: paseoHome,
workspaceId: "attached-reconnect",
...nodeTerminalCommand(
`process.stdout.write("RETAINED\\r\\n"); process.stdin.on("data", data => {
if (data.toString().includes("BURST")) process.stdout.write("x".repeat(12 * 1024 * 1024) + "\\r\\nRETAINED-AFTER-LOSS\\r\\n", () => require("node:fs").writeFileSync(${JSON.stringify(drained)}, "done"));
else process.stdout.write("ECHO:" + data);
}); setInterval(() => {}, 1000);`,
),
}),
);
await waitForCondition(
async () => (await manager!.captureTerminal(session.id)).lines.join("").includes("RETAINED"),
10000,
);
const viewers = terminalViewers(manager, session.id);
const desktop = {};
try {
await viewers.attach(desktop, "desktop");
await waitForCondition(() => viewers.replayed(desktop), 10000);
viewers.frames.length = 0;
const [record] = await workerRecords(paseoHome);
if (loss === "replacement") {
const competitor = await connectTerminalWorker({
socketPath: record!.socketPath,
secret: record!.secret,
});
await waitForCondition(() => !competitor.connected, 10000);
} else {
session.send({ type: "input", data: "BURST\r" });
// Stall only the daemon event loop. The detached worker and PTY keep running,
// filling the bounded worker socket and forcing its disconnect/re-adoption path.
const stall = new Int32Array(new SharedArrayBuffer(4));
const deadline = Date.now() + 10000;
while (!existsSync(drained) && Date.now() < deadline) Atomics.wait(stall, 0, 0, 25);
expect(existsSync(drained)).toBe(true);
}
await waitForCondition(() => viewers.replayed(desktop), 10000);
const replay = viewers.frames
.filter((row) => row.frame.opcode === TerminalStreamOpcode.ReplayData)
.map((row) => Buffer.from(row.frame.payload).toString("utf8"))
.join("");
expect(replay).toContain("RETAINED");
if (loss === "socket-overflow") expect(replay).toContain("RETAINED-AFTER-LOSS");
expect((await workerRecords(paseoHome)).map((worker) => worker.pid)).toEqual([record!.pid]);
session.send({ type: "input", data: "RECOVERED\r" });
await waitForCondition(() => viewers.text(desktop).includes("ECHO:RECOVERED"), 3000);
expect(viewers.ended(desktop)).toHaveLength(0);
expect(manager.getTerminal(session.id)).toBe(session);
} finally {
await viewers.delivery.close();
}
},
30000,
);
it.each([true, false])(
"keeps live modes after a replay captured enabled=%s while restoring its own modes first",
async (enabled) => {
const on = "\x1b[?1h\x1b[?2004h";
const off = "\x1b[?1l\x1b[?2004l";
const captured = enabled ? on : "";
const latest = enabled ? "" : on;
const live = (enabled ? off : on) + "MODE-LIVE";
const worker = new FakeTerminalWorker();
manager = createWorkerTerminalManager({ worker, requestTimeoutMs: 1000 });
worker.emitWorkerMessage({
type: "terminalCreated",
terminal: {
id: "mode-terminal",
name: "Terminal",
cwd: "/tmp",
workspaceId: "modes",
activity: null,
},
state: createTerminalState(),
});
worker.emitWorkerMessage({
type: "terminalMessage",
terminalId: "mode-terminal",
replayPreamble: captured,
message: { type: "snapshotReady", revision: 1 },
});
const session = manager.getTerminal("mode-terminal")!;
const viewers = terminalViewers(manager, session.id);
const restoring = {},
following = {};
try {
await viewers.attach(restoring, "restoring");
await waitForCondition(
() => worker.sentMessages.some((message) => message.type === "replayTerminal"),
1000,
);
const request = worker.sentMessages.find((message) => message.type === "replayTerminal")!;
const metadata = {
rows: 30,
cols: 120,
retainedHistoryLines: 10,
revision: 1,
replayPreamble: captured,
};
worker.emitWorkerMessage({
type: "terminalReplay",
requestId: request.requestId,
event: { type: "begin", metadata },
});
worker.emitWorkerMessage({
type: "terminalReplay",
requestId: request.requestId,
event: { type: "data", data: Buffer.from("CAPTURE-A") },
});
worker.emitWorkerMessage({
type: "terminalMessage",
terminalId: session.id,
replayPreamble: latest,
message: { type: "output", data: live, revision: 2 },
});
expect(session.getReplayPreamble()).toBe(latest);
worker.emitWorkerMessage({
type: "terminalReplay",
requestId: request.requestId,
event: { type: "data", data: Buffer.from("CAPTURE-B") },
});
worker.emitWorkerMessage({
type: "terminalReplay",
requestId: request.requestId,
event: { type: "end" },
});
worker.emitWorkerMessage({
type: "response",
requestId: request.requestId,
ok: true,
result: metadata,
});
await waitForCondition(() => viewers.text(restoring).includes("MODE-LIVE"), 1000);
expect(session.getReplayPreamble()).toBe(latest);
expect(viewers.text(restoring)).toBe(captured + live);
const sequence = viewers.frames
.filter((row) => row.source === restoring)
.map((row) => row.frame.opcode);
expect(sequence.indexOf(TerminalStreamOpcode.Output)).toBeGreaterThan(
sequence.indexOf(TerminalStreamOpcode.ReplayEnd),
);
const modes = new TerminalInputModeTracker();
modes.feed(session.getReplayPreamble());
expect(encodeTerminalKeyInput({ key: "ArrowUp" }, { inputMode: modes.getState() })).toBe(
enabled ? "\x1b[A" : "\x1bOA",
);
expect(modes.getState().bracketedPaste).toBe(!enabled);
await viewers.attach(following, "live-following", { mode: "live" });
await waitForCondition(() => latest === "" || viewers.text(following).length > 0, 1000);
expect(viewers.text(following)).toBe(latest);
expect(
worker.sentMessages.filter((message) => message.type === "replayTerminal"),
).toHaveLength(1);
} finally {
await viewers.delivery.close();
}
},
);
@@ -1,3 +1,4 @@
import type { TerminalReplaySink, TerminalReplayMetadata } from "./terminal-replay.js";
import { randomBytes, randomUUID } from "node:crypto";
import { access } from "node:fs/promises";
import { setTimeout as delay } from "node:timers/promises";
@@ -5,7 +6,7 @@ import type { Logger } from "pino";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js";
import { writtenDuringThisBoot } from "../server/pid-lock.js";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { WorkerTerminalState } from "./terminal-worker-protocol.js";
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-activity";
import type {
@@ -15,7 +16,6 @@ import type {
TerminalCommandFinishedInfo,
TerminalExitInfo,
TerminalSession,
TerminalStateSnapshot,
} from "./terminal.js";
import { resolvePaseoCliExecutablePath } from "./paseo-cli-path.js";
import type { CaptureTerminalLinesResult } from "./terminal-capture.js";
@@ -59,7 +59,6 @@ import type {
TerminalWorkerResponse,
TerminalWorkerToParentMessage,
WorkerCreateTerminalOptions,
TerminalWorkerStateResult,
WorkerTerminalInfo,
} from "./terminal-worker-protocol.js";
@@ -93,7 +92,7 @@ interface PendingRequest {
interface WorkerTerminalRecord {
info: RequiredWorkerTerminalInfo;
state: TerminalState;
state: WorkerTerminalState;
activity: TerminalActivity | null;
// Cached input-mode preamble from the worker (the authoritative tracker lives
// in the worker process). Updated with streamed output and snapshot responses.
@@ -172,6 +171,7 @@ export function createWorkerTerminalManager(
const requestTimeoutMs = managerOptions.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
const resolveCreateEnvironment =
managerOptions.resolveCreateEnvironment ?? resolveDaemonTerminalCreateEnvironment;
const replaySinks = new Map<string, TerminalReplaySink>();
const pendingRequests = new Map<string, PendingRequest>();
const inFlight = new Set<Promise<void>>();
const recordsById = new Map<string, WorkerTerminalRecord>();
@@ -244,7 +244,7 @@ export function createWorkerTerminalManager(
function registerRecord(input: {
info: RequiredWorkerTerminalInfo;
state: TerminalState;
state: WorkerTerminalState;
}): TerminalSession {
const existing = recordsById.get(input.info.id);
if (existing) {
@@ -362,19 +362,8 @@ export function createWorkerTerminalManager(
cols: record.state.cols,
};
},
getState(): TerminalState {
return record.state;
},
getStateSnapshot(options?: { scrollbackLines?: number }): TerminalStateSnapshot {
const scrollbackLines = options?.scrollbackLines;
const scrollback =
typeof scrollbackLines === "number"
? record.state.scrollback.slice(-scrollbackLines)
: record.state.scrollback;
return {
state: { ...record.state, scrollback },
revision: 0,
};
hasOutput(): boolean {
return record.state.hasOutput;
},
getReplayPreamble(): string {
return record.replayPreamble;
@@ -442,9 +431,7 @@ export function createWorkerTerminalManager(
if (!record) {
return;
}
if (message.message.type === "snapshot") {
record.state = message.message.state;
}
if (message.message.type === "output") record.state.hasOutput = true;
record.replayPreamble = message.replayPreamble;
for (const listener of Array.from(record.messageListeners)) {
listener(message.message);
@@ -610,7 +597,23 @@ export function createWorkerTerminalManager(
}
function attachWorker(): void {
worker.on("message", (message: TerminalWorkerToParentMessage) => {
const connection = worker;
connection.on("message", (message: TerminalWorkerToParentMessage) => {
if (message.type === "terminalReplay") {
// Consumption queues into the application's bounded transport synchronously.
// Never wait for app network delivery or accumulate a second replay queue here.
if (worker !== connection || !pendingRequests.has(message.requestId)) return;
try {
replaySinks.get(message.requestId)?.(message.event);
} catch (error) {
const pending = pendingRequests.get(message.requestId)!;
clearTimeout(pending.timeout);
pendingRequests.delete(message.requestId);
replaySinks.delete(message.requestId);
pending.reject(error instanceof Error ? error : new Error(String(error)));
}
return;
}
if (isResponse(message)) {
const pending = pendingRequests.get(message.requestId);
if (!pending) {
@@ -628,14 +631,15 @@ export function createWorkerTerminalManager(
handleWorkerEvent(message);
});
worker.on("close", () => {
connection.on("close", () => {
if (worker !== connection) return;
workerDisconnected = true;
rejectPendingRequests(new Error("Terminal worker connection closed"));
const reconnect = managerOptions.reconnect;
if (!detached && reconnect) {
reconnecting = (async () => {
const connection = await reconnect();
if (!connection) {
const recovered = await reconnect();
if (!recovered) {
for (const terminalId of Array.from(recordsById.keys())) {
handleTerminalExitEvent({
type: "terminalExit",
@@ -646,10 +650,10 @@ export function createWorkerTerminalManager(
return;
}
if (detached) {
connection.disconnect();
recovered.disconnect();
return;
}
worker = connection;
worker = recovered;
workerDisconnected = false;
attachWorker();
await adoptTerminals();
@@ -672,12 +676,18 @@ export function createWorkerTerminalManager(
}
attachWorker();
function sendRequest(input: TerminalWorkerRequestInput): Promise<unknown> {
if (reconnecting && workerDisconnected) return reconnecting.then(() => sendRequest(input));
function sendRequest(
input: TerminalWorkerRequestInput,
sink?: TerminalReplaySink,
): Promise<unknown> {
if (reconnecting && workerDisconnected)
return reconnecting.then(() => sendRequest(input, sink));
if (workerDisconnected || !worker.connected) {
return Promise.reject(new Error("Terminal worker is not running"));
}
const connection = worker;
const requestId = randomUUID();
if (sink) replaySinks.set(requestId, sink);
const message = { ...input, requestId } as TerminalWorkerRequest;
const request = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
@@ -685,7 +695,7 @@ export function createWorkerTerminalManager(
reject(new Error(`Terminal worker request timed out: ${input.type}`));
}, requestTimeoutMs);
pendingRequests.set(requestId, { resolve, reject, timeout });
worker.send(message, (error) => {
connection.send(message, (error) => {
if (!error) {
return;
}
@@ -700,7 +710,10 @@ export function createWorkerTerminalManager(
() => undefined,
);
inFlight.add(settled);
void settled.finally(() => inFlight.delete(settled));
void settled.finally(() => {
inFlight.delete(settled);
replaySinks.delete(requestId);
});
return request;
}
@@ -867,22 +880,12 @@ export function createWorkerTerminalManager(
return recordsById.get(id)?.session;
},
async getTerminalState(
id: string,
options?: { scrollbackLines?: number },
): Promise<TerminalStateSnapshot | null> {
const snapshot = (await sendRequest({
type: "getTerminalState",
terminalId: id,
...(options ? { options } : {}),
})) as TerminalWorkerStateResult;
if (snapshot && snapshot.replayPreamble !== undefined) {
const record = recordsById.get(id);
if (record) {
record.replayPreamble = snapshot.replayPreamble;
}
}
return snapshot;
async replayTerminal(id, options, sink): Promise<TerminalReplayMetadata | null> {
const metadata = (await sendRequest(
{ type: "replayTerminal", terminalId: id, options },
sink,
)) as TerminalReplayMetadata | null;
return metadata;
},
setTerminalTitle(id: string, title: string): boolean {