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:
Neil
2026-08-31 14:47:52 -07:00
parent 938b7ba03a
commit 74ae732ed7
3 changed files with 276 additions and 38 deletions
@@ -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)
})
})
+30 -38
View File
@@ -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()
}
+122
View File
@@ -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
}
}