fix: smooth terminal streaming
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { createTempGitRepo } from "./workspace";
|
||||||
|
import {
|
||||||
|
connectTerminalClient,
|
||||||
|
navigateToTerminal,
|
||||||
|
setupDeterministicPrompt,
|
||||||
|
type TerminalPerfDaemonClient,
|
||||||
|
} from "./terminal-perf";
|
||||||
|
|
||||||
|
type TempRepo = {
|
||||||
|
path: string;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalInstance = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cwd: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TerminalE2EHarness {
|
||||||
|
readonly client: TerminalPerfDaemonClient;
|
||||||
|
readonly tempRepo: TempRepo;
|
||||||
|
readonly workspaceId: string;
|
||||||
|
|
||||||
|
private constructor(input: {
|
||||||
|
client: TerminalPerfDaemonClient;
|
||||||
|
tempRepo: TempRepo;
|
||||||
|
workspaceId: string;
|
||||||
|
}) {
|
||||||
|
this.client = input.client;
|
||||||
|
this.tempRepo = input.tempRepo;
|
||||||
|
this.workspaceId = input.workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
|
||||||
|
const tempRepo = await createTempGitRepo(input.tempPrefix);
|
||||||
|
const client = await connectTerminalClient();
|
||||||
|
const seedResult = await client.openProject(tempRepo.path);
|
||||||
|
if (!seedResult.workspace) {
|
||||||
|
await client.close().catch(() => {});
|
||||||
|
await tempRepo.cleanup().catch(() => {});
|
||||||
|
throw new Error(seedResult.error ?? "Failed to seed workspace");
|
||||||
|
}
|
||||||
|
return new TerminalE2EHarness({
|
||||||
|
client,
|
||||||
|
tempRepo,
|
||||||
|
workspaceId: seedResult.workspace.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
await this.client.close().catch(() => {});
|
||||||
|
await this.tempRepo.cleanup().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTerminal(input: { name: string }): Promise<TerminalInstance> {
|
||||||
|
const result = await this.client.createTerminal(this.tempRepo.path, input.name);
|
||||||
|
if (!result.terminal) {
|
||||||
|
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
}
|
||||||
|
return result.terminal;
|
||||||
|
}
|
||||||
|
|
||||||
|
async killTerminal(terminalId: string): Promise<void> {
|
||||||
|
await this.client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async openTerminal(page: Page, input: { terminalId: string }): Promise<void> {
|
||||||
|
await navigateToTerminal(page, {
|
||||||
|
workspaceId: this.workspaceId,
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalSurface(page: Page) {
|
||||||
|
return page.locator('[data-testid="terminal-surface"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupPrompt(page: Page, sentinel?: string): Promise<void> {
|
||||||
|
await setupDeterministicPrompt(page, sentinel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withTerminalInApp<T>(
|
||||||
|
page: Page,
|
||||||
|
harness: TerminalE2EHarness,
|
||||||
|
input: { name: string },
|
||||||
|
fn: (terminal: TerminalInstance) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const terminal = await harness.createTerminal({ name: input.name });
|
||||||
|
try {
|
||||||
|
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||||
|
return await fn(terminal);
|
||||||
|
} finally {
|
||||||
|
await harness.killTerminal(terminal.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export type TerminalRenderProbeSnapshot = {
|
||||||
|
setCount: number;
|
||||||
|
unsetCount: number;
|
||||||
|
writeCount: number;
|
||||||
|
resetWrites: number;
|
||||||
|
clearScreenWrites: number;
|
||||||
|
altEnterWrites: number;
|
||||||
|
altExitWrites: number;
|
||||||
|
events: TerminalRenderProbeEvent[];
|
||||||
|
frames: TerminalFrame[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalRenderProbeEvent = {
|
||||||
|
at: number;
|
||||||
|
type: "set" | "unset" | "reset-write" | "clear-write" | "alt-enter-write" | "alt-exit-write";
|
||||||
|
preview?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalFrame = {
|
||||||
|
at: number;
|
||||||
|
rowCount: number;
|
||||||
|
nonEmptyRows: number;
|
||||||
|
firstNonEmptyRow: number | null;
|
||||||
|
text: string;
|
||||||
|
topText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalRenderProbeSummary = Omit<TerminalRenderProbeSnapshot, "frames"> & {
|
||||||
|
frameCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalKeystrokeStressReport = {
|
||||||
|
inputTextLength: number;
|
||||||
|
keydownCount: number;
|
||||||
|
inputFrameCount: number;
|
||||||
|
outputFrameCount: number;
|
||||||
|
textMessageFrameCount: number;
|
||||||
|
textMessagePayloadBytes: number;
|
||||||
|
largeTextMessageCount: number;
|
||||||
|
largestTextMessageBytes: number;
|
||||||
|
agentStreamTextMessageCount: number;
|
||||||
|
agentStreamTextMessagePayloadBytes: number;
|
||||||
|
largeAgentStreamTextMessageCount: number;
|
||||||
|
largestAgentStreamTextMessageBytes: number;
|
||||||
|
appEventCount: number;
|
||||||
|
appEventCounts: Record<string, number>;
|
||||||
|
runtimeMaxQueueDepth: number;
|
||||||
|
xtermWriteCount: number;
|
||||||
|
inputFramePayloadBytes: number;
|
||||||
|
outputFramePayloadBytes: number;
|
||||||
|
keydownToInputFrameMs: LatencyStats | null;
|
||||||
|
inputFrameToOutputFrameMs: LatencyStats | null;
|
||||||
|
appBinaryReceivedToFrameDecodedMs: LatencyStats | null;
|
||||||
|
appFrameDecodedToTerminalEmitMs: LatencyStats | null;
|
||||||
|
appTerminalEmitListenerDurationMs: LatencyStats | null;
|
||||||
|
appTerminalEmitToStreamControllerOutputMs: LatencyStats | null;
|
||||||
|
appStreamControllerDecodeToOnOutputMs: LatencyStats | null;
|
||||||
|
appStreamControllerToEmulatorWriteMs: LatencyStats | null;
|
||||||
|
appEmulatorWriteToRuntimeEnqueuedMs: LatencyStats | null;
|
||||||
|
appRuntimeEnqueuedToOperationStartMs: LatencyStats | null;
|
||||||
|
appRuntimeOperationStartToXtermWriteMs: LatencyStats | null;
|
||||||
|
appRuntimeXtermWriteToCommitMs: LatencyStats | null;
|
||||||
|
appBinaryReceivedToRuntimeEnqueuedMs: LatencyStats | null;
|
||||||
|
appBinaryReceivedToRuntimeOperationStartMs: LatencyStats | null;
|
||||||
|
appBinaryReceivedToXtermCommitMs: LatencyStats | null;
|
||||||
|
outputFrameToXtermWriteMs: LatencyStats | null;
|
||||||
|
xtermWriteDurationMs: LatencyStats | null;
|
||||||
|
keydownToXtermCommitMs: LatencyStats | null;
|
||||||
|
firstKeydownAt: number | null;
|
||||||
|
lastXtermCommitAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LatencyStats = {
|
||||||
|
count: number;
|
||||||
|
minMs: number;
|
||||||
|
p50Ms: number;
|
||||||
|
p95Ms: number;
|
||||||
|
maxMs: number;
|
||||||
|
avgMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function installTerminalRenderProbe(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
type ProbeState = {
|
||||||
|
term: any;
|
||||||
|
setCount: number;
|
||||||
|
unsetCount: number;
|
||||||
|
writeCount: number;
|
||||||
|
resetWrites: number;
|
||||||
|
clearScreenWrites: number;
|
||||||
|
altEnterWrites: number;
|
||||||
|
altExitWrites: number;
|
||||||
|
events: TerminalRenderProbeEvent[];
|
||||||
|
frames: TerminalFrame[];
|
||||||
|
rafId: number | null;
|
||||||
|
sampleUntil: number;
|
||||||
|
reset: () => void;
|
||||||
|
snapshot: () => TerminalRenderProbeSnapshot;
|
||||||
|
startSampling: (durationMs: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const win = window as any;
|
||||||
|
const existingDescriptor = Object.getOwnPropertyDescriptor(win, "__paseoTerminal");
|
||||||
|
const getExisting = () =>
|
||||||
|
existingDescriptor?.get ? existingDescriptor.get.call(win) : existingDescriptor?.value;
|
||||||
|
|
||||||
|
const probe: ProbeState = {
|
||||||
|
term: getExisting(),
|
||||||
|
setCount: 0,
|
||||||
|
unsetCount: 0,
|
||||||
|
writeCount: 0,
|
||||||
|
resetWrites: 0,
|
||||||
|
clearScreenWrites: 0,
|
||||||
|
altEnterWrites: 0,
|
||||||
|
altExitWrites: 0,
|
||||||
|
events: [],
|
||||||
|
frames: [],
|
||||||
|
rafId: null,
|
||||||
|
sampleUntil: 0,
|
||||||
|
reset() {
|
||||||
|
this.setCount = 0;
|
||||||
|
this.unsetCount = 0;
|
||||||
|
this.writeCount = 0;
|
||||||
|
this.resetWrites = 0;
|
||||||
|
this.clearScreenWrites = 0;
|
||||||
|
this.altEnterWrites = 0;
|
||||||
|
this.altExitWrites = 0;
|
||||||
|
this.events = [];
|
||||||
|
this.frames = [];
|
||||||
|
},
|
||||||
|
snapshot() {
|
||||||
|
return {
|
||||||
|
setCount: this.setCount,
|
||||||
|
unsetCount: this.unsetCount,
|
||||||
|
writeCount: this.writeCount,
|
||||||
|
resetWrites: this.resetWrites,
|
||||||
|
clearScreenWrites: this.clearScreenWrites,
|
||||||
|
altEnterWrites: this.altEnterWrites,
|
||||||
|
altExitWrites: this.altExitWrites,
|
||||||
|
events: this.events,
|
||||||
|
frames: this.frames,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
startSampling(durationMs: number) {
|
||||||
|
this.sampleUntil = performance.now() + durationMs;
|
||||||
|
if (this.rafId !== null) {
|
||||||
|
cancelAnimationFrame(this.rafId);
|
||||||
|
}
|
||||||
|
const sample = () => {
|
||||||
|
const rows = Array.from(document.querySelectorAll(".xterm-rows > div")).map(
|
||||||
|
(row) => row.textContent ?? "",
|
||||||
|
);
|
||||||
|
const nonEmptyRows = rows.filter((row) => row.trim().length > 0);
|
||||||
|
const firstNonEmptyRow = rows.findIndex((row) => row.trim().length > 0);
|
||||||
|
this.frames.push({
|
||||||
|
at: performance.now(),
|
||||||
|
rowCount: rows.length,
|
||||||
|
nonEmptyRows: nonEmptyRows.length,
|
||||||
|
firstNonEmptyRow: firstNonEmptyRow === -1 ? null : firstNonEmptyRow,
|
||||||
|
text: rows.join("\n"),
|
||||||
|
topText: rows.slice(0, 3).join("\n"),
|
||||||
|
});
|
||||||
|
if (performance.now() < this.sampleUntil) {
|
||||||
|
this.rafId = requestAnimationFrame(sample);
|
||||||
|
} else {
|
||||||
|
this.rafId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.rafId = requestAnimationFrame(sample);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(win, "__terminalRenderProbe", {
|
||||||
|
configurable: true,
|
||||||
|
value: probe,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(win, "__paseoTerminal", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return probe.term;
|
||||||
|
},
|
||||||
|
set(next: any) {
|
||||||
|
if (next === undefined) {
|
||||||
|
probe.unsetCount += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "unset" });
|
||||||
|
probe.term = next;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
probe.setCount += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "set" });
|
||||||
|
probe.term = next;
|
||||||
|
|
||||||
|
if (next?.write && !next.__paseoRenderProbeWriteWrapped) {
|
||||||
|
const originalWrite = next.write.bind(next);
|
||||||
|
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||||
|
const text =
|
||||||
|
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
||||||
|
probe.writeCount += 1;
|
||||||
|
const preview = text
|
||||||
|
.replace(/\u001b/g, "\\x1b")
|
||||||
|
.replace(/\r/g, "\\r")
|
||||||
|
.replace(/\n/g, "\\n")
|
||||||
|
.slice(0, 160);
|
||||||
|
if (text.includes("\u001bc")) {
|
||||||
|
probe.resetWrites += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "reset-write", preview });
|
||||||
|
}
|
||||||
|
if (text.includes("\u001b[2J")) {
|
||||||
|
probe.clearScreenWrites += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "clear-write", preview });
|
||||||
|
}
|
||||||
|
if (text.includes("\u001b[?1049h")) {
|
||||||
|
probe.altEnterWrites += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "alt-enter-write", preview });
|
||||||
|
}
|
||||||
|
if (text.includes("\u001b[?1049l")) {
|
||||||
|
probe.altExitWrites += 1;
|
||||||
|
probe.events.push({ at: performance.now(), type: "alt-exit-write", preview });
|
||||||
|
}
|
||||||
|
return originalWrite(data, callback);
|
||||||
|
};
|
||||||
|
next.__paseoRenderProbeWriteWrapped = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetTerminalRenderProbe(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).__terminalRenderProbe.reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startTerminalFrameSampling(page: Page, durationMs = 2500): Promise<void> {
|
||||||
|
await page.evaluate((ms) => {
|
||||||
|
(window as any).__terminalRenderProbe.startSampling(ms);
|
||||||
|
}, durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readTerminalRenderProbe(page: Page): Promise<TerminalRenderProbeSnapshot> {
|
||||||
|
return page.evaluate(() => (window as any).__terminalRenderProbe.snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function terminalVisibleText(page: Page): Promise<string> {
|
||||||
|
return page.evaluate(() =>
|
||||||
|
Array.from(document.querySelectorAll(".xterm-rows > div"))
|
||||||
|
.map((row) => row.textContent ?? "")
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeTerminalRenderProbe(
|
||||||
|
probe: TerminalRenderProbeSnapshot,
|
||||||
|
): TerminalRenderProbeSummary {
|
||||||
|
return {
|
||||||
|
setCount: probe.setCount,
|
||||||
|
unsetCount: probe.unsetCount,
|
||||||
|
writeCount: probe.writeCount,
|
||||||
|
resetWrites: probe.resetWrites,
|
||||||
|
clearScreenWrites: probe.clearScreenWrites,
|
||||||
|
altEnterWrites: probe.altEnterWrites,
|
||||||
|
altExitWrites: probe.altExitWrites,
|
||||||
|
events: probe.events,
|
||||||
|
frameCount: probe.frames.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installTerminalKeystrokeStressProbe(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
type TimedTextEvent = {
|
||||||
|
at: number;
|
||||||
|
text: string;
|
||||||
|
bytes: number;
|
||||||
|
};
|
||||||
|
type TimedTextMessageEvent = {
|
||||||
|
at: number;
|
||||||
|
bytes: number;
|
||||||
|
kind: string | null;
|
||||||
|
};
|
||||||
|
type XtermWriteEvent = {
|
||||||
|
at: number;
|
||||||
|
committedAt: number | null;
|
||||||
|
text: string;
|
||||||
|
bytes: number;
|
||||||
|
};
|
||||||
|
type AppProbeEvent = {
|
||||||
|
type: string;
|
||||||
|
at: number;
|
||||||
|
bytes?: number;
|
||||||
|
queueDepth?: number;
|
||||||
|
};
|
||||||
|
type StressProbeState = {
|
||||||
|
keydowns: Array<{ at: number; key: string }>;
|
||||||
|
inputFrames: TimedTextEvent[];
|
||||||
|
outputFrames: TimedTextEvent[];
|
||||||
|
textMessageFrames: TimedTextMessageEvent[];
|
||||||
|
xtermWrites: XtermWriteEvent[];
|
||||||
|
appEvents: AppProbeEvent[];
|
||||||
|
reset: () => void;
|
||||||
|
report: (inputText: string) => TerminalKeystrokeStressReport;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INPUT_OPCODE = 0x02;
|
||||||
|
const OUTPUT_OPCODE = 0x01;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
function bytesFrom(data: unknown): Uint8Array | null {
|
||||||
|
if (data instanceof Uint8Array) return data;
|
||||||
|
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
||||||
|
if (ArrayBuffer.isView(data)) {
|
||||||
|
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDataBytes(data: unknown): Promise<Uint8Array | null> {
|
||||||
|
const bytes = bytesFrom(data);
|
||||||
|
if (bytes) return Promise.resolve(bytes);
|
||||||
|
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||||
|
return data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameText(bytes: Uint8Array): string {
|
||||||
|
return decoder.decode(bytes.slice(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDataText(data: unknown): Promise<string | null> {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
return Promise.resolve(data);
|
||||||
|
}
|
||||||
|
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||||
|
return data.text();
|
||||||
|
}
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textMessageKind(text: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as {
|
||||||
|
type?: unknown;
|
||||||
|
message?: {
|
||||||
|
type?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (typeof parsed.type !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof parsed.message?.type === "string") {
|
||||||
|
return `${parsed.type}:${parsed.message.type}`;
|
||||||
|
}
|
||||||
|
return parsed.type;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(values: number[]): LatencyStats | null {
|
||||||
|
if (values.length === 0) return null;
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const percentile = (p: number) => {
|
||||||
|
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||||
|
return sorted[Math.max(0, Math.min(sorted.length - 1, index))];
|
||||||
|
};
|
||||||
|
const total = values.reduce((sum, value) => sum + value, 0);
|
||||||
|
const round2 = (value: number) => Math.round(value * 100) / 100;
|
||||||
|
return {
|
||||||
|
count: values.length,
|
||||||
|
minMs: round2(sorted[0] ?? 0),
|
||||||
|
p50Ms: round2(percentile(50)),
|
||||||
|
p95Ms: round2(percentile(95)),
|
||||||
|
maxMs: round2(sorted[sorted.length - 1] ?? 0),
|
||||||
|
avgMs: round2(total / values.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstAtOrAfter<T extends { at: number }>(events: T[], at: number): T | null {
|
||||||
|
return events.find((event) => event.at >= at) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCommitAtOrAfter(events: XtermWriteEvent[], at: number): XtermWriteEvent | null {
|
||||||
|
return (
|
||||||
|
events.find((event) => typeof event.committedAt === "number" && event.committedAt >= at) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countByType(events: AppProbeEvent[]): Record<string, number> {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const event of events) {
|
||||||
|
counts[event.type] = (counts[event.type] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appEventsOf(type: string, events: AppProbeEvent[]): AppProbeEvent[] {
|
||||||
|
return events.filter((event) => event.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function latencyByIndex(from: AppProbeEvent[], to: AppProbeEvent[]): number[] {
|
||||||
|
const count = Math.min(from.length, to.length);
|
||||||
|
const values: number[] = [];
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
values.push(to[index]!.at - from[index]!.at);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
const probe: StressProbeState = {
|
||||||
|
keydowns: [],
|
||||||
|
inputFrames: [],
|
||||||
|
outputFrames: [],
|
||||||
|
textMessageFrames: [],
|
||||||
|
xtermWrites: [],
|
||||||
|
appEvents: [],
|
||||||
|
reset() {
|
||||||
|
this.keydowns = [];
|
||||||
|
this.inputFrames = [];
|
||||||
|
this.outputFrames = [];
|
||||||
|
this.textMessageFrames = [];
|
||||||
|
this.xtermWrites = [];
|
||||||
|
this.appEvents = [];
|
||||||
|
},
|
||||||
|
report(inputText: string) {
|
||||||
|
const binaryReceived = appEventsOf("daemon-client-binary-received", this.appEvents);
|
||||||
|
const frameDecoded = appEventsOf("daemon-client-frame-decoded", this.appEvents);
|
||||||
|
const terminalEmit = appEventsOf("daemon-client-terminal-emit", this.appEvents);
|
||||||
|
const terminalEmitted = appEventsOf("daemon-client-terminal-emitted", this.appEvents);
|
||||||
|
const streamControllerOutput = appEventsOf("stream-controller-output", this.appEvents);
|
||||||
|
const streamControllerOnOutput = appEventsOf("stream-controller-on-output", this.appEvents);
|
||||||
|
const emulatorWriteOutput = appEventsOf("terminal-emulator-write-output", this.appEvents);
|
||||||
|
const runtimeWriteEnqueued = appEventsOf("runtime-write-enqueued", this.appEvents);
|
||||||
|
const runtimeOperationStart = appEventsOf("runtime-operation-start", this.appEvents);
|
||||||
|
const runtimeXtermWrite = appEventsOf("runtime-xterm-write", this.appEvents);
|
||||||
|
const runtimeXtermCommitted = appEventsOf("runtime-xterm-committed", this.appEvents);
|
||||||
|
const keydownToInputFrame = this.keydowns
|
||||||
|
.map((keydown) => firstAtOrAfter(this.inputFrames, keydown.at)?.at ?? null)
|
||||||
|
.filter((at): at is number => at !== null)
|
||||||
|
.map((at, index) => at - this.keydowns[index]!.at);
|
||||||
|
const inputFrameToOutputFrame = this.inputFrames
|
||||||
|
.map((input) => {
|
||||||
|
const output = firstAtOrAfter(this.outputFrames, input.at);
|
||||||
|
return output ? output.at - input.at : null;
|
||||||
|
})
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
const outputFrameToXtermWrite = this.outputFrames
|
||||||
|
.map((output) => {
|
||||||
|
const write = firstAtOrAfter(this.xtermWrites, output.at);
|
||||||
|
return write ? write.at - output.at : null;
|
||||||
|
})
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
const xtermWriteDurations = this.xtermWrites
|
||||||
|
.map((write) => (write.committedAt === null ? null : write.committedAt - write.at))
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
const keydownToXtermCommit = this.keydowns
|
||||||
|
.map((keydown) => {
|
||||||
|
const write = firstCommitAtOrAfter(this.xtermWrites, keydown.at);
|
||||||
|
return write?.committedAt ? write.committedAt - keydown.at : null;
|
||||||
|
})
|
||||||
|
.filter((value): value is number => value !== null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputTextLength: inputText.length,
|
||||||
|
keydownCount: this.keydowns.length,
|
||||||
|
inputFrameCount: this.inputFrames.length,
|
||||||
|
outputFrameCount: this.outputFrames.length,
|
||||||
|
textMessageFrameCount: this.textMessageFrames.length,
|
||||||
|
textMessagePayloadBytes: this.textMessageFrames.reduce(
|
||||||
|
(sum, frame) => sum + frame.bytes,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
largeTextMessageCount: this.textMessageFrames.filter((frame) => frame.bytes >= 50_000)
|
||||||
|
.length,
|
||||||
|
largestTextMessageBytes: Math.max(
|
||||||
|
0,
|
||||||
|
...this.textMessageFrames.map((frame) => frame.bytes),
|
||||||
|
),
|
||||||
|
agentStreamTextMessageCount: this.textMessageFrames.filter(
|
||||||
|
(frame) => frame.kind === "session:agent_stream",
|
||||||
|
).length,
|
||||||
|
agentStreamTextMessagePayloadBytes: this.textMessageFrames
|
||||||
|
.filter((frame) => frame.kind === "session:agent_stream")
|
||||||
|
.reduce((sum, frame) => sum + frame.bytes, 0),
|
||||||
|
largeAgentStreamTextMessageCount: this.textMessageFrames.filter(
|
||||||
|
(frame) => frame.kind === "session:agent_stream" && frame.bytes >= 50_000,
|
||||||
|
).length,
|
||||||
|
largestAgentStreamTextMessageBytes: Math.max(
|
||||||
|
0,
|
||||||
|
...this.textMessageFrames
|
||||||
|
.filter((frame) => frame.kind === "session:agent_stream")
|
||||||
|
.map((frame) => frame.bytes),
|
||||||
|
),
|
||||||
|
appEventCount: this.appEvents.length,
|
||||||
|
appEventCounts: countByType(this.appEvents),
|
||||||
|
runtimeMaxQueueDepth: Math.max(
|
||||||
|
0,
|
||||||
|
...this.appEvents
|
||||||
|
.map((event) => event.queueDepth)
|
||||||
|
.filter((value): value is number => typeof value === "number"),
|
||||||
|
),
|
||||||
|
xtermWriteCount: this.xtermWrites.length,
|
||||||
|
inputFramePayloadBytes: this.inputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
|
||||||
|
outputFramePayloadBytes: this.outputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
|
||||||
|
keydownToInputFrameMs: summarize(keydownToInputFrame),
|
||||||
|
inputFrameToOutputFrameMs: summarize(inputFrameToOutputFrame),
|
||||||
|
appBinaryReceivedToFrameDecodedMs: summarize(
|
||||||
|
latencyByIndex(binaryReceived, frameDecoded),
|
||||||
|
),
|
||||||
|
appFrameDecodedToTerminalEmitMs: summarize(latencyByIndex(frameDecoded, terminalEmit)),
|
||||||
|
appTerminalEmitListenerDurationMs: summarize(
|
||||||
|
latencyByIndex(terminalEmit, terminalEmitted),
|
||||||
|
),
|
||||||
|
appTerminalEmitToStreamControllerOutputMs: summarize(
|
||||||
|
latencyByIndex(terminalEmit, streamControllerOutput),
|
||||||
|
),
|
||||||
|
appStreamControllerDecodeToOnOutputMs: summarize(
|
||||||
|
latencyByIndex(streamControllerOutput, streamControllerOnOutput),
|
||||||
|
),
|
||||||
|
appStreamControllerToEmulatorWriteMs: summarize(
|
||||||
|
latencyByIndex(streamControllerOnOutput, emulatorWriteOutput),
|
||||||
|
),
|
||||||
|
appEmulatorWriteToRuntimeEnqueuedMs: summarize(
|
||||||
|
latencyByIndex(emulatorWriteOutput, runtimeWriteEnqueued),
|
||||||
|
),
|
||||||
|
appRuntimeEnqueuedToOperationStartMs: summarize(
|
||||||
|
latencyByIndex(runtimeWriteEnqueued, runtimeOperationStart),
|
||||||
|
),
|
||||||
|
appRuntimeOperationStartToXtermWriteMs: summarize(
|
||||||
|
latencyByIndex(runtimeOperationStart, runtimeXtermWrite),
|
||||||
|
),
|
||||||
|
appRuntimeXtermWriteToCommitMs: summarize(
|
||||||
|
latencyByIndex(runtimeXtermWrite, runtimeXtermCommitted),
|
||||||
|
),
|
||||||
|
appBinaryReceivedToRuntimeEnqueuedMs: summarize(
|
||||||
|
latencyByIndex(binaryReceived, runtimeWriteEnqueued),
|
||||||
|
),
|
||||||
|
appBinaryReceivedToRuntimeOperationStartMs: summarize(
|
||||||
|
latencyByIndex(binaryReceived, runtimeOperationStart),
|
||||||
|
),
|
||||||
|
appBinaryReceivedToXtermCommitMs: summarize(
|
||||||
|
latencyByIndex(binaryReceived, runtimeXtermCommitted),
|
||||||
|
),
|
||||||
|
outputFrameToXtermWriteMs: summarize(outputFrameToXtermWrite),
|
||||||
|
xtermWriteDurationMs: summarize(xtermWriteDurations),
|
||||||
|
keydownToXtermCommitMs: summarize(keydownToXtermCommit),
|
||||||
|
firstKeydownAt: this.keydowns[0]?.at ?? null,
|
||||||
|
lastXtermCommitAt:
|
||||||
|
this.xtermWrites
|
||||||
|
.map((write) => write.committedAt)
|
||||||
|
.filter((at): at is number => typeof at === "number")
|
||||||
|
.at(-1) ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(window, "__terminalKeystrokeStressProbe", {
|
||||||
|
configurable: true,
|
||||||
|
value: probe,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"keydown",
|
||||||
|
(event) => {
|
||||||
|
if (event.key.length === 1) {
|
||||||
|
probe.keydowns.push({ at: performance.now(), key: event.key });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const NativeWebSocket = window.WebSocket;
|
||||||
|
class InstrumentedWebSocket extends NativeWebSocket {
|
||||||
|
constructor(url: string | URL, protocols?: string | string[]) {
|
||||||
|
if (protocols === undefined) {
|
||||||
|
super(url);
|
||||||
|
} else {
|
||||||
|
super(url, protocols);
|
||||||
|
}
|
||||||
|
super.addEventListener("message", (event) => {
|
||||||
|
void eventDataBytes(event.data).then((bytes) => {
|
||||||
|
if (!bytes || bytes.byteLength < 2 || bytes[0] !== OUTPUT_OPCODE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
probe.outputFrames.push({
|
||||||
|
at: performance.now(),
|
||||||
|
text: frameText(bytes),
|
||||||
|
bytes: bytes.byteLength - 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
void eventDataText(event.data).then((text) => {
|
||||||
|
if (text === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
probe.textMessageFrames.push({
|
||||||
|
at: performance.now(),
|
||||||
|
bytes: new TextEncoder().encode(text).byteLength,
|
||||||
|
kind: textMessageKind(text),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
|
||||||
|
const bytes = bytesFrom(data);
|
||||||
|
if (bytes && bytes.byteLength >= 2 && bytes[0] === INPUT_OPCODE) {
|
||||||
|
probe.inputFrames.push({
|
||||||
|
at: performance.now(),
|
||||||
|
text: frameText(bytes),
|
||||||
|
bytes: bytes.byteLength - 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
super.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(InstrumentedWebSocket, "CONNECTING", {
|
||||||
|
value: NativeWebSocket.CONNECTING,
|
||||||
|
});
|
||||||
|
Object.defineProperty(InstrumentedWebSocket, "OPEN", { value: NativeWebSocket.OPEN });
|
||||||
|
Object.defineProperty(InstrumentedWebSocket, "CLOSING", { value: NativeWebSocket.CLOSING });
|
||||||
|
Object.defineProperty(InstrumentedWebSocket, "CLOSED", { value: NativeWebSocket.CLOSED });
|
||||||
|
window.WebSocket = InstrumentedWebSocket as typeof WebSocket;
|
||||||
|
|
||||||
|
const existingDescriptor = Object.getOwnPropertyDescriptor(window, "__paseoTerminal");
|
||||||
|
const getExisting = () =>
|
||||||
|
existingDescriptor?.get ? existingDescriptor.get.call(window) : existingDescriptor?.value;
|
||||||
|
|
||||||
|
let terminal = getExisting();
|
||||||
|
Object.defineProperty(window, "__paseoTerminal", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return terminal;
|
||||||
|
},
|
||||||
|
set(next: any) {
|
||||||
|
terminal = next;
|
||||||
|
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
|
||||||
|
const originalWrite = next.write.bind(next);
|
||||||
|
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||||
|
const text =
|
||||||
|
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
||||||
|
const event: XtermWriteEvent = {
|
||||||
|
at: performance.now(),
|
||||||
|
committedAt: null,
|
||||||
|
text,
|
||||||
|
bytes: text.length,
|
||||||
|
};
|
||||||
|
probe.xtermWrites.push(event);
|
||||||
|
return originalWrite(data, () => {
|
||||||
|
event.committedAt = performance.now();
|
||||||
|
callback?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
next.__paseoKeystrokeProbeWriteWrapped = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetTerminalKeystrokeStressProbe(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).__terminalKeystrokeStressProbe.reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readTerminalKeystrokeStressReport(
|
||||||
|
page: Page,
|
||||||
|
inputText: string,
|
||||||
|
): Promise<TerminalKeystrokeStressReport> {
|
||||||
|
return page.evaluate(
|
||||||
|
(text) => (window as any).__terminalKeystrokeStressProbe.report(text),
|
||||||
|
inputText,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
|
||||||
|
import {
|
||||||
|
installTerminalRenderProbe,
|
||||||
|
readTerminalRenderProbe,
|
||||||
|
resetTerminalRenderProbe,
|
||||||
|
startTerminalFrameSampling,
|
||||||
|
summarizeTerminalRenderProbe,
|
||||||
|
terminalVisibleText,
|
||||||
|
} from "./helpers/terminal-probes";
|
||||||
|
import { waitForTerminalContent } from "./helpers/terminal-perf";
|
||||||
|
|
||||||
|
test.describe("Terminal alternate-screen transitions", () => {
|
||||||
|
let harness: TerminalE2EHarness;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-alt-" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await harness?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restores the normal screen after full-screen alternate buffer exit without remounting", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
await installTerminalRenderProbe(page);
|
||||||
|
|
||||||
|
await withTerminalInApp(page, harness, { name: "alternate-screen" }, async () => {
|
||||||
|
await harness.setupPrompt(page);
|
||||||
|
|
||||||
|
const terminal = harness.terminalSurface(page);
|
||||||
|
const historyReady = `HISTORY_READY_${Date.now()}`;
|
||||||
|
await terminal.pressSequentially(
|
||||||
|
`for i in $(seq 1 80); do echo HISTORY_$i; done; echo ${historyReady}\n`,
|
||||||
|
{ delay: 0 },
|
||||||
|
);
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(historyReady), 10_000);
|
||||||
|
|
||||||
|
await resetTerminalRenderProbe(page);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const settledProbe = await readTerminalRenderProbe(page);
|
||||||
|
expect(settledProbe.resetWrites, "terminal should be idle before alternate-screen act").toBe(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await resetTerminalRenderProbe(page);
|
||||||
|
|
||||||
|
const afterAlt = `AFTER_ALT_${Date.now()}`;
|
||||||
|
await startTerminalFrameSampling(page);
|
||||||
|
await terminal.pressSequentially(
|
||||||
|
`printf '\\033[?1049h\\033[2J\\033[HALT_SCREEN_TOP\\n'; sleep 0.25; printf '\\033[?1049l'; echo ${afterAlt}\n`,
|
||||||
|
{ delay: 0 },
|
||||||
|
);
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(afterAlt), 10_000);
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
const probe = await readTerminalRenderProbe(page);
|
||||||
|
const probeSummary = summarizeTerminalRenderProbe(probe);
|
||||||
|
|
||||||
|
await testInfo.attach("alternate-screen-probe", {
|
||||||
|
body: JSON.stringify({ summary: probeSummary, probe }, null, 2),
|
||||||
|
contentType: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(probe.setCount, "terminal instance should not be replaced after attach").toBe(0);
|
||||||
|
expect(probe.unsetCount, "terminal instance should not be unset after attach").toBe(0);
|
||||||
|
expect(
|
||||||
|
probe.altEnterWrites,
|
||||||
|
"test command should enter the alternate screen",
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
expect(probe.altExitWrites, "test command should exit the alternate screen").toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(probe.resetWrites, "alternate-screen exit should not replay a snapshot reset").toBe(0);
|
||||||
|
|
||||||
|
const finalText = await terminalVisibleText(page);
|
||||||
|
expect(finalText).toContain(historyReady);
|
||||||
|
expect(finalText).toContain(afterAlt);
|
||||||
|
|
||||||
|
const suspiciousFrames = probe.frames.filter(
|
||||||
|
(frame) =>
|
||||||
|
frame.text.includes("$") &&
|
||||||
|
!frame.text.includes(historyReady) &&
|
||||||
|
!frame.text.includes(afterAlt) &&
|
||||||
|
frame.nonEmptyRows <= 2 &&
|
||||||
|
(frame.firstNonEmptyRow ?? Number.POSITIVE_INFINITY) <= 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
suspiciousFrames,
|
||||||
|
"normal-screen restore should not flash to a mostly blank prompt-at-top frame",
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import type { ITheme } from "@xterm/xterm";
|
|||||||
import type { TerminalState } from "@server/shared/messages";
|
import type { TerminalState } from "@server/shared/messages";
|
||||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||||
|
import { openExternalUrl } from "../utils/open-external-url";
|
||||||
import { focusWithRetries } from "../utils/web-focus";
|
import { focusWithRetries } from "../utils/web-focus";
|
||||||
import {
|
import {
|
||||||
computeScrollOffsetFromDragDelta,
|
computeScrollOffsetFromDragDelta,
|
||||||
@@ -325,6 +326,7 @@ export default function TerminalEmulator({
|
|||||||
onResize,
|
onResize,
|
||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
|
onOpenExternalUrl: openExternalUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
runtime.setPendingModifiers({ pendingModifiers });
|
runtime.setPendingModifiers({ pendingModifiers });
|
||||||
@@ -350,6 +352,7 @@ export default function TerminalEmulator({
|
|||||||
onResize,
|
onResize,
|
||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
|
onOpenExternalUrl: openExternalUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { WebglAddon } from "@xterm/addon-webgl";
|
|||||||
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
|
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
|
||||||
import { Terminal, type ITheme } from "@xterm/xterm";
|
import { Terminal, type ITheme } from "@xterm/xterm";
|
||||||
import type { TerminalState } from "@server/shared/messages";
|
import type { TerminalState } from "@server/shared/messages";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
|
||||||
import {
|
import {
|
||||||
type PendingTerminalModifiers,
|
type PendingTerminalModifiers,
|
||||||
isTerminalModifierDomKey,
|
isTerminalModifierDomKey,
|
||||||
@@ -37,6 +36,7 @@ export type TerminalEmulatorRuntimeCallbacks = {
|
|||||||
meta: boolean;
|
meta: boolean;
|
||||||
}) => Promise<void> | void;
|
}) => Promise<void> | void;
|
||||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||||
|
onOpenExternalUrl?: (url: string) => Promise<void> | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TerminalEmulatorRuntimeDisposables = {
|
type TerminalEmulatorRuntimeDisposables = {
|
||||||
@@ -80,6 +80,7 @@ const isMac =
|
|||||||
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
||||||
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||||
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||||
|
const RESET_TERMINAL_ANSI = "\u001bc";
|
||||||
|
|
||||||
const DEFAULT_TERMINAL_FONT_FAMILY = [
|
const DEFAULT_TERMINAL_FONT_FAMILY = [
|
||||||
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
|
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
|
||||||
@@ -177,7 +178,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
terminal.loadAddon(
|
terminal.loadAddon(
|
||||||
new WebLinksAddon((event, uri) => {
|
new WebLinksAddon((event, uri) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void openExternalUrl(uri);
|
void this.callbacks.onOpenExternalUrl?.(uri);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
|
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
|
||||||
@@ -264,6 +265,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.lastSize = { rows: nextRows, cols: nextCols };
|
this.lastSize = { rows: nextRows, cols: nextCols };
|
||||||
|
this.refreshVisibleRows();
|
||||||
this.callbacks.onResize?.({
|
this.callbacks.onResize?.({
|
||||||
rows: nextRows,
|
rows: nextRows,
|
||||||
cols: nextCols,
|
cols: nextCols,
|
||||||
@@ -509,7 +511,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
}
|
}
|
||||||
this.outputOperations.push({
|
this.outputOperations.push({
|
||||||
type: "snapshot",
|
type: "snapshot",
|
||||||
text: renderTerminalSnapshotToAnsi(input.state),
|
text: `${RESET_TERMINAL_ANSI}${renderTerminalSnapshotToAnsi(input.state)}`,
|
||||||
rows: input.state.rows,
|
rows: input.state.rows,
|
||||||
cols: input.state.cols,
|
cols: input.state.cols,
|
||||||
suppressInput: true,
|
suppressInput: true,
|
||||||
@@ -535,17 +537,26 @@ export class TerminalEmulatorRuntime {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
this.refreshVisibleRows();
|
||||||
terminal.refresh(0, Math.max(0, terminal.rows - 1));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
focus(): void {
|
focus(): void {
|
||||||
this.terminal?.focus();
|
this.terminal?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private refreshVisibleRows(): void {
|
||||||
|
const terminal = this.terminal;
|
||||||
|
if (!terminal || terminal.rows <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
terminal.refresh(0, terminal.rows - 1);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
unmount(): void {
|
unmount(): void {
|
||||||
this.clearInFlightOutputTimeout();
|
this.clearInFlightOutputTimeout();
|
||||||
const inFlightOperation = this.inFlightOutputOperation;
|
const inFlightOperation = this.inFlightOutputOperation;
|
||||||
@@ -616,7 +627,6 @@ export class TerminalEmulatorRuntime {
|
|||||||
) {
|
) {
|
||||||
terminal.resize(operation.cols, operation.rows);
|
terminal.resize(operation.cols, operation.rows);
|
||||||
}
|
}
|
||||||
terminal.reset();
|
|
||||||
} catch {
|
} catch {
|
||||||
finalizeOperation(operation);
|
finalizeOperation(operation);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
import type { TerminalManager, TerminalsChangedEvent } from "../terminal/terminal-manager.js";
|
import type { TerminalManager, TerminalsChangedEvent } from "../terminal/terminal-manager.js";
|
||||||
import { captureTerminalLines, type TerminalSession } from "../terminal/terminal.js";
|
import { captureTerminalLines, type TerminalSession } from "../terminal/terminal.js";
|
||||||
|
import { TerminalOutputCoalescer } from "../terminal/terminal-output-coalescer.js";
|
||||||
import {
|
import {
|
||||||
TerminalStreamOpcode,
|
TerminalStreamOpcode,
|
||||||
encodeTerminalSnapshotPayload,
|
encodeTerminalSnapshotPayload,
|
||||||
@@ -329,6 +330,7 @@ type ActiveTerminalStream = {
|
|||||||
slot: number;
|
slot: number;
|
||||||
unsubscribe: () => void;
|
unsubscribe: () => void;
|
||||||
needsSnapshot: boolean;
|
needsSnapshot: boolean;
|
||||||
|
outputCoalescer: TerminalOutputCoalescer;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionRuntimeMetrics = {
|
export type SessionRuntimeMetrics = {
|
||||||
@@ -8876,6 +8878,21 @@ export class Session {
|
|||||||
slot,
|
slot,
|
||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
needsSnapshot: true,
|
needsSnapshot: true,
|
||||||
|
outputCoalescer: new TerminalOutputCoalescer({
|
||||||
|
timers: { setTimeout, clearTimeout },
|
||||||
|
onFlush: ({ payload }) => {
|
||||||
|
if (this.activeTerminalStreams.get(slot) !== activeStream) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emitBinary(
|
||||||
|
encodeTerminalStreamFrame({
|
||||||
|
opcode: TerminalStreamOpcode.Output,
|
||||||
|
slot,
|
||||||
|
payload,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.activeTerminalStreams.set(slot, activeStream);
|
this.activeTerminalStreams.set(slot, activeStream);
|
||||||
@@ -8885,21 +8902,19 @@ export class Session {
|
|||||||
if (this.activeTerminalStreams.get(slot) !== activeStream) {
|
if (this.activeTerminalStreams.get(slot) !== activeStream) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type === "snapshot" || message.type === "titleChange") {
|
if (message.type === "snapshot") {
|
||||||
|
activeStream.outputCoalescer.flush();
|
||||||
activeStream.needsSnapshot = true;
|
activeStream.needsSnapshot = true;
|
||||||
this.trySendTerminalSnapshot(activeStream);
|
this.trySendTerminalSnapshot(activeStream);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (message.type === "titleChange") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (activeStream.needsSnapshot || message.data.length === 0) {
|
if (activeStream.needsSnapshot || message.data.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.emitBinary(
|
activeStream.outputCoalescer.handle(message.data);
|
||||||
encodeTerminalStreamFrame({
|
|
||||||
opcode: TerminalStreamOpcode.Output,
|
|
||||||
slot,
|
|
||||||
payload: new Uint8Array(Buffer.from(message.data, "utf8")),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
return slot;
|
return slot;
|
||||||
}
|
}
|
||||||
@@ -8918,6 +8933,7 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activeStream.outputCoalescer.flush();
|
||||||
activeStream.needsSnapshot = false;
|
activeStream.needsSnapshot = false;
|
||||||
this.emitBinary(
|
this.emitBinary(
|
||||||
encodeTerminalStreamFrame({
|
encodeTerminalStreamFrame({
|
||||||
@@ -8950,6 +8966,7 @@ export class Session {
|
|||||||
this.terminalIdToSlot.delete(terminalId);
|
this.terminalIdToSlot.delete(terminalId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
activeStream.outputCoalescer.flush();
|
||||||
this.activeTerminalStreams.delete(slot);
|
this.activeTerminalStreams.delete(slot);
|
||||||
this.terminalIdToSlot.delete(terminalId);
|
this.terminalIdToSlot.delete(terminalId);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js";
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
let nextTimer = 1;
|
||||||
|
const scheduled = new Map<number, { callback: () => void; delayMs: number }>();
|
||||||
|
const flushes: Array<{ payload: string; chars: number; bytes: number }> = [];
|
||||||
|
const coalescer = new TerminalOutputCoalescer({
|
||||||
|
timers: {
|
||||||
|
setTimeout: ((callback: () => void, delayMs?: number) => {
|
||||||
|
const id = nextTimer;
|
||||||
|
nextTimer += 1;
|
||||||
|
scheduled.set(id, { callback, delayMs: delayMs ?? 0 });
|
||||||
|
return id;
|
||||||
|
}) as typeof setTimeout,
|
||||||
|
clearTimeout: ((id: number) => {
|
||||||
|
scheduled.delete(id);
|
||||||
|
}) as typeof clearTimeout,
|
||||||
|
},
|
||||||
|
onFlush: ({ payload, chars, bytes }) => {
|
||||||
|
flushes.push({ payload: payload.toString("utf8"), chars, bytes });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
coalescer,
|
||||||
|
flushes,
|
||||||
|
scheduled,
|
||||||
|
runScheduled() {
|
||||||
|
const callbacks = Array.from(scheduled.values());
|
||||||
|
scheduled.clear();
|
||||||
|
for (const { callback } of callbacks) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TerminalOutputCoalescer", () => {
|
||||||
|
test("coalesces output into one 5ms flush window", () => {
|
||||||
|
const { coalescer, flushes, runScheduled, scheduled } = createHarness();
|
||||||
|
|
||||||
|
coalescer.handle("a");
|
||||||
|
coalescer.handle("b");
|
||||||
|
coalescer.handle("é");
|
||||||
|
|
||||||
|
expect(scheduled.size).toBe(1);
|
||||||
|
expect(Array.from(scheduled.values())).toEqual([
|
||||||
|
{ callback: expect.any(Function), delayMs: 5 },
|
||||||
|
]);
|
||||||
|
expect(flushes).toEqual([]);
|
||||||
|
|
||||||
|
runScheduled();
|
||||||
|
|
||||||
|
expect(flushes).toEqual([{ payload: "abé", chars: 3, bytes: 4 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("manual flush drains pending output and cancels the scheduled flush", () => {
|
||||||
|
const { coalescer, flushes, runScheduled, scheduled } = createHarness();
|
||||||
|
|
||||||
|
coalescer.handle("hello");
|
||||||
|
coalescer.flush();
|
||||||
|
runScheduled();
|
||||||
|
|
||||||
|
expect(scheduled.size).toBe(0);
|
||||||
|
expect(flushes).toEqual([{ payload: "hello", chars: 5, bytes: 5 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispose drops pending output", () => {
|
||||||
|
const { coalescer, flushes, runScheduled, scheduled } = createHarness();
|
||||||
|
|
||||||
|
coalescer.handle("pending");
|
||||||
|
coalescer.dispose();
|
||||||
|
runScheduled();
|
||||||
|
|
||||||
|
expect(scheduled.size).toBe(0);
|
||||||
|
expect(flushes).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
export type TerminalOutputCoalescerTimers = {
|
||||||
|
setTimeout: typeof setTimeout;
|
||||||
|
clearTimeout: typeof clearTimeout;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalOutputCoalescerFlush = {
|
||||||
|
payload: Buffer;
|
||||||
|
chars: number;
|
||||||
|
bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TerminalOutputCoalescerOptions = {
|
||||||
|
timers: TerminalOutputCoalescerTimers;
|
||||||
|
flushDelayMs?: number;
|
||||||
|
onFlush: (payload: TerminalOutputCoalescerFlush) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FLUSH_DELAY_MS = 5;
|
||||||
|
|
||||||
|
export class TerminalOutputCoalescer {
|
||||||
|
private readonly timers: TerminalOutputCoalescerTimers;
|
||||||
|
private readonly flushDelayMs: number;
|
||||||
|
private readonly onFlush: (payload: TerminalOutputCoalescerFlush) => void;
|
||||||
|
private chunks: Buffer[] = [];
|
||||||
|
private bytes = 0;
|
||||||
|
private chars = 0;
|
||||||
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor(options: TerminalOutputCoalescerOptions) {
|
||||||
|
this.timers = options.timers;
|
||||||
|
this.flushDelayMs = options.flushDelayMs ?? DEFAULT_FLUSH_DELAY_MS;
|
||||||
|
this.onFlush = options.onFlush;
|
||||||
|
}
|
||||||
|
|
||||||
|
handle(data: string): void {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = Buffer.from(data, "utf8");
|
||||||
|
this.chunks.push(chunk);
|
||||||
|
this.bytes += chunk.byteLength;
|
||||||
|
this.chars += data.length;
|
||||||
|
|
||||||
|
if (!this.flushTimer) {
|
||||||
|
this.flushTimer = this.timers.setTimeout(() => {
|
||||||
|
this.flushTimer = null;
|
||||||
|
this.flush();
|
||||||
|
}, this.flushDelayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flush(): void {
|
||||||
|
this.clearTimer();
|
||||||
|
|
||||||
|
if (this.chunks.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload =
|
||||||
|
this.chunks.length === 1 ? this.chunks[0]! : Buffer.concat(this.chunks, this.bytes);
|
||||||
|
const bytes = this.bytes;
|
||||||
|
const chars = this.chars;
|
||||||
|
this.clearPending();
|
||||||
|
|
||||||
|
this.onFlush({ payload, bytes, chars });
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.clearTimer();
|
||||||
|
this.clearPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer(): void {
|
||||||
|
if (!this.flushTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.timers.clearTimeout(this.flushTimer);
|
||||||
|
this.flushTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPending(): void {
|
||||||
|
this.chunks = [];
|
||||||
|
this.bytes = 0;
|
||||||
|
this.chars = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -560,6 +560,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
let title: string | undefined;
|
let title: string | undefined;
|
||||||
let pendingTitle: string | undefined;
|
let pendingTitle: string | undefined;
|
||||||
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pendingInput = "";
|
||||||
|
let inputFlushImmediate: ReturnType<typeof setImmediate> | null = null;
|
||||||
|
|
||||||
// Create xterm.js headless terminal
|
// Create xterm.js headless terminal
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
@@ -689,6 +691,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
pendingInput = "";
|
||||||
|
if (inputFlushImmediate) {
|
||||||
|
clearImmediate(inputFlushImmediate);
|
||||||
|
inputFlushImmediate = null;
|
||||||
|
}
|
||||||
if (titleDebounceTimer) {
|
if (titleDebounceTimer) {
|
||||||
clearTimeout(titleDebounceTimer);
|
clearTimeout(titleDebounceTimer);
|
||||||
titleDebounceTimer = null;
|
titleDebounceTimer = null;
|
||||||
@@ -757,14 +764,44 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeInputToPty(data: string): void {
|
||||||
|
ptyProcess.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPendingInput(): void {
|
||||||
|
if (inputFlushImmediate) {
|
||||||
|
clearImmediate(inputFlushImmediate);
|
||||||
|
inputFlushImmediate = null;
|
||||||
|
}
|
||||||
|
const data = pendingInput;
|
||||||
|
pendingInput = "";
|
||||||
|
if (!data || killed || disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeInputToPty(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleInputFlush(): void {
|
||||||
|
if (inputFlushImmediate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inputFlushImmediate = setImmediate(() => {
|
||||||
|
inputFlushImmediate = null;
|
||||||
|
flushPendingInput();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function send(msg: ClientMessage): void {
|
function send(msg: ClientMessage): void {
|
||||||
if (killed) return;
|
if (killed) return;
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "input":
|
case "input": {
|
||||||
ptyProcess.write(msg.data);
|
pendingInput += msg.data;
|
||||||
|
scheduleInputFlush();
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "resize":
|
case "resize":
|
||||||
|
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);
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user