From c2eb0ed099cf108ccf3b7a9b79bcf9385f4c91b7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:47:06 -0700 Subject: [PATCH] Add cancellable, generation-aware git status polling to cut stale scans - Route git.status through an abortable subscription per requestToken so cancelStatus can actually abort the remote scan instead of being a no-op, preventing wasted work and stale responses overwriting fresher state. - Bump the git status polling generation on push-target changes so an in-flight refresh against the old remote/branch can't apply stale upstream data to the new one. - Guard the stale-conflict poller against writes after unmount. - Retire pre-purge line-stat scans in the cache so an older in-flight scan can't repopulate a key after a token-scoped purge. --- .../stale-conflict-operation-poll.ts | 8 +++ .../right-sidebar/useGitStatusPolling.ts | 5 +- src/renderer/src/web/web-preload-api.ts | 51 +++++++++++++++++-- src/shared/git-status-line-stats-cache.ts | 4 ++ 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts b/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts index 08a925961fe..eaf61d70be7 100644 --- a/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts +++ b/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts @@ -62,6 +62,10 @@ export function useStaleConflictOperationPolling(args: { return } + // Why: dispose() cannot interrupt an in-flight probe; without this guard a + // request resolving after cleanup would overwrite newer conflict state. + let active = true + const pollStale = async (): Promise => { // Why: a backoff-deferred run can fire long after the window hides; skip // the probe instead of running SSH/RPC work nobody can see. The @@ -83,6 +87,9 @@ export function useStaleConflictOperationPolling(args: { worktreePath: path, connectionId })) as GitConflictOperation + if (!active) { + return + } setConflictOperation(id, op) } catch { // ignore — worktree may have been removed @@ -106,6 +113,7 @@ export function useStaleConflictOperationPolling(args: { intervalMs: CONFLICT_POLL_INTERVAL_MS }) return () => { + active = false pollRunner.dispose() stopVisiblePoll() } diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 37e8f8f63c5..8511d796630 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -190,7 +190,10 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { statusSchedulerRef.current = null } } - }, [activeExecutionHostId, activeWorktreeId, worktreePath]) + // Why: push-target changes must bump the generation so an in-flight refresh + // captured against the old remote/branch can't apply or cache stale upstream + // status for the new one. + }, [activeExecutionHostId, activePushTarget, activeWorktreeId, worktreePath]) useEffect(() => { const reconcile = (catchUp: boolean): void => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index b7e541eaa2d..abd8534c7a1 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -66,6 +66,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector' +import { callAbortableRuntimeEnvironment } from '../runtime/abortable-runtime-environment-call' import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' import { normalizeTuiAgentArgsRecord, @@ -1642,23 +1643,65 @@ function createFileApi(): NonNullable['fs']> { } } +// Why: track the in-flight abortable status request per token so cancelStatus +// can abort the matching subscription and close its remote request context. +const webGitStatusAbortControllers = new Map() + +async function callAbortableRuntimeStatus( + requestToken: string, + params: unknown +): Promise { + const environment = requireActiveEnvironment() + webGitStatusAbortControllers.get(requestToken)?.abort() + const controller = new AbortController() + webGitStatusAbortControllers.set(requestToken, controller) + try { + const response = await callAbortableRuntimeEnvironment( + environment.id, + 'git.status', + params, + undefined, + controller.signal + ) + updateEnvironmentFromResponse(environment, response) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult + } finally { + if (webGitStatusAbortControllers.get(requestToken) === controller) { + webGitStatusAbortControllers.delete(requestToken) + } + } +} + function createGitApi(): NonNullable['git']> { return { status: async ({ worktreePath, includeIgnored, bypassEffectiveUpstreamNegativeCache, - reuseLineStats + reuseLineStats, + requestToken }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.status', { + const params = { worktree: toRuntimeWorktreeSelector(worktree.id), includeIgnored, bypassEffectiveUpstreamNegativeCache, reuseLineStats - }) + } + // Why: without a token there's nothing to cancel, so stay on the pooled + // call transport. With one, route through the subscription bridge so + // cancelStatus can close the request context and abort the remote scan. + if (!requestToken) { + return callRuntimeResult('git.status', params) + } + return callAbortableRuntimeStatus(requestToken, params) + }, + cancelStatus: async ({ requestToken }) => { + webGitStatusAbortControllers.get(requestToken)?.abort() }, - cancelStatus: async () => {}, submoduleStatus: async ({ worktreePath, submodulePath, area }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) return callRuntimeResult('git.submoduleStatus', { diff --git a/src/shared/git-status-line-stats-cache.ts b/src/shared/git-status-line-stats-cache.ts index aa08f52bf77..9a3ccf59356 100644 --- a/src/shared/git-status-line-stats-cache.ts +++ b/src/shared/git-status-line-stats-cache.ts @@ -247,6 +247,10 @@ export function clearGitStatusLineStatsCacheKey( cacheKey, (keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0) + 1 ) + } else { + // Why: a token-scoped purge must retire scans that began before it, so an + // older in-flight scan can't store pre-purge counts and repopulate this key. + bumpBoundedKeyMap(lastStoredBeginSeqByWorktree, cacheKey, writeToken.beginSeq) } lineStatsByWorktree.delete(cacheKey) }