From b9f65576d91bbdf9f7e73438c48cdc0664ae602f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:05:01 -0700 Subject: [PATCH] Fix Monaco Delayer cancellation during renderer teardown Handle expected Monaco Delayer cancellation during renderer cleanup so ignored trigger promises do not surface as unhandled rejections. Adds regression coverage for the DisposableStore teardown path from the crash report. --- src/renderer/src/env.d.ts | 21 ++++++++ .../monaco-delayer-cancellation-guard.test.ts | 53 +++++++++++++++++++ .../lib/monaco-delayer-cancellation-guard.ts | 43 +++++++++++++++ src/renderer/src/lib/monaco-setup.ts | 2 + 4 files changed, 119 insertions(+) create mode 100644 src/renderer/src/lib/monaco-delayer-cancellation-guard.test.ts create mode 100644 src/renderer/src/lib/monaco-delayer-cancellation-guard.ts 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