diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index f65df59ca04..31c51256a88 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -36,6 +36,27 @@ declare module 'monaco-editor/esm/vs/editor/browser/controller/editContext/clipb } } +declare module 'monaco-editor/esm/vs/base/common/async.js' { + export class Delayer { + constructor(defaultDelay: number) + trigger(task: () => T | Promise, delay?: number): Promise + cancel(): void + dispose(): void + } +} + +declare module 'monaco-editor/esm/vs/base/common/lifecycle.js' { + type Disposable = { + dispose(): void + } + + export class DisposableStore { + add(disposable: T): T + clear(): void + dispose(): void + } +} + declare global { var MonacoEnvironment: | { diff --git a/src/renderer/src/lib/monaco-delayer-cancellation-guard.test.ts b/src/renderer/src/lib/monaco-delayer-cancellation-guard.test.ts new file mode 100644 index 00000000000..4c79fde3eee --- /dev/null +++ b/src/renderer/src/lib/monaco-delayer-cancellation-guard.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { Delayer } from 'monaco-editor/esm/vs/base/common/async.js' +import { DisposableStore } from 'monaco-editor/esm/vs/base/common/lifecycle.js' +import { installMonacoDelayerCancellationGuard } from './monaco-delayer-cancellation-guard' + +const UNHANDLED_REJECTION_SETTLE_MS = 20 + +async function collectUnhandledRejections(run: () => void): Promise { + const reasons: unknown[] = [] + const onUnhandledRejection = (reason: unknown): void => { + reasons.push(reason) + } + + process.on('unhandledRejection', onUnhandledRejection) + try { + run() + await new Promise((resolve) => setTimeout(resolve, UNHANDLED_REJECTION_SETTLE_MS)) + } finally { + process.off('unhandledRejection', onUnhandledRejection) + } + + return reasons +} + +describe('installMonacoDelayerCancellationGuard', () => { + it('marks DisposableStore Delayer cancellation as handled when the trigger promise is ignored', async () => { + installMonacoDelayerCancellationGuard() + installMonacoDelayerCancellationGuard() + + const unhandledRejections = await collectUnhandledRejections(() => { + const store = new DisposableStore() + const delayer = store.add(new Delayer(1000)) + delayer.trigger(() => undefined) + + store.dispose() + }) + + expect(unhandledRejections).toEqual([]) + }) + + it('keeps cancellation visible to callers that await the trigger promise', async () => { + installMonacoDelayerCancellationGuard() + + const delayer = new Delayer(1000) + const promise = delayer.trigger(() => undefined) + delayer.cancel() + + await expect(promise).rejects.toMatchObject({ + name: 'Canceled', + message: 'Canceled' + }) + }) +}) diff --git a/src/renderer/src/lib/monaco-delayer-cancellation-guard.ts b/src/renderer/src/lib/monaco-delayer-cancellation-guard.ts new file mode 100644 index 00000000000..5d06d227c16 --- /dev/null +++ b/src/renderer/src/lib/monaco-delayer-cancellation-guard.ts @@ -0,0 +1,43 @@ +import { Delayer } from 'monaco-editor/esm/vs/base/common/async.js' + +const MONACO_CANCELLATION_NAME = 'Canceled' + +type MonacoDelayerInstance = { + cancel: () => void + completionPromise?: Promise | null +} + +type GuardedDelayerPrototype = MonacoDelayerInstance & { + __orcaDelayerCancellationGuardInstalled?: true +} + +function isMonacoCancellationError(error: unknown): boolean { + return ( + error instanceof Error && + error.name === MONACO_CANCELLATION_NAME && + error.message === MONACO_CANCELLATION_NAME + ) +} + +export function installMonacoDelayerCancellationGuard(): void { + const delayerPrototype = Delayer.prototype as GuardedDelayerPrototype + if (delayerPrototype.__orcaDelayerCancellationGuardInstalled) { + return + } + + const originalCancel = delayerPrototype.cancel + delayerPrototype.cancel = function cancelWithHandledCancellation(this: MonacoDelayerInstance) { + const completionPromise = this.completionPromise + if (completionPromise) { + // Why: Monaco Delayer cancellation is normal during DisposableStore + // teardown, but ignored trigger promises surface as unhandled rejections. + void completionPromise.catch((error) => { + if (!isMonacoCancellationError(error)) { + throw error + } + }) + } + originalCancel.call(this) + } + delayerPrototype.__orcaDelayerCancellationGuardInstalled = true +} diff --git a/src/renderer/src/lib/monaco-setup.ts b/src/renderer/src/lib/monaco-setup.ts index bbe7acfae17..67d05eaefc3 100644 --- a/src/renderer/src/lib/monaco-setup.ts +++ b/src/renderer/src/lib/monaco-setup.ts @@ -11,6 +11,7 @@ import { registerAstroLanguage } from './monaco-languages/register-astro' import { registerNimLanguage } from './monaco-languages/register-nim' import { registerSvelteLanguage } from './monaco-languages/register-svelte' import { registerVueLanguage } from './monaco-languages/register-vue' +import { installMonacoDelayerCancellationGuard } from './monaco-delayer-cancellation-guard' import { installMonacoDiffEditorDisposalGuard } from './monaco-diff-editor-disposal' import { installMonacoContextMenuPaste } from '@/components/editor/install-monaco-context-menu-paste' @@ -75,6 +76,7 @@ registerVueLanguage(monaco) registerSvelteLanguage(monaco) registerAstroLanguage(monaco) registerNimLanguage(monaco) +installMonacoDelayerCancellationGuard() installMonacoDiffEditorDisposalGuard(monaco) // Why: Monaco's built-in context-menu Paste reads navigator.clipboard, which is // blocked in Orca's sandboxed renderer. Route it through the trusted IPC bridge