fix(win): guard xterm link providers so a RangeError can't kill the renderer (#6855)

A synchronous throw inside any terminal link provider's provideLinks
escapes to window.onerror and gets the renderer killed (reason=killed,
exit 1). The reported crash (F0BDKBHDAUE) is xterm web-links'
LinkComputer._getWindowedLineStrings raising 'RangeError: Invalid array
length' while scanning a pathological wrapped line during agent CLI
output (opencode).

Patch terminal.registerLinkProvider at construction so every provider
registered afterward — the web-links addon's internal provider plus
Orca's file-path and terminal-handle providers — has its provideLinks
wrapped in a try/catch that records a crash breadcrumb and degrades to
'no links this hover' instead of throwing.

Covered by terminal-link-provider-guard.test.ts (reproduces the
RangeError and asserts it no longer escapes).

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-30 00:24:30 -07:00 committed by GitHub
parent 9694830764
commit e08df5f98a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 177 additions and 0 deletions

View File

@ -12,6 +12,7 @@ import type { ManagedPaneInternal, PaneManagerOptions } from './pane-manager-typ
import { buildDefaultTerminalOptions } from './pane-terminal-options'
import { shouldFocusTerminalFromPanePointerDown } from './pane-pointer-focus'
import { ENABLE_WEBGL_RENDERER } from './pane-webgl-renderer'
import { installGuardedLinkProviderRegistration } from './terminal-link-provider-guard'
function getTerminalUrlOpenHint(): string {
return navigator.userAgent.includes('Mac')
@ -49,6 +50,11 @@ export function createPaneDOM(
}
const terminal = new Terminal(terminalOpts)
// Why: a synchronous throw inside any link provider's provideLinks (notably
// xterm web-links' LinkComputer raising RangeError on a pathological wrapped
// line) escapes to window.onerror and gets the renderer killed. Guard every
// provider registered after this point — addon-internal and Orca's own.
installGuardedLinkProviderRegistration(terminal)
const fitAddon = new FitAddon()
const searchAddon = new SearchAddon()
const unicode11Addon = new Unicode11Addon()

View File

@ -0,0 +1,111 @@
import type { ILink, ILinkProvider, Terminal } from '@xterm/xterm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
guardLinkProvider,
installGuardedLinkProviderRegistration
} from './terminal-link-provider-guard'
const mocks = vi.hoisted(() => ({
recordRendererCrashBreadcrumb: vi.fn()
}))
vi.mock('@/lib/crash-diagnostics', () => ({
recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb
}))
beforeEach(() => {
mocks.recordRendererCrashBreadcrumb.mockClear()
})
function collectLinks(provider: ILinkProvider, bufferLineNumber = 1): ILink[] | undefined {
let result: ILink[] | undefined
let called = false
provider.provideLinks(bufferLineNumber, (links) => {
called = true
result = links
})
expect(called).toBe(true)
return result
}
describe('guardLinkProvider', () => {
it('reproduces the xterm web-links RangeError without letting it escape', () => {
// Why: this is the F0BDKBHDAUE crash — LinkComputer._getWindowedLineStrings
// allocates an array of invalid length on a pathological wrapped line.
const provider: ILinkProvider = {
provideLinks: () => {
throw new RangeError('Invalid array length')
}
}
const guarded = guardLinkProvider(provider, 'web-links')
expect(() => collectLinks(guarded)).not.toThrow()
expect(collectLinks(guarded)).toBeUndefined()
expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith(
'terminal_link_provider_error',
{
provider: 'web-links',
bufferLineNumber: 1,
errorName: 'RangeError',
errorMessage: 'Invalid array length'
}
)
})
it('passes provided links through unchanged when the provider succeeds', () => {
const links = [{ text: 'term_abc' }] as unknown as ILink[]
const provider: ILinkProvider = {
provideLinks: (_lineNumber, callback) => callback(links)
}
const guarded = guardLinkProvider(provider, 'orca-handle')
expect(collectLinks(guarded)).toBe(links)
expect(mocks.recordRendererCrashBreadcrumb).not.toHaveBeenCalled()
})
it('does not double-invoke the callback when the provider throws after resolving', () => {
const links = [{ text: 'file.ts' }] as unknown as ILink[]
const provider: ILinkProvider = {
provideLinks: (_lineNumber, callback) => {
callback(links)
throw new RangeError('Invalid array length')
}
}
const guarded = guardLinkProvider(provider, 'orca-file')
const callback = vi.fn()
expect(() => guarded.provideLinks(1, callback)).not.toThrow()
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith(links)
expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledOnce()
})
})
describe('installGuardedLinkProviderRegistration', () => {
it('guards every provider registered after install (addon-internal included)', () => {
const registered: ILinkProvider[] = []
const terminal = {
registerLinkProvider: (provider: ILinkProvider) => {
registered.push(provider)
return { dispose: vi.fn() }
}
} as unknown as Terminal
installGuardedLinkProviderRegistration(terminal)
// Simulate the web-links addon's loadAddon -> registerLinkProvider path.
terminal.registerLinkProvider({
provideLinks: () => {
throw new RangeError('Invalid array length')
}
})
expect(registered).toHaveLength(1)
expect(() => collectLinks(registered[0])).not.toThrow()
expect(collectLinks(registered[0])).toBeUndefined()
expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith(
'terminal_link_provider_error',
expect.objectContaining({ provider: 'provider-1', errorName: 'RangeError' })
)
})
})

View File

@ -0,0 +1,60 @@
import type { ILinkProvider, Terminal } from '@xterm/xterm'
import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics'
/**
* Wrap a link provider so a synchronous throw inside `provideLinks` is reported
* as "no links" instead of escaping to `window.onerror`.
*
* Why: xterm's web-links `LinkComputer._getWindowedLineStrings` can raise
* `RangeError: Invalid array length` while scanning a pathological wrapped line
* (e.g. agent CLI output with very wide/control-mangled buffers). That throw
* propagates out of the synchronously-invoked provider and wedges the renderer,
* which Chromium then kills (`killed` exit 1). Degrading to "no link this hover"
* keeps the renderer alive; the user can retry by moving the mouse.
*/
export function guardLinkProvider(provider: ILinkProvider, label: string): ILinkProvider {
return {
provideLinks(bufferLineNumber, callback) {
let callbackInvoked = false
const trackedCallback: typeof callback = (links) => {
callbackInvoked = true
callback(links)
}
try {
provider.provideLinks(bufferLineNumber, trackedCallback)
} catch (error: unknown) {
recordRendererCrashBreadcrumb('terminal_link_provider_error', {
provider: label,
bufferLineNumber,
errorName: error instanceof Error ? error.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error)
})
// Why: only resolve the link request if the provider threw before it
// already delivered links, so we never double-invoke the callback.
if (!callbackInvoked) {
callback(undefined)
}
}
}
}
}
/**
* Patch `terminal.registerLinkProvider` so every provider registered afterward
* including xterm addons' internal providers loaded via `loadAddon` (notably
* the web-links `LinkComputer`) is wrapped by {@link guardLinkProvider}.
* Must run before any `loadAddon`/`registerLinkProvider` call for the terminal.
*/
export function installGuardedLinkProviderRegistration(terminal: Terminal): void {
// Why: never let the guard itself break pane creation if a Terminal stub or a
// future xterm build lacks registerLinkProvider.
if (typeof terminal.registerLinkProvider !== 'function') {
return
}
const register = terminal.registerLinkProvider.bind(terminal)
let providerCount = 0
terminal.registerLinkProvider = (provider: ILinkProvider) => {
providerCount += 1
return register(guardLinkProvider(provider, `provider-${providerCount}`))
}
}