diff --git a/src/main/daemon/daemon-scrollback-budget.test.ts b/src/main/daemon/daemon-scrollback-budget.test.ts index 864bde04d5a..30185869a97 100644 --- a/src/main/daemon/daemon-scrollback-budget.test.ts +++ b/src/main/daemon/daemon-scrollback-budget.test.ts @@ -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) + }) }) diff --git a/src/main/daemon/daemon-scrollback-budget.ts b/src/main/daemon/daemon-scrollback-budget.ts index 81025d9623c..7c04b5bac55 100644 --- a/src/main/daemon/daemon-scrollback-budget.ts +++ b/src/main/daemon/daemon-scrollback-budget.ts @@ -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) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 4137ffa3e57..f16555081c8 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -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 { diff --git a/src/main/daemon/terminal-host-scrollback-budget.test.ts b/src/main/daemon/terminal-host-scrollback-budget.test.ts index 232f638f612..eebdbf7bb88 100644 --- a/src/main/daemon/terminal-host-scrollback-budget.test.ts +++ b/src/main/daemon/terminal-host-scrollback-budget.test.ts @@ -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() + } + }) +}) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index c4463d94e15..0ff3378bff3 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -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 } })