Skip to content

Commit ab309c0

Browse files
authored
Merge pull request #1864 from binaricat/codex/fix-long-line-highlight-lock
[codex] Fix long-line keyword highlight lockup
2 parents 9ae973b + 3c72bba commit ab309c0

2 files changed

Lines changed: 158 additions & 13 deletions

File tree

components/terminal/keywordHighlight.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,64 @@ function createFakeTerminalFromLines(lines: Array<{ text: string; isWrapped: boo
107107
return { term, decorations };
108108
}
109109

110+
function createFakeTerminalFromLargeWrappedBlock({
111+
lineCount,
112+
lineText,
113+
viewportY,
114+
rows,
115+
}: {
116+
lineCount: number;
117+
lineText: string;
118+
viewportY: number;
119+
rows: number;
120+
}) {
121+
let getLineCount = 0;
122+
const decorations: Array<{ x: number; width: number; foregroundColor: string }> = [];
123+
const noopDisposable = { dispose() {} };
124+
const term = {
125+
rows,
126+
cols: lineText.length,
127+
buffer: {
128+
active: {
129+
type: "normal",
130+
viewportY,
131+
baseY: 0,
132+
cursorY: viewportY,
133+
length: lineCount,
134+
getLine: (lineY: number) => {
135+
getLineCount += 1;
136+
if (lineY < 0 || lineY >= lineCount) return undefined;
137+
return createFakeWrappedLine(lineText, lineY > 0);
138+
},
139+
},
140+
},
141+
onScroll: () => noopDisposable,
142+
onWriteParsed: () => noopDisposable,
143+
onResize: () => noopDisposable,
144+
onRender: () => noopDisposable,
145+
registerMarker(offset: number) {
146+
return {
147+
line: offset,
148+
isDisposed: false,
149+
dispose() {
150+
this.isDisposed = true;
151+
},
152+
};
153+
},
154+
registerDecoration(options: { x: number; width: number; foregroundColor: string }) {
155+
decorations.push(options);
156+
return {
157+
isDisposed: false,
158+
dispose() {
159+
this.isDisposed = true;
160+
},
161+
};
162+
},
163+
refresh() {},
164+
};
165+
return { term, decorations, getLineCount: () => getLineCount };
166+
}
167+
110168
function createFakeTerminal(lineText: string, options: { lineCount?: number } = {}) {
111169
const lineCount = options.lineCount ?? 1;
112170
let translateCount = 0;
@@ -310,3 +368,39 @@ test("wrapped highlight scanning falls back when the logical line exceeds the sc
310368
raf.restore();
311369
}
312370
});
371+
372+
test("wrapped highlight scanning stops before walking an oversized soft-wrapped line", () => {
373+
const raf = installAnimationFrameQueue();
374+
try {
375+
const lineText = "a".repeat(80);
376+
const { term, decorations, getLineCount } = createFakeTerminalFromLargeWrappedBlock({
377+
lineCount: 30_000,
378+
lineText,
379+
viewportY: 29_990,
380+
rows: 3,
381+
});
382+
const highlighter = new KeywordHighlighter(term as never);
383+
const rules: KeywordHighlightRule[] = [
384+
{
385+
id: "wrapped",
386+
label: "Wrapped",
387+
patterns: [`${lineText}${lineText}`],
388+
color: "#F87171",
389+
enabled: true,
390+
},
391+
];
392+
393+
highlighter.setRules(rules, true);
394+
raf.flush();
395+
highlighter.dispose();
396+
resetTerminalOutputPressure(term as never);
397+
398+
assert.deepEqual(decorations, []);
399+
assert.ok(
400+
getLineCount() < 3_000,
401+
`expected capped wrapped scan, got ${getLineCount()} getLine calls`,
402+
);
403+
} finally {
404+
raf.restore();
405+
}
406+
});

components/terminal/keywordHighlight.ts

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ interface WrappedBlockContext {
5353
segmentBounds: Map<number, { lineStart: number; lineEnd: number }>;
5454
}
5555

56+
type WrappedBlockCacheEntry = WrappedBlockContext | null;
57+
58+
interface WrappedBlockScanCache {
59+
contexts: Map<number, WrappedBlockCacheEntry>;
60+
cappedMiss: DirtyLineSegment | null;
61+
}
62+
5663
/** Shared empty array for non-matching lines to avoid per-call allocations. */
5764
const EMPTY_RANGES: readonly CachedDecorationRange[] = Object.freeze([]);
5865

@@ -1023,7 +1030,10 @@ export class KeywordHighlighter implements IDisposable {
10231030
private processLineRange(start: number, end: number, cursorAbsoluteY: number) {
10241031
if (end < start) return;
10251032
const buffer = this.term.buffer.active;
1026-
const wrappedBlockCache = new Map<number, WrappedBlockContext>();
1033+
const wrappedBlockCache: WrappedBlockScanCache = {
1034+
contexts: new Map<number, WrappedBlockCacheEntry>(),
1035+
cappedMiss: null,
1036+
};
10271037
const pressure = getTerminalOutputPressure(this.term);
10281038
for (let lineY = start; lineY <= end; lineY++) {
10291039
const line = buffer.getLine(lineY);
@@ -1103,22 +1113,39 @@ export class KeywordHighlighter implements IDisposable {
11031113
return !!nextLine?.isWrapped;
11041114
}
11051115

1106-
private findWrappedBlockStart(buffer: IBuffer, lineY: number): number {
1116+
private findWrappedBlockStart(buffer: IBuffer, lineY: number): { startY: number; cappedRange?: DirtyLineSegment } {
11071117
let startY = lineY;
1118+
let scannedRows = 0;
1119+
const maxRows = this.getWrappedContextScanRowLimit();
11081120
while (startY > 0) {
1121+
scannedRows += 1;
1122+
if (scannedRows > maxRows) {
1123+
return { startY: -1, cappedRange: { start: startY, end: lineY } };
1124+
}
11091125
const current = buffer.getLine(startY);
11101126
if (!current?.isWrapped) break;
11111127
startY -= 1;
11121128
}
1113-
return startY;
1129+
return { startY };
1130+
}
1131+
1132+
private getWrappedContextScanRowLimit(): number {
1133+
const cols = Math.max(1, this.term.cols || 1);
1134+
return Math.max(1, Math.ceil(TERMINAL_AUX_LONG_LINE_SCAN_LIMIT_CHARS / cols) + 1);
11141135
}
11151136

11161137
private buildWrappedBlockContext(buffer: IBuffer, startY: number): WrappedBlockContext | null {
11171138
let logicalLineText = "";
11181139
const segmentBounds = new Map<number, { lineStart: number; lineEnd: number }>();
11191140
let cursorY = startY;
1141+
let scannedRows = 0;
1142+
const maxRows = this.getWrappedContextScanRowLimit();
11201143

11211144
while (true) {
1145+
scannedRows += 1;
1146+
if (scannedRows > maxRows) {
1147+
return null;
1148+
}
11221149
const segment = buffer.getLine(cursorY);
11231150
if (!segment) break;
11241151
const segmentText = segment.translateToString(true);
@@ -1142,16 +1169,22 @@ export class KeywordHighlighter implements IDisposable {
11421169
private getWrappedContext(
11431170
buffer: IBuffer,
11441171
lineY: number,
1145-
cache: Map<number, WrappedBlockContext>,
1172+
line: IBufferLine,
1173+
cache: WrappedBlockScanCache,
11461174
): { logicalLineText: string; lineStart: number; lineEnd: number } | null {
1147-
const startY = this.findWrappedBlockStart(buffer, lineY);
1148-
let block = cache.get(startY);
1149-
if (!block) {
1150-
block = this.buildWrappedBlockContext(buffer, startY) ?? undefined;
1151-
if (block) {
1152-
cache.set(startY, block);
1153-
}
1175+
if (this.isInCappedWrappedMiss(lineY, line, cache)) {
1176+
return null;
11541177
}
1178+
1179+
const { startY, cappedRange } = this.findWrappedBlockStart(buffer, lineY);
1180+
if (startY < 0) {
1181+
cache.cappedMiss = cappedRange ?? { start: lineY, end: lineY };
1182+
return null;
1183+
}
1184+
if (!cache.contexts.has(startY)) {
1185+
cache.contexts.set(startY, this.buildWrappedBlockContext(buffer, startY));
1186+
}
1187+
const block = cache.contexts.get(startY);
11551188
if (!block) return null;
11561189
const bounds = block.segmentBounds.get(lineY);
11571190
if (!bounds) return null;
@@ -1162,14 +1195,32 @@ export class KeywordHighlighter implements IDisposable {
11621195
};
11631196
}
11641197

1198+
private isInCappedWrappedMiss(
1199+
lineY: number,
1200+
line: IBufferLine,
1201+
cache: WrappedBlockScanCache,
1202+
): boolean {
1203+
const miss = cache.cappedMiss;
1204+
if (!miss) return false;
1205+
if (lineY >= miss.start && lineY <= miss.end) return true;
1206+
if (lineY === miss.end + 1 && line.isWrapped) {
1207+
miss.end = lineY;
1208+
return true;
1209+
}
1210+
if (lineY > miss.end) {
1211+
cache.cappedMiss = null;
1212+
}
1213+
return false;
1214+
}
1215+
11651216
private scanWrappedLine(
11661217
buffer: IBuffer,
11671218
lineY: number,
11681219
line: IBufferLine,
11691220
lineText: string,
1170-
wrappedBlockCache: Map<number, WrappedBlockContext>,
1221+
wrappedBlockCache: WrappedBlockScanCache,
11711222
): CachedDecorationRange[] {
1172-
const context = this.getWrappedContext(buffer, lineY, wrappedBlockCache);
1223+
const context = this.getWrappedContext(buffer, lineY, line, wrappedBlockCache);
11731224
if (!context || context.logicalLineText === lineText) {
11741225
return this.scanLine(line, lineText);
11751226
}

0 commit comments

Comments
 (0)