diff --git a/src/main/runtime/terminal-tail-buffer.test.ts b/src/main/runtime/terminal-tail-buffer.test.ts new file mode 100644 index 00000000000..9851bd7fdc6 --- /dev/null +++ b/src/main/runtime/terminal-tail-buffer.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from 'vitest' +import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' +import { MAX_TAIL_LINES } from './terminal-tail-limits' +import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' + +// Guards the per-chunk prefix work in appendNormalizedToTailBuffer: the retained char total is +// carried across appends and the redraw prefix is not re-scanned, so a saturated tail must not be +// walked once per chunk. Correctness is pinned by the cold/warm differential below — a "cold" run +// hands every append a fresh array so the memo always misses and every total is summed in full. + +type TailSim = { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null +} + +function newSim(): TailSim { + return { lines: [], partialLine: '', redrawCursor: null } +} + +type Step = ReturnType + +function feed(sim: TailSim, chunk: string, cold: boolean): Step { + const next = appendNormalizedToTailBuffer( + cold ? [...sim.lines] : sim.lines, + sim.partialLine, + chunk, + sim.redrawCursor + ) + sim.lines = next.lines + sim.partialLine = next.partialLine + sim.redrawCursor = next.redrawCursor + return next +} + +function mulberry32(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const ESC = String.fromCharCode(27) + +/** + * `short` fills the 2000-line cap and mixes in TUI redraws; `long` streams lines wide enough to + * hit the 256 KiB character cap first. Both eviction paths adjust the carried character total, so + * both need differential coverage, and a redraw's row truncation would keep `long` off its cap. + */ +function randomChunk(random: () => number, profile: 'short' | 'long'): string { + const roll = random() + if (profile === 'long') { + if (roll < 0.7) { + return `${'w'.repeat(1000 + Math.floor(random() * 3000))}\n` + } + if (roll < 0.8) { + return `\rspinner ${Math.floor(random() * 100)}%` + } + if (roll < 0.9) { + return 'trailing spaces here \n' + } + return roll < 0.95 ? '' : `no newline ${Math.floor(random() * 1000)}` + } + if (roll < 0.22) { + const lines: string[] = [] + for (let index = 0; index < 30; index += 1) { + lines.push(`burst ${Math.floor(random() * 1e6)}${random() < 0.3 ? ' ' : ''}`) + } + return `${lines.join('\n')}\n` + } + if (roll < 0.42) { + return `plain output ${Math.floor(random() * 1e6)}\n` + } + if (roll < 0.56) { + return `${' '.repeat(Math.floor(random() * 3))}\n` + } + if (roll < 0.68) { + const rows = 1 + Math.floor(random() * 12) + return `${ESC}[${rows}A${ESC}[2Kredrawn ${Math.floor(random() * 1000)}\n` + } + if (roll < 0.8) { + return `\rspinner ${Math.floor(random() * 100)}%` + } + if (roll < 0.86) { + return 'trailing spaces here \n' + } + if (roll < 0.92) { + return `multi\nline\nchunk ${Math.floor(random() * 1000)}\n` + } + if (roll < 0.96) { + return '' + } + return `no newline ${Math.floor(random() * 1000)}` +} + +describe('retained tail buffer prefix reuse', () => { + for (const profile of ['short', 'long'] as const) { + for (const seed of [3, 11, 91, 2024]) { + it(`carries the retained char total exactly (${profile}, seed ${seed})`, () => { + const random = mulberry32(seed) + const warm = newSim() + const cold = newSim() + let sawCap = false + for (let step = 0; step < 1400; step += 1) { + const chunk = randomChunk(random, profile) + const lineCountBefore = warm.lines.length + const warmStep = feed(warm, chunk, false) + const coldStep = feed(cold, chunk, true) + expect(warmStep.lines, `step ${step} lines`).toEqual(coldStep.lines) + expect(warmStep.partialLine, `step ${step} partial`).toBe(coldStep.partialLine) + expect(warmStep.truncated, `step ${step} truncated`).toBe(coldStep.truncated) + expect(warmStep.redrawCursor, `step ${step} cursor`).toEqual(coldStep.redrawCursor) + expect(warmStep.newCompleteLines, `step ${step} newCompleteLines`).toBe( + coldStep.newCompleteLines + ) + expect(warmStep.newlyCompletedLines, `step ${step} newlyCompletedLines`).toEqual( + coldStep.newlyCompletedLines + ) + sawCap = + sawCap || + (profile === 'short' + ? warm.lines.length >= MAX_TAIL_LINES + : // Lines dropped below the line cap on an append-only chunk == character-cap eviction. + !chunk.includes(ESC) && + warm.lines.length < MAX_TAIL_LINES && + lineCountBefore + warmStep.newlyCompletedLines.length > warm.lines.length) + } + // Guard against a vacuous pass: the profile's eviction path must have run. + expect(sawCap).toBe(true) + }) + } + } + + it('does not walk the untouched redraw prefix on every chunk', () => { + const sim = newSim() + for (let index = 0; index < MAX_TAIL_LINES + 200; index += 1) { + feed(sim, `streaming build output line ${index}\n`, false) + } + expect(sim.lines.length).toBe(MAX_TAIL_LINES) + + const redrawChunk = `${ESC}[3A${ESC}[2Krewritten row${ESC}[2B\n` + const spy = vi.spyOn(String.prototype, 'charCodeAt') + let prefixTouches = 0 + try { + feed(sim, redrawChunk, false) + prefixTouches = spy.mock.calls.length + } finally { + spy.mockRestore() + } + // Before this change the prefix trailing-space scan alone cost one charCodeAt per retained + // row (~1990); the chunk itself accounts for well under a hundred. + expect(prefixTouches).toBeLessThan(300) + }) +}) diff --git a/src/main/runtime/terminal-tail-buffer.ts b/src/main/runtime/terminal-tail-buffer.ts index 0cf551e92c6..38f929b5d7a 100644 --- a/src/main/runtime/terminal-tail-buffer.ts +++ b/src/main/runtime/terminal-tail-buffer.ts @@ -1,4 +1,5 @@ import { containsTerminalVerticalLineControl } from './terminal-ansi-normalization' +import { carryTerminalTailSentinelMatches } from './terminal-tail-sentinel-index' import { applyTerminalLineControls, processTerminalTailCompleteSegments, @@ -11,6 +12,35 @@ import { type RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' +type RetainedTailLineStats = { + totalChars: number + /** Whether every line is already right-trimmed, so the redraw prefix trim is a no-op. */ + rightTrimmed: boolean +} + +// Why weak + array-keyed: the tail is replaced (never mutated) on every append, so an entry dies +// with the array it describes and only the live tail per PTY is retained. Carrying the char total +// this way replaces a full-tail re-sum on every chunk. +const tailLineStatsByLines = new WeakMap() + +function getRetainedTailLineStats(lines: readonly string[]): RetainedTailLineStats { + const cached = tailLineStatsByLines.get(lines) + if (cached) { + return cached + } + let totalChars = 0 + let rightTrimmed = true + for (const line of lines) { + totalChars += line.length + if (rightTrimmed && trimTerminalLineRight(line) !== line) { + rightTrimmed = false + } + } + const stats = { totalChars, rightTrimmed } + tailLineStatsByLines.set(lines, stats) + return stats +} + export function appendNormalizedToTailBuffer( previousLines: string[], previousPartialLine: string, @@ -52,7 +82,13 @@ export function appendNormalizedToTailBuffer( // Why: status UIs redraw one line via CR/backspace/erase; retain the latest redraw segment instead of appending every spinner frame. const segments = splitRetainedTerminalTailSegments(combinedChunk) const pieces = processTerminalTailCompleteSegments(segments.completeSegments) - const newlyCompletedLines = pieces.map((line) => trimTerminalLineRight(line)) + const newlyCompletedLines: string[] = [] + let newlyCompletedChars = 0 + for (const piece of pieces) { + const line = trimTerminalLineRight(piece) + newlyCompletedLines.push(line) + newlyCompletedChars += line.length + } const partialResult = applyTerminalLineControls(segments.partialSegment) const nextPartialLine = trimTerminalLineRight(partialResult.text) const retainedPartialLine = nextPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) @@ -67,28 +103,51 @@ export function appendNormalizedToTailBuffer( omittedNewCompleteLines > 0 || nextPartialLine.length > MAX_TAIL_PARTIAL_CHARS + // The plain path only ever appends, so the whole previous tail carries unless it was discarded. + const carriesPreviousLines = newCompleteLines === 0 || omittedNewCompleteLines === 0 + const previousStats = carriesPreviousLines ? getRetainedTailLineStats(previousLines) : null + let carriedSourceStart = 0 + let carriedCount = carriesPreviousLines ? previousLines.length : 0 + let nextLinesChars = + (previousStats?.totalChars ?? 0) + (newCompleteLines > 0 ? newlyCompletedChars : 0) + if (nextLines.length > MAX_TAIL_LINES) { - nextLines = nextLines.slice(nextLines.length - MAX_TAIL_LINES) + const evictedCount = nextLines.length - MAX_TAIL_LINES + for (let index = 0; index < evictedCount; index += 1) { + nextLinesChars -= nextLines[index]!.length + } + nextLines = nextLines.slice(evictedCount) truncated = true + const carriedShift = Math.min(evictedCount, carriedCount) + carriedSourceStart += carriedShift + carriedCount -= carriedShift } if (newCompleteLines > 0 || retainedPartialLine.length > previousPartialLine.length) { - if (nextLines === previousLines) { - nextLines = [...previousLines] - } - let totalChars = - nextLines.reduce((sum, line) => sum + line.length, 0) + retainedPartialLine.length + let totalChars = nextLinesChars + retainedPartialLine.length let trimStartIndex = 0 while (trimStartIndex < nextLines.length && totalChars > MAX_TAIL_CHARS) { - totalChars -= nextLines[trimStartIndex].length + totalChars -= nextLines[trimStartIndex]!.length trimStartIndex += 1 } if (trimStartIndex > 0) { + nextLinesChars = totalChars - retainedPartialLine.length nextLines = nextLines.slice(trimStartIndex) truncated = true + const carriedShift = Math.min(trimStartIndex, carriedCount) + carriedSourceStart += carriedShift + carriedCount -= carriedShift } } + if (nextLines !== previousLines) { + tailLineStatsByLines.set(nextLines, { + totalChars: nextLinesChars, + rightTrimmed: carriedCount === 0 || (previousStats?.rightTrimmed ?? true) + }) + carryTerminalTailSentinelMatches(previousLines, nextLines, carriedSourceStart, carriedCount) + } + const redrawCursor = !partialResult.hadControl || partialResult.cursorColumn === nextPartialLine.length ? null @@ -145,13 +204,23 @@ function appendNormalizedToMultilineTailBuffer( const windowRows = maxUpwardCursorReach(normalizedChunk, previousRedrawCursor) + REDRAW_WINDOW_SAFETY_ROWS if (windowRows >= previousLines.length) { - return appendNormalizedToMultilineTailBufferUnwindowed( + const unwindowed = appendNormalizedToMultilineTailBufferUnwindowed( previousLines, boundedPreviousPartialLine, normalizedChunk, previousPartialWasCapped, previousRedrawCursor ) + if (unwindowed.lines !== previousLines) { + let totalChars = 0 + for (const line of unwindowed.lines) { + totalChars += line.length + } + // Why nothing carries: an unwindowed redraw may rewrite any retained row. + tailLineStatsByLines.set(unwindowed.lines, { totalChars, rightTrimmed: true }) + carryTerminalTailSentinelMatches(previousLines, unwindowed.lines, 0, 0) + } + return unwindowed } const prefixLength = previousLines.length - windowRows const suffix = previousLines.slice(prefixLength) @@ -162,36 +231,59 @@ function appendNormalizedToMultilineTailBuffer( previousPartialWasCapped, previousRedrawCursor ) + const previousStats = getRetainedTailLineStats(previousLines) let lines = previousLines.slice(0, prefixLength) - // Why: the shared prefix must match the unwindowed finalize's trailing-space trim without paying a regex per untouched row. - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]! - const lastChar = line.charCodeAt(line.length - 1) - if (lastChar === 32 || lastChar === 9) { - lines[index] = line.replace(/[ \t]+$/g, '') + let linesChars = previousStats.totalChars + for (const line of suffix) { + linesChars -= line.length + } + let carriedSourceStart = 0 + let carriedCount = prefixLength + if (!previousStats.rightTrimmed) { + // Why: the shared prefix must match the unwindowed finalize's trailing-space trim without paying a regex per untouched row. + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]! + const lastChar = line.charCodeAt(line.length - 1) + if (lastChar === 32 || lastChar === 9) { + const trimmed = line.replace(/[ \t]+$/g, '') + lines[index] = trimmed + linesChars -= line.length - trimmed.length + carriedCount = 0 + } } } for (const line of windowed.lines) { lines.push(line) + linesChars += line.length } let truncated = windowed.truncated if (lines.length > MAX_TAIL_LINES) { - lines = lines.slice(lines.length - MAX_TAIL_LINES) + const evictedCount = lines.length - MAX_TAIL_LINES + for (let index = 0; index < evictedCount; index += 1) { + linesChars -= lines[index]!.length + } + lines = lines.slice(evictedCount) truncated = true + const carriedShift = Math.min(evictedCount, carriedCount) + carriedSourceStart += carriedShift + carriedCount -= carriedShift } - let totalChars = windowed.partialLine.length - for (const line of lines) { - totalChars += line.length - } + let totalChars = linesChars + windowed.partialLine.length let dropCount = 0 while (dropCount < lines.length && totalChars > MAX_TAIL_CHARS) { totalChars -= lines[dropCount]!.length dropCount += 1 } if (dropCount > 0) { + linesChars = totalChars - windowed.partialLine.length lines = lines.slice(dropCount) truncated = true + const carriedShift = Math.min(dropCount, carriedCount) + carriedSourceStart += carriedShift + carriedCount -= carriedShift } + tailLineStatsByLines.set(lines, { totalChars: linesChars, rightTrimmed: true }) + carryTerminalTailSentinelMatches(previousLines, lines, carriedSourceStart, carriedCount) return { lines, partialLine: windowed.partialLine, diff --git a/src/main/runtime/terminal-tail-sentinel-index.test.ts b/src/main/runtime/terminal-tail-sentinel-index.test.ts new file mode 100644 index 00000000000..fcab502da96 --- /dev/null +++ b/src/main/runtime/terminal-tail-sentinel-index.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it, vi } from 'vitest' +import { appendNormalizedToTailBuffer } from './terminal-tail-buffer' +import { MAX_TAIL_CHARS, MAX_TAIL_LINES } from './terminal-tail-limits' +import { buildPreview } from './terminal-tail-state' +import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index' +import { computeTerminalTailWaitState } from './terminal-wait-tail-state' +import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './terminal-wait-detection' +import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' + +// The definition the incremental index must reproduce: does ANY retained line (or the +// partial line) match the sentinel? Written out independently of the implementation. +function referenceMayContainBlockedSignal(lines: string[], partialLine: string): boolean { + for (const line of lines) { + if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(line)) { + return true + } + } + return TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) +} + +function indexedMayContainBlockedSignal(lines: string[], partialLine: string): boolean { + return tailMayContainBlockedSignal(lines) || TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) +} + +type TailSim = { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + preview: string +} + +function newSim(): TailSim { + return { lines: [], partialLine: '', redrawCursor: null, preview: '' } +} + +function feed(sim: TailSim, chunk: string): void { + const next = appendNormalizedToTailBuffer(sim.lines, sim.partialLine, chunk, sim.redrawCursor) + sim.lines = next.lines + sim.partialLine = next.partialLine + sim.redrawCursor = next.redrawCursor + sim.preview = buildPreview(next.lines, next.partialLine) +} + +/** A structurally identical tail the index has never seen, so it takes the full-scan path. */ +function unindexed(sim: TailSim): string[] { + return [...sim.lines] +} + +function assertMatchesFullScan(sim: TailSim): void { + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe( + referenceMayContainBlockedSignal(sim.lines, sim.partialLine) + ) + expect(computeTerminalTailWaitState(sim.lines, sim.partialLine, sim.preview)).toEqual( + computeTerminalTailWaitState(unindexed(sim), sim.partialLine, sim.preview) + ) +} + +const BLOCKED_LINE = 'Update available! Press Enter to continue.' + +function countSentinelTests(run: () => void): number { + const spy = vi.spyOn(TERMINAL_WAIT_BLOCKED_SENTINEL_RE, 'test') + try { + run() + return spy.mock.calls.length + } finally { + spy.mockRestore() + } +} + +function saturatedSim(): TailSim { + const sim = newSim() + for (let index = 0; index < MAX_TAIL_LINES + 400; index += 1) { + feed(sim, `streaming build output line ${index}\n`) + } + expect(sim.lines.length).toBe(MAX_TAIL_LINES) + return sim +} + +describe('terminal tail sentinel index', () => { + it('tests only the lines an append produced, not the whole retained tail', () => { + const sim = saturatedSim() + // Warm the index for the current tail identity. + computeTerminalTailWaitState(sim.lines, sim.partialLine, sim.preview) + + const incrementalTests = countSentinelTests(() => { + for (let index = 0; index < 20; index += 1) { + feed(sim, `fresh line ${index}\n`) + } + computeTerminalTailWaitState(sim.lines, sim.partialLine, sim.preview) + }) + + const fullScanTests = countSentinelTests(() => { + computeTerminalTailWaitState(unindexed(sim), sim.partialLine, sim.preview) + }) + + expect(fullScanTests).toBeGreaterThanOrEqual(MAX_TAIL_LINES) + // 20 appended lines + one partial-line test per compute call. + expect(incrementalTests).toBeLessThanOrEqual(25) + }) + + it('keeps a retained sentinel visible and drops it exactly when it is evicted', () => { + const sim = saturatedSim() + feed(sim, `${BLOCKED_LINE}\n`) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(true) + assertMatchesFullScan(sim) + + // Push the prompt to the very last retained slot. + for (let index = 0; index < MAX_TAIL_LINES - 1; index += 1) { + feed(sim, `after prompt ${index}\n`) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(true) + } + expect(sim.lines[0]).toBe(BLOCKED_LINE) + + // One more line evicts it. + feed(sim, 'evicting line\n') + expect(sim.lines.includes(BLOCKED_LINE)).toBe(false) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(false) + assertMatchesFullScan(sim) + + // And it stays gone many chunks later. + for (let index = 0; index < 200; index += 1) { + feed(sim, `long after eviction ${index}\n`) + } + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(false) + assertMatchesFullScan(sim) + }) + + it('drops a sentinel evicted by the retained-character cap', () => { + const sim = newSim() + feed(sim, `${BLOCKED_LINE}\n`) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(true) + const bulkLine = `${'x'.repeat(4000)}\n` + for (let index = 0; index * 4001 < MAX_TAIL_CHARS + 20000; index += 1) { + feed(sim, bulkLine) + } + expect(sim.lines.includes(BLOCKED_LINE)).toBe(false) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(false) + assertMatchesFullScan(sim) + }) + + it('finds a sentinel split across two chunks once the line completes', () => { + const sim = saturatedSim() + feed(sim, 'Codex asks: press ent') + // Still only a partial line, and no alternative matches the fragment yet. + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(false) + assertMatchesFullScan(sim) + + feed(sim, 'er to confirm') + // Now complete, but still the partial line — the partial is always tested directly. + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(true) + assertMatchesFullScan(sim) + + feed(sim, '\n') + // And once it becomes a retained line the index carries it. + expect(sim.partialLine).toBe('') + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(true) + assertMatchesFullScan(sim) + }) + + it('full-scans a tail array the index has never seen (seed/restore path)', () => { + // primeWaitBlockedBaselineFromSeededTail reads whatever tail the restore seed installed. + const seeded = ['boot log', BLOCKED_LINE, 'trailing'] + expect(tailMayContainBlockedSignal(seeded)).toBe(true) + const state = computeTerminalTailWaitState(seeded, '', '') + expect(state.fromTail).toBe(true) + expect(state.signal?.reason).toBe('codex-update-prompt') + + const clean = ['boot log', 'no prompt here', 'trailing'] + expect(tailMayContainBlockedSignal(clean)).toBe(false) + expect(computeTerminalTailWaitState(clean, '', '').signal).toBeNull() + }) + + it('reports fromTail from a blank tail without consulting the index', () => { + const sim = newSim() + feed(sim, ' \n\t\n') + expect(computeTerminalTailWaitState(sim.lines, sim.partialLine, '').fromTail).toBe(false) + feed(sim, 'now visible\n') + expect(computeTerminalTailWaitState(sim.lines, sim.partialLine, '').fromTail).toBe(true) + }) +}) + +// Deterministic PRNG so a divergence is reproducible from the seed alone. +function mulberry32(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const ESC = String.fromCharCode(27) + +/** + * `streaming` saturates and evicts the retained tail; `tui` trades saturation for redraw + * coverage (cursor-up rewrites of retained rows, and reaches past the redraw window). + */ +function randomChunk(random: () => number, profile: 'streaming' | 'tui'): string { + const roll = random() + if (roll < 0.2) { + const lines: string[] = [] + for (let index = 0; index < 30; index += 1) { + lines.push(`burst line ${Math.floor(random() * 1e6)}`) + } + return `${lines.join('\n')}\n` + } + if (roll < 0.42) { + return `plain output ${Math.floor(random() * 1e6)}\n` + } + if (roll < 0.48) { + return `${' '.repeat(Math.floor(random() * 3))}\n` + } + if (roll < 0.54) { + return `${BLOCKED_LINE}\n` + } + if (roll < 0.58) { + return 'do you trust the files in this folder?\n' + } + if (roll < 0.63) { + // Sentinel split across a chunk boundary. + return random() < 0.5 ? 'Codex asks: press ent' : 'er to confirm\n' + } + if (roll < 0.7) { + // TUI redraw: move the cursor up a few rows and rewrite them. + const rows = 1 + Math.floor(random() * 12) + return `${ESC}[${rows}A${ESC}[2Kredrawn row ${Math.floor(random() * 1000)}\n` + } + if (roll < (profile === 'tui' ? 0.76 : 0.7)) { + // Deep redraw that outruns the window and forces the unwindowed path. + return `${ESC}[${1500 + Math.floor(random() * 800)}A${ESC}[2Kdeep redraw\n` + } + if (roll < 0.82) { + return `\rspinner ${Math.floor(random() * 100)}%` + } + if (roll < 0.87) { + return 'trailing spaces here \n' + } + if (roll < 0.91) { + return `${'y'.repeat(3000)}\n` + } + if (roll < 0.95) { + return `multi\nline\nchunk ${Math.floor(random() * 1000)}\n` + } + if (roll < 0.97) { + return '' + } + return `no newline ${Math.floor(random() * 1000)}` +} + +describe('terminal tail sentinel index property', () => { + for (const profile of ['streaming', 'tui'] as const) { + for (const seed of [1, 7, 42, 1337]) { + it(`matches a full scan on every step of a random ${profile} sequence (seed ${seed})`, () => { + const random = mulberry32(seed) + const sim = newSim() + let sawSentinel = false + let sawSaturation = false + for (let step = 0; step < 1200; step += 1) { + feed(sim, randomChunk(random, profile)) + const expected = referenceMayContainBlockedSignal(sim.lines, sim.partialLine) + expect(indexedMayContainBlockedSignal(sim.lines, sim.partialLine)).toBe(expected) + expect(computeTerminalTailWaitState(sim.lines, sim.partialLine, sim.preview)).toEqual( + computeTerminalTailWaitState(unindexed(sim), sim.partialLine, sim.preview) + ) + sawSentinel = sawSentinel || expected + sawSaturation = sawSaturation || sim.lines.length >= MAX_TAIL_LINES + } + // Guard against a vacuous pass. + expect(sawSentinel).toBe(true) + if (profile === 'streaming') { + expect(sawSaturation).toBe(true) + } + }) + } + } +}) diff --git a/src/main/runtime/terminal-tail-sentinel-index.ts b/src/main/runtime/terminal-tail-sentinel-index.ts new file mode 100644 index 00000000000..1c4d67bd0a3 --- /dev/null +++ b/src/main/runtime/terminal-tail-sentinel-index.ts @@ -0,0 +1,77 @@ +import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './terminal-wait-detection' + +/** + * Which retained tail lines match the wait-blocked sentinel, memoized per + * lines-array identity. + * + * Why: `computeTerminalTailWaitState` must prove the ABSENCE of a signal, so it + * cannot early-exit and re-tested all 2000 retained lines on every scan (20/s + * per streaming PTY) even though only ~20 lines were new. Keyed weakly by the + * array so an entry dies with the tail it describes; the tail array is replaced + * on every append and never mutated in place, so at most one entry per PTY + * stays live. + */ +const sentinelMatchesByTailLines = new WeakMap() + +function collectSentinelMatches( + lines: readonly string[], + startIndex: number, + into: number[] +): void { + for (let index = startIndex; index < lines.length; index += 1) { + if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(lines[index]!)) { + into.push(index) + } + } +} + +/** Ascending indices of sentinel-matching lines; full-scans an unseen array. */ +export function getTerminalTailSentinelMatches(lines: readonly string[]): readonly number[] { + const cached = sentinelMatchesByTailLines.get(lines) + if (cached) { + return cached + } + const matches: number[] = [] + collectSentinelMatches(lines, 0, matches) + sentinelMatchesByTailLines.set(lines, matches) + return matches +} + +export function tailMayContainBlockedSignal(lines: readonly string[]): boolean { + return getTerminalTailSentinelMatches(lines).length > 0 +} + +/** + * Derive `nextLines`' match index from `previousLines`', testing only the lines + * the append actually produced. + * + * The caller guarantees `nextLines[0 … carriedCount)` are the very same strings + * as `previousLines[carriedSourceStart … carriedSourceStart + carriedCount)`, + * and that every later line is newly produced. Matches outside that carried + * window are dropped because their lines were evicted or rewritten, which is + * exactly what the full scan would conclude. + */ +export function carryTerminalTailSentinelMatches( + previousLines: readonly string[], + nextLines: readonly string[], + carriedSourceStart: number, + carriedCount: number +): void { + if (nextLines === previousLines) { + return + } + const matches: number[] = [] + if (carriedCount > 0) { + const carriedEnd = carriedSourceStart + carriedCount + for (const index of getTerminalTailSentinelMatches(previousLines)) { + if (index >= carriedEnd) { + break + } + if (index >= carriedSourceStart) { + matches.push(index - carriedSourceStart) + } + } + } + collectSentinelMatches(nextLines, carriedCount, matches) + sentinelMatchesByTailLines.set(nextLines, matches) +} diff --git a/src/main/runtime/terminal-wait-tail-state.ts b/src/main/runtime/terminal-wait-tail-state.ts index 2b9cafee27b..712b301a961 100644 --- a/src/main/runtime/terminal-wait-tail-state.ts +++ b/src/main/runtime/terminal-wait-tail-state.ts @@ -1,5 +1,6 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import { buildTailLines } from './terminal-tail-state' +import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index' import { findActionableTerminalWaitBlockedSignal, TERMINAL_WAIT_BLOCKED_SENTINEL_RE @@ -60,23 +61,22 @@ function inspectTerminalWaitTail( lines: string[], partialLine: string ): { fromTail: boolean; mayContainBlockedSignal: boolean } { - let fromTail = false - let mayContainBlockedSignal = false + return { + fromTail: hasVisibleTailLine(lines) || partialLine.trim().length > 0, + // Why the index: proving a signal is ABSENT can't early-exit, so a full re-test of the + // 2000-line tail ran per scan; the index tests only the lines each append produced. + mayContainBlockedSignal: + tailMayContainBlockedSignal(lines) || TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) + } +} + +function hasVisibleTailLine(lines: string[]): boolean { for (const line of lines) { - if (!fromTail && line.trim().length > 0) { - fromTail = true - } - if (!mayContainBlockedSignal && TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(line)) { - mayContainBlockedSignal = true + if (line.trim().length > 0) { + return true } } - if (!fromTail && partialLine.trim().length > 0) { - fromTail = true - } - if (!mayContainBlockedSignal && TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine)) { - mayContainBlockedSignal = true - } - return { fromTail, mayContainBlockedSignal } + return false } // Why: consumes precomputed wait states so full-tail scans aren't repeated per chunk (replaces the former inline double full-tail scan).