Fix terminal snapshot flushing
This commit is contained in:
@@ -380,6 +380,271 @@ function getFrameText(frame: TerminalStreamFrame): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
interface TimedTerminalOutputFrame {
|
||||
receivedAtMs: number;
|
||||
payloadBytes: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface TerminalOutputCapture {
|
||||
frames: TimedTerminalOutputFrame[];
|
||||
snapshotCount: number;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
interface TerminalOutputCadenceSummary {
|
||||
frameCount: number;
|
||||
maxGapMs: number;
|
||||
p95GapMs: number;
|
||||
lastOutputAfterStopMs: number;
|
||||
payloadBytes: number[];
|
||||
}
|
||||
|
||||
interface TerminalRepeatCadenceMeasurement {
|
||||
enterSummary: TerminalOutputCadenceSummary;
|
||||
keySummary: TerminalOutputCadenceSummary;
|
||||
snapshotCount: number;
|
||||
}
|
||||
|
||||
function startTerminalOutputCapture(ws: WebSocket, slot: number): TerminalOutputCapture {
|
||||
const decoder = new TextDecoder();
|
||||
const frames: TimedTerminalOutputFrame[] = [];
|
||||
let snapshotCount = 0;
|
||||
|
||||
const onMessage = (raw: WebSocket.RawData, isBinary: boolean) => {
|
||||
if (!isBinary) {
|
||||
return;
|
||||
}
|
||||
const buffer = toWsBuffer(raw);
|
||||
if (!buffer) {
|
||||
return;
|
||||
}
|
||||
const frame = decodeTerminalStreamFrame(new Uint8Array(buffer));
|
||||
if (!frame || frame.slot !== slot) {
|
||||
return;
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Snapshot) {
|
||||
snapshotCount += 1;
|
||||
return;
|
||||
}
|
||||
if (frame.opcode !== TerminalStreamOpcode.Output || frame.payload.byteLength === 0) return;
|
||||
frames.push({
|
||||
receivedAtMs: performance.now(),
|
||||
payloadBytes: frame.payload.byteLength,
|
||||
text: decoder.decode(frame.payload),
|
||||
});
|
||||
};
|
||||
|
||||
ws.on("message", onMessage);
|
||||
return {
|
||||
frames,
|
||||
get snapshotCount() {
|
||||
return snapshotCount;
|
||||
},
|
||||
stop: () => {
|
||||
ws.off("message", onMessage);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sendRawTerminalInput(ws: WebSocket, slot: number, data: string): void {
|
||||
ws.send(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
slot,
|
||||
payload: data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function repeatRawTerminalInput(input: {
|
||||
ws: WebSocket;
|
||||
slot: number;
|
||||
data: string;
|
||||
count: number;
|
||||
intervalMs: number;
|
||||
}): Promise<{
|
||||
stoppedAtMs: number;
|
||||
}> {
|
||||
for (let index = 0; index < input.count; index += 1) {
|
||||
sendRawTerminalInput(input.ws, input.slot, input.data);
|
||||
await new Promise((resolve) => setTimeout(resolve, input.intervalMs));
|
||||
}
|
||||
return {
|
||||
stoppedAtMs: performance.now(),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForTerminalOutputQuiet(
|
||||
capture: TerminalOutputCapture,
|
||||
quietMs: number,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
let lastCount = capture.frames.length;
|
||||
let quietStartedAtMs = performance.now();
|
||||
const startedAtMs = quietStartedAtMs;
|
||||
|
||||
while (performance.now() - startedAtMs < timeoutMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
if (capture.frames.length !== lastCount) {
|
||||
lastCount = capture.frames.length;
|
||||
quietStartedAtMs = performance.now();
|
||||
continue;
|
||||
}
|
||||
if (performance.now() - quietStartedAtMs >= quietMs) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeTerminalOutputCadence(input: {
|
||||
frames: TimedTerminalOutputFrame[];
|
||||
stoppedAtMs: number;
|
||||
}): TerminalOutputCadenceSummary {
|
||||
const gaps = input.frames
|
||||
.slice(1)
|
||||
.map((frame, index) => frame.receivedAtMs - input.frames[index]!.receivedAtMs)
|
||||
.sort((a, b) => a - b);
|
||||
const p95Index =
|
||||
gaps.length === 0 ? -1 : Math.min(gaps.length - 1, Math.ceil(gaps.length * 0.95) - 1);
|
||||
const lastFrame = input.frames.at(-1);
|
||||
|
||||
return {
|
||||
frameCount: input.frames.length,
|
||||
maxGapMs: gaps.at(-1) ?? 0,
|
||||
p95GapMs: p95Index === -1 ? 0 : gaps[p95Index]!,
|
||||
lastOutputAfterStopMs: lastFrame ? lastFrame.receivedAtMs - input.stoppedAtMs : 0,
|
||||
payloadBytes: input.frames.map((frame) => frame.payloadBytes),
|
||||
};
|
||||
}
|
||||
|
||||
function getTerminalOutputCadenceFailures(
|
||||
label: string,
|
||||
summary: TerminalOutputCadenceSummary,
|
||||
limits: {
|
||||
minFrames: number;
|
||||
maxP95GapMs: number;
|
||||
maxLastOutputAfterStopMs: number;
|
||||
},
|
||||
): string[] {
|
||||
const failures: string[] = [];
|
||||
if (summary.frameCount < limits.minFrames) {
|
||||
failures.push(`${label}: frameCount ${summary.frameCount} < ${limits.minFrames}`);
|
||||
}
|
||||
if (summary.p95GapMs >= limits.maxP95GapMs) {
|
||||
failures.push(`${label}: p95GapMs ${summary.p95GapMs.toFixed(1)} >= ${limits.maxP95GapMs}`);
|
||||
}
|
||||
if (summary.lastOutputAfterStopMs >= limits.maxLastOutputAfterStopMs) {
|
||||
failures.push(
|
||||
`${label}: lastOutputAfterStopMs ${summary.lastOutputAfterStopMs.toFixed(1)} >= ${limits.maxLastOutputAfterStopMs}`,
|
||||
);
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
function expectTerminalOutputCadences(input: {
|
||||
enterSummary: TerminalOutputCadenceSummary;
|
||||
keySummary: TerminalOutputCadenceSummary;
|
||||
snapshotCount: number;
|
||||
}): void {
|
||||
const failures = [
|
||||
...getTerminalOutputCadenceFailures("enter-repeat", input.enterSummary, {
|
||||
minFrames: 20,
|
||||
maxP95GapMs: 120,
|
||||
maxLastOutputAfterStopMs: Number.POSITIVE_INFINITY,
|
||||
}),
|
||||
...getTerminalOutputCadenceFailures("key-repeat-after-enter-repeat", input.keySummary, {
|
||||
minFrames: 40,
|
||||
maxP95GapMs: 60,
|
||||
maxLastOutputAfterStopMs: 150,
|
||||
}),
|
||||
];
|
||||
if (input.snapshotCount > 0) {
|
||||
failures.push(`unexpected live snapshot count ${input.snapshotCount} > 0`);
|
||||
}
|
||||
if (failures.length === 0) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`terminal output cadence failed: ${failures.join("; ")}\n${JSON.stringify(input)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function measureRepeatCadenceForTerminal(input: {
|
||||
cwd: string;
|
||||
name: string;
|
||||
options?: { command?: string; args?: string[] };
|
||||
setupInput?: string;
|
||||
}): Promise<TerminalRepeatCadenceMeasurement> {
|
||||
const created = await ctx.client.createTerminal(input.cwd, input.name, undefined, input.options);
|
||||
const terminalId = created.terminal!.id;
|
||||
const ws = await connectRawWebSocket(ctx.daemon.port);
|
||||
|
||||
try {
|
||||
const slot = await subscribeRawTerminal(ws, terminalId, `sub-repeat-cadence-${input.name}`);
|
||||
await waitForRawBinaryFrame(
|
||||
ws,
|
||||
(frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot,
|
||||
10000,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (input.setupInput) {
|
||||
sendRawTerminalInput(ws, slot, input.setupInput);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
const enterCapture = startTerminalOutputCapture(ws, slot);
|
||||
const enterRepeat = await repeatRawTerminalInput({
|
||||
ws,
|
||||
slot,
|
||||
data: "\r",
|
||||
count: 120,
|
||||
intervalMs: 15,
|
||||
});
|
||||
await waitForTerminalOutputQuiet(enterCapture, 700, 10000);
|
||||
enterCapture.stop();
|
||||
const enterSummary = summarizeTerminalOutputCadence({
|
||||
frames: enterCapture.frames,
|
||||
stoppedAtMs: enterRepeat.stoppedAtMs,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const keyCapture = startTerminalOutputCapture(ws, slot);
|
||||
const keyRepeat = await repeatRawTerminalInput({
|
||||
ws,
|
||||
slot,
|
||||
data: "k",
|
||||
count: 80,
|
||||
intervalMs: 15,
|
||||
});
|
||||
await waitForTerminalOutputQuiet(keyCapture, 250, 3000);
|
||||
keyCapture.stop();
|
||||
const keySummary = summarizeTerminalOutputCadence({
|
||||
frames: keyCapture.frames,
|
||||
stoppedAtMs: keyRepeat.stoppedAtMs,
|
||||
});
|
||||
|
||||
return {
|
||||
enterSummary,
|
||||
keySummary,
|
||||
snapshotCount: enterCapture.snapshotCount + keyCapture.snapshotCount,
|
||||
};
|
||||
} finally {
|
||||
await closeWebSocket(ws);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectRepeatCadenceForTerminal(input: {
|
||||
cwd: string;
|
||||
name: string;
|
||||
options?: { command?: string; args?: string[] };
|
||||
setupInput?: string;
|
||||
}): Promise<void> {
|
||||
const measurement = await measureRepeatCadenceForTerminal(input);
|
||||
expectTerminalOutputCadences(measurement);
|
||||
}
|
||||
|
||||
async function subscribeRawTerminal(
|
||||
ws: WebSocket,
|
||||
terminalId: string,
|
||||
@@ -613,6 +878,13 @@ test("client sends input and receives output as raw bytes", async () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 30000);
|
||||
|
||||
test("default zsh terminal does not eagerly flush full state during repeat bursts", async () => {
|
||||
await expectRepeatCadenceForTerminal({
|
||||
cwd: process.cwd(),
|
||||
name: "default-zsh-repeat-cadence",
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
test("one client can stream two terminals concurrently", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const firstCreated = await ctx.client.createTerminal(cwd, "first");
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createTerminal,
|
||||
type CaptureTerminalLinesResult,
|
||||
type TerminalSession,
|
||||
type TerminalStateSnapshot,
|
||||
} from "./terminal.js";
|
||||
import { resolve, sep, win32, posix } from "node:path";
|
||||
|
||||
@@ -33,6 +34,7 @@ export interface TerminalManager {
|
||||
}): Promise<TerminalSession>;
|
||||
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
|
||||
getTerminal(id: string): TerminalSession | undefined;
|
||||
getTerminalState(id: string): Promise<TerminalStateSnapshot | null>;
|
||||
killTerminal(id: string): void;
|
||||
killTerminalAndWait(
|
||||
id: string,
|
||||
@@ -210,6 +212,10 @@ export function createTerminalManager(): TerminalManager {
|
||||
return terminalsById.get(id);
|
||||
},
|
||||
|
||||
async getTerminalState(id: string): Promise<TerminalStateSnapshot | null> {
|
||||
return terminalsById.get(id)?.getStateSnapshot() ?? null;
|
||||
},
|
||||
|
||||
killTerminal(id: string): void {
|
||||
removeSessionById(id, { kill: true });
|
||||
},
|
||||
|
||||
@@ -26,11 +26,18 @@ import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.
|
||||
|
||||
const MAX_TERMINAL_STREAM_SLOTS = 256;
|
||||
|
||||
interface BufferedTerminalOutput {
|
||||
data: string;
|
||||
revision?: number;
|
||||
}
|
||||
|
||||
interface ActiveTerminalStream {
|
||||
terminalId: string;
|
||||
slot: number;
|
||||
unsubscribe: () => void;
|
||||
needsSnapshot: boolean;
|
||||
snapshotInFlight: boolean;
|
||||
bufferedOutputs: BufferedTerminalOutput[];
|
||||
outputCoalescer: TerminalOutputCoalescer;
|
||||
}
|
||||
|
||||
@@ -480,7 +487,7 @@ export class TerminalSessionController {
|
||||
|
||||
const activeStream = this.activeStreams.get(slot);
|
||||
if (activeStream) {
|
||||
this.trySendSnapshot(activeStream);
|
||||
void this.trySendSnapshot(activeStream);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,6 +620,8 @@ export class TerminalSessionController {
|
||||
slot,
|
||||
unsubscribe: () => {},
|
||||
needsSnapshot: true,
|
||||
snapshotInFlight: false,
|
||||
bufferedOutputs: [],
|
||||
outputCoalescer: new TerminalOutputCoalescer({
|
||||
timers: { setTimeout, clearTimeout },
|
||||
onFlush: ({ payload }) => {
|
||||
@@ -640,13 +649,20 @@ export class TerminalSessionController {
|
||||
if (message.type === "snapshot") {
|
||||
activeStream.outputCoalescer.flush();
|
||||
activeStream.needsSnapshot = true;
|
||||
this.trySendSnapshot(activeStream);
|
||||
void this.trySendSnapshot(activeStream);
|
||||
return;
|
||||
}
|
||||
if (message.type === "titleChange") {
|
||||
return;
|
||||
}
|
||||
if (activeStream.needsSnapshot || message.data.length === 0) {
|
||||
if (message.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (activeStream.needsSnapshot || activeStream.snapshotInFlight) {
|
||||
activeStream.bufferedOutputs.push({
|
||||
data: message.data,
|
||||
revision: message.revision,
|
||||
});
|
||||
return;
|
||||
}
|
||||
activeStream.outputCoalescer.handle(message.data);
|
||||
@@ -654,26 +670,60 @@ export class TerminalSessionController {
|
||||
return slot;
|
||||
}
|
||||
|
||||
private trySendSnapshot(activeStream: ActiveTerminalStream): void {
|
||||
if (this.activeStreams.get(activeStream.slot) !== activeStream || !activeStream.needsSnapshot) {
|
||||
private async trySendSnapshot(activeStream: ActiveTerminalStream): Promise<void> {
|
||||
if (
|
||||
this.activeStreams.get(activeStream.slot) !== activeStream ||
|
||||
!activeStream.needsSnapshot ||
|
||||
activeStream.snapshotInFlight
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const terminal = this.terminalManager?.getTerminal(activeStream.terminalId);
|
||||
if (!terminal) {
|
||||
if (!this.terminalManager?.getTerminal(activeStream.terminalId)) {
|
||||
this.detachStream(activeStream.terminalId, { emitExit: true });
|
||||
return;
|
||||
}
|
||||
|
||||
activeStream.outputCoalescer.flush();
|
||||
activeStream.needsSnapshot = false;
|
||||
this.emitBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Snapshot,
|
||||
slot: activeStream.slot,
|
||||
payload: encodeTerminalSnapshotPayload(terminal.getState()),
|
||||
}),
|
||||
);
|
||||
activeStream.snapshotInFlight = true;
|
||||
try {
|
||||
const snapshot = await this.terminalManager.getTerminalState(activeStream.terminalId);
|
||||
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
|
||||
return;
|
||||
}
|
||||
if (!snapshot) {
|
||||
this.detachStream(activeStream.terminalId, { emitExit: true });
|
||||
return;
|
||||
}
|
||||
|
||||
this.emitBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Snapshot,
|
||||
slot: activeStream.slot,
|
||||
payload: encodeTerminalSnapshotPayload(snapshot.state),
|
||||
}),
|
||||
);
|
||||
|
||||
const bufferedOutputs = activeStream.bufferedOutputs.splice(
|
||||
0,
|
||||
activeStream.bufferedOutputs.length,
|
||||
);
|
||||
for (const output of bufferedOutputs) {
|
||||
if (output.revision !== undefined && output.revision <= snapshot.revision) {
|
||||
continue;
|
||||
}
|
||||
activeStream.outputCoalescer.handle(output.data);
|
||||
}
|
||||
activeStream.needsSnapshot = false;
|
||||
} catch (error) {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, terminalId: activeStream.terminalId },
|
||||
"Failed to pull terminal snapshot",
|
||||
);
|
||||
activeStream.needsSnapshot = true;
|
||||
} finally {
|
||||
activeStream.snapshotInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private allocateSlot(): number | null {
|
||||
@@ -699,6 +749,7 @@ export class TerminalSessionController {
|
||||
return false;
|
||||
}
|
||||
activeStream.outputCoalescer.flush();
|
||||
activeStream.bufferedOutputs.length = 0;
|
||||
this.activeStreams.delete(slot);
|
||||
this.idToSlot.delete(terminalId);
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
|
||||
const manager = createTerminalManager();
|
||||
const unsubscribeByTerminalId = new Map<string, Array<() => void>>();
|
||||
const stateFlushTimeoutByTerminalId = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let ipcClosing = false;
|
||||
|
||||
function sendToParent(message: TerminalWorkerToParentMessage): void {
|
||||
@@ -37,11 +36,6 @@ function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo {
|
||||
}
|
||||
|
||||
function clearTerminalSubscriptions(terminalId: string): void {
|
||||
const stateFlushTimeout = stateFlushTimeoutByTerminalId.get(terminalId);
|
||||
if (stateFlushTimeout) {
|
||||
clearTimeout(stateFlushTimeout);
|
||||
stateFlushTimeoutByTerminalId.delete(terminalId);
|
||||
}
|
||||
const subscriptions = unsubscribeByTerminalId.get(terminalId);
|
||||
if (subscriptions) {
|
||||
for (const unsubscribe of subscriptions) {
|
||||
@@ -55,40 +49,10 @@ function clearTerminalSubscriptions(terminalId: string): void {
|
||||
unsubscribeByTerminalId.delete(terminalId);
|
||||
}
|
||||
|
||||
function flushTerminalState(session: TerminalSession): void {
|
||||
const stateFlushTimeout = stateFlushTimeoutByTerminalId.get(session.id);
|
||||
if (stateFlushTimeout) {
|
||||
clearTimeout(stateFlushTimeout);
|
||||
stateFlushTimeoutByTerminalId.delete(session.id);
|
||||
}
|
||||
sendToParent({
|
||||
type: "terminalStateUpdated",
|
||||
terminalId: session.id,
|
||||
state: session.getState(),
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleTerminalStateFlush(session: TerminalSession): void {
|
||||
const existingTimeout = stateFlushTimeoutByTerminalId.get(session.id);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
}
|
||||
stateFlushTimeoutByTerminalId.set(
|
||||
session.id,
|
||||
setTimeout(() => {
|
||||
stateFlushTimeoutByTerminalId.delete(session.id);
|
||||
flushTerminalState(session);
|
||||
}, 16),
|
||||
);
|
||||
}
|
||||
|
||||
function watchTerminal(session: TerminalSession): void {
|
||||
clearTerminalSubscriptions(session.id);
|
||||
|
||||
const unsubscribeMessage = session.subscribe((message) => {
|
||||
if (message.type === "output") {
|
||||
scheduleTerminalStateFlush(session);
|
||||
}
|
||||
sendToParent({
|
||||
type: "terminalMessage",
|
||||
terminalId: session.id,
|
||||
@@ -150,10 +114,11 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
|
||||
case "createTerminal": {
|
||||
const session = await manager.createTerminal(message.options);
|
||||
watchTerminal(session);
|
||||
const initialSnapshot = session.getStateSnapshot();
|
||||
sendToParent({
|
||||
type: "terminalCreated",
|
||||
terminal: toTerminalInfo(session),
|
||||
state: session.getState(),
|
||||
state: initialSnapshot.state,
|
||||
});
|
||||
sendToParent({
|
||||
type: "response",
|
||||
@@ -161,7 +126,7 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
|
||||
ok: true,
|
||||
result: {
|
||||
terminal: toTerminalInfo(session),
|
||||
state: session.getState(),
|
||||
state: initialSnapshot.state,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -205,6 +170,17 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
case "getTerminalState": {
|
||||
const session = manager.getTerminal(message.terminalId);
|
||||
sendToParent({
|
||||
type: "response",
|
||||
requestId: message.requestId,
|
||||
ok: true,
|
||||
result: session?.getStateSnapshot() ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "listDirectories": {
|
||||
sendToParent({
|
||||
type: "response",
|
||||
@@ -245,9 +221,6 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
|
||||
case "send": {
|
||||
const session = manager.getTerminal(message.terminalId);
|
||||
session?.send(message.message);
|
||||
if (session && message.message.type === "resize") {
|
||||
flushTerminalState(session);
|
||||
}
|
||||
sendToParent({ type: "response", requestId: message.requestId, ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { TerminalExitInfo, ServerMessage, ClientMessage } from "./terminal.js";
|
||||
import type {
|
||||
TerminalExitInfo,
|
||||
ServerMessage,
|
||||
ClientMessage,
|
||||
TerminalStateSnapshot,
|
||||
} from "./terminal.js";
|
||||
import type { TerminalState } from "../shared/messages.js";
|
||||
import type { CaptureTerminalLinesResult } from "./terminal.js";
|
||||
|
||||
@@ -52,6 +57,11 @@ export type TerminalWorkerRequest =
|
||||
terminalId: string;
|
||||
options?: WorkerKillAndWaitOptions;
|
||||
}
|
||||
| {
|
||||
type: "getTerminalState";
|
||||
requestId: string;
|
||||
terminalId: string;
|
||||
}
|
||||
| {
|
||||
type: "captureTerminal";
|
||||
requestId: string;
|
||||
@@ -105,11 +115,6 @@ export type TerminalWorkerEvent =
|
||||
terminalId: string;
|
||||
message: ServerMessage;
|
||||
}
|
||||
| {
|
||||
type: "terminalStateUpdated";
|
||||
terminalId: string;
|
||||
state: TerminalState;
|
||||
}
|
||||
| {
|
||||
type: "terminalExit";
|
||||
terminalId: string;
|
||||
@@ -136,3 +141,4 @@ export type TerminalWorkerEvent =
|
||||
export type TerminalWorkerToParentMessage = TerminalWorkerResponse | TerminalWorkerEvent;
|
||||
|
||||
export type TerminalWorkerCaptureResult = CaptureTerminalLinesResult;
|
||||
export type TerminalWorkerStateResult = TerminalStateSnapshot | null;
|
||||
|
||||
@@ -27,14 +27,19 @@ export interface TerminalCommandFinishedInfo {
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export interface TerminalStateSnapshot {
|
||||
state: TerminalState;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: "input"; data: string }
|
||||
| { type: "resize"; rows: number; cols: number }
|
||||
| { type: "mouse"; row: number; col: number; button: number; action: "down" | "up" | "move" };
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: "output"; data: string }
|
||||
| { type: "snapshot"; state: TerminalState }
|
||||
| { type: "output"; data: string; revision?: number }
|
||||
| { type: "snapshot"; state: TerminalState; revision?: number }
|
||||
| { type: "titleChange"; title?: string };
|
||||
|
||||
export interface TerminalSession {
|
||||
@@ -48,6 +53,7 @@ export interface TerminalSession {
|
||||
onTitleChange(listener: (title?: string) => void): () => void;
|
||||
getSize(): { rows: number; cols: number };
|
||||
getState(): TerminalState;
|
||||
getStateSnapshot(): TerminalStateSnapshot;
|
||||
getTitle(): string | undefined;
|
||||
getExitInfo(): TerminalExitInfo | null;
|
||||
kill(): void;
|
||||
@@ -605,6 +611,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingInput = "";
|
||||
let inputFlushImmediate: ReturnType<typeof setImmediate> | null = null;
|
||||
let stateRevision = 0;
|
||||
|
||||
// Create xterm.js headless terminal
|
||||
const terminal = new Terminal({
|
||||
@@ -751,6 +758,18 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
titleChangeListeners.clear();
|
||||
}
|
||||
|
||||
function writeOutputToHeadless(data: string): void {
|
||||
terminal.write(data, () => {
|
||||
if (disposed || killed) {
|
||||
return;
|
||||
}
|
||||
stateRevision += 1;
|
||||
for (const listener of listeners) {
|
||||
listener({ type: "output", data, revision: stateRevision });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Pipe PTY output to terminal emulator
|
||||
ptyProcess.onData((data) => {
|
||||
if (killed) return;
|
||||
@@ -758,14 +777,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
if (recentOutputText.length > TERMINAL_EXIT_OUTPUT_CHAR_LIMIT) {
|
||||
recentOutputText = recentOutputText.slice(-TERMINAL_EXIT_OUTPUT_CHAR_LIMIT);
|
||||
}
|
||||
terminal.write(data, () => {
|
||||
if (disposed || killed) {
|
||||
return;
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
listener({ type: "output", data });
|
||||
}
|
||||
});
|
||||
writeOutputToHeadless(data);
|
||||
});
|
||||
|
||||
ptyProcess.onExit((event) => {
|
||||
@@ -799,6 +811,13 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
};
|
||||
}
|
||||
|
||||
function getStateSnapshot(): TerminalStateSnapshot {
|
||||
return {
|
||||
state: getState(),
|
||||
revision: stateRevision,
|
||||
};
|
||||
}
|
||||
|
||||
function getSize(): { rows: number; cols: number } {
|
||||
return {
|
||||
rows: terminal.rows,
|
||||
@@ -846,6 +865,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
flushPendingInput();
|
||||
terminal.resize(msg.cols, msg.rows);
|
||||
ptyProcess.resize(msg.cols, msg.rows);
|
||||
stateRevision += 1;
|
||||
break;
|
||||
case "mouse":
|
||||
// Mouse events can be sent as escape sequences if terminal supports it
|
||||
@@ -859,7 +879,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
|
||||
terminal.write("", () => {
|
||||
if (!disposed && listeners.has(listener)) {
|
||||
listener({ type: "snapshot", state: getState() });
|
||||
listener({ type: "snapshot", ...getStateSnapshot() });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -999,6 +1019,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
onTitleChange,
|
||||
getSize,
|
||||
getState,
|
||||
getStateSnapshot,
|
||||
getTitle,
|
||||
getExitInfo,
|
||||
kill,
|
||||
|
||||
@@ -20,13 +20,13 @@ function nodeTerminalCommand(script: string): { command: string; args: string[]
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
predicate: () => boolean,
|
||||
predicate: () => boolean | Promise<boolean>,
|
||||
timeoutMs: number,
|
||||
intervalMs = 25,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (predicate()) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
@@ -35,9 +35,12 @@ async function waitForCondition(
|
||||
}
|
||||
|
||||
function getVisibleText(session: TerminalSession): string {
|
||||
return session
|
||||
.getState()
|
||||
.grid.map((row) =>
|
||||
return getVisibleTextFromState(session.getState());
|
||||
}
|
||||
|
||||
function getVisibleTextFromState(state: TerminalState): string {
|
||||
return state.grid
|
||||
.map((row) =>
|
||||
row
|
||||
.map((cell) => cell.char)
|
||||
.join("")
|
||||
@@ -173,7 +176,7 @@ it("creates a terminal through the worker and streams output", async () => {
|
||||
expect(snapshots).toBe(snapshotsBeforeOutput);
|
||||
});
|
||||
|
||||
it("refreshes cached terminal state after worker output", async () => {
|
||||
it("pulls fresh terminal state from the worker authority", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-state-"));
|
||||
temporaryDirs.push(cwd);
|
||||
manager = createWorkerTerminalManager();
|
||||
@@ -187,9 +190,14 @@ it("refreshes cached terminal state after worker output", async () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForCondition(() => getVisibleText(session).includes("worker-state-ready"), 10000);
|
||||
let visibleText = "";
|
||||
await waitForCondition(async () => {
|
||||
const snapshot = await manager!.getTerminalState(session.id);
|
||||
visibleText = snapshot ? getVisibleTextFromState(snapshot.state) : "";
|
||||
return visibleText.includes("worker-state-ready");
|
||||
}, 10000);
|
||||
|
||||
expect(getVisibleText(session)).toContain("worker-state-ready");
|
||||
expect(visibleText).toContain("worker-state-ready");
|
||||
});
|
||||
|
||||
it("refreshes cached terminal title after worker title changes", async () => {
|
||||
@@ -236,9 +244,12 @@ it("captures terminal output from the worker authority", async () => {
|
||||
|
||||
session.send({ type: "input", data: "echo hello world\r" });
|
||||
|
||||
await waitForCondition(() => getVisibleText(session).includes("hello world"), 10000);
|
||||
let capture = await manager.captureTerminal(session.id);
|
||||
await waitForCondition(async () => {
|
||||
capture = await manager!.captureTerminal(session.id);
|
||||
return capture.lines.join("\n").includes("hello world");
|
||||
}, 10000);
|
||||
|
||||
const capture = await manager.captureTerminal(session.id);
|
||||
expect(capture.lines.join("\n")).toContain("hello world");
|
||||
expect(capture.totalLines).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
TerminalCommandFinishedInfo,
|
||||
TerminalExitInfo,
|
||||
TerminalSession,
|
||||
TerminalStateSnapshot,
|
||||
} from "./terminal.js";
|
||||
import type {
|
||||
TerminalListItem,
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
TerminalWorkerResponse,
|
||||
TerminalWorkerToParentMessage,
|
||||
WorkerCreateTerminalOptions,
|
||||
TerminalWorkerStateResult,
|
||||
WorkerTerminalInfo,
|
||||
} from "./terminal-worker-protocol.js";
|
||||
|
||||
@@ -186,15 +188,17 @@ export function createWorkerTerminalManager(
|
||||
return record.info.cwd;
|
||||
},
|
||||
send(message: ClientMessage): void {
|
||||
if (message.type === "resize") {
|
||||
record.state = {
|
||||
...record.state,
|
||||
rows: message.rows,
|
||||
cols: message.cols,
|
||||
};
|
||||
}
|
||||
sendBestEffortRequest({ type: "send", terminalId: record.info.id, message });
|
||||
},
|
||||
subscribe(listener: (msg: ServerMessage) => void): () => void {
|
||||
record.messageListeners.add(listener);
|
||||
queueMicrotask(() => {
|
||||
if (record.messageListeners.has(listener)) {
|
||||
listener({ type: "snapshot", state: record.state });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
record.messageListeners.delete(listener);
|
||||
};
|
||||
@@ -237,6 +241,12 @@ export function createWorkerTerminalManager(
|
||||
getState(): TerminalState {
|
||||
return record.state;
|
||||
},
|
||||
getStateSnapshot(): TerminalStateSnapshot {
|
||||
return {
|
||||
state: record.state,
|
||||
revision: 0,
|
||||
};
|
||||
},
|
||||
getTitle(): string | undefined {
|
||||
return record.info.title;
|
||||
},
|
||||
@@ -297,16 +307,6 @@ export function createWorkerTerminalManager(
|
||||
}
|
||||
}
|
||||
|
||||
function handleTerminalStateUpdatedEvent(
|
||||
message: Extract<TerminalWorkerToParentMessage, { type: "terminalStateUpdated" }>,
|
||||
): void {
|
||||
const record = recordsById.get(message.terminalId);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
record.state = message.state;
|
||||
}
|
||||
|
||||
function handleTerminalExitEvent(
|
||||
message: Extract<TerminalWorkerToParentMessage, { type: "terminalExit" }>,
|
||||
): void {
|
||||
@@ -400,11 +400,6 @@ export function createWorkerTerminalManager(
|
||||
return;
|
||||
}
|
||||
|
||||
case "terminalStateUpdated": {
|
||||
handleTerminalStateUpdatedEvent(message);
|
||||
return;
|
||||
}
|
||||
|
||||
case "terminalExit": {
|
||||
handleTerminalExitEvent(message);
|
||||
return;
|
||||
@@ -525,6 +520,13 @@ export function createWorkerTerminalManager(
|
||||
return recordsById.get(id)?.session;
|
||||
},
|
||||
|
||||
async getTerminalState(id: string): Promise<TerminalStateSnapshot | null> {
|
||||
return (await sendRequest({
|
||||
type: "getTerminalState",
|
||||
terminalId: id,
|
||||
})) as TerminalWorkerStateResult;
|
||||
},
|
||||
|
||||
killTerminal(id: string): void {
|
||||
void sendRequest({ type: "killTerminal", terminalId: id }).catch(() => {
|
||||
// no-op; kill is intentionally best-effort and synchronous in the public interface.
|
||||
|
||||
Reference in New Issue
Block a user