Align terminal stream with snapshot-only attach

This commit is contained in:
Mohamed Boudra
2026-03-22 22:49:05 +07:00
parent 138c774297
commit babc724220
14 changed files with 262 additions and 550 deletions

View File

@@ -216,7 +216,7 @@ export function TerminalPane({
if (terminalIdRef.current === exitedTerminalId) {
emulatorRef.current?.clear();
}
streamControllerRef.current?.handleStreamExit({
streamControllerRef.current?.handleTerminalExit({
terminalId: exitedTerminalId,
});
setModifiers({ ...EMPTY_MODIFIERS });

View File

@@ -609,17 +609,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
}));
});
const unsubscribeStreamExit = client.on("terminal_stream_exit", (message) => {
if (message.type !== "terminal_stream_exit") {
return;
}
});
client.subscribeTerminals({ cwd: normalizedWorkspaceId });
return () => {
unsubscribeChanged();
unsubscribeStreamExit();
client.unsubscribeTerminals({ cwd: normalizedWorkspaceId });
};
}, [client, isConnected, normalizedWorkspaceId, queryClient, terminalsQueryKey]);

View File

@@ -6,44 +6,32 @@ import {
type TerminalStreamControllerStatus,
} from "./terminal-stream-controller";
type TerminalSnapshot = {
rows: number;
cols: number;
grid: Array<Array<{ char: string }>>;
scrollback: Array<Array<{ char: string }>>;
cursor: { row: number; col: number };
};
type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array }
| {
terminalId: string;
type: "snapshot";
state: {
rows: number;
cols: number;
grid: Array<Array<{ char: string }>>;
scrollback: Array<Array<{ char: string }>>;
cursor: { row: number; col: number };
};
};
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot };
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
private readonly listeners = new Set<(event: TerminalStreamEvent) => void>();
public subscribeCalls: string[] = [];
public unsubscribeCalls: string[] = [];
public resizeCalls: Array<{ terminalId: string; rows: number; cols: number }> = [];
public nextSubscribeResponses: Array<{
terminalId: string;
state: {
rows: number;
cols: number;
grid: Array<Array<{ char: string }>>;
scrollback: Array<Array<{ char: string }>>;
cursor: { row: number; col: number };
} | null;
error?: string | null;
}> = [];
public nextSubscribeResults: Array<{ terminalId: string; error?: string | null }> = [];
async subscribeTerminal(terminalId: string) {
this.subscribeCalls.push(terminalId);
const response = this.nextSubscribeResponses.shift();
if (!response) {
throw new Error("Missing fake subscribe response");
const result = this.nextSubscribeResults.shift();
if (!result) {
throw new Error("Missing fake subscribe result");
}
return response;
return result;
}
unsubscribeTerminal(terminalId: string): void {
@@ -54,11 +42,7 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
terminalId: string,
message: { type: "resize"; rows: number; cols: number },
): void {
this.resizeCalls.push({
terminalId,
rows: message.rows,
cols: message.cols,
});
this.resizeCalls.push({ terminalId, rows: message.rows, cols: message.cols });
}
onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void {
@@ -75,65 +59,40 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
}
}
function createControllerHarness(input?: {
client?: FakeTerminalStreamClient;
}): {
client: FakeTerminalStreamClient;
outputs: Array<{ terminalId: string; text: string }>;
snapshots: Array<{ terminalId: string; text: string }>;
statuses: TerminalStreamControllerStatus[];
controller: TerminalStreamController;
} {
function createHarness(input?: { client?: FakeTerminalStreamClient }) {
const client = input?.client ?? new FakeTerminalStreamClient();
const outputs: Array<{ terminalId: string; text: string }> = [];
const snapshots: Array<{ terminalId: string; text: string }> = [];
const statuses: TerminalStreamControllerStatus[] = [];
const controller = new TerminalStreamController({
client,
getPreferredSize: () => ({ rows: 24, cols: 80 }),
onOutput: ({ terminalId, text }) => {
outputs.push({ terminalId, text });
onOutput: (output) => {
outputs.push(output);
},
onSnapshot: ({ terminalId, state }) => {
snapshots.push({
terminalId,
text: state.grid
.map((row) => row.map((cell) => cell.char).join(""))
.join("\n"),
text: state.grid.map((row) => row.map((cell) => cell.char).join("")).join("\n"),
});
},
onStatusChange: (status) => {
statuses.push(status);
},
waitForDelay: async () => {},
});
return {
client,
outputs,
snapshots,
statuses,
controller,
};
return { client, controller, outputs, snapshots, statuses };
}
async function flushAsyncWork(): Promise<void> {
await Promise.resolve();
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 0);
});
await Promise.resolve();
}
describe("terminal-stream-controller", () => {
it("subscribes to a terminal, resizes it, and forwards snapshot/output events", async () => {
const harness = createControllerHarness();
harness.client.nextSubscribeResponses.push({
terminalId: "term-1",
state: null,
error: null,
});
it("subscribes, resizes, and forwards snapshot/output events", async () => {
const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
@@ -166,76 +125,50 @@ describe("terminal-stream-controller", () => {
});
});
it("retries retryable subscribe failures and then attaches", async () => {
const harness = createControllerHarness();
harness.client.nextSubscribeResponses.push({
it("surfaces subscribe failures without retrying", async () => {
const harness = createHarness();
harness.client.nextSubscribeResults.push({
terminalId: "term-1",
state: null,
error: "network disconnected",
});
harness.client.nextSubscribeResponses.push({
terminalId: "term-1",
state: null,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
expect(harness.client.subscribeCalls).toEqual(["term-1", "term-1"]);
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
error: null,
error: "network disconnected",
});
});
it("reconnects to the selected terminal when the stream exits", async () => {
const harness = createControllerHarness();
harness.client.nextSubscribeResponses.push({
terminalId: "term-1",
state: null,
error: null,
});
harness.client.nextSubscribeResponses.push({
terminalId: "term-1",
state: null,
error: null,
});
it("treats terminal exit as final and does not reconnect", async () => {
const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
harness.controller.handleStreamExit({ terminalId: "term-1" });
harness.controller.handleTerminalExit({ terminalId: "term-1" });
await flushAsyncWork();
expect(harness.client.subscribeCalls).toEqual(["term-1", "term-1"]);
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
error: null,
error: "Terminal exited",
});
});
it("unsubscribes when switching terminals and on dispose", async () => {
const harness = createControllerHarness();
harness.client.nextSubscribeResponses.push({
terminalId: "term-1",
state: null,
error: null,
});
harness.client.nextSubscribeResponses.push({
terminalId: "term-2",
state: null,
error: null,
});
const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
harness.client.nextSubscribeResults.push({ terminalId: "term-2", error: null });
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
harness.controller.setTerminal({ terminalId: "term-2" });
await flushAsyncWork();
harness.controller.dispose();
expect(harness.client.unsubscribeCalls).toEqual(["term-1", "term-2"]);

View File

@@ -1,15 +1,8 @@
import type { TerminalState } from "@server/shared/messages";
import {
getTerminalAttachRetryDelayMs,
isTerminalAttachRetryableError,
waitForDuration,
withPromiseTimeout,
} from "@/utils/terminal-attach";
export type TerminalStreamControllerClient = {
subscribeTerminal: (terminalId: string) => Promise<{
terminalId: string;
state: TerminalState | null;
error?: string | null;
}>;
unsubscribeTerminal: (terminalId: string) => void;
@@ -43,227 +36,116 @@ export type TerminalStreamControllerOptions = {
onOutput: (input: { terminalId: string; text: string }) => void;
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
maxAttachAttempts?: number;
attachTimeoutMs?: number;
reconnectErrorMessage?: string;
withTimeout?: <T>(input: {
promise: Promise<T>;
timeoutMs: number;
timeoutMessage: string;
}) => Promise<T>;
waitForDelay?: (input: { durationMs: number }) => Promise<void>;
isRetryableError?: (input: { message: string }) => boolean;
getRetryDelayMs?: (input: { attempt: number }) => number;
};
const DEFAULT_ATTACH_MAX_ATTEMPTS = 4;
const DEFAULT_ATTACH_TIMEOUT_MS = 12_000;
const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…";
const TERMINAL_EXITED_ERROR = "Terminal exited";
export class TerminalStreamController {
private readonly unsubscribeStreamEvents: () => void;
private readonly decoder = new TextDecoder();
private selectedTerminalId: string | null = null;
private attachGeneration = 0;
private isDisposed = false;
private readonly unsubscribeStreamEvents: () => void;
private terminalId: string | null = null;
private disposed = false;
constructor(private readonly options: TerminalStreamControllerOptions) {
this.unsubscribeStreamEvents = this.options.client.onTerminalStreamEvent((event) => {
if (this.isDisposed || event.terminalId !== this.selectedTerminalId) {
if (this.disposed || event.terminalId !== this.terminalId) {
return;
}
if (event.type === "snapshot") {
this.decoder.decode();
this.options.onSnapshot({
terminalId: event.terminalId,
state: event.state,
});
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state });
return;
}
const text = this.decoder.decode(event.data, { stream: true });
if (text.length === 0) {
return;
if (text.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, text });
}
this.options.onOutput({
terminalId: event.terminalId,
text,
});
});
}
setTerminal(input: { terminalId: string | null }): void {
if (this.isDisposed) {
if (this.disposed || input.terminalId === this.terminalId) {
return;
}
const nextTerminalId = input.terminalId;
if (this.selectedTerminalId === nextTerminalId) {
return;
}
const previousTerminalId = this.selectedTerminalId;
this.selectedTerminalId = nextTerminalId;
this.attachGeneration += 1;
const generation = this.attachGeneration;
const previousTerminalId = this.terminalId;
this.terminalId = nextTerminalId;
this.decoder.decode();
if (previousTerminalId) {
this.options.client.unsubscribeTerminal(previousTerminalId);
}
if (!nextTerminalId) {
this.updateStatus({
terminalId: null,
isAttaching: false,
error: null,
});
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
return;
}
this.updateStatus({
terminalId: nextTerminalId,
isAttaching: true,
error: null,
});
void this.attachTerminal({
terminalId: nextTerminalId,
generation,
});
}
handleStreamExit(input: { terminalId: string }): void {
if (this.isDisposed || this.selectedTerminalId !== input.terminalId) {
return;
}
this.attachGeneration += 1;
const generation = this.attachGeneration;
this.decoder.decode();
this.updateStatus({
terminalId: input.terminalId,
isAttaching: true,
error: this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE,
});
void this.attachTerminal({
terminalId: input.terminalId,
generation,
});
}
dispose(): void {
if (this.isDisposed) {
return;
}
this.isDisposed = true;
this.attachGeneration += 1;
this.decoder.decode();
const selectedTerminalId = this.selectedTerminalId;
this.selectedTerminalId = null;
if (selectedTerminalId) {
this.options.client.unsubscribeTerminal(selectedTerminalId);
}
this.unsubscribeStreamEvents();
this.updateStatus({
terminalId: null,
isAttaching: false,
error: null,
});
}
private async attachTerminal(input: { terminalId: string; generation: number }): Promise<void> {
const {
maxAttachAttempts = DEFAULT_ATTACH_MAX_ATTEMPTS,
attachTimeoutMs = DEFAULT_ATTACH_TIMEOUT_MS,
withTimeout = withPromiseTimeout,
waitForDelay = waitForDuration,
isRetryableError = isTerminalAttachRetryableError,
getRetryDelayMs = getTerminalAttachRetryDelayMs,
} = this.options;
let lastErrorMessage = "Unable to subscribe to terminal";
for (let attempt = 0; attempt < maxAttachAttempts; attempt += 1) {
if (!this.isAttachGenerationCurrent(input)) {
return;
}
try {
const payload = await withTimeout({
promise: this.options.client.subscribeTerminal(input.terminalId),
timeoutMs: attachTimeoutMs,
timeoutMessage: "Timed out subscribing to terminal",
});
if (!this.isAttachGenerationCurrent(input)) {
this.options.client.unsubscribeTerminal(input.terminalId);
this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null });
void this.options.client
.subscribeTerminal(nextTerminalId)
.then((payload) => {
if (this.disposed || this.terminalId !== nextTerminalId) {
return;
}
if (payload.error) {
lastErrorMessage = payload.error;
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
continue;
}
this.updateStatus({
terminalId: input.terminalId,
this.terminalId = null;
this.options.onStatusChange?.({
terminalId: nextTerminalId,
isAttaching: false,
error: lastErrorMessage,
error: payload.error,
});
return;
}
const preferredSize = this.options.getPreferredSize();
if (preferredSize) {
this.options.client.sendTerminalInput(input.terminalId, {
this.options.client.sendTerminalInput(nextTerminalId, {
type: "resize",
rows: preferredSize.rows,
cols: preferredSize.cols,
});
}
this.updateStatus({
terminalId: input.terminalId,
this.options.onStatusChange?.({
terminalId: nextTerminalId,
isAttaching: false,
error: null,
});
return;
} catch (error) {
lastErrorMessage =
error instanceof Error ? error.message : "Unable to subscribe to terminal";
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
continue;
})
.catch((error: unknown) => {
if (this.disposed || this.terminalId !== nextTerminalId) {
return;
}
this.updateStatus({
terminalId: input.terminalId,
this.terminalId = null;
this.options.onStatusChange?.({
terminalId: nextTerminalId,
isAttaching: false,
error: lastErrorMessage,
error: error instanceof Error ? error.message : "Unable to subscribe to terminal",
});
return;
}
}
});
}
this.updateStatus({
handleTerminalExit(input: { terminalId: string }): void {
if (this.disposed || input.terminalId !== this.terminalId) {
return;
}
this.decoder.decode();
this.terminalId = null;
this.options.onStatusChange?.({
terminalId: input.terminalId,
isAttaching: false,
error: lastErrorMessage,
error: TERMINAL_EXITED_ERROR,
});
}
private isAttachGenerationCurrent(input: { terminalId: string; generation: number }): boolean {
if (this.isDisposed) {
return false;
dispose(): void {
if (this.disposed) {
return;
}
return this.attachGeneration === input.generation && this.selectedTerminalId === input.terminalId;
}
private updateStatus(status: TerminalStreamControllerStatus): void {
this.options.onStatusChange?.(status);
this.disposed = true;
this.decoder.decode();
const terminalId = this.terminalId;
this.terminalId = null;
if (terminalId) {
this.options.client.unsubscribeTerminal(terminalId);
}
this.unsubscribeStreamEvents();
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
}
}

View File

@@ -1,50 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getTerminalAttachRetryDelayMs,
isTerminalAttachRetryableError,
withPromiseTimeout,
} from "./terminal-attach";
describe("terminal-attach", () => {
it("computes bounded exponential retry delays", () => {
expect(getTerminalAttachRetryDelayMs({ attempt: 0 })).toBe(250);
expect(getTerminalAttachRetryDelayMs({ attempt: 1 })).toBe(500);
expect(getTerminalAttachRetryDelayMs({ attempt: 2 })).toBe(1_000);
expect(getTerminalAttachRetryDelayMs({ attempt: 3 })).toBe(2_000);
expect(getTerminalAttachRetryDelayMs({ attempt: 8 })).toBe(2_000);
});
it("matches retryable attach errors", () => {
expect(isTerminalAttachRetryableError({ message: "Terminal not found while attaching" })).toBe(
true,
);
expect(isTerminalAttachRetryableError({ message: "Network disconnected during attach" })).toBe(
true,
);
expect(isTerminalAttachRetryableError({ message: "stream ended before snapshot" })).toBe(
true,
);
expect(isTerminalAttachRetryableError({ message: "permission denied" })).toBe(false);
});
it("resolves before timeout when promise completes", async () => {
await expect(
withPromiseTimeout({
promise: Promise.resolve("ok"),
timeoutMs: 50,
timeoutMessage: "timed out",
}),
).resolves.toBe("ok");
});
it("rejects when timeout wins", async () => {
await expect(
withPromiseTimeout({
promise: new Promise<string>(() => {}),
timeoutMs: 10,
timeoutMessage: "timed out",
}),
).rejects.toThrow("timed out");
});
});

View File

@@ -1,49 +0,0 @@
const TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS = [
"terminal not found",
"timed out",
"timeout",
"connection",
"network",
"disconnected",
"stream ended",
] as const;
export function getTerminalAttachRetryDelayMs(input: { attempt: number }): number {
const clampedAttempt = Math.max(0, input.attempt);
const exponentialDelay = 250 * 2 ** clampedAttempt;
return Math.min(2_000, exponentialDelay);
}
export function isTerminalAttachRetryableError(input: { message: string }): boolean {
const normalized = input.message.toLowerCase();
return TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern));
}
export async function waitForDuration(input: { durationMs: number }): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, Math.max(0, input.durationMs));
});
}
export async function withPromiseTimeout<T>(input: {
promise: Promise<T>;
timeoutMs: number;
timeoutMessage: string;
}): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(
() => {
reject(new Error(input.timeoutMessage));
},
Math.max(0, input.timeoutMs),
);
});
try {
return await Promise.race([input.promise, timeoutPromise]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}

View File

@@ -1257,7 +1257,6 @@ describe("DaemonClient", () => {
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
state: null,
error: null,
requestId: "sub-1",
},
@@ -1308,7 +1307,6 @@ describe("DaemonClient", () => {
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
state: null,
error: null,
requestId: "sub-2",
},
@@ -1356,7 +1354,6 @@ describe("DaemonClient", () => {
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
state: null,
error: null,
requestId: "sub-3",
},
@@ -1418,7 +1415,6 @@ describe("DaemonClient", () => {
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
state: null,
error: null,
requestId: "sub-4",
},

View File

@@ -4,6 +4,11 @@ import { tmpdir } from "os";
import path from "path";
import WebSocket from "ws";
import { DaemonClient } from "../../client/daemon-client.js";
import {
WSOutboundMessageSchema,
type TerminalState,
type WSOutboundMessage,
} from "../../shared/messages.js";
import {
decodeTerminalStreamFrame,
TerminalStreamOpcode,
@@ -11,6 +16,8 @@ import {
} from "../../shared/terminal-stream-protocol.js";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
type RawSessionEnvelope = Extract<WSOutboundMessage, { type: "session" }>;
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-terminal-e2e-"));
}
@@ -24,10 +31,7 @@ function createLogger() {
};
}
function extractStateText(state: {
grid: Array<Array<{ char: string }>>;
scrollback: Array<Array<{ char: string }>>;
}): string {
function extractStateText(state: Pick<TerminalState, "grid" | "scrollback">): string {
return [...state.scrollback, ...state.grid]
.map((row) =>
row
@@ -57,21 +61,9 @@ async function waitForCondition(
async function waitForTerminalSnapshot(
client: DaemonClient,
terminalId: string,
predicate: (state: {
rows: number;
cols: number;
grid: Array<Array<{ char: string }>>;
scrollback: Array<Array<{ char: string }>>;
cursor: { row: number; col: number };
}) => boolean,
predicate: (state: TerminalState) => boolean,
timeout = 10000,
): Promise<{
rows: number;
cols: number;
grid: Array<Array<{ char: string }>>;
scrollback: Array<Array<{ char: string }>>;
cursor: { row: number; col: number };
}> {
): Promise<TerminalState> {
return new Promise((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
unsubscribe();
@@ -156,9 +148,7 @@ async function connectRawWebSocket(port: number): Promise<WebSocket> {
const helloReady = waitForRawSessionMessage(
ws,
(message) =>
message.type === "session" &&
message.message?.type === "status" &&
message.message.payload?.status === "server_info",
message.message.type === "status" && message.message.payload.status === "server_info",
10000,
);
@@ -198,15 +188,9 @@ async function closeWebSocket(ws: WebSocket, timeout = 5000): Promise<void> {
async function waitForRawSessionMessage(
ws: WebSocket,
predicate: (message: {
type?: string;
message?: { type?: string; payload?: Record<string, any> };
}) => boolean,
predicate: (message: RawSessionEnvelope) => boolean,
timeout = 10000,
): Promise<{
type?: string;
message?: { type?: string; payload?: Record<string, any> };
}> {
): Promise<RawSessionEnvelope> {
return new Promise((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
cleanup();
@@ -220,10 +204,11 @@ async function waitForRawSessionMessage(
return;
}
try {
const parsed = JSON.parse(text) as {
type?: string;
message?: { type?: string; payload?: Record<string, any> };
};
const parsedResult = WSOutboundMessageSchema.safeParse(JSON.parse(text));
if (!parsedResult.success || parsedResult.data.type !== "session") {
return;
}
const parsed = parsedResult.data;
if (!predicate(parsed)) {
return;
}
@@ -294,9 +279,8 @@ async function subscribeRawTerminal(ws: WebSocket, terminalId: string, requestId
const ready = waitForRawSessionMessage(
ws,
(message) =>
message.type === "session" &&
message.message?.type === "subscribe_terminal_response" &&
message.message.payload?.requestId === requestId,
message.message.type === "subscribe_terminal_response" &&
message.message.payload.requestId === requestId,
10000,
);
@@ -311,7 +295,11 @@ async function subscribeRawTerminal(ws: WebSocket, terminalId: string, requestId
}),
);
await ready;
const message = await ready;
if (message.message.type !== "subscribe_terminal_response") {
throw new Error("Expected subscribe_terminal_response");
}
expect(message.message.payload).not.toHaveProperty("state");
}
describe("daemon E2E terminal", () => {

View File

@@ -279,8 +279,7 @@ type ActiveTerminalStream = {
terminalId: string;
unsubscribe: () => void;
needsSnapshot: boolean;
primed: boolean;
snapshotTimer: ReturnType<typeof setTimeout> | null;
snapshotRetryTimer: ReturnType<typeof setTimeout> | null;
};
export type SessionRuntimeMetrics = {
@@ -1817,8 +1816,8 @@ export class Session {
if (!resize) {
return;
}
activeStream.needsSnapshot = true;
terminal.send({ type: "resize", rows: resize.rows, cols: resize.cols });
this.queueTerminalSnapshot(activeStream, terminal);
return;
}
@@ -7666,7 +7665,6 @@ export class Session {
type: "subscribe_terminal_response",
payload: {
terminalId: msg.terminalId,
state: null,
error: "Terminal manager not available",
requestId: msg.requestId,
},
@@ -7680,7 +7678,6 @@ export class Session {
type: "subscribe_terminal_response",
payload: {
terminalId: msg.terminalId,
state: null,
error: "Terminal not found",
requestId: msg.requestId,
},
@@ -7689,23 +7686,16 @@ export class Session {
}
this.ensureTerminalExitSubscription(session);
const activeStream = this.bindActiveTerminalStream(session);
// Send initial state
this.emit({
type: "subscribe_terminal_response",
payload: {
terminalId: msg.terminalId,
state: session.getState(),
error: null,
requestId: msg.requestId,
},
});
if (activeStream) {
this.sendTerminalSnapshot(activeStream, session);
activeStream.primed = true;
}
this.bindActiveTerminalStream(session);
}
private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void {
@@ -7803,126 +7793,77 @@ export class Session {
const activeStream: ActiveTerminalStream = {
terminalId: terminal.id,
unsubscribe: () => {},
needsSnapshot: false,
primed: false,
snapshotTimer: null,
needsSnapshot: true,
snapshotRetryTimer: null,
};
const trySendSnapshot = () => {
if (this.activeTerminalStream !== activeStream || !activeStream.needsSnapshot) {
return;
}
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_LOW_WATER_BYTES) {
if (!activeStream.snapshotRetryTimer) {
activeStream.snapshotRetryTimer = setTimeout(() => {
activeStream.snapshotRetryTimer = null;
trySendSnapshot();
}, 33);
}
return;
}
if (activeStream.snapshotRetryTimer) {
clearTimeout(activeStream.snapshotRetryTimer);
activeStream.snapshotRetryTimer = null;
}
activeStream.needsSnapshot = false;
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
payload: encodeTerminalSnapshotPayload(terminal.getState()),
}),
);
};
activeStream.unsubscribe = terminal.subscribe((message) => {
if (this.activeTerminalStream !== activeStream) {
return;
}
if (message.type === "output") {
this.handleActiveTerminalOutput(activeStream, terminal, message.data);
if (message.type === "snapshot") {
trySendSnapshot();
return;
}
if (message.type === "snapshot") {
this.maybeSendQueuedTerminalSnapshot(activeStream, terminal);
if (activeStream.needsSnapshot || message.data.length === 0) {
return;
}
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) {
activeStream.needsSnapshot = true;
trySendSnapshot();
return;
}
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
payload: new Uint8Array(Buffer.from(message.data, "utf8")),
}),
);
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) {
activeStream.needsSnapshot = true;
trySendSnapshot();
}
});
this.activeTerminalStream = activeStream;
return activeStream;
}
private handleActiveTerminalOutput(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
data: string,
): void {
if (!activeStream.primed || data.length === 0) {
return;
}
if (activeStream.needsSnapshot) {
this.maybeSendQueuedTerminalSnapshot(activeStream, terminal);
return;
}
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_HIGH_WATER_BYTES) {
activeStream.needsSnapshot = true;
this.scheduleTerminalSnapshotRetry(activeStream, terminal);
return;
}
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
payload: new Uint8Array(Buffer.from(data, "utf8")),
}),
);
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_HIGH_WATER_BYTES) {
activeStream.needsSnapshot = true;
this.scheduleTerminalSnapshotRetry(activeStream, terminal);
}
}
private queueTerminalSnapshot(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
): void {
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_HIGH_WATER_BYTES) {
activeStream.needsSnapshot = true;
this.scheduleTerminalSnapshotRetry(activeStream, terminal);
return;
}
this.sendTerminalSnapshot(activeStream, terminal);
}
private maybeSendQueuedTerminalSnapshot(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
): void {
if (!activeStream.needsSnapshot) {
return;
}
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_LOW_WATER_BYTES) {
this.scheduleTerminalSnapshotRetry(activeStream, terminal);
return;
}
this.sendTerminalSnapshot(activeStream, terminal);
}
private sendTerminalSnapshot(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
): void {
this.clearTerminalSnapshotRetry(activeStream);
activeStream.needsSnapshot = false;
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
payload: encodeTerminalSnapshotPayload(terminal.getState()),
}),
);
}
private scheduleTerminalSnapshotRetry(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
): void {
if (activeStream.snapshotTimer) {
return;
}
activeStream.snapshotTimer = setTimeout(() => {
activeStream.snapshotTimer = null;
if (this.activeTerminalStream !== activeStream || !activeStream.needsSnapshot) {
return;
}
this.maybeSendQueuedTerminalSnapshot(activeStream, terminal);
}, 33);
}
private clearTerminalSnapshotRetry(activeStream: ActiveTerminalStream): void {
if (!activeStream.snapshotTimer) {
return;
}
clearTimeout(activeStream.snapshotTimer);
activeStream.snapshotTimer = null;
}
private detachActiveTerminalStream(options?: { emitExit: boolean }): boolean {
const activeStream = this.activeTerminalStream;
if (!activeStream) {
return false;
}
this.activeTerminalStream = null;
this.clearTerminalSnapshotRetry(activeStream);
if (activeStream.snapshotRetryTimer) {
clearTimeout(activeStream.snapshotRetryTimer);
activeStream.snapshotRetryTimer = null;
}
try {
activeStream.unsubscribe();
} catch (error) {

View File

@@ -2104,15 +2104,15 @@ export const TerminalCellSchema = z.object({
bold: z.boolean().optional(),
italic: z.boolean().optional(),
underline: z.boolean().optional(),
});
}).strict();
export const TerminalStateSchema = z.object({
rows: z.number(),
cols: z.number(),
grid: z.array(z.array(TerminalCellSchema)),
scrollback: z.array(z.array(TerminalCellSchema)),
cursor: z.object({ row: z.number(), col: z.number() }),
});
cursor: z.object({ row: z.number(), col: z.number() }).strict(),
}).strict();
export const ListTerminalsResponseSchema = z.object({
type: z.literal("list_terminals_response"),
@@ -2144,7 +2144,6 @@ export const SubscribeTerminalResponseSchema = z.object({
type: z.literal("subscribe_terminal_response"),
payload: z.object({
terminalId: z.string(),
state: TerminalStateSchema.nullable(),
error: z.string().nullable(),
requestId: z.string(),
}),

View File

@@ -56,4 +56,50 @@ describe("terminal stream protocol", () => {
it("rejects unknown opcodes", () => {
expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01]))).toBeNull();
});
it("rejects malformed JSON payloads", () => {
const malformed = new TextEncoder().encode("{");
expect(decodeTerminalResizePayload(malformed)).toBeNull();
expect(decodeTerminalSnapshotPayload(malformed)).toBeNull();
});
it("rejects invalid resize and snapshot shapes", () => {
expect(
decodeTerminalResizePayload(new TextEncoder().encode(JSON.stringify({ rows: "24", cols: 80 }))),
).toBeNull();
expect(
decodeTerminalSnapshotPayload(
new TextEncoder().encode(
JSON.stringify({
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
}),
),
),
).toBeNull();
});
it("rejects unknown fields in resize and snapshot payloads", () => {
expect(
decodeTerminalResizePayload(
new TextEncoder().encode(JSON.stringify({ rows: 24, cols: 80, extra: true })),
),
).toBeNull();
expect(
decodeTerminalSnapshotPayload(
new TextEncoder().encode(
JSON.stringify({
rows: 1,
cols: 1,
grid: [[{ char: "A", extra: true }]],
scrollback: [],
cursor: { row: 0, col: 1 },
}),
),
),
).toBeNull();
});
});

View File

@@ -4,7 +4,7 @@ import { TerminalStateSchema } from "./messages.js";
export const TerminalStreamResizeSchema = z.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
});
}).strict();
export const TerminalStreamOpcode = {
Output: 0x01,
@@ -115,6 +115,10 @@ function encodeJsonPayload(value: unknown): Uint8Array {
}
function decodeJsonPayload(bytes: Uint8Array): unknown {
const text = new TextDecoder().decode(bytes);
return JSON.parse(text);
try {
const text = new TextDecoder().decode(bytes);
return JSON.parse(text);
} catch {
return null;
}
}

View File

@@ -438,6 +438,31 @@ describe("Terminal", () => {
unsubscribe();
});
it("emits output only after getState reflects the new data", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
await waitForLines(session, ["$"]);
const outputSeenInState = new Promise<boolean>((resolve) => {
const unsubscribe = session.subscribe((message) => {
if (message.type !== "output" || !message.data.includes("state-after-output")) {
return;
}
unsubscribe();
const stateText = getLines(session.getState()).join("\n");
resolve(stateText.includes("state-after-output"));
});
});
session.send({ type: "input", data: "echo state-after-output\r" });
expect(await outputSeenInState).toBe(true);
});
it("unsubscribe stops receiving messages", async () => {
const session = trackSession(
await createTerminal({

View File

@@ -322,10 +322,13 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
// Pipe PTY output to terminal emulator
ptyProcess.onData((data) => {
if (killed) return;
for (const listener of listeners) {
listener({ type: "output", data });
}
terminal.write(data, () => {
if (disposed || killed) {
return;
}
for (const listener of listeners) {
listener({ type: "output", data });
}
scheduleStateBroadcast();
});
});
@@ -359,6 +362,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
case "resize":
terminal.resize(msg.cols, msg.rows);
ptyProcess.resize(msg.cols, msg.rows);
scheduleStateBroadcast();
break;
case "mouse":
// Mouse events can be sent as escape sequences if terminal supports it
@@ -370,8 +374,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
function subscribe(listener: (msg: ServerMessage) => void): () => void {
listeners.add(listener);
queueMicrotask(() => {
if (listeners.has(listener)) {
terminal.write("", () => {
if (!disposed && listeners.has(listener)) {
listener({ type: "snapshot", state: getState() });
}
});