Align terminal stream with snapshot-only attach
This commit is contained in:
@@ -216,7 +216,7 @@ export function TerminalPane({
|
|||||||
if (terminalIdRef.current === exitedTerminalId) {
|
if (terminalIdRef.current === exitedTerminalId) {
|
||||||
emulatorRef.current?.clear();
|
emulatorRef.current?.clear();
|
||||||
}
|
}
|
||||||
streamControllerRef.current?.handleStreamExit({
|
streamControllerRef.current?.handleTerminalExit({
|
||||||
terminalId: exitedTerminalId,
|
terminalId: exitedTerminalId,
|
||||||
});
|
});
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
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 });
|
client.subscribeTerminals({ cwd: normalizedWorkspaceId });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribeChanged();
|
unsubscribeChanged();
|
||||||
unsubscribeStreamExit();
|
|
||||||
client.unsubscribeTerminals({ cwd: normalizedWorkspaceId });
|
client.unsubscribeTerminals({ cwd: normalizedWorkspaceId });
|
||||||
};
|
};
|
||||||
}, [client, isConnected, normalizedWorkspaceId, queryClient, terminalsQueryKey]);
|
}, [client, isConnected, normalizedWorkspaceId, queryClient, terminalsQueryKey]);
|
||||||
|
|||||||
@@ -6,44 +6,32 @@ import {
|
|||||||
type TerminalStreamControllerStatus,
|
type TerminalStreamControllerStatus,
|
||||||
} from "./terminal-stream-controller";
|
} 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 =
|
type TerminalStreamEvent =
|
||||||
| { terminalId: string; type: "output"; data: Uint8Array }
|
| { terminalId: string; type: "output"; data: Uint8Array }
|
||||||
| {
|
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot };
|
||||||
terminalId: string;
|
|
||||||
type: "snapshot";
|
|
||||||
state: {
|
|
||||||
rows: number;
|
|
||||||
cols: number;
|
|
||||||
grid: Array<Array<{ char: string }>>;
|
|
||||||
scrollback: Array<Array<{ char: string }>>;
|
|
||||||
cursor: { row: number; col: number };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
||||||
private readonly listeners = new Set<(event: TerminalStreamEvent) => void>();
|
private readonly listeners = new Set<(event: TerminalStreamEvent) => void>();
|
||||||
public subscribeCalls: string[] = [];
|
public subscribeCalls: string[] = [];
|
||||||
public unsubscribeCalls: string[] = [];
|
public unsubscribeCalls: string[] = [];
|
||||||
public resizeCalls: Array<{ terminalId: string; rows: number; cols: number }> = [];
|
public resizeCalls: Array<{ terminalId: string; rows: number; cols: number }> = [];
|
||||||
public nextSubscribeResponses: Array<{
|
public nextSubscribeResults: Array<{ terminalId: string; error?: string | null }> = [];
|
||||||
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;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
async subscribeTerminal(terminalId: string) {
|
async subscribeTerminal(terminalId: string) {
|
||||||
this.subscribeCalls.push(terminalId);
|
this.subscribeCalls.push(terminalId);
|
||||||
const response = this.nextSubscribeResponses.shift();
|
const result = this.nextSubscribeResults.shift();
|
||||||
if (!response) {
|
if (!result) {
|
||||||
throw new Error("Missing fake subscribe response");
|
throw new Error("Missing fake subscribe result");
|
||||||
}
|
}
|
||||||
return response;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
unsubscribeTerminal(terminalId: string): void {
|
unsubscribeTerminal(terminalId: string): void {
|
||||||
@@ -54,11 +42,7 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
|||||||
terminalId: string,
|
terminalId: string,
|
||||||
message: { type: "resize"; rows: number; cols: number },
|
message: { type: "resize"; rows: number; cols: number },
|
||||||
): void {
|
): void {
|
||||||
this.resizeCalls.push({
|
this.resizeCalls.push({ terminalId, rows: message.rows, cols: message.cols });
|
||||||
terminalId,
|
|
||||||
rows: message.rows,
|
|
||||||
cols: message.cols,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void {
|
onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void {
|
||||||
@@ -75,65 +59,40 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createControllerHarness(input?: {
|
function createHarness(input?: { client?: FakeTerminalStreamClient }) {
|
||||||
client?: FakeTerminalStreamClient;
|
|
||||||
}): {
|
|
||||||
client: FakeTerminalStreamClient;
|
|
||||||
outputs: Array<{ terminalId: string; text: string }>;
|
|
||||||
snapshots: Array<{ terminalId: string; text: string }>;
|
|
||||||
statuses: TerminalStreamControllerStatus[];
|
|
||||||
controller: TerminalStreamController;
|
|
||||||
} {
|
|
||||||
const client = input?.client ?? new FakeTerminalStreamClient();
|
const client = input?.client ?? new FakeTerminalStreamClient();
|
||||||
const outputs: Array<{ terminalId: string; text: string }> = [];
|
const outputs: Array<{ terminalId: string; text: string }> = [];
|
||||||
const snapshots: Array<{ terminalId: string; text: string }> = [];
|
const snapshots: Array<{ terminalId: string; text: string }> = [];
|
||||||
const statuses: TerminalStreamControllerStatus[] = [];
|
const statuses: TerminalStreamControllerStatus[] = [];
|
||||||
|
|
||||||
const controller = new TerminalStreamController({
|
const controller = new TerminalStreamController({
|
||||||
client,
|
client,
|
||||||
getPreferredSize: () => ({ rows: 24, cols: 80 }),
|
getPreferredSize: () => ({ rows: 24, cols: 80 }),
|
||||||
onOutput: ({ terminalId, text }) => {
|
onOutput: (output) => {
|
||||||
outputs.push({ terminalId, text });
|
outputs.push(output);
|
||||||
},
|
},
|
||||||
onSnapshot: ({ terminalId, state }) => {
|
onSnapshot: ({ terminalId, state }) => {
|
||||||
snapshots.push({
|
snapshots.push({
|
||||||
terminalId,
|
terminalId,
|
||||||
text: state.grid
|
text: state.grid.map((row) => row.map((cell) => cell.char).join("")).join("\n"),
|
||||||
.map((row) => row.map((cell) => cell.char).join(""))
|
|
||||||
.join("\n"),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onStatusChange: (status) => {
|
onStatusChange: (status) => {
|
||||||
statuses.push(status);
|
statuses.push(status);
|
||||||
},
|
},
|
||||||
waitForDelay: async () => {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { client, controller, outputs, snapshots, statuses };
|
||||||
client,
|
|
||||||
outputs,
|
|
||||||
snapshots,
|
|
||||||
statuses,
|
|
||||||
controller,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flushAsyncWork(): Promise<void> {
|
async function flushAsyncWork(): Promise<void> {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
setTimeout(() => resolve(), 0);
|
|
||||||
});
|
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("terminal-stream-controller", () => {
|
describe("terminal-stream-controller", () => {
|
||||||
it("subscribes to a terminal, resizes it, and forwards snapshot/output events", async () => {
|
it("subscribes, resizes, and forwards snapshot/output events", async () => {
|
||||||
const harness = createControllerHarness();
|
const harness = createHarness();
|
||||||
harness.client.nextSubscribeResponses.push({
|
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
|
||||||
terminalId: "term-1",
|
|
||||||
state: null,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
harness.controller.setTerminal({ terminalId: "term-1" });
|
harness.controller.setTerminal({ terminalId: "term-1" });
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
@@ -166,76 +125,50 @@ describe("terminal-stream-controller", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retries retryable subscribe failures and then attaches", async () => {
|
it("surfaces subscribe failures without retrying", async () => {
|
||||||
const harness = createControllerHarness();
|
const harness = createHarness();
|
||||||
harness.client.nextSubscribeResponses.push({
|
harness.client.nextSubscribeResults.push({
|
||||||
terminalId: "term-1",
|
terminalId: "term-1",
|
||||||
state: null,
|
|
||||||
error: "network disconnected",
|
error: "network disconnected",
|
||||||
});
|
});
|
||||||
harness.client.nextSubscribeResponses.push({
|
|
||||||
terminalId: "term-1",
|
|
||||||
state: null,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
harness.controller.setTerminal({ terminalId: "term-1" });
|
harness.controller.setTerminal({ terminalId: "term-1" });
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(harness.client.subscribeCalls).toEqual(["term-1", "term-1"]);
|
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
|
||||||
expect(harness.statuses.at(-1)).toEqual({
|
expect(harness.statuses.at(-1)).toEqual({
|
||||||
terminalId: "term-1",
|
terminalId: "term-1",
|
||||||
isAttaching: false,
|
isAttaching: false,
|
||||||
error: null,
|
error: "network disconnected",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reconnects to the selected terminal when the stream exits", async () => {
|
it("treats terminal exit as final and does not reconnect", async () => {
|
||||||
const harness = createControllerHarness();
|
const harness = createHarness();
|
||||||
harness.client.nextSubscribeResponses.push({
|
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
|
||||||
terminalId: "term-1",
|
|
||||||
state: null,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
harness.client.nextSubscribeResponses.push({
|
|
||||||
terminalId: "term-1",
|
|
||||||
state: null,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
harness.controller.setTerminal({ terminalId: "term-1" });
|
harness.controller.setTerminal({ terminalId: "term-1" });
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
|
harness.controller.handleTerminalExit({ terminalId: "term-1" });
|
||||||
harness.controller.handleStreamExit({ terminalId: "term-1" });
|
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
|
|
||||||
expect(harness.client.subscribeCalls).toEqual(["term-1", "term-1"]);
|
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
|
||||||
expect(harness.statuses.at(-1)).toEqual({
|
expect(harness.statuses.at(-1)).toEqual({
|
||||||
terminalId: "term-1",
|
terminalId: "term-1",
|
||||||
isAttaching: false,
|
isAttaching: false,
|
||||||
error: null,
|
error: "Terminal exited",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unsubscribes when switching terminals and on dispose", async () => {
|
it("unsubscribes when switching terminals and on dispose", async () => {
|
||||||
const harness = createControllerHarness();
|
const harness = createHarness();
|
||||||
harness.client.nextSubscribeResponses.push({
|
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
|
||||||
terminalId: "term-1",
|
harness.client.nextSubscribeResults.push({ terminalId: "term-2", error: null });
|
||||||
state: null,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
harness.client.nextSubscribeResponses.push({
|
|
||||||
terminalId: "term-2",
|
|
||||||
state: null,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
harness.controller.setTerminal({ terminalId: "term-1" });
|
harness.controller.setTerminal({ terminalId: "term-1" });
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
|
|
||||||
harness.controller.setTerminal({ terminalId: "term-2" });
|
harness.controller.setTerminal({ terminalId: "term-2" });
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
|
|
||||||
harness.controller.dispose();
|
harness.controller.dispose();
|
||||||
|
|
||||||
expect(harness.client.unsubscribeCalls).toEqual(["term-1", "term-2"]);
|
expect(harness.client.unsubscribeCalls).toEqual(["term-1", "term-2"]);
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import type { TerminalState } from "@server/shared/messages";
|
import type { TerminalState } from "@server/shared/messages";
|
||||||
import {
|
|
||||||
getTerminalAttachRetryDelayMs,
|
|
||||||
isTerminalAttachRetryableError,
|
|
||||||
waitForDuration,
|
|
||||||
withPromiseTimeout,
|
|
||||||
} from "@/utils/terminal-attach";
|
|
||||||
|
|
||||||
export type TerminalStreamControllerClient = {
|
export type TerminalStreamControllerClient = {
|
||||||
subscribeTerminal: (terminalId: string) => Promise<{
|
subscribeTerminal: (terminalId: string) => Promise<{
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
state: TerminalState | null;
|
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}>;
|
}>;
|
||||||
unsubscribeTerminal: (terminalId: string) => void;
|
unsubscribeTerminal: (terminalId: string) => void;
|
||||||
@@ -43,227 +36,116 @@ export type TerminalStreamControllerOptions = {
|
|||||||
onOutput: (input: { terminalId: string; text: string }) => void;
|
onOutput: (input: { terminalId: string; text: string }) => void;
|
||||||
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
|
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
|
||||||
onStatusChange?: (status: TerminalStreamControllerStatus) => 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 TERMINAL_EXITED_ERROR = "Terminal exited";
|
||||||
const DEFAULT_ATTACH_TIMEOUT_MS = 12_000;
|
|
||||||
const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…";
|
|
||||||
|
|
||||||
export class TerminalStreamController {
|
export class TerminalStreamController {
|
||||||
private readonly unsubscribeStreamEvents: () => void;
|
|
||||||
private readonly decoder = new TextDecoder();
|
private readonly decoder = new TextDecoder();
|
||||||
private selectedTerminalId: string | null = null;
|
private readonly unsubscribeStreamEvents: () => void;
|
||||||
private attachGeneration = 0;
|
private terminalId: string | null = null;
|
||||||
private isDisposed = false;
|
private disposed = false;
|
||||||
|
|
||||||
constructor(private readonly options: TerminalStreamControllerOptions) {
|
constructor(private readonly options: TerminalStreamControllerOptions) {
|
||||||
this.unsubscribeStreamEvents = this.options.client.onTerminalStreamEvent((event) => {
|
this.unsubscribeStreamEvents = this.options.client.onTerminalStreamEvent((event) => {
|
||||||
if (this.isDisposed || event.terminalId !== this.selectedTerminalId) {
|
if (this.disposed || event.terminalId !== this.terminalId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.type === "snapshot") {
|
if (event.type === "snapshot") {
|
||||||
this.decoder.decode();
|
this.decoder.decode();
|
||||||
this.options.onSnapshot({
|
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state });
|
||||||
terminalId: event.terminalId,
|
|
||||||
state: event.state,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = this.decoder.decode(event.data, { stream: true });
|
const text = this.decoder.decode(event.data, { stream: true });
|
||||||
if (text.length === 0) {
|
if (text.length > 0) {
|
||||||
return;
|
this.options.onOutput({ terminalId: event.terminalId, text });
|
||||||
}
|
}
|
||||||
this.options.onOutput({
|
|
||||||
terminalId: event.terminalId,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setTerminal(input: { terminalId: string | null }): void {
|
setTerminal(input: { terminalId: string | null }): void {
|
||||||
if (this.isDisposed) {
|
if (this.disposed || input.terminalId === this.terminalId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextTerminalId = input.terminalId;
|
const nextTerminalId = input.terminalId;
|
||||||
if (this.selectedTerminalId === nextTerminalId) {
|
const previousTerminalId = this.terminalId;
|
||||||
return;
|
this.terminalId = nextTerminalId;
|
||||||
}
|
|
||||||
|
|
||||||
const previousTerminalId = this.selectedTerminalId;
|
|
||||||
this.selectedTerminalId = nextTerminalId;
|
|
||||||
this.attachGeneration += 1;
|
|
||||||
const generation = this.attachGeneration;
|
|
||||||
|
|
||||||
this.decoder.decode();
|
this.decoder.decode();
|
||||||
if (previousTerminalId) {
|
if (previousTerminalId) {
|
||||||
this.options.client.unsubscribeTerminal(previousTerminalId);
|
this.options.client.unsubscribeTerminal(previousTerminalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nextTerminalId) {
|
if (!nextTerminalId) {
|
||||||
this.updateStatus({
|
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
|
||||||
terminalId: null,
|
|
||||||
isAttaching: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null });
|
||||||
this.updateStatus({
|
void this.options.client
|
||||||
terminalId: nextTerminalId,
|
.subscribeTerminal(nextTerminalId)
|
||||||
isAttaching: true,
|
.then((payload) => {
|
||||||
error: null,
|
if (this.disposed || this.terminalId !== nextTerminalId) {
|
||||||
});
|
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
lastErrorMessage = payload.error;
|
this.terminalId = null;
|
||||||
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
|
this.options.onStatusChange?.({
|
||||||
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
|
terminalId: nextTerminalId,
|
||||||
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateStatus({
|
|
||||||
terminalId: input.terminalId,
|
|
||||||
isAttaching: false,
|
isAttaching: false,
|
||||||
error: lastErrorMessage,
|
error: payload.error,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferredSize = this.options.getPreferredSize();
|
const preferredSize = this.options.getPreferredSize();
|
||||||
if (preferredSize) {
|
if (preferredSize) {
|
||||||
this.options.client.sendTerminalInput(input.terminalId, {
|
this.options.client.sendTerminalInput(nextTerminalId, {
|
||||||
type: "resize",
|
type: "resize",
|
||||||
rows: preferredSize.rows,
|
rows: preferredSize.rows,
|
||||||
cols: preferredSize.cols,
|
cols: preferredSize.cols,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
this.options.onStatusChange?.({
|
||||||
this.updateStatus({
|
terminalId: nextTerminalId,
|
||||||
terminalId: input.terminalId,
|
|
||||||
isAttaching: false,
|
isAttaching: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
return;
|
})
|
||||||
} catch (error) {
|
.catch((error: unknown) => {
|
||||||
lastErrorMessage =
|
if (this.disposed || this.terminalId !== nextTerminalId) {
|
||||||
error instanceof Error ? error.message : "Unable to subscribe to terminal";
|
return;
|
||||||
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
|
|
||||||
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
|
|
||||||
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
this.terminalId = null;
|
||||||
this.updateStatus({
|
this.options.onStatusChange?.({
|
||||||
terminalId: input.terminalId,
|
terminalId: nextTerminalId,
|
||||||
isAttaching: false,
|
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,
|
terminalId: input.terminalId,
|
||||||
isAttaching: false,
|
isAttaching: false,
|
||||||
error: lastErrorMessage,
|
error: TERMINAL_EXITED_ERROR,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private isAttachGenerationCurrent(input: { terminalId: string; generation: number }): boolean {
|
dispose(): void {
|
||||||
if (this.isDisposed) {
|
if (this.disposed) {
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
return this.attachGeneration === input.generation && this.selectedTerminalId === input.terminalId;
|
this.disposed = true;
|
||||||
}
|
this.decoder.decode();
|
||||||
|
const terminalId = this.terminalId;
|
||||||
private updateStatus(status: TerminalStreamControllerStatus): void {
|
this.terminalId = null;
|
||||||
this.options.onStatusChange?.(status);
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1257,7 +1257,6 @@ describe("DaemonClient", () => {
|
|||||||
type: "subscribe_terminal_response",
|
type: "subscribe_terminal_response",
|
||||||
payload: {
|
payload: {
|
||||||
terminalId: "term-1",
|
terminalId: "term-1",
|
||||||
state: null,
|
|
||||||
error: null,
|
error: null,
|
||||||
requestId: "sub-1",
|
requestId: "sub-1",
|
||||||
},
|
},
|
||||||
@@ -1308,7 +1307,6 @@ describe("DaemonClient", () => {
|
|||||||
type: "subscribe_terminal_response",
|
type: "subscribe_terminal_response",
|
||||||
payload: {
|
payload: {
|
||||||
terminalId: "term-1",
|
terminalId: "term-1",
|
||||||
state: null,
|
|
||||||
error: null,
|
error: null,
|
||||||
requestId: "sub-2",
|
requestId: "sub-2",
|
||||||
},
|
},
|
||||||
@@ -1356,7 +1354,6 @@ describe("DaemonClient", () => {
|
|||||||
type: "subscribe_terminal_response",
|
type: "subscribe_terminal_response",
|
||||||
payload: {
|
payload: {
|
||||||
terminalId: "term-1",
|
terminalId: "term-1",
|
||||||
state: null,
|
|
||||||
error: null,
|
error: null,
|
||||||
requestId: "sub-3",
|
requestId: "sub-3",
|
||||||
},
|
},
|
||||||
@@ -1418,7 +1415,6 @@ describe("DaemonClient", () => {
|
|||||||
type: "subscribe_terminal_response",
|
type: "subscribe_terminal_response",
|
||||||
payload: {
|
payload: {
|
||||||
terminalId: "term-1",
|
terminalId: "term-1",
|
||||||
state: null,
|
|
||||||
error: null,
|
error: null,
|
||||||
requestId: "sub-4",
|
requestId: "sub-4",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { tmpdir } from "os";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import { DaemonClient } from "../../client/daemon-client.js";
|
import { DaemonClient } from "../../client/daemon-client.js";
|
||||||
|
import {
|
||||||
|
WSOutboundMessageSchema,
|
||||||
|
type TerminalState,
|
||||||
|
type WSOutboundMessage,
|
||||||
|
} from "../../shared/messages.js";
|
||||||
import {
|
import {
|
||||||
decodeTerminalStreamFrame,
|
decodeTerminalStreamFrame,
|
||||||
TerminalStreamOpcode,
|
TerminalStreamOpcode,
|
||||||
@@ -11,6 +16,8 @@ import {
|
|||||||
} from "../../shared/terminal-stream-protocol.js";
|
} from "../../shared/terminal-stream-protocol.js";
|
||||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||||
|
|
||||||
|
type RawSessionEnvelope = Extract<WSOutboundMessage, { type: "session" }>;
|
||||||
|
|
||||||
function tmpCwd(): string {
|
function tmpCwd(): string {
|
||||||
return mkdtempSync(path.join(tmpdir(), "daemon-terminal-e2e-"));
|
return mkdtempSync(path.join(tmpdir(), "daemon-terminal-e2e-"));
|
||||||
}
|
}
|
||||||
@@ -24,10 +31,7 @@ function createLogger() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractStateText(state: {
|
function extractStateText(state: Pick<TerminalState, "grid" | "scrollback">): string {
|
||||||
grid: Array<Array<{ char: string }>>;
|
|
||||||
scrollback: Array<Array<{ char: string }>>;
|
|
||||||
}): string {
|
|
||||||
return [...state.scrollback, ...state.grid]
|
return [...state.scrollback, ...state.grid]
|
||||||
.map((row) =>
|
.map((row) =>
|
||||||
row
|
row
|
||||||
@@ -57,21 +61,9 @@ async function waitForCondition(
|
|||||||
async function waitForTerminalSnapshot(
|
async function waitForTerminalSnapshot(
|
||||||
client: DaemonClient,
|
client: DaemonClient,
|
||||||
terminalId: string,
|
terminalId: string,
|
||||||
predicate: (state: {
|
predicate: (state: TerminalState) => boolean,
|
||||||
rows: number;
|
|
||||||
cols: number;
|
|
||||||
grid: Array<Array<{ char: string }>>;
|
|
||||||
scrollback: Array<Array<{ char: string }>>;
|
|
||||||
cursor: { row: number; col: number };
|
|
||||||
}) => boolean,
|
|
||||||
timeout = 10000,
|
timeout = 10000,
|
||||||
): Promise<{
|
): Promise<TerminalState> {
|
||||||
rows: number;
|
|
||||||
cols: number;
|
|
||||||
grid: Array<Array<{ char: string }>>;
|
|
||||||
scrollback: Array<Array<{ char: string }>>;
|
|
||||||
cursor: { row: number; col: number };
|
|
||||||
}> {
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeoutHandle = setTimeout(() => {
|
const timeoutHandle = setTimeout(() => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
@@ -156,9 +148,7 @@ async function connectRawWebSocket(port: number): Promise<WebSocket> {
|
|||||||
const helloReady = waitForRawSessionMessage(
|
const helloReady = waitForRawSessionMessage(
|
||||||
ws,
|
ws,
|
||||||
(message) =>
|
(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,
|
10000,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -198,15 +188,9 @@ async function closeWebSocket(ws: WebSocket, timeout = 5000): Promise<void> {
|
|||||||
|
|
||||||
async function waitForRawSessionMessage(
|
async function waitForRawSessionMessage(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
predicate: (message: {
|
predicate: (message: RawSessionEnvelope) => boolean,
|
||||||
type?: string;
|
|
||||||
message?: { type?: string; payload?: Record<string, any> };
|
|
||||||
}) => boolean,
|
|
||||||
timeout = 10000,
|
timeout = 10000,
|
||||||
): Promise<{
|
): Promise<RawSessionEnvelope> {
|
||||||
type?: string;
|
|
||||||
message?: { type?: string; payload?: Record<string, any> };
|
|
||||||
}> {
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeoutHandle = setTimeout(() => {
|
const timeoutHandle = setTimeout(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -220,10 +204,11 @@ async function waitForRawSessionMessage(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text) as {
|
const parsedResult = WSOutboundMessageSchema.safeParse(JSON.parse(text));
|
||||||
type?: string;
|
if (!parsedResult.success || parsedResult.data.type !== "session") {
|
||||||
message?: { type?: string; payload?: Record<string, any> };
|
return;
|
||||||
};
|
}
|
||||||
|
const parsed = parsedResult.data;
|
||||||
if (!predicate(parsed)) {
|
if (!predicate(parsed)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -294,9 +279,8 @@ async function subscribeRawTerminal(ws: WebSocket, terminalId: string, requestId
|
|||||||
const ready = waitForRawSessionMessage(
|
const ready = waitForRawSessionMessage(
|
||||||
ws,
|
ws,
|
||||||
(message) =>
|
(message) =>
|
||||||
message.type === "session" &&
|
message.message.type === "subscribe_terminal_response" &&
|
||||||
message.message?.type === "subscribe_terminal_response" &&
|
message.message.payload.requestId === requestId,
|
||||||
message.message.payload?.requestId === requestId,
|
|
||||||
10000,
|
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", () => {
|
describe("daemon E2E terminal", () => {
|
||||||
|
|||||||
@@ -279,8 +279,7 @@ type ActiveTerminalStream = {
|
|||||||
terminalId: string;
|
terminalId: string;
|
||||||
unsubscribe: () => void;
|
unsubscribe: () => void;
|
||||||
needsSnapshot: boolean;
|
needsSnapshot: boolean;
|
||||||
primed: boolean;
|
snapshotRetryTimer: ReturnType<typeof setTimeout> | null;
|
||||||
snapshotTimer: ReturnType<typeof setTimeout> | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionRuntimeMetrics = {
|
export type SessionRuntimeMetrics = {
|
||||||
@@ -1817,8 +1816,8 @@ export class Session {
|
|||||||
if (!resize) {
|
if (!resize) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
activeStream.needsSnapshot = true;
|
||||||
terminal.send({ type: "resize", rows: resize.rows, cols: resize.cols });
|
terminal.send({ type: "resize", rows: resize.rows, cols: resize.cols });
|
||||||
this.queueTerminalSnapshot(activeStream, terminal);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7666,7 +7665,6 @@ export class Session {
|
|||||||
type: "subscribe_terminal_response",
|
type: "subscribe_terminal_response",
|
||||||
payload: {
|
payload: {
|
||||||
terminalId: msg.terminalId,
|
terminalId: msg.terminalId,
|
||||||
state: null,
|
|
||||||
error: "Terminal manager not available",
|
error: "Terminal manager not available",
|
||||||
requestId: msg.requestId,
|
requestId: msg.requestId,
|
||||||
},
|
},
|
||||||
@@ -7680,7 +7678,6 @@ export class Session {
|
|||||||
type: "subscribe_terminal_response",
|
type: "subscribe_terminal_response",
|
||||||
payload: {
|
payload: {
|
||||||
terminalId: msg.terminalId,
|
terminalId: msg.terminalId,
|
||||||
state: null,
|
|
||||||
error: "Terminal not found",
|
error: "Terminal not found",
|
||||||
requestId: msg.requestId,
|
requestId: msg.requestId,
|
||||||
},
|
},
|
||||||
@@ -7689,23 +7686,16 @@ export class Session {
|
|||||||
}
|
}
|
||||||
this.ensureTerminalExitSubscription(session);
|
this.ensureTerminalExitSubscription(session);
|
||||||
|
|
||||||
const activeStream = this.bindActiveTerminalStream(session);
|
|
||||||
|
|
||||||
// Send initial state
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "subscribe_terminal_response",
|
type: "subscribe_terminal_response",
|
||||||
payload: {
|
payload: {
|
||||||
terminalId: msg.terminalId,
|
terminalId: msg.terminalId,
|
||||||
state: session.getState(),
|
|
||||||
error: null,
|
error: null,
|
||||||
requestId: msg.requestId,
|
requestId: msg.requestId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (activeStream) {
|
this.bindActiveTerminalStream(session);
|
||||||
this.sendTerminalSnapshot(activeStream, session);
|
|
||||||
activeStream.primed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void {
|
private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void {
|
||||||
@@ -7803,126 +7793,77 @@ export class Session {
|
|||||||
const activeStream: ActiveTerminalStream = {
|
const activeStream: ActiveTerminalStream = {
|
||||||
terminalId: terminal.id,
|
terminalId: terminal.id,
|
||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
needsSnapshot: false,
|
needsSnapshot: true,
|
||||||
primed: false,
|
snapshotRetryTimer: null,
|
||||||
snapshotTimer: 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) => {
|
activeStream.unsubscribe = terminal.subscribe((message) => {
|
||||||
if (this.activeTerminalStream !== activeStream) {
|
if (this.activeTerminalStream !== activeStream) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type === "output") {
|
if (message.type === "snapshot") {
|
||||||
this.handleActiveTerminalOutput(activeStream, terminal, message.data);
|
trySendSnapshot();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type === "snapshot") {
|
if (activeStream.needsSnapshot || message.data.length === 0) {
|
||||||
this.maybeSendQueuedTerminalSnapshot(activeStream, terminal);
|
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;
|
this.activeTerminalStream = activeStream;
|
||||||
return 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 {
|
private detachActiveTerminalStream(options?: { emitExit: boolean }): boolean {
|
||||||
const activeStream = this.activeTerminalStream;
|
const activeStream = this.activeTerminalStream;
|
||||||
if (!activeStream) {
|
if (!activeStream) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.activeTerminalStream = null;
|
this.activeTerminalStream = null;
|
||||||
this.clearTerminalSnapshotRetry(activeStream);
|
if (activeStream.snapshotRetryTimer) {
|
||||||
|
clearTimeout(activeStream.snapshotRetryTimer);
|
||||||
|
activeStream.snapshotRetryTimer = null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
activeStream.unsubscribe();
|
activeStream.unsubscribe();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2104,15 +2104,15 @@ export const TerminalCellSchema = z.object({
|
|||||||
bold: z.boolean().optional(),
|
bold: z.boolean().optional(),
|
||||||
italic: z.boolean().optional(),
|
italic: z.boolean().optional(),
|
||||||
underline: z.boolean().optional(),
|
underline: z.boolean().optional(),
|
||||||
});
|
}).strict();
|
||||||
|
|
||||||
export const TerminalStateSchema = z.object({
|
export const TerminalStateSchema = z.object({
|
||||||
rows: z.number(),
|
rows: z.number(),
|
||||||
cols: z.number(),
|
cols: z.number(),
|
||||||
grid: z.array(z.array(TerminalCellSchema)),
|
grid: z.array(z.array(TerminalCellSchema)),
|
||||||
scrollback: 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({
|
export const ListTerminalsResponseSchema = z.object({
|
||||||
type: z.literal("list_terminals_response"),
|
type: z.literal("list_terminals_response"),
|
||||||
@@ -2144,7 +2144,6 @@ export const SubscribeTerminalResponseSchema = z.object({
|
|||||||
type: z.literal("subscribe_terminal_response"),
|
type: z.literal("subscribe_terminal_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
terminalId: z.string(),
|
terminalId: z.string(),
|
||||||
state: TerminalStateSchema.nullable(),
|
|
||||||
error: z.string().nullable(),
|
error: z.string().nullable(),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -56,4 +56,50 @@ describe("terminal stream protocol", () => {
|
|||||||
it("rejects unknown opcodes", () => {
|
it("rejects unknown opcodes", () => {
|
||||||
expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01]))).toBeNull();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { TerminalStateSchema } from "./messages.js";
|
|||||||
export const TerminalStreamResizeSchema = z.object({
|
export const TerminalStreamResizeSchema = z.object({
|
||||||
rows: z.number().int().positive(),
|
rows: z.number().int().positive(),
|
||||||
cols: z.number().int().positive(),
|
cols: z.number().int().positive(),
|
||||||
});
|
}).strict();
|
||||||
|
|
||||||
export const TerminalStreamOpcode = {
|
export const TerminalStreamOpcode = {
|
||||||
Output: 0x01,
|
Output: 0x01,
|
||||||
@@ -115,6 +115,10 @@ function encodeJsonPayload(value: unknown): Uint8Array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function decodeJsonPayload(bytes: Uint8Array): unknown {
|
function decodeJsonPayload(bytes: Uint8Array): unknown {
|
||||||
const text = new TextDecoder().decode(bytes);
|
try {
|
||||||
return JSON.parse(text);
|
const text = new TextDecoder().decode(bytes);
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -438,6 +438,31 @@ describe("Terminal", () => {
|
|||||||
unsubscribe();
|
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 () => {
|
it("unsubscribe stops receiving messages", async () => {
|
||||||
const session = trackSession(
|
const session = trackSession(
|
||||||
await createTerminal({
|
await createTerminal({
|
||||||
|
|||||||
@@ -322,10 +322,13 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
// Pipe PTY output to terminal emulator
|
// Pipe PTY output to terminal emulator
|
||||||
ptyProcess.onData((data) => {
|
ptyProcess.onData((data) => {
|
||||||
if (killed) return;
|
if (killed) return;
|
||||||
for (const listener of listeners) {
|
|
||||||
listener({ type: "output", data });
|
|
||||||
}
|
|
||||||
terminal.write(data, () => {
|
terminal.write(data, () => {
|
||||||
|
if (disposed || killed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener({ type: "output", data });
|
||||||
|
}
|
||||||
scheduleStateBroadcast();
|
scheduleStateBroadcast();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -359,6 +362,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
case "resize":
|
case "resize":
|
||||||
terminal.resize(msg.cols, msg.rows);
|
terminal.resize(msg.cols, msg.rows);
|
||||||
ptyProcess.resize(msg.cols, msg.rows);
|
ptyProcess.resize(msg.cols, msg.rows);
|
||||||
|
scheduleStateBroadcast();
|
||||||
break;
|
break;
|
||||||
case "mouse":
|
case "mouse":
|
||||||
// Mouse events can be sent as escape sequences if terminal supports it
|
// 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 {
|
function subscribe(listener: (msg: ServerMessage) => void): () => void {
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
|
|
||||||
queueMicrotask(() => {
|
terminal.write("", () => {
|
||||||
if (listeners.has(listener)) {
|
if (!disposed && listeners.has(listener)) {
|
||||||
listener({ type: "snapshot", state: getState() });
|
listener({ type: "snapshot", state: getState() });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user