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.
This commit is contained in:
Jinjing
2026-07-15 18:47:06 -07:00
parent 2f4071de49
commit c2eb0ed099
4 changed files with 63 additions and 5 deletions
@@ -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<void> => {
// 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()
}
@@ -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 => {
+47 -4
View File
@@ -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<Partial<PreloadApi>['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<string, AbortController>()
async function callAbortableRuntimeStatus<TResult>(
requestToken: string,
params: unknown
): Promise<TResult> {
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<Partial<PreloadApi>['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', {
@@ -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)
}