From e08df5f98a2e95b7da60fd807fb8da0d28da6f59 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:24:30 -0700 Subject: [PATCH] fix(win): guard xterm link providers so a RangeError can't kill the renderer (#6855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/lib/pane-manager/pane-dom-creation.ts | 6 + .../terminal-link-provider-guard.test.ts | 111 ++++++++++++++++++ .../terminal-link-provider-guard.ts | 60 ++++++++++ 3 files changed, 177 insertions(+) create mode 100644 src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-link-provider-guard.ts diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 757dc535ec7..8c1c1e289ff 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -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() diff --git a/src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts b/src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts new file mode 100644 index 00000000000..b92ef23369f --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts @@ -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' }) + ) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-link-provider-guard.ts b/src/renderer/src/lib/pane-manager/terminal-link-provider-guard.ts new file mode 100644 index 00000000000..58db1c5b947 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-link-provider-guard.ts @@ -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}`)) + } +}