diff --git a/config/scripts/terminal-control-strip-benchmark.mjs b/config/scripts/terminal-control-strip-benchmark.mjs index c06eb2a7ff0..52c563050cf 100644 --- a/config/scripts/terminal-control-strip-benchmark.mjs +++ b/config/scripts/terminal-control-strip-benchmark.mjs @@ -1,6 +1,54 @@ #!/usr/bin/env node -// Mirrors the complete pre/post stripTerminalControl paths at their production size caps. +// Compares legacy, slice-run, and production-adaptive control stripping. +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import nodeModule from 'node:module' import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +if (!process.execArgv.includes('--experimental-transform-types')) { + const result = spawnSync( + process.execPath, + ['--experimental-transform-types', '--no-warnings', import.meta.filename], + { stdio: 'inherit' } + ) + process.exit(result.status ?? 1) +} + +nodeModule.registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) { + const candidate = new URL(`${specifier}.ts`, context.parentURL) + if (existsSync(fileURLToPath(candidate))) { + return { url: candidate.href, shortCircuit: true } + } + } + return nextResolve(specifier, context) + } +}) + +const PRODUCTION_SOURCE = readFileSync( + new URL('../../src/shared/terminal-control-stripping.ts', import.meta.url), + 'utf8' +) +for (const marker of [ + 'export function stripTerminalControl(data: string): string', + 'strippedInBlock === CONTROL_DENSITY_FALLBACK_COUNT', + 'output += withoutAnsi.slice(runStart, index)', + // Retuning either density constant changes which fixtures sit above/below the trigger, so + // pin the literals rather than the names: a silent retune would leave the sub-threshold + // fixture measuring a boundary that no longer exists. + 'const CONTROL_DENSITY_BLOCK_CODE_UNITS = 64', + 'const CONTROL_DENSITY_FALLBACK_COUNT = 32' +]) { + if (!PRODUCTION_SOURCE.includes(marker)) { + throw new Error(`terminal-control-stripping.ts no longer contains \`${marker}\``) + } +} + +const { stripTerminalControl: stripAdaptive } = await import( + new URL('../../src/shared/terminal-control-stripping.ts', import.meta.url).href +) const ESC = String.fromCharCode(0x1b) const BEL = String.fromCharCode(0x07) @@ -15,6 +63,9 @@ const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp( const HISTORY_LIMIT = 300 const SCAN_LIMIT = 4096 const SAMPLE_ID_LENGTH = 24 +// Mirrors terminal-control-stripping.ts; the marker guard above fails if either is retuned. +const CONTROL_DENSITY_BLOCK_CODE_UNITS = 64 +const CONTROL_DENSITY_FALLBACK_COUNT = 32 const ITERATIONS = Number(process.env.ORCA_STRIP_BENCH_ITERATIONS ?? '501') let resultChecksum = 0 let validatedPairs = 0 @@ -75,6 +126,25 @@ function stripSliceRuns(data) { return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart) } +function adaptiveFallbackIndex(data) { + const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '') + let strippedInBlock = 0 + let blockEnd = 64 + for (let index = 0; index < withoutAnsi.length; index += 1) { + if (index === blockEnd) { + strippedInBlock = 0 + blockEnd += 64 + } + if (isStrippedCode(withoutAnsi.charCodeAt(index))) { + strippedInBlock += 1 + if (strippedInBlock === 32) { + return index + } + } + } + return -1 +} + function fixedSampleId(sampleId) { return `sample:${sampleId}`.padEnd(SAMPLE_ID_LENGTH, '_').slice(0, SAMPLE_ID_LENGTH) } @@ -95,6 +165,26 @@ function makeTuiFixture(length, sampleId, strippedControl) { return text + 'x'.repeat(length - text.length) } +// 31 controls per 64-unit block: one below the fallback trigger, so the adaptive path keeps +// slice-run bookkeeping on a shape dense enough to lose to the per-character legacy. This is the +// worst surviving case; it exists so the narrowed adverse window stays visible instead of hiding +// behind the 50% fixture, where the fallback fires and wins. +function makeSubThresholdDenseFixture(length, sampleId, strippedControl) { + const id = fixedSampleId(sampleId) + const units = [] + for (let index = 0; index < length; index += 1) { + const blockOffset = index % CONTROL_DENSITY_BLOCK_CODE_UNITS + if (index < id.length) { + units.push(id[index]) + } else if (blockOffset % 2 === 1 && blockOffset < (CONTROL_DENSITY_FALLBACK_COUNT - 1) * 2) { + units.push(strippedControl) + } else { + units.push(String.fromCharCode(97 + (index % 26))) + } + } + return units.join('') +} + function makeDenseFixture(length, sampleId, strippedControl) { const prefix = `\x1b[35m${fixedSampleId(sampleId)}` const suffix = '\x1b[0m' @@ -120,33 +210,35 @@ function consumeOutput(output) { resultChecksum ^= output.charCodeAt(Math.floor(output.length / 2)) } -function recordPair(fixture, sampleId, perCharFirst, samples) { - const perCharFixture = fixture.make(sampleId, perCharFirst ? '\x01' : '\x02') - const sliceRunsFixture = fixture.make(sampleId, perCharFirst ? '\x02' : '\x01') - if ( - perCharFixture === sliceRunsFixture || - perCharFixture.length !== fixture.length || - sliceRunsFixture.length !== fixture.length - ) { +const IMPLEMENTATIONS = [ + ['perChar', stripPerChar, '\x01'], + ['sliceRuns', stripSliceRuns, '\x02'], + ['adaptive', stripAdaptive, '\x03'] +] + +function recordRotation(fixture, sampleId, lead, samples) { + const inputs = IMPLEMENTATIONS.map(([name, strip, control]) => ({ + name, + strip, + input: fixture.make(sampleId, control) + })) + if (inputs.some(({ input }) => input.length !== fixture.length)) { throw new Error(`invalid inputs for ${fixture.label}, sample ${sampleId}`) } - let perCharResult - let sliceRunsResult - if (perCharFirst) { - perCharResult = measure(stripPerChar, perCharFixture) - sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture) - } else { - sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture) - perCharResult = measure(stripPerChar, perCharFixture) + const results = new Map() + for (let offset = 0; offset < inputs.length; offset += 1) { + const entry = inputs[(lead + offset) % inputs.length] + results.set(entry.name, measure(entry.strip, entry.input)) } - if (perCharResult.output !== sliceRunsResult.output) { + const outputs = [...results.values()].map(({ output }) => output) + if (new Set(outputs).size !== 1) { throw new Error(`strip mismatch for ${fixture.label}, sample ${sampleId}`) } - consumeOutput(perCharResult.output) - consumeOutput(sliceRunsResult.output) + for (const [name, result] of results) { + consumeOutput(result.output) + samples[name].push(result.elapsed) + } validatedPairs += 1 - samples.perChar.push(perCharResult.elapsed) - samples.sliceRuns.push(sliceRunsResult.elapsed) } const denseBodyLength = SCAN_LIMIT - '\x1b[35m'.length - SAMPLE_ID_LENGTH - '\x1b[0m'.length @@ -171,32 +263,68 @@ const fixtures = [ label: `${SCAN_LIMIT} scan ${denseControlPercent}% C0`, length: SCAN_LIMIT, make: (sampleId, control) => makeDenseFixture(SCAN_LIMIT, sampleId, control) + }, + { + label: `${SCAN_LIMIT} scan 31/block C0`, + length: SCAN_LIMIT, + make: (sampleId, control) => makeSubThresholdDenseFixture(SCAN_LIMIT, sampleId, control) } ] +const selectorFixtures = [ + { label: '31 controls', data: `${'\x01'.repeat(31)}${'a'.repeat(33)}`, expected: -1 }, + { label: '32 controls', data: `${'\x01'.repeat(32)}${'a'.repeat(32)}`, expected: 31 }, + { + label: 'block reset at 64', + data: `${'\x01'.repeat(31)}${'a'.repeat(33)}${'\x01'.repeat(32)}`, + expected: 95 + }, + { + label: 'late dense block', + data: `${'a'.repeat(64 * 3)}${'\x01'.repeat(32)}tail`, + expected: 223 + }, + { + label: 'routine TUI', + data: makeTuiFixture(SCAN_LIMIT, 'selector', '\x01'), + expected: -1 + }, + { + label: '31/block never triggers', + data: makeSubThresholdDenseFixture(SCAN_LIMIT, 'selector', '\x01'), + expected: -1 + } +] +for (const fixture of selectorFixtures) { + const actual = adaptiveFallbackIndex(fixture.data) + if (actual !== fixture.expected) { + throw new Error(`${fixture.label} fallback index ${actual}, expected ${fixture.expected}`) + } +} + const pad = (value, width) => String(value).padStart(width) console.log('Complete stripTerminalControl path. Lower is better.') +console.log(`iterations=${ITERATIONS} (${ITERATIONS * 3} rotated samples/implementation, median)`) console.log( - `iterations=${ITERATIONS} (${ITERATIONS * 2} first-touch samples/implementation, counterbalanced median)` -) -console.log( - `${pad('fixture', 25)} ${pad('per-char', 11)} ${pad('slice runs', 12)} ${pad('speedup', 9)}` + `${pad('fixture', 25)} ${pad('per-char', 11)} ${pad('slice runs', 12)} ${pad('adaptive', 11)} ${pad('vs legacy', 10)} ${pad('vs slice', 9)}` ) for (const fixture of fixtures) { - const samples = { perChar: [], sliceRuns: [] } + const samples = { perChar: [], sliceRuns: [], adaptive: [] } for (let index = 0; index < ITERATIONS; index += 1) { - const orders = index % 2 === 0 ? [true, false] : [false, true] - for (const perCharFirst of orders) { - const orderLabel = perCharFirst ? 'per-first' : 'slice-first' - recordPair(fixture, `${index}:${orderLabel}`, perCharFirst, samples) + for (let lead = 0; lead < IMPLEMENTATIONS.length; lead += 1) { + recordRotation(fixture, `${index}:lead-${lead}`, lead, samples) } } const perChar = median(samples.perChar) const sliceRuns = median(samples.sliceRuns) + const adaptive = median(samples.adaptive) console.log( - `${pad(fixture.label, 25)} ${pad(`${(perChar * 1000).toFixed(1)} us`, 11)} ${pad(`${(sliceRuns * 1000).toFixed(1)} us`, 12)} ${pad(`${(perChar / sliceRuns).toFixed(2)}x`, 9)}` + `${pad(fixture.label, 25)} ${pad(`${(perChar * 1000).toFixed(1)} us`, 11)} ${pad(`${(sliceRuns * 1000).toFixed(1)} us`, 12)} ${pad(`${(adaptive * 1000).toFixed(1)} us`, 11)} ${pad(`${(perChar / adaptive).toFixed(2)}x`, 10)} ${pad(`${(sliceRuns / adaptive).toFixed(2)}x`, 9)}` ) } -console.log(`\nvalidated=${validatedPairs} measured pairs, result checksum=${resultChecksum >>> 0}`) +console.log( + `\nvalidated=${validatedPairs} measured rotations, result checksum=${resultChecksum >>> 0}` +) +console.log(`selector checks=${selectorFixtures.length}`) console.log('Production calls are bounded to 4096, 4096, 300, and 301 code units.') diff --git a/config/scripts/terminal-output-frame-chunk-benchmark.mjs b/config/scripts/terminal-output-frame-chunk-benchmark.mjs index 6dd6364524c..c18d8eb2032 100644 --- a/config/scripts/terminal-output-frame-chunk-benchmark.mjs +++ b/config/scripts/terminal-output-frame-chunk-benchmark.mjs @@ -8,10 +8,8 @@ // `chunk += part`. The gate in front of it (terminalStreamByteLengthExceeds) ran the // same per-code-point walk over the whole payload a second time. // -// The fix: one charCodeAt scan computing the UTF-8 width inline with no per-code-point -// string, chunk text taken as data.slice(chunkStart, end), and a gate of -// `data.length > CHUNK || Buffer.byteLength(data, 'utf8') > CHUNK` -- sound because a -// string's UTF-8 length is never below its UTF-16 length. +// The fix: one charCodeAt scan computing UTF-8 width inline, slices for chunk text, +// and bounded byte probes when UTF-16 length alone cannot prove fit or overflow. // // BOTH arms run the complete production path, encodeTerminalStreamText included, and // their emitted frames (base64 + seq + opcode) are compared before any timing, so an @@ -48,11 +46,13 @@ nodeModule.registerHooks({ }) const ITERATIONS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ITERATIONS ?? '40') +const GATE_ITERATIONS = Number(process.env.ORCA_FRAME_GATE_BENCH_ITERATIONS ?? '2000') const WARMUP = Number(process.env.ORCA_FRAME_CHUNK_BENCH_WARMUP ?? '8') const ROUNDS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ROUNDS ?? '6') for (const [name, value] of [ ['ORCA_FRAME_CHUNK_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_FRAME_GATE_BENCH_ITERATIONS', GATE_ITERATIONS], ['ORCA_FRAME_CHUNK_BENCH_WARMUP', WARMUP], ['ORCA_FRAME_CHUNK_BENCH_ROUNDS', ROUNDS] ]) { @@ -73,12 +73,19 @@ const CLIPBOARD_SOURCE = readFileSync( 'utf8' ) -// Why re-read the sources: this benchmark's claim is that the OLD arm paid a -// measureClipboardTextByteLength call per code point and the NEW one pays a single -// Buffer.byteLength gate. Match the CALL form, not a bare word -- a comment naming -// the function would satisfy a substring check. +// Match executable source markers so a stale benchmark fails instead of misleading. for (const [source, label, marker] of [ - [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', "Buffer.byteLength(data, 'utf8')"], + [ + CHUNK_SOURCE, + 'terminal-output-frame-chunks.ts', + 'export function exceedsTerminalStreamChunkBytes(data: string): boolean' + ], + [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS'], + [ + CHUNK_SOURCE, + 'terminal-output-frame-chunks.ts', + 'terminalStreamByteLength(data.slice(start, end))' + ], [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'const text = data.slice(chunkStart, end)'], [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'data.charCodeAt(index + 1)'], [CLIPBOARD_SOURCE, 'clipboard-text.ts', 'export function measureClipboardTextByteLength('], @@ -103,10 +110,17 @@ const { measureClipboardTextByteLength } = await import( const { TerminalStreamOpcode, encodeTerminalStreamJson, encodeTerminalStreamText } = await import( new URL('../../src/shared/terminal-stream-protocol.ts', import.meta.url).href ) -const { iterateTerminalOutputFrameChunks } = await import( +const { exceedsTerminalStreamChunkBytes, iterateTerminalOutputFrameChunks } = await import( new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url).href ) +function previousGate(data) { + return ( + data.length > TERMINAL_STREAM_CHUNK_BYTES || + Buffer.byteLength(data, 'utf8') > TERMINAL_STREAM_CHUNK_BYTES + ) +} + // Pre-fix arm: the exact code that shipped, including the second full walk in the gate. function* iterateBefore(data, meta) { const rawLength = meta?.rawLength ?? data.length @@ -260,6 +274,11 @@ const fixtures = [ data: repeatTo(LONE_HIGH, 64 * 1024), meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) }, + { + label: 'late-wide gate miss (3 chunks)', + data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(32_000)}`, + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, { label: 'ascii 512KiB (snapshot chunking)', data: 'x'.repeat(512 * 1024), @@ -309,6 +328,38 @@ function measureInterleaved(data, meta) { return { beforeMs: median(beforeSamples), afterMs: median(afterSamples) } } +let gateChecksum = 0 + +function drainGate(gate, data) { + gateChecksum = Math.imul(gateChecksum ^ (gate(data) ? 1 : 0), 16777619) >>> 0 +} + +function measureGateInterleaved(data) { + for (let index = 0; index < WARMUP * 10; index += 1) { + drainGate(previousGate, data) + drainGate(exceedsTerminalStreamChunkBytes, data) + } + const previousSamples = [] + const boundedSamples = [] + for (let round = 0; round < ROUNDS; round += 1) { + const run = (gate, samples) => { + const start = performance.now() + for (let index = 0; index < GATE_ITERATIONS; index += 1) { + drainGate(gate, data) + } + samples.push((performance.now() - start) / GATE_ITERATIONS) + } + if (round % 2 === 0) { + run(previousGate, previousSamples) + run(exceedsTerminalStreamChunkBytes, boundedSamples) + } else { + run(exceedsTerminalStreamChunkBytes, boundedSamples) + run(previousGate, previousSamples) + } + } + return { previousMs: median(previousSamples), boundedMs: median(boundedSamples) } +} + const pad = (value, width) => String(value).padStart(width) console.log('iterateTerminalOutputFrameChunks, per flushed batch. Lower is better.') console.log( @@ -342,6 +393,39 @@ for (const fixture of fixtures) { console.log( `\nvalidated=${comparedFixtures} fixtures frame-identical before timing, checksum=${frameChecksum >>> 0}` ) +const gateFixtures = [ + { label: 'ascii 4KiB fit proof', data: 'x'.repeat(4 * 1024) }, + { + label: 'three-byte exact-cap fit proof', + data: '\u20ac'.repeat(TERMINAL_STREAM_CHUNK_BYTES / 3) + }, + { + label: 'late-wide fit after probes', + data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(11_000)}` + }, + { + label: 'late-wide miss during probes', + data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(32_000)}` + }, + { label: 'ascii 64KiB overflow proof', data: 'x'.repeat(64 * 1024) } +] + +console.log('\nTerminal frame-fit gate only. Lower is better.') +console.log( + `${pad('fixture', 34)} ${pad('result', 8)} ${pad('whole scan', 12)} ${pad('bounded', 11)} ${pad('speedup', 9)}` +) +for (const fixture of gateFixtures) { + const previous = previousGate(fixture.data) + const bounded = exceedsTerminalStreamChunkBytes(fixture.data) + if (previous !== bounded) { + throw new Error(`gate result differs for ${fixture.label}`) + } + const { previousMs, boundedMs } = measureGateInterleaved(fixture.data) + console.log( + `${pad(fixture.label, 34)} ${pad(bounded ? 'miss' : 'fit', 8)} ${pad(`${(previousMs * 1000).toFixed(2)} us`, 12)} ${pad(`${(boundedMs * 1000).toFixed(2)} us`, 11)} ${pad(boundedMs > 0 ? `${(previousMs / boundedMs).toFixed(2)}x` : 'n/a', 9)}` + ) +} +console.log(`gate fixtures=${gateFixtures.length}, checksum=${gateChecksum >>> 0}`) console.log( 'Every byte of remote terminal output crosses this function; the batcher flushes at\nTERMINAL_OUTPUT_BATCH_MAX_BYTES (64 KiB) or every 5 ms, so a busy remote agent pane\nruns it tens of times a second per subscribed stream.' ) diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts index f43f7cb93fa..538e700e7a8 100644 --- a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts +++ b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts @@ -7,6 +7,8 @@ import { import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control' import { measureClipboardTextByteLength } from '../../../shared/clipboard-text' import { + TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS, + exceedsTerminalStreamChunkBytes, iterateTerminalOutputFrameChunks, type TerminalOutputFrameChunk, type TerminalOutputMeta @@ -23,6 +25,19 @@ function legacyByteLengthExceeds(data: string, maxBytes: number): boolean { return measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit } +function expectGateEquivalent(data: string, label: string): void { + expect({ label, result: exceedsTerminalStreamChunkBytes(data) }).toEqual({ + label, + result: legacyByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES) + }) +} + +function makeExactByteLength(unit: string, byteLength: number): string { + const unitBytes = Buffer.byteLength(unit, 'utf8') + const repeats = Math.floor(byteLength / unitBytes) + return unit.repeat(repeats) + 'a'.repeat(byteLength - repeats * unitBytes) +} + function* legacyIterateTerminalOutputFrameChunks( data: string, meta?: TerminalOutputMeta @@ -216,6 +231,50 @@ function randomText(random: () => number, parts: number): string { } describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization loop', () => { + it('matches the legacy gate at the byte cap ±3 for every UTF-8 shape', () => { + for (const [label, unit] of [ + ['ascii', 'a'], + ['two-byte', '\u00e9'], + ['three-byte', '\u20ac'], + ['astral', SURROGATE_PAIR], + ['lone-high', LONE_HIGH], + ['lone-low', LONE_LOW], + ['reversed-surrogates', `${LONE_LOW}${LONE_HIGH}a`] + ] as const) { + for (let delta = -3; delta <= 3; delta += 1) { + const byteLength = TERMINAL_STREAM_CHUNK_BYTES + delta + const data = makeExactByteLength(unit, byteLength) + expect(Buffer.byteLength(data, 'utf8')).toBe(byteLength) + expectGateEquivalent(data, `${label} delta=${delta}`) + } + } + }) + + it('matches when probe boundaries bisect or surround surrogate pairs', () => { + const probe = TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS + for (const offset of [-2, -1, 0, 1, 2]) { + const pairStart = probe + offset + const prefix = 'a'.repeat(pairStart) + const suffix = '\u20ac'.repeat(12_000) + for (const middle of [SURROGATE_PAIR, LONE_HIGH, LONE_LOW, LONE_LOW + LONE_HIGH]) { + const data = prefix + middle + suffix + expectGateEquivalent(data, `probe offset=${offset} middle=${escapeUnits(middle)}`) + expectEquivalent(data, undefined, `probe frames offset=${offset}`) + } + } + }) + + it('stops correctly when late wide text crosses the cap', () => { + const asciiPrefix = 'a'.repeat(16_000) + for (const wide of ['\u00e9', '\u20ac', SURROGATE_PAIR, LONE_HIGH]) { + for (const wideParts of [8_000, 12_000, 16_000]) { + const data = asciiPrefix + wide.repeat(wideParts) + expectGateEquivalent(data, `late-wide ${escapeUnits(wide)} parts=${wideParts}`) + expectEquivalent(data, undefined, `late-wide frames ${escapeUnits(wide)}`) + } + } + }) + it('matches on the small/no-chunking sizes', () => { for (const size of [0, 1, 2, 3, 7, 64, 1024, TERMINAL_STREAM_CHUNK_BYTES - 1]) { sweepAll('a'.repeat(size), `ascii ${size}`) @@ -416,7 +475,7 @@ describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization expectEquivalent(data, { seq: 88_888, rawLength: data.length }, `fuzz-cap seq trial=${trial}`) expectEquivalent(data, { seq: 88_888 }, `fuzz-cap delayed trial=${trial}`) } - }) + }, 15_000) it('keeps every emitted frame within the wire cap and reassembles to the input', () => { const data = `${'a'.repeat(200 * 1024)}${SURROGATE_PAIR.repeat(4096)}${LONE_HIGH}` diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks.ts b/src/main/runtime/rpc/terminal-output-frame-chunks.ts index f040934a565..167b6c20494 100644 --- a/src/main/runtime/rpc/terminal-output-frame-chunks.ts +++ b/src/main/runtime/rpc/terminal-output-frame-chunks.ts @@ -4,6 +4,7 @@ import { encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control' +import { terminalStreamByteLength } from './terminal-stream-byte-length' export type TerminalOutputMeta = { seq?: number @@ -18,12 +19,31 @@ export type TerminalOutputFrameChunk = { opcode?: TerminalStreamOpcode } -// Why: UTF-8 length is never below UTF-16 length, so a code-unit count over the cap already proves the byte count is. -function exceedsTerminalStreamChunkBytes(data: string): boolean { - return ( - data.length > TERMINAL_STREAM_CHUNK_BYTES || - Buffer.byteLength(data, 'utf8') > TERMINAL_STREAM_CHUNK_BYTES - ) +export const TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS = 8 * 1024 +const MAX_UTF8_BYTES_PER_CODE_UNIT = 3 + +export function exceedsTerminalStreamChunkBytes(data: string): boolean { + if (data.length > TERMINAL_STREAM_CHUNK_BYTES) { + return true + } + if (data.length * MAX_UTF8_BYTES_PER_CODE_UNIT <= TERMINAL_STREAM_CHUNK_BYTES) { + return false + } + let byteLength = 0 + for (let start = 0; start < data.length; ) { + let end = Math.min(start + TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS, data.length) + const high = data.charCodeAt(end - 1) + const low = data.charCodeAt(end) + if (end < data.length && high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff) { + end -= 1 + } + byteLength += terminalStreamByteLength(data.slice(start, end)) + if (byteLength > TERMINAL_STREAM_CHUNK_BYTES) { + return true + } + start = end + } + return false } export function* iterateTerminalOutputFrameChunks( diff --git a/src/shared/command-code-output-status.test.ts b/src/shared/command-code-output-status.test.ts index e19504fc5ed..a86c89a174b 100644 --- a/src/shared/command-code-output-status.test.ts +++ b/src/shared/command-code-output-status.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { createCommandCodeOutputStatusDetector } from './command-code-output-status' +import { + createCommandCodeOutputStatusDetector, + stripTerminalControl +} from './command-code-output-status' afterEach(() => { vi.restoreAllMocks() @@ -317,6 +320,44 @@ function maxStringContextLength(contexts: unknown[]): number { } describe('terminal control stripping', () => { + const esc = String.fromCharCode(0x1b) + const bel = String.fromCharCode(0x07) + const ansiEscape = new RegExp( + `${esc}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${bel}]*(?:${bel}|${esc}\\\\))`, + 'g' + ) + const incompleteAnsiEscape = new RegExp( + `${esc}(?:\\[[0-?]*[ -/]*|\\][^${bel}${esc}]*|\\S?)?$`, + 'g' + ) + + function legacyStripTerminalControl(data: string): string { + const withoutAnsi = data.replace(ansiEscape, '').replace(incompleteAnsiEscape, '') + let output = '' + for (let index = 0; index < withoutAnsi.length; index += 1) { + const code = withoutAnsi.charCodeAt(index) + if ((code <= 0x1f && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f)) { + continue + } + output += withoutAnsi[index] + } + return output + } + + function makeRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + return (state >>> 0) / 0x1_0000_0000 + } + } + + function expectLegacyEquivalent(data: string): void { + expect(stripTerminalControl(data)).toBe(legacyStripTerminalControl(data)) + } + function promptFrom(raw: string): string | null { let captured: string | null = null const detector = createCommandCodeOutputStatusDetector({ @@ -347,4 +388,78 @@ describe('terminal control stripping', () => { '日本語 \u{1f389} prompt' ) }) + + it('matches the legacy filter across ANSI, C0/C1, line breaks, and Unicode', () => { + for (const data of [ + '', + 'plain text', + '\x1b[35mstyled\x1b[0m', + '\x1b[unterminated', + '\x00leading', + 'trailing\x9f', + '\x01\x02adjacent\x7f\x80', + 'keep\r\nline breaks', + '日本語 \u{1f389} \ud83d \ude00', + '\x1b]0;title\x07prompt', + '\x1b]0;title\x1b\\prompt' + ]) { + expectLegacyEquivalent(data) + } + }) + + it('matches legacy output at density thresholds and block resets', () => { + const control = '\x01' + const fixtures = [ + `${control.repeat(31)}${'a'.repeat(33)}`, + `${control.repeat(32)}${'a'.repeat(32)}`, + `${'a'.repeat(32)}${control.repeat(31)}a`, + `${control.repeat(31)}${'a'.repeat(33)}${control.repeat(31)}z`, + `${control.repeat(31)}${'a'.repeat(33)}${control.repeat(32)}z`, + `${'a'.repeat(64 * 3)}${control.repeat(32)}tail`, + `${'a\x01'.repeat(2048)}tail` + ] + for (const data of fixtures) { + expectLegacyEquivalent(data) + } + }) + + it('exhaustively matches short strings over control and Unicode code units', () => { + const alphabet = ['a', '\r', '\n', '\x01', '\x7f', '\x80', '\u20ac', '\ud83d'] + for (let encoded = 0; encoded < alphabet.length ** 4; encoded += 1) { + let cursor = encoded + let data = '' + for (let position = 0; position < 4; position += 1) { + data += alphabet[cursor % alphabet.length] + cursor = Math.floor(cursor / alphabet.length) + } + expectLegacyEquivalent(data) + } + }) + + it('matches seeded random terminal text', () => { + const random = makeRandom(0xc0de_0727) + const alphabet = [ + 'a', + 'Z', + '\r', + '\n', + '\x00', + '\x1f', + '\x7f', + '\x9f', + '\u00e9', + '\u20ac', + '\u{1f389}', + '\x1b[35m', + '\x1b[0m' + ] + for (let trial = 0; trial < 2_000; trial += 1) { + let data = '' + const parts = Math.floor(random() * 256) + for (let index = 0; index < parts; index += 1) { + data += alphabet[Math.floor(random() * alphabet.length)] + } + expectLegacyEquivalent(data) + } + }) }) diff --git a/src/shared/command-code-output-status.ts b/src/shared/command-code-output-status.ts index 70bb9ff039e..889c9a787e0 100644 --- a/src/shared/command-code-output-status.ts +++ b/src/shared/command-code-output-status.ts @@ -9,21 +9,14 @@ import { cleanCommandCodePromptCandidate, isCommandCodeIdlePromptCandidate } from './command-code-prompt-text' +import { stripTerminalControl } from './terminal-control-stripping' + +export { stripTerminalControl } from './terminal-control-stripping' type CommandCodeOutputStatusDetector = { observe: (data: string) => boolean } -const ESC = String.fromCharCode(0x1b) -const BEL = String.fromCharCode(0x07) -const ANSI_ESCAPE_RE = new RegExp( - `${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`, - 'g' -) -const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp( - `${ESC}(?:\\[[0-?]*[ -/]*|\\][^${BEL}${ESC}]*|\\S?)?$`, - 'g' -) const RECENT_TEXT_LIMIT = 300 const STATUS_SCAN_TEXT_LIMIT = 4096 const COMMAND_CODE_STATUS_GLYPH_RE_SOURCE = '[·○◇☆✧⌘✻⎿]' @@ -121,41 +114,6 @@ const ACTIVE_EXECUTION_STATUS_RE = new RegExp( const IDLE_PROMPT_RE = /(?:^|[\r\n])\s*[❯>]\s+Ask your question\.\.\./ const COMMAND_CODE_BANNER_RE = /\bCommand Code\b/ -function stripTerminalControl(data: string): string { - if (!terminalControlMayAffectText(data)) { - return data - } - const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '') - // Four calls per PTY chunk favor copying sparse intact runs over per-character concatenation. - let output = '' - let runStart = 0 - for (let index = 0; index < withoutAnsi.length; index += 1) { - const code = withoutAnsi.charCodeAt(index) - if ((code <= 0x1f && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f)) { - if (index > runStart) { - output += withoutAnsi.slice(runStart, index) - } - runStart = index + 1 - } - } - return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart) -} - -function terminalControlMayAffectText(data: string): boolean { - for (let index = 0; index < data.length; index += 1) { - const code = data.charCodeAt(index) - if ( - code === 0x0d || - code === 0x1b || - (code <= 0x1f && code !== 0x0a) || - (code >= 0x7f && code <= 0x9f) - ) { - return true - } - } - return false -} - function cleanPromptCandidate(value: string): string { return cleanCommandCodePromptCandidate(stripTerminalControl(value)) } diff --git a/src/shared/terminal-control-stripping.ts b/src/shared/terminal-control-stripping.ts new file mode 100644 index 00000000000..2f843e92630 --- /dev/null +++ b/src/shared/terminal-control-stripping.ts @@ -0,0 +1,73 @@ +const ESC = String.fromCharCode(0x1b) +const BEL = String.fromCharCode(0x07) +const ANSI_ESCAPE_RE = new RegExp( + `${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`, + 'g' +) +const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp( + `${ESC}(?:\\[[0-?]*[ -/]*|\\][^${BEL}${ESC}]*|\\S?)?$`, + 'g' +) +const CONTROL_DENSITY_BLOCK_CODE_UNITS = 64 +const CONTROL_DENSITY_FALLBACK_COUNT = 32 + +function isStrippedTerminalControl(code: number): boolean { + return (code <= 0x1f && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f) +} + +export function stripTerminalControl(data: string): string { + if (!terminalControlMayAffectText(data)) { + return data + } + const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '') + // Four calls per PTY chunk favor copying sparse intact runs over per-character concatenation. + let output = '' + let runStart = 0 + let strippedInBlock = 0 + let blockEnd = CONTROL_DENSITY_BLOCK_CODE_UNITS + for (let index = 0; index < withoutAnsi.length; index += 1) { + if (index === blockEnd) { + strippedInBlock = 0 + blockEnd += CONTROL_DENSITY_BLOCK_CODE_UNITS + } + if (isStrippedTerminalControl(withoutAnsi.charCodeAt(index))) { + if (index > runStart) { + output += withoutAnsi.slice(runStart, index) + } + runStart = index + 1 + strippedInBlock += 1 + if (strippedInBlock === CONTROL_DENSITY_FALLBACK_COUNT) { + let tailOutput = '' + for (let tailIndex = runStart; tailIndex < withoutAnsi.length; tailIndex += 1) { + const tailCode = withoutAnsi.charCodeAt(tailIndex) + // Inlined isStrippedTerminalControl: this tail runs per code unit on the shape that + // already lost to the call overhead. Keep the two copies in sync. + if ( + (tailCode <= 0x1f && tailCode !== 0x0a && tailCode !== 0x0d) || + (tailCode >= 0x7f && tailCode <= 0x9f) + ) { + continue + } + tailOutput += withoutAnsi[tailIndex] + } + return output + tailOutput + } + } + } + return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart) +} + +function terminalControlMayAffectText(data: string): boolean { + for (let index = 0; index < data.length; index += 1) { + const code = data.charCodeAt(index) + if ( + code === 0x0d || + code === 0x1b || + (code <= 0x1f && code !== 0x0a) || + (code >= 0x7f && code <= 0x9f) + ) { + return true + } + } + return false +}