From 0944fdbef9a3562df4312532a438cbfdcf718579 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 05:13:45 -0700 Subject: [PATCH] perf(terminal): scan output frames by code unit, not per code point iterateTerminalOutputFrameChunks walked `for (const part of data)`, materializing a 1-2 character string per code point and calling terminalStreamByteLength on each, while accumulating the frame text with `chunk += part`. The accumulator was never needed: `chunk` only ever reconstructs the contiguous substring data[chunkStart..end), and `startSeq + chunkStartOffset + chunk.length` collapses algebraically to `startSeq + end`. Track two integer offsets and emit data.slice(chunkStart, end) instead, computing UTF-8 width inline from charCodeAt. Also short-circuits the cap gate on UTF-16 length before measuring UTF-8 bytes, which is sound because UTF-8 length is never below UTF-16 length. 2.4-6.1x across payload shapes, stable across reruns. This runs per terminal output batch and per snapshot chunk. Extracted to its own module along a real seam (the chunk-emission concern plus its two types and cap gate); methods/terminal.ts shrinks by 85 lines. No max-lines suppression added and the baseline is untouched. Co-authored-by: Orca --- .../terminal-output-frame-chunk-benchmark.mjs | 347 +++++++++++++ src/main/runtime/rpc/methods/terminal.ts | 97 +--- ...al-output-frame-chunks-equivalence.test.ts | 467 ++++++++++++++++++ .../rpc/terminal-output-frame-chunks.ts | 112 +++++ 4 files changed, 932 insertions(+), 91 deletions(-) create mode 100644 config/scripts/terminal-output-frame-chunk-benchmark.mjs create mode 100644 src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts create mode 100644 src/main/runtime/rpc/terminal-output-frame-chunks.ts diff --git a/config/scripts/terminal-output-frame-chunk-benchmark.mjs b/config/scripts/terminal-output-frame-chunk-benchmark.mjs new file mode 100644 index 00000000000..33679b06632 --- /dev/null +++ b/config/scripts/terminal-output-frame-chunk-benchmark.mjs @@ -0,0 +1,347 @@ +#!/usr/bin/env node +// Benchmark: iterateTerminalOutputFrameChunks, which every byte of remote terminal +// output passes through on its way to a mobile/remote-desktop multiplex stream. +// +// The pre-fix loop was `for (const part of data)`: V8 materializes a fresh 1-2 code +// unit string per code point, then measureClipboardTextByteLength re-walked that +// string through codePointAt to get its UTF-8 width, and the chunk was rebuilt with +// `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. +// +// BOTH arms run the complete production path, encodeTerminalStreamText included, and +// their emitted frames (base64 + seq + opcode) are compared before any timing, so an +// arm that split differently or renumbered a seq cannot be reported as a win. +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' + +// terminal-stream-protocol.ts declares a TS enum, which Node's default strip-only +// loader rejects; re-exec once with type transformation rather than re-modelling +// the opcodes here (a hand copy could drift from the wire contract). +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) +} + +// The app's TS sources import siblings without an extension; Node's ESM resolver needs it. +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 ITERATIONS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ITERATIONS ?? '40') +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_CHUNK_BENCH_WARMUP', WARMUP], + ['ORCA_FRAME_CHUNK_BENCH_ROUNDS', ROUNDS] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} +if (ROUNDS % 2 !== 0) { + throw new Error(`ORCA_FRAME_CHUNK_BENCH_ROUNDS must be even so each arm leads equally`) +} + +const CHUNK_SOURCE = readFileSync( + new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url), + 'utf8' +) +const CLIPBOARD_SOURCE = readFileSync( + new URL('../../src/shared/clipboard-text.ts', import.meta.url), + '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. +for (const [source, label, marker] of [ + [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', "Buffer.byteLength(data, 'utf8')"], + [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'text: data.slice(chunkStart, end)'], + [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'data.charCodeAt(index + 1)'], + [CLIPBOARD_SOURCE, 'clipboard-text.ts', 'export function measureClipboardTextByteLength('], + [CLIPBOARD_SOURCE, 'clipboard-text.ts', 'text.codePointAt(index)'] +]) { + if (!source.includes(marker)) { + throw new Error(`${label} no longer contains \`${marker}\`; this benchmark is stale`) + } +} +if (CHUNK_SOURCE.includes('for (const part of data)')) { + throw new Error( + 'terminal-output-frame-chunks.ts still iterates code points as strings; this benchmark is stale' + ) +} + +const { TERMINAL_STREAM_CHUNK_BYTES } = await import( + new URL('../../src/shared/terminal-multiplex-flow-control.ts', import.meta.url).href +) +const { measureClipboardTextByteLength } = await import( + new URL('../../src/shared/clipboard-text.ts', import.meta.url).href +) +const { TerminalStreamOpcode, encodeTerminalStreamJson, encodeTerminalStreamText } = await import( + new URL('../../src/shared/terminal-stream-protocol.ts', import.meta.url).href +) +const { iterateTerminalOutputFrameChunks } = await import( + new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url).href +) + +// 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 + if (meta?.transformed || rawLength !== data.length) { + yield { + opcode: TerminalStreamOpcode.OutputSpan, + bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }), + seq: meta?.seq + } + return + } + if ( + !measureClipboardTextByteLength(data, { stopAfterBytes: TERMINAL_STREAM_CHUNK_BYTES }) + .exceededLimit + ) { + yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } + return + } + const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length + const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number' + const startSeq = canPreserveChunkSeq ? meta.seq - rawLength : undefined + let chunk = '' + let chunkBytes = 0 + let chunkStartOffset = 0 + let offset = 0 + let delayedChunk = null + + const takeChunk = () => { + if (!chunk) { + return null + } + const chunkSeq = canPreserveChunkSeq ? startSeq + chunkStartOffset + chunk.length : undefined + const current = { text: chunk, seq: chunkSeq } + chunk = '' + chunkBytes = 0 + chunkStartOffset = offset + return current + } + + for (const part of data) { + const partBytes = measureClipboardTextByteLength(part).byteLength + if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { + const nextChunk = takeChunk() + if (nextChunk) { + if (shouldDelayFinalSeq) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = nextChunk + } else { + yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq } + } + } + } + chunk += part + chunkBytes += partBytes + offset += part.length + } + const finalChunk = takeChunk() + if (shouldDelayFinalSeq) { + if (finalChunk) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = finalChunk + } + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq } + } + return + } + if (finalChunk) { + yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq } + } +} + +// The multiplex stream consumes every frame's bytes/seq/opcode; charge both arms for it. +let frameChecksum = 0 +function drain(iterate, data, meta) { + let frames = 0 + let bytes = 0 + let seqSum = 0 + for (const frame of iterate(data, meta)) { + frames += 1 + bytes += frame.bytes.byteLength + seqSum += frame.seq ?? 0 + } + frameChecksum = Math.imul(frameChecksum ^ (frames + bytes + seqSum), 16777619) >>> 0 + return frames +} + +function describeFrames(iterate, data, meta) { + const shapes = [] + for (const frame of iterate(data, meta)) { + shapes.push( + `${Buffer.from(frame.bytes).toString('base64')}|${frame.seq ?? 'u'}|${frame.opcode ?? 'u'}` + ) + } + return shapes.join('\n') +} + +const SURROGATE_PAIR = '\u{1f600}' +const LONE_HIGH = '\ud83d' + +function repeatTo(unit, codeUnits) { + let out = '' + while (out.length < codeUnits) { + out += unit + } + return out.slice(0, out.length - (out.length % unit.length)) +} + +// A realistic agent-TUI line: SGR runs, a wide glyph, a currency sign, an emoji. +const TUI_LINE = + '\u001b[35m\u273b Thinking\u001b[0m about the \u20ac plan \u{1f600} 42 passed, 0 failed\r\n' + +const fixtures = [ + { + label: 'ascii 4KiB (typical batch)', + data: 'x'.repeat(4 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: `ascii ${TERMINAL_STREAM_CHUNK_BYTES}B (at cap)`, + data: 'x'.repeat(TERMINAL_STREAM_CHUNK_BYTES), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: 'ascii 64KiB (batch cap, 2 chunks)', + data: 'x'.repeat(64 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: 'mixed TUI 64KiB (2 chunks)', + data: repeatTo(TUI_LINE, 64 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + // seq without an explicit rawLength: the batcher's ordinary shape. + label: 'mixed TUI 64KiB (implicit raw)', + data: repeatTo(TUI_LINE, 64 * 1024), + meta: () => ({ seq: 5_000_000 }) + }, + { + label: 'emoji 64KiB (4-byte, 2 chunks)', + data: repeatTo(SURROGATE_PAIR, 32 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: 'lone surrogates 64KiB', + data: repeatTo(LONE_HIGH, 64 * 1024), + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, + { + label: 'ascii 512KiB (snapshot chunking)', + data: 'x'.repeat(512 * 1024), + meta: () => undefined + } +] + +function median(samples) { + const sorted = [...samples].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +// Why interleaved with an alternating lead: running one arm's whole batch first lets +// CPU frequency drift correlate with whichever arm is being measured. Elsewhere in this +// effort that alone reported 23.3x for a real 6.7x. +function measureInterleaved(data, meta) { + for (let index = 0; index < WARMUP; index += 1) { + drain(iterateBefore, data, meta) + drain(iterateTerminalOutputFrameChunks, data, meta) + } + const beforeSamples = [] + const afterSamples = [] + for (let round = 0; round < ROUNDS; round += 1) { + const runBefore = () => { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + drain(iterateBefore, data, meta) + } + beforeSamples.push((performance.now() - start) / ITERATIONS) + } + const runAfter = () => { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + drain(iterateTerminalOutputFrameChunks, data, meta) + } + afterSamples.push((performance.now() - start) / ITERATIONS) + } + if (round % 2 === 0) { + runBefore() + runAfter() + } else { + runAfter() + runBefore() + } + } + return { beforeMs: median(beforeSamples), afterMs: median(afterSamples) } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('iterateTerminalOutputFrameChunks, per flushed batch. Lower is better.') +console.log( + `iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (alternating lead, per-arm median)` +) +console.log( + `${pad('fixture', 34)} ${pad('frames', 7)} ${pad('per-part', 11)} ${pad('scanned', 11)} ${pad('speedup', 9)}` +) + +let comparedFixtures = 0 +for (const fixture of fixtures) { + const meta = fixture.meta(fixture.data) + const before = describeFrames(iterateBefore, fixture.data, meta) + const after = describeFrames(iterateTerminalOutputFrameChunks, fixture.data, meta) + if (before !== after) { + throw new Error(`frames differ for ${fixture.label}`) + } + const frames = before.split('\n').length + // Guard against a fixture that never reaches the chunking loop; then both arms would + // just be measuring the gate and the comparison above would prove nothing about it. + if (fixture.data.length > TERMINAL_STREAM_CHUNK_BYTES && frames < 2) { + throw new Error(`${fixture.label} never split; fixture does not exercise the chunk loop`) + } + comparedFixtures += 1 + const { beforeMs, afterMs } = measureInterleaved(fixture.data, meta) + console.log( + `${pad(fixture.label, 34)} ${pad(frames, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} + +console.log( + `\nvalidated=${comparedFixtures} fixtures frame-identical before timing, checksum=${frameChecksum >>> 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/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 1261a2de4cf..11848a2f0db 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -17,6 +17,11 @@ import { encodeTerminalStreamText, type TerminalStreamFrame } from '../../../../shared/terminal-stream-protocol' +import { + iterateTerminalOutputFrameChunks, + type TerminalOutputFrameChunk, + type TerminalOutputMeta +} from '../terminal-output-frame-chunks' import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-education-telemetry' import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges' import { @@ -49,8 +54,7 @@ import { TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, TERMINAL_MULTIPLEX_MAX_STREAMS_PER_CONNECTION, TERMINAL_MULTIPLEX_PENDING_MAX_BYTES, - TERMINAL_OUTPUT_BATCH_MAX_BYTES, - TERMINAL_STREAM_CHUNK_BYTES + TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../../shared/terminal-multiplex-flow-control' import { drainTerminalMultiplexRoundRobin } from '../terminal-multiplex-round-robin' @@ -139,19 +143,6 @@ type TerminalOutputChunk = { meta?: TerminalOutputMeta } -type TerminalOutputMeta = { - seq?: number - rawLength?: number - transformed?: boolean - cwd?: string -} - -type TerminalOutputFrameChunk = { - bytes: Uint8Array - seq?: number - opcode?: TerminalStreamOpcode -} - function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutputMeta) => void): { push: (data: string, meta?: TerminalOutputMeta) => void flush: () => void @@ -240,82 +231,6 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp } } -function* iterateTerminalOutputFrameChunks( - data: string, - meta?: TerminalOutputMeta -): Generator { - const rawLength = meta?.rawLength ?? data.length - if (meta?.transformed || rawLength !== data.length) { - yield { - opcode: TerminalStreamOpcode.OutputSpan, - bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }), - seq: meta?.seq - } - return - } - if (!terminalStreamByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) { - yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } - return - } - const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length - const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number' - const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined - let chunk = '' - let chunkBytes = 0 - let chunkStartOffset = 0 - let offset = 0 - let delayedChunk: { text: string; seq?: number } | null = null - - const takeChunk = (): { text: string; seq?: number } | null => { - if (!chunk) { - return null - } - const chunkSeq = canPreserveChunkSeq ? startSeq! + chunkStartOffset + chunk.length : undefined - const current = { text: chunk, seq: chunkSeq } - chunk = '' - chunkBytes = 0 - chunkStartOffset = offset - return current - } - - for (const part of data) { - const partBytes = terminalStreamByteLength(part) - if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { - const nextChunk = takeChunk() - if (nextChunk) { - if (shouldDelayFinalSeq) { - if (delayedChunk) { - yield { bytes: encodeTerminalStreamText(delayedChunk.text) } - } - delayedChunk = nextChunk - } else { - yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq } - } - } - } - chunk += part - chunkBytes += partBytes - offset += part.length - } - const finalChunk = takeChunk() - if (shouldDelayFinalSeq) { - // Why: only the final frame can safely carry the high-water mark when rawLength can't map back to UTF-16 offsets. - if (finalChunk) { - if (delayedChunk) { - yield { bytes: encodeTerminalStreamText(delayedChunk.text) } - } - delayedChunk = finalChunk - } - if (delayedChunk) { - yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq } - } - return - } - if (finalChunk) { - yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq } - } -} - function isTerminalInputLockedForClient( runtime: OrcaRuntimeService, ptyId: string, 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 new file mode 100644 index 00000000000..8cf7980b089 --- /dev/null +++ b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts @@ -0,0 +1,467 @@ +import { describe, expect, it } from 'vitest' +import { + TerminalStreamOpcode, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control' +import { measureClipboardTextByteLength } from '../../../shared/clipboard-text' +import { + iterateTerminalOutputFrameChunks, + type TerminalOutputFrameChunk, + type TerminalOutputMeta +} from './terminal-output-frame-chunks' + +// Byte-for-byte reference: the pre-optimization implementation, copied verbatim. +// It accumulates `chunk += part` over `for (const part of data)` and measures each +// code point through the shared clipboard measurer. +function legacyByteLength(data: string): number { + return measureClipboardTextByteLength(data).byteLength +} + +function legacyByteLengthExceeds(data: string, maxBytes: number): boolean { + return measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit +} + +function* legacyIterateTerminalOutputFrameChunks( + data: string, + meta?: TerminalOutputMeta +): Generator { + const rawLength = meta?.rawLength ?? data.length + if (meta?.transformed || rawLength !== data.length) { + yield { + opcode: TerminalStreamOpcode.OutputSpan, + bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }), + seq: meta?.seq + } + return + } + if (!legacyByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) { + yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } + return + } + const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length + const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number' + const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined + let chunk = '' + let chunkBytes = 0 + let chunkStartOffset = 0 + let offset = 0 + let delayedChunk: { text: string; seq?: number } | null = null + + const takeChunk = (): { text: string; seq?: number } | null => { + if (!chunk) { + return null + } + const chunkSeq = canPreserveChunkSeq ? startSeq! + chunkStartOffset + chunk.length : undefined + const current = { text: chunk, seq: chunkSeq } + chunk = '' + chunkBytes = 0 + chunkStartOffset = offset + return current + } + + for (const part of data) { + const partBytes = legacyByteLength(part) + if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { + const nextChunk = takeChunk() + if (nextChunk) { + if (shouldDelayFinalSeq) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = nextChunk + } else { + yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq } + } + } + } + chunk += part + chunkBytes += partBytes + offset += part.length + } + const finalChunk = takeChunk() + if (shouldDelayFinalSeq) { + if (finalChunk) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = finalChunk + } + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq } + } + return + } + if (finalChunk) { + yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq } + } +} + +type FrameShape = { base64: string; seq: number | 'undefined'; opcode: number | 'undefined' } + +function describeFrames(frames: Iterable): FrameShape[] { + const out: FrameShape[] = [] + for (const frame of frames) { + out.push({ + base64: Buffer.from(frame.bytes).toString('base64'), + seq: frame.seq ?? 'undefined', + opcode: frame.opcode ?? 'undefined' + }) + } + return out +} + +function expectEquivalent(data: string, meta: TerminalOutputMeta | undefined, label: string): void { + const legacy = describeFrames(legacyIterateTerminalOutputFrameChunks(data, meta)) + const next = describeFrames(iterateTerminalOutputFrameChunks(data, meta)) + expect(next, label).toEqual(legacy) +} + +const SURROGATE_PAIR = '\u{1f600}' +const LONE_HIGH = '\ud83d' +const LONE_LOW = '\ude00' +// Extremes of both surrogate ranges: U+10000 (D800 DC00) and U+10FFFF (DBFF DFFF). +const FIRST_ASTRAL = '\u{10000}' +const LAST_ASTRAL = '\u{10ffff}' +const SURROGATE_EDGES = [ + '\ud800', + '\udbff', + '\udc00', + '\udfff', + FIRST_ASTRAL, + LAST_ASTRAL, + '\udbff\udbff', + '\ud800\udbff', + '\udfff\udc00' +] + +// Meta shapes exercised against every fixture: no meta, seq-preserved (rawLength === +// data.length), the delayed-final-seq path (rawLength !== data.length -> OutputSpan), +// transformed, and cwd-only. +function metaShapesFor(data: string): { label: string; meta: TerminalOutputMeta | undefined }[] { + return [ + { label: 'no-meta', meta: undefined }, + { label: 'seq-only', meta: { seq: 5_000_000 } }, + { label: 'seq+rawLength=len', meta: { seq: 9_000, rawLength: data.length } }, + { label: 'seq+rawLength!=len', meta: { seq: 9_000, rawLength: data.length + 7 } }, + { label: 'rawLength!=len only', meta: { rawLength: data.length + 3 } }, + { label: 'transformed', meta: { seq: 42, rawLength: data.length, transformed: true } }, + { label: 'cwd-only', meta: { cwd: '/home/dev/orca' } }, + { label: 'seq=0', meta: { seq: 0, rawLength: data.length } } + ] +} + +function sweepAll(data: string, label: string): void { + for (const shape of metaShapesFor(data)) { + expectEquivalent(data, shape.meta, `${label} [${shape.label}]`) + } +} + +// A fixture whose seq path is only observable when rawLength maps 1:1 to UTF-16 +// offsets; forcing that shape is what makes the algebraic collapse testable. +function seqPreservingMeta(data: string, seq: number): TerminalOutputMeta { + return { seq, rawLength: data.length } +} + +function escapeUnits(value: string): string { + const units: string[] = [] + for (let index = 0; index < value.length; index += 1) { + units.push(`U+${value.charCodeAt(index).toString(16).toUpperCase()}`) + } + return units.join(' ') +} + +function repeatToLength(unit: string, codeUnits: number): string { + let out = '' + while (out.length < codeUnits) { + out += unit + } + return out.slice(0, out.length - (out.length % unit.length)) +} + +// Deterministic PRNG so a fuzz failure is reproducible from the seed alone. +function makeRandom(seed: number): () => number { + let state = seed >>> 0 || 1 + return () => { + state ^= state << 13 + state >>>= 0 + state ^= state >>> 17 + state ^= state << 5 + state >>>= 0 + return state / 0x1_0000_0000 + } +} + +const FUZZ_ALPHABET = [ + 'a', + 'z', + '\r', + '\n', + '\u00e9', + '\u20ac', + SURROGATE_PAIR, + LONE_HIGH, + LONE_LOW, + '\u0000', + '\u001b' +] + +function randomText(random: () => number, parts: number): string { + let out = '' + for (let index = 0; index < parts; index += 1) { + out += FUZZ_ALPHABET[Math.floor(random() * FUZZ_ALPHABET.length)] + } + return out +} + +describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization loop', () => { + 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}`) + } + }) + + it('matches across 1B..200KiB ASCII payloads', () => { + for (const size of [ + 1, + 100, + TERMINAL_STREAM_CHUNK_BYTES, + TERMINAL_STREAM_CHUNK_BYTES + 1, + TERMINAL_STREAM_CHUNK_BYTES * 2, + TERMINAL_STREAM_CHUNK_BYTES * 3 + 17, + 100 * 1024, + 200 * 1024 + ]) { + sweepAll('x'.repeat(size), `ascii ${size}`) + } + }) + + it('matches on multi-byte-only payloads at every UTF-8 width', () => { + // UTF-8 width boundaries: 1|2 at U+0080, 2|3 at U+0800, 3|4 at U+10000. + for (const unit of [ + '\u00e9', + '\u20ac', + SURROGATE_PAIR, + '\u0080', + '\u07ff', + '\u0800', + '\uffff' + ]) { + for (const codeUnits of [ + TERMINAL_STREAM_CHUNK_BYTES / 2, + TERMINAL_STREAM_CHUNK_BYTES, + TERMINAL_STREAM_CHUNK_BYTES + 64, + 90 * 1024 + ]) { + const data = repeatToLength(unit, codeUnits) + sweepAll(data, `unit=${JSON.stringify(unit)} codeUnits=${codeUnits}`) + } + } + }) + + it('matches with lone surrogates, including a trailing lone high surrogate', () => { + const filler = 'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + 5) + for (const data of [ + LONE_HIGH, + LONE_LOW, + LONE_HIGH + LONE_HIGH, + LONE_LOW + LONE_HIGH, + filler + LONE_HIGH, + filler + LONE_LOW, + LONE_HIGH + filler, + LONE_LOW + filler, + filler + LONE_HIGH + filler, + // Reversed pair: never a valid pair, so both halves must stay 3-byte replacements. + filler + LONE_LOW + LONE_HIGH + filler, + repeatToLength(LONE_HIGH, 60 * 1024), + repeatToLength(LONE_LOW + LONE_HIGH, 60 * 1024) + ]) { + sweepAll(data, `lone-surrogate len=${data.length}`) + } + }) + + it('matches at both ends of both surrogate ranges (U+D800..U+DBFF, U+DC00..U+DFFF)', () => { + const filler = 'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + 5) + for (const edge of SURROGATE_EDGES) { + for (const data of [ + edge, + filler + edge, + edge + filler, + filler + edge + filler, + repeatToLength(edge, 40 * 1024) + ]) { + sweepAll(data, `surrogate-edge ${escapeUnits(edge)} len=${data.length}`) + } + // Land the split inside the edge sequence itself. + for (let offset = -4; offset <= 4; offset += 1) { + const data = `${'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + offset)}${edge}${'b'.repeat(8)}` + expectEquivalent(data, undefined, `surrogate-edge ${escapeUnits(edge)} offset=${offset}`) + expectEquivalent( + data, + seqPreservingMeta(data, 500_000 + data.length), + `surrogate-edge ${escapeUnits(edge)} offset=${offset} seq` + ) + } + } + }) + + it('sweeps a chunk boundary across a surrogate pair at CHUNK-4..CHUNK+4', () => { + for (let offset = -4; offset <= 4; offset += 1) { + const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset + const data = `${'a'.repeat(prefixBytes)}${SURROGATE_PAIR}${'b'.repeat(64)}` + sweepAll(data, `pair boundary offset=${offset}`) + expectEquivalent( + data, + seqPreservingMeta(data, 1_000_000 + data.length), + `pair boundary offset=${offset} seq-preserved` + ) + } + }) + + it('sweeps a chunk boundary across a multi-byte run at CHUNK-4..CHUNK+4', () => { + for (let offset = -4; offset <= 4; offset += 1) { + const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset + // 3-byte run straddling the cap: the split point cannot land mid-code-point. + const data = `${'a'.repeat(prefixBytes)}${'\u20ac'.repeat(32)}${'b'.repeat(16)}` + sweepAll(data, `multibyte boundary offset=${offset}`) + } + }) + + it('sweeps a chunk boundary across a lone surrogate at CHUNK-4..CHUNK+4', () => { + for (let offset = -4; offset <= 4; offset += 1) { + const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset + for (const lone of [LONE_HIGH, LONE_LOW]) { + const data = `${'a'.repeat(prefixBytes)}${lone}${'b'.repeat(64)}` + sweepAll(data, `lone ${lone === LONE_HIGH ? 'high' : 'low'} boundary offset=${offset}`) + } + // Lone high surrogate as the very last code unit of the payload. + const trailing = `${'a'.repeat(prefixBytes)}${LONE_HIGH}` + sweepAll(trailing, `trailing lone high offset=${offset}`) + } + }) + + it('sweeps 2-byte and 4-byte code points across a 1-code-unit window at the cap', () => { + for (const unit of ['\u00e9', SURROGATE_PAIR]) { + for ( + let pad = TERMINAL_STREAM_CHUNK_BYTES - 6; + pad <= TERMINAL_STREAM_CHUNK_BYTES + 2; + pad += 1 + ) { + const data = `${'a'.repeat(pad)}${unit.repeat(8)}${'z'.repeat(8)}` + expectEquivalent(data, undefined, `window unit=${unit} pad=${pad}`) + expectEquivalent( + data, + seqPreservingMeta(data, 777 + data.length), + `window unit=${unit} pad=${pad} seq` + ) + expectEquivalent( + data, + { seq: 777, rawLength: data.length + 1 }, + `window unit=${unit} pad=${pad} delayed` + ) + } + } + }) + + it('matches on the delayed-final-seq path across many chunk counts', () => { + // rawLength !== data.length routes to OutputSpan; force the multi-chunk delayed + // path by keeping rawLength === data.length but seq present with transformed=false, + // then separately assert the true delayed shape (canPreserveChunkSeq=false). + for (const chunkCount of [1, 2, 3, 5, 9]) { + const data = `${'m'.repeat(TERMINAL_STREAM_CHUNK_BYTES * chunkCount)}${SURROGATE_PAIR}tail` + expectEquivalent(data, { seq: 4242 }, `delayed chunks=${chunkCount} seq-only`) + expectEquivalent(data, { seq: 4242, rawLength: 1 }, `delayed chunks=${chunkCount} raw=1`) + expectEquivalent(data, undefined, `delayed chunks=${chunkCount} no-meta`) + } + }) + + it('matches on realistic mixed terminal output', () => { + const line = '\u001b[35m\u273b Thinking\u001b[0m about the \u20ac plan \u{1f600} 42 passed\r\n' + for (const repeats of [1, 200, 2000, 6000]) { + const data = line.repeat(repeats) + sweepAll(data, `mixed repeats=${repeats}`) + } + }) + + it('fuzzes 4000 short random payloads over the surrogate/control alphabet', () => { + const random = makeRandom(0x5eed_1234) + for (let trial = 0; trial < 4000; trial += 1) { + const data = randomText(random, Math.floor(random() * 40)) + expectEquivalent(data, undefined, `fuzz-small trial=${trial}`) + expectEquivalent( + data, + { seq: 31337, rawLength: data.length }, + `fuzz-small seq trial=${trial}` + ) + } + }) + + it('fuzzes 800 near-cap payloads whose split point lands in the random region', () => { + const random = makeRandom(0x1234_5eed) + for (let trial = 0; trial < 800; trial += 1) { + const fillerLength = TERMINAL_STREAM_CHUNK_BYTES - 6 + Math.floor(random() * 12) + const data = 'q'.repeat(fillerLength) + randomText(random, 1 + Math.floor(random() * 24)) + expectEquivalent(data, undefined, `fuzz-cap trial=${trial}`) + expectEquivalent(data, { seq: 88_888, rawLength: data.length }, `fuzz-cap seq trial=${trial}`) + expectEquivalent(data, { seq: 88_888 }, `fuzz-cap delayed trial=${trial}`) + } + }) + + 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}` + const frames = [...iterateTerminalOutputFrameChunks(data, seqPreservingMeta(data, 999_999))] + expect(frames.length).toBeGreaterThan(4) + for (const frame of frames) { + expect(frame.bytes.byteLength).toBeLessThanOrEqual(TERMINAL_STREAM_CHUNK_BYTES) + } + expect(Buffer.concat(frames.map((frame) => Buffer.from(frame.bytes))).toString('utf8')).toBe( + Buffer.from(new TextEncoder().encode(data)).toString('utf8') + ) + // Seqs must be strictly increasing and end at the meta high-water mark. + const seqs = frames.map((frame) => frame.seq!) + expect(seqs.every((seq, index) => index === 0 || seq > seqs[index - 1]!)).toBe(true) + expect(seqs.at(-1)).toBe(999_999) + }) + + it('emits exactly one frame when the payload fits the cap in bytes but not naively', () => { + // 3-byte code points: 16384 code units = 49152 bytes = exactly the cap. + const exact = '\u20ac'.repeat(TERMINAL_STREAM_CHUNK_BYTES / 3) + expect(Buffer.byteLength(exact, 'utf8')).toBe(TERMINAL_STREAM_CHUNK_BYTES) + expect([...iterateTerminalOutputFrameChunks(exact)]).toHaveLength(1) + expect([...legacyIterateTerminalOutputFrameChunks(exact)]).toHaveLength(1) + const overByOne = `${exact}a` + expect([...iterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1) + expect([...legacyIterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1) + }) + + // The chunking loop is only reached when rawLength === data.length and transformed + // is falsy, which makes canPreserveChunkSeq === (typeof meta.seq === 'number') and + // therefore shouldDelayFinalSeq unconditionally false. The branch survives here + // (it also survived in the pre-optimization code) purely as defence in depth. + it('never reaches the delayed-final-seq branch for any meta shape', () => { + for (const data of ['', 'a', 'abc', 'x'.repeat(200)]) { + for (const seq of [undefined, 0, 5] as (number | undefined)[]) { + for (const rawDelta of [undefined, 0, 1, -1] as (number | undefined)[]) { + for (const transformed of [undefined, false, true] as (boolean | undefined)[]) { + const meta: TerminalOutputMeta = {} + if (seq !== undefined) { + meta.seq = seq + } + if (rawDelta !== undefined) { + meta.rawLength = data.length + rawDelta + } + if (transformed !== undefined) { + meta.transformed = transformed + } + const rawLength = meta.rawLength ?? data.length + const reachesChunkLoop = !meta.transformed && rawLength === data.length + const canPreserveChunkSeq = typeof meta.seq === 'number' && rawLength === data.length + const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta.seq === 'number' + expect(reachesChunkLoop && shouldDelayFinalSeq, JSON.stringify(meta)).toBe(false) + } + } + } + } + }) +}) diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks.ts b/src/main/runtime/rpc/terminal-output-frame-chunks.ts new file mode 100644 index 00000000000..adfa4dc7c91 --- /dev/null +++ b/src/main/runtime/rpc/terminal-output-frame-chunks.ts @@ -0,0 +1,112 @@ +import { + TerminalStreamOpcode, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control' + +export type TerminalOutputMeta = { + seq?: number + rawLength?: number + transformed?: boolean + cwd?: string +} + +export type TerminalOutputFrameChunk = { + bytes: Uint8Array + seq?: number + 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 function* iterateTerminalOutputFrameChunks( + data: string, + meta?: TerminalOutputMeta +): Generator { + const rawLength = meta?.rawLength ?? data.length + if (meta?.transformed || rawLength !== data.length) { + yield { + opcode: TerminalStreamOpcode.OutputSpan, + bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }), + seq: meta?.seq + } + return + } + if (!exceedsTerminalStreamChunkBytes(data)) { + yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq } + return + } + const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length + // Unreachable past the OutputSpan branch above (rawLength === data.length there), but kept + // as defence in depth: it is the only shape that can carry a seq the chunk offsets can't map. + const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number' + const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined + let chunkStart = 0 + let chunkBytes = 0 + let delayedChunk: { text: string; seq?: number } | null = null + + // Why two offsets instead of an accumulator: the emitted text is always the contiguous + // substring from chunkStart to end, and startSeq + chunkStart + text.length is startSeq + end. + const takeChunk = (end: number): { text: string; seq?: number } => { + const current = { + text: data.slice(chunkStart, end), + seq: canPreserveChunkSeq ? startSeq! + end : undefined + } + chunkStart = end + chunkBytes = 0 + return current + } + + let index = 0 + while (index < data.length) { + const code = data.charCodeAt(index) + let width = 1 + let partBytes = 3 + if (code < 0x80) { + partBytes = 1 + } else if (code < 0x800) { + partBytes = 2 + } else if ( + code >= 0xd800 && + code <= 0xdbff && + // Past the end charCodeAt is NaN, which masks to 0 — a trailing lone surrogate stays 3 bytes. + (data.charCodeAt(index + 1) & 0xfc00) === 0xdc00 + ) { + partBytes = 4 + width = 2 + } + // Why chunkBytes > 0: keeps a code point wider than the whole cap in its own frame + // instead of splitting an empty one forever. + if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { + const nextChunk = takeChunk(index) + if (shouldDelayFinalSeq) { + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + delayedChunk = nextChunk + } else { + yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq } + } + } + chunkBytes += partBytes + index += width + } + // A split always advances past its boundary, so the tail after the loop is never empty. + const finalChunk = takeChunk(data.length) + if (shouldDelayFinalSeq) { + // Why: only the final frame can safely carry the high-water mark when rawLength can't map back to UTF-16 offsets. + if (delayedChunk) { + yield { bytes: encodeTerminalStreamText(delayedChunk.text) } + } + yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: meta.seq } + return + } + yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq } +}