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:
parent
9694830764
commit
e08df5f98a
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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}`))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue