diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index facf417cdbb..875ab37310c 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -14,7 +14,7 @@ import { // Why: ONE module owns the scan cache so the desktop IPC handler AND the runtime // RPC method share a single cache instance — opening the desktop panel and the // mobile screen for the same scope must not double-scan hundreds of transcripts. -const AI_VAULT_CACHE_TTL_MS = 15_000 +const AI_VAULT_CACHE_TTL_MS = 60_000 // Why: codex-home + WSL home dirs must be sourced from a serve-mode-reachable // seam (the OrcaRuntimeService deps), NOT the window-only registerCoreHandlers diff --git a/src/main/ipc/parcel-watcher-crash-fuse.test.ts b/src/main/ipc/parcel-watcher-crash-fuse.test.ts new file mode 100644 index 00000000000..740dafbb7ff --- /dev/null +++ b/src/main/ipc/parcel-watcher-crash-fuse.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { WatcherProcessCrashFuse } from './parcel-watcher-crash-fuse' + +describe('WatcherProcessCrashFuse', () => { + it('opens after three crashes at the watcher restart cadence', () => { + const fuse = new WatcherProcessCrashFuse() + + fuse.recordCrash(0) + fuse.recordCrash(40_000) + fuse.recordCrash(80_000) + + expect(fuse.isOpen(80_000)).toBe(true) + }) + + it('stays open until explicitly reset', () => { + const fuse = new WatcherProcessCrashFuse() + + fuse.recordCrash(0) + fuse.recordCrash(40_000) + fuse.recordCrash(80_000) + + expect(fuse.isOpen(10 * 60_000)).toBe(true) + fuse.reset() + expect(fuse.isOpen(10 * 60_000)).toBe(false) + }) + + it('expires crashes outside the two-minute window', () => { + const fuse = new WatcherProcessCrashFuse() + + fuse.recordCrash(0) + fuse.recordCrash(40_000) + fuse.recordCrash(120_000) + + expect(fuse.isOpen(120_000)).toBe(false) + }) +}) diff --git a/src/main/ipc/parcel-watcher-crash-fuse.ts b/src/main/ipc/parcel-watcher-crash-fuse.ts index 69717311ba5..428eafe5a1e 100644 --- a/src/main/ipc/parcel-watcher-crash-fuse.ts +++ b/src/main/ipc/parcel-watcher-crash-fuse.ts @@ -1,21 +1,30 @@ -const CRASH_WINDOW_MS = 30_000 +const CRASH_WINDOW_MS = 2 * 60_000 const MAX_CRASHES_PER_WINDOW = 3 export class WatcherProcessCrashFuse { private crashTimes: number[] = [] + private open = false recordCrash(now = Date.now()): void { + if (this.open) { + return + } this.removeExpired(now) this.crashTimes.push(now) + this.open = this.crashTimes.length >= MAX_CRASHES_PER_WINDOW } isOpen(now = Date.now()): boolean { + if (this.open) { + return true + } this.removeExpired(now) - return this.crashTimes.length >= MAX_CRASHES_PER_WINDOW + return false } reset(): void { this.crashTimes = [] + this.open = false } private removeExpired(now: number): void { diff --git a/src/main/ipc/parcel-watcher-process.test.ts b/src/main/ipc/parcel-watcher-process.test.ts index 805281bbea6..be5000fc49a 100644 --- a/src/main/ipc/parcel-watcher-process.test.ts +++ b/src/main/ipc/parcel-watcher-process.test.ts @@ -656,6 +656,37 @@ describe('subscribeViaWatcherProcess', () => { ) }) + it('does not respawn for later subscriptions after the crash fuse opens', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(0) + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + ackSubscribe(currentChild()) + await promise + + for (const crashTime of [0, 40_000]) { + vi.setSystemTime(crashTime) + const child = currentChild() + child.connected = false + child.emit('exit', 3221226505, null) + ackSubscribe(currentChild()) + } + vi.setSystemTime(80_000) + const last = currentChild() + last.connected = false + last.emit('exit', 3221226505, null) + expect(forkMock).toHaveBeenCalledTimes(3) + + vi.setSystemTime(10 * 60_000) + await expect(subscribeViaWatcherProcess('/later', vi.fn(), {})).rejects.toMatchObject({ + code: 'process_unavailable' + }) + expect(forkMock).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + it('kills the idle child after the last unsubscribe and respawns on the next subscribe', async () => { const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) const first = currentChild() diff --git a/src/main/ipc/worktree-base-directory-change-collector.ts b/src/main/ipc/worktree-base-directory-change-collector.ts index 168b32b48ad..c77f12104ee 100644 --- a/src/main/ipc/worktree-base-directory-change-collector.ts +++ b/src/main/ipc/worktree-base-directory-change-collector.ts @@ -16,6 +16,12 @@ export type WorktreeBaseCollectedChanges = { headIdentityRepoIds: string[] } +export function hasCollectedWorktreeBaseChanges(changes: WorktreeBaseCollectedChanges): boolean { + return [changes.structureRepoIds, changes.gitStatusRepoIds, changes.headIdentityRepoIds].some( + (ids) => ids.length > 0 + ) +} + type ChangeBuckets = { structureRepoIds: Set gitStatusRepoIds: Set diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index 5a9f8852c84..d8f58ff8503 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -353,6 +353,44 @@ describe('worktree base directory watcher', () => { expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') }) + it('throttles repeated structural refreshes from watcher failures', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const onWatchError = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onWatchError + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(60_000) + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + warn.mockRestore() + + expect(notifyWorktreesChanged).toHaveBeenCalledTimes(2) + }) + + it('lets a real event reset the watcher-failure refresh cooldown', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const onWatchError = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onWatchError + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + emit(PROJECT_GIT_COMMON_DIR, [{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'config') }]) + await vi.advanceTimersByTimeAsync(300) + vi.mocked(notifyWorktreesChanged).mockClear() + + onWatchError?.(new Error('watch interrupted')) + await vi.advanceTimersByTimeAsync(300) + warn.mockRestore() + + expect(notifyWorktreesChanged).toHaveBeenCalledOnce() + }) + it('keeps linked HEAD and lock metadata structural', async () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index 382a21dfc08..411faae40d4 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -9,7 +9,7 @@ import { import { collectLocalWorktreeBaseChanges, collectRemoteWorktreeBaseChanges, - type WorktreeBaseCollectedChanges + hasCollectedWorktreeBaseChanges } from './worktree-base-directory-change-collector' import { clearPendingWorktreeBaseNotifications, @@ -33,6 +33,7 @@ import { updateActiveGitStatusRefBinding, type GitStatusRefBindingRequest } from './worktree-git-status-ref-watch' +import { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown' type ActiveWatch = WorktreeBaseWatchTarget & { mainWindow: BrowserWindow @@ -43,6 +44,7 @@ type ActiveWatch = WorktreeBaseWatchTarget & { pendingHeadIdentityRepoIds: Set headIdentityRefresh: WorktreeHeadIdentityRefreshState gitStatusRefPaths: Set + watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown disposed: boolean } @@ -57,12 +59,6 @@ export function setWorktreeGitStatusRefWatch( return updateActiveGitStatusRefBinding(args, () => activeWatches.values(), resolveUpstreamRef) } -function hasCollectedChanges(changes: WorktreeBaseCollectedChanges): boolean { - return [changes.structureRepoIds, changes.gitStatusRepoIds, changes.headIdentityRepoIds].some( - (ids) => ids.length > 0 - ) -} - function handleLocalWatchEvents( watch: ActiveWatch, error: Error | null, @@ -74,16 +70,19 @@ function handleLocalWatchEvents( if (error) { console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values()) - scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) + if (watch.watcherFailureRefresh.consume()) { + scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) + } return } + watch.watcherFailureRefresh.reset() invalidateGitStatusRefResolutionForPaths( watch, events.map((event) => event.path), () => activeWatches.values() ) const changes = collectLocalWorktreeBaseChanges(watch, events) - if (hasCollectedChanges(changes)) { + if (hasCollectedWorktreeBaseChanges(changes)) { scheduleWorktreeBaseNotification(watch, changes) } } @@ -108,7 +107,7 @@ function handleRemoteWatchEvents( scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) return } - if (hasCollectedChanges(changes)) { + if (hasCollectedWorktreeBaseChanges(changes)) { scheduleWorktreeBaseNotification(watch, changes) } } @@ -129,6 +128,7 @@ function createActiveWatch( pendingHeadIdentityRepoIds: new Set(), headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), gitStatusRefPaths, + watcherFailureRefresh: new WorktreeWatcherFailureRefreshCooldown(), disposed: false } } diff --git a/src/main/ipc/worktree-watcher-failure-refresh-cooldown.ts b/src/main/ipc/worktree-watcher-failure-refresh-cooldown.ts new file mode 100644 index 00000000000..76c33a621fa --- /dev/null +++ b/src/main/ipc/worktree-watcher-failure-refresh-cooldown.ts @@ -0,0 +1,17 @@ +const WATCHER_FAILURE_REFRESH_COOLDOWN_MS = 60_000 + +export class WorktreeWatcherFailureRefreshCooldown { + private refreshedAt: number | null = null + + consume(now = Date.now()): boolean { + if (this.refreshedAt !== null && now - this.refreshedAt < WATCHER_FAILURE_REFRESH_COOLDOWN_MS) { + return false + } + this.refreshedAt = now + return true + } + + reset(): void { + this.refreshedAt = null + } +} diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index 606972f4249..7e053daa613 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -186,6 +186,22 @@ describe('aiVault.listSessions handler + shared cache', () => { expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) }) + it('keeps completed scans cached for one minute', async () => { + vi.useFakeTimers({ now: new Date('2026-08-05T00:00:00.000Z') }) + try { + await listAiVaultSessions({ limit: 500 }) + await vi.advanceTimersByTimeAsync(59_999) + await listAiVaultSessions({ limit: 500 }) + expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + await listAiVaultSessions({ limit: 500 }) + expect(scanAiVaultSessions).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + it('serves lower depths from a larger completed scan', async () => { await listAiVaultSessions({ limit: 1000 }) await listAiVaultSessions({ limit: 250 }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index 3af723343fb..7aa4662fb93 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -20,7 +20,7 @@ const EMPTY_RESULT: AiVaultListResult = { scannedAt: '2026-07-01T00:00:00.000Z' } -const THROTTLE_MS = 5_000 +const THROTTLE_MS = 30_000 const listSessionsMock = vi.fn<(args: unknown) => Promise>() const cancelListSessionsMock = vi.fn<() => Promise>() @@ -206,14 +206,14 @@ afterEach(() => { }) describe('useAiVaultSessionRefresh refocus behavior', () => { - it('bypasses the scan cache on mount so panel entry shows new sessions', async () => { + it('uses the shared scan cache on local panel entry', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ executionHostScope: 'local', - force: true + force: false }) }) @@ -351,32 +351,25 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(latest?.scanResult?.scannedAt).toBe('2026-07-01T00:00:02.000Z') }) - it('force re-scans on refocus once the throttle allows it', async () => { + it('refreshes from the shared cache on refocus', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) - await advance(THROTTLE_MS + 1) await fireWindowFocused() expect(listSessionsMock).toHaveBeenCalledTimes(2) - expect(lastCallArgs()).toMatchObject({ force: true }) + expect(lastCallArgs()).toMatchObject({ force: false }) }) - it('defers a refocus inside the throttle window to a trailing forced scan', async () => { + it('does not force transcript scans for refocus events', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) - // Within the throttle window nothing runs yet — the event must not be - // dropped, so it lands as one trailing scan when the throttle frees up. await fireWindowFocused() - await dispatch(document, 'visibilitychange') - expect(listSessionsMock).toHaveBeenCalledTimes(1) - - await advance(THROTTLE_MS + 1) expect(listSessionsMock).toHaveBeenCalledTimes(2) - expect(lastCallArgs()).toMatchObject({ force: true }) + expect(lastCallArgs()).toMatchObject({ force: false }) }) it('ignores focus/visibility events while the document is hidden', async () => { @@ -393,15 +386,12 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(listSessionsMock).toHaveBeenCalledTimes(1) }) - it('stops listening and cancels trailing scans after unmount', async () => { + it('stops listening after unmount', async () => { await renderHook() await flushMicrotasks() expect(listSessionsMock).toHaveBeenCalledTimes(1) - // Queue a trailing forced scan, then unmount before it fires. - await fireWindowFocused() roots.splice(0).forEach((root) => act(() => root.unmount())) - await advance(THROTTLE_MS + 1) await fireWindowFocused() await dispatch(document, 'visibilitychange') @@ -479,7 +469,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { expect(lastCallArgs()).toMatchObject({ force: true }) }) - it('counts a manual force refresh against the rescan throttle', async () => { + it('keeps refocus cache-backed after a manual force refresh', async () => { await renderHook() await flushMicrotasks() @@ -489,9 +479,9 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { }) expect(listSessionsMock).toHaveBeenCalledTimes(2) - // The button just scanned; an immediate refocus defers to trailing. await fireWindowFocused() - expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(listSessionsMock).toHaveBeenCalledTimes(3) + expect(lastCallArgs()).toMatchObject({ force: false }) }) }) @@ -514,10 +504,13 @@ describe('useAiVaultSessionRefresh in-app agent session behavior', () => { expect(listSessionsMock).toHaveBeenCalledTimes(1) await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1') }) - expect(listSessionsMock).toHaveBeenCalledTimes(1) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + await setAgentStatuses({ 'pane-2': makeAgentEntry('sess-2') }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) await advance(THROTTLE_MS + 1) - expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(listSessionsMock).toHaveBeenCalledTimes(3) expect(lastCallArgs()).toMatchObject({ force: true }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index e9cafb17d8a..9dd74274cb8 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -4,7 +4,7 @@ import { type AiVaultListResult, type AiVaultSession } from '../../../../shared/ai-vault-types' -import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostScope } from '../../../../shared/execution-host' +import type { ExecutionHostScope } from '../../../../shared/execution-host' import { useAppStore } from '@/store' import type { AiVaultSessionLimit } from './ai-vault-session-limit' import { @@ -14,22 +14,11 @@ import { resetAiVaultSessionResultCacheForTest } from './ai-vault-session-result-cache' -// Panel entry and window refocus must show sessions started since the last -// scan, so they bypass the main process's 15s cache — but a full scan parses -// up to ~1000 transcripts, so bound forced scans to one per interval. Module -// scope so the throttle survives panel remounts (the panel unmounts per tab). -const FORCED_RESCAN_MIN_INTERVAL_MS = 5_000 +// In-app session creation bypasses the cache so the new session appears promptly. +// Keep the budget at module scope so tab remounts cannot amplify full scans. +const FORCED_RESCAN_MIN_INTERVAL_MS = 30_000 let lastForcedRescanAt = 0 -function consumeForcedRescanBudget(): boolean { - const now = Date.now() - if (now - lastForcedRescanAt < FORCED_RESCAN_MIN_INTERVAL_MS) { - return false - } - lastForcedRescanAt = now - return true -} - export function resetAiVaultForcedRescanThrottleForTest(): void { lastForcedRescanAt = 0 resetAiVaultSessionResultCacheForTest() @@ -201,7 +190,7 @@ export function useAiVaultSessionRefresh( [currentScanScopeKey] ) - // Forced rescans triggered by events (refocus, agent-session starts) run + // Forced rescans triggered by new agent sessions run // immediately when the throttle allows, otherwise once as soon as it frees // up — dropping the event would leave a just-started session invisible // until some unrelated later trigger. @@ -240,33 +229,23 @@ export function useAiVaultSessionRefresh( } }, []) - // Remote scans can take long enough for normal tab navigation to feel stuck, - // so re-entering a remote panel uses its host/scope cache. Explicit refresh, - // app refocus and new in-app agent sessions still force a fresh scan. + // Panel entry reuses the renderer result first, then the host scan cache. useEffect(() => { if (refreshInFlightRef.current) { void window.api.aiVault.cancelListSessions({ requestToken: requestTokenRef.current }) } - const refreshOnEntry = executionHostScope === LOCAL_EXECUTION_HOST_ID - const force = refreshOnEntry && consumeForcedRescanBudget() - void refresh({ force, reuseLoadedDepth: true }) - if (refreshOnEntry && !force) { - requestForcedRescan() - } - }, [executionHostScope, refresh, requestForcedRescan, scanScopeKey]) + void refresh({ force: false, reuseLoadedDepth: true }) + }, [executionHostScope, refresh, scanScopeKey]) - // Sessions started while the app was backgrounded should appear when the - // user returns, so refocus also bypasses the scan cache (throttled). OS - // refocus arrives via the main process — renderer DOM focus events don't - // fire on macOS app activation; visibilitychange covers minimize-restore. + // Refocus checks the shared host cache without forcing another transcript scan. useEffect(() => { const onRefocus = (): void => { if (document.visibilityState !== 'visible') { return } - requestForcedRescan() + void refresh({ background: true, force: false }) } const unsubscribeWindowFocus = window.api.aiVault.onWindowFocused?.(onRefocus) document.addEventListener('visibilitychange', onRefocus) @@ -274,7 +253,7 @@ export function useAiVaultSessionRefresh( unsubscribeWindowFocus?.() document.removeEventListener('visibilitychange', onRefocus) } - }, [requestForcedRescan]) + }, [refresh]) // Sessions started inside Orca never blur the window, so refocus alone // can't surface them. Agent hooks already report provider sessions; re-scan diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 7ad97e6b692..d9a00636180 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -335,6 +335,7 @@ type MockTransport = { const scheduleRuntimeGraphSync = vi.fn() const shouldSeedCacheTimerOnInitialTitle = vi.fn(() => false) const scheduleTerminalWebglAtlasRecovery = vi.fn() +const scheduleTerminalWebglPresent = vi.fn() let mockStoreState: StoreState let transportFactoryQueue: MockTransport[] = [] @@ -358,7 +359,8 @@ vi.mock('@/store', () => ({ })) vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery + scheduleTerminalWebglAtlasRecovery, + scheduleTerminalWebglPresent })) function notifyStoreSubscribers(): void { @@ -16884,7 +16886,7 @@ describe('connectPanePty', () => { expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) - it('schedules WebGL atlas recovery for Vim-style foreground alternate-screen redraws', async () => { + it('schedules a WebGL repaint for Vim-style foreground alternate-screen redraws', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { const { connectPanePty } = await import('./pty-connection') @@ -16917,16 +16919,17 @@ describe('connectPanePty', () => { '\x1b[2J\x1b[H{"name":"eepo"}\r\n\x1b[2;1H{"name":"expo"}\x1b[K' ) - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expect(scheduleTerminalWebglPresent).not.toHaveBeenCalled() parseCallback?.() expect(refresh).toHaveBeenCalledWith(0, 39, true) - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + expect(scheduleTerminalWebglPresent).toHaveBeenCalledTimes(1) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() } finally { restoreNavigator() } }) - it('schedules WebGL atlas recovery when a foreground rewrite enters alternate screen', async () => { + it('schedules a WebGL repaint when a foreground rewrite enters alternate screen', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { const { connectPanePty } = await import('./pty-connection') @@ -16956,18 +16959,19 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[HVim package.json') - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expect(scheduleTerminalWebglPresent).not.toHaveBeenCalled() // Why: xterm switches to the alternate buffer while parsing; the write callback observes the post-parse state. ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' parseCallback?.() expect(refresh).toHaveBeenCalledWith(0, 39, true) - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + expect(scheduleTerminalWebglPresent).toHaveBeenCalledTimes(1) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() } finally { restoreNavigator() } }) - it('schedules WebGL atlas recovery when the alternate-screen enter sequence splits across chunks', async () => { + it('schedules a WebGL repaint when the alternate-screen enter sequence splits across chunks', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { const { connectPanePty } = await import('./pty-connection') @@ -16998,18 +17002,19 @@ describe('connectPanePty', () => { // Why: PTY reads split CSI sequences at arbitrary byte boundaries, so the enter sequence can straddle two onData chunks. capturedDataCallback.current?.('\x1b[?104') parseCallback?.() - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expect(scheduleTerminalWebglPresent).not.toHaveBeenCalled() capturedDataCallback.current?.('9h\x1b[2J\x1b[H~\x1b[K') ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' parseCallback?.() - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + expect(scheduleTerminalWebglPresent).toHaveBeenCalledTimes(1) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() } finally { restoreNavigator() } }) - it('schedules WebGL atlas recovery when a foreground rewrite leaves alternate screen', async () => { + it('schedules a WebGL repaint when a foreground rewrite leaves alternate screen', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { const { connectPanePty } = await import('./pty-connection') @@ -17042,13 +17047,14 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('\x1b[34;1H\x1b[K\x1b[34;1H\x1b[?1049l\x1b[?25h') ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal' parseCallback?.() - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + expect(scheduleTerminalWebglPresent).toHaveBeenCalledTimes(1) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() } finally { restoreNavigator() } }) - it('schedules WebGL atlas recovery when one chunk enters and exits alternate screen', async () => { + it('schedules a WebGL repaint when one chunk enters and exits alternate screen', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { const { connectPanePty } = await import('./pty-connection') @@ -17090,13 +17096,14 @@ describe('connectPanePty', () => { bufferChangeListener?.() bufferChangeListener?.() parseCallback?.() - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + expect(scheduleTerminalWebglPresent).toHaveBeenCalledTimes(1) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() } finally { restoreNavigator() } }) - it('schedules WebGL atlas recovery for real captured Vim redraw chunks split mid-sequence', async () => { + it('schedules WebGL repaints for real captured Vim redraw chunks split mid-sequence', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { const { connectPanePty } = await import('./pty-connection') @@ -17128,13 +17135,14 @@ describe('connectPanePty', () => { // Captured from a real vim session: a 1024-byte PTY read boundary cuts the cursor move \x1b[30;5H into "\x1b[30" + ";5H". capturedDataCallback.current?.('"rules": {\x1b[29;15H\x1b[K\x1b[30') parseCallback?.() - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + expect(scheduleTerminalWebglPresent).toHaveBeenCalledTimes(1) capturedDataCallback.current?.( ';5H "js-combine-iterations": "off"\r\n }\x1b[31;6H\x1b[K\x1b[33;1H\x1b[?25h' ) parseCallback?.() - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(2) + expect(scheduleTerminalWebglPresent).toHaveBeenCalledTimes(2) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() } finally { restoreNavigator() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index ec335002286..19c4571f6ff 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -233,7 +233,10 @@ import { createCommandCodeOutputStatusDetector } from '../../../../shared/comman import type { PtyDataMeta } from './pty-dispatcher' import { getEagerPtyBufferHandle } from './pty-dispatcher' import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' -import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' +import { + scheduleTerminalWebglAtlasRecovery, + scheduleTerminalWebglPresent +} from './terminal-webgl-atlas-recovery' import { CONPTY_DA1_RESPONSE, DEFAULT_DA1_RESPONSE, @@ -6326,8 +6329,8 @@ export function connectPanePty( return decision.prefersRenderRefresh } - // Why: Vim-style rewrites leave stale WebGL glyphs until the atlas rebuilds; alt-screen membership is only authoritative post-parse (enter/exit can split chunks), so capture pre-parse and decide at parse completion. - function alternateScreenRewriteAtlasRecoveryOnParsed(): () => void { + // Why: alt-screen membership is only authoritative post-parse because enter/exit sequences can split across chunks. + function alternateScreenRewritePresentOnParsed(): () => void { const wasAlternateScreenBuffer = pane.terminal.buffer.active.type === 'alternate' const switchesBeforeParse = alternateScreenBufferSwitches return () => { @@ -6336,7 +6339,7 @@ export function connectPanePty( alternateScreenBufferSwitches !== switchesBeforeParse || pane.terminal.buffer.active.type === 'alternate' ) { - scheduleTerminalWebglAtlasRecovery() + scheduleTerminalWebglPresent() } } } @@ -6464,7 +6467,7 @@ export function connectPanePty( ? recoverWebglAtlasAfterParse ? scheduleTerminalWebglAtlasRecovery : renderRefreshDecision.inPlaceRewrite - ? alternateScreenRewriteAtlasRecoveryOnParsed() + ? alternateScreenRewritePresentOnParsed() : undefined : undefined const foregroundRenderRefreshNeeded = renderRefreshDecision.refresh diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts index a1d4690f45b..917a96bfa0d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -13,12 +13,12 @@ import type { TerminalTab } from '../../../../shared/types' // was built for. Reveal cost is a flat ~170ms remount regardless of buffer // size, so cutting remount *frequency* beats shaving replay. export const TERMINAL_WORKTREE_COLD_PARK_DELAY_MS = 30_000 -export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 15 * 60_000 -export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 8 +export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 5 * 60_000 +export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 4 export const TERMINAL_WORKTREE_PARK_DELAY_MS = TERMINAL_WORKTREE_COLD_PARK_DELAY_MS export const TERMINAL_TAB_COLD_PARK_DELAY_MS = 30_000 -export const TERMINAL_TAB_HOT_RETAIN_MS = 15 * 60_000 -export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 12 +export const TERMINAL_TAB_HOT_RETAIN_MS = 5 * 60_000 +export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 6 // Why: tests override these per call (instead of process.env reads inside the // module) to shrink the 30s hysteresis to test-friendly durations. diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts index 161d3376043..695a2546261 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -13,8 +13,8 @@ import type { TerminalTab } from '../../../../shared/types' // Why these sizes: a retained hidden pane costs a measured ~2.5MB of V8 heap // at the 5k-row default scrollback and ~19MB at 50k (plus per-pane queues), // not the ~4-5MB per WORKTREE the warm cap assumed — so un-parkable worktrees -// (pty classes parking can't restore) get a retention budget: at most 12 stay -// mounted while hidden and none past 45 minutes, evicted least-recently-hidden +// (pty classes parking can't restore) get a retention budget: at most 4 stay +// mounted while hidden and none past 15 minutes, evicted least-recently-hidden // first via force-park. The TTL is absolute: the last-active exemption bounds // the cap, never the clock. // NOT covered by this bound: eviction-exempt TABS (isEvictionExemptTerminalPty @@ -27,8 +27,8 @@ import type { TerminalTab } from '../../../../shared/types' // spared worktree (last-active, exempt tabs) can hold full 50k-row scrollback // indefinitely. Accepted tradeoff: high-scrollback users rely on unmount // eviction, not demotion. -export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 12 -export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 45 * 60_000 +export const TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT = 4 +export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 15 * 60_000 export function hasPendingRetentionSpawnWork( tab: Pick, diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts index 69d5df93f3d..64b777cc9c0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts @@ -8,6 +8,7 @@ import { scheduleImagePasteWebglAtlasRecovery, scheduleTabRevealWebglAtlasRecovery, scheduleTerminalWebglAtlasRecovery, + scheduleTerminalWebglPresent, TERMINAL_OUTPUT_RECOVERY_QUIET_MS } from './terminal-webgl-atlas-recovery' @@ -204,6 +205,24 @@ describe('terminal WebGL atlas recovery', () => { expect(manager.refreshAllPanes).not.toHaveBeenCalled() }) + it('repaints settled rewrites without clearing the shared atlas', () => { + vi.useFakeTimers() + const manager = registerManager() + + scheduleTerminalWebglPresent() + scheduleTerminalWebglPresent() + vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS - 1) + + expect(manager.scheduleRevealPresent).not.toHaveBeenCalled() + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + + expect(manager.scheduleRevealPresent).toHaveBeenCalledOnce() + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(manager.refreshAllPanes).not.toHaveBeenCalled() + }) + it('recovers exactly once after terminal output settles, not a 3-stage burst', () => { // Brennan-approved single-recovery decision: the streaming settle fires one // clear+refresh, not the rAF+120+500 burst, so a clear can only ever land diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts index d7d54dc0995..0edff6b1760 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts @@ -18,6 +18,7 @@ const TERMINAL_OUTPUT_RECOVERY_REFILL_INTERVAL_MS = 6_000 let terminalOutputRecoveryDebounceTimer: ReturnType | null = null let terminalOutputRecoveryRetryTimer: ReturnType | null = null +let terminalPresentDebounceTimer: ReturnType | null = null let terminalOutputRecoveryWipeTokens = TERMINAL_OUTPUT_RECOVERY_BURST_WIPES let terminalOutputRecoveryTokensRefilledAt: number | null = null let terminalOutputRecoveryLastWipeAt: number | null = null @@ -95,6 +96,16 @@ export function scheduleTerminalWebglAtlasRecovery(): void { }, TERMINAL_OUTPUT_RECOVERY_QUIET_MS) } +export function scheduleTerminalWebglPresent(): void { + if (terminalPresentDebounceTimer != null) { + globalThis.clearTimeout(terminalPresentDebounceTimer) + } + terminalPresentDebounceTimer = globalThis.setTimeout(() => { + terminalPresentDebounceTimer = null + presentPanesWithoutAtlasClear() + }, TERMINAL_OUTPUT_RECOVERY_QUIET_MS) +} + function scheduleTerminalOutputRecoveryRetry(delayMs: number): void { terminalOutputRecoveryRetryTimer = globalThis.setTimeout(() => { terminalOutputRecoveryRetryTimer = null @@ -128,6 +139,10 @@ export function resetTerminalWebglAtlasRecoveryBudgetForTesting(): void { globalThis.clearTimeout(terminalOutputRecoveryRetryTimer) } terminalOutputRecoveryRetryTimer = null + if (terminalPresentDebounceTimer != null) { + globalThis.clearTimeout(terminalPresentDebounceTimer) + } + terminalPresentDebounceTimer = null terminalOutputRecoveryWipeTokens = TERMINAL_OUTPUT_RECOVERY_BURST_WIPES terminalOutputRecoveryTokensRefilledAt = null terminalOutputRecoveryLastWipeAt = null diff --git a/src/renderer/src/lib/pane-manager/pane-display-visibility.ts b/src/renderer/src/lib/pane-manager/pane-display-visibility.ts new file mode 100644 index 00000000000..7c7fc7f637b --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-display-visibility.ts @@ -0,0 +1,15 @@ +import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' + +export function isManagedPaneDisplayNone(pane: ManagedPane): boolean { + const element = (pane as ManagedPaneInternal).xtermContainer ?? pane.container + const view = element?.ownerDocument?.defaultView + if (!element || !view) { + return false + } + for (let current: HTMLElement | null = element; current; current = current.parentElement) { + if (view.getComputedStyle(current).display === 'none') { + return true + } + } + return false +} diff --git a/src/renderer/src/lib/pane-manager/pane-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-fit.test.ts index 2b2471a2c7c..6ad551bec7f 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' -import type { ManagedPane, ScrollState } from './pane-manager-types' +import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types' import { safeFit, safeFitAndThen } from './pane-fit' import { paneFitClientSizeChanged } from './pane-reveal-fit' @@ -214,6 +214,28 @@ describe('safeFitAndThen unmeasurable-pane retry', () => { expect(continuation).not.toHaveBeenCalled() await expect(handle.completion).resolves.toBe(false) }) + + it('does not retry a pane explicitly hidden with display none', async () => { + vi.mocked(recordRendererCrashBreadcrumb).mockClear() + const pane = createPane({ rect: { width: 0, height: 0 } }) + const container = (pane as unknown as ManagedPaneInternal).xtermContainer + Object.assign(container, { + ownerDocument: { + defaultView: { getComputedStyle: () => ({ display: 'none' }) } + }, + parentElement: null + }) + const continuation = vi.fn() + + const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, { + retryIfUnmeasurable: true + }) + + expect(requestAnimationFrame).not.toHaveBeenCalled() + expect(continuation).not.toHaveBeenCalled() + expect(recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + await expect(handle.completion).resolves.toBe(false) + }) }) describe('paneFitClientSizeChanged (reveal fit gate)', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts index e017566e392..fb962d72c4d 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -1,4 +1,5 @@ import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types' +import { isManagedPaneDisplayNone } from './pane-display-visibility' import { getFitOverrideForPty } from './mobile-fit-overrides' import { armPaneFitContinuationRetry, @@ -314,7 +315,11 @@ export function safeFitAndThen( () => { if (pendingSafeFitContinuations.get(pane)?.get(operationKey) === pending) { if (!safeFit(pane) && options.retryIfUnmeasurable) { - armSafeFitContinuationRetry(pane) + if (isManagedPaneDisplayNone(pane)) { + cancel() + } else { + armSafeFitContinuationRetry(pane) + } } } } @@ -323,7 +328,11 @@ export function safeFitAndThen( return { completion, cancel } } if (!safeFit(pane) && options.retryIfUnmeasurable) { - armSafeFitContinuationRetry(pane) + if (isManagedPaneDisplayNone(pane)) { + cancel() + } else { + armSafeFitContinuationRetry(pane) + } } return { completion, cancel } }