mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
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 <help@stably.ai>
This commit is contained in:
@@ -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.'
|
||||
)
|
||||
@@ -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<ArrayBufferLike>
|
||||
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<TerminalOutputFrameChunk> {
|
||||
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,
|
||||
|
||||
@@ -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<TerminalOutputFrameChunk> {
|
||||
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<TerminalOutputFrameChunk>): 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<ArrayBufferLike>
|
||||
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<TerminalOutputFrameChunk> {
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user