diff --git a/config/scripts/summarize-terminal-perf-report.mjs b/config/scripts/summarize-terminal-perf-report.mjs index cbae766b3..a6e0654a9 100644 --- a/config/scripts/summarize-terminal-perf-report.mjs +++ b/config/scripts/summarize-terminal-perf-report.mjs @@ -87,7 +87,12 @@ function printMarkdownTable(rows) { ['Main In-Flight Chars', 'mainInFlightChars'], ['Main Max In-Flight', 'mainMaxInFlightChars'], ['Main Active PTYs', 'mainActivePtys'], - ['Main Flush Scheduled', 'mainFlushScheduled'] + ['Main Flush Scheduled', 'mainFlushScheduled'], + ['Main Peak Pending', 'mainPeakPendingChars'], + ['Main Peak Max Pending', 'mainPeakMaxPendingChars'], + ['Main Peak In-Flight', 'mainPeakInFlightChars'], + ['Main Peak Max In-Flight', 'mainPeakMaxInFlightChars'], + ['Main ACK-Gated Skips', 'mainAckGatedFlushSkips'] ] console.log(`| ${columns.map(([label]) => label).join(' | ')} |`) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 76584c432..8d6882ed6 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -152,6 +152,7 @@ import { clearProviderPtyState, deletePtyOwnership, getPtyRendererDeliveryDebugSnapshot, + resetPtyRendererDeliveryDebug, getPtyIdForPaneKey, hasPendingRendererSerializerForPaneKey, setPtyOwnership, @@ -4231,7 +4232,12 @@ describe('registerPtyHandlers', () => { rendererInFlightPtyCount: 1, rendererInFlightChars: 512 * 1024, maxRendererInFlightCharsByPty: 512 * 1024, - flushScheduled: false + flushScheduled: false, + peakPendingChars: 600 * 1024, + peakMaxPendingCharsByPty: 600 * 1024, + peakRendererInFlightChars: 512 * 1024, + peakMaxRendererInFlightCharsByPty: 512 * 1024, + ackGatedFlushSkipCount: 1 }) secondProc.emitData('second-terminal-output') @@ -4254,7 +4260,20 @@ describe('registerPtyHandlers', () => { expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingPtyCount: 1, pendingChars: 72 * 1024, - rendererInFlightChars: 512 * 1024 + 'second-terminal-output'.length + rendererInFlightChars: 512 * 1024 + 'second-terminal-output'.length, + peakPendingChars: 600 * 1024, + peakRendererInFlightChars: 512 * 1024 + 'second-terminal-output'.length + }) + + resetPtyRendererDeliveryDebug() + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 72 * 1024, + rendererInFlightChars: 512 * 1024 + 'second-terminal-output'.length, + peakPendingChars: 72 * 1024, + peakRendererInFlightChars: 512 * 1024 + 'second-terminal-output'.length, + ackGatedFlushSkipCount: 0 }) } finally { vi.useRealTimers() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 445b9497a..e2edb4494 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -902,6 +902,11 @@ export type PtyRendererDeliveryDebugSnapshot = { maxRendererInFlightCharsByPty: number activeRendererPtyCount: number flushScheduled: boolean + peakPendingChars: number + peakMaxPendingCharsByPty: number + peakRendererInFlightChars: number + peakMaxRendererInFlightCharsByPty: number + ackGatedFlushSkipCount: number } const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = { @@ -912,17 +917,27 @@ const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapsh rendererInFlightChars: 0, maxRendererInFlightCharsByPty: 0, activeRendererPtyCount: 0, - flushScheduled: false + flushScheduled: false, + peakPendingChars: 0, + peakMaxPendingCharsByPty: 0, + peakRendererInFlightChars: 0, + peakMaxRendererInFlightCharsByPty: 0, + ackGatedFlushSkipCount: 0 } let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({ ...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT }) +let resetPtyRendererDeliveryDebugSnapshot = (): void => {} export function getPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { return readPtyRendererDeliveryDebugSnapshot() } +export function resetPtyRendererDeliveryDebug(): void { + resetPtyRendererDeliveryDebugSnapshot() +} + function clearDidFinishLoadHandler(): void { if (didFinishLoadHandler && didFinishLoadWebContents) { didFinishLoadWebContents.removeListener('did-finish-load', didFinishLoadHandler) @@ -966,6 +981,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:clearPendingPaneSerializer') ipcMain.removeHandler('pty:getMainBufferSnapshot') ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') + ipcMain.removeHandler('pty:resetRendererDeliveryDebug') ipcMain.removeHandler('pty:writeAccepted') ipcMain.removeAllListeners('pty:write') ipcMain.removeAllListeners('pty:ackColdRestore') @@ -1063,6 +1079,11 @@ export function registerPtyHandlers( const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 const INTERACTIVE_REDRAW_MAX_CHARS = PTY_BATCH_FLUSH_CHUNK_CHARS const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 + let peakPendingChars = 0 + let peakMaxPendingCharsByPty = 0 + let peakRendererInFlightChars = 0 + let peakMaxRendererInFlightCharsByPty = 0 + let ackGatedFlushSkipCount = 0 function getMaxMapValue(values: Iterable): number { let max = 0 @@ -1072,7 +1093,7 @@ export function registerPtyHandlers( return max } - readPtyRendererDeliveryDebugSnapshot = () => { + function readCurrentPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { let pendingChars = 0 let maxPendingCharsByPty = 0 for (const pending of pendingData.values()) { @@ -1088,10 +1109,36 @@ export function registerPtyHandlers( rendererInFlightChars: rendererInFlightTotalChars, maxRendererInFlightCharsByPty: getMaxMapValue(rendererInFlightCharsByPty.values()), activeRendererPtyCount: activeRendererPtys.size, - flushScheduled: flushTimer !== null + flushScheduled: flushTimer !== null, + peakPendingChars, + peakMaxPendingCharsByPty, + peakRendererInFlightChars, + peakMaxRendererInFlightCharsByPty, + ackGatedFlushSkipCount } } + function recordPtyRendererDeliveryPressure(): void { + const current = readCurrentPtyRendererDeliveryDebugSnapshot() + peakPendingChars = Math.max(peakPendingChars, current.pendingChars) + peakMaxPendingCharsByPty = Math.max(peakMaxPendingCharsByPty, current.maxPendingCharsByPty) + peakRendererInFlightChars = Math.max(peakRendererInFlightChars, current.rendererInFlightChars) + peakMaxRendererInFlightCharsByPty = Math.max( + peakMaxRendererInFlightCharsByPty, + current.maxRendererInFlightCharsByPty + ) + } + + readPtyRendererDeliveryDebugSnapshot = readCurrentPtyRendererDeliveryDebugSnapshot + resetPtyRendererDeliveryDebugSnapshot = () => { + peakPendingChars = 0 + peakMaxPendingCharsByPty = 0 + peakRendererInFlightChars = 0 + peakMaxRendererInFlightCharsByPty = 0 + ackGatedFlushSkipCount = 0 + recordPtyRendererDeliveryPressure() + } + function isLikelyInteractiveRedraw(data: string): boolean { if (data.length <= INTERACTIVE_OUTPUT_MAX_CHARS) { return true @@ -1159,6 +1206,7 @@ export function registerPtyHandlers( const charCount = getPtyPayloadCharCount(payload) rendererInFlightCharsByPty.set(id, (rendererInFlightCharsByPty.get(id) ?? 0) + charCount) rendererInFlightTotalChars += charCount + recordPtyRendererDeliveryPressure() mainWindow.webContents.send('pty:data', payload) } @@ -1206,6 +1254,7 @@ export function registerPtyHandlers( pendingData.clear() rendererInFlightCharsByPty.clear() rendererInFlightTotalChars = 0 + recordPtyRendererDeliveryPressure() return } let writes = 0 @@ -1230,6 +1279,10 @@ export function registerPtyHandlers( sendPtyDataToRenderer(id, makePtyDataPayload(id, chunk, pending.startSeq)) writes++ } + if (pendingData.size > 0 && writes === 0) { + ackGatedFlushSkipCount++ + } + recordPtyRendererDeliveryPressure() if (pendingData.size > 0 && writes > 0) { // Why: a background terminal can dump megabytes at once. Yield between // small IPC slices so keystroke writes are not stuck behind one flush. @@ -1277,6 +1330,7 @@ export function registerPtyHandlers( pendingData.clear() rendererInFlightCharsByPty.clear() rendererInFlightTotalChars = 0 + recordPtyRendererDeliveryPressure() return } const existing = pendingData.get(payload.id) @@ -1293,6 +1347,7 @@ export function registerPtyHandlers( // bounded, and the per-PTY cap still prevents an active TUI runaway. if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) { pendingData.set(payload.id, pending) + recordPtyRendererDeliveryPressure() return } pendingData.delete(payload.id) @@ -1309,6 +1364,7 @@ export function registerPtyHandlers( return } pendingData.set(payload.id, pending) + recordPtyRendererDeliveryPressure() if (!flushTimer) { schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) } @@ -1339,6 +1395,7 @@ export function registerPtyHandlers( rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0) ) rendererInFlightCharsByPty.delete(payload.id) + recordPtyRendererDeliveryPressure() mainWindow.webContents.send('pty:exit', payload) } }) @@ -1861,6 +1918,9 @@ export function registerPtyHandlers( ipcMain.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { return getPtyRendererDeliveryDebugSnapshot() }) + ipcMain.handle('pty:resetRendererDeliveryDebug', (): void => { + resetPtyRendererDeliveryDebug() + }) ipcMain.handle( 'pty:spawn', @@ -2540,6 +2600,7 @@ export function registerPtyHandlers( rendererInFlightCharsByPty.set(args.id, next) } tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) + recordPtyRendererDeliveryPressure() if (pendingData.size > 0 && !flushTimer) { schedulePendingDataFlush(0) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 2837cca33..cc5372e91 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -927,7 +927,13 @@ export type PreloadApi = { maxRendererInFlightCharsByPty: number activeRendererPtyCount: number flushScheduled: boolean + peakPendingChars: number + peakMaxPendingCharsByPty: number + peakRendererInFlightChars: number + peakMaxRendererInFlightCharsByPty: number + ackGatedFlushSkipCount: number }> + resetRendererDeliveryDebug: () => Promise onData: ( callback: (data: { id: string; data: string; seq?: number; rawLength?: number }) => void ) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 2431e894a..5c123f09d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -717,8 +717,16 @@ const api = { maxRendererInFlightCharsByPty: number activeRendererPtyCount: number flushScheduled: boolean + peakPendingChars: number + peakMaxPendingCharsByPty: number + peakRendererInFlightChars: number + peakMaxRendererInFlightCharsByPty: number + ackGatedFlushSkipCount: number }> => ipcRenderer.invoke('pty:getRendererDeliveryDebugSnapshot'), + resetRendererDeliveryDebug: (): Promise => + ipcRenderer.invoke('pty:resetRendererDeliveryDebug'), + /** Check if a PTY's shell has child processes (e.g. a running command). * Returns false for an idle shell prompt. */ hasChildProcesses: (id: string): Promise => diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 2b77302ba..299f49247 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2160,6 +2160,23 @@ function createPtyApi(): NonNullable['pty']> { getCwd: () => Promise.resolve('~'), listSessions: () => Promise.resolve([]), getMainBufferSnapshot: () => Promise.resolve(null), + getRendererDeliveryDebugSnapshot: () => + Promise.resolve({ + pendingPtyCount: 0, + pendingChars: 0, + maxPendingCharsByPty: 0, + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0, + maxRendererInFlightCharsByPty: 0, + activeRendererPtyCount: 0, + flushScheduled: false, + peakPendingChars: 0, + peakMaxPendingCharsByPty: 0, + peakRendererInFlightChars: 0, + peakMaxRendererInFlightCharsByPty: 0, + ackGatedFlushSkipCount: 0 + }), + resetRendererDeliveryDebug: () => Promise.resolve(), onData: () => noopUnsubscribe, onReplay: () => noopUnsubscribe, onExit: () => noopUnsubscribe, diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index 0cdc90baa..372f50c56 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -74,6 +74,11 @@ type MainPtyPressureDebugSnapshot = { maxRendererInFlightCharsByPty: number activeRendererPtyCount: number flushScheduled: boolean + peakPendingChars: number + peakMaxPendingCharsByPty: number + peakRendererInFlightChars: number + peakMaxRendererInFlightCharsByPty: number + ackGatedFlushSkipCount: number } const KEY_LATENCY_SAMPLES = 'abcdefghijklmnop' @@ -343,9 +348,10 @@ async function measureTypingDuringLoad( } async function resetTerminalPtyOutputDebug(page: Page): Promise { - await page.evaluate(() => { + await page.evaluate(async () => { ;(window as SyntheticOpenCodeWindow).__terminalPtyOutputDebug?.reset() ;(window as SyntheticOpenCodeWindow).__terminalOutputSchedulerDebug?.reset() + await window.api.pty.resetRendererDeliveryDebug() }) } @@ -387,7 +393,7 @@ function annotateTypingMeasurement( ? ` deferredForegroundEnqueue=${scheduler.deferredForegroundEnqueueCount} deferredForegroundWrite=${scheduler.deferredForegroundWriteCount} scheduledDrains=${scheduler.scheduledDrainCount}` : '' const mainPressureSummary = mainPressure - ? ` mainPendingPtys=${mainPressure.pendingPtyCount} mainPendingChars=${mainPressure.pendingChars} mainMaxPendingChars=${mainPressure.maxPendingCharsByPty} mainInFlightPtys=${mainPressure.rendererInFlightPtyCount} mainInFlightChars=${mainPressure.rendererInFlightChars} mainMaxInFlightChars=${mainPressure.maxRendererInFlightCharsByPty} mainActivePtys=${mainPressure.activeRendererPtyCount} mainFlushScheduled=${mainPressure.flushScheduled}` + ? ` mainPendingPtys=${mainPressure.pendingPtyCount} mainPendingChars=${mainPressure.pendingChars} mainMaxPendingChars=${mainPressure.maxPendingCharsByPty} mainInFlightPtys=${mainPressure.rendererInFlightPtyCount} mainInFlightChars=${mainPressure.rendererInFlightChars} mainMaxInFlightChars=${mainPressure.maxRendererInFlightCharsByPty} mainActivePtys=${mainPressure.activeRendererPtyCount} mainFlushScheduled=${mainPressure.flushScheduled} mainPeakPendingChars=${mainPressure.peakPendingChars} mainPeakMaxPendingChars=${mainPressure.peakMaxPendingCharsByPty} mainPeakInFlightChars=${mainPressure.peakRendererInFlightChars} mainPeakMaxInFlightChars=${mainPressure.peakMaxRendererInFlightCharsByPty} mainAckGatedFlushSkips=${mainPressure.ackGatedFlushSkipCount}` : '' testInfo.annotations.push({ type,