mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
* perf(relay): keep the PTY replay window as chunks, not a re-sliced string appendReplayBuffer did `buffered += data` then `buffered.slice(-REPLAY_BUFFER_MAX)` once over the 100KB cap. It runs per raw node-pty emission -- before batching -- so once a PTY saturates the window (which a long-lived shell does almost immediately) every subsequent chunk copied the whole 100KB. Reuse RecentPtyOutputBuffer, which already solved this shape in the main process: keep chunks, drop from the head, defer the join to read(). The relay's three readers are attach, adopt, and revive only. 66-205x on the append path, per PTY, on the user's SSH host. RecentPtyOutputBuffer's limit is now configurable, because the relay retains 100KB where the main process retains 64KB. One arithmetic branch still used the hardcoded constant after that change and silently under-retained (100,800 of 102,400 code units); the equivalence tests caught it before it shipped, and the suite now pins the configured limit directly. Co-authored-by: Orca <help@stably.ai> * test(relay): exercise a real surrogate split; drop eval from the benchmark Review feedback, both valid: - The surrogate test never split a pair. The cap is even and a pair is two code units, so an emoji run alone always cuts on a pair boundary. A trailing single unit shifts the cut mid-pair, leaving a dangling low surrogate (0xDE00) -- asserted directly now, with the boundary-aligned case kept as its own test. - Parse REPLAY_BUFFER_MAX as a product instead of eval(). The regex already admits only digits, spaces and `*`, and eval tripped Biome's noGlobalEval regardless of the eslint suppression. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
130 lines
4.9 KiB
TypeScript
130 lines
4.9 KiB
TypeScript
export const RECENT_PTY_OUTPUT_LIMIT = 64 * 1024
|
|
|
|
// Compact the backing array once this many fully-dropped head slots accumulate,
|
|
// so the array itself stays bounded under long chunk floods.
|
|
const DROPPED_HEAD_COMPACT_THRESHOLD = 1024
|
|
|
|
/**
|
|
* Bounded deque of raw PTY output chunks retaining exactly the last
|
|
* RECENT_PTY_OUTPUT_LIMIT UTF-16 code units.
|
|
*
|
|
* Why: eagerly rebuilding a rolling 64KB string per PTY chunk flattened a
|
|
* ~128KB rope on every write; keep chunks and defer the join to rare readers.
|
|
*/
|
|
export class RecentPtyOutputBuffer {
|
|
private chunks: string[] = []
|
|
private headIndex = 0
|
|
// Code units already trimmed off the front of the head chunk. Deferred so
|
|
// repeated small trims never allocate a substring per append, and so the
|
|
// head chunk's original text stays available for candidate backfill.
|
|
private headOffset = 0
|
|
private totalLen = 0
|
|
// True when the stored head chunk is not the full original PTY chunk (a
|
|
// single over-limit append is stored pre-sliced), so backfill replay knows
|
|
// the original line context of its leading text is gone.
|
|
private headChunkIsPartial = false
|
|
// Original chunk boundaries are owed only to the one-time path-candidate
|
|
// backfill; compact() ends that obligation and lets read() collapse.
|
|
private preserveChunkBoundaries: boolean
|
|
// Why configurable: the relay retains a different window than the main process.
|
|
private readonly limit: number
|
|
|
|
constructor(options?: { preserveChunkBoundaries?: boolean; limit?: number }) {
|
|
this.preserveChunkBoundaries = options?.preserveChunkBoundaries ?? true
|
|
this.limit = options?.limit ?? RECENT_PTY_OUTPUT_LIMIT
|
|
if (!Number.isSafeInteger(this.limit) || this.limit <= 0) {
|
|
throw new Error(`RecentPtyOutputBuffer limit must be a positive integer, got ${this.limit}`)
|
|
}
|
|
}
|
|
|
|
append(data: string): void {
|
|
if (data.length === 0) {
|
|
return
|
|
}
|
|
if (data.length >= this.limit) {
|
|
this.chunks = [data.slice(-this.limit)]
|
|
this.headIndex = 0
|
|
this.headOffset = 0
|
|
this.totalLen = this.limit
|
|
this.headChunkIsPartial = data.length > this.limit
|
|
return
|
|
}
|
|
this.chunks.push(data)
|
|
this.totalLen += data.length
|
|
while (this.totalLen > this.limit) {
|
|
const headRemaining = this.chunks[this.headIndex].length - this.headOffset
|
|
const excess = this.totalLen - this.limit
|
|
if (headRemaining <= excess) {
|
|
// Release the dropped chunk's reference; the slot is reclaimed on compaction.
|
|
this.chunks[this.headIndex] = ''
|
|
this.headIndex += 1
|
|
this.headOffset = 0
|
|
this.headChunkIsPartial = false
|
|
this.totalLen -= headRemaining
|
|
} else {
|
|
this.headOffset += excess
|
|
this.totalLen -= excess
|
|
}
|
|
}
|
|
if (this.headIndex >= DROPPED_HEAD_COMPACT_THRESHOLD) {
|
|
this.chunks = this.chunks.slice(this.headIndex)
|
|
this.headIndex = 0
|
|
}
|
|
}
|
|
|
|
read(): string {
|
|
if (this.preserveChunkBoundaries) {
|
|
// Join without mutating: boundaries and the original head chunk are
|
|
// still owed to retainedChunks(); reads are rare before compact().
|
|
if (this.chunks.length - this.headIndex > 1) {
|
|
const retained = this.chunks.slice(this.headIndex)
|
|
if (this.headOffset > 0) {
|
|
retained[0] = retained[0].slice(this.headOffset)
|
|
}
|
|
return retained.join('')
|
|
}
|
|
const head = this.chunks[this.headIndex] ?? ''
|
|
return this.headOffset > 0 ? head.slice(this.headOffset) : head
|
|
}
|
|
if (this.chunks.length - this.headIndex > 1) {
|
|
// Collapse to the joined tail so repeated reads stay O(1).
|
|
const retained = this.chunks.slice(this.headIndex)
|
|
if (this.headOffset > 0) {
|
|
retained[0] = retained[0].slice(this.headOffset)
|
|
this.headOffset = 0
|
|
}
|
|
this.chunks = [retained.join('')]
|
|
this.headIndex = 0
|
|
} else if (this.headOffset > 0) {
|
|
// Single retained chunk: apply the deferred head trim once, here.
|
|
this.chunks[this.headIndex] = this.chunks[this.headIndex].slice(this.headOffset)
|
|
this.headOffset = 0
|
|
}
|
|
return this.chunks[this.headIndex] ?? ''
|
|
}
|
|
|
|
/**
|
|
* Retained chunks with original PTY boundaries. The head chunk is its full
|
|
* original text (any window-trimmed prefix included) unless
|
|
* headChunkIsPartial. Why: path-candidate backfill must replay the eager
|
|
* per-chunk extraction exactly — trimming or joining chunks changes the
|
|
* candidate set. Only meaningful before compact().
|
|
*/
|
|
retainedChunks(): { chunks: string[]; headChunkIsPartial: boolean } {
|
|
return {
|
|
chunks: this.chunks.slice(this.headIndex),
|
|
headChunkIsPartial: this.headChunkIsPartial
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ends the chunk-boundary obligation after the one-time backfill and
|
|
* collapses immediately, so the append/read hot path returns to the
|
|
* compact single-chunk steady state.
|
|
*/
|
|
compact(): void {
|
|
this.preserveChunkBoundaries = false
|
|
this.read()
|
|
}
|
|
}
|