mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
perf(terminal): memoize the headless snapshot per mutation epoch
Attaching a viewer serializes the session's whole headless buffer synchronously on the daemon event loop, so every reattach of a quiescent session re-serialized identical bytes. Measured with ORCA_PTY_SPAWN_TIMING=1, stable_adoption was 253-281ms per session on reattach. Move snapshot assembly into HeadlessSnapshotCache and memoize its expensive parts (the serialize, the OSC link walk, the frame-restore fields) on a mutation epoch that every emulator state mutation bumps, so a cache hit is byte-identical by construction rather than merely fresh-enough. The async write path bumps on entry and again in the parse-completion callback, so a snapshot taken mid-parse can never be retained. Reattach of a quiescent session after: stable_adoption 12ms. Sessions with output since their last snapshot re-serialize exactly as before. Retention is capped: an entry is held for the session's lifetime once it goes quiescent, and a renderer may request 50k scrollback rows, so oversized payloads serve normally but are not retained. Cache hits clone nested values so a caller mutating its snapshot cannot corrupt later ones.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
|
||||
// Why this suite: attach latency is dominated by serializing the full buffer,
|
||||
// so getSnapshot memoizes on a mutation epoch. A missed invalidation would
|
||||
// hand a viewer a stale terminal, so every mutator gets its own case.
|
||||
let emulator: HeadlessEmulator | undefined
|
||||
|
||||
afterEach(() => {
|
||||
emulator?.dispose()
|
||||
emulator = undefined
|
||||
})
|
||||
|
||||
/** Counts real serializations so a "cache hit" claim is proven, not implied. */
|
||||
function spyOnSerialize(target: HeadlessEmulator): { calls: () => number } {
|
||||
const serializer = (
|
||||
target as unknown as { serializer: { serialize: (...args: never[]) => string } }
|
||||
).serializer
|
||||
const spy = vi.spyOn(serializer, 'serialize')
|
||||
return { calls: () => spy.mock.calls.length }
|
||||
}
|
||||
|
||||
describe('HeadlessEmulator snapshot cache', () => {
|
||||
it('serves a repeated snapshot without re-serializing', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
await emulator.write('hello world')
|
||||
const first = emulator.getSnapshot()
|
||||
const serialize = spyOnSerialize(emulator)
|
||||
|
||||
const second = emulator.getSnapshot()
|
||||
|
||||
expect(serialize.calls()).toBe(0)
|
||||
expect(second.snapshotAnsi).toBe(first.snapshotAnsi)
|
||||
expect(second.scrollbackAnsi).toBe(first.scrollbackAnsi)
|
||||
})
|
||||
|
||||
it('re-serializes for a different scrollbackRows window', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
await emulator.write('hello world')
|
||||
emulator.getSnapshot({ scrollbackRows: 100 })
|
||||
const serialize = spyOnSerialize(emulator)
|
||||
|
||||
emulator.getSnapshot({ scrollbackRows: 500 })
|
||||
|
||||
expect(serialize.calls()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('reflects an async write that lands after a cached snapshot', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
await emulator.write('first line')
|
||||
expect(emulator.getSnapshot().snapshotAnsi).toContain('first line')
|
||||
|
||||
await emulator.write('\r\nsecond line')
|
||||
|
||||
const snapshot = emulator.getSnapshot()
|
||||
expect(snapshot.snapshotAnsi).toContain('second line')
|
||||
})
|
||||
|
||||
it('invalidates on resize', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
await emulator.write('sized')
|
||||
expect(emulator.getSnapshot().cols).toBe(80)
|
||||
|
||||
emulator.resize(120, 40)
|
||||
|
||||
const snapshot = emulator.getSnapshot()
|
||||
expect(snapshot.cols).toBe(120)
|
||||
expect(snapshot.rows).toBe(40)
|
||||
})
|
||||
|
||||
it('invalidates on clearScrollback', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await emulator.write(`line ${i}\r\n`)
|
||||
}
|
||||
expect(emulator.getSnapshot().snapshotAnsi).toContain('line 0')
|
||||
|
||||
emulator.clearScrollback()
|
||||
|
||||
expect(emulator.getSnapshot().snapshotAnsi).not.toContain('line 0')
|
||||
})
|
||||
|
||||
it('invalidates on cwd and title changes', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
await emulator.write('x')
|
||||
expect(emulator.getSnapshot().cwd).toBeNull()
|
||||
|
||||
emulator.setCwd('/tmp/project')
|
||||
expect(emulator.getSnapshot().cwd).toBe('/tmp/project')
|
||||
|
||||
emulator.setLastTitle('agent running')
|
||||
expect(emulator.getSnapshot().lastTitle).toBe('agent running')
|
||||
})
|
||||
|
||||
it('invalidates on restored osc links', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
await emulator.write('link target')
|
||||
expect(emulator.getSnapshot().oscLinks).toEqual([])
|
||||
|
||||
emulator.setRestoredOscLinks([{ row: 0, startCol: 0, endCol: 4, uri: 'https://example.com' }])
|
||||
|
||||
expect(emulator.getSnapshot().oscLinks?.length ?? 0).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('never hands out aliases into the retained cache entry', async () => {
|
||||
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
|
||||
await emulator.write('aliasing check')
|
||||
emulator.setRestoredOscLinks([{ row: 0, startCol: 0, endCol: 4, uri: 'https://example.com' }])
|
||||
|
||||
const first = emulator.getSnapshot()
|
||||
const originalUri = first.oscLinks?.[0]?.uri
|
||||
first.oscLinks?.push({ row: 9, startCol: 0, endCol: 1, uri: 'https://injected' })
|
||||
const firstLink = first.oscLinks?.[0]
|
||||
if (firstLink) {
|
||||
firstLink.uri = 'https://mutated'
|
||||
}
|
||||
;(first.modes as { alternateScreen: boolean }).alternateScreen = true
|
||||
|
||||
const second = emulator.getSnapshot()
|
||||
expect(second.oscLinks).toHaveLength(1)
|
||||
expect(second.oscLinks?.[0]?.uri).toBe(originalUri)
|
||||
expect(second.modes.alternateScreen).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,19 +3,11 @@ import { Terminal } from '@xterm/headless'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider'
|
||||
import {
|
||||
readSavedCursorRegister,
|
||||
serializeWithAbsoluteCursor
|
||||
} from '../../shared/terminal-serialize-absolute-cursor'
|
||||
import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail'
|
||||
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
|
||||
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
|
||||
import { readTerminalModes } from './headless-emulator-modes'
|
||||
import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences'
|
||||
import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
|
||||
import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner'
|
||||
import { buildFrameRestoreSnapshotFields } from './terminal-frame-restore-sequences'
|
||||
import { splitTerminalSnapshotAnsi } from './terminal-snapshot-ansi-buffers'
|
||||
import {
|
||||
installTerminalViewAttributeResponder,
|
||||
type TerminalViewAttributeResponder
|
||||
@@ -25,6 +17,7 @@ import type { TerminalSnapshot, TerminalModes } from './types'
|
||||
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
|
||||
import type { TerminalCursorContext } from '../../shared/terminal-composer-draft'
|
||||
import { readTerminalCursorLineContext } from '../../shared/terminal-cursor-line-context'
|
||||
import { HeadlessSnapshotCache } from './headless-snapshot-cache'
|
||||
|
||||
export type HeadlessEmulatorOptions = {
|
||||
cols: number
|
||||
@@ -72,6 +65,7 @@ export class HeadlessEmulator {
|
||||
private queryReplyForwardingDepth = 0
|
||||
// Why: a mid-escape chunk tail lives in xterm's parser, not the buffer, so serialize() drops it and it renders literal after restore (Bug E).
|
||||
private partialEscapeTail = ''
|
||||
private readonly snapshotCache = new HeadlessSnapshotCache()
|
||||
|
||||
constructor(opts: HeadlessEmulatorOptions) {
|
||||
this.pathFlavor = opts.pathFlavor
|
||||
@@ -143,6 +137,7 @@ export class HeadlessEmulator {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.markMutated()
|
||||
this.terminal.options.cursorStyle = attributes.cursorStyle
|
||||
this.terminal.options.cursorBlink = attributes.cursorBlink
|
||||
this.viewAttributeResponder?.clearColorOverrides()
|
||||
@@ -156,6 +151,11 @@ export class HeadlessEmulator {
|
||||
return this.write(`\x1b[=${flags};1u`)
|
||||
}
|
||||
|
||||
/** Invalidates the snapshot cache; called by every state mutation. */
|
||||
private markMutated(): void {
|
||||
this.snapshotCache.markMutated()
|
||||
}
|
||||
|
||||
private emitQueryReply(reply: string): void {
|
||||
if (this.queryReplyForwardingDepth > 0 && this.onQueryReply) {
|
||||
this.onQueryReply(reply)
|
||||
@@ -172,6 +172,7 @@ export class HeadlessEmulator {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
this.markMutated()
|
||||
const forwardQueryReplies = opts.forwardQueryReplies === true
|
||||
if (this.tryWriteSync(data, { forwardQueryReplies })) {
|
||||
return Promise.resolve()
|
||||
@@ -191,6 +192,10 @@ export class HeadlessEmulator {
|
||||
// Why: commit the mouse-mode mirror only after xterm has parsed the same bytes (snapshots combine both).
|
||||
this.mouseModes.scan(data)
|
||||
this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data)
|
||||
// Why again: xterm parses asynchronously, so the buffer only reaches
|
||||
// its post-write state here; the entry bump alone would let a
|
||||
// snapshot taken mid-parse cache a half-applied buffer.
|
||||
this.markMutated()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
@@ -209,6 +214,7 @@ export class HeadlessEmulator {
|
||||
if (typeof writeSync !== 'function') {
|
||||
return false
|
||||
}
|
||||
this.markMutated()
|
||||
this.oscText.scan(data)
|
||||
const forwardQueryReplies = opts.forwardQueryReplies === true
|
||||
if (forwardQueryReplies) {
|
||||
@@ -231,6 +237,7 @@ export class HeadlessEmulator {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.markMutated()
|
||||
this.restoredOscLinks = []
|
||||
this.terminal.resize(cols, rows)
|
||||
}
|
||||
@@ -241,37 +248,18 @@ export class HeadlessEmulator {
|
||||
}
|
||||
|
||||
getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot {
|
||||
const modes = this.getModes()
|
||||
// Why absolute: relative cursor restore is off by a column after a wrap-pending final row; saved-cursor rides along for DECRC.
|
||||
const serializedAnsi = serializeWithAbsoluteCursor(
|
||||
this.serializer,
|
||||
this.terminal,
|
||||
{ scrollback: opts.scrollbackRows },
|
||||
readSavedCursorRegister(this.terminal)
|
||||
return this.snapshotCache.build(
|
||||
{
|
||||
serializer: this.serializer,
|
||||
terminal: this.terminal,
|
||||
restoredOscLinks: this.restoredOscLinks,
|
||||
readModes: () => this.getModes(),
|
||||
cwd: this.oscText.cwd,
|
||||
lastTitle: this.oscText.lastTitle,
|
||||
partialEscapeTail: this.partialEscapeTail
|
||||
},
|
||||
opts.scrollbackRows
|
||||
)
|
||||
const { snapshotAnsi, scrollbackAnsi } = splitTerminalSnapshotAnsi(serializedAnsi, modes)
|
||||
const snapshot: TerminalSnapshot = {
|
||||
snapshotAnsi,
|
||||
scrollbackAnsi,
|
||||
oscLinks: collectHeadlessOscLinkRanges(
|
||||
this.terminal,
|
||||
opts.scrollbackRows,
|
||||
this.restoredOscLinks
|
||||
),
|
||||
rehydrateSequences: buildRehydrateSequences(modes),
|
||||
...buildFrameRestoreSnapshotFields(this.serializer, this.terminal, modes),
|
||||
cwd: this.oscText.cwd,
|
||||
modes,
|
||||
cols: this.terminal.cols,
|
||||
rows: this.terminal.rows,
|
||||
scrollbackLines: this.terminal.buffer.normal.length - this.terminal.rows,
|
||||
lastTitle: this.oscText.lastTitle ?? undefined,
|
||||
// Why written LAST by the restorer: the next live chunk must complete this dangling sequence, not render it literally (Bug E / #7329).
|
||||
...(this.partialEscapeTail.length > 0
|
||||
? { pendingEscapeTailAnsi: this.partialEscapeTail }
|
||||
: {})
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
get isAlternateScreen(): boolean {
|
||||
@@ -333,18 +321,22 @@ export class HeadlessEmulator {
|
||||
}
|
||||
|
||||
setCwd(cwd: string | null): void {
|
||||
this.markMutated()
|
||||
this.oscText.cwd = cwd
|
||||
}
|
||||
|
||||
setLastTitle(title: string): void {
|
||||
this.markMutated()
|
||||
this.oscText.lastTitle = title
|
||||
}
|
||||
|
||||
setRestoredOscLinks(links: TerminalOscLinkRange[] | undefined): void {
|
||||
this.markMutated()
|
||||
this.restoredOscLinks = links?.slice() ?? []
|
||||
}
|
||||
|
||||
clearScrollback(): void {
|
||||
this.markMutated()
|
||||
this.restoredOscLinks = []
|
||||
this.terminal.clear()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { SerializeAddon } from '@xterm/addon-serialize'
|
||||
import type { Terminal } from '@xterm/headless'
|
||||
import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences'
|
||||
import { buildFrameRestoreSnapshotFields } from './terminal-frame-restore-sequences'
|
||||
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
|
||||
import { splitTerminalSnapshotAnsi } from './terminal-snapshot-ansi-buffers'
|
||||
import {
|
||||
readSavedCursorRegister,
|
||||
serializeWithAbsoluteCursor
|
||||
} from '../../shared/terminal-serialize-absolute-cursor'
|
||||
import type { TerminalModes, TerminalSnapshot } from './types'
|
||||
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
|
||||
|
||||
/**
|
||||
* Snapshot assembly for HeadlessEmulator, memoized on a mutation epoch.
|
||||
*
|
||||
* Why: attaching a viewer serializes the session's whole buffer synchronously
|
||||
* on the daemon event loop, so every reattach of a quiescent session paid to
|
||||
* re-serialize identical bytes (measured 253-281ms per session). The cache is
|
||||
* keyed on an epoch the emulator bumps on every state mutation, so a hit is
|
||||
* byte-identical by construction rather than merely fresh-enough.
|
||||
*/
|
||||
type CachedParts = {
|
||||
snapshotAnsi: string
|
||||
scrollbackAnsi: string
|
||||
oscLinks: TerminalOscLinkRange[]
|
||||
frameRestore: { frameRestoreAnsi?: string }
|
||||
modes: TerminalModes
|
||||
}
|
||||
|
||||
// Why a cap: an entry is retained for the session's lifetime once the session
|
||||
// goes quiescent — exactly the parked case this optimizes. A 5k-row buffer
|
||||
// serializes to a few hundred KB, but a renderer may ask for 50k rows, so an
|
||||
// uncapped cache would retain tens of MB per session. Oversized payloads still
|
||||
// serve correctly, they just re-serialize instead of being retained.
|
||||
const MAX_CACHED_SNAPSHOT_CHARS = 2_000_000
|
||||
|
||||
export type HeadlessSnapshotSource = {
|
||||
serializer: SerializeAddon
|
||||
terminal: Terminal
|
||||
restoredOscLinks: TerminalOscLinkRange[]
|
||||
readModes: () => TerminalModes
|
||||
cwd: string | null
|
||||
lastTitle: string | null | undefined
|
||||
partialEscapeTail: string
|
||||
}
|
||||
|
||||
export class HeadlessSnapshotCache {
|
||||
private epoch = 0
|
||||
private retained: (CachedParts & { epoch: number; scrollbackRows: number | undefined }) | null =
|
||||
null
|
||||
|
||||
/** Invalidates the cache. Every emulator state mutation must call this. */
|
||||
markMutated(): void {
|
||||
this.epoch += 1
|
||||
this.retained = null
|
||||
}
|
||||
|
||||
private resolve(source: HeadlessSnapshotSource, scrollbackRows: number | undefined): CachedParts {
|
||||
const retained = this.retained
|
||||
if (retained && retained.epoch === this.epoch && retained.scrollbackRows === scrollbackRows) {
|
||||
return retained
|
||||
}
|
||||
const computed = computeCachedParts(source, scrollbackRows)
|
||||
// Why length-gated: see MAX_CACHED_SNAPSHOT_CHARS. Declining to retain
|
||||
// costs the pre-existing serialize, never correctness.
|
||||
this.retained =
|
||||
computed.snapshotAnsi.length + computed.scrollbackAnsi.length <= MAX_CACHED_SNAPSHOT_CHARS
|
||||
? { ...computed, epoch: this.epoch, scrollbackRows }
|
||||
: null
|
||||
return computed
|
||||
}
|
||||
|
||||
/** Builds a caller-owned snapshot, reusing the memoized serialize on a hit. */
|
||||
build(source: HeadlessSnapshotSource, scrollbackRows: number | undefined): TerminalSnapshot {
|
||||
const parts = this.resolve(source, scrollbackRows)
|
||||
// Why cloned: a hit hands back the retained entry, so a caller mutating
|
||||
// its snapshot would otherwise corrupt every later one.
|
||||
const modes = { ...parts.modes }
|
||||
return {
|
||||
snapshotAnsi: parts.snapshotAnsi,
|
||||
scrollbackAnsi: parts.scrollbackAnsi,
|
||||
oscLinks: parts.oscLinks.map((link) => ({ ...link })),
|
||||
rehydrateSequences: buildRehydrateSequences(modes),
|
||||
...parts.frameRestore,
|
||||
cwd: source.cwd,
|
||||
modes,
|
||||
cols: source.terminal.cols,
|
||||
rows: source.terminal.rows,
|
||||
scrollbackLines: source.terminal.buffer.normal.length - source.terminal.rows,
|
||||
lastTitle: source.lastTitle ?? undefined,
|
||||
// Why written LAST by the restorer: the next live chunk must complete this dangling sequence, not render it literally (Bug E / #7329).
|
||||
...(source.partialEscapeTail.length > 0
|
||||
? { pendingEscapeTailAnsi: source.partialEscapeTail }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function computeCachedParts(
|
||||
source: HeadlessSnapshotSource,
|
||||
scrollbackRows: number | undefined
|
||||
): CachedParts {
|
||||
const modes = source.readModes()
|
||||
// Why absolute: relative cursor restore is off by a column after a wrap-pending final row; saved-cursor rides along for DECRC.
|
||||
const serializedAnsi = serializeWithAbsoluteCursor(
|
||||
source.serializer,
|
||||
source.terminal,
|
||||
{ scrollback: scrollbackRows },
|
||||
readSavedCursorRegister(source.terminal)
|
||||
)
|
||||
return {
|
||||
...splitTerminalSnapshotAnsi(serializedAnsi, modes),
|
||||
oscLinks: collectHeadlessOscLinkRanges(
|
||||
source.terminal,
|
||||
scrollbackRows,
|
||||
source.restoredOscLinks
|
||||
),
|
||||
frameRestore: buildFrameRestoreSnapshotFields(source.serializer, source.terminal, modes),
|
||||
modes
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user