From 05607f85a877fdaf3dbe1405af63dd0844413ba7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:21:46 -0700 Subject: [PATCH] perf(runtime): remove timer clamps from cooperative yields Renderer paste and input loops can schedule more than a thousand zero-delay timer yields for a maximum-size payload. Chromium clamps nested timers to 4ms, adding seconds of idle wall time. Use MessageChannel tasks in renderer runtimes and setImmediate in Node while retaining a timer fallback for tests and unsupported environments. --- src/main/browser/browser-text-insertion.ts | 3 +- src/main/opencode-usage/scanner.ts | 2 +- .../dictation/dictation-insertion-target.ts | 5 +- .../editor/monaco-large-text-paste.ts | 5 +- .../editor/rich-markdown-large-text-paste.ts | 5 +- .../terminal-pane/pty-input-write-queue.ts | 7 +-- .../components/terminal-pane/pty-transport.ts | 7 +-- .../terminal-pane/terminal-paste-executor.ts | 9 +-- .../src/lib/agent-draft-paste-content.ts | 7 +-- .../src/lib/paste-payload-metadata.ts | 10 ++-- .../src/lib/primary-selection-paste.ts | 5 +- src/renderer/src/lib/text-control-paste.ts | 5 +- src/shared/clipboard-text.ts | 10 ++-- src/shared/event-loop-yield.test.ts | 42 ++++++++++++++ src/shared/event-loop-yield.ts | 55 +++++++++++++++++++ 15 files changed, 122 insertions(+), 55 deletions(-) create mode 100644 src/shared/event-loop-yield.test.ts create mode 100644 src/shared/event-loop-yield.ts diff --git a/src/main/browser/browser-text-insertion.ts b/src/main/browser/browser-text-insertion.ts index 9acee214399..4d89b3a06fa 100644 --- a/src/main/browser/browser-text-insertion.ts +++ b/src/main/browser/browser-text-insertion.ts @@ -1,4 +1,5 @@ import { measureClipboardTextByteLength } from '../../shared/clipboard-text' +import { yieldToEventLoop } from '../../shared/event-loop-yield' import type { CdpCommandSender } from './snapshot-engine' export const BROWSER_TEXT_INSERT_CHUNK_BYTES = 64 * 1024 @@ -71,7 +72,7 @@ export async function insertTextThroughCdp( // process responsive between bounded CDP payloads. chunk = chunks.next() if (options?.yieldBetweenChunks !== false && !chunk.done) { - await new Promise((resolve) => setTimeout(resolve, 0)) + await yieldToEventLoop() } } } diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 57a95df3a62..f1d339c320b 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -145,7 +145,7 @@ export async function getProcessedDatabaseInfo( } async function yieldToEventLoop(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setImmediate(resolve)) } function getProjectJoin(db: Database.Database): string { diff --git a/src/renderer/src/components/dictation/dictation-insertion-target.ts b/src/renderer/src/components/dictation/dictation-insertion-target.ts index 923bf590fcb..4f8573be15a 100644 --- a/src/renderer/src/components/dictation/dictation-insertion-target.ts +++ b/src/renderer/src/components/dictation/dictation-insertion-target.ts @@ -1,3 +1,4 @@ +import { yieldToEventLoop } from '../../../../shared/event-loop-yield' import { TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES, TEXT_CONTROL_PASTE_DIRECT_MAX_BYTES, @@ -180,7 +181,3 @@ function getNextDictationChunkBoundary(text: string, startIndex: number, maxByte return index } - -function yieldToEventLoop(): Promise { - return new Promise((resolve) => globalThis.setTimeout(resolve, 0)) -} diff --git a/src/renderer/src/components/editor/monaco-large-text-paste.ts b/src/renderer/src/components/editor/monaco-large-text-paste.ts index 5a5b7dff2b3..7cb6ed83c76 100644 --- a/src/renderer/src/components/editor/monaco-large-text-paste.ts +++ b/src/renderer/src/components/editor/monaco-large-text-paste.ts @@ -1,4 +1,5 @@ import type { editor } from 'monaco-editor' +import { yieldToEventLoop } from '../../../../shared/event-loop-yield' import { measureTextControlPasteByteLength, measureTextControlPasteByteLengthWithYield @@ -158,10 +159,6 @@ function setCollapsedSelection(monacoEditor: MonacoPasteEditor, position: Positi }) } -function yieldToEventLoop(): Promise { - return new Promise((resolve) => globalThis.setTimeout(resolve, 0)) -} - async function insertMonacoTextInChunks( monacoEditor: MonacoPasteEditor, text: string, diff --git a/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts b/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts index d4eecf1e331..b6f87a0b01c 100644 --- a/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts +++ b/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts @@ -1,5 +1,6 @@ import type { Editor } from '@tiptap/react' import { toast } from 'sonner' +import { yieldToEventLoop } from '../../../../shared/event-loop-yield' import { measureTextControlPasteByteLength, measureTextControlPasteByteLengthWithYield @@ -87,10 +88,6 @@ function getNextChunkBoundary(text: string, startIndex: number, maxBytes: number return index } -function yieldToEventLoop(): Promise { - return new Promise((resolve) => globalThis.setTimeout(resolve, 0)) -} - function isEditorAvailable( editor: Editor, canContinue: RichMarkdownLargeTextPasteOptions['canContinue'] diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts index b20e764df15..11764c3b224 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -1,3 +1,4 @@ +import { yieldToEventLoop } from '../../../../shared/event-loop-yield' import { isTerminalInputTooLargeWithDeferredMeasurement, iterateTerminalInputChunks @@ -28,16 +29,12 @@ export type PtyInputWriteQueueDeps = { yieldBetweenWrites?: () => Promise } -function defaultYieldBetweenWrites(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)) -} - function isCoalescibleText(text: string): boolean { return text.length <= TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS } export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInputWriteQueue { - const yieldBetweenWrites = deps.yieldBetweenWrites ?? defaultYieldBetweenWrites + const yieldBetweenWrites = deps.yieldBetweenWrites ?? yieldToEventLoop let pending: PendingPtyInputWrite[] = [] let drainPromise: Promise | null = null diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 2fded96d677..e401187cca5 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -10,6 +10,7 @@ import { isTerminalInputTooLargeWithDeferredMeasurement, iterateTerminalInputChunks } from '../../../../shared/terminal-input' +import { yieldToEventLoop } from '../../../../shared/event-loop-yield' import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' import { ptyDataHandlers, @@ -622,10 +623,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra commit: clearAccumulatedState } - function yieldToInputWriteDrain(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)) - } - async function writeAcceptedPtyInput(id: string, data: string): Promise { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) @@ -644,7 +641,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } chunk = chunks.next() if (!chunk.done) { - await yieldToInputWriteDrain() + await yieldToEventLoop() } } return true diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts b/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts index ce3d14c9cf8..9630fec6742 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts @@ -1,3 +1,4 @@ +import { yieldToEventLoop } from '../../../../shared/event-loop-yield' import { BRACKETED_PASTE_END, BRACKETED_PASTE_START } from './terminal-bracketed-paste' import { iterateTerminalPastePlanChunks } from './terminal-paste-chunks' import { createRedactedPasteExecutionDiagnostic } from './terminal-paste-diagnostics' @@ -30,7 +31,7 @@ export async function executeTerminalPastePlan( writePty, isTargetCurrent, canContinue, - yieldToEventLoop = defaultYieldToEventLoop, + yieldToEventLoop: yieldBetweenChunks = yieldToEventLoop, operationTimeoutMs = getTerminalPasteOperationTimeoutMs(plan), now = defaultNow }: ExecuteTerminalPastePlanArgs @@ -101,7 +102,7 @@ export async function executeTerminalPastePlan( } else if (chunk === BRACKETED_PASTE_END) { bracketedPasteOpen = false } - await yieldToEventLoop() + await yieldBetweenChunks() } return finish('pasted', chunksWritten) } @@ -140,7 +141,3 @@ function result( function defaultNow(): number { return globalThis.performance?.now?.() ?? Date.now() } - -function defaultYieldToEventLoop(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)) -} diff --git a/src/renderer/src/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index b3ad4d2cfa9..23109db3ba6 100644 --- a/src/renderer/src/lib/agent-draft-paste-content.ts +++ b/src/renderer/src/lib/agent-draft-paste-content.ts @@ -1,3 +1,4 @@ +import { yieldToEventLoop } from '../../../shared/event-loop-yield' import type { GlobalSettings } from '../../../shared/types' import { BRACKETED_PASTE_END, @@ -159,7 +160,7 @@ async function isSanitizedDraftPasteOverLimit(content: string, maxBytes: number) index += 1 } if (index >= nextYieldAt) { - await yieldToAgentDraftPastePreflight() + await yieldToEventLoop() nextYieldAt = index + AGENT_DRAFT_PASTE_PREFLIGHT_YIELD_CODE_UNITS } } @@ -187,10 +188,6 @@ function getUtf8ByteLengthForCodePoint(codePoint: number): number { return 4 } -function yieldToAgentDraftPastePreflight(): Promise { - return new Promise((resolve) => globalThis.setTimeout(resolve, 0)) -} - async function writeAgentDraftPtyInput( settings: Pick | null | undefined, ptyId: string, diff --git a/src/renderer/src/lib/paste-payload-metadata.ts b/src/renderer/src/lib/paste-payload-metadata.ts index 8f9c9aaaef9..be63b7bc558 100644 --- a/src/renderer/src/lib/paste-payload-metadata.ts +++ b/src/renderer/src/lib/paste-payload-metadata.ts @@ -1,3 +1,5 @@ +import { yieldToEventLoop } from '../../../shared/event-loop-yield' + export type PastePayloadMetadata = { byteLength: number exceededLimit: boolean @@ -62,7 +64,7 @@ export async function measurePastePayloadMetadataWithYield( 1, options.yieldAfterCodeUnits ?? PASTE_PAYLOAD_METADATA_YIELD_CODE_UNITS ) - const yieldToEventLoop = options.yieldToEventLoop ?? defaultPastePayloadMetadataYield + const yieldBetweenBatches = options.yieldToEventLoop ?? yieldToEventLoop let nextYieldAt = yieldAfterCodeUnits let byteLength = 0 let hasControlSequences = false @@ -89,7 +91,7 @@ export async function measurePastePayloadMetadataWithYield( index += 1 } if (index >= nextYieldAt) { - await yieldToEventLoop() + await yieldBetweenBatches() nextYieldAt = index + yieldAfterCodeUnits } } @@ -149,7 +151,3 @@ function getUtf8ByteLengthForCodePoint(codePoint: number): number { } return 4 } - -function defaultPastePayloadMetadataYield(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)) -} diff --git a/src/renderer/src/lib/primary-selection-paste.ts b/src/renderer/src/lib/primary-selection-paste.ts index b2947c4edaf..93e237378d0 100644 --- a/src/renderer/src/lib/primary-selection-paste.ts +++ b/src/renderer/src/lib/primary-selection-paste.ts @@ -1,3 +1,4 @@ +import { yieldToEventLoop } from '../../../shared/event-loop-yield' import { isPrimarySelectionTextControl } from './primary-selection-capture' import { TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES, @@ -157,10 +158,6 @@ function insertContentEditableChunk(target: HTMLElement, range: Range, text: str return range } -function yieldToEventLoop(): Promise { - return new Promise((resolve) => globalThis.setTimeout(resolve, 0)) -} - async function pasteLargeTextIntoContentEditable( target: HTMLElement, text: string, diff --git a/src/renderer/src/lib/text-control-paste.ts b/src/renderer/src/lib/text-control-paste.ts index ee44438c875..a1733044a9f 100644 --- a/src/renderer/src/lib/text-control-paste.ts +++ b/src/renderer/src/lib/text-control-paste.ts @@ -1,3 +1,4 @@ +import { yieldToEventLoop } from '../../../shared/event-loop-yield' import { createTextControlCancelledResult, createTextControlPastedResult, @@ -186,10 +187,6 @@ function getSelectionRange(target: HTMLInputElement | HTMLTextAreaElement): { } } -function yieldToEventLoop(): Promise { - return new Promise((resolve) => globalThis.setTimeout(resolve, 0)) -} - function defaultNow(): number { return globalThis.performance?.now?.() ?? Date.now() } diff --git a/src/shared/clipboard-text.ts b/src/shared/clipboard-text.ts index 9a098634c57..18425efe4cd 100644 --- a/src/shared/clipboard-text.ts +++ b/src/shared/clipboard-text.ts @@ -1,3 +1,5 @@ +import { yieldToEventLoop } from './event-loop-yield' + export const CLIPBOARD_TEXT_READ_MAX_BYTES = 16 * 1024 * 1024 export const CLIPBOARD_TEXT_WRITE_MAX_BYTES = 16 * 1024 * 1024 export const CLIPBOARD_TEXT_TOO_LARGE_ERROR = 'Clipboard text is too large for this paste target.' @@ -53,7 +55,7 @@ export async function measureClipboardTextByteLengthWithYield( 1, options.yieldAfterCodeUnits ?? CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS ) - const yieldToEventLoop = options.yieldToEventLoop ?? defaultClipboardTextMeasureYield + const yieldBetweenBatches = options.yieldToEventLoop ?? yieldToEventLoop let nextYieldAt = yieldAfterCodeUnits let byteLength = 0 @@ -67,7 +69,7 @@ export async function measureClipboardTextByteLengthWithYield( index += 1 } if (index >= nextYieldAt) { - await yieldToEventLoop() + await yieldBetweenBatches() nextYieldAt = index + yieldAfterCodeUnits } } @@ -183,7 +185,3 @@ function getUtf8ByteLengthForCodePoint(codePoint: number): number { } return 4 } - -function defaultClipboardTextMeasureYield(): Promise { - return new Promise((resolve) => setTimeout(resolve, 0)) -} diff --git a/src/shared/event-loop-yield.test.ts b/src/shared/event-loop-yield.test.ts new file mode 100644 index 00000000000..0f5c7b6e3a1 --- /dev/null +++ b/src/shared/event-loop-yield.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { yieldToEventLoop } from './event-loop-yield' + +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +describe('yieldToEventLoop', () => { + it('uses setImmediate in Node runtimes', async () => { + const scheduleImmediate = vi.fn((callback: () => void) => queueMicrotask(callback)) + vi.stubEnv('VITEST', 'false') + vi.stubGlobal('window', undefined) + vi.stubGlobal('setImmediate', scheduleImmediate) + + await yieldToEventLoop() + + expect(scheduleImmediate).toHaveBeenCalledOnce() + }) + + it('posts one macrotask per renderer yield', async () => { + const postMessage = vi.fn() + vi.stubEnv('VITEST', 'false') + vi.stubGlobal('window', {}) + vi.stubGlobal( + 'MessageChannel', + class { + port1: { onmessage: ((event: MessageEvent) => void) | null } = { onmessage: null } + port2 = { + postMessage: (data: unknown): void => { + postMessage(data) + queueMicrotask(() => this.port1.onmessage?.({ data } as MessageEvent)) + } + } + } + ) + + await Promise.all([yieldToEventLoop(), yieldToEventLoop()]) + + expect(postMessage).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/shared/event-loop-yield.ts b/src/shared/event-loop-yield.ts new file mode 100644 index 00000000000..a928faa16c0 --- /dev/null +++ b/src/shared/event-loop-yield.ts @@ -0,0 +1,55 @@ +type ImmediateGlobal = typeof globalThis & { + setImmediate?: (callback: () => void) => unknown +} + +const pendingRendererYields: (() => void)[] = [] +let nextRendererYieldIndex = 0 +let rendererYieldChannel: MessageChannel | null = null + +function isVitestEnvironment(): boolean { + return typeof process !== 'undefined' && process.env?.VITEST === 'true' +} + +function getRendererYieldChannel(): MessageChannel { + if (!rendererYieldChannel) { + rendererYieldChannel = new globalThis.MessageChannel() + rendererYieldChannel.port1.onmessage = () => { + const resolve = pendingRendererYields[nextRendererYieldIndex] + if (!resolve) { + return + } + nextRendererYieldIndex += 1 + if (nextRendererYieldIndex === pendingRendererYields.length) { + pendingRendererYields.length = 0 + nextRendererYieldIndex = 0 + } + resolve() + } + } + return rendererYieldChannel +} + +export function yieldToEventLoop(): Promise { + return new Promise((resolve) => { + // Vitest fake timers cannot advance MessageChannel tasks. + if (isVitestEnvironment()) { + globalThis.setTimeout(resolve, 0) + return + } + + const setImmediate = (globalThis as ImmediateGlobal).setImmediate + if (typeof window === 'undefined' && setImmediate) { + setImmediate(resolve) + return + } + + if (typeof globalThis.MessageChannel === 'function') { + // Posted tasks avoid Chromium's nested-timer clamp while still yielding to input and paint. + pendingRendererYields.push(resolve) + getRendererYieldChannel().port2.postMessage(undefined) + return + } + + globalThis.setTimeout(resolve, 0) + }) +}