diff --git a/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.bound.test.ts b/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.bound.test.ts new file mode 100644 index 0000000000..4b98534033 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.bound.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' + +import { + registerAgentTerminalWriter, + syncAgentTerminalSnapshot, + writeAgentTerminalChunk +} from './agent-terminal-stream' + +// Module-level state is process-keyed and shared across cases, so every test uses +// unique procIds. +const CHUNK = 'x'.repeat(64_000) + +function runFinishedProc(id: string, chunks = 6): void { + for (let i = 0; i < chunks; i++) { + writeAgentTerminalChunk(id, CHUNK) + } +} + +describe('agent terminal retention is bounded', () => { + it('a finished process does not pin its backlog for the life of the renderer', () => { + // 200 background commands, none mounted — the shape that grew to ~93 MB. + for (let i = 0; i < 200; i++) { + runFinishedProc(`bound-${i}`) + } + + // The oldest must be gone: reopening it replays nothing until the registry + // snapshot re-seeds it. + let replayed = '' + const stop = registerAgentTerminalWriter('bound-0', chunk => (replayed += chunk)) + + stop() + expect(replayed).toBe('') + }) + + it('keeps the most recent process replayable', () => { + for (let i = 0; i < 200; i++) { + runFinishedProc(`recent-${i}`) + } + + let replayed = '' + const stop = registerAgentTerminalWriter('recent-199', chunk => (replayed += chunk)) + + stop() + expect(replayed.length).toBeGreaterThan(0) + }) + + it('never evicts a process whose terminal is mounted', () => { + // A long-lived mounted tab, then a flood of unrelated finished processes. + const mounted = 'mounted-proc' + + registerAgentTerminalWriter(mounted, () => {}) + runFinishedProc(mounted) + + for (let i = 0; i < 200; i++) { + runFinishedProc(`flood-${i}`) + } + + // Still replayable: on-screen state is not a cache. + let replayed = '' + const stop = registerAgentTerminalWriter(mounted, chunk => (replayed += chunk)) + + stop() + expect(replayed.length).toBeGreaterThan(0) + }) + + it('recency is the LAST write, not the first', () => { + // Small chunks so the CHARACTER ceiling never fires: this case is purely about + // the entry ceiling and the order eviction walks. `backlog` insertion order is + // the LRU clock, and a plain re-set leaves a key in its ORIGINAL slot — so + // without the delete-then-set refresh, touching a process would not protect it. + const line = 'x'.repeat(64) + + for (let i = 0; i < 20; i++) { + writeAgentTerminalChunk(`age-${i}`, `age-${i}-MARK ${line}\n`) + } + + // Touch the OLDEST while it is still resident, then overflow the ceiling. + writeAgentTerminalChunk('age-0', 'TOUCHED\n') + + for (let i = 20; i < 32; i++) { + writeAgentTerminalChunk(`age-${i}`, `age-${i}-MARK ${line}\n`) + } + + const replay = (proc: string) => { + let out = '' + const stop = registerAgentTerminalWriter(proc, chunk => (out += chunk)) + + stop() + + return out + } + + // The touched process kept its ORIGINAL content (it was never dropped and + // re-created); its untouched neighbour from the same era is gone. + expect(replay('age-0')).toContain('age-0-MARK') + expect(replay('age-1')).toBe('') + }) + + it('an evicted process does not diff against a tail it no longer has', () => { + // `lastSnapshots` is the delta fence for `backlog`. If eviction dropped the + // backlog but KEPT the fence, the next snapshot takes the `startsWith(previous)` + // path and writes only the delta — so the screen shows `BBB` where the process + // actually printed `AAABBB`. + const proc = 'fence-proc' + + writeAgentTerminalChunk(proc, 'AAA') + syncAgentTerminalSnapshot(proc, 'AAA') + + for (let i = 0; i < 200; i++) { + runFinishedProc(`fence-flood-${i}`) + } + + let screen = '' + + const stop = registerAgentTerminalWriter(proc, chunk => { + screen = chunk.startsWith('\x1bc') ? chunk.slice(2) : screen + chunk + }) + + syncAgentTerminalSnapshot(proc, 'AAABBB') + stop() + expect(screen).toContain('AAABBB') + }) + + it('an evicted process re-seeds cleanly from the registry snapshot', () => { + // Eviction drops backlog AND its lastSnapshots delta fence together, so the + // next snapshot must reset the screen rather than diff against a missing tail. + for (let i = 0; i < 200; i++) { + runFinishedProc(`reseed-${i}`) + } + + let screen = '' + + const stop = registerAgentTerminalWriter('reseed-0', chunk => { + screen = chunk.startsWith('\x1bc') ? chunk.slice(2) : screen + chunk + }) + + syncAgentTerminalSnapshot('reseed-0', 'recovered output') + stop() + expect(screen).toContain('recovered output') + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.ts b/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.ts index 8b5f581e96..a2612c136f 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.ts @@ -14,6 +14,59 @@ const seededCommands = new Set() const MAX_BACKLOG = 256_000 +// Every `terminal(background=true)` mints a NEW process id, and nothing here ever +// forgot one: a finished process kept its backlog and last snapshot for the life of +// the renderer. Retention grew linearly with the number of commands ever run — +// replaying every process through `registerAgentTerminalWriter` measured 256K chars +// held per process (~512 KB, JS strings being UTF-16), so 100 -> 25.6M chars, 200 -> +// 51.2M, 400 -> 102.4M (~195 MB). One shape of the unbounded renderer growth in #77311. +// +// Bounded like the other renderer caches (`lib/lru-cache`, `chat/shiki-highlight-cache`): +// an entry ceiling AND a total-character ceiling, since one busy process can hold as +// much as twenty quiet ones. A process whose terminal is MOUNTED is never evicted — +// that is on screen, not a cache. Everything dropped is regenerable: the next +// `syncAgentTerminalSnapshot` re-seeds the tab from the registry's rolling tail. +const MAX_TRACKED_PROCS = 24 +const MAX_TOTAL_CHARS = 2_000_000 + +/** Forget one process entirely. The four maps are evicted TOGETHER: `lastSnapshots` + * is the delta fence for `backlog`, so dropping one without the other would make the + * next snapshot diff against a tail that is no longer there. */ +function forgetProc(procId: string): void { + backlog.delete(procId) + commandHeaders.delete(procId) + lastSnapshots.delete(procId) + seededCommands.delete(procId) +} + +/** Drop the least-recently-written unmounted processes until both ceilings hold. + * `backlog` insertion order is the LRU clock (writers re-insert on every chunk). */ +function evictColdProcs(): void { + const total = () => { + let chars = 0 + + for (const [proc, text] of backlog) { + chars += text.length + (lastSnapshots.get(proc)?.length ?? 0) + } + + return chars + } + + if (backlog.size <= MAX_TRACKED_PROCS && total() <= MAX_TOTAL_CHARS) { + return + } + + for (const proc of [...backlog.keys()]) { + if (backlog.size <= MAX_TRACKED_PROCS && total() <= MAX_TOTAL_CHARS) { + return + } + + if (!writers.has(proc)) { + forgetProc(proc) + } + } +} + /** A live agent terminal registers its xterm write and replays the backlog. * Returns an idempotent unregister. */ export function registerAgentTerminalWriter(procId: string, write: Writer): () => void { @@ -40,8 +93,12 @@ export function writeAgentTerminalChunk(procId: string, chunk: string): void { } const next = (backlog.get(procId) ?? '') + chunk + // delete-then-set moves this process to the tail: a plain re-set would keep its + // original slot and make the oldest-first eviction below pick a live process. + backlog.delete(procId) backlog.set(procId, next.length > MAX_BACKLOG ? next.slice(-MAX_BACKLOG) : next) writers.get(procId)?.(chunk) + evictColdProcs() } /** Seed the tab with the command immediately, so an agent terminal never opens @@ -75,6 +132,7 @@ export function syncAgentTerminalSnapshot(procId: string, output: string): void if (output === previous || output === body || body.endsWith(output)) { lastSnapshots.set(procId, output) + evictColdProcs() return }