perf: bound repeated watcher, vault, and terminal work

This commit is contained in:
Brennan Benson
2026-08-05 18:41:39 -07:00
parent 79896cb9a6
commit 0b610bccc4
20 changed files with 317 additions and 101 deletions
+1 -1
View File
@@ -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
@@ -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)
})
})
+11 -2
View File
@@ -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 {
@@ -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()
@@ -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<string>
gitStatusRepoIds: Set<string>
@@ -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)
+10 -10
View File
@@ -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<string>
headIdentityRefresh: WorktreeHeadIdentityRefreshState
gitStatusRefPaths: Set<string>
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
}
}
@@ -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
}
}
@@ -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 })
@@ -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<AiVaultListResult>>()
const cancelListSessionsMock = vi.fn<() => Promise<void>>()
@@ -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 })
})
@@ -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
@@ -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()
}
@@ -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
@@ -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.
@@ -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<TerminalTab, 'id' | 'ptyId' | 'pendingActivationSpawn'>,
@@ -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
@@ -18,6 +18,7 @@ const TERMINAL_OUTPUT_RECOVERY_REFILL_INTERVAL_MS = 6_000
let terminalOutputRecoveryDebounceTimer: ReturnType<typeof setTimeout> | null = null
let terminalOutputRecoveryRetryTimer: ReturnType<typeof setTimeout> | null = null
let terminalPresentDebounceTimer: ReturnType<typeof setTimeout> | 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
@@ -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
}
@@ -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)', () => {
+11 -2
View File
@@ -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 }
}