Harden terminal streaming and worktree setup flow
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||
|
||||
type StubTerminal = {
|
||||
write: (text: string, callback?: () => void) => void;
|
||||
reset: () => void;
|
||||
focus: () => void;
|
||||
};
|
||||
|
||||
function createRuntimeWithTerminal(): {
|
||||
runtime: TerminalEmulatorRuntime;
|
||||
terminal: StubTerminal & {
|
||||
resetCalls: number;
|
||||
};
|
||||
writeCallbacks: Array<() => void>;
|
||||
writeTexts: string[];
|
||||
} {
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
const writeCallbacks: Array<() => void> = [];
|
||||
const writeTexts: string[] = [];
|
||||
let resetCalls = 0;
|
||||
|
||||
const terminal: StubTerminal & { resetCalls: number } = {
|
||||
write: (text: string, callback?: () => void) => {
|
||||
writeTexts.push(text);
|
||||
if (callback) {
|
||||
writeCallbacks.push(callback);
|
||||
}
|
||||
},
|
||||
reset: () => {
|
||||
resetCalls += 1;
|
||||
terminal.resetCalls = resetCalls;
|
||||
},
|
||||
focus: () => {},
|
||||
resetCalls,
|
||||
};
|
||||
|
||||
(runtime as unknown as { terminal: StubTerminal }).terminal = terminal;
|
||||
|
||||
return {
|
||||
runtime,
|
||||
terminal,
|
||||
writeCallbacks,
|
||||
writeTexts,
|
||||
};
|
||||
}
|
||||
|
||||
describe("terminal-emulator-runtime", () => {
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { window?: { __paseoTerminal?: unknown } }).window = {
|
||||
__paseoTerminal: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("processes write and clear operations in strict order", () => {
|
||||
const { runtime, terminal, writeCallbacks, writeTexts } = createRuntimeWithTerminal();
|
||||
const committed: string[] = [];
|
||||
|
||||
runtime.write({
|
||||
text: "first",
|
||||
onCommitted: () => {
|
||||
committed.push("first");
|
||||
},
|
||||
});
|
||||
runtime.clear({
|
||||
onCommitted: () => {
|
||||
committed.push("clear");
|
||||
},
|
||||
});
|
||||
runtime.write({
|
||||
text: "second",
|
||||
onCommitted: () => {
|
||||
committed.push("second");
|
||||
},
|
||||
});
|
||||
|
||||
expect(writeTexts).toEqual(["first"]);
|
||||
expect(terminal.resetCalls).toBe(0);
|
||||
expect(committed).toEqual([]);
|
||||
|
||||
writeCallbacks[0]?.();
|
||||
|
||||
expect(committed).toEqual(["first", "clear"]);
|
||||
expect(terminal.resetCalls).toBe(1);
|
||||
expect(writeTexts).toEqual(["first", "second"]);
|
||||
|
||||
writeCallbacks[1]?.();
|
||||
expect(committed).toEqual(["first", "clear", "second"]);
|
||||
});
|
||||
|
||||
it("falls back to timeout commit when xterm write callback does not fire", () => {
|
||||
vi.useFakeTimers();
|
||||
const { runtime } = createRuntimeWithTerminal();
|
||||
const onCommitted = vi.fn();
|
||||
|
||||
runtime.write({
|
||||
text: "stuck",
|
||||
onCommitted,
|
||||
});
|
||||
|
||||
expect(onCommitted).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(onCommitted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores stale duplicate write callbacks from a previous operation", () => {
|
||||
const { runtime, writeCallbacks } = createRuntimeWithTerminal();
|
||||
const committed: string[] = [];
|
||||
|
||||
runtime.write({
|
||||
text: "first",
|
||||
onCommitted: () => {
|
||||
committed.push("first");
|
||||
},
|
||||
});
|
||||
runtime.write({
|
||||
text: "second",
|
||||
onCommitted: () => {
|
||||
committed.push("second");
|
||||
},
|
||||
});
|
||||
|
||||
writeCallbacks[0]?.();
|
||||
expect(committed).toEqual(["first"]);
|
||||
|
||||
writeCallbacks[0]?.();
|
||||
expect(committed).toEqual(["first"]);
|
||||
|
||||
writeCallbacks[1]?.();
|
||||
expect(committed).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("commits pending output operations during unmount to avoid deadlock", () => {
|
||||
const { runtime } = createRuntimeWithTerminal();
|
||||
const onCommittedA = vi.fn();
|
||||
const onCommittedB = vi.fn();
|
||||
|
||||
runtime.write({
|
||||
text: "a",
|
||||
onCommitted: onCommittedA,
|
||||
});
|
||||
runtime.write({
|
||||
text: "b",
|
||||
onCommitted: onCommittedB,
|
||||
});
|
||||
|
||||
runtime.unmount();
|
||||
|
||||
expect(onCommittedA).toHaveBeenCalledTimes(1);
|
||||
expect(onCommittedB).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,12 @@ type TerminalEmulatorRuntimeDisposables = {
|
||||
disposeTerminal: () => void;
|
||||
};
|
||||
|
||||
type TerminalOutputOperation = {
|
||||
type: "write" | "clear";
|
||||
text: string;
|
||||
onCommitted?: () => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__paseoTerminal?: Terminal;
|
||||
@@ -59,6 +65,7 @@ declare global {
|
||||
|
||||
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
||||
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||
|
||||
export class TerminalEmulatorRuntime {
|
||||
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
|
||||
@@ -71,8 +78,9 @@ export class TerminalEmulatorRuntime {
|
||||
private fitAddon: FitAddon | null = null;
|
||||
private lastSize: { rows: number; cols: number } | null = null;
|
||||
private cleanup: (() => void) | null = null;
|
||||
private pendingWriteText = "";
|
||||
private isWriteFlushQueued = false;
|
||||
private outputOperations: TerminalOutputOperation[] = [];
|
||||
private inFlightOutputOperation: TerminalOutputOperation | null = null;
|
||||
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void {
|
||||
this.callbacks = input.callbacks;
|
||||
@@ -287,6 +295,8 @@ export class TerminalEmulatorRuntime {
|
||||
});
|
||||
}
|
||||
|
||||
this.processOutputQueue();
|
||||
|
||||
const disposables: TerminalEmulatorRuntimeDisposables = {
|
||||
disposeInput: () => {
|
||||
inputDisposable.dispose();
|
||||
@@ -338,30 +348,39 @@ export class TerminalEmulatorRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
write(input: { text: string }): void {
|
||||
if (!this.terminal || input.text.length === 0) {
|
||||
write(input: { text: string; onCommitted?: () => void }): void {
|
||||
if (input.text.length === 0) {
|
||||
input.onCommitted?.();
|
||||
return;
|
||||
}
|
||||
this.pendingWriteText += input.text;
|
||||
this.outputOperations.push({
|
||||
type: "write",
|
||||
text: input.text,
|
||||
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
||||
});
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "output:enqueue",
|
||||
details: {
|
||||
chunkLength: input.text.length,
|
||||
queueLength: this.pendingWriteText.length,
|
||||
queueLength: this.outputOperations.length,
|
||||
preview: summarizeTerminalText({ text: input.text, maxChars: 64 }),
|
||||
},
|
||||
});
|
||||
this.scheduleWriteFlush();
|
||||
this.processOutputQueue();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pendingWriteText = "";
|
||||
clear(input?: { onCommitted?: () => void }): void {
|
||||
this.outputOperations.push({
|
||||
type: "clear",
|
||||
text: "",
|
||||
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
||||
});
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "output:clear",
|
||||
});
|
||||
this.terminal?.reset();
|
||||
this.processOutputQueue();
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
@@ -373,11 +392,19 @@ export class TerminalEmulatorRuntime {
|
||||
scope: "emulator-runtime",
|
||||
event: "mount:unmount",
|
||||
details: {
|
||||
pendingWriteLength: this.pendingWriteText.length,
|
||||
pendingWriteOperations: this.outputOperations.length,
|
||||
},
|
||||
});
|
||||
this.pendingWriteText = "";
|
||||
this.isWriteFlushQueued = false;
|
||||
this.clearInFlightOutputTimeout();
|
||||
const inFlightOperation = this.inFlightOutputOperation;
|
||||
this.inFlightOutputOperation = null;
|
||||
if (inFlightOperation?.onCommitted) {
|
||||
inFlightOperation.onCommitted();
|
||||
}
|
||||
const pendingOperations = this.outputOperations.splice(0, this.outputOperations.length);
|
||||
for (const operation of pendingOperations) {
|
||||
operation.onCommitted?.();
|
||||
}
|
||||
|
||||
this.cleanup?.();
|
||||
this.cleanup = null;
|
||||
@@ -389,24 +416,39 @@ export class TerminalEmulatorRuntime {
|
||||
this.lastSize = null;
|
||||
}
|
||||
|
||||
private scheduleWriteFlush(): void {
|
||||
if (this.isWriteFlushQueued) {
|
||||
private processOutputQueue(): void {
|
||||
if (this.inFlightOutputOperation) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isWriteFlushQueued = true;
|
||||
queueMicrotask(() => {
|
||||
this.isWriteFlushQueued = false;
|
||||
this.flushWriteQueue();
|
||||
});
|
||||
}
|
||||
|
||||
private flushWriteQueue(): void {
|
||||
if (!this.terminal || this.pendingWriteText.length === 0) {
|
||||
const terminal = this.terminal;
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
const text = this.pendingWriteText;
|
||||
this.pendingWriteText = "";
|
||||
|
||||
const operation = this.outputOperations.shift();
|
||||
if (!operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inFlightOutputOperation = operation;
|
||||
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
|
||||
if (this.inFlightOutputOperation !== expectedOperation) {
|
||||
return;
|
||||
}
|
||||
this.inFlightOutputOperation = null;
|
||||
this.clearInFlightOutputTimeout();
|
||||
expectedOperation.onCommitted?.();
|
||||
this.processOutputQueue();
|
||||
};
|
||||
|
||||
if (operation.type === "clear") {
|
||||
terminal.reset();
|
||||
finalizeOperation(operation);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = operation.text;
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "output:flush",
|
||||
@@ -415,7 +457,33 @@ export class TerminalEmulatorRuntime {
|
||||
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
||||
},
|
||||
});
|
||||
this.terminal.write(text);
|
||||
this.inFlightOutputOperationTimeout = setTimeout(() => {
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "output:flush-timeout",
|
||||
details: {
|
||||
length: text.length,
|
||||
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
||||
},
|
||||
});
|
||||
finalizeOperation(operation);
|
||||
}, OUTPUT_OPERATION_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
terminal.write(text, () => {
|
||||
finalizeOperation(operation);
|
||||
});
|
||||
} catch {
|
||||
finalizeOperation(operation);
|
||||
}
|
||||
}
|
||||
|
||||
private clearInFlightOutputTimeout(): void {
|
||||
if (!this.inFlightOutputOperationTimeout) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.inFlightOutputOperationTimeout);
|
||||
this.inFlightOutputOperationTimeout = null;
|
||||
}
|
||||
|
||||
private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TerminalOutputDeliveryQueue } from "./terminal-output-delivery-queue";
|
||||
|
||||
describe("terminal-output-delivery-queue", () => {
|
||||
it("retries in-flight delivery when consume is missing", () => {
|
||||
vi.useFakeTimers();
|
||||
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||
const queue = new TerminalOutputDeliveryQueue({
|
||||
onDeliver: (chunk) => {
|
||||
delivered.push(chunk);
|
||||
},
|
||||
deliveryTimeoutMs: 100,
|
||||
});
|
||||
|
||||
queue.enqueue({ sequence: 1, text: "a" });
|
||||
queue.enqueue({ sequence: 2, text: "b" });
|
||||
|
||||
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(delivered).toEqual([
|
||||
{ sequence: 1, text: "a" },
|
||||
{ sequence: 1, text: "a" },
|
||||
]);
|
||||
|
||||
queue.consume({ sequence: 1 });
|
||||
expect(delivered).toEqual([
|
||||
{ sequence: 1, text: "a" },
|
||||
{ sequence: 1, text: "a" },
|
||||
{ sequence: 2, text: "b" },
|
||||
]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("delivers first chunk immediately and blocks later chunks until consumed", () => {
|
||||
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||
const queue = new TerminalOutputDeliveryQueue({
|
||||
onDeliver: (chunk) => {
|
||||
delivered.push(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
queue.enqueue({ sequence: 1, text: "a" });
|
||||
queue.enqueue({ sequence: 2, text: "b" });
|
||||
queue.enqueue({ sequence: 3, text: "c" });
|
||||
|
||||
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
|
||||
|
||||
queue.consume({ sequence: 1 });
|
||||
expect(delivered).toEqual([
|
||||
{ sequence: 1, text: "a" },
|
||||
{ sequence: 3, text: "bc" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores stale consume acknowledgements", () => {
|
||||
const delivered = vi.fn();
|
||||
const queue = new TerminalOutputDeliveryQueue({ onDeliver: delivered });
|
||||
|
||||
queue.enqueue({ sequence: 1, text: "x" });
|
||||
queue.consume({ sequence: 99 });
|
||||
queue.enqueue({ sequence: 2, text: "y" });
|
||||
|
||||
expect(delivered).toHaveBeenCalledTimes(1);
|
||||
expect(delivered).toHaveBeenNthCalledWith(1, { sequence: 1, text: "x" });
|
||||
|
||||
queue.consume({ sequence: 1 });
|
||||
|
||||
expect(delivered).toHaveBeenCalledTimes(2);
|
||||
expect(delivered).toHaveBeenNthCalledWith(2, { sequence: 2, text: "y" });
|
||||
});
|
||||
|
||||
it("resets in-flight and pending chunks", () => {
|
||||
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||
const queue = new TerminalOutputDeliveryQueue({
|
||||
onDeliver: (chunk) => {
|
||||
delivered.push(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
queue.enqueue({ sequence: 1, text: "hello" });
|
||||
queue.enqueue({ sequence: 2, text: " world" });
|
||||
|
||||
queue.reset();
|
||||
queue.enqueue({ sequence: 3, text: "next" });
|
||||
|
||||
expect(delivered).toEqual([
|
||||
{ sequence: 1, text: "hello" },
|
||||
{ sequence: 3, text: "next" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves empty chunk payloads for authoritative clears", () => {
|
||||
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||
const queue = new TerminalOutputDeliveryQueue({
|
||||
onDeliver: (chunk) => {
|
||||
delivered.push(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
queue.enqueue({ sequence: 1, text: "abc" });
|
||||
queue.consume({ sequence: 1 });
|
||||
queue.enqueue({ sequence: 2, text: "" });
|
||||
|
||||
expect(delivered).toEqual([
|
||||
{ sequence: 1, text: "abc" },
|
||||
{ sequence: 2, text: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats clear chunks as a delivery barrier and drops pending stale text", () => {
|
||||
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||
const queue = new TerminalOutputDeliveryQueue({
|
||||
onDeliver: (chunk) => {
|
||||
delivered.push(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
queue.enqueue({ sequence: 1, text: "a" });
|
||||
queue.enqueue({ sequence: 2, text: "b" });
|
||||
queue.enqueue({ sequence: 3, text: "" });
|
||||
queue.enqueue({ sequence: 4, text: "c" });
|
||||
|
||||
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
|
||||
|
||||
queue.consume({ sequence: 1 });
|
||||
expect(delivered).toEqual([
|
||||
{ sequence: 1, text: "a" },
|
||||
{ sequence: 3, text: "" },
|
||||
]);
|
||||
|
||||
queue.consume({ sequence: 3 });
|
||||
expect(delivered).toEqual([
|
||||
{ sequence: 1, text: "a" },
|
||||
{ sequence: 3, text: "" },
|
||||
{ sequence: 4, text: "c" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
||||
|
||||
export type TerminalOutputDeliveryChunk = {
|
||||
sequence: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type TerminalOutputDeliveryQueueOptions = {
|
||||
onDeliver: (chunk: TerminalOutputDeliveryChunk) => void;
|
||||
deliveryTimeoutMs?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_DELIVERY_TIMEOUT_MS = 8_000;
|
||||
|
||||
export class TerminalOutputDeliveryQueue {
|
||||
private readonly pendingChunks: TerminalOutputDeliveryChunk[] = [];
|
||||
private inFlightChunk: TerminalOutputDeliveryChunk | null = null;
|
||||
private inFlightTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastSeenSequence = 0;
|
||||
private readonly deliveryTimeoutMs: number;
|
||||
|
||||
constructor(private readonly options: TerminalOutputDeliveryQueueOptions) {
|
||||
this.deliveryTimeoutMs =
|
||||
options.deliveryTimeoutMs ?? DEFAULT_DELIVERY_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
enqueue(chunk: TerminalOutputDeliveryChunk): void {
|
||||
if (chunk.sequence <= 0) {
|
||||
return;
|
||||
}
|
||||
if (chunk.sequence <= this.lastSeenSequence) {
|
||||
return;
|
||||
}
|
||||
this.lastSeenSequence = chunk.sequence;
|
||||
|
||||
if (chunk.text.length === 0) {
|
||||
this.pendingChunks.length = 0;
|
||||
this.pendingChunks.push(chunk);
|
||||
terminalDebugLog({
|
||||
scope: "output-delivery-queue",
|
||||
event: "enqueue:clear",
|
||||
details: {
|
||||
sequence: chunk.sequence,
|
||||
},
|
||||
});
|
||||
this.tryDeliver();
|
||||
return;
|
||||
}
|
||||
|
||||
const lastPendingChunk = this.pendingChunks[this.pendingChunks.length - 1];
|
||||
if (lastPendingChunk && lastPendingChunk.text.length > 0) {
|
||||
lastPendingChunk.sequence = chunk.sequence;
|
||||
lastPendingChunk.text += chunk.text;
|
||||
} else {
|
||||
this.pendingChunks.push(chunk);
|
||||
}
|
||||
terminalDebugLog({
|
||||
scope: "output-delivery-queue",
|
||||
event: "enqueue:text",
|
||||
details: {
|
||||
sequence: chunk.sequence,
|
||||
pendingCount: this.pendingChunks.length,
|
||||
textLength: chunk.text.length,
|
||||
preview: summarizeTerminalText({ text: chunk.text, maxChars: 80 }),
|
||||
},
|
||||
});
|
||||
this.tryDeliver();
|
||||
}
|
||||
|
||||
consume(input: { sequence: number }): void {
|
||||
if (this.inFlightChunk?.sequence !== input.sequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
terminalDebugLog({
|
||||
scope: "output-delivery-queue",
|
||||
event: "consume",
|
||||
details: {
|
||||
sequence: input.sequence,
|
||||
},
|
||||
});
|
||||
this.clearInFlightTimeout();
|
||||
this.inFlightChunk = null;
|
||||
this.tryDeliver();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.clearInFlightTimeout();
|
||||
this.pendingChunks.length = 0;
|
||||
this.inFlightChunk = null;
|
||||
this.lastSeenSequence = 0;
|
||||
}
|
||||
|
||||
private tryDeliver(): void {
|
||||
if (this.inFlightChunk) {
|
||||
return;
|
||||
}
|
||||
const nextChunk = this.pendingChunks.shift();
|
||||
if (!nextChunk) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inFlightChunk = nextChunk;
|
||||
terminalDebugLog({
|
||||
scope: "output-delivery-queue",
|
||||
event: "deliver:start",
|
||||
details: {
|
||||
sequence: nextChunk.sequence,
|
||||
pendingCount: this.pendingChunks.length,
|
||||
},
|
||||
});
|
||||
this.deliverInFlightChunk();
|
||||
}
|
||||
|
||||
private deliverInFlightChunk(): void {
|
||||
const chunk = this.inFlightChunk;
|
||||
if (!chunk) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearInFlightTimeout();
|
||||
this.inFlightTimeout = setTimeout(() => {
|
||||
if (!this.inFlightChunk) {
|
||||
return;
|
||||
}
|
||||
terminalDebugLog({
|
||||
scope: "output-delivery-queue",
|
||||
event: "deliver:timeout-retry",
|
||||
details: {
|
||||
sequence: this.inFlightChunk.sequence,
|
||||
pendingCount: this.pendingChunks.length,
|
||||
timeoutMs: this.deliveryTimeoutMs,
|
||||
},
|
||||
});
|
||||
this.deliverInFlightChunk();
|
||||
}, this.deliveryTimeoutMs);
|
||||
|
||||
this.options.onDeliver({
|
||||
sequence: chunk.sequence,
|
||||
text: chunk.text,
|
||||
});
|
||||
}
|
||||
|
||||
private clearInFlightTimeout(): void {
|
||||
if (!this.inFlightTimeout) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.inFlightTimeout);
|
||||
this.inFlightTimeout = null;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type FakeStreamSubscriber = (chunk: TerminalStreamControllerChunk) => void;
|
||||
|
||||
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
||||
private readonly streamSubscribers = new Map<number, Set<FakeStreamSubscriber>>();
|
||||
private readonly pendingChunksByStreamId = new Map<number, TerminalStreamControllerChunk[]>();
|
||||
public attachCalls: Array<{
|
||||
terminalId: string;
|
||||
options?: {
|
||||
@@ -47,6 +48,14 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
||||
streamId: number,
|
||||
handler: (chunk: TerminalStreamControllerChunk) => void
|
||||
): () => void {
|
||||
const pendingChunks = this.pendingChunksByStreamId.get(streamId);
|
||||
if (pendingChunks && pendingChunks.length > 0) {
|
||||
for (const chunk of pendingChunks) {
|
||||
handler(chunk);
|
||||
}
|
||||
this.pendingChunksByStreamId.delete(streamId);
|
||||
}
|
||||
|
||||
const subscribers = this.streamSubscribers.get(streamId) ?? new Set();
|
||||
subscribers.add(handler);
|
||||
this.streamSubscribers.set(streamId, subscribers);
|
||||
@@ -61,21 +70,44 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
||||
|
||||
emitChunk(input: {
|
||||
streamId: number;
|
||||
offset?: number;
|
||||
endOffset: number;
|
||||
replay?: boolean;
|
||||
data: string;
|
||||
}): void {
|
||||
const subscribers = this.streamSubscribers.get(input.streamId);
|
||||
if (!subscribers || subscribers.size === 0) {
|
||||
return;
|
||||
}
|
||||
const bytes = new TextEncoder().encode(input.data);
|
||||
const chunk: TerminalStreamControllerChunk = {
|
||||
offset: input.offset ?? input.endOffset - bytes.byteLength,
|
||||
endOffset: input.endOffset,
|
||||
data: new TextEncoder().encode(input.data),
|
||||
replay: input.replay,
|
||||
data: bytes,
|
||||
};
|
||||
for (const subscriber of subscribers) {
|
||||
subscriber(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
bufferChunk(input: {
|
||||
streamId: number;
|
||||
offset?: number;
|
||||
endOffset: number;
|
||||
replay?: boolean;
|
||||
data: string;
|
||||
}): void {
|
||||
const chunks = this.pendingChunksByStreamId.get(input.streamId) ?? [];
|
||||
const bytes = new TextEncoder().encode(input.data);
|
||||
chunks.push({
|
||||
offset: input.offset ?? input.endOffset - bytes.byteLength,
|
||||
endOffset: input.endOffset,
|
||||
replay: input.replay,
|
||||
data: bytes,
|
||||
});
|
||||
this.pendingChunksByStreamId.set(input.streamId, chunks);
|
||||
}
|
||||
}
|
||||
|
||||
function createControllerHarness(input?: {
|
||||
@@ -251,4 +283,203 @@ describe("terminal-stream-controller", () => {
|
||||
expect(harness.resets).toEqual(["term-reset"]);
|
||||
expect(harness.controller.getActiveStreamId()).toBe(12);
|
||||
});
|
||||
|
||||
it("delivers buffered replay chunks flushed synchronously during subscribe", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 17,
|
||||
replayedFrom: 0,
|
||||
currentOffset: 10,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
harness.client.bufferChunk({
|
||||
streamId: 17,
|
||||
offset: 0,
|
||||
endOffset: 10,
|
||||
replay: true,
|
||||
data: "buffered-replay",
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-buffered" });
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.chunks).toEqual([
|
||||
{ terminalId: "term-buffered", text: "buffered-replay" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears stale selected output when bootstrap replay starts before current offset", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 61,
|
||||
replayedFrom: 0,
|
||||
currentOffset: 20,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-bootstrap-reset" });
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.resets).toEqual(["term-bootstrap-reset"]);
|
||||
});
|
||||
|
||||
it("reattaches and replays from last contiguous offset when chunk offsets gap", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 31,
|
||||
currentOffset: 0,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 32,
|
||||
currentOffset: 8,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-gap" });
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.client.emitChunk({
|
||||
streamId: 31,
|
||||
offset: 0,
|
||||
endOffset: 2,
|
||||
data: "ok",
|
||||
});
|
||||
harness.client.emitChunk({
|
||||
streamId: 31,
|
||||
offset: 4,
|
||||
endOffset: 8,
|
||||
data: "miss",
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.chunks).toEqual([{ terminalId: "term-gap", text: "ok" }]);
|
||||
expect(harness.client.detachCalls).toContain(31);
|
||||
expect(harness.client.attachCalls.length).toBe(2);
|
||||
expect(harness.client.attachCalls[1]?.options).toEqual({
|
||||
resumeOffset: 2,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
expect(harness.controller.getActiveStreamId()).toBe(32);
|
||||
});
|
||||
|
||||
it("does not treat replay range before currentOffset as a live gap", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 41,
|
||||
replayedFrom: 50,
|
||||
currentOffset: 100,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
harness.client.bufferChunk({
|
||||
streamId: 41,
|
||||
offset: 80,
|
||||
endOffset: 100,
|
||||
data: "replay-tail",
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-replay" });
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.client.emitChunk({
|
||||
streamId: 41,
|
||||
offset: 100,
|
||||
endOffset: 102,
|
||||
data: "ok",
|
||||
});
|
||||
|
||||
expect(harness.client.detachCalls).toEqual([]);
|
||||
expect(harness.client.attachCalls).toHaveLength(1);
|
||||
expect(harness.controller.getActiveStreamId()).toBe(41);
|
||||
expect(harness.chunks).toEqual([
|
||||
{ terminalId: "term-replay", text: "replay-tail" },
|
||||
{ terminalId: "term-replay", text: "ok" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts clamped replay start after reconnect without entering a reconnect loop", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 71,
|
||||
replayedFrom: 205,
|
||||
currentOffset: 205,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 72,
|
||||
replayedFrom: 396,
|
||||
currentOffset: 978,
|
||||
reset: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-clamped-replay" });
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.client.emitChunk({
|
||||
streamId: 71,
|
||||
offset: 205,
|
||||
endOffset: 299,
|
||||
data: "first",
|
||||
});
|
||||
harness.client.emitChunk({
|
||||
streamId: 71,
|
||||
offset: 396,
|
||||
endOffset: 493,
|
||||
data: "gap",
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.client.attachCalls).toHaveLength(2);
|
||||
expect(harness.client.attachCalls[1]?.options).toEqual({
|
||||
resumeOffset: 299,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
expect(harness.controller.getActiveStreamId()).toBe(72);
|
||||
|
||||
harness.client.emitChunk({
|
||||
streamId: 72,
|
||||
replay: true,
|
||||
offset: 396,
|
||||
endOffset: 493,
|
||||
data: "replay-1",
|
||||
});
|
||||
harness.client.emitChunk({
|
||||
streamId: 72,
|
||||
replay: true,
|
||||
offset: 493,
|
||||
endOffset: 590,
|
||||
data: "replay-2",
|
||||
});
|
||||
harness.client.emitChunk({
|
||||
streamId: 72,
|
||||
replay: true,
|
||||
offset: 687,
|
||||
endOffset: 784,
|
||||
data: "replay-3",
|
||||
});
|
||||
harness.client.emitChunk({
|
||||
streamId: 72,
|
||||
replay: false,
|
||||
offset: 978,
|
||||
endOffset: 1075,
|
||||
data: "live",
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(harness.client.attachCalls).toHaveLength(2);
|
||||
expect(harness.client.detachCalls.filter((streamId) => streamId === 71)).toHaveLength(1);
|
||||
expect(harness.chunks.at(-1)).toEqual({
|
||||
terminalId: "term-clamped-replay",
|
||||
text: "live",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,13 +10,16 @@ import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
||||
|
||||
export type TerminalStreamControllerAttachPayload = {
|
||||
streamId: number | null;
|
||||
replayedFrom?: number;
|
||||
currentOffset: number;
|
||||
reset: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export type TerminalStreamControllerChunk = {
|
||||
offset: number;
|
||||
endOffset: number;
|
||||
replay?: boolean;
|
||||
data: Uint8Array;
|
||||
};
|
||||
|
||||
@@ -71,6 +74,8 @@ type TerminalStreamControllerActiveStream = {
|
||||
terminalId: string;
|
||||
streamId: number;
|
||||
decoder: TextDecoder;
|
||||
nextExpectedOffset: number | null;
|
||||
catchUpEndOffset: number | null;
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
@@ -308,6 +313,37 @@ export class TerminalStreamController {
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const streamId = attachPayload.streamId;
|
||||
const replayedFromOffset =
|
||||
typeof attachPayload.replayedFrom === "number"
|
||||
? Math.max(0, Math.floor(attachPayload.replayedFrom))
|
||||
: null;
|
||||
const currentOffset = Math.max(
|
||||
0,
|
||||
Math.floor(attachPayload.currentOffset)
|
||||
);
|
||||
const shouldResetForReplayBootstrap =
|
||||
typeof resumeOffset !== "number" &&
|
||||
typeof replayedFromOffset === "number" &&
|
||||
replayedFromOffset < currentOffset;
|
||||
if (shouldResetForReplayBootstrap) {
|
||||
this.options.onReset?.({ terminalId: input.terminalId });
|
||||
}
|
||||
const startExpectedOffset =
|
||||
replayedFromOffset ??
|
||||
(typeof resumeOffset === "number"
|
||||
? Math.max(0, Math.floor(resumeOffset))
|
||||
: currentOffset);
|
||||
const catchUpEndOffset = currentOffset;
|
||||
|
||||
const activeStream: TerminalStreamControllerActiveStream = {
|
||||
terminalId: input.terminalId,
|
||||
streamId,
|
||||
decoder,
|
||||
nextExpectedOffset: startExpectedOffset,
|
||||
catchUpEndOffset,
|
||||
unsubscribe: () => {},
|
||||
};
|
||||
this.activeStream = activeStream;
|
||||
const unsubscribe = this.options.client.onTerminalStreamData(streamId, (chunk) => {
|
||||
this.handleChunk({
|
||||
terminalId: input.terminalId,
|
||||
@@ -316,21 +352,22 @@ export class TerminalStreamController {
|
||||
decoder,
|
||||
});
|
||||
});
|
||||
|
||||
this.activeStream = {
|
||||
terminalId: input.terminalId,
|
||||
streamId,
|
||||
decoder,
|
||||
unsubscribe,
|
||||
};
|
||||
if (this.activeStream === activeStream) {
|
||||
activeStream.unsubscribe = unsubscribe;
|
||||
} else {
|
||||
unsubscribe();
|
||||
}
|
||||
terminalDebugLog({
|
||||
scope: "stream-controller",
|
||||
event: "attach:success",
|
||||
details: {
|
||||
terminalId: input.terminalId,
|
||||
streamId,
|
||||
replayedFrom: attachPayload.replayedFrom ?? null,
|
||||
requestedResumeOffset: resumeOffset ?? null,
|
||||
currentOffset: attachPayload.currentOffset,
|
||||
reset: attachPayload.reset,
|
||||
bootstrapReset: shouldResetForReplayBootstrap,
|
||||
},
|
||||
});
|
||||
this.updateStatus({
|
||||
@@ -390,9 +427,66 @@ export class TerminalStreamController {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkOffset = Number.isFinite(input.chunk.offset)
|
||||
? Math.max(0, Math.floor(input.chunk.offset))
|
||||
: 0;
|
||||
const chunkEndOffset = Number.isFinite(input.chunk.endOffset)
|
||||
? Math.max(0, Math.floor(input.chunk.endOffset))
|
||||
: chunkOffset;
|
||||
if (chunkEndOffset < chunkOffset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedOffset = activeStream.nextExpectedOffset;
|
||||
if (typeof expectedOffset === "number") {
|
||||
if (chunkEndOffset <= expectedOffset) {
|
||||
return;
|
||||
}
|
||||
if (chunkOffset !== expectedOffset) {
|
||||
const catchUpEndOffset = activeStream.catchUpEndOffset;
|
||||
const canSkipReplayGap =
|
||||
chunkOffset > expectedOffset &&
|
||||
typeof catchUpEndOffset === "number" &&
|
||||
expectedOffset < catchUpEndOffset &&
|
||||
chunkOffset <= catchUpEndOffset;
|
||||
|
||||
if (canSkipReplayGap) {
|
||||
terminalDebugLog({
|
||||
scope: "stream-controller",
|
||||
event: "stream:replay-gap-skip",
|
||||
details: {
|
||||
terminalId: input.terminalId,
|
||||
streamId: input.streamId,
|
||||
expectedOffset,
|
||||
observedOffset: chunkOffset,
|
||||
catchUpEndOffset,
|
||||
replay: Boolean(input.chunk.replay),
|
||||
},
|
||||
});
|
||||
activeStream.nextExpectedOffset = chunkOffset;
|
||||
} else {
|
||||
this.recoverFromStreamGap({
|
||||
terminalId: input.terminalId,
|
||||
streamId: input.streamId,
|
||||
expectedOffset,
|
||||
observedOffset: chunkOffset,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof activeStream.catchUpEndOffset === "number" &&
|
||||
chunkEndOffset >= activeStream.catchUpEndOffset
|
||||
) {
|
||||
activeStream.catchUpEndOffset = null;
|
||||
}
|
||||
|
||||
activeStream.nextExpectedOffset = chunkEndOffset;
|
||||
updateTerminalResumeOffset({
|
||||
terminalId: input.terminalId,
|
||||
offset: input.chunk.endOffset,
|
||||
offset: chunkEndOffset,
|
||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||
});
|
||||
|
||||
@@ -406,7 +500,9 @@ export class TerminalStreamController {
|
||||
details: {
|
||||
terminalId: input.terminalId,
|
||||
streamId: input.streamId,
|
||||
endOffset: input.chunk.endOffset,
|
||||
offset: chunkOffset,
|
||||
endOffset: chunkEndOffset,
|
||||
replay: Boolean(input.chunk.replay),
|
||||
byteLength: input.chunk.data.byteLength,
|
||||
textLength: text.length,
|
||||
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
||||
@@ -419,6 +515,57 @@ export class TerminalStreamController {
|
||||
});
|
||||
}
|
||||
|
||||
private recoverFromStreamGap(input: {
|
||||
terminalId: string;
|
||||
streamId: number;
|
||||
expectedOffset: number;
|
||||
observedOffset: number;
|
||||
}): void {
|
||||
const activeStream = this.activeStream;
|
||||
if (!activeStream) {
|
||||
return;
|
||||
}
|
||||
if (activeStream.streamId !== input.streamId || activeStream.terminalId !== input.terminalId) {
|
||||
return;
|
||||
}
|
||||
if (this.selectedTerminalId !== input.terminalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
terminalDebugLog({
|
||||
scope: "stream-controller",
|
||||
event: "stream:gap-reconnect",
|
||||
details: {
|
||||
terminalId: input.terminalId,
|
||||
streamId: input.streamId,
|
||||
expectedOffset: input.expectedOffset,
|
||||
observedOffset: input.observedOffset,
|
||||
},
|
||||
});
|
||||
|
||||
updateTerminalResumeOffset({
|
||||
terminalId: input.terminalId,
|
||||
offset: input.expectedOffset,
|
||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||
});
|
||||
|
||||
this.attachGeneration += 1;
|
||||
const generation = this.attachGeneration;
|
||||
|
||||
void this.detachActiveStream({ shouldDetach: true });
|
||||
this.updateStatus({
|
||||
terminalId: input.terminalId,
|
||||
streamId: null,
|
||||
isAttaching: true,
|
||||
error:
|
||||
this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE,
|
||||
});
|
||||
void this.attachTerminal({
|
||||
terminalId: input.terminalId,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
|
||||
private async detachActiveStream(input: { shouldDetach: boolean }): Promise<void> {
|
||||
const activeStream = this.activeStream;
|
||||
if (!activeStream) {
|
||||
|
||||
Reference in New Issue
Block a user