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