Align terminal stream with snapshot-only attach
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user