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:
Neil
2026-09-03 17:59:16 -07:00
committed by GitHub
parent 963839aa4f
commit 8463dcb7b9
8 changed files with 905 additions and 8 deletions
+1 -1
View File
@@ -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) {
+27
View File
@@ -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",
+5 -5
View File
@@ -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
+3 -2
View File
@@ -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)
+1
View File
@@ -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)
})
})