mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(terminal): make wrapped-line search rewind iterative and bound its scans (#18402)
Patches @xterm/addon-search so one very long un-newlined line no longer overflows the stack, freezes the renderer, or goes unsearched. Submitted upstream as xtermjs/xterm.js#6149 (issue #6148); drop the patch once a release ships it. See the PR for measurements and the differential fuzz.
This commit is contained in:
@@ -380,7 +380,7 @@ jobs:
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
|
||||
# Why: the check rebuilds every package in the manifest from a pinned upstream
|
||||
# commit — @xterm/xterm and the two addons, each built twice (once unmodified to
|
||||
# commit — @xterm/xterm and its three addons, each built twice (once unmodified to
|
||||
# prove the toolchain still reproduces the published bundles, once patched). Caching
|
||||
# the npm metadata and the shallow clone keeps the repeated cost to the builds
|
||||
# themselves; the key is the manifest, so a commit, package or toolchain bump
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,231 @@
|
||||
diff --git a/src/SearchEngine.ts b/src/SearchEngine.ts
|
||||
index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..5b3c5cc5e861356b87e8a15c55797f45bac20a5c 100644
|
||||
--- a/src/SearchEngine.ts
|
||||
+++ b/src/SearchEngine.ts
|
||||
@@ -76,6 +76,9 @@ export class SearchEngine {
|
||||
// Search from startRow + 1 to end
|
||||
if (!result) {
|
||||
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
||||
+ if (this._isRowCoveredByEarlierSearch(y)) {
|
||||
+ continue;
|
||||
+ }
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
@@ -127,6 +130,9 @@ export class SearchEngine {
|
||||
// Search from startRow + 1 to end
|
||||
if (!result) {
|
||||
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
||||
+ if (this._isRowCoveredByEarlierSearch(y)) {
|
||||
+ continue;
|
||||
+ }
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
@@ -138,6 +144,11 @@ export class SearchEngine {
|
||||
// If we hit the bottom and didn't search from the very top wrap back up
|
||||
if (!result && startRow !== 0) {
|
||||
for (let y = 0; y < startRow; y++) {
|
||||
+ // Row 0 is never skipped: it can be a continuation whose line start was trimmed from the
|
||||
+ // scrollback, and nothing earlier in this loop has searched it.
|
||||
+ if (y > 0 && this._isRowCoveredByEarlierSearch(y)) {
|
||||
+ continue;
|
||||
+ }
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
@@ -237,6 +248,22 @@ export class SearchEngine {
|
||||
(((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));
|
||||
}
|
||||
|
||||
+ /** `_isWholeWord` gated on the option, so a rejected hit can be stepped past instead of ending the scan. */
|
||||
+ private _satisfiesWholeWord(searchIndex: number, line: string, term: string, searchOptions: ISearchOptions): boolean {
|
||||
+ return !searchOptions.wholeWord || this._isWholeWord(searchIndex, line, term);
|
||||
+ }
|
||||
+
|
||||
+ /**
|
||||
+ * Whether an earlier `_findInLine` in this same call already scanned this row's line from an
|
||||
+ * equal or lower offset, which makes rescanning it pure O(rows^2) work on one long line. Sound
|
||||
+ * for every option because `_findInLine` returns the first accepted match at or after its
|
||||
+ * offset, which is monotone in that offset. Only valid once such a search has happened — the
|
||||
+ * wrap-around loop starts at row 0, whose line start may have been trimmed from the scrollback.
|
||||
+ */
|
||||
+ private _isRowCoveredByEarlierSearch(row: number): boolean {
|
||||
+ return this._terminal.buffer.active.getLine(row)?.isWrapped === true;
|
||||
+ }
|
||||
+
|
||||
/**
|
||||
* Searches a line for a search term. Takes the provided terminal line and searches the text line,
|
||||
* which may contain subsequent terminal lines if the text is wrapped. If the provided line number
|
||||
@@ -250,23 +277,26 @@ 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) {
|
||||
- if (isReverseSearch) {
|
||||
+ if (isReverseSearch) {
|
||||
+ // Reverse search never rewinds: its caller carries startCol down the rows of the line. Row 0
|
||||
+ // is searched even when wrapped, since its line start may have been trimmed from the scrollback.
|
||||
+ if (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) {
|
||||
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);
|
||||
+ } else {
|
||||
+ // A loop rather than recursion: one frame per wrapped row overflows the stack on a line long
|
||||
+ // enough to fill the scrollback. Bounded at row 0 because after a reflow the buffer's ring
|
||||
+ // holds stale entries at negative indices, so `getLine(-1)` answers with a wrapped line.
|
||||
+ while (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) {
|
||||
+ searchPosition.startRow--;
|
||||
+ searchPosition.startCol += this._terminal.cols;
|
||||
+ }
|
||||
}
|
||||
+ const row = searchPosition.startRow;
|
||||
+ const col = searchPosition.startCol;
|
||||
+
|
||||
let cache = this._lineCache.getLineFromCache(row);
|
||||
if (!cache) {
|
||||
cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);
|
||||
@@ -274,7 +304,7 @@ export class SearchEngine {
|
||||
}
|
||||
const [stringLine, offsets] = cache;
|
||||
|
||||
- const offset = this._bufferColsToStringOffset(row, col);
|
||||
+ const offset = this._bufferColsToStringOffset(row, col, offsets);
|
||||
let searchTerm = term;
|
||||
let searchStringLine = stringLine;
|
||||
if (!searchOptions.regex) {
|
||||
@@ -289,32 +319,46 @@ export class SearchEngine {
|
||||
if (isReverseSearch) {
|
||||
// This loop will get the resultIndex of the _last_ regex match in the range 0..offset
|
||||
while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {
|
||||
- resultIndex = searchRegex.lastIndex - foundTerm[0].length;
|
||||
- term = foundTerm[0];
|
||||
- searchRegex.lastIndex -= (term.length - 1);
|
||||
+ const matchIndex = searchRegex.lastIndex - foundTerm[0].length;
|
||||
+ if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) {
|
||||
+ resultIndex = matchIndex;
|
||||
+ term = foundTerm[0];
|
||||
+ }
|
||||
+ searchRegex.lastIndex = matchIndex + 1;
|
||||
}
|
||||
} else {
|
||||
- foundTerm = searchRegex.exec(searchStringLine.slice(offset));
|
||||
- if (foundTerm && foundTerm[0].length > 0) {
|
||||
- resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);
|
||||
- term = foundTerm[0];
|
||||
+ // Driven over the whole line from `offset` rather than over `slice(offset)`: a slice
|
||||
+ // re-anchors ^ and \b at whatever column the row happened to wrap at, and only
|
||||
+ // first-accepted-match-at-or-after-offset is monotone in `offset`, which is what lets
|
||||
+ // `_isRowCoveredByEarlierSearch` skip a wrapped row an earlier scan already covered.
|
||||
+ searchRegex.lastIndex = offset;
|
||||
+ while (foundTerm = searchRegex.exec(searchStringLine)) {
|
||||
+ const matchIndex = searchRegex.lastIndex - foundTerm[0].length;
|
||||
+ if (foundTerm[0].length > 0 && this._satisfiesWholeWord(matchIndex, searchStringLine, foundTerm[0], searchOptions)) {
|
||||
+ resultIndex = matchIndex;
|
||||
+ term = foundTerm[0];
|
||||
+ break;
|
||||
+ }
|
||||
+ // A zero-length or rejected match would otherwise repeat forever.
|
||||
+ searchRegex.lastIndex = matchIndex + 1;
|
||||
}
|
||||
}
|
||||
+ } else if (isReverseSearch) {
|
||||
+ let matchIndex = offset - searchTerm.length >= 0 ? searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length) : -1;
|
||||
+ // `lastIndexOf` clamps a negative fromIndex to 0, so index 0 has to end the walk.
|
||||
+ while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) {
|
||||
+ matchIndex = matchIndex > 0 ? searchStringLine.lastIndexOf(searchTerm, matchIndex - 1) : -1;
|
||||
+ }
|
||||
+ resultIndex = matchIndex;
|
||||
} else {
|
||||
- if (isReverseSearch) {
|
||||
- if (offset - searchTerm.length >= 0) {
|
||||
- resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);
|
||||
- }
|
||||
- } else {
|
||||
- resultIndex = searchStringLine.indexOf(searchTerm, offset);
|
||||
+ let matchIndex = searchStringLine.indexOf(searchTerm, offset);
|
||||
+ while (matchIndex >= 0 && !this._satisfiesWholeWord(matchIndex, searchStringLine, searchTerm, searchOptions)) {
|
||||
+ matchIndex = searchStringLine.indexOf(searchTerm, matchIndex + 1);
|
||||
}
|
||||
+ resultIndex = matchIndex;
|
||||
}
|
||||
|
||||
if (resultIndex >= 0) {
|
||||
- if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {
|
||||
- return;
|
||||
- }
|
||||
-
|
||||
// Adjust the row number and search index if needed since a "line" of text can span multiple
|
||||
// rows
|
||||
let startRowOffset = 0;
|
||||
@@ -365,12 +409,21 @@ export class SearchEngine {
|
||||
return offset;
|
||||
}
|
||||
|
||||
- private _bufferColsToStringOffset(startRow: number, cols: number): number {
|
||||
- let lineIndex = startRow;
|
||||
- let offset = 0;
|
||||
- let line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
- while (cols > 0 && line) {
|
||||
- for (let i = 0; i < cols && i < this._terminal.cols; i++) {
|
||||
+ /**
|
||||
+ * `cols` counts from the start of the logical line, so summing the cells of every row before the
|
||||
+ * resume point costs O(line) per call and the highlight-all pass makes one call per match.
|
||||
+ * `lineOffsets` already holds the string offset each wrapped row starts at — the same map used
|
||||
+ * above to turn a match index back into a row — so only the last, partial row needs cells. It is
|
||||
+ * also the map the row a match lands on is read from, which the cell sum disagreed with by one
|
||||
+ * for a row whose trailing cell is the null placeholder of a wide character that wrapped.
|
||||
+ */
|
||||
+ private _bufferColsToStringOffset(startRow: number, cols: number, lineOffsets: number[]): number {
|
||||
+ const rowsBack = Math.min(Math.floor(cols / this._terminal.cols), lineOffsets.length - 1);
|
||||
+ let offset = lineOffsets[rowsBack];
|
||||
+ const line = this._terminal.buffer.active.getLine(startRow + rowsBack);
|
||||
+ if (line) {
|
||||
+ const colsInRow = Math.min(cols - rowsBack * this._terminal.cols, this._terminal.cols);
|
||||
+ for (let i = 0; i < colsInRow; i++) {
|
||||
const cell = line.getCell(i);
|
||||
if (!cell) {
|
||||
break;
|
||||
@@ -380,12 +433,6 @@ export class SearchEngine {
|
||||
offset += cell.getCode() === 0 ? 1 : cell.getChars().length;
|
||||
}
|
||||
}
|
||||
- lineIndex++;
|
||||
- line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
- if (line && !line.isWrapped) {
|
||||
- break;
|
||||
- }
|
||||
- cols -= this._terminal.cols;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
diff --git a/src/SearchLineCache.ts b/src/SearchLineCache.ts
|
||||
index 526f4bfcc74a881bb39b400ec79a25d33d602303..19b22f2f70e50a6b01d07966e15727cc5271c776 100644
|
||||
--- a/src/SearchLineCache.ts
|
||||
+++ b/src/SearchLineCache.ts
|
||||
@@ -109,9 +109,13 @@ export class SearchLineCache extends Disposable {
|
||||
public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {
|
||||
const strings = [];
|
||||
const lineOffsets = [0];
|
||||
+ // A single line longer than the whole scrollback leaves every buffer row wrapped, and the
|
||||
+ // buffer's ring answers an out-of-range row by cycling back to the start, so an unbounded walk
|
||||
+ // never reaches an unwrapped line.
|
||||
+ const bufferLength = this._terminal.buffer.active.length;
|
||||
let line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
while (line) {
|
||||
- const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1);
|
||||
+ const nextLine = lineIndex + 1 < bufferLength ? this._terminal.buffer.active.getLine(lineIndex + 1) : undefined;
|
||||
const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
|
||||
let string = line.translateToString(!lineWrapsToNext && trimRight);
|
||||
if (lineWrapsToNext && nextLine) {
|
||||
@@ -59,6 +59,33 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"$upstream": "Submitted as https://github.com/xtermjs/xterm.js/pull/6149 (issue #6148). Once a release ships it, bump the addon and drop this entry.",
|
||||
"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",
|
||||
|
||||
@@ -24,11 +24,11 @@ truth. Everything else is derived from it by
|
||||
`config/scripts/regenerate-xterm-patches.mjs`, which is pinned to the exact
|
||||
upstream commit the published tarball was built from.
|
||||
|
||||
`@xterm/addon-webgl` and `@xterm/addon-serialize` are generated the same way,
|
||||
from their own source patches under `config/patches/xterm-src/`. Their entries
|
||||
differ only in `packageDir` and build steps; everything below applies to all
|
||||
three. `@xterm/addon-ligatures` is the one patch still written by hand — see
|
||||
[Known Gaps](#known-gaps).
|
||||
`@xterm/addon-webgl`, `@xterm/addon-search` and `@xterm/addon-serialize` are
|
||||
generated the same way, from their own source patches under
|
||||
`config/patches/xterm-src/`. Their entries differ only in `packageDir` and build
|
||||
steps; everything below applies to all four. `@xterm/addon-ligatures` is the one
|
||||
patch still written by hand — see [Known Gaps](#known-gaps).
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
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': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0
|
||||
'@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=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@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=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@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,362 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ISearchOptions } from '@xterm/addon-search'
|
||||
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_DEFAULT,
|
||||
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/; submitted upstream as
|
||||
* https://github.com/xtermjs/xterm.js/pull/6149): `_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.
|
||||
*/
|
||||
|
||||
/** Reaches the ring behind the public buffer API to pin the negative-index state reflow leaves. */
|
||||
type RingBufferProbe = {
|
||||
_core: { buffer: { lines: { _array: unknown[] } } }
|
||||
}
|
||||
|
||||
const COLS = 80
|
||||
const ROWS = 24
|
||||
/** Long enough that the wrap chain outruns V8's stack on any host. */
|
||||
const WRAPPED_ROWS = 12_000
|
||||
/** A line longer than this scrollback loses its first rows: the bug is eviction, not size. */
|
||||
const TRIMMED_HEAD_SCROLLBACK = 100
|
||||
const TRIMMED_HEAD_LINE_ROWS = 200
|
||||
const NEEDLE = 'needle'
|
||||
/**
|
||||
* Rows of one wrapped line per match, and how many matches that line holds. Enough matches to make
|
||||
* the highlight-all pass — which re-enters the line once per match — the dominant cost, and enough
|
||||
* rows that a per-match walk of the line is a freeze rather than a slow search. Kept under the
|
||||
* addon's 1 000-decoration limit so the match count is exact.
|
||||
*/
|
||||
const MATCH_ROW_STRIDE = 40
|
||||
const MATCHES_IN_LINE = 750
|
||||
/**
|
||||
* A full-buffer scan runs on the renderer's main thread on every keystroke in
|
||||
* the find bar, so anything near this is a visible freeze rather than a slow
|
||||
* search. Unfixed it is ~18s for a default-scrollback buffer; fixed, ~6ms.
|
||||
*/
|
||||
const FULL_SCAN_BUDGET_MS = 5_000
|
||||
|
||||
// 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
|
||||
|
||||
/**
|
||||
* Every mode the find bar can put the engine in. Regex and whole word used to be
|
||||
* excluded from the wrapped-row skip, which left them on the O(rows^2) walk after
|
||||
* the recursion that used to abort it was gone: a 12 000-row line took 5.4 minutes
|
||||
* of blocked main thread instead of throwing after 48 seconds.
|
||||
*/
|
||||
const SEARCH_MODES = [
|
||||
['plain', {}],
|
||||
['regex', { regex: true }],
|
||||
['whole word', { wholeWord: true }]
|
||||
] as const satisfies readonly (readonly [string, ISearchOptions])[]
|
||||
|
||||
function write(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => terminal.write(data, resolve))
|
||||
}
|
||||
|
||||
function openTerminalWithSearch(scrollback: number = DESKTOP_TERMINAL_SCROLLBACK_ROWS_MAX): {
|
||||
terminal: Terminal
|
||||
search: SearchAddon
|
||||
} {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const terminal = new Terminal({ cols: COLS, rows: ROWS, scrollback })
|
||||
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.each(SEARCH_MODES)(
|
||||
'rewinds to the start of the line without overflowing the stack (%s)',
|
||||
async (_mode, options) => {
|
||||
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. Space-delimited so
|
||||
// the whole-word mode has something to find.
|
||||
await write(
|
||||
terminal,
|
||||
`${'x'.repeat(COLS * (WRAPPED_ROWS - 1) - NEEDLE.length - 1)} ${NEEDLE} ${'x'.repeat(COLS - 1)}`
|
||||
)
|
||||
|
||||
const find = (): boolean =>
|
||||
safeFind((term, searchOptions) => search.findNext(term, searchOptions), NEEDLE, {
|
||||
...options,
|
||||
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)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(SEARCH_MODES)(
|
||||
'scans a long wrapped line once, not once per wrapped row (%s)',
|
||||
async (_mode, options) => {
|
||||
const { terminal, search } = openTerminalWithSearch()
|
||||
await write(terminal, 'x'.repeat(COLS * DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
|
||||
|
||||
// No match, so the scan visits every row: the shape that froze the pane.
|
||||
const startedAt = performance.now()
|
||||
safeFind((term, searchOptions) => search.findNext(term, searchOptions), NEEDLE, {
|
||||
...options,
|
||||
decorations: SEARCH_DECORATIONS
|
||||
})
|
||||
|
||||
expect(performance.now() - startedAt).toBeLessThan(FULL_SCAN_BUDGET_MS)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(SEARCH_MODES)(
|
||||
'highlights many matches in one wrapped line without re-walking it per match (%s)',
|
||||
async (_mode, options) => {
|
||||
const { terminal, search } = openTerminalWithSearch()
|
||||
// `_highlightAllMatches` calls `SearchEngine.find` once per match, and each call resumes
|
||||
// deep inside the line. Converting the resume column to a string offset walked every cell
|
||||
// before it, O(line) per match, so this shape stayed quadratic after the no-match scan was
|
||||
// bounded: ~11s for a line this long, a renderer freeze rather than a RangeError the
|
||||
// boundary recovered from. The per-match rewind and case fold are O(rows) and O(chars)
|
||||
// but measured at well under 1s combined, so they stay simple.
|
||||
const block = ` ${NEEDLE} ${'x'.repeat(COLS * MATCH_ROW_STRIDE - NEEDLE.length - 2)}`
|
||||
await write(terminal, block.repeat(MATCHES_IN_LINE))
|
||||
|
||||
let resultCount = -1
|
||||
search.onDidChangeResults((event) => {
|
||||
resultCount = event.resultCount
|
||||
})
|
||||
const startedAt = performance.now()
|
||||
const found = safeFind(
|
||||
(term, searchOptions) => search.findNext(term, searchOptions),
|
||||
NEEDLE,
|
||||
{
|
||||
...options,
|
||||
decorations: SEARCH_DECORATIONS
|
||||
}
|
||||
)
|
||||
|
||||
expect(performance.now() - startedAt).toBeLessThan(FULL_SCAN_BUDGET_MS)
|
||||
expect(found).toBe(true)
|
||||
expect(resultCount).toBe(MATCHES_IN_LINE)
|
||||
}
|
||||
)
|
||||
|
||||
it('reports every match inside a wrapped line', async () => {
|
||||
const { terminal, search } = openTerminalWithSearch()
|
||||
let resultCount = -1
|
||||
search.onDidChangeResults((event) => {
|
||||
resultCount = event.resultCount
|
||||
})
|
||||
// One logical line wrapping over three rows with a match in each, then a
|
||||
// separate unwrapped line.
|
||||
const paddedNeedle = NEEDLE + 'x'.repeat(COLS - NEEDLE.length)
|
||||
await write(terminal, `${paddedNeedle.repeat(3)}\r\nplain ${NEEDLE}\r\n`)
|
||||
|
||||
safeFind((term, options) => search.findNext(term, options), NEEDLE, {
|
||||
decorations: SEARCH_DECORATIONS
|
||||
})
|
||||
|
||||
expect(resultCount).toBe(4)
|
||||
})
|
||||
|
||||
it('keeps reaching matches in a wrapped line whose first row was trimmed away', async () => {
|
||||
const { terminal, search } = openTerminalWithSearch(TRIMMED_HEAD_SCROLLBACK)
|
||||
// One long line whose head is evicted, so the surviving chain begins on a
|
||||
// row marked isWrapped and no line start is left in the buffer to cover it.
|
||||
const paddedNeedle = NEEDLE + 'x'.repeat(COLS - NEEDLE.length)
|
||||
const lead = 'x'.repeat(COLS * (TRIMMED_HEAD_LINE_ROWS - 3))
|
||||
await write(terminal, `${lead}${paddedNeedle.repeat(2)}${'x'.repeat(COLS)}\r\n`)
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await write(terminal, `line ${i}\r\n`)
|
||||
}
|
||||
const buffer = terminal.buffer.active
|
||||
expect(buffer.getLine(0)?.isWrapped).toBe(true)
|
||||
|
||||
const matchRows: number[] = []
|
||||
for (let y = 0; y < buffer.length; y++) {
|
||||
if (buffer.getLine(y)?.translateToString().includes(NEEDLE)) {
|
||||
matchRows.push(y)
|
||||
}
|
||||
}
|
||||
expect(matchRows.length).toBe(2)
|
||||
|
||||
// Cycle far enough to come back round in both directions: the surviving rows must stay
|
||||
// reachable, not be visited once and then stranded. Reverse search used to return for any
|
||||
// wrapped row including row 0, so a trimmed-head line was never searched backwards at all.
|
||||
for (const direction of ['findNext', 'findPrevious'] as const) {
|
||||
const visits = new Map<number, number>()
|
||||
for (let i = 0; i < 12; i++) {
|
||||
safeFind((term, options) => search[direction](term, options), NEEDLE, {
|
||||
decorations: SEARCH_DECORATIONS
|
||||
})
|
||||
const row = terminal.getSelectionPosition()?.start.y
|
||||
if (row !== undefined) {
|
||||
visits.set(row, (visits.get(row) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of matchRows) {
|
||||
expect(visits.get(row) ?? 0, direction).toBeGreaterThan(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('searches a line that is longer than the whole scrollback', async () => {
|
||||
const { terminal, search } = openTerminalWithSearch(TRIMMED_HEAD_SCROLLBACK)
|
||||
// Every row of the buffer is then a continuation, and the ring answers an out-of-range row by
|
||||
// cycling back to row 0, so walking forward for the end of the line never terminates.
|
||||
const paddedNeedle = NEEDLE + 'x'.repeat(COLS - NEEDLE.length)
|
||||
await write(terminal, `${'x'.repeat(COLS * TRIMMED_HEAD_LINE_ROWS)}${paddedNeedle.repeat(3)}`)
|
||||
const buffer = terminal.buffer.active
|
||||
expect(buffer.getLine(0)?.isWrapped).toBe(true)
|
||||
expect(buffer.getLine(buffer.length - 1)?.isWrapped).toBe(true)
|
||||
|
||||
const startedAt = performance.now()
|
||||
const found = safeFind((term, options) => search.findNext(term, options), NEEDLE, {
|
||||
decorations: SEARCH_DECORATIONS
|
||||
})
|
||||
|
||||
expect(found).toBe(true)
|
||||
expect(performance.now() - startedAt).toBeLessThan(FULL_SCAN_BUDGET_MS)
|
||||
})
|
||||
|
||||
it('stops rewinding at row 0 when a reflow trims a wrapped line head', async () => {
|
||||
const { terminal, search } = openTerminalWithSearch(TRIMMED_HEAD_SCROLLBACK)
|
||||
const paddedNeedle = NEEDLE + 'x'.repeat(COLS - NEEDLE.length)
|
||||
await write(terminal, `${'x'.repeat(COLS * TRIMMED_HEAD_LINE_ROWS)}${paddedNeedle.repeat(4)}`)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await write(terminal, `line ${i}\r\n`)
|
||||
}
|
||||
// Narrowing a pane reflows the buffer, which leaves the ring holding entries at negative
|
||||
// indices, so `getLine(-1)` answers with a stale wrapped line instead of undefined. The rewind
|
||||
// has to stop at row 0 or it walks backwards forever and hangs the renderer.
|
||||
terminal.resize(15, 5)
|
||||
await write(terminal, '')
|
||||
expect(terminal.buffer.active.getLine(0)?.isWrapped).toBe(true)
|
||||
expect(
|
||||
Object.keys((terminal as unknown as RingBufferProbe)._core.buffer.lines._array)
|
||||
).toContain('-1')
|
||||
|
||||
const startedAt = performance.now()
|
||||
const found = safeFind((term, options) => search.findNext(term, options), NEEDLE, {
|
||||
decorations: SEARCH_DECORATIONS
|
||||
})
|
||||
|
||||
expect(found).toBe(true)
|
||||
expect(performance.now() - startedAt).toBeLessThan(FULL_SCAN_BUDGET_MS)
|
||||
})
|
||||
|
||||
it('keeps scanning a line past a hit whole word rejects', async () => {
|
||||
const { terminal, search } = openTerminalWithSearch()
|
||||
// Upstream stopped at the first `indexOf` hit, so `aneedlea` hid the real word
|
||||
// nine columns later and the find bar reported no match at all. Scanning on is
|
||||
// also what makes the wrapped-row skip sound for wholeWord.
|
||||
await write(terminal, `a${NEEDLE}a ${NEEDLE} done\r\n`)
|
||||
|
||||
const found = safeFind((term, options) => search.findNext(term, options), NEEDLE, {
|
||||
wholeWord: true,
|
||||
decorations: SEARCH_DECORATIONS
|
||||
})
|
||||
|
||||
expect(found).toBe(true)
|
||||
expect(terminal.getSelectionPosition()?.start).toEqual({ x: 9, y: 0 })
|
||||
})
|
||||
|
||||
it('finds a whole-word match that only matches from a later wrapped row', async () => {
|
||||
const { terminal, search } = openTerminalWithSearch()
|
||||
// The first hit on the line is `aneedlea`; the real word is on the second
|
||||
// wrapped row, which the skip removes from the walk.
|
||||
const filler = 'x'.repeat(COLS - NEEDLE.length - 2)
|
||||
await write(terminal, `a${NEEDLE}a${filler} ${NEEDLE} ${filler}`)
|
||||
|
||||
const found = safeFind((term, options) => search.findNext(term, options), NEEDLE, {
|
||||
wholeWord: true,
|
||||
decorations: SEARCH_DECORATIONS
|
||||
})
|
||||
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
|
||||
it('steps past a zero-length regex match instead of abandoning the line', async () => {
|
||||
const { terminal, search } = openTerminalWithSearch()
|
||||
await write(terminal, `abc ${NEEDLE} def\r\n`)
|
||||
|
||||
// `^` matches empty at offset 0, which upstream took as the line's only answer.
|
||||
const found = safeFind((term, options) => search.findNext(term, options), `^|${NEEDLE}`, {
|
||||
regex: true,
|
||||
decorations: SEARCH_DECORATIONS
|
||||
})
|
||||
|
||||
expect(found).toBe(true)
|
||||
expect(terminal.getSelectionPosition()?.start).toEqual({ x: 4, y: 0 })
|
||||
})
|
||||
|
||||
it('anchors a regex to the logical line, not to every wrapped row', async () => {
|
||||
const { terminal, search } = openTerminalWithSearch()
|
||||
// The needle starts the second row of one wrapped line. A wrap column is a
|
||||
// rendering artifact, so `^` must not match there.
|
||||
await write(terminal, 'x'.repeat(COLS) + NEEDLE + 'x'.repeat(COLS - NEEDLE.length))
|
||||
|
||||
const found = safeFind((term, options) => search.findNext(term, options), `^${NEEDLE}`, {
|
||||
regex: true,
|
||||
decorations: SEARCH_DECORATIONS
|
||||
})
|
||||
|
||||
expect(found).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user