perf(terminal): stop terminal search rescanning every row of a wrapped line

SearchEngine's forward scans walk every buffer row and _findInLine rewinds each
wrapped row back to the start of its line, so a single long un-newlined line
costs O(rows^2) on the renderer's main thread. The find bar re-runs that scan on
every keystroke and again 200ms after every onWriteParsed, so a pane holding one
long line freezes repeatedly rather than searching.

A wrapped row's text was already searched when the first row of its line was, so
skip it. Only sound for a plain search: wholeWord and regex can reject at one
offset and match at a later one in the same line, so both keep the old walk.

Measured, one un-newlined line at 80 cols filling the default 5000-row
scrollback, no match (real Terminal + SearchAddon, happy-dom):

  upstream                 17046 ms
  with the recursion fix   18500 ms
  with this commit             6 ms
This commit is contained in:
Neil
2026-09-02 22:42:08 -07:00
parent 8d5d1d548b
commit 6fddfd04e5
4 changed files with 173 additions and 17 deletions
File diff suppressed because one or more lines are too long
@@ -1,8 +1,58 @@
diff --git a/src/SearchEngine.ts b/src/SearchEngine.ts
index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..f856f1f9444a13d29fcbd2f264b0c2bfbf33db1c 100644
index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..4ce664a7b913c5c19d6bbf7feba92cbdaef38c6f 100644
--- a/src/SearchEngine.ts
+++ b/src/SearchEngine.ts
@@ -250,23 +250,19 @@ export class SearchEngine {
@@ -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._isRowCoveredByLineStart(y, searchOptions)) {
+ 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._isRowCoveredByLineStart(y, searchOptions)) {
+ continue;
+ }
searchPosition.startRow = y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
@@ -138,6 +144,9 @@ 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++) {
+ if (this._isRowCoveredByLineStart(y, searchOptions)) {
+ continue;
+ }
searchPosition.startRow = y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
@@ -237,6 +246,19 @@ export class SearchEngine {
(((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));
}
+ /**
+ * A wrapped row's text was already searched in full when the first row of its line was, so a
+ * full-buffer scan that visits every row does O(rows^2) work over one long line. Skipping is
+ * only sound for a plain search: wholeWord and regex can both reject at one offset and match at
+ * a later one within the same line.
+ */
+ private _isRowCoveredByLineStart(row: number, searchOptions?: ISearchOptions): boolean {
+ if (searchOptions?.regex || searchOptions?.wholeWord) {
+ return false;
+ }
+ 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 +272,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 {
+3 -3
View File
@@ -111,7 +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-search@0.17.0-beta.300': 7001761370c7940292deae5b7c38eabfdf7e38f6088443b49faad16bece45b23
'@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
@@ -320,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(patch_hash=af96be17952171ef1ba0069a283115a8cca0b381ad54e555fed832277286ea53)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
version: 0.17.0-beta.300(patch_hash=7001761370c7940292deae5b7c38eabfdf7e38f6088443b49faad16bece45b23)(@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))
@@ -9755,7 +9755,7 @@ snapshots:
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.300(patch_hash=af96be17952171ef1ba0069a283115a8cca0b381ad54e555fed832277286ea53)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
'@xterm/addon-search@0.17.0-beta.300(patch_hash=7001761370c7940292deae5b7c38eabfdf7e38f6088443b49faad16bece45b23)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
@@ -4,7 +4,10 @@ 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 {
DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT,
DESKTOP_TERMINAL_SCROLLBACK_ROWS_MAX
} from '../../../shared/terminal-scrollback-policy'
import { safeFind } from './terminal-search-safe-find'
/**
@@ -32,6 +35,12 @@ const ROWS = 24
/** Long enough that the wrap chain outruns V8's stack on any host. */
const WRAPPED_ROWS = 12_000
const NEEDLE = 'needle'
/**
* 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.
@@ -102,4 +111,51 @@ describe('terminal search inside one very long wrapped line', () => {
}).not.toThrow()
expect(found).toBe(true)
})
it('scans a long wrapped line once, not once per wrapped row', async () => {
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, options) => search.findNext(term, options), NEEDLE, {
decorations: SEARCH_DECORATIONS
})
expect(performance.now() - startedAt).toBeLessThan(FULL_SCAN_BUDGET_MS)
})
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('still finds a whole-word match that only matches from a later wrapped row', async () => {
const { terminal, search } = openTerminalWithSearch()
// From the line start the first hit is `aneedlea`, which wholeWord rejects
// without looking further, so the match on the second row is only reachable
// by searching that row — it must not be skipped as already covered.
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)
})
})