fix(desktop): a finished agent terminal stops pinning its backlog for the life of the renderer
Every `terminal(background=true)` mints a new process id, and the agent-terminal stream module never forgot one. `writers` was unregistered when a tab unmounted, but `backlog`, `lastSnapshots`, `commandHeaders` and `seededCommands` are keyed by process id and had no eviction at all, so every command an agent ever ran kept its output until the window closed. Replaying each process back through `registerAgentTerminalWriter` measures the retention directly, without depending on GC: 256K chars held per finished process (~512 KB, JS strings being UTF-16), growing linearly — 100 processes 25.6M chars, 200 51.2M, 400 102.4M (~195 MB). With this change it is flat at 1.79M chars (~3.4 MB) for every one of those counts. Bounded the way the other renderer caches already are (`lib/lru-cache`, `chat/shiki-highlight-cache`): an entry ceiling and a total-character ceiling, because one busy process can hold as much as twenty quiet ones. `backlog` insertion order is the LRU clock, so a chunk write re-inserts to move its process to the tail — a plain re-set keeps the original slot and would let the oldest-first sweep evict a process that is still streaming. 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. The four maps are evicted together because `lastSnapshots` is the delta fence for `backlog`; dropping one without the other makes the next snapshot diff against a tail that is gone and paint only the delta. Each guarantee is pinned by a regression that fails when that guarantee alone is reverted: no eviction, evicting a mounted process, losing the recency refresh, and keeping a stale delta fence.
This commit is contained in:
committed by
brooklyn!
parent
5f23cac568
commit
0b9287c8cf
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,59 @@ const seededCommands = new Set<string>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user