From 6a03873d8119e8ea882e8ffeccdf1fe5ccded6d3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 21 Apr 2026 20:47:17 +0700 Subject: [PATCH] fix: smooth terminal streaming --- packages/app/e2e/helpers/terminal-dsl.ts | 98 +++ packages/app/e2e/helpers/terminal-probes.ts | 680 ++++++++++++++++++ .../app/e2e/terminal-alternate-screen.spec.ts | 96 +++ .../app/src/components/terminal-emulator.tsx | 3 + .../runtime/terminal-emulator-runtime.ts | 28 +- packages/server/src/server/session.ts | 33 +- .../terminal-output-coalescer.test.ts | 79 ++ .../src/terminal/terminal-output-coalescer.ts | 87 +++ packages/server/src/terminal/terminal.ts | 41 +- 9 files changed, 1126 insertions(+), 19 deletions(-) create mode 100644 packages/app/e2e/helpers/terminal-dsl.ts create mode 100644 packages/app/e2e/helpers/terminal-probes.ts create mode 100644 packages/app/e2e/terminal-alternate-screen.spec.ts create mode 100644 packages/server/src/terminal/terminal-output-coalescer.test.ts create mode 100644 packages/server/src/terminal/terminal-output-coalescer.ts diff --git a/packages/app/e2e/helpers/terminal-dsl.ts b/packages/app/e2e/helpers/terminal-dsl.ts new file mode 100644 index 000000000..8c12547ce --- /dev/null +++ b/packages/app/e2e/helpers/terminal-dsl.ts @@ -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; +}; + +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 { + 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 { + await this.client.close().catch(() => {}); + await this.tempRepo.cleanup().catch(() => {}); + } + + async createTerminal(input: { name: string }): Promise { + 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 { + await this.client.killTerminal(terminalId).catch(() => {}); + } + + async openTerminal(page: Page, input: { terminalId: string }): Promise { + 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 { + await setupDeterministicPrompt(page, sentinel); + } +} + +export async function withTerminalInApp( + page: Page, + harness: TerminalE2EHarness, + input: { name: string }, + fn: (terminal: TerminalInstance) => Promise, +): Promise { + 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); + } +} diff --git a/packages/app/e2e/helpers/terminal-probes.ts b/packages/app/e2e/helpers/terminal-probes.ts new file mode 100644 index 000000000..0e91ebf97 --- /dev/null +++ b/packages/app/e2e/helpers/terminal-probes.ts @@ -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 & { + 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; + 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 { + 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 { + await page.evaluate(() => { + (window as any).__terminalRenderProbe.reset(); + }); +} + +export async function startTerminalFrameSampling(page: Page, durationMs = 2500): Promise { + await page.evaluate((ms) => { + (window as any).__terminalRenderProbe.startSampling(ms); + }, durationMs); +} + +export async function readTerminalRenderProbe(page: Page): Promise { + return page.evaluate(() => (window as any).__terminalRenderProbe.snapshot()); +} + +export async function terminalVisibleText(page: Page): Promise { + 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 { + 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 { + 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 { + 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(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 { + const counts: Record = {}; + 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 { + await page.evaluate(() => { + (window as any).__terminalKeystrokeStressProbe.reset(); + }); +} + +export async function readTerminalKeystrokeStressReport( + page: Page, + inputText: string, +): Promise { + return page.evaluate( + (text) => (window as any).__terminalKeystrokeStressProbe.report(text), + inputText, + ); +} diff --git a/packages/app/e2e/terminal-alternate-screen.spec.ts b/packages/app/e2e/terminal-alternate-screen.spec.ts new file mode 100644 index 000000000..0e32e6eb1 --- /dev/null +++ b/packages/app/e2e/terminal-alternate-screen.spec.ts @@ -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([]); + }); + }); +}); diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 2416241ad..de14ccf9c 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -8,6 +8,7 @@ import type { ITheme } from "@xterm/xterm"; import type { TerminalState } from "@server/shared/messages"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime"; +import { openExternalUrl } from "../utils/open-external-url"; import { focusWithRetries } from "../utils/web-focus"; import { computeScrollOffsetFromDragDelta, @@ -325,6 +326,7 @@ export default function TerminalEmulator({ onResize, onTerminalKey, onPendingModifiersConsumed, + onOpenExternalUrl: openExternalUrl, }, }); runtime.setPendingModifiers({ pendingModifiers }); @@ -350,6 +352,7 @@ export default function TerminalEmulator({ onResize, onTerminalKey, onPendingModifiersConsumed, + onOpenExternalUrl: openExternalUrl, }, }); }, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index d2148e576..8fb79b01b 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -8,7 +8,6 @@ import { WebglAddon } from "@xterm/addon-webgl"; import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs"; import { Terminal, type ITheme } from "@xterm/xterm"; import type { TerminalState } from "@server/shared/messages"; -import { openExternalUrl } from "@/utils/open-external-url"; import { type PendingTerminalModifiers, isTerminalModifierDomKey, @@ -37,6 +36,7 @@ export type TerminalEmulatorRuntimeCallbacks = { meta: boolean; }) => Promise | void; onPendingModifiersConsumed?: () => Promise | void; + onOpenExternalUrl?: (url: string) => Promise | void; }; type TerminalEmulatorRuntimeDisposables = { @@ -80,6 +80,7 @@ const isMac = const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18; const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000]; const OUTPUT_OPERATION_TIMEOUT_MS = 5_000; +const RESET_TERMINAL_ANSI = "\u001bc"; const DEFAULT_TERMINAL_FONT_FAMILY = [ // Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs. @@ -177,7 +178,7 @@ export class TerminalEmulatorRuntime { terminal.loadAddon( new WebLinksAddon((event, uri) => { event.preventDefault(); - void openExternalUrl(uri); + void this.callbacks.onOpenExternalUrl?.(uri); }), ); terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 })); @@ -264,6 +265,7 @@ export class TerminalEmulatorRuntime { } this.lastSize = { rows: nextRows, cols: nextCols }; + this.refreshVisibleRows(); this.callbacks.onResize?.({ rows: nextRows, cols: nextCols, @@ -509,7 +511,7 @@ export class TerminalEmulatorRuntime { } this.outputOperations.push({ type: "snapshot", - text: renderTerminalSnapshotToAnsi(input.state), + text: `${RESET_TERMINAL_ANSI}${renderTerminalSnapshotToAnsi(input.state)}`, rows: input.state.rows, cols: input.state.cols, suppressInput: true, @@ -535,17 +537,26 @@ export class TerminalEmulatorRuntime { return; } - try { - terminal.refresh(0, Math.max(0, terminal.rows - 1)); - } catch { - // ignore - } + this.refreshVisibleRows(); } focus(): void { 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 { this.clearInFlightOutputTimeout(); const inFlightOperation = this.inFlightOutputOperation; @@ -616,7 +627,6 @@ export class TerminalEmulatorRuntime { ) { terminal.resize(operation.cols, operation.rows); } - terminal.reset(); } catch { finalizeOperation(operation); return; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 203fce77c..1321b14b4 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -40,6 +40,7 @@ import { } from "./messages.js"; import type { TerminalManager, TerminalsChangedEvent } from "../terminal/terminal-manager.js"; import { captureTerminalLines, type TerminalSession } from "../terminal/terminal.js"; +import { TerminalOutputCoalescer } from "../terminal/terminal-output-coalescer.js"; import { TerminalStreamOpcode, encodeTerminalSnapshotPayload, @@ -329,6 +330,7 @@ type ActiveTerminalStream = { slot: number; unsubscribe: () => void; needsSnapshot: boolean; + outputCoalescer: TerminalOutputCoalescer; }; export type SessionRuntimeMetrics = { @@ -8876,6 +8878,21 @@ export class Session { slot, unsubscribe: () => {}, 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); @@ -8885,21 +8902,19 @@ export class Session { if (this.activeTerminalStreams.get(slot) !== activeStream) { return; } - if (message.type === "snapshot" || message.type === "titleChange") { + if (message.type === "snapshot") { + activeStream.outputCoalescer.flush(); activeStream.needsSnapshot = true; this.trySendTerminalSnapshot(activeStream); return; } + if (message.type === "titleChange") { + return; + } if (activeStream.needsSnapshot || message.data.length === 0) { return; } - this.emitBinary( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Output, - slot, - payload: new Uint8Array(Buffer.from(message.data, "utf8")), - }), - ); + activeStream.outputCoalescer.handle(message.data); }); return slot; } @@ -8918,6 +8933,7 @@ export class Session { return; } + activeStream.outputCoalescer.flush(); activeStream.needsSnapshot = false; this.emitBinary( encodeTerminalStreamFrame({ @@ -8950,6 +8966,7 @@ export class Session { this.terminalIdToSlot.delete(terminalId); return false; } + activeStream.outputCoalescer.flush(); this.activeTerminalStreams.delete(slot); this.terminalIdToSlot.delete(terminalId); try { diff --git a/packages/server/src/terminal/terminal-output-coalescer.test.ts b/packages/server/src/terminal/terminal-output-coalescer.test.ts new file mode 100644 index 000000000..55f732d34 --- /dev/null +++ b/packages/server/src/terminal/terminal-output-coalescer.test.ts @@ -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 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([]); + }); +}); diff --git a/packages/server/src/terminal/terminal-output-coalescer.ts b/packages/server/src/terminal/terminal-output-coalescer.ts new file mode 100644 index 000000000..37bfc2531 --- /dev/null +++ b/packages/server/src/terminal/terminal-output-coalescer.ts @@ -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 | 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; + } +} diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index c9ad7e2ee..ee1833de6 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -560,6 +560,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise | null = null; + let pendingInput = ""; + let inputFlushImmediate: ReturnType | null = null; // Create xterm.js headless terminal const terminal = new Terminal({ @@ -689,6 +691,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { + inputFlushImmediate = null; + flushPendingInput(); + }); + } + function send(msg: ClientMessage): void { if (killed) return; switch (msg.type) { - case "input": - ptyProcess.write(msg.data); + case "input": { + pendingInput += msg.data; + scheduleInputFlush(); break; + } case "resize": + flushPendingInput(); terminal.resize(msg.cols, msg.rows); ptyProcess.resize(msg.cols, msg.rows); break;