Prevent continuous git status scanning in large repositories

On repos where `git status --untracked-files=all` takes tens of seconds,
the background status poll restarted a fresh scan 3s after the previous
one finished, keeping a git process at high CPU almost continuously
while the workspace sat idle (#7983).

The coalesced poll runner now paces reruns by the previous run's
duration, split by trigger class:

- Evidence-free timer ticks wait 5x the last refresh duration (capped
  at 5 minutes), bounding idle polling to ~1/6 duty cycle.
- Change signals (file-watch events, repo metadata pushes, finished
  terminal commands, window reveal after hidden) wait only 1x, so real
  changes in a slow repo still surface promptly; a change signal can
  pull an already-scheduled tick run earlier, and the strongest pending
  trigger wins for trailing reruns.
- Backoff-deferred scans are skipped while the window is hidden; the
  becoming-visible run catches up on the short lane.

Fast repos keep the exact 3s cadence (the multiplier never drops the
gap below the existing floor), and user-triggered refreshes are
unaffected (they bypass the poll runner). The stale-conflict poll gets
the same pacing, which also spaces slow remote SSH probe chains.

Fixes #7983
This commit is contained in:
Brennan Benson
2026-07-09 20:11:17 -07:00
parent 4ead072a16
commit 552b99298b
5 changed files with 347 additions and 33 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { createCoalescedPollRunner } from './coalesced-poll-runner'
import { type CoalescedPollRunner, createCoalescedPollRunner } from './coalesced-poll-runner'
function deferred(): {
promise: Promise<void>
@@ -88,6 +88,189 @@ describe('createCoalescedPollRunner', () => {
vi.useRealTimers()
})
describe('slowTaskBackoff', () => {
const BACKOFF = { idleMultiplier: 5, changeSignalMultiplier: 1, maxIntervalMs: 300_000 }
function makeSlowRunner(): {
runner: CoalescedPollRunner
task: ReturnType<typeof vi.fn>
calls: ReturnType<typeof deferred>[]
} {
const calls: ReturnType<typeof deferred>[] = []
const task = vi.fn(() => {
const call = deferred()
calls.push(call)
return call.promise
})
const runner = createCoalescedPollRunner(task, {
minIntervalMs: 3000,
slowTaskBackoff: BACKOFF
})
return { runner, task, calls }
}
async function completeRunOfDuration(
runner: CoalescedPollRunner,
calls: ReturnType<typeof deferred>[],
durationMs: number
): Promise<void> {
runner.run()
await vi.advanceTimersByTimeAsync(durationMs)
calls.at(-1)?.resolve()
await flushMicrotasks()
}
it('scales the tick idle gap with the previous run duration', async () => {
vi.useFakeTimers()
const { runner, task, calls } = makeSlowRunner()
// A 35s run (large-monorepo git status) with poll ticks arriving during it.
runner.run()
runner.run()
await vi.advanceTimersByTimeAsync(35_000)
calls[0]?.resolve()
await flushMicrotasks()
expect(task).toHaveBeenCalledTimes(1)
// Ticks during the backoff window must not start a run early.
await vi.advanceTimersByTimeAsync(3000)
runner.run()
await vi.advanceTimersByTimeAsync(171_998)
runner.run()
expect(task).toHaveBeenCalledTimes(1)
// 5x the 35s duration → next tick-driven run only after 175s idle.
await vi.advanceTimersByTimeAsync(2)
expect(task).toHaveBeenCalledTimes(2)
calls[1]?.resolve()
await flushMicrotasks()
vi.useRealTimers()
})
it('caps the backoff at maxIntervalMs', async () => {
vi.useFakeTimers()
const { runner, task, calls } = makeSlowRunner()
await completeRunOfDuration(runner, calls, 120_000)
expect(task).toHaveBeenCalledTimes(1)
// 5 x 120s = 600s uncapped; the cap holds the gap to 300s.
runner.run()
await vi.advanceTimersByTimeAsync(299_999)
expect(task).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(task).toHaveBeenCalledTimes(2)
calls[1]?.resolve()
await flushMicrotasks()
vi.useRealTimers()
})
it('keeps minIntervalMs as the gap for fast tasks', async () => {
vi.useFakeTimers()
const { runner, task, calls } = makeSlowRunner()
await completeRunOfDuration(runner, calls, 100)
expect(task).toHaveBeenCalledTimes(1)
// max(3000, 5 x 100ms) = 3000 → normal-repo cadence is unchanged.
runner.run()
await vi.advanceTimersByTimeAsync(2999)
expect(task).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(task).toHaveBeenCalledTimes(2)
calls[1]?.resolve()
await flushMicrotasks()
vi.useRealTimers()
})
it('lets change signals run after the short backoff instead of the tick backoff', async () => {
vi.useFakeTimers()
const { runner, task, calls } = makeSlowRunner()
await completeRunOfDuration(runner, calls, 35_000)
expect(task).toHaveBeenCalledTimes(1)
// A change signal waits 1 x 35s, not 5 x 35s.
runner.run({ changeSignal: true })
await vi.advanceTimersByTimeAsync(34_999)
expect(task).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(task).toHaveBeenCalledTimes(2)
calls[1]?.resolve()
await flushMicrotasks()
vi.useRealTimers()
})
it('pulls an already-scheduled tick run earlier when a change signal arrives', async () => {
vi.useFakeTimers()
const { runner, task, calls } = makeSlowRunner()
await completeRunOfDuration(runner, calls, 35_000)
runner.run() // tick scheduled for +175s
await vi.advanceTimersByTimeAsync(10_000)
runner.run({ changeSignal: true }) // reschedules for end + 35s
await vi.advanceTimersByTimeAsync(24_999)
expect(task).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(task).toHaveBeenCalledTimes(2)
// The superseded tick timer must not fire an extra run later.
calls[1]?.resolve()
await flushMicrotasks()
await vi.advanceTimersByTimeAsync(600_000)
expect(task).toHaveBeenCalledTimes(2)
vi.useRealTimers()
})
it('does not let a later tick delay a scheduled change-signal run', async () => {
vi.useFakeTimers()
const { runner, task, calls } = makeSlowRunner()
await completeRunOfDuration(runner, calls, 35_000)
runner.run({ changeSignal: true }) // scheduled for +35s
await vi.advanceTimersByTimeAsync(10_000)
runner.run() // tick must not push the schedule to +175s
await vi.advanceTimersByTimeAsync(24_999)
expect(task).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(task).toHaveBeenCalledTimes(2)
calls[1]?.resolve()
await flushMicrotasks()
vi.useRealTimers()
})
it('keeps change-signal pacing for a signal that arrived mid-run', async () => {
vi.useFakeTimers()
const { runner, task, calls } = makeSlowRunner()
runner.run()
await vi.advanceTimersByTimeAsync(20_000)
runner.run({ changeSignal: true }) // during flight
await vi.advanceTimersByTimeAsync(15_000)
runner.run() // a later tick must not downgrade the pending signal
calls[0]?.resolve()
await flushMicrotasks()
expect(task).toHaveBeenCalledTimes(1)
// Trailing rerun waits 1 x 35s (signal), not 5 x 35s (tick).
await vi.advanceTimersByTimeAsync(34_999)
expect(task).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(task).toHaveBeenCalledTimes(2)
calls[1]?.resolve()
await flushMicrotasks()
vi.useRealTimers()
})
})
it('cancels scheduled deferred run on dispose', async () => {
vi.useFakeTimers()
const calls: ReturnType<typeof deferred>[] = []
@@ -1,42 +1,95 @@
export type CoalescedPollRunnerTrigger = {
// True when the caller has evidence something changed (file-watch event,
// repo metadata push signal, finished terminal command) rather than a bare
// timer tick. Change signals wait out a shorter slow-task backoff.
changeSignal?: boolean
}
export type CoalescedPollRunner = {
run: () => void
run: (trigger?: CoalescedPollRunnerTrigger) => void
dispose: () => void
}
export type SlowTaskBackoffOptions = {
// Idle gap before an evidence-free timer tick may re-run, as a multiple of
// the previous run's duration.
idleMultiplier: number
// Idle gap before a change-signal run may re-run. Kept low so slow repos
// still refresh promptly after real changes, while sustained churn can
// never keep the task running back-to-back.
changeSignalMultiplier: number
maxIntervalMs: number
}
// Why: on large monorepos a poll task (git status) can take tens of seconds,
// so a fixed idle gap makes polling effectively continuous (#7983). Scale the
// gap with the previous run's duration — aggressively for evidence-free timer
// ticks, mildly for change signals — capped so results never go minutes-stale.
export function createCoalescedPollRunner(
task: () => Promise<void>,
options?: { minIntervalMs?: number }
options?: {
minIntervalMs?: number
slowTaskBackoff?: SlowTaskBackoffOptions
}
): CoalescedPollRunner {
let disposed = false
let inFlight = false
let rerun = false
let rerunTrigger: CoalescedPollRunnerTrigger | null = null
let lastRunEndedAt = -Infinity
let lastRunDurationMs = 0
let timeoutId: ReturnType<typeof setTimeout> | null = null
let timeoutFiresAt = Infinity
const minIntervalMs = options?.minIntervalMs ?? 0
const run = (): void => {
const requiredIdleMsFor = (trigger?: CoalescedPollRunnerTrigger): number => {
const backoff = options?.slowTaskBackoff
if (!backoff) {
return minIntervalMs
}
const multiplier = trigger?.changeSignal
? backoff.changeSignalMultiplier
: backoff.idleMultiplier
return Math.max(minIntervalMs, Math.min(lastRunDurationMs * multiplier, backoff.maxIntervalMs))
}
const clearScheduledRun = (): void => {
if (timeoutId !== null) {
clearTimeout(timeoutId)
timeoutId = null
timeoutFiresAt = Infinity
}
}
const run = (trigger?: CoalescedPollRunnerTrigger): void => {
if (disposed) {
return
}
if (inFlight) {
rerun = true
return
}
if (timeoutId !== null) {
// Why: keep the strongest pending trigger so a change signal arriving
// mid-run is not downgraded to tick pacing by a later timer tick.
rerunTrigger = { changeSignal: rerunTrigger?.changeSignal || trigger?.changeSignal }
return
}
const now = Date.now()
const timeSinceLastEnd = now - lastRunEndedAt
const minInterval = options?.minIntervalMs ?? 0
if (timeSinceLastEnd < minInterval) {
const delay = minInterval - timeSinceLastEnd
const allowedAt = lastRunEndedAt + requiredIdleMsFor(trigger)
if (now < allowedAt) {
// Why: a change signal may pull an already-scheduled evidence-free run
// earlier; a weaker trigger must never push a scheduled run later.
if (allowedAt >= timeoutFiresAt) {
return
}
clearScheduledRun()
timeoutFiresAt = allowedAt
timeoutId = setTimeout(() => {
timeoutId = null
run()
}, delay)
timeoutFiresAt = Infinity
run(trigger)
}, allowedAt - now)
return
}
clearScheduledRun()
inFlight = true
void task()
.catch(() => {
@@ -46,10 +99,11 @@ export function createCoalescedPollRunner(
.finally(() => {
inFlight = false
lastRunEndedAt = Date.now()
const shouldRerun = rerun && !disposed
rerun = false
if (shouldRerun) {
run()
lastRunDurationMs = lastRunEndedAt - now
const trailingTrigger = disposed ? null : rerunTrigger
rerunTrigger = null
if (trailingTrigger) {
run(trailingTrigger)
}
})
}
@@ -58,11 +112,8 @@ export function createCoalescedPollRunner(
run,
dispose: () => {
disposed = true
rerun = false
if (timeoutId !== null) {
clearTimeout(timeoutId)
timeoutId = null
}
rerunTrigger = null
clearScheduledRun()
}
}
}
@@ -7,7 +7,7 @@ import { getConnectionId } from '@/lib/connection-context'
import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client'
import { refreshGitStatusForWorktree } from './git-status-refresh'
import { type CoalescedPollRunner, createCoalescedPollRunner } from './coalesced-poll-runner'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval'
import {
hasInteractiveActiveGitStatusConsumer,
shouldPollActiveGitStatus
@@ -22,6 +22,16 @@ const INTERACTIVE_STATUS_POLL_INTERVAL_MS = MIN_STATUS_REFRESH_INTERVAL_MS
// metadata watch, shell command completion) cover branch switches; the
// terminal-only poll is a last-resort backstop for shells without either.
const TERMINAL_ONLY_STATUS_POLL_INTERVAL_MS = 30_000
// Why: on a large monorepo one status refresh can take tens of seconds, so the
// fixed 3s gap kept a git process running almost continuously while the
// workspace was idle (#7983). Evidence-free ticks wait 5x the previous poll
// duration (~1/6 duty cycle); change signals wait only 1x so real changes in a
// slow repo still surface promptly; the cap bounds worst-case staleness.
const SLOW_GIT_POLL_BACKOFF = {
idleMultiplier: 5,
changeSignalMultiplier: 1,
maxIntervalMs: 5 * 60_000
}
export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
const enabled = options.enabled ?? true
@@ -100,6 +110,12 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
}, [allWorktrees, conflictOperationByWorktree, activeWorktreeId, repoMap])
const runFetchStatus = useCallback(async () => {
// Why: a backoff-deferred run can fire long after the window hides; skip
// the scan instead of running tens of seconds of git work nobody can see.
// The becoming-visible run catches up via the change-signal lane.
if (!isWindowVisible()) {
return
}
if (!shouldPollActiveWorktreeGitStatus || !activeWorktreeId || !worktreePath) {
return
}
@@ -144,7 +160,8 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
const statusPollRunnerRef = useRef<CoalescedPollRunner | null>(null)
useEffect(() => {
const runner = createCoalescedPollRunner(() => runFetchStatusRef.current(), {
minIntervalMs: MIN_STATUS_REFRESH_INTERVAL_MS
minIntervalMs: MIN_STATUS_REFRESH_INTERVAL_MS,
slowTaskBackoff: SLOW_GIT_POLL_BACKOFF
})
statusPollRunnerRef.current = runner
return () => {
@@ -157,24 +174,33 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
statusPollRunnerRef.current?.run()
}, [])
// Why: file-watch and push signals carry evidence something changed, so they
// take the runner's short-backoff lane instead of evidence-free tick pacing.
const fetchStatusOnChangeSignal = useCallback(() => {
statusPollRunnerRef.current?.run({ changeSignal: true })
}, [])
useEffect(() => {
if (!activeStatusPollScope) {
return
}
// Why: this root-level poll should pause while hidden, but visible
// unfocused windows still need fresh status for second-display workflows.
// Change signals are dropped while hidden, so the becoming-visible run
// rides the short-backoff lane to catch up on anything that was missed.
return installWindowVisibilityInterval({
run: fetchStatus,
runOnVisible: fetchStatusOnChangeSignal,
intervalMs: activeStatusPollIntervalMs
})
}, [activeStatusPollIntervalMs, activeStatusPollScope, fetchStatus])
}, [activeStatusPollIntervalMs, activeStatusPollScope, fetchStatus, fetchStatusOnChangeSignal])
useGitStatusFileWatchRefresh({
activeConnectionId,
activeRepoSupportsGit,
activeWorktreeId,
enabled,
fetchStatus,
fetchStatus: fetchStatusOnChangeSignal,
gitStatusHugeByWorktree,
isConnectionReady,
openFiles,
@@ -188,7 +214,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
activeRepoId,
activeWorktreeId,
enabled: shouldPollActiveWorktreeGitStatus,
fetchStatus
fetchStatus: fetchStatusOnChangeSignal
})
// Why: poll conflict operation for non-active worktrees that have a stale
@@ -225,13 +251,18 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
}
// Why: remote conflict probes can exceed the 3s interval. Keep one poll in
// flight and coalesce skipped ticks into one trailing pass so stale badges
// catch up without stacking SSH/RPC work.
const pollRunner = createCoalescedPollRunner(pollStale)
// flight, coalesce skipped ticks into one trailing pass, and back off after
// slow probe chains so stale badges catch up without stacking SSH/RPC work.
const pollRunner = createCoalescedPollRunner(pollStale, {
slowTaskBackoff: SLOW_GIT_POLL_BACKOFF
})
// Why: conflict badges are visible sidebar state; keep them fresh in
// visible unfocused windows, but do not poll disconnected hidden windows.
// The becoming-visible run rides the short-backoff lane so badges catch
// up promptly after a hidden stretch.
const stopVisiblePoll = installWindowVisibilityInterval({
run: () => pollRunner.run(),
runOnVisible: () => pollRunner.run({ changeSignal: true }),
intervalMs: MIN_STATUS_REFRESH_INTERVAL_MS
})
return () => {
@@ -59,6 +59,51 @@ describe('installWindowVisibilityInterval', () => {
)
})
it('uses runOnVisible for the becoming-visible run and run for interval ticks', () => {
let visibilityState: DocumentVisibilityState = 'hidden'
const documentListeners = new Map<string, () => void>()
const intervalCallbacks: (() => void)[] = []
const setIntervalMock = vi.fn((callback: () => void) => {
intervalCallbacks.push(callback)
return 1 as unknown as ReturnType<typeof setInterval>
})
const run = vi.fn()
const runOnVisible = vi.fn()
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
vi.stubGlobal('document', {
get visibilityState() {
return visibilityState
},
addEventListener: vi.fn((event: string, listener: () => void) => {
documentListeners.set(event, listener)
}),
removeEventListener: vi.fn()
})
const cleanup = installWindowVisibilityInterval({
run,
runOnVisible,
intervalMs: 3000,
setIntervalFn: setIntervalMock,
clearIntervalFn: vi.fn()
})
visibilityState = 'visible'
documentListeners.get('visibilitychange')?.()
expect(runOnVisible).toHaveBeenCalledTimes(1)
expect(run).not.toHaveBeenCalled()
intervalCallbacks.at(0)?.()
expect(run).toHaveBeenCalledTimes(1)
expect(runOnVisible).toHaveBeenCalledTimes(1)
cleanup()
})
it('starts while visible even when the window is not focused', () => {
const run = vi.fn()
const setIntervalMock = vi.fn(() => 1 as unknown as ReturnType<typeof setInterval>)
@@ -10,6 +10,10 @@ export function isWindowVisible(): boolean {
export function installWindowVisibilityInterval(args: {
run: () => void
// Why: callers that drop refresh signals while hidden can treat the
// becoming-visible run as evidence-bearing (something may have been missed)
// instead of a bare interval tick. Defaults to `run`.
runOnVisible?: () => void
intervalMs: number
setIntervalFn?: (callback: () => void, intervalMs: number) => WindowVisibilityIntervalTimer
clearIntervalFn?: (handle: WindowVisibilityIntervalTimer) => void
@@ -33,7 +37,7 @@ export function installWindowVisibilityInterval(args: {
if (intervalId || !isWindowVisible()) {
return
}
args.run()
;(args.runOnVisible ?? args.run)()
// Why: many callers shell out or cross IPC. Keep their interval alive only
// while Orca can present the refreshed data, but still refresh a visible
// unfocused window so status UI does not go stale on a second display.