From 8b154d686c2fd84eaf4c125bbfd0b6a0707ae743 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:21:14 -0700 Subject: [PATCH] perf(runtime): remove timer clamps from cooperative yields (#10908) * 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. * fix(runtime): preserve pacing and release yield callbacks Adversarial review found that concurrent producers could retain resolved callbacks until global quiescence. Route renderer yields by token and delete each resolver before resuming its producer. Keep timer pacing in terminal paste and accepted-write loops where SSH and local PTYs do not provide drain acknowledgement. Use the shared scheduler for the OpenCode scanner. --- src/main/browser/browser-text-insertion.ts | 3 +- src/main/opencode-usage/scanner.ts | 5 +- .../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 +-- .../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 | 54 +++++++++++++++++ src/shared/event-loop-yield.ts | 60 +++++++++++++++++++ 13 files changed, 134 insertions(+), 47 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 9acee2143..4d89b3a06 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 57a95df3a..7d9b0ee1c 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs' import { readdir, realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, isAbsolute, join, posix, win32 } from 'node:path' +import { yieldToEventLoop } from '../../shared/event-loop-yield' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import Database from '../sqlite/sync-database' @@ -144,10 +145,6 @@ export async function getProcessedDatabaseInfo( } } -async function yieldToEventLoop(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)) -} - function getProjectJoin(db: Database.Database): string { return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') ? 'LEFT JOIN project p ON p.id = s.project_id' diff --git a/src/renderer/src/components/dictation/dictation-insertion-target.ts b/src/renderer/src/components/dictation/dictation-insertion-target.ts index 923bf590f..4f8573be1 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 5a5b7dff2..7cb6ed83c 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 d4eecf1e3..b6f87a0b0 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 b20e764df..11764c3b2 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/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index b3ad4d2cf..23109db3b 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 8f9c9aaae..be63b7bc5 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 b2947c4ed..93e237378 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 ee44438c8..a1733044a 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 9a098634c..18425efe4 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 000000000..0bbba1b36 --- /dev/null +++ b/src/shared/event-loop-yield.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getPendingRendererYieldCountForTesting, 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('releases callbacks during sustained concurrent renderer yields', async () => { + const postMessage = vi.fn() + let peakPendingAfterResolution = 0 + 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) + setTimeout(() => this.port1.onmessage?.({ data } as MessageEvent), 0) + } + } + } + ) + + const runProducer = async (): Promise => { + for (let index = 0; index < 20; index += 1) { + await yieldToEventLoop() + peakPendingAfterResolution = Math.max( + peakPendingAfterResolution, + getPendingRendererYieldCountForTesting() + ) + } + } + await Promise.all([runProducer(), runProducer()]) + + expect(postMessage).toHaveBeenCalledTimes(40) + expect(peakPendingAfterResolution).toBeLessThanOrEqual(1) + expect(getPendingRendererYieldCountForTesting()).toBe(0) + }) +}) diff --git a/src/shared/event-loop-yield.ts b/src/shared/event-loop-yield.ts new file mode 100644 index 000000000..98af4358d --- /dev/null +++ b/src/shared/event-loop-yield.ts @@ -0,0 +1,60 @@ +type ImmediateGlobal = typeof globalThis & { + setImmediate?: (callback: () => void) => unknown +} + +const pendingRendererYields = new Map void>() +let nextRendererYieldId = 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 = (event) => { + const yieldId = event.data + const resolve = typeof yieldId === 'number' ? pendingRendererYields.get(yieldId) : undefined + if (!resolve) { + return + } + pendingRendererYields.delete(yieldId) + resolve() + } + } + return rendererYieldChannel +} + +/** @internal */ +export function getPendingRendererYieldCountForTesting(): number { + return pendingRendererYields.size +} + +/** Yields to another runnable task without a timer clamp when supported. */ +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. + const yieldId = nextRendererYieldId + nextRendererYieldId += 1 + pendingRendererYields.set(yieldId, resolve) + getRendererYieldChannel().port2.postMessage(yieldId) + return + } + + globalThis.setTimeout(resolve, 0) + }) +}