mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
fix(terminal): stop terminal search overflowing the stack on long wrapped lines
@xterm/addon-search's SearchEngine._findInLine rewound to the first row of a wrapped line by calling itself once per wrapped row, so recursion depth equalled the number of screen rows one logical line occupies. A single un-newlined line (base64, a minified bundle, one huge log record) in a pane with Orca's scrollback throws RangeError: Maximum call stack size exceeded out of TerminalSearch's effect, which safeFind rethrows, tripping the terminal.workbench error boundary and killing the whole terminal surface (crash report 012eb5be, Orca 1.4.194). Patch the rewind into a loop, through the existing xterm patch harness: a source patch under config/patches/xterm-src/ plus the generated dist patch, both emitted by config/scripts/regenerate-xterm-patches.mjs from the pinned upstream commit. Still present in @xterm/addon-search 0.17.0-beta.301; worth upstreaming.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,35 @@
|
||||
diff --git a/src/SearchEngine.ts b/src/SearchEngine.ts
|
||||
index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..f856f1f9444a13d29fcbd2f264b0c2bfbf33db1c 100644
|
||||
--- a/src/SearchEngine.ts
|
||||
+++ b/src/SearchEngine.ts
|
||||
@@ -250,23 +250,19 @@ export class SearchEngine {
|
||||
* @returns The search result if it was found.
|
||||
*/
|
||||
private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {
|
||||
- const row = searchPosition.startRow;
|
||||
- const col = searchPosition.startCol;
|
||||
-
|
||||
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
|
||||
- const firstLine = this._terminal.buffer.active.getLine(row);
|
||||
- if (firstLine?.isWrapped) {
|
||||
+ // This walks back to the line start rather than recursing into it: one frame per wrapped
|
||||
+ // row overflows the stack on a line long enough to fill the scrollback.
|
||||
+ while (this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) {
|
||||
+ searchPosition.startCol += this._terminal.cols;
|
||||
if (isReverseSearch) {
|
||||
- searchPosition.startCol += this._terminal.cols;
|
||||
return;
|
||||
}
|
||||
-
|
||||
- // This will iterate until we find the line start.
|
||||
- // When we find it, we will search using the calculated start column.
|
||||
searchPosition.startRow--;
|
||||
- searchPosition.startCol += this._terminal.cols;
|
||||
- return this._findInLine(term, searchPosition, searchOptions);
|
||||
}
|
||||
+ const row = searchPosition.startRow;
|
||||
+ const col = searchPosition.startCol;
|
||||
+
|
||||
let cache = this._lineCache.getLineFromCache(row);
|
||||
if (!cache) {
|
||||
cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);
|
||||
@@ -59,6 +59,32 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@xterm/addon-search",
|
||||
"version": "0.17.0-beta.300",
|
||||
"packageDir": "addons/addon-search",
|
||||
"$note": "No versionStampFile: publish.js stamps the addon's package.json, which overlayBuildOutput never patches. The root `build` is required because the addon's own tsgo -p . has empty files/include and only project references, so it emits nothing on its own; `package` is the addon's webpack (CJS half) and the root `esbuild-package` emits the ESM half.",
|
||||
"sourcePatch": "config/patches/xterm-src/@xterm__addon-search@0.17.0-beta.300.src.patch",
|
||||
"patch": "config/patches/@xterm__addon-search@0.17.0-beta.300.patch",
|
||||
"generatedPaths": ["lib/"],
|
||||
"build": [
|
||||
{
|
||||
"cwd": "../..",
|
||||
"command": "npm",
|
||||
"args": ["run", "build"]
|
||||
},
|
||||
{
|
||||
"cwd": ".",
|
||||
"command": "npm",
|
||||
"args": ["run", "package"]
|
||||
},
|
||||
{
|
||||
"cwd": "../..",
|
||||
"command": "npm",
|
||||
"args": ["run", "esbuild-package"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@xterm/addon-serialize",
|
||||
"version": "0.15.0-beta.300",
|
||||
|
||||
Generated
+3
-2
@@ -111,6 +111,7 @@ overrides:
|
||||
patchedDependencies:
|
||||
'@vscode/windows-process-tree@0.8.0': 9217ef36c01ed74127fef5512b0c92089cdbf820fd6c109dd671137eebdc7585
|
||||
'@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
|
||||
'@xterm/addon-search@0.17.0-beta.300': af96be17952171ef1ba0069a283115a8cca0b381ad54e555fed832277286ea53
|
||||
'@xterm/addon-serialize@0.15.0-beta.300': 851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294
|
||||
'@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e
|
||||
'@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d
|
||||
@@ -319,7 +320,7 @@ importers:
|
||||
version: 0.11.0-beta.300(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
|
||||
'@xterm/addon-search':
|
||||
specifier: 0.17.0-beta.300
|
||||
version: 0.17.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
|
||||
version: 0.17.0-beta.300(patch_hash=af96be17952171ef1ba0069a283115a8cca0b381ad54e555fed832277286ea53)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.300
|
||||
version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
|
||||
@@ -9754,7 +9755,7 @@ snapshots:
|
||||
lru-cache: 11.5.1
|
||||
opentype.js: 2.0.0
|
||||
|
||||
'@xterm/addon-search@0.17.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
|
||||
'@xterm/addon-search@0.17.0-beta.300(patch_hash=af96be17952171ef1ba0069a283115a8cca0b381ad54e555fed832277286ea53)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ patchedDependencies:
|
||||
node-pty@1.1.0: config/patches/node-pty@1.1.0.patch
|
||||
'@xterm/addon-ligatures@0.11.0-beta.300': config/patches/@xterm__addon-ligatures@0.11.0-beta.300.patch
|
||||
'@xterm/addon-webgl@0.20.0-beta.299': config/patches/@xterm__addon-webgl@0.20.0-beta.299.patch
|
||||
'@xterm/addon-search@0.17.0-beta.300': config/patches/@xterm__addon-search@0.17.0-beta.300.patch
|
||||
'@xterm/addon-serialize@0.15.0-beta.300': config/patches/@xterm__addon-serialize@0.15.0-beta.300.patch
|
||||
'@xterm/xterm@6.1.0-beta.303': config/patches/@xterm__xterm@6.1.0-beta.303.patch
|
||||
lint-staged@16.4.0: config/patches/lint-staged@16.4.0.patch
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_MAX } from '../../../shared/terminal-scrollback-policy'
|
||||
import { safeFind } from './terminal-search-safe-find'
|
||||
|
||||
/**
|
||||
* Regression for crash report 012eb5be (Orca 1.4.194, win32): searching a pane
|
||||
* that held one un-newlined line — base64, a minified bundle, a single huge log
|
||||
* record — threw `RangeError: Maximum call stack size exceeded` out of
|
||||
* TerminalSearch's effect and tripped the `terminal.workbench` error boundary.
|
||||
*
|
||||
* Mechanism, in @xterm/addon-search's SearchEngine (patched in
|
||||
* config/patches/@xterm__addon-search@*.patch, generated from the source patch
|
||||
* under config/patches/xterm-src/): `_findInLine` rewound to the first row of a
|
||||
* wrapped line by calling itself once per wrapped row, so recursion depth equals
|
||||
* the number of screen rows the logical line occupies. Scrollback reaches
|
||||
* DESKTOP_TERMINAL_SCROLLBACK_ROWS_MAX rows, which is far past V8's stack.
|
||||
*
|
||||
* The rewind is reached on every re-entry into the middle of a wrapped line —
|
||||
* `_highlightAllMatches` restarting at the row after a match, and `findNext`
|
||||
* resuming from the current selection — so this drives the real Terminal +
|
||||
* SearchAddon through Orca's own `safeFind`, which deliberately rethrows
|
||||
* anything that is not the decoration error.
|
||||
*/
|
||||
|
||||
const COLS = 80
|
||||
const ROWS = 24
|
||||
/** Long enough that the wrap chain outruns V8's stack on any host. */
|
||||
const WRAPPED_ROWS = 12_000
|
||||
const NEEDLE = 'needle'
|
||||
|
||||
// Matches the decoration options TerminalSearch passes, so the highlight-all
|
||||
// pass (the crash's entry point) actually runs.
|
||||
const SEARCH_DECORATIONS = {
|
||||
matchBackground: '#5c4a00',
|
||||
matchBorder: '#5c4a00',
|
||||
matchOverviewRuler: '#ffcc00',
|
||||
activeMatchBackground: '#c4580e',
|
||||
activeMatchBorder: '#ffcf6b',
|
||||
activeMatchColorOverviewRuler: '#ff9900'
|
||||
} as const
|
||||
|
||||
function write(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => terminal.write(data, resolve))
|
||||
}
|
||||
|
||||
function openTerminalWithSearch(): { terminal: Terminal; search: SearchAddon } {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal({
|
||||
cols: COLS,
|
||||
rows: ROWS,
|
||||
scrollback: DESKTOP_TERMINAL_SCROLLBACK_ROWS_MAX
|
||||
})
|
||||
terminal.open(container)
|
||||
const search = new SearchAddon()
|
||||
terminal.loadAddon(search)
|
||||
return { terminal, search }
|
||||
}
|
||||
|
||||
describe('terminal search inside one very long wrapped line', () => {
|
||||
beforeEach(() => {
|
||||
// happy-dom has no canvas text metrics; xterm measures glyphs on open().
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('rewinds to the start of the line without overflowing the stack', async () => {
|
||||
const { terminal, search } = openTerminalWithSearch()
|
||||
// One line of WRAPPED_ROWS screen rows whose only match ends on the
|
||||
// second-to-last row, so the highlight pass resumes one row further on and
|
||||
// has to rewind the whole chain to reach the line start.
|
||||
await write(
|
||||
terminal,
|
||||
'x'.repeat(COLS * (WRAPPED_ROWS - 1) - NEEDLE.length) + NEEDLE + 'x'.repeat(COLS)
|
||||
)
|
||||
|
||||
const find = (): boolean =>
|
||||
safeFind((term, options) => search.findNext(term, options), NEEDLE, {
|
||||
decorations: SEARCH_DECORATIONS
|
||||
})
|
||||
|
||||
let found: boolean | undefined
|
||||
expect(() => {
|
||||
found = find()
|
||||
}).not.toThrow()
|
||||
expect(found).toBe(true)
|
||||
|
||||
// Second find resumes from the selection, deep inside the wrapped line.
|
||||
expect(() => {
|
||||
found = find()
|
||||
}).not.toThrow()
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user