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
+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
}
}