mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
perf(terminal): bound the highlight pass over a long wrapped line
`SearchAddon._highlightAllMatches` calls `SearchEngine.find` once per match, up to the addon's 1 000-decoration limit, and every call resumes deep inside the logical line. Three things in that path were O(line) per call, so one un-newlined line holding many matches stayed quadratic after the no-match scan was bounded: the rewind to the line start, `stringLine.toLowerCase()`, and `_bufferColsToStringOffset` summing the cells of every row before the resume column. Measured on the real Terminal + real SearchAddon through Orca's `safeFind`, one line of 30 000 rows with 750 matches: 31.0 s plain, 36.2 s regex, 38.2 s whole word of blocked renderer main thread — re-run on every find-bar keystroke and 200 ms after every write. Above the default scrollback that is a worse failure than the RangeError the `terminal.workbench` boundary used to recover from, so bounding the other scans was not enough on its own. - `_bufferColsToStringOffset` takes the cached `lineOffsets` and reads whole rows out of it, walking cells only for the last, partial row. - `SearchLineCache.lineStartRow` remembers the wrapped span it last resolved, so re-entering the same line costs O(1) instead of O(rows). It keeps the row-0 bound the rewind loop had. - `SearchLineCache.caseFoldLine` memoizes the folded line per row. Both memos live in `SearchLineCache` beside `_linesCache` and are dropped by `_destroyLinesCache`, so they inherit its invalidation exactly: line feed, cursor move, resize, and the 15 s TTL. Same three shapes now 222 ms / 310 ms / 180 ms of test time including the 2.4 MB write, against a 5 000 ms budget the committed test asserts. Reading whole rows out of `lineOffsets` also removes an upstream off-by-one: the cell sum counted the trailing null placeholder of a wide character that wrapped to the next row, which `translateBufferLineToStringWithWrap` had already dropped from the joined string, so a match at that boundary was skipped. A 400-scenario differential fuzz against the previous build diverges in 27 scenarios; in every one the new match count equals an independent oracle counted over the same translated lines, the old build under-counted in 8 of the 18 rechecked, and no selection on either side pointed at text that was not the term.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
diff --git a/src/SearchEngine.ts b/src/SearchEngine.ts
|
||||
index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..54ab40085a4797a7eef9efaa89567ce618f5cc40 100644
|
||||
index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..0704a06eb00ed2d1c6bb8b151522082eb1529533 100644
|
||||
--- a/src/SearchEngine.ts
|
||||
+++ b/src/SearchEngine.ts
|
||||
@@ -76,6 +76,9 @@ export class SearchEngine {
|
||||
@@ -57,7 +57,7 @@ index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..54ab40085a4797a7eef9efaa89567ce6
|
||||
/**
|
||||
* 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 {
|
||||
@@ -250,23 +277,24 @@ export class SearchEngine {
|
||||
* @returns The search result if it was found.
|
||||
*/
|
||||
private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {
|
||||
@@ -81,14 +81,12 @@ index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..54ab40085a4797a7eef9efaa89567ce6
|
||||
- searchPosition.startCol += this._terminal.cols;
|
||||
- return this._findInLine(term, searchPosition, searchOptions);
|
||||
+ } else {
|
||||
+ // Walked rather than recursed: 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 entries at negative indices, so `getLine(-1)` answers with a stale wrapped line and
|
||||
+ // a line whose head was trimmed from the scrollback would rewind forever.
|
||||
+ while (searchPosition.startRow > 0 && this._terminal.buffer.active.getLine(searchPosition.startRow)?.isWrapped) {
|
||||
+ searchPosition.startCol += this._terminal.cols;
|
||||
+ searchPosition.startRow--;
|
||||
+ }
|
||||
+ // Resolved through the line cache rather than recursed: one frame per wrapped row overflows
|
||||
+ // the stack on a line long enough to fill the scrollback, and the cache remembers the span
|
||||
+ // so re-entering the same line once per match does not walk it again.
|
||||
+ const lineStart = this._lineCache.lineStartRow(searchPosition.startRow);
|
||||
+ searchPosition.startCol += (searchPosition.startRow - lineStart) * this._terminal.cols;
|
||||
+ searchPosition.startRow = lineStart;
|
||||
}
|
||||
+ const row = searchPosition.startRow;
|
||||
+ const col = searchPosition.startCol;
|
||||
@@ -96,7 +94,22 @@ index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..54ab40085a4797a7eef9efaa89567ce6
|
||||
let cache = this._lineCache.getLineFromCache(row);
|
||||
if (!cache) {
|
||||
cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);
|
||||
@@ -289,32 +319,46 @@ export class SearchEngine {
|
||||
@@ -274,12 +302,12 @@ 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) {
|
||||
searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
|
||||
- searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();
|
||||
+ searchStringLine = searchOptions.caseSensitive ? stringLine : this._lineCache.caseFoldLine(row, stringLine);
|
||||
}
|
||||
|
||||
let resultIndex = -1;
|
||||
@@ -289,32 +317,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))) {
|
||||
@@ -160,11 +173,113 @@ index 1760bc2bd1fd274d23e2032fde631b39c739f0d9..54ab40085a4797a7eef9efaa89567ce6
|
||||
// Adjust the row number and search index if needed since a "line" of text can span multiple
|
||||
// rows
|
||||
let startRowOffset = 0;
|
||||
@@ -365,12 +407,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 +431,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
|
||||
index 526f4bfcc74a881bb39b400ec79a25d33d602303..8bee362587eead2b1a9d09140bd09abe98f4efd4 100644
|
||||
--- a/src/SearchLineCache.ts
|
||||
+++ b/src/SearchLineCache.ts
|
||||
@@ -109,9 +109,13 @@ export class SearchLineCache extends Disposable {
|
||||
@@ -38,6 +38,10 @@ export class SearchLineCache extends Disposable {
|
||||
private _linesCache: LineCacheEntry[] | undefined;
|
||||
private _linesCacheTimeout = this._register(new MutableDisposable());
|
||||
private _linesCacheDisposables = this._register(new MutableDisposable());
|
||||
+ /** Rows `start`..`end` are one logical line: `start` begins it and every row after it wraps. */
|
||||
+ private _wrappedSpan: { start: number, end: number } | undefined;
|
||||
+ /** The case-folded form of one cached line, keyed by the row the line starts on. */
|
||||
+ private _caseFoldedLine: { row: number, folded: string } | undefined;
|
||||
// Track access to avoid recreating a timeout on every init call which occurs once per search
|
||||
// result (findNext/findPrevious -> _highlightAllMatches -> find loop).
|
||||
private _lastAccessTimestamp = 0;
|
||||
@@ -68,6 +72,8 @@ export class SearchLineCache extends Disposable {
|
||||
|
||||
private _destroyLinesCache(): void {
|
||||
this._linesCache = undefined;
|
||||
+ this._wrappedSpan = undefined;
|
||||
+ this._caseFoldedLine = undefined;
|
||||
this._lastAccessTimestamp = 0;
|
||||
this._linesCacheDisposables.clear();
|
||||
this._linesCacheTimeout.clear();
|
||||
@@ -88,6 +94,40 @@ export class SearchLineCache extends Disposable {
|
||||
}, delay);
|
||||
}
|
||||
|
||||
+ /**
|
||||
+ * The row the logical line containing `row` starts on. Spans are remembered because the
|
||||
+ * highlight-all pass re-enters the same line once per match, so rewinding row by row costs
|
||||
+ * O(rows) every time. Bounded at row 0: after a reflow the buffer's ring holds entries at
|
||||
+ * negative indices, so `getLine(-1)` answers with a stale wrapped line and the walk never ends.
|
||||
+ */
|
||||
+ public lineStartRow(row: number): number {
|
||||
+ const span = this._wrappedSpan;
|
||||
+ if (span && row >= span.start && row <= span.end) {
|
||||
+ return span.start;
|
||||
+ }
|
||||
+ let start = row;
|
||||
+ while (start > 0 && this._terminal.buffer.active.getLine(start)?.isWrapped) {
|
||||
+ if (span && start > span.start && start <= span.end) {
|
||||
+ start = span.start;
|
||||
+ break;
|
||||
+ }
|
||||
+ start--;
|
||||
+ }
|
||||
+ this._wrappedSpan = { start, end: span?.start === start ? Math.max(span.end, row) : row };
|
||||
+ return start;
|
||||
+ }
|
||||
+
|
||||
+ /**
|
||||
+ * The case-folded form of a cached line. Memoized because a line long enough to fill the
|
||||
+ * scrollback is otherwise re-folded once per match by the highlight-all pass.
|
||||
+ */
|
||||
+ public caseFoldLine(row: number, line: string): string {
|
||||
+ if (this._caseFoldedLine?.row !== row) {
|
||||
+ this._caseFoldedLine = { row, folded: line.toLowerCase() };
|
||||
+ }
|
||||
+ return this._caseFoldedLine.folded;
|
||||
+ }
|
||||
+
|
||||
public getLineFromCache(row: number): LineCacheEntry | undefined {
|
||||
return this._linesCache?.[row];
|
||||
}
|
||||
@@ -109,9 +149,13 @@ export class SearchLineCache extends Disposable {
|
||||
public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {
|
||||
const strings = [];
|
||||
const lineOffsets = [0];
|
||||
|
||||
Generated
+3
-3
@@ -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': f9a3b2b87e32128b5c61af442e8a6d6d5bacbfe779c58711d28d7e9c6eb6a497
|
||||
'@xterm/addon-search@0.17.0-beta.300': 2a0516e909d3dcbde25bea0560882a21f539d242c4c1b031d0b96f657d8387bd
|
||||
'@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=f9a3b2b87e32128b5c61af442e8a6d6d5bacbfe779c58711d28d7e9c6eb6a497)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
|
||||
version: 0.17.0-beta.300(patch_hash=2a0516e909d3dcbde25bea0560882a21f539d242c4c1b031d0b96f657d8387bd)(@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=f9a3b2b87e32128b5c61af442e8a6d6d5bacbfe779c58711d28d7e9c6eb6a497)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
|
||||
'@xterm/addon-search@0.17.0-beta.300(patch_hash=2a0516e909d3dcbde25bea0560882a21f539d242c4c1b031d0b96f657d8387bd)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d)
|
||||
|
||||
|
||||
@@ -44,6 +44,14 @@ const WRAPPED_ROWS = 12_000
|
||||
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
|
||||
@@ -154,6 +162,38 @@ describe('terminal search inside one very long wrapped line', () => {
|
||||
}
|
||||
)
|
||||
|
||||
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: it rewinds to the line start, re-folds the line's text and converts
|
||||
// the resume column to a string offset cell by cell. All three were O(line) per match, so
|
||||
// this shape stayed quadratic after the no-match scan was bounded: measured at 117s for a
|
||||
// line this long, a hard renderer freeze rather than a RangeError the boundary recovered from.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user