162 lines
4.3 KiB
TypeScript
162 lines
4.3 KiB
TypeScript
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?.()
|
|
}
|
|
}
|