perf: coalesce terminal output in Main and write it to xterm once per frame

This commit is contained in:
mesalogo
2026-10-03 13:54:24 +08:00
parent 10c5162811
commit f4458afe4b
10 changed files with 1252 additions and 168 deletions
+206 -21
View File
@@ -258,30 +258,215 @@ describe('LocalTerminalSession', () => {
})
it('pauses at the pending-event boundary and resumes after ACK', async () => {
const pty = new FakePty()
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: sessionDependencies(pty)
})
let lastSequence = 0
session.onEvent((event) => {
lastSequence = event.sequence
})
vi.useFakeTimers()
try {
const pty = new FakePty()
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: sessionDependencies(pty)
})
let lastSequence = 0
session.onEvent((event) => {
lastSequence = event.sequence
})
for (
let index = 0;
index < TERMINAL_LIMITS.maximumPendingEvents;
index += 1
) {
pty.emitData('x')
if (pty.pause.mock.calls.length > 0) {
break
for (
let index = 0;
index < TERMINAL_LIMITS.maximumPendingEvents;
index += 1
) {
pty.emitData('x')
// Each flush window yields one event.
vi.advanceTimersByTime(20)
if (pty.pause.mock.calls.length > 0) {
break
}
}
}
expect(pty.pause).toHaveBeenCalledTimes(1)
session.acknowledge(lastSequence)
expect(pty.resume).toHaveBeenCalledTimes(1)
expect(pty.pause).toHaveBeenCalledTimes(1)
expect(session.snapshot().state).toBe('running')
session.acknowledge(lastSequence)
expect(pty.resume).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('coalesces bursts into few ordered output events', async () => {
vi.useFakeTimers()
try {
const pty = new FakePty()
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: sessionDependencies(pty)
})
const events: TerminalEvent[] = []
session.onEvent((event) => events.push(event))
const expected: string[] = []
for (let index = 0; index < 1_000; index += 1) {
const chunk = `line ${index} 你好\r\n`
expected.push(chunk)
pty.emitData(chunk)
if (index % 100 === 0) {
vi.advanceTimersByTime(8)
}
}
vi.advanceTimersByTime(20)
const output = events.filter((event) => event.type === 'output')
expect(output.map((event) => event.data).join('')).toBe(expected.join(''))
expect(output.length).toBeLessThan(25)
const sequences = events.map((event) => event.sequence)
expect(sequences).toEqual([...sequences].sort((a, b) => a - b))
expect(new Set(sequences).size).toBe(sequences.length)
await session.close()
} finally {
vi.useRealTimers()
}
})
it('flushes buffered output before exit events and stops its timer', async () => {
vi.useFakeTimers()
try {
const pty = new FakePty()
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: sessionDependencies(pty)
})
const events: TerminalEvent[] = []
session.onEvent((event) => events.push(event))
pty.emitData('first ')
pty.emitData('trailing output')
pty.emitData(' tail')
pty.emitExit(0)
expect(
events.slice(2).map((event) =>
event.type === 'output' ? event.data : event.type
)
).toEqual(['first ', 'trailing output tail', 'exit', 'state'])
expect(vi.getTimerCount()).toBe(0)
vi.advanceTimersByTime(100)
expect(events).toHaveLength(6)
} finally {
vi.useRealTimers()
}
})
it('flushes buffered output before the closing state on kill', async () => {
vi.useFakeTimers()
try {
const pty = new FakePty()
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: sessionDependencies(pty, {
terminate: vi.fn(async () => undefined)
})
})
const events: TerminalEvent[] = []
session.onEvent((event) => events.push(event))
pty.emitData('a')
pty.emitData('b')
await session.close()
expect(
events.slice(2).map((event) =>
event.type === 'output'
? event.data
: event.type === 'state'
? event.state
: event.type
)
).toEqual(['a', 'b', 'closing', 'exit', 'exited'])
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('counts buffered and in-flight bytes for backpressure and keeps them bounded', async () => {
vi.useFakeTimers()
try {
const pty = new FakePty()
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: sessionDependencies(pty)
})
const outputs: Extract<TerminalEvent, { type: 'output' }>[] = []
session.onEvent((event) => {
if (event.type === 'output') {
outputs.push(event)
}
})
const block = 'q'.repeat(16 * 1024)
let produced = 0
// A renderer that never ACKs: output must pause well before the hard
// 4 MiB bound and never exceed it.
while (pty.pause.mock.calls.length === 0) {
pty.emitData(block)
produced += block.length
expect(produced).toBeLessThan(TERMINAL_LIMITS.maximumBufferedOutputBytes)
}
expect(session.snapshot().state).toBe('running')
const emittedBytes = outputs.reduce((sum, event) => sum + event.data.length, 0)
expect(produced - emittedBytes).toBeLessThan(TERMINAL_LIMITS.maximumEventBytes)
expect(
outputs.every(
(event) => Buffer.byteLength(event.data) <= TERMINAL_LIMITS.maximumEventBytes
)
).toBe(true)
// In-flight data after pause is absorbed by the headroom.
for (let index = 0; index < 16; index += 1) {
pty.emitData(block)
produced += block.length
}
vi.advanceTimersByTime(20)
expect(session.snapshot().state).toBe('running')
// A partial ACK that leaves more than half the budget pending must not
// resume; ACKing the rest must.
session.acknowledge(outputs[0]!.sequence)
expect(pty.resume).not.toHaveBeenCalled()
session.acknowledge(outputs.at(-1)!.sequence)
expect(pty.resume).toHaveBeenCalledTimes(1)
expect(
outputs.map((event) => event.data).join('').length
).toBe(produced)
await session.close()
} finally {
vi.useRealTimers()
}
})
it('fails with output-limit-exceeded when the renderer never ACKs and the PTY ignores pause', async () => {
vi.useFakeTimers()
try {
const pty = new FakePty()
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: sessionDependencies(pty, {
terminate: vi.fn(async () => undefined)
})
})
const block = 'q'.repeat(64 * 1024)
for (
let produced = 0;
produced <= 2 * TERMINAL_LIMITS.maximumBufferedOutputBytes &&
session.snapshot().state === 'running';
produced += block.length
) {
pty.emitData(block)
}
expect(session.snapshot()).toMatchObject({
state: 'failed',
error: { code: 'output-limit-exceeded' }
})
expect(vi.getTimerCount()).toBe(0)
await session.close()
} finally {
vi.useRealTimers()
}
})
it('reports launch failures with stable failed state', async () => {
+64 -49
View File
@@ -16,6 +16,10 @@ import {
} from '../../shared/terminal-contracts'
import { requestProcessTreeTermination } from '../agent/child-process-termination'
import { buildCredentialFilteredUserEnvironment } from '../agent/process-environment'
import {
TerminalOutputCoalescer,
type TerminalOutputCoalescerOptions
} from './terminal-output-coalescer'
export type LocalTerminalTarget = Extract<
TerminalTarget,
@@ -52,6 +56,7 @@ export type LocalTerminalDependencies = {
directoryExists?: (path: string) => Promise<boolean>
spawn?: LocalPtySpawn
terminate?: (pty: IPty, platform: NodeJS.Platform) => Promise<void>
outputCoalescing?: Omit<TerminalOutputCoalescerOptions, 'emit'>
}
export type LocalTerminalSessionOptions = {
@@ -72,11 +77,25 @@ type UnsequencedTerminalEvent<T> = T extends TerminalEvent
type LocalTerminalEvent = UnsequencedTerminalEvent<TerminalEvent>
const CONTROL_EVENT_RESERVE = 2
const PAUSE_PENDING_EVENT_COUNT =
/** Hard limits: exceeding them fails the session (renderer stopped ACKing). */
const MAXIMUM_PENDING_OUTPUT_EVENTS =
TERMINAL_LIMITS.maximumPendingEvents - CONTROL_EVENT_RESERVE
const RESUME_PENDING_EVENT_COUNT = Math.floor(
PAUSE_PENDING_EVENT_COUNT / 2
/**
* Soft limits: pause the PTY with headroom below the hard limits, because a
* paused PTY may still deliver data that was already in flight. Coalesced
* events are up to 64 KiB, so the byte limit is normally reached first.
*/
const PAUSE_PENDING_EVENT_COUNT = Math.floor(
(MAXIMUM_PENDING_OUTPUT_EVENTS * 3) / 4
)
const PAUSE_PENDING_OUTPUT_BYTES = Math.floor(
(TERMINAL_LIMITS.maximumBufferedOutputBytes * 3) / 4
)
const RESUME_PENDING_EVENT_COUNT = Math.floor(
MAXIMUM_PENDING_OUTPUT_EVENTS / 2
)
const RESUME_PENDING_OUTPUT_BYTES =
TERMINAL_LIMITS.maximumBufferedOutputBytes / 2
const CLOSE_WAIT_MS = 1_500
const FORCE_WAIT_MS = 250
@@ -248,32 +267,6 @@ export async function resolveLocalTerminalLaunch(
}
}
function splitBoundedOutput(data: string): string[] {
const encoded = Buffer.from(data)
const chunks: string[] = []
let offset = 0
while (offset < encoded.byteLength) {
let end = Math.min(
offset + TERMINAL_LIMITS.maximumEventBytes,
encoded.byteLength
)
if (end < encoded.byteLength) {
while (
end > offset &&
(encoded[end]! & 0xc0) === 0x80
) {
end -= 1
}
}
if (end === offset) {
end = Math.min(offset + 1, encoded.byteLength)
}
chunks.push(encoded.subarray(offset, end).toString('utf8'))
offset = end
}
return chunks
}
async function waitUntil(
predicate: () => boolean,
milliseconds: number
@@ -369,12 +362,17 @@ export class LocalTerminalSession {
private exit: TerminalExit | null = null
private error: TerminalError | null = null
private closePromise: Promise<TerminalSnapshot> | undefined
private readonly output: TerminalOutputCoalescer
private constructor(options: LocalTerminalSessionOptions) {
this.options = options
this.dependencies = options.dependencies ?? {}
this.sessionId = options.sessionId ?? randomUUID()
this.size = { ...options.size }
this.output = new TerminalOutputCoalescer({
...this.dependencies.outputCoalescing,
emit: (data) => this.emitOutput(data)
})
this.emit({ type: 'state', state: 'starting' })
}
@@ -487,8 +485,8 @@ export class LocalTerminalSession {
if (
this.paused &&
this.pendingEvents.length <= RESUME_PENDING_EVENT_COUNT &&
this.pendingOutputBytes <=
TERMINAL_LIMITS.maximumBufferedOutputBytes / 2
this.pendingOutputBytes + this.output.bufferedBytes <=
RESUME_PENDING_OUTPUT_BYTES
) {
this.paused = false
this.pty?.resume()
@@ -545,31 +543,39 @@ export class LocalTerminalSession {
if (this.state !== 'running' || data.length === 0) {
return
}
for (const chunk of splitBoundedOutput(data)) {
if (
this.pendingEvents.length >= PAUSE_PENDING_EVENT_COUNT ||
this.pendingOutputBytes + Buffer.byteLength(chunk) >
TERMINAL_LIMITS.maximumBufferedOutputBytes
) {
this.fail(
'output-limit-exceeded',
'Terminal output exceeded the bounded Main process buffer'
)
void this.close()
return
}
this.pendingOutputBytes += Buffer.byteLength(chunk)
this.emit({ type: 'output', data: chunk })
this.updateBackpressure()
// Bytes count against backpressure as soon as they are buffered, so a slow
// renderer pauses the PTY regardless of the coalescing window.
this.output.push(data)
this.updateBackpressure()
}
/** Called by the coalescer with ordered, bounded (<= 64 KiB) chunks. */
private emitOutput(chunk: string): void {
const bytes = Buffer.byteLength(chunk)
if (
this.pendingEvents.length >= MAXIMUM_PENDING_OUTPUT_EVENTS ||
this.pendingOutputBytes + bytes >
TERMINAL_LIMITS.maximumBufferedOutputBytes
) {
this.fail(
'output-limit-exceeded',
'Terminal output exceeded the bounded Main process buffer'
)
void this.close()
return
}
this.pendingOutputBytes += bytes
this.emit({ type: 'output', data: chunk })
this.updateBackpressure()
}
private updateBackpressure(): void {
if (
!this.paused &&
this.state === 'running' &&
(this.pendingEvents.length >= PAUSE_PENDING_EVENT_COUNT ||
this.pendingOutputBytes >=
TERMINAL_LIMITS.maximumBufferedOutputBytes)
this.pendingOutputBytes + this.output.bufferedBytes >=
PAUSE_PENDING_OUTPUT_BYTES)
) {
this.paused = true
this.pty?.pause()
@@ -599,8 +605,10 @@ export class LocalTerminalSession {
this.exit.signal = 'unknown'
}
this.state = 'exited'
// emit() flushes buffered output first, so trailing output precedes exit.
this.emit({ type: 'exit', exit: this.exit })
this.emit({ type: 'state', state: 'exited' })
this.output.dispose()
}
private fail(
@@ -618,6 +626,9 @@ export class LocalTerminalSession {
retryable: code === 'launch-failed'
}
this.state = 'failed'
// Failure is terminal: drop buffered output (it is what exceeded the
// bound) and cancel the flush timer before announcing the error.
this.output.dispose()
this.emit({ type: 'error', error: this.error })
this.emit({ type: 'state', state: 'failed' })
}
@@ -625,6 +636,10 @@ export class LocalTerminalSession {
private emit(
event: LocalTerminalEvent
): void {
if (event.type !== 'output') {
// Control events must follow every byte the PTY produced before them.
this.output.flush()
}
if (this.lastSequence >= Number.MAX_SAFE_INTEGER) {
this.state = 'failed'
this.error = {
+74 -12
View File
@@ -185,21 +185,83 @@ describe("SSH terminal session", () => {
});
it("emits ordered bounded output and pauses until acknowledged", async () => {
const harness = createHarness();
const session = await openSession(harness);
vi.useFakeTimers();
try {
const harness = createHarness();
const session = await openSession(harness);
for (let index = 0; index < 255; index += 1) {
harness.channel.emit("data", `chunk-${index}`);
for (let index = 0; index < 255; index += 1) {
harness.channel.emit("data", `chunk-${index}`);
// One flush window per chunk keeps one event per chunk.
vi.advanceTimersByTime(20);
}
expect(harness.events).toHaveLength(256);
expect(harness.events.map((event) => event.sequence)).toEqual(
Array.from({ length: 256 }, (_, index) => index + 1),
);
expect(harness.channel.pause).toHaveBeenCalledOnce();
session.acknowledge(256);
expect(harness.channel.resume).toHaveBeenCalledOnce();
session.close();
} finally {
vi.useRealTimers();
}
});
expect(harness.events).toHaveLength(256);
expect(harness.events.map((event) => event.sequence)).toEqual(
Array.from({ length: 256 }, (_, index) => index + 1),
);
expect(harness.channel.pause).toHaveBeenCalledOnce();
session.acknowledge(256);
expect(harness.channel.resume).toHaveBeenCalledOnce();
session.close();
it("coalesces bursts in order and flushes trailing output before exit", async () => {
vi.useFakeTimers();
try {
const harness = createHarness();
await openSession(harness);
const expected: string[] = [];
for (let index = 0; index < 500; index += 1) {
const chunk = `chunk-${index};`;
expected.push(chunk);
harness.channel.emit("data", Buffer.from(chunk));
}
// Split multi-byte character across two SSH packets.
const bytes = Buffer.from("你");
harness.channel.emit("data", bytes.subarray(0, 1));
harness.channel.emit("data", bytes.subarray(1));
expected.push("你");
harness.channel.emit("exit", 0);
harness.channel.emit("close");
const output = harness.events.filter((event) => event.type === "output");
expect(output.map((event) => event.data).join("")).toBe(expected.join(""));
expect(output.length).toBeLessThanOrEqual(2);
expect(harness.events.slice(-2).map((event) => event.type)).toEqual([
"exit",
"state",
]);
const count = harness.events.length;
vi.advanceTimersByTime(100);
expect(harness.events).toHaveLength(count);
} finally {
vi.useRealTimers();
}
});
it("flushes buffered output before closing on kill", async () => {
vi.useFakeTimers();
try {
const harness = createHarness();
const session = await openSession(harness);
harness.channel.emit("data", "a");
harness.channel.emit("data", "b");
session.close();
expect(
harness.events.slice(1).map((event) =>
event.type === "output" ? event.data : event.type === "state" ? event.state : event.type,
),
).toEqual(["a", "b", "closing", "exit", "exited"]);
const count = harness.events.length;
vi.advanceTimersByTime(100);
expect(harness.events).toHaveLength(count);
} finally {
vi.useRealTimers();
}
});
it("marks a disconnected SSH transport interrupted immediately and rejects late I/O", async () => {
+36 -47
View File
@@ -16,6 +16,10 @@ import {
type SshConnectionPoolTarget,
type SshTerminalConnectionLease,
} from "../ssh/ssh-connection-pool";
import {
TerminalOutputCoalescer,
type TerminalOutputCoalescerOptions,
} from "./terminal-output-coalescer";
const SSH_TERMINAL_TERM = "xterm-256color";
@@ -71,29 +75,13 @@ function eventByteLength(event: TerminalEvent): number {
}
}
function splitUtf8(text: string, maximumBytes: number): string[] {
const chunks: string[] = [];
let chunk = "";
let chunkBytes = 0;
for (const character of text) {
const characterBytes = Buffer.byteLength(character);
if (chunk && chunkBytes + characterBytes > maximumBytes) {
chunks.push(chunk);
chunk = "";
chunkBytes = 0;
}
chunk += character;
chunkBytes += characterBytes;
}
if (chunk) {
chunks.push(chunk);
}
return chunks;
}
const PAUSE_PENDING_BYTES =
TERMINAL_LIMITS.maximumBufferedOutputBytes - TERMINAL_LIMITS.maximumEventBytes;
export async function createSshTerminalSession(
pool: SshConnectionPool,
options: SshTerminalSessionOptions,
coalescing?: Omit<TerminalOutputCoalescerOptions, "emit">,
): Promise<SshTerminalSession> {
const initialSize = terminalSizeSchema.parse(options.size);
const workingDirectoryCommand = options.workingDirectory && !options.command
@@ -128,7 +116,22 @@ export async function createSshTerminalSession(
const decoder = new StringDecoder("utf8");
let running: RunningSession | undefined;
const updateBackpressure = (): void => {
if (
!paused &&
(pendingEvents.length >= TERMINAL_LIMITS.maximumPendingEvents ||
pendingBytes + output.bufferedBytes >= PAUSE_PENDING_BYTES)
) {
paused = true;
channel.pause();
}
};
const emit = (event: TerminalEventPayload): void => {
if (event.type !== "output") {
// Control events must follow every byte received before them.
output.flush();
}
sequence += 1;
const sequenced = {
...event,
@@ -139,19 +142,18 @@ export async function createSshTerminalSession(
pendingEvents.push({ event: sequenced, bytes });
pendingBytes += bytes;
options.onEvent(sequenced);
if (
!paused &&
(pendingEvents.length >= TERMINAL_LIMITS.maximumPendingEvents ||
pendingBytes >=
TERMINAL_LIMITS.maximumBufferedOutputBytes -
TERMINAL_LIMITS.maximumEventBytes)
) {
paused = true;
channel.pause();
}
updateBackpressure();
};
const output = new TerminalOutputCoalescer({
...coalescing,
emit: (data) => emit({ type: "output", data }),
});
const release = (): void => {
// Every terminal path calls release() after its final events; stop the
// flush timer and drop anything that can no longer be delivered.
output.dispose();
const active = running;
running = undefined;
active?.removeDisconnectListener();
@@ -183,12 +185,8 @@ export async function createSshTerminalSession(
}
const text =
typeof chunk === "string" ? chunk : decoder.write(Buffer.from(chunk));
for (const data of splitUtf8(
text,
TERMINAL_LIMITS.maximumEventBytes,
)) {
emit({ type: "output", data });
}
output.push(text);
updateBackpressure();
});
channel.once(
"exit",
@@ -210,15 +208,8 @@ export async function createSshTerminalSession(
release();
return;
}
const finalText = decoder.end();
if (finalText) {
for (const data of splitUtf8(
finalText,
TERMINAL_LIMITS.maximumEventBytes,
)) {
emit({ type: "output", data });
}
}
output.push(decoder.end());
output.flush();
if (!lease.isUsable()) {
interrupt();
return;
@@ -279,9 +270,7 @@ export async function createSshTerminalSession(
if (
paused &&
pendingEvents.length < TERMINAL_LIMITS.maximumPendingEvents &&
pendingBytes <
TERMINAL_LIMITS.maximumBufferedOutputBytes -
TERMINAL_LIMITS.maximumEventBytes
pendingBytes + output.bufferedBytes < PAUSE_PENDING_BYTES
) {
paused = false;
channel.resume();
@@ -0,0 +1,165 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TERMINAL_LIMITS } from '../../shared/terminal-contracts'
import {
TERMINAL_OUTPUT_FLUSH_INTERVAL_MS,
TerminalOutputCoalescer,
splitUtf8Bounded
} from './terminal-output-coalescer'
describe('TerminalOutputCoalescer', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('emits the first chunk immediately and merges a burst into one flush', () => {
const emitted: string[] = []
const coalescer = new TerminalOutputCoalescer({
emit: (data) => emitted.push(data)
})
coalescer.push('$ ')
expect(emitted).toEqual(['$ '])
for (let index = 0; index < 100; index += 1) {
coalescer.push(`${index},`)
}
expect(emitted).toHaveLength(1)
vi.advanceTimersByTime(TERMINAL_OUTPUT_FLUSH_INTERVAL_MS)
expect(emitted).toHaveLength(2)
expect(emitted.join('')).toBe(
'$ ' + Array.from({ length: 100 }, (_, index) => `${index},`).join('')
)
coalescer.dispose()
})
it('preserves exact order across timer, size-cap and explicit flushes', () => {
const emitted: string[] = []
const coalescer = new TerminalOutputCoalescer({
emit: (data) => emitted.push(data),
maximumBatchBytes: 16
})
const expected: string[] = []
for (let index = 0; index < 500; index += 1) {
const chunk = index % 7 === 0 ? `你好${index}` : `#${index};`
expected.push(chunk)
coalescer.push(chunk)
if (index % 13 === 0) {
vi.advanceTimersByTime(TERMINAL_OUTPUT_FLUSH_INTERVAL_MS)
}
if (index % 97 === 0) {
coalescer.flush()
}
}
coalescer.flush()
expect(emitted.join('')).toBe(expected.join(''))
expect(emitted.every((chunk) => Buffer.byteLength(chunk) <= 16)).toBe(true)
expect(coalescer.bufferedBytes).toBe(0)
coalescer.dispose()
})
it('emits full batches at the size cap without waiting for the timer', () => {
const emitted: string[] = []
const coalescer = new TerminalOutputCoalescer({
emit: (data) => emitted.push(data)
})
const cap = TERMINAL_LIMITS.maximumEventBytes
coalescer.push('x')
const block = 'y'.repeat(4096)
for (let index = 0; index < 40; index += 1) {
coalescer.push(block)
}
// 160 KiB buffered inside one window: two full 64 KiB batches go out
// immediately; the remainder waits for the timer.
expect(emitted.slice(1).map((chunk) => Buffer.byteLength(chunk))).toEqual([
cap,
cap
])
expect(coalescer.bufferedBytes).toBe(40 * 4096 - 2 * cap)
expect(coalescer.bufferedBytes).toBeLessThan(cap)
vi.advanceTimersByTime(TERMINAL_OUTPUT_FLUSH_INTERVAL_MS)
expect(coalescer.bufferedBytes).toBe(0)
expect(emitted.join('')).toBe('x' + block.repeat(40))
coalescer.dispose()
})
it('never splits a multi-byte UTF-8 character across batches', () => {
const emitted: string[] = []
const coalescer = new TerminalOutputCoalescer({
emit: (data) => emitted.push(data),
maximumBatchBytes: 5
})
coalescer.push('a')
coalescer.push('你好世界🙂')
coalescer.flush()
expect(emitted.join('')).toBe('a你好世界🙂')
for (const chunk of emitted) {
expect(Buffer.from(chunk).toString('utf8')).toBe(chunk)
expect(chunk).not.toContain('\uFFFD')
}
coalescer.dispose()
})
it('dispose cancels the timer and drops later input', () => {
const emit = vi.fn()
const coalescer = new TerminalOutputCoalescer({ emit })
coalescer.push('a')
coalescer.push('b')
coalescer.dispose()
expect(vi.getTimerCount()).toBe(0)
coalescer.push('c')
coalescer.flush()
vi.advanceTimersByTime(100)
expect(emit.mock.calls).toEqual([['a']])
})
it('stops scheduling once idle', () => {
const coalescer = new TerminalOutputCoalescer({ emit: vi.fn() })
coalescer.push('a')
vi.advanceTimersByTime(TERMINAL_OUTPUT_FLUSH_INTERVAL_MS)
expect(vi.getTimerCount()).toBe(0)
coalescer.dispose()
})
it('reduces IPC events by orders of magnitude for a 20 MB flood', () => {
// Model node-pty delivering ~4 KiB reads; compare the number of output
// events (one IPC send each) before and after coalescing.
const totalBytes = 20 * 1024 * 1024
const readBytes = 4096
const read = 'z'.repeat(readBytes)
let coalescedEvents = 0
let coalescedBytes = 0
const coalescer = new TerminalOutputCoalescer({
emit: (data) => {
coalescedEvents += 1
coalescedBytes += data.length
}
})
const reads = totalBytes / readBytes
for (let index = 0; index < reads; index += 1) {
coalescer.push(read)
if (index % 64 === 0) {
vi.advanceTimersByTime(1)
}
}
coalescer.flush()
coalescer.dispose()
expect(coalescedBytes).toBe(totalBytes)
// Uncoalesced: one event per read (5120). Coalesced: ~64 KiB per event.
expect(coalescedEvents).toBeLessThanOrEqual(
Math.ceil(totalBytes / TERMINAL_LIMITS.maximumEventBytes) + 100
)
expect(coalescedEvents).toBeLessThan(reads / 10)
})
})
describe('splitUtf8Bounded', () => {
it('returns no chunks for empty input and a single chunk when small', () => {
expect(splitUtf8Bounded('', 4)).toEqual([])
expect(splitUtf8Bounded('abc', 4)).toEqual(['abc'])
})
})
@@ -0,0 +1,161 @@
import { TERMINAL_LIMITS } from '../../shared/terminal-contracts'
/**
* Minimum spacing between timer-driven flushes. Output arriving while the
* coalescer is idle is flushed immediately (leading edge), so interactive
* echo gets no added latency; bursts inside the window are merged.
*/
export const TERMINAL_OUTPUT_FLUSH_INTERVAL_MS = 8
/** A full batch is flushed without waiting for the timer. */
export const TERMINAL_OUTPUT_MAXIMUM_BATCH_BYTES =
TERMINAL_LIMITS.maximumEventBytes
type TimerHandle = ReturnType<typeof setTimeout>
export type TerminalOutputCoalescerOptions = {
/** Receives ordered chunks of at most `maximumBatchBytes` UTF-8 bytes. */
emit: (data: string) => void
flushIntervalMs?: number
maximumBatchBytes?: number
}
/**
* Splits text into ordered chunks of at most `maximumBytes` UTF-8 bytes without
* cutting a multi-byte sequence.
*/
export function splitUtf8Bounded(
data: string,
maximumBytes: number
): string[] {
const encoded = Buffer.from(data)
if (encoded.byteLength <= maximumBytes) {
return encoded.byteLength === 0 ? [] : [data]
}
const chunks: string[] = []
let offset = 0
while (offset < encoded.byteLength) {
let end = Math.min(offset + maximumBytes, encoded.byteLength)
if (end < encoded.byteLength) {
while (end > offset && (encoded[end]! & 0xc0) === 0x80) {
end -= 1
}
}
if (end === offset) {
end = Math.min(offset + 1, encoded.byteLength)
}
chunks.push(encoded.subarray(offset, end).toString('utf8'))
offset = end
}
return chunks
}
/**
* Per-terminal output buffer in Main. It never holds more than one batch
* (`maximumBatchBytes`), so a slow renderer is handled by the session's
* ACK-based backpressure rather than by growing this buffer.
*/
export class TerminalOutputCoalescer {
private readonly emitChunk: (data: string) => void
private readonly flushIntervalMs: number
private readonly maximumBatchBytes: number
private chunks: string[] = []
private bytes = 0
private timer: TimerHandle | undefined
private disposed = false
constructor(options: TerminalOutputCoalescerOptions) {
this.emitChunk = options.emit
this.flushIntervalMs =
options.flushIntervalMs ?? TERMINAL_OUTPUT_FLUSH_INTERVAL_MS
this.maximumBatchBytes =
options.maximumBatchBytes ?? TERMINAL_OUTPUT_MAXIMUM_BATCH_BYTES
}
/** UTF-8 bytes received but not yet emitted. */
get bufferedBytes(): number {
return this.bytes
}
get isDisposed(): boolean {
return this.disposed
}
push(data: string): void {
if (this.disposed || data.length === 0) {
return
}
this.chunks.push(data)
this.bytes += Buffer.byteLength(data)
if (this.timer === undefined) {
this.flush()
this.startWindow()
return
}
if (this.bytes >= this.maximumBatchBytes) {
this.emitFullBatches()
}
}
/** Emits everything buffered, in order. Safe to call re-entrantly. */
flush(): void {
if (this.disposed || this.bytes === 0) {
return
}
const data = this.take()
for (const chunk of splitUtf8Bounded(data, this.maximumBatchBytes)) {
if (this.disposed) {
return
}
this.emitChunk(chunk)
}
}
/** Drops buffered data and cancels the timer. Further input is ignored. */
dispose(): void {
this.disposed = true
this.chunks = []
this.bytes = 0
if (this.timer !== undefined) {
clearTimeout(this.timer)
this.timer = undefined
}
}
private emitFullBatches(): void {
const parts = splitUtf8Bounded(this.take(), this.maximumBatchBytes)
const last = parts.at(-1)
if (last !== undefined && Buffer.byteLength(last) < this.maximumBatchBytes) {
parts.pop()
this.chunks = [last]
this.bytes = Buffer.byteLength(last)
}
for (const chunk of parts) {
if (this.disposed) {
return
}
this.emitChunk(chunk)
}
}
private take(): string {
const data = this.chunks.length === 1 ? this.chunks[0]! : this.chunks.join('')
this.chunks = []
this.bytes = 0
return data
}
private startWindow(): void {
if (this.disposed) {
return
}
this.timer = setTimeout(() => {
this.timer = undefined
if (this.bytes > 0) {
this.flush()
this.startWindow()
}
}, this.flushIntervalMs)
this.timer.unref?.()
}
}