Fix terminal snapshot flushing
This commit is contained in:
@@ -380,6 +380,271 @@ function getFrameText(frame: TerminalStreamFrame): string {
|
|||||||
return "";
|
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(
|
async function subscribeRawTerminal(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
terminalId: string,
|
terminalId: string,
|
||||||
@@ -613,6 +878,13 @@ test("client sends input and receives output as raw bytes", async () => {
|
|||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 30000);
|
}, 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 () => {
|
test("one client can stream two terminals concurrently", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const firstCreated = await ctx.client.createTerminal(cwd, "first");
|
const firstCreated = await ctx.client.createTerminal(cwd, "first");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
createTerminal,
|
createTerminal,
|
||||||
type CaptureTerminalLinesResult,
|
type CaptureTerminalLinesResult,
|
||||||
type TerminalSession,
|
type TerminalSession,
|
||||||
|
type TerminalStateSnapshot,
|
||||||
} from "./terminal.js";
|
} from "./terminal.js";
|
||||||
import { resolve, sep, win32, posix } from "node:path";
|
import { resolve, sep, win32, posix } from "node:path";
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export interface TerminalManager {
|
|||||||
}): Promise<TerminalSession>;
|
}): Promise<TerminalSession>;
|
||||||
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
|
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
|
||||||
getTerminal(id: string): TerminalSession | undefined;
|
getTerminal(id: string): TerminalSession | undefined;
|
||||||
|
getTerminalState(id: string): Promise<TerminalStateSnapshot | null>;
|
||||||
killTerminal(id: string): void;
|
killTerminal(id: string): void;
|
||||||
killTerminalAndWait(
|
killTerminalAndWait(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -210,6 +212,10 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
return terminalsById.get(id);
|
return terminalsById.get(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getTerminalState(id: string): Promise<TerminalStateSnapshot | null> {
|
||||||
|
return terminalsById.get(id)?.getStateSnapshot() ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
killTerminal(id: string): void {
|
killTerminal(id: string): void {
|
||||||
removeSessionById(id, { kill: true });
|
removeSessionById(id, { kill: true });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,11 +26,18 @@ import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.
|
|||||||
|
|
||||||
const MAX_TERMINAL_STREAM_SLOTS = 256;
|
const MAX_TERMINAL_STREAM_SLOTS = 256;
|
||||||
|
|
||||||
|
interface BufferedTerminalOutput {
|
||||||
|
data: string;
|
||||||
|
revision?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ActiveTerminalStream {
|
interface ActiveTerminalStream {
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
slot: number;
|
slot: number;
|
||||||
unsubscribe: () => void;
|
unsubscribe: () => void;
|
||||||
needsSnapshot: boolean;
|
needsSnapshot: boolean;
|
||||||
|
snapshotInFlight: boolean;
|
||||||
|
bufferedOutputs: BufferedTerminalOutput[];
|
||||||
outputCoalescer: TerminalOutputCoalescer;
|
outputCoalescer: TerminalOutputCoalescer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +487,7 @@ export class TerminalSessionController {
|
|||||||
|
|
||||||
const activeStream = this.activeStreams.get(slot);
|
const activeStream = this.activeStreams.get(slot);
|
||||||
if (activeStream) {
|
if (activeStream) {
|
||||||
this.trySendSnapshot(activeStream);
|
void this.trySendSnapshot(activeStream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,6 +620,8 @@ export class TerminalSessionController {
|
|||||||
slot,
|
slot,
|
||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
needsSnapshot: true,
|
needsSnapshot: true,
|
||||||
|
snapshotInFlight: false,
|
||||||
|
bufferedOutputs: [],
|
||||||
outputCoalescer: new TerminalOutputCoalescer({
|
outputCoalescer: new TerminalOutputCoalescer({
|
||||||
timers: { setTimeout, clearTimeout },
|
timers: { setTimeout, clearTimeout },
|
||||||
onFlush: ({ payload }) => {
|
onFlush: ({ payload }) => {
|
||||||
@@ -640,13 +649,20 @@ export class TerminalSessionController {
|
|||||||
if (message.type === "snapshot") {
|
if (message.type === "snapshot") {
|
||||||
activeStream.outputCoalescer.flush();
|
activeStream.outputCoalescer.flush();
|
||||||
activeStream.needsSnapshot = true;
|
activeStream.needsSnapshot = true;
|
||||||
this.trySendSnapshot(activeStream);
|
void this.trySendSnapshot(activeStream);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type === "titleChange") {
|
if (message.type === "titleChange") {
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
activeStream.outputCoalescer.handle(message.data);
|
activeStream.outputCoalescer.handle(message.data);
|
||||||
@@ -654,26 +670,60 @@ export class TerminalSessionController {
|
|||||||
return slot;
|
return slot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private trySendSnapshot(activeStream: ActiveTerminalStream): void {
|
private async trySendSnapshot(activeStream: ActiveTerminalStream): Promise<void> {
|
||||||
if (this.activeStreams.get(activeStream.slot) !== activeStream || !activeStream.needsSnapshot) {
|
if (
|
||||||
|
this.activeStreams.get(activeStream.slot) !== activeStream ||
|
||||||
|
!activeStream.needsSnapshot ||
|
||||||
|
activeStream.snapshotInFlight
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminal = this.terminalManager?.getTerminal(activeStream.terminalId);
|
if (!this.terminalManager?.getTerminal(activeStream.terminalId)) {
|
||||||
if (!terminal) {
|
|
||||||
this.detachStream(activeStream.terminalId, { emitExit: true });
|
this.detachStream(activeStream.terminalId, { emitExit: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeStream.outputCoalescer.flush();
|
activeStream.outputCoalescer.flush();
|
||||||
activeStream.needsSnapshot = false;
|
activeStream.snapshotInFlight = true;
|
||||||
this.emitBinary(
|
try {
|
||||||
encodeTerminalStreamFrame({
|
const snapshot = await this.terminalManager.getTerminalState(activeStream.terminalId);
|
||||||
opcode: TerminalStreamOpcode.Snapshot,
|
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
|
||||||
slot: activeStream.slot,
|
return;
|
||||||
payload: encodeTerminalSnapshotPayload(terminal.getState()),
|
}
|
||||||
}),
|
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 {
|
private allocateSlot(): number | null {
|
||||||
@@ -699,6 +749,7 @@ export class TerminalSessionController {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
activeStream.outputCoalescer.flush();
|
activeStream.outputCoalescer.flush();
|
||||||
|
activeStream.bufferedOutputs.length = 0;
|
||||||
this.activeStreams.delete(slot);
|
this.activeStreams.delete(slot);
|
||||||
this.idToSlot.delete(terminalId);
|
this.idToSlot.delete(terminalId);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
|
|
||||||
const manager = createTerminalManager();
|
const manager = createTerminalManager();
|
||||||
const unsubscribeByTerminalId = new Map<string, Array<() => void>>();
|
const unsubscribeByTerminalId = new Map<string, Array<() => void>>();
|
||||||
const stateFlushTimeoutByTerminalId = new Map<string, ReturnType<typeof setTimeout>>();
|
|
||||||
let ipcClosing = false;
|
let ipcClosing = false;
|
||||||
|
|
||||||
function sendToParent(message: TerminalWorkerToParentMessage): void {
|
function sendToParent(message: TerminalWorkerToParentMessage): void {
|
||||||
@@ -37,11 +36,6 @@ function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearTerminalSubscriptions(terminalId: string): void {
|
function clearTerminalSubscriptions(terminalId: string): void {
|
||||||
const stateFlushTimeout = stateFlushTimeoutByTerminalId.get(terminalId);
|
|
||||||
if (stateFlushTimeout) {
|
|
||||||
clearTimeout(stateFlushTimeout);
|
|
||||||
stateFlushTimeoutByTerminalId.delete(terminalId);
|
|
||||||
}
|
|
||||||
const subscriptions = unsubscribeByTerminalId.get(terminalId);
|
const subscriptions = unsubscribeByTerminalId.get(terminalId);
|
||||||
if (subscriptions) {
|
if (subscriptions) {
|
||||||
for (const unsubscribe of subscriptions) {
|
for (const unsubscribe of subscriptions) {
|
||||||
@@ -55,40 +49,10 @@ function clearTerminalSubscriptions(terminalId: string): void {
|
|||||||
unsubscribeByTerminalId.delete(terminalId);
|
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 {
|
function watchTerminal(session: TerminalSession): void {
|
||||||
clearTerminalSubscriptions(session.id);
|
clearTerminalSubscriptions(session.id);
|
||||||
|
|
||||||
const unsubscribeMessage = session.subscribe((message) => {
|
const unsubscribeMessage = session.subscribe((message) => {
|
||||||
if (message.type === "output") {
|
|
||||||
scheduleTerminalStateFlush(session);
|
|
||||||
}
|
|
||||||
sendToParent({
|
sendToParent({
|
||||||
type: "terminalMessage",
|
type: "terminalMessage",
|
||||||
terminalId: session.id,
|
terminalId: session.id,
|
||||||
@@ -150,10 +114,11 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
|
|||||||
case "createTerminal": {
|
case "createTerminal": {
|
||||||
const session = await manager.createTerminal(message.options);
|
const session = await manager.createTerminal(message.options);
|
||||||
watchTerminal(session);
|
watchTerminal(session);
|
||||||
|
const initialSnapshot = session.getStateSnapshot();
|
||||||
sendToParent({
|
sendToParent({
|
||||||
type: "terminalCreated",
|
type: "terminalCreated",
|
||||||
terminal: toTerminalInfo(session),
|
terminal: toTerminalInfo(session),
|
||||||
state: session.getState(),
|
state: initialSnapshot.state,
|
||||||
});
|
});
|
||||||
sendToParent({
|
sendToParent({
|
||||||
type: "response",
|
type: "response",
|
||||||
@@ -161,7 +126,7 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
|
|||||||
ok: true,
|
ok: true,
|
||||||
result: {
|
result: {
|
||||||
terminal: toTerminalInfo(session),
|
terminal: toTerminalInfo(session),
|
||||||
state: session.getState(),
|
state: initialSnapshot.state,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -205,6 +170,17 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "getTerminalState": {
|
||||||
|
const session = manager.getTerminal(message.terminalId);
|
||||||
|
sendToParent({
|
||||||
|
type: "response",
|
||||||
|
requestId: message.requestId,
|
||||||
|
ok: true,
|
||||||
|
result: session?.getStateSnapshot() ?? null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
case "listDirectories": {
|
case "listDirectories": {
|
||||||
sendToParent({
|
sendToParent({
|
||||||
type: "response",
|
type: "response",
|
||||||
@@ -245,9 +221,6 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
|
|||||||
case "send": {
|
case "send": {
|
||||||
const session = manager.getTerminal(message.terminalId);
|
const session = manager.getTerminal(message.terminalId);
|
||||||
session?.send(message.message);
|
session?.send(message.message);
|
||||||
if (session && message.message.type === "resize") {
|
|
||||||
flushTerminalState(session);
|
|
||||||
}
|
|
||||||
sendToParent({ type: "response", requestId: message.requestId, ok: true });
|
sendToParent({ type: "response", requestId: message.requestId, ok: true });
|
||||||
return;
|
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 { TerminalState } from "../shared/messages.js";
|
||||||
import type { CaptureTerminalLinesResult } from "./terminal.js";
|
import type { CaptureTerminalLinesResult } from "./terminal.js";
|
||||||
|
|
||||||
@@ -52,6 +57,11 @@ export type TerminalWorkerRequest =
|
|||||||
terminalId: string;
|
terminalId: string;
|
||||||
options?: WorkerKillAndWaitOptions;
|
options?: WorkerKillAndWaitOptions;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "getTerminalState";
|
||||||
|
requestId: string;
|
||||||
|
terminalId: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "captureTerminal";
|
type: "captureTerminal";
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -105,11 +115,6 @@ export type TerminalWorkerEvent =
|
|||||||
terminalId: string;
|
terminalId: string;
|
||||||
message: ServerMessage;
|
message: ServerMessage;
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: "terminalStateUpdated";
|
|
||||||
terminalId: string;
|
|
||||||
state: TerminalState;
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
type: "terminalExit";
|
type: "terminalExit";
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
@@ -136,3 +141,4 @@ export type TerminalWorkerEvent =
|
|||||||
export type TerminalWorkerToParentMessage = TerminalWorkerResponse | TerminalWorkerEvent;
|
export type TerminalWorkerToParentMessage = TerminalWorkerResponse | TerminalWorkerEvent;
|
||||||
|
|
||||||
export type TerminalWorkerCaptureResult = CaptureTerminalLinesResult;
|
export type TerminalWorkerCaptureResult = CaptureTerminalLinesResult;
|
||||||
|
export type TerminalWorkerStateResult = TerminalStateSnapshot | null;
|
||||||
|
|||||||
@@ -27,14 +27,19 @@ export interface TerminalCommandFinishedInfo {
|
|||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TerminalStateSnapshot {
|
||||||
|
state: TerminalState;
|
||||||
|
revision: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: "input"; data: string }
|
| { type: "input"; data: string }
|
||||||
| { type: "resize"; rows: number; cols: number }
|
| { type: "resize"; rows: number; cols: number }
|
||||||
| { type: "mouse"; row: number; col: number; button: number; action: "down" | "up" | "move" };
|
| { type: "mouse"; row: number; col: number; button: number; action: "down" | "up" | "move" };
|
||||||
|
|
||||||
export type ServerMessage =
|
export type ServerMessage =
|
||||||
| { type: "output"; data: string }
|
| { type: "output"; data: string; revision?: number }
|
||||||
| { type: "snapshot"; state: TerminalState }
|
| { type: "snapshot"; state: TerminalState; revision?: number }
|
||||||
| { type: "titleChange"; title?: string };
|
| { type: "titleChange"; title?: string };
|
||||||
|
|
||||||
export interface TerminalSession {
|
export interface TerminalSession {
|
||||||
@@ -48,6 +53,7 @@ export interface TerminalSession {
|
|||||||
onTitleChange(listener: (title?: string) => void): () => void;
|
onTitleChange(listener: (title?: string) => void): () => void;
|
||||||
getSize(): { rows: number; cols: number };
|
getSize(): { rows: number; cols: number };
|
||||||
getState(): TerminalState;
|
getState(): TerminalState;
|
||||||
|
getStateSnapshot(): TerminalStateSnapshot;
|
||||||
getTitle(): string | undefined;
|
getTitle(): string | undefined;
|
||||||
getExitInfo(): TerminalExitInfo | null;
|
getExitInfo(): TerminalExitInfo | null;
|
||||||
kill(): void;
|
kill(): void;
|
||||||
@@ -605,6 +611,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let pendingInput = "";
|
let pendingInput = "";
|
||||||
let inputFlushImmediate: ReturnType<typeof setImmediate> | null = null;
|
let inputFlushImmediate: ReturnType<typeof setImmediate> | null = null;
|
||||||
|
let stateRevision = 0;
|
||||||
|
|
||||||
// Create xterm.js headless terminal
|
// Create xterm.js headless terminal
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
@@ -751,6 +758,18 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
titleChangeListeners.clear();
|
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
|
// Pipe PTY output to terminal emulator
|
||||||
ptyProcess.onData((data) => {
|
ptyProcess.onData((data) => {
|
||||||
if (killed) return;
|
if (killed) return;
|
||||||
@@ -758,14 +777,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
if (recentOutputText.length > TERMINAL_EXIT_OUTPUT_CHAR_LIMIT) {
|
if (recentOutputText.length > TERMINAL_EXIT_OUTPUT_CHAR_LIMIT) {
|
||||||
recentOutputText = recentOutputText.slice(-TERMINAL_EXIT_OUTPUT_CHAR_LIMIT);
|
recentOutputText = recentOutputText.slice(-TERMINAL_EXIT_OUTPUT_CHAR_LIMIT);
|
||||||
}
|
}
|
||||||
terminal.write(data, () => {
|
writeOutputToHeadless(data);
|
||||||
if (disposed || killed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const listener of listeners) {
|
|
||||||
listener({ type: "output", data });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ptyProcess.onExit((event) => {
|
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 } {
|
function getSize(): { rows: number; cols: number } {
|
||||||
return {
|
return {
|
||||||
rows: terminal.rows,
|
rows: terminal.rows,
|
||||||
@@ -846,6 +865,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
flushPendingInput();
|
flushPendingInput();
|
||||||
terminal.resize(msg.cols, msg.rows);
|
terminal.resize(msg.cols, msg.rows);
|
||||||
ptyProcess.resize(msg.cols, msg.rows);
|
ptyProcess.resize(msg.cols, msg.rows);
|
||||||
|
stateRevision += 1;
|
||||||
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
|
||||||
@@ -859,7 +879,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
|
|
||||||
terminal.write("", () => {
|
terminal.write("", () => {
|
||||||
if (!disposed && listeners.has(listener)) {
|
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,
|
onTitleChange,
|
||||||
getSize,
|
getSize,
|
||||||
getState,
|
getState,
|
||||||
|
getStateSnapshot,
|
||||||
getTitle,
|
getTitle,
|
||||||
getExitInfo,
|
getExitInfo,
|
||||||
kill,
|
kill,
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ function nodeTerminalCommand(script: string): { command: string; args: string[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function waitForCondition(
|
async function waitForCondition(
|
||||||
predicate: () => boolean,
|
predicate: () => boolean | Promise<boolean>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
intervalMs = 25,
|
intervalMs = 25,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
if (predicate()) {
|
if (await predicate()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||||
@@ -35,9 +35,12 @@ async function waitForCondition(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getVisibleText(session: TerminalSession): string {
|
function getVisibleText(session: TerminalSession): string {
|
||||||
return session
|
return getVisibleTextFromState(session.getState());
|
||||||
.getState()
|
}
|
||||||
.grid.map((row) =>
|
|
||||||
|
function getVisibleTextFromState(state: TerminalState): string {
|
||||||
|
return state.grid
|
||||||
|
.map((row) =>
|
||||||
row
|
row
|
||||||
.map((cell) => cell.char)
|
.map((cell) => cell.char)
|
||||||
.join("")
|
.join("")
|
||||||
@@ -173,7 +176,7 @@ it("creates a terminal through the worker and streams output", async () => {
|
|||||||
expect(snapshots).toBe(snapshotsBeforeOutput);
|
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-"));
|
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-state-"));
|
||||||
temporaryDirs.push(cwd);
|
temporaryDirs.push(cwd);
|
||||||
manager = createWorkerTerminalManager();
|
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 () => {
|
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" });
|
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.lines.join("\n")).toContain("hello world");
|
||||||
expect(capture.totalLines).toBeGreaterThan(0);
|
expect(capture.totalLines).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
TerminalCommandFinishedInfo,
|
TerminalCommandFinishedInfo,
|
||||||
TerminalExitInfo,
|
TerminalExitInfo,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
|
TerminalStateSnapshot,
|
||||||
} from "./terminal.js";
|
} from "./terminal.js";
|
||||||
import type {
|
import type {
|
||||||
TerminalListItem,
|
TerminalListItem,
|
||||||
@@ -21,6 +22,7 @@ import type {
|
|||||||
TerminalWorkerResponse,
|
TerminalWorkerResponse,
|
||||||
TerminalWorkerToParentMessage,
|
TerminalWorkerToParentMessage,
|
||||||
WorkerCreateTerminalOptions,
|
WorkerCreateTerminalOptions,
|
||||||
|
TerminalWorkerStateResult,
|
||||||
WorkerTerminalInfo,
|
WorkerTerminalInfo,
|
||||||
} from "./terminal-worker-protocol.js";
|
} from "./terminal-worker-protocol.js";
|
||||||
|
|
||||||
@@ -186,15 +188,17 @@ export function createWorkerTerminalManager(
|
|||||||
return record.info.cwd;
|
return record.info.cwd;
|
||||||
},
|
},
|
||||||
send(message: ClientMessage): void {
|
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 });
|
sendBestEffortRequest({ type: "send", terminalId: record.info.id, message });
|
||||||
},
|
},
|
||||||
subscribe(listener: (msg: ServerMessage) => void): () => void {
|
subscribe(listener: (msg: ServerMessage) => void): () => void {
|
||||||
record.messageListeners.add(listener);
|
record.messageListeners.add(listener);
|
||||||
queueMicrotask(() => {
|
|
||||||
if (record.messageListeners.has(listener)) {
|
|
||||||
listener({ type: "snapshot", state: record.state });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
return () => {
|
||||||
record.messageListeners.delete(listener);
|
record.messageListeners.delete(listener);
|
||||||
};
|
};
|
||||||
@@ -237,6 +241,12 @@ export function createWorkerTerminalManager(
|
|||||||
getState(): TerminalState {
|
getState(): TerminalState {
|
||||||
return record.state;
|
return record.state;
|
||||||
},
|
},
|
||||||
|
getStateSnapshot(): TerminalStateSnapshot {
|
||||||
|
return {
|
||||||
|
state: record.state,
|
||||||
|
revision: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
getTitle(): string | undefined {
|
getTitle(): string | undefined {
|
||||||
return record.info.title;
|
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(
|
function handleTerminalExitEvent(
|
||||||
message: Extract<TerminalWorkerToParentMessage, { type: "terminalExit" }>,
|
message: Extract<TerminalWorkerToParentMessage, { type: "terminalExit" }>,
|
||||||
): void {
|
): void {
|
||||||
@@ -400,11 +400,6 @@ export function createWorkerTerminalManager(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "terminalStateUpdated": {
|
|
||||||
handleTerminalStateUpdatedEvent(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "terminalExit": {
|
case "terminalExit": {
|
||||||
handleTerminalExitEvent(message);
|
handleTerminalExitEvent(message);
|
||||||
return;
|
return;
|
||||||
@@ -525,6 +520,13 @@ export function createWorkerTerminalManager(
|
|||||||
return recordsById.get(id)?.session;
|
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 {
|
killTerminal(id: string): void {
|
||||||
void sendRequest({ type: "killTerminal", terminalId: id }).catch(() => {
|
void sendRequest({ type: "killTerminal", terminalId: id }).catch(() => {
|
||||||
// no-op; kill is intentionally best-effort and synchronous in the public interface.
|
// no-op; kill is intentionally best-effort and synchronous in the public interface.
|
||||||
|
|||||||
Reference in New Issue
Block a user