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.
This commit is contained in:
parent
c140a51118
commit
8b154d686c
|
|
@ -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<void>((resolve) => setTimeout(resolve, 0))
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, 0))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
async function insertMonacoTextInChunks(
|
||||
monacoEditor: MonacoPasteEditor,
|
||||
text: string,
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function isEditorAvailable(
|
||||
editor: Editor,
|
||||
canContinue: RichMarkdownLargeTextPasteOptions['canContinue']
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { yieldToEventLoop } from '../../../../shared/event-loop-yield'
|
||||
import {
|
||||
isTerminalInputTooLargeWithDeferredMeasurement,
|
||||
iterateTerminalInputChunks
|
||||
|
|
@ -28,16 +29,12 @@ export type PtyInputWriteQueueDeps = {
|
|||
yieldBetweenWrites?: () => Promise<void>
|
||||
}
|
||||
|
||||
function defaultYieldBetweenWrites(): Promise<void> {
|
||||
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<void> | null = null
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
async function writeAgentDraftPtyInput(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
ptyId: string,
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
async function pasteLargeTextIntoContentEditable(
|
||||
target: HTMLElement,
|
||||
text: string,
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function defaultNow(): number {
|
||||
return globalThis.performance?.now?.() ?? Date.now()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
type ImmediateGlobal = typeof globalThis & {
|
||||
setImmediate?: (callback: () => void) => unknown
|
||||
}
|
||||
|
||||
const pendingRendererYields = new Map<number, () => 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<void> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue