mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user