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?.()
}
}
+116
View File
@@ -8,7 +8,9 @@ import type {
} from '../../shared/terminal-contracts'
import {
TerminalPanel,
resetTerminalRendererWarningsForTests,
resolveTerminalFontFamily,
warnTerminalRendererFallback,
type TerminalAdapter,
type TerminalClipboardAdapter,
type TerminalEmulator,
@@ -483,6 +485,120 @@ describe('TerminalPanel', () => {
)
})
it('batches output into one write per frame and ACKs after the write callback', async () => {
const frames = new Set<() => void>()
const frameScheduler = (callback: () => void): (() => void) => {
frames.add(callback)
return () => frames.delete(callback)
}
const runFrame = (): void => {
const current = [...frames]
frames.clear()
current.forEach((callback) => callback())
}
const backend = adapterHarness()
const emulator = emulatorHarness()
const callbacks: Array<() => void> = []
emulator.write.mockImplementation((_: string, callback?: () => void) => {
if (callback) {
callbacks.push(callback)
}
})
render(
<TerminalPanel
adapter={backend.adapter}
frameScheduler={frameScheduler}
target={{ type: 'local' }}
terminalFactory={emulator.factory}
/>
)
resizeObserver.trigger()
await waitFor(() =>
expect(backend.adapter.create).toHaveBeenCalledOnce()
)
for (let sequence = 1; sequence <= 5; sequence += 1) {
backend.emit({ sessionId, sequence, type: 'output', data: `${sequence};` })
}
await waitFor(() => expect(frames.size).toBe(1))
expect(emulator.write).not.toHaveBeenCalled()
runFrame()
expect(emulator.write).toHaveBeenCalledOnce()
expect(emulator.write).toHaveBeenCalledWith('1;2;3;4;5;', expect.any(Function))
expect(backend.adapter.ack).not.toHaveBeenCalled()
callbacks.shift()?.()
expect(backend.adapter.ack).toHaveBeenCalledOnce()
expect(backend.adapter.ack).toHaveBeenCalledWith({ sessionId, sequence: 5 })
// A control event flushes pending output first and is applied after it.
backend.emit({ sessionId, sequence: 6, type: 'output', data: 'bye' })
backend.emit({
sessionId,
sequence: 7,
type: 'exit',
exit: { exitCode: 0, signal: null }
})
await waitFor(() =>
expect(emulator.write).toHaveBeenLastCalledWith('bye', expect.any(Function))
)
expect(screen.queryByText('已退出')).not.toBeInTheDocument()
callbacks.shift()?.()
await waitFor(() =>
expect(backend.adapter.ack).toHaveBeenLastCalledWith({ sessionId, sequence: 7 })
)
expect(backend.adapter.ack).toHaveBeenCalledWith({ sessionId, sequence: 6 })
expect(emulator.write).toHaveBeenCalledTimes(2)
})
it('keeps writing output for a hidden terminal tab', async () => {
const backend = adapterHarness()
const emulator = emulatorHarness()
render(
<div hidden>
<TerminalPanel
adapter={backend.adapter}
sessionId={sessionId}
target={{ type: 'local' }}
terminalFactory={emulator.factory}
/>
</div>
)
await waitFor(() =>
expect(backend.adapter.getSnapshot).toHaveBeenCalled()
)
await screen.findByText('PowerShell 7')
backend.emit({ sessionId, sequence: 1, type: 'output', data: 'a' })
backend.emit({ sessionId, sequence: 2, type: 'output', data: 'b' })
await waitFor(() =>
expect(emulator.write).toHaveBeenCalledWith('ab', expect.any(Function))
)
await waitFor(() =>
expect(backend.adapter.ack).toHaveBeenCalledWith({ sessionId, sequence: 2 })
)
})
it('logs a bounded, content-free warning when the WebGL renderer falls back', () => {
resetTerminalRendererWarningsForTests()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
for (let index = 0; index < 20; index += 1) {
warnTerminalRendererFallback(
index % 2 === 0 ? 'webgl-load-failed' : 'webgl-context-lost',
new TypeError('secret user content')
)
}
expect(warn).toHaveBeenCalledTimes(5)
expect(warn).toHaveBeenCalledWith(
'[terminal] webgl-load-failed; falling back to the DOM renderer',
{ error: 'TypeError' }
)
expect(JSON.stringify(warn.mock.calls)).not.toContain('secret')
} finally {
warn.mockRestore()
resetTerminalRendererWarningsForTests()
}
})
it('uses the resolved cross-platform monospace font stack', () => {
document.documentElement.style.setProperty(
'--font-family-mono',
+111 -39
View File
@@ -28,6 +28,10 @@ import type {
TerminalTarget
} from '../../shared/terminal-contracts'
import './terminal-panel.css'
import {
TerminalOutputBatcher,
type TerminalFrameScheduler
} from './terminal-output-batcher'
export type TerminalAdapter = Omit<DesktopApi['terminal'], 'onEvent'> & {
subscribe: DesktopApi['terminal']['onEvent']
@@ -74,6 +78,8 @@ export type TerminalPanelProps = {
sessionId?: TerminalSessionId
title?: string
terminalFactory?: TerminalFactory
/** Test seam for the per-frame output batching scheduler. */
frameScheduler?: TerminalFrameScheduler
onSessionChange?: (snapshot: TerminalSnapshot) => void
onRename?: (title: string) => void
}
@@ -105,6 +111,31 @@ export function resolveTerminalFontFamily(): string {
return configured || fallbackTerminalFontFamily
}
/** Diagnostics are bounded per renderer so a flapping GPU cannot spam logs. */
const MAXIMUM_RENDERER_WARNINGS = 5
let rendererWarningCount = 0
export function warnTerminalRendererFallback(
reason: 'webgl-load-failed' | 'webgl-context-lost',
cause?: unknown
): void {
if (rendererWarningCount >= MAXIMUM_RENDERER_WARNINGS) {
return
}
rendererWarningCount += 1
// Only the error class is logged: messages could echo environment details.
const errorName =
cause instanceof Error ? cause.name.slice(0, 64) : undefined
console.warn(
`[terminal] ${reason}; falling back to the DOM renderer`,
errorName ? { error: errorName } : {}
)
}
export function resetTerminalRendererWarningsForTests(): void {
rendererWarningCount = 0
}
function createDefaultTerminal(): TerminalFactoryResult {
const terminal = new Terminal({
cursorBlink: true,
@@ -122,14 +153,22 @@ function createDefaultTerminal(): TerminalFactoryResult {
fitAddon,
searchAddon,
activateRenderer: () => {
const webglAddon = new WebglAddon()
let webglAddon: WebglAddon
try {
webglAddon = new WebglAddon()
} catch (cause) {
warnTerminalRendererFallback('webgl-load-failed', cause)
return
}
const contextLossSubscription = webglAddon.onContextLoss(() => {
warnTerminalRendererFallback('webgl-context-lost')
contextLossSubscription.dispose()
webglAddon.dispose()
})
try {
terminal.loadAddon(webglAddon)
} catch {
} catch (cause) {
warnTerminalRendererFallback('webgl-load-failed', cause)
contextLossSubscription.dispose()
webglAddon.dispose()
}
@@ -157,6 +196,7 @@ export function TerminalPanel({
sessionId,
title,
terminalFactory = createDefaultTerminal,
frameScheduler,
onSessionChange,
onRename
}: TerminalPanelProps): React.JSX.Element {
@@ -176,6 +216,9 @@ export function TerminalPanel({
const mountedRef = useRef(false)
const acceptedSequenceRef = useRef(0)
const eventQueueRef = useRef(Promise.resolve())
const outputBatcherRef = useRef<TerminalOutputBatcher | undefined>(
undefined
)
const lastSizeRef = useRef<
{ cols: number; rows: number } | undefined
>(undefined)
@@ -213,6 +256,17 @@ export function TerminalPanel({
}
}, [])
const reportAckFailure = useCallback((reason: unknown): void => {
if (mountedRef.current) {
setError(
failureMessage(
reason,
translationRef.current('sidebar.terminal.errors.ack')
)
)
}
}, [])
const consumeEvent = useCallback(
async (event: TerminalEvent): Promise<void> => {
if (
@@ -225,33 +279,42 @@ export function TerminalPanel({
acceptedSequenceRef.current = event.sequence
if (event.type === 'output') {
await new Promise<void>((resolve) => {
emulatorRef.current?.write(event.data, resolve)
if (!emulatorRef.current) {
resolve()
}
})
} else {
const current = snapshotRef.current
if (current) {
if (event.type === 'state') {
publishSnapshot({ ...current, state: event.state })
} else if (event.type === 'exit') {
publishSnapshot({
...current,
state: 'exited',
exit: event.exit,
error: null
})
} else {
publishSnapshot({
...current,
state: 'failed',
error: event.error,
exit: null
})
setError(event.error.message)
}
// Merged with neighbours and written once per frame; the batcher ACKs
// the batch's last sequence after xterm has processed it.
outputBatcherRef.current?.push(
event.sessionId,
event.sequence,
event.data
)
return
}
// Control events apply only after all earlier output has been written.
await outputBatcherRef.current?.flush()
if (
!mountedRef.current ||
event.sessionId !== activeSessionRef.current
) {
return
}
const current = snapshotRef.current
if (current) {
if (event.type === 'state') {
publishSnapshot({ ...current, state: event.state })
} else if (event.type === 'exit') {
publishSnapshot({
...current,
state: 'exited',
exit: event.exit,
error: null
})
} else {
publishSnapshot({
...current,
state: 'failed',
error: event.error,
exit: null
})
setError(event.error.message)
}
}
@@ -262,18 +325,11 @@ export function TerminalPanel({
sequence: event.sequence
})
} catch (reason) {
if (mountedRef.current) {
setError(
failureMessage(
reason,
translationRef.current('sidebar.terminal.errors.ack')
)
)
}
reportAckFailure(reason)
}
}
},
[adapter, publishSnapshot]
[adapter, publishSnapshot, reportAckFailure]
)
useEffect(() => {
@@ -291,6 +347,19 @@ export function TerminalPanel({
terminal.open(hostRef.current)
activateRenderer?.()
}
const outputBatcher = new TerminalOutputBatcher({
write: (data, done) => terminal.write(data, done),
acknowledge: (ackSessionId, sequence) => {
if (!mountedRef.current) {
return
}
adapter
.ack({ sessionId: ackSessionId, sequence })
.catch(reportAckFailure)
},
...(frameScheduler ? { schedule: frameScheduler } : {})
})
outputBatcherRef.current = outputBatcher
const inputSubscription = terminal.onData((data) => {
const current = snapshotRef.current
@@ -322,12 +391,14 @@ export function TerminalPanel({
operationGenerationRef.current += 1
unsubscribe()
inputSubscription.dispose()
outputBatcher.dispose()
outputBatcherRef.current = undefined
terminal.dispose()
emulatorRef.current = undefined
fitAddonRef.current = undefined
searchAddonRef.current = undefined
}
}, [adapter, consumeEvent, terminalFactory])
}, [adapter, consumeEvent, frameScheduler, reportAckFailure, terminalFactory])
const attachSession = useCallback(
async (
@@ -548,6 +619,7 @@ export function TerminalPanel({
if (!mountedRef.current) {
return
}
outputBatcherRef.current?.reset()
emulatorRef.current?.clear()
activeSessionRef.current = undefined
acceptedSequenceRef.current = 0
@@ -0,0 +1,164 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
TERMINAL_FRAME_FALLBACK_MS,
TerminalOutputBatcher,
scheduleTerminalFrame,
type TerminalFrameScheduler
} from './terminal-output-batcher'
function manualScheduler(): {
schedule: TerminalFrameScheduler
runFrame: () => void
pendingFrames: () => number
} {
const callbacks = new Set<() => void>()
return {
schedule: (callback) => {
callbacks.add(callback)
return () => callbacks.delete(callback)
},
runFrame: () => {
const current = [...callbacks]
callbacks.clear()
for (const callback of current) {
callback()
}
},
pendingFrames: () => callbacks.size
}
}
describe('TerminalOutputBatcher', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('writes once per frame in order and ACKs the last sequence after the write callback', async () => {
const frames = manualScheduler()
const writes: Array<{ data: string; done: () => void }> = []
const acks: number[] = []
const batcher = new TerminalOutputBatcher({
write: (data, done) => writes.push({ data, done }),
acknowledge: (_, sequence) => acks.push(sequence),
schedule: frames.schedule
})
batcher.push('s', 3, 'a')
batcher.push('s', 4, 'b')
batcher.push('s', 5, 'c')
expect(frames.pendingFrames()).toBe(1)
expect(writes).toHaveLength(0)
frames.runFrame()
expect(writes.map((write) => write.data)).toEqual(['abc'])
expect(acks).toEqual([])
writes[0]!.done()
expect(acks).toEqual([5])
batcher.push('s', 6, 'd')
frames.runFrame()
writes[1]!.done()
writes[1]!.done()
expect(writes.map((write) => write.data)).toEqual(['abc', 'd'])
expect(acks).toEqual([5, 6])
batcher.dispose()
})
it('flush writes pending output immediately and waits for the emulator', async () => {
const frames = manualScheduler()
let release: (() => void) | undefined
const batcher = new TerminalOutputBatcher({
write: (_, done) => {
release = done
},
acknowledge: vi.fn(),
schedule: frames.schedule
})
batcher.push('s', 1, 'x')
let flushed = false
const flush = batcher.flush().then(() => {
flushed = true
})
expect(frames.pendingFrames()).toBe(0)
await Promise.resolve()
expect(flushed).toBe(false)
release?.()
await flush
expect(flushed).toBe(true)
batcher.dispose()
})
it('reset drops unwritten output but ACKs it so Main is not left paused', () => {
const frames = manualScheduler()
const write = vi.fn()
const acknowledge = vi.fn()
const batcher = new TerminalOutputBatcher({
write,
acknowledge,
schedule: frames.schedule
})
batcher.push('old', 9, 'stale')
batcher.reset()
frames.runFrame()
expect(write).not.toHaveBeenCalled()
expect(acknowledge).toHaveBeenCalledWith('old', 9)
batcher.dispose()
})
it('does not ACK after dispose', () => {
const frames = manualScheduler()
let done: (() => void) | undefined
const acknowledge = vi.fn()
const batcher = new TerminalOutputBatcher({
write: (_, callback) => {
done = callback
},
acknowledge,
schedule: frames.schedule
})
batcher.push('s', 1, 'x')
frames.runFrame()
batcher.dispose()
done?.()
expect(acknowledge).not.toHaveBeenCalled()
})
it('keeps flushing hidden terminals via the timer fallback when frames stop', () => {
vi.useFakeTimers()
// Hidden/minimized windows never run animation frames.
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1))
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const writes: string[] = []
const acks: number[] = []
const batcher = new TerminalOutputBatcher({
write: (data, done) => {
writes.push(data)
done()
},
acknowledge: (_, sequence) => acks.push(sequence)
})
batcher.push('s', 1, 'hidden ')
batcher.push('s', 2, 'output')
vi.advanceTimersByTime(TERMINAL_FRAME_FALLBACK_MS)
expect(writes).toEqual(['hidden output'])
expect(acks).toEqual([2])
batcher.dispose()
})
it('scheduleTerminalFrame runs once whichever of frame or timer fires first', () => {
vi.useFakeTimers()
let frame: (() => void) | undefined
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: () => void) => {
frame = callback
return 7
}))
const cancelAnimationFrame = vi.fn()
vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame)
const callback = vi.fn()
scheduleTerminalFrame(callback)
frame?.()
vi.advanceTimersByTime(TERMINAL_FRAME_FALLBACK_MS * 2)
expect(callback).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
})
+155
View File
@@ -0,0 +1,155 @@
/**
* Renderer-side terminal output batching (P3): output events are merged and
* written to the emulator at most once per animation frame, in sequence order.
* Each batch is acknowledged with its highest sequence after the emulator's
* write callback, which is what Main's ACK-based backpressure expects.
*/
/** Requests `callback` once; the returned function cancels the request. */
export type TerminalFrameScheduler = (callback: () => void) => () => void
/**
* Animation frames stop while the window is minimized or occluded, so a timer
* fallback keeps output (and ACKs) flowing for hidden terminals. Without it
* Main would pause the PTY until the window became visible again.
*/
export const TERMINAL_FRAME_FALLBACK_MS = 100
export const scheduleTerminalFrame: TerminalFrameScheduler = (callback) => {
let settled = false
let frame: number | undefined
const run = (): void => {
if (settled) {
return
}
settled = true
if (frame !== undefined) {
cancelAnimationFrame(frame)
}
clearTimeout(timer)
callback()
}
const timer = setTimeout(run, TERMINAL_FRAME_FALLBACK_MS)
if (typeof requestAnimationFrame === 'function') {
frame = requestAnimationFrame(run)
}
return () => {
settled = true
if (frame !== undefined) {
cancelAnimationFrame(frame)
}
clearTimeout(timer)
}
}
export type TerminalOutputBatcherOptions = {
/** Writes data to the emulator; `done` must run after it is processed. */
write: (data: string, done: () => void) => void
/** Acknowledges every output event up to and including `sequence`. */
acknowledge: (sessionId: string, sequence: number) => void
schedule?: TerminalFrameScheduler
}
export class TerminalOutputBatcher {
private readonly writeData: TerminalOutputBatcherOptions['write']
private readonly acknowledge: TerminalOutputBatcherOptions['acknowledge']
private readonly schedule: TerminalFrameScheduler
private pending: string[] = []
private pendingSessionId: string | undefined
private pendingSequence = 0
private cancelFrame: (() => void) | undefined
private lastWrite: Promise<void> = Promise.resolve()
private disposed = false
constructor(options: TerminalOutputBatcherOptions) {
this.writeData = options.write
this.acknowledge = options.acknowledge
this.schedule = options.schedule ?? scheduleTerminalFrame
}
get hasPending(): boolean {
return this.pending.length > 0
}
/** Queues output; events must be pushed in increasing sequence order. */
push(sessionId: string, sequence: number, data: string): void {
if (this.disposed) {
return
}
if (this.pendingSessionId !== undefined && this.pendingSessionId !== sessionId) {
// Never mix streams: the previous session's output is obsolete.
this.reset()
}
this.pendingSessionId = sessionId
this.pendingSequence = sequence
this.pending.push(data)
this.cancelFrame ??= this.schedule(() => {
this.cancelFrame = undefined
void this.flush()
})
}
/**
* Writes queued output now and resolves after every write issued so far has
* been processed by the emulator (so callers can order control events).
*/
flush(): Promise<void> {
if (this.disposed) {
return Promise.resolve()
}
this.cancelFrame?.()
this.cancelFrame = undefined
if (this.pending.length > 0) {
const data = this.pending.length === 1 ? this.pending[0]! : this.pending.join('')
const sessionId = this.pendingSessionId!
const sequence = this.pendingSequence
this.pending = []
this.pendingSessionId = undefined
this.lastWrite = new Promise<void>((resolve) => {
let finished = false
const done = (): void => {
if (finished) {
return
}
finished = true
if (!this.disposed) {
this.acknowledge(sessionId, sequence)
}
resolve()
}
try {
this.writeData(data, done)
} catch {
done()
}
})
}
return this.lastWrite
}
/**
* Drops queued (unwritten) output when the session is replaced. The dropped
* events are still acknowledged so Main does not keep them counted against
* that session's backpressure window.
*/
reset(): void {
const sessionId = this.pendingSessionId
const sequence = this.pendingSequence
this.dropPending()
if (!this.disposed && sessionId !== undefined) {
this.acknowledge(sessionId, sequence)
}
}
dispose(): void {
this.disposed = true
this.dropPending()
}
private dropPending(): void {
this.cancelFrame?.()
this.cancelFrame = undefined
this.pending = []
this.pendingSessionId = undefined
}
}