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.
This commit is contained in:
Neil
2026-06-30 16:05:01 -07:00
committed by GitHub
parent 043731e934
commit b9f65576d9
4 changed files with 119 additions and 0 deletions
+21
View File
@@ -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<T = unknown> {
constructor(defaultDelay: number)
trigger(task: () => T | Promise<T>, delay?: number): Promise<T | undefined>
cancel(): void
dispose(): void
}
}
declare module 'monaco-editor/esm/vs/base/common/lifecycle.js' {
type Disposable = {
dispose(): void
}
export class DisposableStore {
add<T extends Disposable>(disposable: T): T
clear(): void
dispose(): void
}
}
declare global {
var MonacoEnvironment:
| {
@@ -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<unknown[]> {
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'
})
})
})
@@ -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<unknown> | 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
}
+2
View File
@@ -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