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
@@ -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 });
@@ -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]);
@@ -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"]);
@@ -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 });
}
}
@@ -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");
});
});
-49
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);
}
}
}