perf: coalesce terminal output in Main and write it to xterm once per frame
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user