From 53f03255dd5d895ac47b5b5bfc11ebd4b0404ed4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 16 May 2026 10:56:18 -0700 Subject: [PATCH] Coalesce stale conflict polling (#2081) --- .../coalesced-poll-runner.test.ts | 58 +++++++++++++++++++ .../right-sidebar/coalesced-poll-runner.ts | 43 ++++++++++++++ .../right-sidebar/useGitStatusPolling.ts | 12 +++- 3 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts create mode 100644 src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts diff --git a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts new file mode 100644 index 000000000..e019284d9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' +import { createCoalescedPollRunner } from './coalesced-poll-runner' + +function deferred(): { + promise: Promise + resolve: () => void +} { + let resolve: () => void = () => {} + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +async function flushMicrotasks(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('createCoalescedPollRunner', () => { + it('keeps one task in flight and runs one trailing task after skipped triggers', async () => { + const calls: ReturnType[] = [] + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + return call.promise + }) + const runner = createCoalescedPollRunner(task) + + runner.run() + runner.run() + runner.run() + await flushMicrotasks() + + expect(task).toHaveBeenCalledTimes(1) + calls[0]?.resolve() + await flushMicrotasks() + + expect(task).toHaveBeenCalledTimes(2) + calls[1]?.resolve() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(2) + }) + + it('drops queued trailing work after disposal', async () => { + const call = deferred() + const task = vi.fn(() => call.promise) + const runner = createCoalescedPollRunner(task) + + runner.run() + runner.run() + runner.dispose() + call.resolve() + await flushMicrotasks() + + expect(task).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts new file mode 100644 index 000000000..60a7ace8d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts @@ -0,0 +1,43 @@ +export type CoalescedPollRunner = { + run: () => void + dispose: () => void +} + +export function createCoalescedPollRunner(task: () => Promise): CoalescedPollRunner { + let disposed = false + let inFlight = false + let rerun = false + + const run = (): void => { + if (disposed) { + return + } + if (inFlight) { + rerun = true + return + } + inFlight = true + void task() + .catch(() => { + // Poll callers handle their own expected transient errors. A rejected + // task must still release the in-flight latch and optional trailing run. + }) + .finally(() => { + inFlight = false + if (rerun && !disposed) { + rerun = false + run() + return + } + rerun = false + }) + } + + return { + run, + dispose: () => { + disposed = true + rerun = false + } + } +} diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index e0b15bb9a..f39d9272e 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -6,6 +6,7 @@ import { isGitRepoKind } from '../../../../shared/repo-kind' import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client' import { refreshGitStatusForWorktree } from './git-status-refresh' +import { createCoalescedPollRunner } from './coalesced-poll-runner' const POLL_INTERVAL_MS = 3000 @@ -164,15 +165,20 @@ export function useGitStatusPolling(): void { } } - void pollStale() + // 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) + pollRunner.run() const intervalId = setInterval(() => { if (document.hasFocus()) { - void pollStale() + pollRunner.run() } }, POLL_INTERVAL_MS) - const onFocus = (): void => void pollStale() + const onFocus = (): void => pollRunner.run() window.addEventListener('focus', onFocus) return () => { + pollRunner.dispose() clearInterval(intervalId) window.removeEventListener('focus', onFocus) }