fix(daemon): preserve scrollback budget invariants

This commit is contained in:
Brennan Benson
2026-08-07 11:43:41 -07:00
parent 2972ac4482
commit ada99d35b5
5 changed files with 86 additions and 4 deletions
@@ -16,6 +16,11 @@ describe('computeSessionScrollbackRows', () => {
)
})
it('preserves the default 5000-row restore depth through 25 sessions', () => {
expect(computeSessionScrollbackRows(25)).toBe(DAEMON_SCROLLBACK_FULL_ROWS)
expect(computeSessionScrollbackRows(26)).toBe(Math.floor(DAEMON_SCROLLBACK_BUDGET_ROWS / 26))
})
it('splits the budget evenly once it binds', () => {
expect(computeSessionScrollbackRows(40, { budgetRows: 100_000 })).toBe(2500)
expect(computeSessionScrollbackRows(50, { budgetRows: 100_000 })).toBe(2000)
@@ -57,6 +62,7 @@ describe('computeSessionScrollbackRows', () => {
it('accepts an env override and ignores an unusable one', () => {
const env = { ORCA_DAEMON_SCROLLBACK_BUDGET_ROWS: '20000' } as NodeJS.ProcessEnv
expect(computeSessionScrollbackRows(1, { env })).toBe(DAEMON_SCROLLBACK_FULL_ROWS)
expect(computeSessionScrollbackRows(10, { env })).toBe(2000)
// Why: an unparseable or non-positive override must not disable the budget — unbounded is the bug.
for (const raw of ['nonsense', '0', '-5']) {
@@ -66,4 +72,9 @@ describe('computeSessionScrollbackRows', () => {
)
}
})
it('honors a tightened budget that binds with one session', () => {
const env = { ORCA_DAEMON_SCROLLBACK_BUDGET_ROWS: '2000' } as NodeJS.ProcessEnv
expect(computeSessionScrollbackRows(1, { env })).toBe(2000)
})
})
+1 -1
View File
@@ -35,7 +35,7 @@ export function computeSessionScrollbackRows(
options.budgetRows ??
parseBudgetOverrideRows((options.env ?? process.env)[DAEMON_SCROLLBACK_ENV_VAR]) ??
DAEMON_SCROLLBACK_BUDGET_ROWS
if (!Number.isFinite(liveSessionCount) || liveSessionCount <= 1) {
if (!Number.isFinite(liveSessionCount) || liveSessionCount <= 0) {
return DAEMON_SCROLLBACK_FULL_ROWS
}
const evenShare = Math.floor(budgetRows / liveSessionCount)
+8
View File
@@ -340,7 +340,15 @@ export class HeadlessEmulator {
if (this.terminal.options.scrollback === next) {
return
}
const normalBufferWasActive = this.terminal.buffer.active.type === 'normal'
const previousNormalLength = this.terminal.buffer.normal.length
this.terminal.options.scrollback = next
const trimmedRows = previousNormalLength - this.terminal.buffer.normal.length
if (normalBufferWasActive && trimmedRows > 0) {
this.restoredOscLinks = this.restoredOscLinks
.filter((link) => link.row >= trimmedRows)
.map((link) => ({ ...link, row: link.row - trimmedRows }))
}
}
dispose(): void {
@@ -89,6 +89,16 @@ describe('TerminalHost scrollback budget', () => {
}
})
it('does not scan every session again when attaching to an existing session', async () => {
await create('a')
await create('b')
applyRows.mockClear()
await create('a')
expect(applyRows).not.toHaveBeenCalled()
})
it('reduces per-session depth as the session count grows past the budget', async () => {
const SESSIONS = 60
for (let i = 0; i < SESSIONS; i++) {
@@ -99,6 +109,13 @@ describe('TerminalHost scrollback budget', () => {
expect(applied).toBeLessThan(DAEMON_SCROLLBACK_FULL_ROWS)
})
it('applies each concurrent create before yielding', async () => {
const pending = Array.from({ length: 30 }, (_, i) => create(`concurrent-${i}`))
expect(lastAppliedRows()).toBe(computeSessionScrollbackRows(30))
await Promise.all(pending)
})
it('restores depth to survivors when sessions exit', async () => {
const SESSIONS = 60
for (let i = 0; i < SESSIONS; i++) {
@@ -117,3 +134,47 @@ describe('TerminalHost scrollback budget', () => {
expect(afterDrain).toBe(DAEMON_SCROLLBACK_FULL_ROWS)
})
})
describe('HeadlessEmulator scrollback budget application', () => {
it('trims immediately without misplacing restored OSC links', () => {
const emulator = new HeadlessEmulator({ cols: 20, rows: 2, scrollback: 20 })
try {
emulator.writeSync(Array.from({ length: 10 }, (_, i) => `L${i}\r\n`).join(''))
emulator.setRestoredOscLinks([
{ row: 8, startCol: 0, endCol: 2, uri: 'https://retained.invalid' },
{ row: 2, startCol: 0, endCol: 2, uri: 'https://trimmed.invalid' }
])
emulator.setRetainedScrollbackRows(3)
const snapshot = emulator.getSnapshot()
expect(snapshot.scrollbackLines).toBe(3)
expect(snapshot.oscLinks).toEqual([
{ row: 2, startCol: 0, endCol: 2, uri: 'https://retained.invalid' }
])
} finally {
emulator.dispose()
}
})
it('keeps alternate-buffer restored OSC links fixed while trimming the normal buffer', () => {
const emulator = new HeadlessEmulator({ cols: 20, rows: 2, scrollback: 20 })
try {
emulator.writeSync(Array.from({ length: 10 }, (_, i) => `L${i}\r\n`).join(''))
emulator.writeSync('\x1b[?1049hALT')
emulator.setRestoredOscLinks([
{ row: 0, startCol: 0, endCol: 3, uri: 'https://alternate.invalid' }
])
emulator.setRetainedScrollbackRows(3)
const snapshot = emulator.getSnapshot()
expect(snapshot.scrollbackLines).toBe(3)
expect(snapshot.oscLinks).toEqual([
{ row: 0, startCol: 0, endCol: 3, uri: 'https://alternate.invalid' }
])
} finally {
emulator.dispose()
}
})
})
+5 -3
View File
@@ -65,15 +65,17 @@ export class TerminalHost {
spawnSubprocess: this.spawnSubprocess,
creationFenced: this.creationFenced,
onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId),
onSessionCreated: (sessionId, generation, isAlive) =>
this.agentSessionGenerations.remember(sessionId, generation, isAlive),
onSessionCreated: (sessionId, generation, isAlive) => {
this.agentSessionGenerations.remember(sessionId, generation, isAlive)
// Apply synchronously with map insertion so concurrent creates cannot retain full depth.
this.applyScrollbackBudget()
},
onSessionExit: (sessionId, generation) => {
this.agentSessionOwners.release(sessionId, generation)
this.agentSessionGenerations.forget(sessionId, generation)
this.reapSession(sessionId)
}
})
this.applyScrollbackBudget()
return result
}
})