diff --git a/src/main/daemon/headless-emulator-snapshot-cache.test.ts b/src/main/daemon/headless-emulator-snapshot-cache.test.ts new file mode 100644 index 00000000000..e302b6aaa7f --- /dev/null +++ b/src/main/daemon/headless-emulator-snapshot-cache.test.ts @@ -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) + }) +}) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 69623e4c7d6..fc5a05f4f4a 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -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() } diff --git a/src/main/daemon/headless-snapshot-cache.ts b/src/main/daemon/headless-snapshot-cache.ts new file mode 100644 index 00000000000..66eaed00316 --- /dev/null +++ b/src/main/daemon/headless-snapshot-cache.ts @@ -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 + } +}