feat(git-status): batch, cancel, and cache git status polling to cut idl

- Add a single duty-cycled refresh scheduler (activity debounce + 60s
  safety timer) replacing multiple overlapping intervals, so status
  polling no longer runs near-continuously on large repos (#7983).
- Let safety refreshes reuse cached numstat line counts instead of
  re-running diff --numstat every cycle, invalidated by head change,
  known mutations, and a bounded TTL.
- Thread AbortSignal/request-token cancellation through IPC, RPC, and
  relay layers so a superseded or backgrounded git:status call is
  killed instead of finishing wastefully.
- Fix automatic upstream/status apply ordering so a slow, older
  refresh can no longer clobber a newer result, and so an earlier
  refresh still applies when a later one fails.
This commit is contained in:
Jinjing
2026-07-15 17:33:17 -07:00
parent 0ab2e001d1
commit e7222a560b
38 changed files with 2378 additions and 420 deletions
+110
View File
@@ -1414,6 +1414,116 @@ describe('getStatus', () => {
])
})
it('reuses unchanged line stats only when the safety hint is present', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args.includes('status')) {
return Promise.resolve({
stdout:
'# branch.oid head-1\n' + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n'
})
}
if (args.includes('--numstat')) {
return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' })
}
return Promise.resolve({ stdout: '' })
})
await getStatus('/repo')
const reused = await getStatus('/repo', { reuseLineStats: true })
await getStatus('/repo')
expect(reused.entries).toEqual([
{ path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 }
])
expect(
gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat'))
).toHaveLength(2)
})
it('recomputes after a scan whose numstat failed instead of pinning missing counts', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
let failNumstat = true
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args.includes('status')) {
return Promise.resolve({
stdout:
'# branch.oid head-1\n' + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n'
})
}
if (args.includes('--numstat')) {
if (failNumstat) {
failNumstat = false
return Promise.reject(new Error('transient index.lock'))
}
return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' })
}
return Promise.resolve({ stdout: '' })
})
const failed = await getStatus('/repo')
const reused = await getStatus('/repo', { reuseLineStats: true })
expect(failed.entries[0]?.added).toBeUndefined()
expect(reused.entries).toEqual([
{ path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 }
])
})
it('invalidates safety reuse for a new head and for known mutations', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
let head = 'head-1'
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args.includes('status')) {
return Promise.resolve({
stdout:
`# branch.oid ${head}\n` + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n'
})
}
if (args.includes('--numstat')) {
return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' })
}
return Promise.resolve({ stdout: '' })
})
await getStatus('/repo')
head = 'head-2'
await getStatus('/repo', { reuseLineStats: true })
await stageFile('/repo', 'src/unstaged.ts')
await getStatus('/repo', { reuseLineStats: true })
expect(
gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat'))
).toHaveLength(3)
})
it('isolates line-stat reuse between WSL distributions', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args.includes('status')) {
return Promise.resolve({
stdout: '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n'
})
}
if (args.includes('--numstat')) {
return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' })
}
return Promise.resolve({ stdout: '' })
})
await getStatus('/repo', { wslDistro: 'ubuntu' })
await getStatus('/repo', { wslDistro: 'debian', reuseLineStats: true })
await getStatus('/repo', { wslDistro: 'ubuntu', reuseLineStats: true })
expect(
gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat'))
).toHaveLength(2)
})
it('attaches numstat counts for literal paths containing rename markers', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
+60 -13
View File
@@ -51,6 +51,12 @@ import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output'
import {
beginGitStatusLineStatsCacheWrite,
clearGitStatusLineStatsCache,
clearGitStatusLineStatsCacheKey,
reuseOrRecomputeGitStatusLineStats
} from '../../shared/git-status-line-stats-cache'
const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES
@@ -97,6 +103,7 @@ const statusReadsInFlight = new Map<string, Promise<GitStatusResult>>()
export function invalidateGitReadCaches(): void {
gitDiffReadDedupe.clear()
statusReadsInFlight.clear()
clearGitStatusLineStatsCache()
clearSubmodulePathsCache()
resolvedUpstreamNameCache.clear()
}
@@ -195,6 +202,7 @@ export function getEffectiveUpstreamStatusGenerationCountForTests(): number {
export type GetStatusOptions = GitRuntimeOptions & {
includeIgnored?: boolean
reuseLineStats?: boolean
/**
* Max changed-file entries before git is stopped and the result is marked
* `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap.
@@ -243,6 +251,7 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri
worktreePath,
options.wslDistro ?? '',
options.includeIgnored === true,
options.reuseLineStats === true,
options.bypassEffectiveUpstreamNegativeCache === true,
limit
].join('\0')
@@ -252,6 +261,8 @@ async function runGetStatus(
worktreePath: string,
options: GetStatusOptions = {}
): Promise<GitStatusResult> {
const lineStatsCacheKey = getStatusLineStatsCacheKey(worktreePath, options)
const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey)
let effectiveUpstreamStatus: GitUpstreamStatus | undefined
let statusSucceeded = false
// Why: a negative/fractional/NaN limit would trigger spurious early-stop or
@@ -303,7 +314,10 @@ async function runGetStatus(
}
didHitLimit = stoppedEarly
statusSucceeded = true
} catch {
} catch (error) {
if (options.signal?.aborted) {
throw error
}
// Not a git repo or git not available
}
@@ -334,11 +348,17 @@ async function runGetStatus(
options
)
try {
// Why: the probe promise and its name/negative caches are shared by
// concurrent status reads, so one caller's abort must not reject the
// shared probe or evict warm cache state for the others. The probe is
// small and its cached result stays useful, so run it unbound from
// this request's signal.
const { signal: _requestSignal, ...sharedProbeOptions } = options
effectiveUpstreamStatus = await readOrProbeEffectiveUpstreamStatus(
cacheKey,
worktreePath,
branchName,
options,
sharedProbeOptions,
options.bypassEffectiveUpstreamNegativeCache === true
)
} catch {
@@ -355,7 +375,17 @@ async function runGetStatus(
// running numstat over a huge change set would reintroduce the cost the limit
// exists to avoid, matching how a "huge" repo disables extra git features.
if (!didHitLimit) {
await attachLineStats(worktreePath, entries, options)
await reuseOrRecomputeGitStatusLineStats({
cacheKey: lineStatsCacheKey,
head,
entries,
writeToken: lineStatsWriteToken,
reuse: options.reuseLineStats === true,
isAborted: () => options.signal?.aborted === true,
recompute: () => attachLineStats(worktreePath, entries, options)
})
} else {
clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken)
}
return {
@@ -382,6 +412,12 @@ async function runGetStatus(
}
}
function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string {
// Why: identical path strings can address different Linux filesystems in
// different WSL distros, so derived stats must follow Git's execution host.
return `${options.wslDistro ?? 'native'}\0${worktreePath}`
}
/**
* Resolve a submodule's own worktree path from a parent worktree + relative
* submodule path, rejecting anything that escapes the parent. Shared by the
@@ -505,7 +541,7 @@ async function runNumstat(
worktreePath: string,
cached: boolean,
options: GitRuntimeOptions = {}
): Promise<Map<string, GitLineStats>> {
): Promise<Map<string, GitLineStats> | null> {
try {
const { stdout } = await gitExecFileAsync(
[
@@ -520,20 +556,25 @@ async function runNumstat(
{ ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() }
)
return parseNumstat(stdout)
} catch {
} catch (error) {
if (options.signal?.aborted) {
throw error
}
// Why: a numstat failure (e.g. transient lock) should leave rows without
// counts rather than break the whole status refresh.
return new Map()
// counts rather than break the whole status refresh. Null (vs an empty
// map) tells the caller the pass is incomplete and must not be cached.
return null
}
}
/** Returns false when a numstat pass failed, so callers skip caching it. */
async function attachLineStats(
worktreePath: string,
entries: GitStatusEntry[],
options: GitRuntimeOptions = {}
): Promise<void> {
): Promise<boolean> {
if (entries.length === 0) {
return
return true
}
const hasStaged = entries.some((entry) => entry.area === 'staged')
const hasUnstaged = entries.some((entry) => entry.area === 'unstaged')
@@ -544,18 +585,19 @@ async function attachLineStats(
const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([
hasStaged ? runNumstat(worktreePath, true, options) : Promise.resolve(emptyStats),
hasUnstaged ? runNumstat(worktreePath, false, options) : Promise.resolve(emptyStats),
collectUntrackedAdditions(worktreePath, untrackedPaths)
collectUntrackedAdditions(worktreePath, untrackedPaths, options.signal)
])
for (const entry of entries) {
applyLineStats(
entry,
entry.area === 'staged'
? stagedStats.get(entry.path)
? (stagedStats ?? emptyStats).get(entry.path)
: entry.area === 'unstaged'
? unstagedStats.get(entry.path)
? (unstagedStats ?? emptyStats).get(entry.path)
: untrackedStats.get(entry.path)
)
}
return stagedStats !== null && unstagedStats !== null
}
function getShortBranchName(branch: string | undefined): string | null {
@@ -753,7 +795,12 @@ async function probeOrRevalidateEffectiveUpstreamStatus(
cached.upstreamName
)
return { status, probedSameNameOriginRef: false }
} catch {
} catch (error) {
// Why: an aborted probe says nothing about the ref; evicting the warm
// name cache here would force a pointless full re-resolve next scan.
if (options.signal?.aborted) {
throw error
}
// Ref deleted or repo state changed — fall through to a full re-resolve.
resolvedUpstreamNameCache.delete(cacheKey)
}
+72 -3
View File
@@ -1148,6 +1148,72 @@ describe('registerFilesystemHandlers', () => {
})
})
it('forwards line-stat reuse through local and SSH git status IPC', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
const sshProvider = {
getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
}
getSshGitProviderMock.mockReturnValue(sshProvider)
registerFilesystemHandlers(store as never)
await handlers.get('git:status')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
reuseLineStats: true
})
await handlers.get('git:status')!(null, {
worktreePath: '/remote/repo',
connectionId: 'ssh-1',
reuseLineStats: true
})
expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
includeIgnored: false,
reuseLineStats: true
})
expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', {
includeIgnored: false,
reuseLineStats: true
})
})
it('aborts tokenized local status without crossing renderer boundaries', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
const statusSignals: AbortSignal[] = []
getStatusMock.mockImplementation(
(_worktreePath: string, options: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
if (options.signal) {
statusSignals.push(options.signal)
options.signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true
})
}
})
)
registerFilesystemHandlers(store as never)
const firstEvent = { sender: { id: 7 } }
const secondEvent = { sender: { id: 8 } }
const firstRequest = handlers.get('git:status')!(firstEvent, {
worktreePath: WORKTREE_FEATURE_PATH,
requestToken: 'status-1'
}) as Promise<unknown>
const secondRequest = handlers.get('git:status')!(secondEvent, {
worktreePath: WORKTREE_FEATURE_PATH,
requestToken: 'status-1'
}) as Promise<unknown>
await vi.waitFor(() => expect(statusSignals).toHaveLength(2))
await handlers.get('git:cancelStatus')!(firstEvent, { requestToken: 'status-1' })
expect(statusSignals[0]?.aborted).toBe(true)
expect(statusSignals[1]?.aborted).toBe(false)
await expect(firstRequest).rejects.toThrow('aborted')
await handlers.get('git:cancelStatus')!(secondEvent, { requestToken: 'status-1' })
await expect(secondRequest).rejects.toThrow('aborted')
})
it('checks ignored paths through local and SSH git providers', async () => {
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js'])
@@ -2364,20 +2430,23 @@ describe('registerFilesystemHandlers', () => {
registerFilesystemHandlers(store as never)
const pending = handlers.get('fs:listFiles')!(null, {
// Why: cancellation keys are scoped to the issuing webContents, so the
// cancel must come from the same sender as the listing request.
const senderEvent = { sender: { id: 7 } }
const pending = handlers.get('fs:listFiles')!(senderEvent, {
rootPath: '/home/user/repo',
connectionId: 'conn-1',
requestToken: 'token-1'
}) as Promise<string[]>
expect(capturedSignal?.aborted).toBe(false)
await handlers.get('fs:cancelListFiles')!(null, { requestToken: 'token-1' })
await handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'token-1' })
expect(capturedSignal?.aborted).toBe(true)
await expect(pending).rejects.toThrow('listing cancelled')
// Unknown or already-settled tokens are a no-op, not an error.
expect(() =>
handlers.get('fs:cancelListFiles')!(null, { requestToken: 'unknown' })
handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'unknown' })
).not.toThrow()
})
})
+37 -26
View File
@@ -122,6 +122,7 @@ import { getRuntimePathBasename } from '../../shared/cross-platform-path'
import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import { registerLocalLogTailHandlers } from './local-log-tail'
import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader'
import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation'
// Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes
// ordinary JSON/log files inaccessible before the editor can degrade features.
@@ -1072,11 +1073,11 @@ export function registerFilesystemHandlers(
// Why #7721: keyed by renderer-generated token so a workspace switch can
// abort the previous workspace's full-tree scan (SSH relays otherwise stack
// scans that starve interactive fs.readDir/fs.stat past their 30s timeout).
const listFilesCancellations = new Map<string, AbortController>()
const listFilesCancellations = createSenderScopedRequestCancellations()
ipcMain.handle(
'fs:listFiles',
async (
_event,
event,
args: {
rootPath: string
connectionId?: string
@@ -1084,10 +1085,7 @@ export function registerFilesystemHandlers(
requestToken?: string
}
): Promise<string[]> => {
const controller = args.requestToken ? new AbortController() : null
if (controller && args.requestToken) {
listFilesCancellations.set(args.requestToken, controller)
}
const controller = listFilesCancellations.begin(event, args.requestToken)
try {
if (args.connectionId) {
const provider = getSshFilesystemProvider(args.connectionId)
@@ -1108,53 +1106,66 @@ export function registerFilesystemHandlers(
}
return await listQuickOpenFiles(args.rootPath, store, args.excludePaths, controller?.signal)
} finally {
if (args.requestToken) {
listFilesCancellations.delete(args.requestToken)
}
listFilesCancellations.finish(event, args.requestToken, controller)
}
}
)
ipcMain.handle('fs:cancelListFiles', (_event, args: { requestToken: string }): void => {
// Why: best-effort — the entry is gone once the listing settles.
listFilesCancellations.get(args.requestToken)?.abort()
ipcMain.handle('fs:cancelListFiles', (event, args: { requestToken: string }): void => {
listFilesCancellations.cancel(event, args.requestToken)
})
// ─── Git operations ─────────────────────────────────────
const gitStatusCancellations = createSenderScopedRequestCancellations()
ipcMain.handle(
'git:status',
async (
_event,
event,
args: {
worktreePath: string
connectionId?: string
includeIgnored?: boolean
bypassEffectiveUpstreamNegativeCache?: boolean
reuseLineStats?: boolean
requestToken?: string
}
): Promise<GitStatusResult> => {
const controller = gitStatusCancellations.begin(event, args.requestToken)
const options = {
includeIgnored: args.includeIgnored ?? false,
...(args.reuseLineStats === true ? { reuseLineStats: true } : {}),
...(args.bypassEffectiveUpstreamNegativeCache === true
? { bypassEffectiveUpstreamNegativeCache: true }
: {})
: {}),
...(controller ? { signal: controller.signal } : {})
}
if (args.connectionId) {
const provider = getSshGitProvider(args.connectionId)
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
try {
if (args.connectionId) {
const provider = getSshGitProvider(args.connectionId)
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
// Why: awaiting here keeps the cancellation token registered until
// the remote request settles instead of running finally immediately.
return await provider.getStatus(args.worktreePath, options)
}
return provider.getStatus(args.worktreePath, options)
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
args.worktreePath,
worktreePath
)
return await getStatus(worktreePath, { ...options, ...gitOptions })
} finally {
gitStatusCancellations.finish(event, args.requestToken, controller)
}
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
args.worktreePath,
worktreePath
)
return getStatus(worktreePath, { ...options, ...gitOptions })
}
)
ipcMain.handle('git:cancelStatus', (event, args: { requestToken: string }): void => {
gitStatusCancellations.cancel(event, args.requestToken)
})
// Why: the parent status only reports one gitlink row per submodule. When the
// user expands a dirty submodule, this fetches the inner per-file changes by
// running a plain status inside the submodule's own worktree (read-only).
@@ -0,0 +1,50 @@
import type { IpcMainInvokeEvent } from 'electron'
export type SenderScopedRequestCancellations = {
/** Registers a cancellable request; aborts any previous request that reused the token. */
begin: (event: IpcMainInvokeEvent, requestToken: string | undefined) => AbortController | null
/** Removes the registration once the request settles (no-op if it was replaced). */
finish: (
event: IpcMainInvokeEvent,
requestToken: string | undefined,
controller: AbortController | null
) => void
/** Best-effort abort from the issuing webContents; a settled request is gone. */
cancel: (event: IpcMainInvokeEvent, requestToken: string) => void
}
/**
* Registry for renderer-cancellable IPC requests. Keys are scoped to the
* issuing webContents so one window's token can never cancel another window's
* request, and reusing a token aborts the previous request before the new one
* registers.
*/
export function createSenderScopedRequestCancellations(): SenderScopedRequestCancellations {
const controllers = new Map<string, AbortController>()
const keyFor = (event: IpcMainInvokeEvent, requestToken: string): string =>
`${event.sender.id}\0${requestToken}`
return {
begin: (event, requestToken) => {
if (!requestToken) {
return null
}
const key = keyFor(event, requestToken)
controllers.get(key)?.abort()
const controller = new AbortController()
controllers.set(key, controller)
return controller
},
finish: (event, requestToken, controller) => {
if (!requestToken || !controller) {
return
}
const key = keyFor(event, requestToken)
if (controllers.get(key) === controller) {
controllers.delete(key)
}
},
cancel: (event, requestToken) => {
controllers.get(keyFor(event, requestToken))?.abort()
}
}
}
@@ -97,6 +97,22 @@ describe('SshGitProvider', () => {
})
})
it('getStatus forwards line-stat reuse and cancellation to the relay', async () => {
const controller = new AbortController()
mux.request.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
await provider.getStatus('/home/user/repo', {
reuseLineStats: true,
signal: controller.signal
})
expect(mux.request).toHaveBeenCalledWith(
'git.status',
{ worktreePath: '/home/user/repo', reuseLineStats: true },
{ signal: controller.signal }
)
})
it('getSubmoduleStatus sends git.submoduleStatus request', async () => {
const statusResult = { entries: [], conflictOperation: 'unknown' }
mux.request.mockResolvedValue(statusResult)
+3 -1
View File
@@ -106,10 +106,12 @@ export class SshGitProvider implements IGitProvider {
const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache
? { bypassEffectiveUpstreamNegativeCache: true }
: {}
const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {}
const request = {
worktreePath,
...includeIgnoredArgs,
...upstreamCacheBypassArgs
...upstreamCacheBypassArgs,
...lineStatsReuseArgs
}
return (await (options?.signal
? this.mux.request('git.status', request, { signal: options.signal })
+1
View File
@@ -306,6 +306,7 @@ export type TerminalArtifactAccessOptions = {
export type GitProviderStatusOptions = {
includeIgnored?: boolean
bypassEffectiveUpstreamNegativeCache?: boolean
reuseLineStats?: boolean
signal?: AbortSignal
}
+2 -1
View File
@@ -9,7 +9,8 @@ export const WorktreeSelector = z.object({
export const GitStatusParams = WorktreeSelector.extend({
includeIgnored: z.boolean().optional(),
bypassEffectiveUpstreamNegativeCache: z.boolean().optional()
bypassEffectiveUpstreamNegativeCache: z.boolean().optional(),
reuseLineStats: z.boolean().optional()
})
export const GitCheckIgnored = WorktreeSelector.extend({
+22
View File
@@ -80,6 +80,28 @@ describe('git RPC methods', () => {
})
})
it('forwards line-stat reuse and request cancellation for status requests', async () => {
const controller = new AbortController()
const runtime = {
getRuntimeId: () => 'test-runtime',
getRuntimeGitStatus: vi.fn().mockResolvedValue({
entries: [],
conflictOperation: 'unknown'
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
await dispatcher.dispatch(
makeRequest('git.status', { worktree: 'id:wt-1', reuseLineStats: true }),
{ signal: controller.signal }
)
expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', {
reuseLineStats: true,
signal: controller.signal
})
})
it('returns ignored paths for selected explorer rows', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+7 -3
View File
@@ -88,10 +88,12 @@ export const GIT_METHODS: RpcMethod[] = [
defineMethod({
name: 'git.status',
params: GitStatusParams,
handler: async (params, { runtime }) => {
handler: async (params, { runtime, signal }) => {
const options =
params.includeIgnored === undefined &&
params.bypassEffectiveUpstreamNegativeCache === undefined
params.bypassEffectiveUpstreamNegativeCache === undefined &&
params.reuseLineStats === undefined &&
signal === undefined
? undefined
: {
...(params.includeIgnored === undefined
@@ -99,7 +101,9 @@ export const GIT_METHODS: RpcMethod[] = [
: { includeIgnored: params.includeIgnored }),
...(params.bypassEffectiveUpstreamNegativeCache === true
? { bypassEffectiveUpstreamNegativeCache: true }
: {})
: {}),
...(params.reuseLineStats === true ? { reuseLineStats: true } : {}),
...(signal ? { signal } : {})
}
return options === undefined
? runtime.getRuntimeGitStatus(params.worktree)
+3
View File
@@ -2512,7 +2512,10 @@ export type PreloadApi = {
connectionId?: string
includeIgnored?: boolean
bypassEffectiveUpstreamNegativeCache?: boolean
reuseLineStats?: boolean
requestToken?: string
}) => Promise<GitStatusResult>
cancelStatus: (args: { requestToken: string }) => Promise<void>
submoduleStatus: (args: {
worktreePath: string
submodulePath: string
+4
View File
@@ -2943,7 +2943,11 @@ const api = {
connectionId?: string
includeIgnored?: boolean
bypassEffectiveUpstreamNegativeCache?: boolean
reuseLineStats?: boolean
requestToken?: string
}): Promise<unknown> => ipcRenderer.invoke('git:status', args),
cancelStatus: (args: { requestToken: string }): Promise<void> =>
ipcRenderer.invoke('git:cancelStatus', args),
submoduleStatus: (args: {
worktreePath: string
submodulePath: string
+7 -1
View File
@@ -16,7 +16,13 @@ import { readWorkingDiffFile } from './git-working-file-read'
export type GitExec = (
args: string[],
cwd: string,
opts?: { maxBuffer?: number; disableOptionalLocks?: boolean; stdin?: string; timeout?: number }
opts?: {
maxBuffer?: number
disableOptionalLocks?: boolean
signal?: AbortSignal
stdin?: string
timeout?: number
}
) => Promise<{ stdout: string; stderr: string }>
export type GitBufferExec = (args: string[], cwd: string) => Promise<Buffer>
+47
View File
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitExec } from './git-handler-ops'
import { getStatusOp } from './git-handler-status-ops'
import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache'
import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache'
const LARGE_STATUS_ENTRY_COUNT = 150_000
@@ -26,6 +27,7 @@ describe('getStatusOp', () => {
beforeEach(() => {
clearNoEffectiveUpstreamStatusCache()
clearGitStatusLineStatsCache()
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-status-'))
})
@@ -79,6 +81,51 @@ describe('getStatusOp', () => {
expect(result.entries).toHaveLength(5)
})
it('reuses unchanged line stats only for hinted safety reads', async () => {
const statusOutput = `${buildBranchStatusOutput('head-1', '(detached)')}\n1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts`
const git = vi.fn<GitExec>(async (args) => {
if (args.includes('status')) {
return { stdout: statusOutput, stderr: '' }
}
if (args.includes('diff')) {
return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' }
}
throw new Error(`Unexpected git command: ${args.join(' ')}`)
})
await getStatusOp(git, { worktreePath: tmpDir })
const reused = await getStatusOp(git, { worktreePath: tmpDir, reuseLineStats: true })
await getStatusOp(git, { worktreePath: tmpDir })
expect(reused.entries).toContainEqual(
expect.objectContaining({ path: 'src/a.ts', added: 3, removed: 2 })
)
expect(git.mock.calls.filter(([args]) => args.includes('diff'))).toHaveLength(2)
})
it('forwards the request abort signal to status and numstat subprocesses', async () => {
const controller = new AbortController()
const git = vi.fn<GitExec>(async (args) => {
if (args.includes('status')) {
return {
stdout: '1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts',
stderr: ''
}
}
if (args.includes('diff')) {
return { stdout: '1\t0\tsrc/a.ts\n', stderr: '' }
}
throw new Error(`Unexpected git command: ${args.join(' ')}`)
})
await getStatusOp(git, { worktreePath: tmpDir }, { signal: controller.signal })
expect(git.mock.calls).not.toHaveLength(0)
for (const [, , options] of git.mock.calls) {
expect(options?.signal).toBe(controller.signal)
}
})
it('caches no-effective-upstream probes across status polls for the same head', async () => {
const git = vi.fn<GitExec>(async (args) => {
if (args.includes('status')) {
+51 -18
View File
@@ -20,6 +20,11 @@ import {
type GitLineStats
} from '../shared/git-uncommitted-line-stats'
import { DEFAULT_GIT_STATUS_LIMIT } from '../shared/git-status-limit'
import {
beginGitStatusLineStatsCacheWrite,
clearGitStatusLineStatsCacheKey,
reuseOrRecomputeGitStatusLineStats
} from '../shared/git-status-line-stats-cache'
export async function resolveGitDir(worktreePath: string): Promise<string> {
const dotGitPath = path.join(worktreePath, '.git')
@@ -58,7 +63,8 @@ export async function detectConflictOperation(worktreePath: string): Promise<str
export async function getStatusOp(
git: GitExec,
params: Record<string, unknown>
params: Record<string, unknown>,
options: { signal?: AbortSignal } = {}
): Promise<{
entries: Record<string, unknown>[]
conflictOperation: string
@@ -70,6 +76,8 @@ export async function getStatusOp(
statusLength?: number
}> {
const worktreePath = params.worktreePath as string
const lineStatsCacheKey = `relay\0${worktreePath}`
const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey)
const includeIgnored = params.includeIgnored === true
// Why: reject non-finite/negative limits so the cap guard stays reliable
// (NaN would silently disable capping; negatives would over-truncate).
@@ -106,7 +114,8 @@ export async function getStatusOp(
const { stdout } = await git(statusArgs, worktreePath, {
// Why: status polling is read-like; avoid refreshing the index and racing
// terminal Git commands on `.git/worktrees/*/index.lock`.
disableOptionalLocks: true
disableOptionalLocks: true,
signal: options.signal
})
const parsed = parseStatusOutput(stdout)
head = parsed.head
@@ -134,6 +143,9 @@ export async function getStatusOp(
const branchName = getShortBranchName(branch)
if (branchName) {
try {
// Why: this probe coalesces across concurrent status reads, so one
// request's abort must not reject the shared in-flight promise for
// the others; the probe is small and its cached result stays useful.
upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus(
{ worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName },
(args) => git(args, worktreePath),
@@ -155,7 +167,10 @@ export async function getStatusOp(
}
}
}
} catch {
} catch (error) {
if (options.signal?.aborted) {
throw error
}
// not a git repo or git not available
}
@@ -165,7 +180,17 @@ export async function getStatusOp(
// running numstat over a huge change set would reintroduce the cost the limit
// exists to avoid.
if (!didHitLimit) {
await attachLineStats(git, worktreePath, entries)
await reuseOrRecomputeGitStatusLineStats({
cacheKey: lineStatsCacheKey,
head,
entries,
writeToken: lineStatsWriteToken,
reuse: params.reuseLineStats === true,
isAborted: () => options.signal?.aborted === true,
recompute: () => attachLineStats(git, worktreePath, entries, options.signal)
})
} else {
clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken)
}
return {
@@ -182,29 +207,36 @@ export async function getStatusOp(
async function runNumstat(
git: GitExec,
worktreePath: string,
cached: boolean
): Promise<Map<string, GitLineStats>> {
cached: boolean,
signal?: AbortSignal
): Promise<Map<string, GitLineStats> | null> {
try {
const { stdout } = await git(
['-c', 'core.quotePath=false', 'diff', ...(cached ? ['--cached'] : []), '--numstat', '-M'],
worktreePath,
{ disableOptionalLocks: true }
{ disableOptionalLocks: true, signal }
)
return parseNumstat(stdout)
} catch {
} catch (error) {
if (signal?.aborted) {
throw error
}
// Why: a numstat failure should leave rows without counts rather than break
// the whole status refresh.
return new Map()
// the whole status refresh. Null (vs an empty map) tells the caller the
// pass is incomplete and must not be cached.
return null
}
}
/** Returns false when a numstat pass failed, so callers skip caching it. */
async function attachLineStats(
git: GitExec,
worktreePath: string,
entries: Record<string, unknown>[]
): Promise<void> {
entries: Record<string, unknown>[],
signal?: AbortSignal
): Promise<boolean> {
if (entries.length === 0) {
return
return true
}
const hasStaged = entries.some((entry) => entry.area === 'staged')
const hasUnstaged = entries.some((entry) => entry.area === 'unstaged')
@@ -213,21 +245,22 @@ async function attachLineStats(
.map((entry) => entry.path as string)
const emptyStats = new Map<string, GitLineStats>()
const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([
hasStaged ? runNumstat(git, worktreePath, true) : Promise.resolve(emptyStats),
hasUnstaged ? runNumstat(git, worktreePath, false) : Promise.resolve(emptyStats),
collectUntrackedAdditions(worktreePath, untrackedPaths)
hasStaged ? runNumstat(git, worktreePath, true, signal) : Promise.resolve(emptyStats),
hasUnstaged ? runNumstat(git, worktreePath, false, signal) : Promise.resolve(emptyStats),
collectUntrackedAdditions(worktreePath, untrackedPaths, signal)
])
for (const entry of entries) {
const filePath = entry.path as string
applyLineStats(
entry as { added?: number; removed?: number },
entry.area === 'staged'
? stagedStats.get(filePath)
? (stagedStats ?? emptyStats).get(filePath)
: entry.area === 'unstaged'
? unstagedStats.get(filePath)
? (unstagedStats ?? emptyStats).get(filePath)
: untrackedStats.get(filePath)
)
}
return stagedStats !== null && unstagedStats !== null
}
function getShortBranchName(branch: string | undefined): string | null {
+5 -3
View File
@@ -76,6 +76,7 @@ import {
import { GitResponseStreamRegistry } from './git-response-stream'
import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol'
import { endSubprocessStdin } from '../shared/subprocess-stdin-write'
import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache'
const execFileAsync = promisify(execFile)
const MAX_GIT_BUFFER = 10 * 1024 * 1024
@@ -192,7 +193,7 @@ export class GitHandler {
}
private registerHandlers(): void {
this.dispatcher.onRequest('git.status', (p) => this.getStatus(p))
this.dispatcher.onRequest('git.status', (p, context) => this.getStatus(p, context))
this.dispatcher.onRequest('git.submoduleStatus', (p) => this.getSubmoduleStatus(p))
this.dispatcher.onRequest('git.checkIgnored', (p) => this.checkIgnored(p))
this.dispatcher.onRequest('git.history', (p) => this.history(p))
@@ -281,6 +282,7 @@ export class GitHandler {
private clearGitMutationReadCaches(): void {
this.gitDiffReadDedupe.clear()
clearGitStatusLineStatsCache()
clearSubmodulePathsCache(this.submodulePathsCache)
}
@@ -342,9 +344,9 @@ export class GitHandler {
return stdout
}
private async getStatus(params: Record<string, unknown>) {
private async getStatus(params: Record<string, unknown>, context: RequestContext) {
this.gitDiffReadDedupe.clear()
return getStatusOp(this.git.bind(this), params)
return getStatusOp(this.git.bind(this), params, { signal: context.signal })
}
// Why: the parent status only lists a single gitlink row per submodule. The
@@ -25,6 +25,17 @@ export type SlowTaskBackoffOptions = {
// 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.
// Shared with the git-status refresh scheduler so both pacing machines keep
// the same guarantee: sustained triggers can never run the task back-to-back.
export function slowTaskRequiredIdleMs(
lastRunDurationMs: number,
multiplier: number,
minIntervalMs: number,
maxIntervalMs: number
): number {
return Math.max(minIntervalMs, Math.min(lastRunDurationMs * multiplier, maxIntervalMs))
}
export function createCoalescedPollRunner(
task: () => Promise<void>,
options?: {
@@ -49,7 +60,12 @@ export function createCoalescedPollRunner(
const multiplier = trigger?.changeSignal
? backoff.changeSignalMultiplier
: backoff.idleMultiplier
return Math.max(minIntervalMs, Math.min(lastRunDurationMs * multiplier, backoff.maxIntervalMs))
return slowTaskRequiredIdleMs(
lastRunDurationMs,
multiplier,
minIntervalMs,
backoff.maxIntervalMs
)
}
const clearScheduledRun = (): void => {
@@ -0,0 +1,100 @@
const MAX_REFRESH_ORDERING_WORKTREES = 1024
const strictUpstreamRefreshGenerationByWorktree = new Map<string, number>()
const automaticRefreshGenerationByWorktree = new Map<string, number>()
// Why: automatic ordering is latest-APPLIED-wins, not latest-started-wins. A
// later-started refresh that fails (or never applies) must not veto an earlier
// refresh's good result, so the fence only advances when a result is applied.
const lastAppliedAutomaticGenerationByWorktree = new Map<string, number>()
const automaticUpstreamRefreshInFlightByWorktree = new Map<string, number>()
export type AutomaticRefreshOrder = {
strictGeneration: number
automaticGeneration: number
}
function trimRefreshOrderingState(): void {
for (const worktreeId of strictUpstreamRefreshGenerationByWorktree.keys()) {
if (strictUpstreamRefreshGenerationByWorktree.size <= MAX_REFRESH_ORDERING_WORKTREES) {
break
}
if (automaticUpstreamRefreshInFlightByWorktree.has(worktreeId)) {
continue
}
strictUpstreamRefreshGenerationByWorktree.delete(worktreeId)
}
}
export function beginAutomaticUpstreamRefresh(worktreeId: string): AutomaticRefreshOrder {
automaticUpstreamRefreshInFlightByWorktree.set(
worktreeId,
(automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0) + 1
)
const automaticGeneration = (automaticRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1
automaticRefreshGenerationByWorktree.set(worktreeId, automaticGeneration)
return {
strictGeneration: strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0,
automaticGeneration
}
}
export function finishAutomaticUpstreamRefresh(worktreeId: string): void {
const count = automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0
if (count <= 1) {
automaticUpstreamRefreshInFlightByWorktree.delete(worktreeId)
automaticRefreshGenerationByWorktree.delete(worktreeId)
lastAppliedAutomaticGenerationByWorktree.delete(worktreeId)
} else {
automaticUpstreamRefreshInFlightByWorktree.set(worktreeId, count - 1)
}
trimRefreshOrderingState()
}
export function shouldApplyAutomaticUpstreamRefresh(
worktreeId: string,
order: AutomaticRefreshOrder,
shouldApply?: () => boolean
): boolean {
return (
(strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) === order.strictGeneration &&
order.automaticGeneration >= (lastAppliedAutomaticGenerationByWorktree.get(worktreeId) ?? 0) &&
(shouldApply?.() ?? true)
)
}
/**
* Check-and-claim variant used immediately before applying state. On success
* it advances the applied fence so an older concurrent refresh that settles
* later cannot overwrite this refresh's newer result.
*/
export function claimAutomaticUpstreamRefreshApply(
worktreeId: string,
order: AutomaticRefreshOrder,
shouldApply?: () => boolean
): boolean {
if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, order, shouldApply)) {
return false
}
lastAppliedAutomaticGenerationByWorktree.set(
worktreeId,
Math.max(
lastAppliedAutomaticGenerationByWorktree.get(worktreeId) ?? 0,
order.automaticGeneration
)
)
return true
}
export function beginStrictUpstreamRefresh(worktreeId: string): void {
strictUpstreamRefreshGenerationByWorktree.set(
worktreeId,
(strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1
)
trimRefreshOrderingState()
}
export function clearGitStatusRefreshOrderingStateForTests(): void {
strictUpstreamRefreshGenerationByWorktree.clear()
automaticRefreshGenerationByWorktree.clear()
lastAppliedAutomaticGenerationByWorktree.clear()
automaticUpstreamRefreshInFlightByWorktree.clear()
}
@@ -0,0 +1,246 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createGitStatusRefreshScheduler,
type GitStatusRefreshReason
} from './git-status-refresh-scheduler'
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve = (): void => {}
const promise = new Promise<void>((settle) => {
resolve = settle
})
return { promise, resolve }
}
async function flushMicrotasks(): Promise<void> {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve()
}
}
function createScheduler(
task: (request: { reason: GitStatusRefreshReason; signal: AbortSignal }) => Promise<void>
) {
return createGitStatusRefreshScheduler(task, {
safetyIntervalMs: 60_000,
activityDebounceMs: 125,
activityMinGapMs: 3000,
slowTaskBackoff: {
idleMultiplier: 5,
changeSignalMultiplier: 1,
maxIntervalMs: 5 * 60_000
}
})
}
describe('createGitStatusRefreshScheduler', () => {
afterEach(() => {
vi.useRealTimers()
})
it('refreshes immediately on resume and uses one 60-second safety timeout', async () => {
vi.useFakeTimers()
const reasons: GitStatusRefreshReason[] = []
const task = vi.fn(async ({ reason }: { reason: GitStatusRefreshReason }) => {
reasons.push(reason)
})
const scheduler = createScheduler(task)
scheduler.resumeSafety()
expect(task).toHaveBeenCalledTimes(1)
await flushMicrotasks()
await vi.advanceTimersByTimeAsync(59_999)
expect(task).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(task).toHaveBeenCalledTimes(2)
expect(reasons).toEqual(['activity', 'safety'])
})
it('coalesces activity signals and restarts the safety horizon', async () => {
vi.useFakeTimers()
const task = vi.fn(async () => {})
const scheduler = createScheduler(task)
scheduler.resumeSafety()
await flushMicrotasks()
await vi.advanceTimersByTimeAsync(59_000)
scheduler.signal()
scheduler.signal()
scheduler.signal()
await vi.advanceTimersByTimeAsync(124)
expect(task).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(task).toHaveBeenCalledTimes(2)
await flushMicrotasks()
await vi.advanceTimersByTimeAsync(59_999)
expect(task).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(task).toHaveBeenCalledTimes(3)
})
it('serializes in-flight work and retains one trailing activity refresh', async () => {
vi.useFakeTimers()
const calls: ReturnType<typeof deferred>[] = []
let active = 0
let maxActive = 0
const task = vi.fn(() => {
const call = deferred()
calls.push(call)
active += 1
maxActive = Math.max(maxActive, active)
return call.promise.finally(() => {
active -= 1
})
})
const scheduler = createScheduler(task)
scheduler.resumeSafety()
scheduler.signal()
scheduler.signal()
scheduler.refreshNow()
expect(task).toHaveBeenCalledTimes(1)
calls[0]?.resolve()
await flushMicrotasks()
// Why: the trailing refresh respects the 3s activity floor from run end.
expect(task).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(3000)
expect(task).toHaveBeenCalledTimes(2)
expect(maxActive).toBe(1)
calls[1]?.resolve()
await flushMicrotasks()
await vi.advanceTimersByTimeAsync(60_000)
expect(task).toHaveBeenCalledTimes(3)
})
it('spaces sustained signal bursts by the activity floor instead of running back-to-back', async () => {
vi.useFakeTimers()
const calls: ReturnType<typeof deferred>[] = []
const task = vi.fn(() => {
const call = deferred()
calls.push(call)
return call.promise
})
const scheduler = createScheduler(task)
scheduler.resumeSafety()
scheduler.signal()
calls[0]?.resolve()
await flushMicrotasks()
expect(task).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(2999)
expect(task).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(task).toHaveBeenCalledTimes(2)
// A signal mid-flight plus immediate settle still waits out the floor.
scheduler.signal()
calls[1]?.resolve()
await flushMicrotasks()
expect(task).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(3000)
expect(task).toHaveBeenCalledTimes(3)
})
it('stretches activity and safety pacing after a slow scan', async () => {
vi.useFakeTimers()
const calls: ReturnType<typeof deferred>[] = []
const task = vi.fn(() => {
const call = deferred()
calls.push(call)
return call.promise
})
const scheduler = createScheduler(task)
scheduler.resumeSafety()
// The first scan takes 30 seconds; a signal arrives while it is running.
scheduler.signal()
await vi.advanceTimersByTimeAsync(30_000)
calls[0]?.resolve()
await flushMicrotasks()
expect(task).toHaveBeenCalledTimes(1)
// Trailing activity waits max(3s floor, 1x scan duration) = 30s.
await vi.advanceTimersByTimeAsync(29_999)
expect(task).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(task).toHaveBeenCalledTimes(2)
// The second scan is also slow; the next safety waits max(60s, 5x) = 150s.
await vi.advanceTimersByTimeAsync(30_000)
calls[1]?.resolve()
await flushMicrotasks()
await vi.advanceTimersByTimeAsync(149_999)
expect(task).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(task).toHaveBeenCalledTimes(3)
})
it('aborts on pause and catches up after the obsolete request settles', async () => {
vi.useFakeTimers()
const calls: ReturnType<typeof deferred>[] = []
const signals: AbortSignal[] = []
const task = vi.fn(({ signal }: { signal: AbortSignal }) => {
const call = deferred()
calls.push(call)
signals.push(signal)
return call.promise
})
const scheduler = createScheduler(task)
scheduler.resumeSafety()
scheduler.pause()
expect(signals[0]?.aborted).toBe(true)
scheduler.resumeSafety()
expect(task).toHaveBeenCalledTimes(1)
calls[0]?.resolve()
await flushMicrotasks()
// Why: the reveal catch-up rides the paced activity lane (3s floor).
await vi.advanceTimersByTimeAsync(3000)
expect(task).toHaveBeenCalledTimes(2)
expect(signals[1]?.aborted).toBe(false)
calls[1]?.resolve()
await flushMicrotasks()
})
it('keeps huge-status signal mode active without a safety timeout', async () => {
vi.useFakeTimers()
const task = vi.fn(async () => {})
const scheduler = createScheduler(task)
scheduler.resumeSafety()
await flushMicrotasks()
scheduler.suspendSafety()
await vi.advanceTimersByTimeAsync(120_000)
expect(task).toHaveBeenCalledTimes(1)
scheduler.signal()
await vi.advanceTimersByTimeAsync(125)
expect(task).toHaveBeenCalledTimes(2)
await flushMicrotasks()
await vi.advanceTimersByTimeAsync(120_000)
expect(task).toHaveBeenCalledTimes(2)
})
it('cleans up debounce and safety timers on dispose', async () => {
vi.useFakeTimers()
const task = vi.fn(async () => {})
const scheduler = createScheduler(task)
scheduler.resumeSafety()
await flushMicrotasks()
scheduler.signal()
scheduler.dispose()
await vi.advanceTimersByTimeAsync(120_000)
expect(task).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,210 @@
import { slowTaskRequiredIdleMs, type SlowTaskBackoffOptions } from './coalesced-poll-runner'
export type GitStatusRefreshReason = 'activity' | 'safety'
export type GitStatusRefreshScheduler = {
resumeSafety: () => void
pause: () => void
suspendSafety: () => void
signal: () => void
refreshNow: () => void
dispose: () => void
}
type RefreshTask = (request: {
reason: GitStatusRefreshReason
signal: AbortSignal
}) => Promise<void>
export function createGitStatusRefreshScheduler(
task: RefreshTask,
options: {
safetyIntervalMs: number
activityDebounceMs: number
// Why: sustained signal streams (busy agent terminals, file churn) must
// never run status scans back-to-back (#7983). Activity runs keep this
// minimum idle gap since the previous run's end, scaled up by the slow
// task backoff when the previous scan itself was slow.
activityMinGapMs: number
slowTaskBackoff: SlowTaskBackoffOptions
}
): GitStatusRefreshScheduler {
let disposed = false
let safetyEnabled = false
let inFlight = false
let pendingActivity = false
let activityTimer: ReturnType<typeof setTimeout> | null = null
let activityTimerFiresAt = Infinity
let safetyTimer: ReturnType<typeof setTimeout> | null = null
let activeController: AbortController | null = null
let lastRunEndedAt = -Infinity
let lastRunDurationMs = 0
const clearActivityTimer = (): void => {
if (activityTimer !== null) {
clearTimeout(activityTimer)
activityTimer = null
activityTimerFiresAt = Infinity
}
}
const clearSafetyTimer = (): void => {
if (safetyTimer !== null) {
clearTimeout(safetyTimer)
safetyTimer = null
}
}
const requiredActivityIdleMs = (): number =>
slowTaskRequiredIdleMs(
lastRunDurationMs,
options.slowTaskBackoff.changeSignalMultiplier,
options.activityMinGapMs,
options.slowTaskBackoff.maxIntervalMs
)
const scheduleSafetyRun = (): void => {
// Why: slow scans stretch the safety horizon (idle-lane backoff) so a
// tens-of-seconds status scan never approaches a continuous git duty cycle.
const delay = Math.max(
options.safetyIntervalMs,
slowTaskRequiredIdleMs(
lastRunDurationMs,
options.slowTaskBackoff.idleMultiplier,
0,
options.slowTaskBackoff.maxIntervalMs
)
)
safetyTimer = setTimeout(() => {
safetyTimer = null
startRun('safety')
}, delay)
}
const scheduleActivityRun = (minDelayMs: number): void => {
if (disposed) {
return
}
if (inFlight) {
// Why: the trigger may describe a mutation after the running Git
// snapshot, so one trailing refresh is required for freshness.
pendingActivity = true
return
}
const now = Date.now()
const delay = Math.max(minDelayMs, lastRunEndedAt + requiredActivityIdleMs() - now)
if (delay <= 0) {
startRun('activity')
return
}
const firesAt = now + delay
if (activityTimer !== null) {
// Why: an earlier-eligible trigger may pull a pending run forward, but a
// later debounce window must never push an already-scheduled run back.
if (firesAt >= activityTimerFiresAt) {
return
}
clearActivityTimer()
}
activityTimerFiresAt = firesAt
activityTimer = setTimeout(() => {
activityTimer = null
activityTimerFiresAt = Infinity
startRun('activity')
}, delay)
}
const startRun = (reason: GitStatusRefreshReason): void => {
if (disposed || inFlight) {
return
}
clearActivityTimer()
clearSafetyTimer()
inFlight = true
const startedAt = Date.now()
const controller = new AbortController()
activeController = controller
let result: Promise<void>
try {
result = task({ reason, signal: controller.signal })
} catch (error) {
result = Promise.reject(error)
}
void result
.catch(() => {
// Status refresh errors are transient; the next signal or safety run retries.
})
.finally(() => {
lastRunEndedAt = Date.now()
lastRunDurationMs = Math.max(0, lastRunEndedAt - startedAt)
if (activeController === controller) {
activeController = null
}
inFlight = false
if (disposed) {
return
}
if (pendingActivity) {
pendingActivity = false
scheduleActivityRun(0)
return
}
if (safetyEnabled) {
scheduleSafetyRun()
}
})
}
return {
resumeSafety: () => {
if (disposed) {
return
}
const wasEnabled = safetyEnabled
safetyEnabled = true
if (wasEnabled) {
return
}
clearSafetyTimer()
if (inFlight) {
// Why: a request aborted while hidden cannot serve as reveal catch-up;
// retain one fresh run and wait for the old transport to settle.
pendingActivity ||= activeController?.signal.aborted === true
return
}
scheduleActivityRun(0)
},
pause: () => {
safetyEnabled = false
pendingActivity = false
clearActivityTimer()
clearSafetyTimer()
activeController?.abort()
},
suspendSafety: () => {
safetyEnabled = false
clearSafetyTimer()
},
signal: () => {
if (disposed) {
return
}
clearSafetyTimer()
scheduleActivityRun(options.activityDebounceMs)
},
refreshNow: () => {
if (disposed) {
return
}
clearSafetyTimer()
scheduleActivityRun(0)
},
dispose: () => {
disposed = true
safetyEnabled = false
pendingActivity = false
clearActivityTimer()
clearSafetyTimer()
activeController?.abort()
}
}
}
@@ -233,6 +233,97 @@ describe('refreshGitStatusForWorktree', () => {
expect(deps.setUpstreamStatus).toHaveBeenCalledWith('wt-race', strictStatus.upstreamStatus)
})
it('does not let an older automatic status overwrite the latest automatic result', async () => {
const olderStatus = deferred<GitStatusResult>()
const latestStatus: GitStatusResult = {
entries: [{ path: 'latest.ts', status: 'modified', area: 'unstaged' }],
conflictOperation: 'unknown',
head: 'latest'
}
const gitStatus = vi
.fn()
.mockReturnValueOnce(olderStatus.promise)
.mockResolvedValueOnce(latestStatus)
vi.stubGlobal('window', { api: { git: { status: gitStatus } } })
const deps = makeDeps()
const olderRefresh = refreshGitStatusForWorktree({
worktreeId: 'wt-automatic-race',
worktreePath: '/repo',
deps
})
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1))
await refreshGitStatusForWorktree({
worktreeId: 'wt-automatic-race',
worktreePath: '/repo',
deps
})
olderStatus.resolve({
entries: [{ path: 'older.ts', status: 'modified', area: 'unstaged' }],
conflictOperation: 'unknown',
head: 'older'
})
await olderRefresh
expect(deps.setGitStatus).toHaveBeenCalledTimes(1)
expect(deps.setGitStatus).toHaveBeenCalledWith('wt-automatic-race', latestStatus)
})
it('applies an earlier automatic result when a later automatic refresh fails', async () => {
const olderStatus = deferred<GitStatusResult>()
const gitStatus = vi
.fn()
.mockReturnValueOnce(olderStatus.promise)
.mockRejectedValueOnce(new Error('transient index.lock'))
vi.stubGlobal('window', { api: { git: { status: gitStatus } } })
const deps = makeDeps()
const olderRefresh = refreshGitStatusForWorktree({
worktreeId: 'wt-failed-veto',
worktreePath: '/repo',
deps
})
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1))
await expect(
refreshGitStatusForWorktree({
worktreeId: 'wt-failed-veto',
worktreePath: '/repo',
deps
})
).rejects.toThrow('transient index.lock')
const freshStatus: GitStatusResult = {
entries: [{ path: 'fresh.ts', status: 'modified', area: 'unstaged' }],
conflictOperation: 'unknown',
head: 'fresh'
}
olderStatus.resolve(freshStatus)
await olderRefresh
expect(deps.setGitStatus).toHaveBeenCalledWith('wt-failed-veto', freshStatus)
})
it('does not apply a status result after its liveness guard expires', async () => {
const status: GitStatusResult = {
entries: [],
conflictOperation: 'unknown',
head: 'stale'
}
vi.stubGlobal('window', { api: { git: { status: vi.fn().mockResolvedValue(status) } } })
const deps = makeDeps()
await refreshGitStatusForWorktree({
worktreeId: 'wt-stale',
worktreePath: '/repo',
deps,
request: { shouldApply: () => false }
})
expect(deps.setGitStatus).not.toHaveBeenCalled()
expect(deps.updateWorktreeGitIdentity).not.toHaveBeenCalled()
expect(deps.fetchUpstreamStatus).not.toHaveBeenCalled()
})
it('does not let an older automatic explicit upstream fetch overwrite a strict result', async () => {
const automaticFetch = deferred<GitUpstreamStatus | null>()
const strictStatus: GitStatusResult = {
@@ -11,6 +11,15 @@ import type {
GitUpstreamStatus,
GlobalSettings
} from '../../../../shared/types'
import {
beginAutomaticUpstreamRefresh,
beginStrictUpstreamRefresh,
claimAutomaticUpstreamRefreshApply,
clearGitStatusRefreshOrderingStateForTests,
finishAutomaticUpstreamRefresh,
shouldApplyAutomaticUpstreamRefresh,
type AutomaticRefreshOrder
} from './git-status-refresh-ordering'
export type GitStatusRefreshDeps = {
setGitStatus: (worktreeId: string, status: GitStatusResult) => void
@@ -31,44 +40,6 @@ export type GitStatusRefreshDeps = {
) => Promise<GitUpstreamStatus | null>
}
const MAX_REFRESH_ORDERING_WORKTREES = 1024
const strictUpstreamRefreshGenerationByWorktree = new Map<string, number>()
const automaticUpstreamRefreshInFlightByWorktree = new Map<string, number>()
function trimRefreshOrderingState(): void {
for (const worktreeId of strictUpstreamRefreshGenerationByWorktree.keys()) {
if (strictUpstreamRefreshGenerationByWorktree.size <= MAX_REFRESH_ORDERING_WORKTREES) {
break
}
if (automaticUpstreamRefreshInFlightByWorktree.has(worktreeId)) {
continue
}
strictUpstreamRefreshGenerationByWorktree.delete(worktreeId)
}
}
function beginAutomaticUpstreamRefresh(worktreeId: string): number {
automaticUpstreamRefreshInFlightByWorktree.set(
worktreeId,
(automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0) + 1
)
return strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0
}
function finishAutomaticUpstreamRefresh(worktreeId: string): void {
const count = automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0
if (count <= 1) {
automaticUpstreamRefreshInFlightByWorktree.delete(worktreeId)
} else {
automaticUpstreamRefreshInFlightByWorktree.set(worktreeId, count - 1)
}
trimRefreshOrderingState()
}
function shouldApplyAutomaticUpstreamRefresh(worktreeId: string, startGeneration: number): boolean {
return (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) === startGeneration
}
async function fetchAndApplyAutomaticUpstreamStatus({
settings,
worktreeId,
@@ -76,7 +47,8 @@ async function fetchAndApplyAutomaticUpstreamStatus({
connectionId,
pushTarget,
deps,
startGeneration
order,
shouldApply
}: {
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
worktreeId: string
@@ -84,8 +56,12 @@ async function fetchAndApplyAutomaticUpstreamStatus({
connectionId?: string
pushTarget?: GitPushTarget
deps: GitStatusRefreshDeps
startGeneration: number
order: AutomaticRefreshOrder
shouldApply?: () => boolean
}): Promise<GitUpstreamStatus | null> {
if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, order, shouldApply)) {
return null
}
const upstreamStatus = await deps.fetchUpstreamStatus(
worktreeId,
worktreePath,
@@ -110,24 +86,15 @@ async function fetchAndApplyAutomaticUpstreamStatus({
}
return null
}
if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, startGeneration)) {
if (!claimAutomaticUpstreamRefreshApply(worktreeId, order, shouldApply)) {
return null
}
deps.setUpstreamStatus(worktreeId, upstreamStatus)
return upstreamStatus
}
function beginStrictUpstreamRefresh(worktreeId: string): void {
strictUpstreamRefreshGenerationByWorktree.set(
worktreeId,
(strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1
)
trimRefreshOrderingState()
}
export function clearGitStatusRefreshOrderingForTests(): void {
strictUpstreamRefreshGenerationByWorktree.clear()
automaticUpstreamRefreshInFlightByWorktree.clear()
clearGitStatusRefreshOrderingStateForTests()
clearAutomaticPushTargetUpstreamStatusCache()
}
@@ -137,7 +104,8 @@ export async function refreshGitStatusForWorktree({
worktreePath,
connectionId,
pushTarget,
deps
deps,
request
}: {
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
worktreeId: string
@@ -145,17 +113,30 @@ export async function refreshGitStatusForWorktree({
connectionId?: string
pushTarget?: GitPushTarget
deps: GitStatusRefreshDeps
request?: {
reuseLineStats?: boolean
signal?: AbortSignal
shouldApply?: () => boolean
}
}): Promise<void> {
const upstreamStartGeneration = beginAutomaticUpstreamRefresh(worktreeId)
const refreshOrder = beginAutomaticUpstreamRefresh(worktreeId)
try {
const status = (await getRuntimeGitStatus({
settings,
worktreeId,
worktreePath,
connectionId
})) as GitStatusResult
const status = (await getRuntimeGitStatus(
{
settings,
worktreeId,
worktreePath,
connectionId
},
request
? {
...(request.reuseLineStats === true ? { reuseLineStats: true } : {}),
...(request.signal ? { signal: request.signal } : {})
}
: undefined
)) as GitStatusResult
if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, upstreamStartGeneration)) {
if (!claimAutomaticUpstreamRefreshApply(worktreeId, refreshOrder, request?.shouldApply)) {
return
}
@@ -192,7 +173,8 @@ export async function refreshGitStatusForWorktree({
connectionId,
pushTarget,
deps,
startGeneration: upstreamStartGeneration
order: refreshOrder,
shouldApply: request?.shouldApply
})
if (upstreamStatus) {
// Why: explicit publish-target comparison can spawn several git
@@ -219,11 +201,14 @@ export async function refreshGitStatusForWorktree({
worktreePath,
connectionId,
deps,
startGeneration: upstreamStartGeneration
order: refreshOrder,
shouldApply: request?.shouldApply
})
return
}
deps.setUpstreamStatus(worktreeId, status.upstreamStatus)
if (claimAutomaticUpstreamRefreshApply(worktreeId, refreshOrder, request?.shouldApply)) {
deps.setUpstreamStatus(worktreeId, status.upstreamStatus)
}
return
}
await fetchAndApplyAutomaticUpstreamStatus({
@@ -233,7 +218,8 @@ export async function refreshGitStatusForWorktree({
connectionId,
pushTarget,
deps,
startGeneration: upstreamStartGeneration
order: refreshOrder,
shouldApply: request?.shouldApply
})
} finally {
finishAutomaticUpstreamRefresh(worktreeId)
@@ -0,0 +1,113 @@
import { useEffect, useMemo } from 'react'
import type { GitConflictOperation, Repo } from '../../../../shared/types'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { getConnectionId } from '@/lib/connection-context'
import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client'
import { createCoalescedPollRunner, type SlowTaskBackoffOptions } from './coalesced-poll-runner'
import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval'
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
const CONFLICT_POLL_INTERVAL_MS = 3000
/**
* Polls the conflict operation of non-active worktrees whose sidebar badge
* still shows merge/rebase/cherry-pick, so the badge clears when the operation
* finishes. This is a lightweight fs-only check (no git status), so it stays
* cheap even with many worktrees.
*/
export function useStaleConflictOperationPolling(args: {
enabled: boolean
activeWorktreeId: string | null
allWorktrees: { id: string; path: string; repoId: string }[]
repoMap: Map<string, Pick<Repo, 'kind'>>
conflictOperationByWorktree: Record<string, GitConflictOperation>
setConflictOperation: (worktreeId: string, operation: GitConflictOperation) => void
isConnectionReady: (connectionId: string | null | undefined) => boolean
slowTaskBackoff: SlowTaskBackoffOptions
}): void {
const {
enabled,
activeWorktreeId,
allWorktrees,
repoMap,
conflictOperationByWorktree,
setConflictOperation,
isConnectionReady,
slowTaskBackoff
} = args
// Why: only non-active worktrees with a known conflict operation need this
// probe — the full git status refresh already covers the active worktree.
const staleConflictWorktrees = useMemo(() => {
const result: { id: string; path: string }[] = []
for (const [worktreeId, op] of Object.entries(conflictOperationByWorktree)) {
if (worktreeId === activeWorktreeId || op === 'unknown') {
continue
}
const worktree = allWorktrees.find((entry) => entry.id === worktreeId)
if (worktree) {
const repo = repoMap.get(worktree.repoId)
if (repo && !isGitRepoKind(repo)) {
continue
}
result.push({ id: worktree.id, path: worktree.path })
}
}
return result
}, [allWorktrees, conflictOperationByWorktree, activeWorktreeId, repoMap])
useEffect(() => {
if (!enabled) {
return
}
if (staleConflictWorktrees.length === 0) {
return
}
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
// becoming-visible run catches up via the change-signal lane.
if (!isWindowVisible()) {
return
}
for (const { id, path } of staleConflictWorktrees) {
try {
const connectionId = getConnectionId(id) ?? undefined
// Why: after explicit SSH disconnect the provider is intentionally
// gone; keep remote polling quiet until the target reconnects.
if (!isConnectionReady(connectionId)) {
continue
}
const op = (await getRuntimeGitConflictOperation({
settings: getRightSidebarWorktreeRuntimeSettings(id),
worktreeId: id,
worktreePath: path,
connectionId
})) as GitConflictOperation
setConflictOperation(id, op)
} catch {
// ignore — worktree may have been removed
}
}
}
// Why: remote conflict probes can exceed the 3s interval. Keep one poll in
// 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
})
// 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: CONFLICT_POLL_INTERVAL_MS
})
return () => {
pollRunner.dispose()
stopVisiblePoll()
}
}, [enabled, staleConflictWorktrees, setConflictOperation, isConnectionReady, slowTaskBackoff])
}
@@ -10,6 +10,8 @@ import { makeOpenFile, makeWorktree, TEST_REPO } from '@/store/slices/store-test
import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event'
import { useGitStatusPolling } from './useGitStatusPolling'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
// Mock the refresh boundary so we count invocations precisely without
// triggering real IPC cascades (upstream probes, store mutations, etc.).
const refreshMock = vi.hoisted(() => vi.fn())
@@ -50,7 +52,7 @@ function HookProbe(): null {
return null
}
async function renderHook(): Promise<void> {
async function renderHook(): Promise<Root> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
@@ -58,6 +60,7 @@ async function renderHook(): Promise<void> {
await act(async () => {
root.render(createElement(HookProbe))
})
return root
}
async function flushMicrotasks(): Promise<void> {
@@ -99,7 +102,7 @@ describe('useGitStatusPolling rerender stability', () => {
vi.useRealTimers()
})
it('keeps the poll runner stable when openFiles changes mid-cooldown', async () => {
it('keeps the refresh scheduler stable when openFiles changes mid-signal debounce', async () => {
// Spy on addEventListener so we can guard that the file-watch listener
// registered before emitting — otherwise the test would prove nothing.
const addSpy = vi.spyOn(window, 'addEventListener')
@@ -111,10 +114,8 @@ describe('useGitStatusPolling rerender stability', () => {
// run() once at install time).
expect(refreshMock).toHaveBeenCalledTimes(1)
// Rerender: change openFiles in the store. This gives runFetchStatus a new
// identity (it lists openFiles in its useCallback deps). With the old
// useMemo([runFetchStatus]) the runner would be recreated here, resetting
// lastRunEndedAt to -Infinity and bypassing the cooldown.
// Rerender: change openFiles in the store while the file-watch/scheduler
// debounce is pending. The active-worktree scheduler must survive it.
await act(async () => {
useAppStore.setState({
openFiles: [makeOpenFile({ id: `${WORKTREE_PATH}/a.ts`, worktreeId: WORKTREE_ID })]
@@ -139,18 +140,15 @@ describe('useGitStatusPolling rerender stability', () => {
})
)
// Advance past the 125 ms file-watch debounce. fetchStatus is called, but
// the runner's cooldown (POLL_INTERVAL_MS = 3000 ms from first run end)
// should suppress the second refresh.
// Advance past the 125 ms file-watch debounce. The scheduler coalesces the
// signal and paces it behind the 3 s anti-churn floor from the mount run.
await vi.advanceTimersByTimeAsync(200)
expect(refreshMock).toHaveBeenCalledTimes(1)
// Mid-cooldown: still no second refresh.
await vi.advanceTimersByTimeAsync(1300)
await vi.advanceTimersByTimeAsync(2799)
expect(refreshMock).toHaveBeenCalledTimes(1)
// After the full 3 s cooldown the trailing refresh fires.
await vi.advanceTimersByTimeAsync(1500)
await vi.advanceTimersByTimeAsync(1)
await flushMicrotasks()
expect(refreshMock).toHaveBeenCalledTimes(2)
@@ -175,4 +173,123 @@ describe('useGitStatusPolling rerender stability', () => {
// without having to wait for the 3000ms timer.
expect(refreshMock).toHaveBeenCalledTimes(2)
})
it('refreshes immediately when Source Control becomes visible', async () => {
useAppStore.setState({ rightSidebarOpen: false })
await renderHook()
await flushMicrotasks()
expect(refreshMock).toHaveBeenCalledTimes(1)
// Let the 3 s anti-churn floor from the mount refresh elapse first.
await vi.advanceTimersByTimeAsync(3000)
await act(async () => {
useAppStore.setState({ rightSidebarOpen: true })
})
await flushMicrotasks()
expect(refreshMock).toHaveBeenCalledTimes(2)
})
it('refreshes immediately when the Files tab becomes visible', async () => {
useAppStore.setState({ rightSidebarOpen: false, rightSidebarTab: 'explorer' })
await renderHook()
await flushMicrotasks()
expect(refreshMock).toHaveBeenCalledTimes(1)
// Let the 3 s anti-churn floor from the mount refresh elapse first.
await vi.advanceTimersByTimeAsync(3000)
await act(async () => {
useAppStore.setState({ rightSidebarOpen: true })
})
await flushMicrotasks()
expect(refreshMock).toHaveBeenCalledTimes(2)
})
it('refreshes immediately when an SSH execution host reconnects', async () => {
useAppStore.setState({
repos: [{ ...repo, connectionId: 'ssh-1' }],
sshConnectionStates: new Map([
['ssh-1', { status: 'disconnected', error: null, reconnectAttempt: 0 }]
])
} as Partial<AppState>)
await renderHook()
await flushMicrotasks()
expect(refreshMock).not.toHaveBeenCalled()
await act(async () => {
useAppStore.setState({
sshConnectionStates: new Map([
['ssh-1', { status: 'connected', error: null, reconnectAttempt: 0 }]
])
} as Partial<AppState>)
})
await flushMicrotasks()
expect(refreshMock).toHaveBeenCalledTimes(1)
})
it('marks only the 60-second safety refresh for line-stat reuse', async () => {
await renderHook()
await flushMicrotasks()
expect(refreshMock.mock.calls[0]?.[0].request.reuseLineStats).toBeUndefined()
await vi.advanceTimersByTimeAsync(60_000)
await flushMicrotasks()
expect(refreshMock).toHaveBeenCalledTimes(2)
expect(refreshMock.mock.calls[1]?.[0].request.reuseLineStats).toBe(true)
})
it('aborts and rejects stale work when the execution host changes', async () => {
let resolveFirst!: () => void
const firstRefresh = new Promise<void>((resolve) => {
resolveFirst = resolve
})
let firstRequest!: { signal: AbortSignal; shouldApply: () => boolean }
refreshMock.mockImplementationOnce(
(args: { request: { signal: AbortSignal; shouldApply: () => boolean } }) => {
firstRequest = args.request
return firstRefresh
}
)
await renderHook()
expect(refreshMock).toHaveBeenCalledTimes(1)
await act(async () => {
useAppStore.setState({
settings: { activeRuntimeEnvironmentId: 'env-2' } as AppState['settings']
})
})
await flushMicrotasks()
expect(firstRequest.signal.aborted).toBe(true)
expect(firstRequest.shouldApply()).toBe(false)
expect(refreshMock).toHaveBeenCalledTimes(2)
resolveFirst()
await flushMicrotasks()
})
it('aborts and rejects stale work on unmount', async () => {
let resolveFirst!: () => void
const firstRefresh = new Promise<void>((resolve) => {
resolveFirst = resolve
})
let firstRequest!: { signal: AbortSignal; shouldApply: () => boolean }
refreshMock.mockImplementationOnce(
(args: { request: { signal: AbortSignal; shouldApply: () => boolean } }) => {
firstRequest = args.request
return firstRefresh
}
)
const root = await renderHook()
act(() => root.unmount())
roots.splice(roots.indexOf(root), 1)
expect(firstRequest.signal.aborted).toBe(true)
expect(firstRequest.shouldApply()).toBe(false)
resolveFirst()
await flushMicrotasks()
})
})
@@ -238,7 +238,7 @@ describe('useGitStatusPolling', () => {
expect(globalThis.setInterval).not.toHaveBeenCalled()
})
it('uses a slower git status cadence when only terminal branch detection needs polling', async () => {
it('uses the safety scheduler instead of an interval for terminal-only branch detection', async () => {
const { gitStatus } = await usePollingOnce(
{
entries: [],
@@ -255,7 +255,7 @@ describe('useGitStatusPolling', () => {
)
expect(gitStatus).toHaveBeenCalledTimes(1)
expect(globalThis.setInterval).toHaveBeenCalledWith(expect.any(Function), 30_000)
expect(globalThis.setInterval).not.toHaveBeenCalled()
})
it('does not install the visible git status poll while disabled', async () => {
@@ -411,6 +411,7 @@ describe('useGitStatusPolling', () => {
await vi.advanceTimersByTimeAsync(125)
expect(gitStatus).toHaveBeenCalledTimes(1)
// Why: evidence refreshes keep the 3s anti-churn floor from the last run.
await vi.advanceTimersByTimeAsync(2875)
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(2))
expect(window.api.fs.watchWorktree).not.toHaveBeenCalled()
@@ -652,6 +653,7 @@ describe('useGitStatusPolling', () => {
await vi.advanceTimersByTimeAsync(65)
expect(gitStatus).toHaveBeenCalledTimes(callsBeforeDebounceFires)
// Why: evidence refreshes keep the 3s anti-churn floor from the last run.
await vi.advanceTimersByTimeAsync(2875)
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(callsBeforeDebounceFires + 1))
@@ -728,7 +730,6 @@ describe('useGitStatusPolling', () => {
it('does not overlap slow visible git status polls and runs one trailing refresh', async () => {
vi.resetModules()
vi.useFakeTimers()
let intervalCallback: (() => void) | null = null
let resolveFirst!: (value: GitStatusResult) => void
const firstStatus = new Promise<GitStatusResult>((resolve) => {
resolveFirst = resolve
@@ -804,27 +805,22 @@ describe('useGitStatusPolling', () => {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
vi.stubGlobal(
'setInterval',
vi.fn((callback: () => void) => {
intervalCallback = callback
return 1
})
)
vi.stubGlobal('setInterval', vi.fn())
vi.stubGlobal('clearInterval', vi.fn())
const { useGitStatusPolling: runPolling } = await import('./useGitStatusPolling')
GitStatusPollingHarness({ runPolling })
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1))
expect(intervalCallback).toBeTypeOf('function')
const tick = intervalCallback as unknown as () => void
tick()
tick()
const onChanged = window.api.worktrees.onChanged as ReturnType<typeof vi.fn>
const handleRepoSignal = onChanged.mock.calls[0][0] as (payload: { repoId: string }) => void
handleRepoSignal({ repoId: repo.id })
handleRepoSignal({ repoId: repo.id })
expect(gitStatus).toHaveBeenCalledTimes(1)
resolveFirst(status)
await vi.waitFor(() => expect(state.setGitStatus).toHaveBeenCalledTimes(1))
// Why: the trailing refresh respects the 3s anti-churn floor after settle.
await vi.advanceTimersByTimeAsync(3000)
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(state.setGitStatus).toHaveBeenCalledTimes(2))
@@ -1,13 +1,11 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useAppStore } from '@/store'
import { useAllWorktrees, useRepoById, useRepoMap, useWorktreeById } from '@/store/selectors'
import type { GitConflictOperation } from '../../../../shared/types'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { getConnectionId } from '@/lib/connection-context'
import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { refreshGitStatusForWorktree } from './git-status-refresh'
import { type CoalescedPollRunner, createCoalescedPollRunner } from './coalesced-poll-runner'
import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval'
import { isWindowVisible } from '@/lib/window-visibility-interval'
import {
hasInteractiveActiveGitStatusConsumer,
shouldPollActiveGitStatus
@@ -15,18 +13,20 @@ import {
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh'
import { useGitStatusPushSignalRefresh } from './git-status-push-signal-refresh'
import { useStaleConflictOperationPolling } from './stale-conflict-operation-poll'
import {
createGitStatusRefreshScheduler,
type GitStatusRefreshReason,
type GitStatusRefreshScheduler
} from './git-status-refresh-scheduler'
const MIN_STATUS_REFRESH_INTERVAL_MS = 3000
const INTERACTIVE_STATUS_POLL_INTERVAL_MS = MIN_STATUS_REFRESH_INTERVAL_MS
// Why: file-watch refreshes cover content changes and push signals (repo
// 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 STATUS_SAFETY_INTERVAL_MS = 60_000
const STATUS_ACTIVITY_DEBOUNCE_MS = 125
// Why: evidence-driven status refreshes must keep the pre-scheduler floor so
// sustained terminal/file signals can never run git back-to-back (#7983).
const STATUS_ACTIVITY_MIN_GAP_MS = 3000
// Why: status scans and remote conflict probes can take longer than their
// timers; duration-aware spacing prevents a slow task from running nonstop.
const SLOW_GIT_POLL_BACKOFF = {
idleMultiplier: 5,
changeSignalMultiplier: 1,
@@ -37,6 +37,9 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
const enabled = options.enabled ?? true
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeWorktree = useWorktreeById(activeWorktreeId)
const activeExecutionHostId = useAppStore((s) =>
getExecutionHostIdForWorktree(s, activeWorktreeId)
)
const allWorktrees = useAllWorktrees()
const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity)
const setGitStatus = useAppStore((s) => s.setGitStatus)
@@ -87,143 +90,167 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
canFetchActiveWorktreeGitStatus &&
!!activeWorktreeId &&
!gitStatusHugeByWorktree?.[activeWorktreeId]
const activeStatusPollIntervalMs = hasInteractiveActiveGitStatusConsumer(
const activeStatusPollScope = shouldPollActiveWorktreeGitStatus
? `${activeExecutionHostId}\0${activeWorktreeId}\0${worktreePath}`
: null
// Why: opening any git-status consumer (Source Control, Files, Checks, or an
// editor file) must refresh promptly, matching the pre-scheduler behavior
// where the interactive interval flip re-ran an immediate poll.
const interactiveConsumerVisible = hasInteractiveActiveGitStatusConsumer(
activeGitStatusPollingArgs
)
? INTERACTIVE_STATUS_POLL_INTERVAL_MS
: TERMINAL_ONLY_STATUS_POLL_INTERVAL_MS
const activeStatusPollScope = shouldPollActiveWorktreeGitStatus ? activeWorktreeId : null
// Why: build a list of non-active worktrees that still have a known conflict
// operation (merge/rebase/cherry-pick). These need lightweight polling so
// their sidebar badges clear when the operation finishes — the full git status
// poll only covers the active worktree.
const staleConflictWorktrees = useMemo(() => {
const result: { id: string; path: string }[] = []
for (const [worktreeId, op] of Object.entries(conflictOperationByWorktree)) {
if (worktreeId === activeWorktreeId || op === 'unknown') {
continue
const runFetchStatus = useCallback(
async (request: {
reason: GitStatusRefreshReason
signal: AbortSignal
shouldApply: () => boolean
}) => {
// Why: eligibility can change between timer dispatch and task start.
// Avoid launching work that its liveness guard would have to discard.
if (
request.signal.aborted ||
!isWindowVisible() ||
!canFetchActiveWorktreeGitStatus ||
!activeWorktreeId ||
!worktreePath
) {
return
}
const worktree = allWorktrees.find((entry) => entry.id === worktreeId)
if (worktree) {
const repo = repoMap.get(worktree.repoId)
if (repo && !isGitRepoKind(repo)) {
continue
}
result.push({ id: worktree.id, path: worktree.path })
try {
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
await refreshGitStatusForWorktree({
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
worktreeId: activeWorktreeId,
worktreePath,
connectionId,
pushTarget: activePushTarget,
deps: {
setGitStatus,
updateWorktreeGitIdentity,
setUpstreamStatus,
fetchUpstreamStatus
},
request: {
...(request.reason === 'safety' ? { reuseLineStats: true } : {}),
signal: request.signal,
shouldApply: request.shouldApply
}
})
} catch {
// ignore
}
}
return result
}, [allWorktrees, conflictOperationByWorktree, activeWorktreeId, repoMap])
},
[
activePushTarget,
activeWorktreeId,
fetchUpstreamStatus,
canFetchActiveWorktreeGitStatus,
worktreePath,
setGitStatus,
setUpstreamStatus,
updateWorktreeGitIdentity
]
)
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 (!canFetchActiveWorktreeGitStatus || !activeWorktreeId || !worktreePath) {
return
}
try {
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
await refreshGitStatusForWorktree({
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
worktreeId: activeWorktreeId,
worktreePath,
connectionId,
pushTarget: activePushTarget,
deps: {
setGitStatus,
updateWorktreeGitIdentity,
setUpstreamStatus,
fetchUpstreamStatus
}
})
} catch {
// ignore
}
}, [
activePushTarget,
activeWorktreeId,
fetchUpstreamStatus,
canFetchActiveWorktreeGitStatus,
worktreePath,
setGitStatus,
setUpstreamStatus,
updateWorktreeGitIdentity
])
// Why: the runner must survive rerenders so `lastRunEndedAt` and `inFlight`
// are never reset by a UI-state change mid-burst (e.g. openFiles update while
// git is still running). A ref keeps one runner per active-worktree lifetime;
// `runFetchStatusRef` lets the runner always call the latest closure without
// being recreated. The runner is disposed and replaced only when the active
// worktree changes or the hook unmounts.
// Why: the scheduler must survive harmless store rerenders so its in-flight,
// trailing-signal, and safety-horizon state remain authoritative.
const runFetchStatusRef = useRef(runFetchStatus)
runFetchStatusRef.current = runFetchStatus
const canApplyScheduledStatusRef = useRef(canFetchActiveWorktreeGitStatus)
canApplyScheduledStatusRef.current = canFetchActiveWorktreeGitStatus
const statusRefreshGenerationRef = useRef(0)
const statusPollRunnerRef = useRef<CoalescedPollRunner | null>(null)
const statusSchedulerRef = useRef<GitStatusRefreshScheduler | null>(null)
useEffect(() => {
const runner = createCoalescedPollRunner(() => runFetchStatusRef.current(), {
minIntervalMs: MIN_STATUS_REFRESH_INTERVAL_MS,
slowTaskBackoff: SLOW_GIT_POLL_BACKOFF
})
statusPollRunnerRef.current = runner
const generation = ++statusRefreshGenerationRef.current
const scheduler = createGitStatusRefreshScheduler(
({ reason, signal }) =>
runFetchStatusRef.current({
reason,
signal,
shouldApply: () =>
statusRefreshGenerationRef.current === generation &&
canApplyScheduledStatusRef.current &&
!signal.aborted &&
isWindowVisible()
}),
{
safetyIntervalMs: STATUS_SAFETY_INTERVAL_MS,
activityDebounceMs: STATUS_ACTIVITY_DEBOUNCE_MS,
activityMinGapMs: STATUS_ACTIVITY_MIN_GAP_MS,
slowTaskBackoff: SLOW_GIT_POLL_BACKOFF
}
)
statusSchedulerRef.current = scheduler
return () => {
runner.dispose()
statusPollRunnerRef.current = null
}
}, [activeWorktreeId])
const fetchStatus = useCallback(() => {
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 })
}, [])
// Why: while huge, the visibility interval below is not installed, and push
// signals are dropped while hidden — e.g. an agent committing behind a
// minimized window. Catch up on reveal so the huge flag can still clear.
const hugeStatusPauseActive = canFetchActiveWorktreeGitStatus && !activeStatusPollScope
useEffect(() => {
if (
!hugeStatusPauseActive ||
typeof document === 'undefined' ||
typeof document.addEventListener !== 'function'
) {
return
}
const catchUpOnReveal = (): void => {
if (isWindowVisible()) {
fetchStatusOnChangeSignal()
statusRefreshGenerationRef.current += 1
scheduler.dispose()
if (statusSchedulerRef.current === scheduler) {
statusSchedulerRef.current = null
}
}
document.addEventListener('visibilitychange', catchUpOnReveal)
return () => {
document.removeEventListener('visibilitychange', catchUpOnReveal)
}
}, [hugeStatusPauseActive, fetchStatusOnChangeSignal])
}, [activeExecutionHostId, activeWorktreeId, worktreePath])
useEffect(() => {
if (!activeStatusPollScope) {
const reconcile = (catchUp: boolean): void => {
const scheduler = statusSchedulerRef.current
if (!scheduler) {
return
}
if (!canFetchActiveWorktreeGitStatus || !isWindowVisible()) {
scheduler.pause()
return
}
if (activeStatusPollScope) {
scheduler.resumeSafety()
return
}
scheduler.suspendSafety()
if (catchUp) {
scheduler.refreshNow()
}
}
reconcile(false)
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') {
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, fetchStatusOnChangeSignal])
const handleVisibilityChange = (): void => reconcile(isWindowVisible())
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [activeStatusPollScope, canFetchActiveWorktreeGitStatus])
const previousConsumerVisibilityRef = useRef({
worktreeId: activeWorktreeId,
visible: interactiveConsumerVisible,
canFetch: canFetchActiveWorktreeGitStatus
})
useEffect(() => {
const previous = previousConsumerVisibilityRef.current
if (
interactiveConsumerVisible &&
previous.worktreeId === activeWorktreeId &&
!previous.visible &&
previous.canFetch &&
canFetchActiveWorktreeGitStatus &&
isWindowVisible()
) {
statusSchedulerRef.current?.refreshNow()
}
previousConsumerVisibilityRef.current = {
worktreeId: activeWorktreeId,
visible: interactiveConsumerVisible,
canFetch: canFetchActiveWorktreeGitStatus
}
}, [activeWorktreeId, canFetchActiveWorktreeGitStatus, interactiveConsumerVisible])
// Why: file, terminal, and metadata evidence share one scheduler window so
// a single Git operation cannot fan out into several status subprocesses.
const fetchStatusOnChangeSignal = useCallback(() => {
statusSchedulerRef.current?.signal()
}, [])
useGitStatusFileWatchRefresh({
activeConnectionId,
@@ -247,63 +274,14 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
fetchStatus: fetchStatusOnChangeSignal
})
// Why: poll conflict operation for non-active worktrees that have a stale
// non-unknown operation. This is a lightweight fs-only check (no git status)
// so it won't cause performance issues even with many worktrees.
useEffect(() => {
if (!enabled) {
return
}
if (staleConflictWorktrees.length === 0) {
return
}
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
// becoming-visible run catches up via the change-signal lane.
if (!isWindowVisible()) {
return
}
for (const { id, path } of staleConflictWorktrees) {
try {
const connectionId = getConnectionId(id) ?? undefined
// Why: after explicit SSH disconnect the provider is intentionally
// gone; keep remote polling quiet until the target reconnects.
if (!isConnectionReady(connectionId)) {
continue
}
const op = (await getRuntimeGitConflictOperation({
settings: getRightSidebarWorktreeRuntimeSettings(id),
worktreeId: id,
worktreePath: path,
connectionId
})) as GitConflictOperation
setConflictOperation(id, op)
} catch {
// ignore — worktree may have been removed
}
}
}
// Why: remote conflict probes can exceed the 3s interval. Keep one poll in
// 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 () => {
pollRunner.dispose()
stopVisiblePoll()
}
}, [enabled, staleConflictWorktrees, setConflictOperation, isConnectionReady])
useStaleConflictOperationPolling({
enabled,
activeWorktreeId,
allWorktrees,
repoMap,
conflictOperationByWorktree,
setConflictOperation,
isConnectionReady,
slowTaskBackoff: SLOW_GIT_POLL_BACKOFF
})
}
@@ -0,0 +1,65 @@
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
export function createRuntimeRpcAbortError(): Error {
const error = new Error('Runtime request aborted')
error.name = 'AbortError'
return error
}
export async function callAbortableRuntimeEnvironment(
environmentId: string,
method: string,
params: unknown,
timeoutMs: number | undefined,
signal: AbortSignal
): Promise<RuntimeRpcResponse<unknown>> {
if (signal.aborted) {
throw createRuntimeRpcAbortError()
}
// Why: the one-shot runtime call bridge cannot cancel host work; the
// subscription bridge closes its request context when we unsubscribe.
return new Promise((resolve, reject) => {
let handle: { unsubscribe: () => void } | null = null
let settled = false
// Why: the subscription transport's own timeout only bounds subscription
// start, not the response. Keep the one-shot call path's response deadline
// so a connected-but-unresponsive runtime cannot stall the caller forever.
const deadline =
timeoutMs === undefined
? null
: setTimeout(() => {
finish(() => reject(new Error(`Runtime request timed out before ${method} completed`)))
}, timeoutMs)
const finish = (complete: () => void): void => {
if (settled) {
return
}
settled = true
if (deadline !== null) {
clearTimeout(deadline)
}
signal.removeEventListener('abort', onAbort)
handle?.unsubscribe()
complete()
}
const onAbort = (): void => finish(() => reject(createRuntimeRpcAbortError()))
signal.addEventListener('abort', onAbort, { once: true })
void window.api.runtimeEnvironments
.subscribe(
{ selector: environmentId, method, params, timeoutMs },
{
onResponse: (response) => finish(() => resolve(response)),
onError: (error) => finish(() => reject(new Error(error.message))),
onClose: () =>
finish(() => reject(new Error(`Runtime request closed before ${method} completed`)))
}
)
.then((subscription) => {
handle = subscription
if (settled) {
subscription.unsubscribe()
}
})
.catch((error) => finish(() => reject(error)))
})
}
@@ -23,6 +23,7 @@ import {
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
const gitStatus = vi.fn()
const gitCancelStatus = vi.fn()
const gitCheckIgnored = vi.fn()
const gitSubmoduleStatus = vi.fn()
const gitDiff = vi.fn()
@@ -44,6 +45,8 @@ const runtimeCall = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
gitStatus.mockReset()
gitCancelStatus.mockReset()
gitCancelStatus.mockResolvedValue(undefined)
gitCheckIgnored.mockReset()
gitSubmoduleStatus.mockReset()
gitDiff.mockReset()
@@ -68,6 +71,7 @@ beforeEach(() => {
api: {
git: {
status: gitStatus,
cancelStatus: gitCancelStatus,
checkIgnored: gitCheckIgnored,
submoduleStatus: gitSubmoduleStatus,
diff: gitDiff,
@@ -204,6 +208,39 @@ describe('runtime git client', () => {
})
})
it('forwards line-stat reuse to local status and cancels tokenized work on abort', async () => {
const controller = new AbortController()
let resolveStatus!: (value: { entries: never[]; conflictOperation: string }) => void
gitStatus.mockImplementation(
() =>
new Promise((resolve) => {
resolveStatus = resolve
})
)
const request = getRuntimeGitStatus(
{
settings: { activeRuntimeEnvironmentId: null },
worktreeId: 'wt-1',
worktreePath: '/repo'
},
{ reuseLineStats: true, signal: controller.signal }
)
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalled())
const statusArgs = gitStatus.mock.calls[0]?.[0] as { requestToken?: string }
controller.abort()
resolveStatus({ entries: [], conflictOperation: 'unknown' })
await request
expect(gitStatus).toHaveBeenCalledWith({
worktreePath: '/repo',
connectionId: undefined,
reuseLineStats: true,
requestToken: expect.any(String)
})
expect(gitCancelStatus).toHaveBeenCalledWith({ requestToken: statusArgs.requestToken })
})
it('checks ignored paths through local git IPC', async () => {
gitCheckIgnored.mockResolvedValue(['dist/bundle.js'])
@@ -408,6 +445,57 @@ describe('runtime git client', () => {
})
})
it('keeps safety (reuse) refreshes on the pooled call transport even with a signal', async () => {
const controller = new AbortController()
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
ok: true,
result: { entries: [], conflictOperation: 'unknown' },
_meta: { runtimeId: 'remote-runtime' }
})
await getRuntimeGitStatus(
{
settings: { activeRuntimeEnvironmentId: 'env-1' },
worktreeId: 'wt-1',
worktreePath: '/repo'
},
{ reuseLineStats: true, signal: controller.signal }
)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'git.status',
params: { worktree: 'id:wt-1', reuseLineStats: true },
timeoutMs: 15_000
})
})
it('forwards line-stat reuse through the active runtime environment', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
ok: true,
result: { entries: [], conflictOperation: 'unknown' },
_meta: { runtimeId: 'remote-runtime' }
})
await getRuntimeGitStatus(
{
settings: { activeRuntimeEnvironmentId: 'env-1' },
worktreeId: 'wt-1',
worktreePath: '/repo'
},
{ reuseLineStats: true }
)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'git.status',
params: { worktree: 'id:wt-1', reuseLineStats: true },
timeoutMs: 15_000
})
})
it('checks ignored paths through the active runtime environment', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
+55 -9
View File
@@ -133,20 +133,30 @@ export function getRuntimeGitScope(
export async function getRuntimeGitStatus(
context: RuntimeGitContext,
options?: { includeIgnored?: boolean; bypassEffectiveUpstreamNegativeCache?: boolean }
options?: {
includeIgnored?: boolean
bypassEffectiveUpstreamNegativeCache?: boolean
reuseLineStats?: boolean
signal?: AbortSignal
}
): Promise<GitStatusResult> {
const target = getActiveRuntimeTarget(context.settings)
const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {}
const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache
? { bypassEffectiveUpstreamNegativeCache: true }
: {}
const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {}
if (target.kind === 'local' || !context.worktreeId) {
return window.api.git.status({
worktreePath: resolveLocalWorktreePath(context),
connectionId: context.connectionId,
...includeIgnoredArgs,
...upstreamCacheBypassArgs
})
return callLocalGitStatus(
{
worktreePath: resolveLocalWorktreePath(context),
connectionId: context.connectionId,
...includeIgnoredArgs,
...upstreamCacheBypassArgs,
...lineStatsReuseArgs
},
options?.signal
)
}
return callRuntimeRpc<GitStatusResult>(
target,
@@ -154,12 +164,48 @@ export async function getRuntimeGitStatus(
{
worktree: toRuntimeWorktreeSelector(context.worktreeId),
...includeIgnoredArgs,
...upstreamCacheBypassArgs
...upstreamCacheBypassArgs,
...lineStatsReuseArgs
},
{ timeoutMs: 15_000 }
{
timeoutMs: 15_000,
// Why: an abort signal forces the dedicated per-request subscription
// socket (one WebSocket + E2EE handshake per request). The safety
// refresh is the idle steady state, so it stays on the pooled call
// transport: its stale result is rejected by the caller's liveness
// guard and the 15s timeout bounds it. Activity refreshes keep the
// abortable transport so pause/host switches cancel real remote work.
...(options?.reuseLineStats ? {} : { signal: options?.signal })
}
)
}
let nextGitStatusRequestToken = 0
async function callLocalGitStatus(
args: Parameters<Window['api']['git']['status']>[0],
signal?: AbortSignal
): Promise<GitStatusResult> {
if (!signal) {
return window.api.git.status(args)
}
if (signal.aborted) {
const error = new Error('Git status request aborted')
error.name = 'AbortError'
throw error
}
const requestToken = `git-status-${Date.now()}-${++nextGitStatusRequestToken}`
const cancel = (): void => {
void window.api.git.cancelStatus({ requestToken }).catch(() => {})
}
signal.addEventListener('abort', cancel, { once: true })
try {
return await window.api.git.status({ ...args, requestToken })
} finally {
signal.removeEventListener('abort', cancel)
}
}
export async function getRuntimeGitSubmoduleStatus(
context: RuntimeGitContext,
submodulePath: string,
@@ -20,15 +20,20 @@ import {
const runtimeCall = vi.fn()
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentSubscribe = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
runtimeCall.mockReset()
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentSubscribe.mockReset()
vi.stubGlobal('window', {
api: {
runtime: { call: runtimeCall },
runtimeEnvironments: { call: runtimeEnvironmentCall }
runtimeEnvironments: {
call: runtimeEnvironmentCall,
subscribe: runtimeEnvironmentSubscribe
}
}
})
})
@@ -109,6 +114,49 @@ describe('runtime RPC client routing', () => {
expect(runtimeCall).not.toHaveBeenCalled()
})
it('uses an abortable subscription for paired-runtime requests', async () => {
const controller = new AbortController()
const unsubscribe = vi.fn()
runtimeEnvironmentSubscribe.mockResolvedValue({ unsubscribe, sendBinary: vi.fn() })
const request = callRuntimeRpc(
{ kind: 'environment', environmentId: 'env-1' },
'status.get',
undefined,
{ signal: controller.signal }
)
await vi.waitFor(() => expect(runtimeEnvironmentSubscribe).toHaveBeenCalled())
controller.abort()
await expect(request).rejects.toMatchObject({ name: 'AbortError' })
await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1))
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('rejects an abortable paired-runtime request at the response deadline', async () => {
vi.useFakeTimers()
try {
const controller = new AbortController()
const unsubscribe = vi.fn()
runtimeEnvironmentSubscribe.mockResolvedValue({ unsubscribe, sendBinary: vi.fn() })
const request = callRuntimeRpc(
{ kind: 'environment', environmentId: 'env-1' },
'status.get',
undefined,
{ signal: controller.signal, timeoutMs: 15_000 }
)
await vi.waitFor(() => expect(runtimeEnvironmentSubscribe).toHaveBeenCalled())
const rejection = expect(request).rejects.toThrow('timed out before status.get completed')
await vi.advanceTimersByTimeAsync(15_000)
await rejection
await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1))
} finally {
vi.useRealTimers()
}
})
it('preflights remote runtime compatibility before non-status calls', async () => {
runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => {
const result =
+29 -26
View File
@@ -4,20 +4,22 @@ import type { RuntimeStatus } from '../../../shared/runtime-types'
import type { RuntimeCapability } from '../../../shared/protocol-version'
import { withBrowserPaneUiRuntimeRpcSource } from '../../../shared/runtime-rpc-feature-interaction-source'
import { assertRuntimeStatusCompatible } from './runtime-protocol-compat'
import {
callAbortableRuntimeEnvironment,
createRuntimeRpcAbortError
} from './abortable-runtime-environment-call'
export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string }
const RUNTIME_COMPATIBILITY_CACHE_MAX = 32
const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000
// Why: a saved environment can restart into a different Orca version without
// changing ids; capability verdicts must eventually follow that version change.
// Why: capability verdicts must eventually follow a saved environment's version changes.
const RUNTIME_CAPABILITY_STATUS_TTL_MS = 60_000
type RuntimeCompatibilityCacheEntry = {
check: Promise<void>
failedAt: number | null
// True only once status.get settled and proved compatible. Stays false while
// the probe is in flight, so a recovery clear can drop a doomed pending probe.
// False while probing so recovery can drop a doomed pending compatibility check.
provenCompatible: boolean
status: RuntimeStatus | null
statusCheckedAt: number | null
@@ -73,34 +75,38 @@ export async function callRuntimeRpc<TResult>(
timeoutMs?: number
suppressFeatureInteraction?: boolean
reuseRecentCompatibilityFailure?: boolean
signal?: AbortSignal
} = {}
): Promise<TResult> {
if (target.kind === 'environment' && method !== 'status.get') {
await ensureRuntimeEnvironmentCompatible(target.environmentId, options)
}
const nextParams = addFeatureInteractionSource(params, options)
if (options.signal?.aborted) {
throw createRuntimeRpcAbortError()
}
const nextParams = options.suppressFeatureInteraction
? withBrowserPaneUiRuntimeRpcSource(params)
: params
const response =
target.kind === 'local'
? await window.api.runtime.call({ method, params: nextParams })
: await window.api.runtimeEnvironments.call({
selector: target.environmentId,
method,
params: nextParams,
timeoutMs: options.timeoutMs
})
: options.signal
? await callAbortableRuntimeEnvironment(
target.environmentId,
method,
nextParams,
options.timeoutMs,
options.signal
)
: await window.api.runtimeEnvironments.call({
selector: target.environmentId,
method,
params: nextParams,
timeoutMs: options.timeoutMs
})
return unwrapRuntimeRpcResult<TResult>(response as RuntimeRpcResponse<TResult>)
}
function addFeatureInteractionSource(
params: unknown,
options: { suppressFeatureInteraction?: boolean }
): unknown {
if (!options.suppressFeatureInteraction) {
return params
}
return withBrowserPaneUiRuntimeRpcSource(params)
}
async function ensureRuntimeEnvironmentCompatible(
environmentId: string,
options: { timeoutMs?: number; reuseRecentCompatibilityFailure?: boolean } = {}
@@ -187,11 +193,8 @@ function rememberRuntimeEnvironmentCompatibility(
}
}
// Why: a live status.get answer proves any cached compatibility verdict that is
// not a settled success is stale. Drop settled failures AND still-pending probes
// (a probe queued on the dropped connection is doomed, and a reachability-
// triggered refresh must not coalesce onto it) so the refresh re-probes. Only
// proven-compatible successes stay cached.
// Why: a live status answer invalidates failures and pending probes from the
// dropped connection; only proven-compatible successes remain reusable.
export function clearRecentRuntimeCompatibilityFailure(environmentId: string): void {
const trimmed = environmentId.trim()
if (!trimmed) {
+10 -2
View File
@@ -1634,13 +1634,21 @@ function createFileApi(): NonNullable<Partial<PreloadApi>['fs']> {
function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
return {
status: async ({ worktreePath, includeIgnored }) => {
status: async ({
worktreePath,
includeIgnored,
bypassEffectiveUpstreamNegativeCache,
reuseLineStats
}) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.status', {
worktree: toRuntimeWorktreeSelector(worktree.id),
includeIgnored
includeIgnored,
bypassEffectiveUpstreamNegativeCache,
reuseLineStats
})
},
cancelStatus: async () => {},
submoduleStatus: async ({ worktreePath, submodulePath, area }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.submoduleStatus', {
@@ -205,6 +205,49 @@ describe('WebRuntimeClient', () => {
}
})
it('delivers plain unary responses to their subscription instead of dropping them', async () => {
const client = new WebRuntimeClient({
v: 2,
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: Buffer.alloc(32).toString('base64')
})
try {
const keyPair = generateKeyPair()
const sharedKey = deriveSharedKey(keyPair.secretKey, keyPair.publicKey)
const internals = client as unknown as {
state: string
sharedKey: Uint8Array | null
subscriptions: Map<
string,
{ callbacks: { onResponse: (response: unknown) => void; onClose?: () => void } }
>
handleSocketMessage: (rawData: unknown, sourceWs?: unknown) => Promise<void>
}
internals.state = 'connected'
internals.sharedKey = sharedKey
const onResponse = vi.fn()
internals.subscriptions.set('req-1', { callbacks: { onResponse } })
// A unary RPC (e.g. abortable git.status) answered over the subscription
// bridge: not streaming and neither an end nor a scrollback frame.
const response = {
id: 'req-1',
ok: true,
result: { entries: [], conflictOperation: 'unknown' },
_meta: { runtimeId: 'runtime-1' }
}
await internals.handleSocketMessage(encrypt(JSON.stringify(response), sharedKey))
expect(onResponse).toHaveBeenCalledWith(response)
// The caller owns the unsubscribe; a plain reply must not close it.
expect(internals.subscriptions.has('req-1')).toBe(true)
internals.subscriptions.delete('req-1')
} finally {
client.close()
}
})
it('keeps file watches on the owning WebSocket instead of opening child clients', async () => {
const client = new WebRuntimeClient({
v: 2,
+8 -25
View File
@@ -511,9 +511,14 @@ export class WebRuntimeClient {
}
const subscription = this.subscriptions.get(response.id)
if (subscription && isSubscriptionResponse(response)) {
subscription.callbacks.onResponse(response)
if (response.ok && isEndResult(response.result)) {
if (subscription) {
// Why: a unary RPC carried over the subscription bridge (e.g. an
// abortable git.status) answers with a plain response frame. Every frame
// for a subscribed id belongs to that subscription, so deliver it
// instead of dropping it on the pending-call map.
const subscriptionResponse = response as RuntimeRpcResponse<unknown>
subscription.callbacks.onResponse(subscriptionResponse)
if (subscriptionResponse.ok && isEndResult(subscriptionResponse.result)) {
this.subscriptions.delete(response.id)
subscription.callbacks.onClose?.()
}
@@ -765,24 +770,6 @@ export class WebRuntimeClient {
}
}
function isSubscriptionResponse(
response: RuntimeRpcResponse<unknown> | Record<string, unknown>
): response is RuntimeRpcResponse<unknown> {
if (!('ok' in response)) {
return false
}
if (response.ok === false) {
return true
}
if (response.ok === false) {
return true
}
const success = response as RuntimeRpcResponse<unknown> & { ok: true; streaming?: true }
return (
success.streaming === true || isEndResult(success.result) || isScrollbackResult(success.result)
)
}
function isRuntimeFailureResponse(
response: RuntimeRpcResponse<unknown> | Record<string, unknown>
): response is RuntimeRpcResponse<unknown> & { ok: false } {
@@ -815,10 +802,6 @@ function isEndResult(value: unknown): value is { type: 'end' } {
return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'end'
}
function isScrollbackResult(value: unknown): value is { type: 'scrollback' } {
return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'scrollback'
}
async function websocketPayloadToUint8(
value: unknown
): Promise<Uint8Array<ArrayBufferLike> | null> {
@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
applyCachedGitStatusLineStats,
beginGitStatusLineStatsCacheWrite,
clearGitStatusLineStatsCache,
clearGitStatusLineStatsCacheKey,
GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS,
storeGitStatusLineStats
} from './git-status-line-stats-cache'
const cachedEntries = [
{
path: 'src/a.ts',
status: 'modified',
area: 'unstaged',
added: 3,
removed: 2
}
]
describe('git status line stats cache', () => {
beforeEach(() => {
clearGitStatusLineStatsCache()
})
it('reuses counts only for an exact head and status-entry identity', () => {
storeGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries: cachedEntries })
const matchingEntries = [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }]
expect(
applyCachedGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-1',
entries: matchingEntries
})
).toBe(true)
expect(matchingEntries).toEqual(cachedEntries)
expect(
applyCachedGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-2',
entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }]
})
).toBe(false)
})
it('invalidates a matching status identity at the bounded age', () => {
storeGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-1',
entries: cachedEntries,
now: 1_000
})
expect(
applyCachedGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-1',
entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }],
now: 1_000 + GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS
})
).toBe(false)
})
it('isolates execution-host keys and supports mutation invalidation', () => {
storeGitStatusLineStats({
cacheKey: 'wsl:ubuntu\0/repo',
head: 'head-1',
entries: cachedEntries
})
expect(
applyCachedGitStatusLineStats({
cacheKey: 'wsl:debian\0/repo',
head: 'head-1',
entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }]
})
).toBe(false)
clearGitStatusLineStatsCacheKey('wsl:ubuntu\0/repo')
expect(
applyCachedGitStatusLineStats({
cacheKey: 'wsl:ubuntu\0/repo',
head: 'head-1',
entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }]
})
).toBe(false)
})
it('lets an earlier recompute store after a later reuse-only read began', () => {
const recomputeWrite = beginGitStatusLineStatsCacheWrite('native\0/repo')
// A hinted safety read begins later, reuses, and never stores; it must not
// starve the older recompute's store.
beginGitStatusLineStatsCacheWrite('native\0/repo')
storeGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-1',
entries: cachedEntries,
writeToken: recomputeWrite
})
const entries: { path: string; status: string; area: string; added?: number }[] = [
{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }
]
expect(
applyCachedGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries })
).toBe(true)
expect(entries[0]?.added).toBe(3)
})
it('rejects stale cache writes and writes started before invalidation', () => {
const olderWrite = beginGitStatusLineStatsCacheWrite('native\0/repo')
const latestWrite = beginGitStatusLineStatsCacheWrite('native\0/repo')
storeGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-1',
entries: [{ ...cachedEntries[0], added: 8 }],
writeToken: latestWrite
})
storeGitStatusLineStats({
cacheKey: 'native\0/repo',
head: 'head-1',
entries: cachedEntries,
writeToken: olderWrite
})
const entries: { path: string; status: string; area: string; added?: number }[] = [
{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }
]
expect(
applyCachedGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries })
).toBe(true)
expect(entries[0]?.added).toBe(8)
const invalidatedWrite = beginGitStatusLineStatsCacheWrite('native\0/other')
clearGitStatusLineStatsCache()
storeGitStatusLineStats({
cacheKey: 'native\0/other',
head: 'head-1',
entries: cachedEntries,
writeToken: invalidatedWrite
})
expect(
applyCachedGitStatusLineStats({
cacheKey: 'native\0/other',
head: 'head-1',
entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }]
})
).toBe(false)
})
})
+235
View File
@@ -0,0 +1,235 @@
type GitStatusLineStatsEntry = {
path?: unknown
status?: unknown
area?: unknown
oldPath?: unknown
conflictKind?: unknown
conflictStatus?: unknown
conflictStatusSource?: unknown
submodule?: {
commitChanged?: boolean
trackedChanges?: boolean
untrackedChanges?: boolean
}
added?: number
removed?: number
}
type CachedLineStats = {
identity: string
storedAt: number
stats: { added?: number; removed?: number }[]
}
export type GitStatusLineStatsWriteToken = {
cacheKey: string
globalGeneration: number
keyGeneration: number
beginSeq: number
}
// Why: the TTL is the sole staleness backstop when file contents change while
// the porcelain identity stays "modified" (added/removed are excluded from the
// reuse identity), so a missed watcher signal pins counts for at most this long.
export const GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS = 2 * 60_000
const GIT_STATUS_LINE_STATS_CACHE_MAX_ENTRIES = 128
const GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES = 1024
const lineStatsByWorktree = new Map<string, CachedLineStats>()
// Why: mutation invalidation must retire scans that began before it. A scan
// captures these generations at begin; a mismatch at store/clear time means an
// invalidation happened mid-scan and the derived stats may be pre-mutation.
let globalInvalidationGeneration = 0
const keyInvalidationGenerationByWorktree = new Map<string, number>()
// Why: overlapping recomputes must resolve latest-begun-wins without letting a
// reuse-only read (which never stores) starve an older recompute's store.
const lastStoredBeginSeqByWorktree = new Map<string, number>()
let nextBeginSeq = 0
// Why: wall-clock steps (NTP, VM resume) must not extend or shrink the TTL.
const monotonicNowMs = (): number => performance.now()
function bumpBoundedKeyMap(map: Map<string, number>, cacheKey: string, value: number): void {
map.delete(cacheKey)
map.set(cacheKey, value)
while (map.size > GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES) {
const oldestKey = map.keys().next().value
if (oldestKey === undefined) {
return
}
map.delete(oldestKey)
}
}
export function beginGitStatusLineStatsCacheWrite(cacheKey: string): GitStatusLineStatsWriteToken {
return {
cacheKey,
globalGeneration: globalInvalidationGeneration,
keyGeneration: keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0,
beginSeq: ++nextBeginSeq
}
}
function isWriteTokenCurrent(token: GitStatusLineStatsWriteToken): boolean {
return (
token.globalGeneration === globalInvalidationGeneration &&
token.keyGeneration === (keyInvalidationGenerationByWorktree.get(token.cacheKey) ?? 0) &&
token.beginSeq >= (lastStoredBeginSeqByWorktree.get(token.cacheKey) ?? 0)
)
}
function createInputIdentity(head: string | undefined, entries: GitStatusLineStatsEntry[]): string {
return JSON.stringify([
head ?? null,
entries.map((entry) => [
entry.path,
entry.status,
entry.area,
entry.oldPath ?? null,
entry.conflictKind ?? null,
entry.conflictStatus ?? null,
entry.conflictStatusSource ?? null,
entry.submodule?.commitChanged ?? null,
entry.submodule?.trackedChanges ?? null,
entry.submodule?.untrackedChanges ?? null
])
])
}
function trimLineStatsCache(now: number): void {
for (const [key, cached] of lineStatsByWorktree) {
if (now - cached.storedAt >= GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS) {
lineStatsByWorktree.delete(key)
}
}
while (lineStatsByWorktree.size > GIT_STATUS_LINE_STATS_CACHE_MAX_ENTRIES) {
const oldestKey = lineStatsByWorktree.keys().next().value
if (oldestKey === undefined) {
return
}
lineStatsByWorktree.delete(oldestKey)
}
}
export function applyCachedGitStatusLineStats(input: {
cacheKey: string
head?: string
entries: GitStatusLineStatsEntry[]
now?: number
}): boolean {
const now = input.now ?? monotonicNowMs()
const cached = lineStatsByWorktree.get(input.cacheKey)
if (!cached) {
return false
}
if (
now - cached.storedAt >= GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS ||
cached.identity !== createInputIdentity(input.head, input.entries)
) {
lineStatsByWorktree.delete(input.cacheKey)
return false
}
lineStatsByWorktree.delete(input.cacheKey)
lineStatsByWorktree.set(input.cacheKey, cached)
input.entries.forEach((entry, index) => {
const stats = cached.stats[index]
if (stats?.added !== undefined) {
entry.added = stats.added
}
if (stats?.removed !== undefined) {
entry.removed = stats.removed
}
})
return true
}
export function storeGitStatusLineStats(input: {
cacheKey: string
head?: string
entries: GitStatusLineStatsEntry[]
now?: number
writeToken?: GitStatusLineStatsWriteToken
}): void {
const writeToken = input.writeToken ?? beginGitStatusLineStatsCacheWrite(input.cacheKey)
if (!isWriteTokenCurrent(writeToken)) {
return
}
bumpBoundedKeyMap(lastStoredBeginSeqByWorktree, input.cacheKey, writeToken.beginSeq)
const now = input.now ?? monotonicNowMs()
lineStatsByWorktree.delete(input.cacheKey)
lineStatsByWorktree.set(input.cacheKey, {
identity: createInputIdentity(input.head, input.entries),
storedAt: now,
stats: input.entries.map((entry) => ({
...(entry.added === undefined ? {} : { added: entry.added }),
...(entry.removed === undefined ? {} : { removed: entry.removed })
}))
})
trimLineStatsCache(now)
}
/**
* Shared post-status line-stat step for every host that executes Git. Reuses
* the cached snapshot only for hinted safety reads; otherwise recomputes and
* stores. `recompute` returns false when the counts are incomplete (e.g. a
* transient numstat failure) so a failed pass never replaces or pins a
* snapshot, and the previous good counts stay reusable.
*/
export async function reuseOrRecomputeGitStatusLineStats(input: {
cacheKey: string
head?: string
entries: GitStatusLineStatsEntry[]
writeToken: GitStatusLineStatsWriteToken
reuse: boolean
isAborted: () => boolean
recompute: () => Promise<boolean>
}): Promise<void> {
if (
input.reuse &&
applyCachedGitStatusLineStats({
cacheKey: input.cacheKey,
head: input.head,
entries: input.entries
})
) {
return
}
const complete = await input.recompute()
if (input.isAborted()) {
clearGitStatusLineStatsCacheKey(input.cacheKey, input.writeToken)
return
}
if (!complete) {
return
}
storeGitStatusLineStats({
cacheKey: input.cacheKey,
head: input.head,
entries: input.entries,
writeToken: input.writeToken
})
}
export function clearGitStatusLineStatsCache(): void {
globalInvalidationGeneration += 1
lineStatsByWorktree.clear()
keyInvalidationGenerationByWorktree.clear()
lastStoredBeginSeqByWorktree.clear()
}
export function clearGitStatusLineStatsCacheKey(
cacheKey: string,
writeToken?: GitStatusLineStatsWriteToken
): void {
if (writeToken !== undefined && !isWriteTokenCurrent(writeToken)) {
return
}
if (writeToken === undefined) {
bumpBoundedKeyMap(
keyInvalidationGenerationByWorktree,
cacheKey,
(keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0) + 1
)
}
lineStatsByWorktree.delete(cacheKey)
}
+7 -1
View File
@@ -178,10 +178,16 @@ function rememberUntrackedStats(
// We count their contents directly to show an additions magnitude.
export async function collectUntrackedAdditions(
worktreePath: string,
untrackedPaths: readonly string[]
untrackedPaths: readonly string[],
signal?: AbortSignal
): Promise<Map<string, GitLineStats>> {
const result = new Map<string, GitLineStats>()
for (let i = 0; i < untrackedPaths.length; i += UNTRACKED_READ_CONCURRENCY) {
// Why: an aborted refresh discards its result, so stop reading here rather
// than keep burning (possibly remote-host) file I/O after cancellation.
if (signal?.aborted) {
return result
}
const chunk = untrackedPaths.slice(i, i + UNTRACKED_READ_CONCURRENCY)
await Promise.all(
chunk.map(async (relativePath) => {