perf(terminal): scan only new tail lines for the wait-blocked sentinel

The wait-blocked scan must prove a signal is ABSENT, so it could not early-exit and re-tested all 2000 retained lines with a 13-alternative regex on every scan (20/s per streaming PTY) even though only ~20 lines were new. Index the matching line indices per tail-array identity and carry them across appends, testing only the lines each append produced.

Also carries the retained character total and the redraw prefix's right-trimmed state across appends, so a saturated tail is no longer re-summed and re-scanned per chunk.
This commit is contained in:
Neil
2026-09-03 20:13:54 -07:00
parent 34222e0137
commit da359accca
5 changed files with 637 additions and 34 deletions
@@ -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<typeof appendNormalizedToTailBuffer>
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)
})
})
+112 -20
View File
@@ -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<readonly string[], RetainedTailLineStats>()
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,
@@ -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)
}
})
}
}
})
@@ -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<readonly string[], number[]>()
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)
}
+14 -14
View File
@@ -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).