From 9e2320c122cc6d209b6d13fbad46c894772e7960 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Thu, 15 Oct 2026 09:12:04 +0800 Subject: [PATCH] fix(git): limit remote polling to active consumers --- .../workspaceGitRefreshOptions.test.ts | 6 +- .../workspaces/workspaceGitRefreshOptions.ts | 8 +- src/web-ui/src/tools/git/AGENTS.md | 16 ++ .../src/tools/git/hooks/useGitState.test.tsx | 10 + src/web-ui/src/tools/git/hooks/useGitState.ts | 5 + .../tools/git/state/GitStateManager.test.ts | 185 ++++++++++++- .../src/tools/git/state/GitStateManager.ts | 243 ++++++++++++------ src/web-ui/src/tools/git/state/types.ts | 2 + 8 files changed, 383 insertions(+), 92 deletions(-) create mode 100644 src/web-ui/src/tools/git/AGENTS.md diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts index 5960685ef4..47bd51424d 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts @@ -62,9 +62,11 @@ describe('getWorkspaceGitBasicInfoOptions', () => { }); }); - it('keeps remote workspace rows on the existing default git refresh behavior', () => { + it('defers inactive remote workspace rows until activation too', () => { expect(getWorkspaceGitBasicInfoOptions(createWorkspace(WorkspaceKind.Remote), false)) - .toBeUndefined(); + .toMatchObject({ isActive: false, refreshOnMount: false, refreshOnActive: true }); + expect(getWorkspaceGitBasicInfoOptions(createWorkspace(WorkspaceKind.Remote), true)) + .toMatchObject({ isActive: true, refreshOnMount: true, refreshOnActive: true }); }); it('suppresses only mount refresh during a history session transition', () => { diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts index acf0f5d6fe..a0713f5984 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts @@ -1,4 +1,4 @@ -import { isRemoteWorkspace, type WorkspaceInfo } from '@/shared/types'; +import type { WorkspaceInfo } from '@/shared/types'; import type { GitBasicInfoOptions } from '@/tools/git/hooks/useGitState'; export const WORKSPACE_GIT_BASIC_INFO_SOURCE = 'workspace_item_git_basic_info'; @@ -10,13 +10,9 @@ export const WORKSPACE_GIT_PENDING_CANCEL_SOURCES = [ export const WORKSPACE_GIT_PENDING_CANCEL_REASONS = ['mount', 'visibility'] as const; export function getWorkspaceGitBasicInfoOptions( - workspace: WorkspaceInfo, + _workspace: WorkspaceInfo, isActive: boolean ): GitBasicInfoOptions | undefined { - if (isRemoteWorkspace(workspace)) { - return undefined; - } - return { isActive, refreshOnMount: isActive, diff --git a/src/web-ui/src/tools/git/AGENTS.md b/src/web-ui/src/tools/git/AGENTS.md new file mode 100644 index 0000000000..b4b4ba1f15 --- /dev/null +++ b/src/web-ui/src/tools/git/AGENTS.md @@ -0,0 +1,16 @@ +# Git state and refresh verification + +For changes to Git state subscriptions, polling, refresh scheduling, or workspace +row activation, run the focused contracts from the repository root: + +```bash +pnpm --dir src/web-ui run test:run src/tools/git/state/GitStateManager.test.ts src/tools/git/hooks/useGitState.test.tsx src/tools/git/services/GitService.test.ts src/tools/git/services/WorkspaceGitInitializer.test.ts src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts +``` + +Subscriptions observe state; only active consumers register polling demand. +Keep remote polling conservative, serialize background polls per SSH connection, +and preserve manual/operation refreshes during retry backoff. Unchanged refreshes +must not emit state-change events. Retain repository trust error/recovery behavior. + +These tests mock host Git calls. Report live SSH and peer-device verification +separately; fake timers and mocked transports are not remote integration evidence. diff --git a/src/web-ui/src/tools/git/hooks/useGitState.test.tsx b/src/web-ui/src/tools/git/hooks/useGitState.test.tsx index 8a049e86d7..1c3f5cfbc6 100644 --- a/src/web-ui/src/tools/git/hooks/useGitState.test.tsx +++ b/src/web-ui/src/tools/git/hooks/useGitState.test.tsx @@ -10,6 +10,7 @@ import { useGitState } from './useGitState'; const gitStateManagerMock = vi.hoisted(() => ({ getState: vi.fn(() => null), subscribe: vi.fn(() => vi.fn()), + registerPolling: vi.fn(() => vi.fn()), registerWindowFocusRefresh: vi.fn(() => vi.fn()), refresh: vi.fn(() => Promise.resolve()), cancelPendingRefresh: vi.fn(() => false), @@ -67,6 +68,8 @@ describe('useGitState visibility refresh', () => { }); expect(gitStateManagerMock.refresh).not.toHaveBeenCalled(); + expect(gitStateManagerMock.subscribe).toHaveBeenCalledTimes(1); + expect(gitStateManagerMock.registerPolling).not.toHaveBeenCalled(); expect(gitStateManagerMock.registerWindowFocusRefresh).not.toHaveBeenCalled(); await act(async () => { @@ -83,6 +86,13 @@ describe('useGitState visibility refresh', () => { }, ); expect(gitStateManagerMock.registerWindowFocusRefresh).toHaveBeenCalledTimes(1); + expect(gitStateManagerMock.registerPolling).toHaveBeenCalledTimes(1); + const releasePolling = gitStateManagerMock.registerPolling.mock.results[0].value; + await act(async () => { + root.render(); + }); + expect(releasePolling).toHaveBeenCalledTimes(1); + expect(gitStateManagerMock.subscribe).toHaveBeenCalledTimes(1); }); it('cancels and replays active automatic refreshes when mount refresh is temporarily suppressed', async () => { diff --git a/src/web-ui/src/tools/git/hooks/useGitState.ts b/src/web-ui/src/tools/git/hooks/useGitState.ts index 0cb88cb24b..f8aefdc3f3 100644 --- a/src/web-ui/src/tools/git/hooks/useGitState.ts +++ b/src/web-ui/src/tools/git/hooks/useGitState.ts @@ -122,6 +122,11 @@ export function useGitState(options: UseGitStateOptions): UseGitStateReturn { const isFirstMountRef = useRef(true); + useEffect(() => { + if (!normalizedPath.workspaceId || !isActive) return; + return gitStateManager.registerPolling(normalizedPath); + }, [isActive, normalizedPath]); + useEffect(() => { if (!normalizedPath.workspaceId || !isActive || !participateInWindowFocusRefresh) { return; diff --git a/src/web-ui/src/tools/git/state/GitStateManager.test.ts b/src/web-ui/src/tools/git/state/GitStateManager.test.ts index 8e8ac063cb..a4e5edc455 100644 --- a/src/web-ui/src/tools/git/state/GitStateManager.test.ts +++ b/src/web-ui/src/tools/git/state/GitStateManager.test.ts @@ -2,6 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GitStateManager } from './GitStateManager'; import type { GitStateLayer } from './types'; +import { globalEventBus } from '@/infrastructure/event-bus'; + +const workspaceCatalog = vi.hoisted(() => new Map()); +const peerMode = vi.hoisted(() => ({ active: false })); +vi.mock('@/infrastructure/services/business/workspaceManager', () => ({ + workspaceManager: { getState: () => ({ openedWorkspaces: workspaceCatalog }) }, +})); +vi.mock('@/infrastructure/peer-device/peerModeFlag', () => ({ + isPeerDeviceModeActive: () => peerMode.active, +})); const gitApiMocks = vi.hoisted(() => ({ isGitRepository: vi.fn(), @@ -59,6 +69,10 @@ describe('GitStateManager refresh performance guards', () => { beforeEach(() => { vi.useFakeTimers(); + workspaceCatalog.clear(); + workspaceCatalog.set('workspace-1', { workspaceKind: 'normal' }); + workspaceCatalog.set('workspace-2', { workspaceKind: 'normal' }); + peerMode.active = false; GitStateManager.resetInstance(); manager = GitStateManager.getInstance(); manager.setCacheConfig({ basic: 0, status: 0, detailed: 0 }); @@ -103,10 +117,10 @@ describe('GitStateManager refresh performance guards', () => { const addListener = vi.spyOn(document, 'addEventListener'); const removeListener = vi.spyOn(document, 'removeEventListener'); try { - const unsubscribe = manager.subscribe(repositoryPath, () => {}); + const unsubscribe = manager.registerPolling(repositoryPath); hidden.mockReturnValue(true); document.dispatchEvent(new Event('visibilitychange')); - const unsubscribeOther = manager.subscribe({ workspaceId: 'workspace-2' }, () => {}); + const unsubscribeOther = manager.registerPolling({ workspaceId: 'workspace-2' }); await vi.advanceTimersByTimeAsync(4100); expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled(); const listeners = addListener.mock.calls.filter(([type]) => type === 'visibilitychange'); @@ -131,7 +145,7 @@ describe('GitStateManager refresh performance guards', () => { it('does not start polling for an initially hidden document', async () => { const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true); try { - manager.subscribe(repositoryPath, () => {}); + manager.registerPolling(repositoryPath); await vi.advanceTimersByTimeAsync(4100); expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled(); hidden.mockReturnValue(false); @@ -164,6 +178,171 @@ describe('GitStateManager refresh performance guards', () => { }); }); + it('keeps passive subscriptions free of IO and reference-counts active demand', async () => { + manager.subscribe(repositoryPath, () => {}); + await vi.advanceTimersByTimeAsync(60_000); + expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled(); + const releaseA = manager.registerPolling(repositoryPath); + const releaseB = manager.registerPolling(repositoryPath); + releaseA(); + await vi.advanceTimersByTimeAsync(2100); + expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(1); + releaseB(); + await vi.advanceTimersByTimeAsync(60_000); + expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(1); + }); + + it('polls active SSH workspaces slowly and never broadcasts unchanged results', async () => { + workspaceCatalog.set('workspace-1', { workspaceKind: 'remote', connectionId: 'ssh-a' }); + workspaceCatalog.set('workspace-2', { workspaceKind: 'remote', connectionId: 'ssh-b' }); + for (const workspaceId of ['workspace-1', 'workspace-2', 'workspace-3']) { + manager.subscribe({ workspaceId }, () => {}); + } + manager.registerPolling(repositoryPath); + await vi.advanceTimersByTimeAsync(29_999); + expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(101); + expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(1); + vi.mocked(globalEventBus.emit).mockClear(); + gitEventServiceMock.emit.mockClear(); + await vi.advanceTimersByTimeAsync(36_000); + expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(2); + expect(globalEventBus.emit).not.toHaveBeenCalled(); + expect(gitEventServiceMock.emit).not.toHaveBeenCalled(); + expect(gitApiMocks.getStatus).not.toHaveBeenCalled(); + }); + + it('serializes background reads on one SSH connection without starving another repository', async () => { + for (const workspaceId of ['workspace-1', 'workspace-2']) { + workspaceCatalog.set(workspaceId, { workspaceKind: 'remote', connectionId: 'ssh-a' }); + manager.registerPolling({ workspaceId }); + } + const read = deferred(); + gitApiMocks.getRepositoryBasic.mockReturnValueOnce(read.promise); + await vi.advanceTimersByTimeAsync(40_000); + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1); + read.resolve({ current_branch: 'main' }); + await vi.advanceTimersByTimeAsync(2100); + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(2); + expect(gitApiMocks.isGitRepository.mock.calls.map(([scope]) => scope.workspaceId)) + .toEqual(['workspace-1', 'workspace-2']); + }); + + it('backs off failures, allows immediate manual recovery and clears the visible error', async () => { + workspaceCatalog.set('workspace-1', { workspaceKind: 'remote', connectionId: 'ssh-a' }); + const seen = vi.fn(); + manager.subscribe(repositoryPath, seen); + manager.registerPolling(repositoryPath); + gitApiMocks.isGitRepository.mockRejectedValueOnce(new Error('SSH disconnected')); + await vi.advanceTimersByTimeAsync(30_100); + expect(seen.mock.lastCall?.[0].error).toBe('SSH disconnected'); + await vi.advanceTimersByTimeAsync(59_000); + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1); + const retry = manager.refresh(repositoryPath, { layers: ['basic'], force: true }); + await vi.advanceTimersByTimeAsync(100); + await retry; + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(2); + expect(seen.mock.lastCall?.[0].error).toBeNull(); + await vi.advanceTimersByTimeAsync(33_000); + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(3); + }); + + it('preserves peer-device polling suppression and uses conservative cadence without metadata', async () => { + workspaceCatalog.clear(); + manager.registerPolling(repositoryPath); + await vi.advanceTimersByTimeAsync(6000); + expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled(); + peerMode.active = true; + await vi.advanceTimersByTimeAsync(60_000); + expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled(); + peerMode.active = false; + }); + + it('increases offline retry delays and caps them at five minutes', async () => { + workspaceCatalog.set('workspace-1', { workspaceKind: 'remote', connectionId: 'ssh-a' }); + gitApiMocks.isGitRepository.mockRejectedValue(new Error('SSH disconnected')); + manager.registerPolling(repositoryPath); + await vi.advanceTimersByTimeAsync(30_100); + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(60_000); + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(2000); + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(120_000); + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(2000); + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(242_000); + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(4); + await vi.advanceTimersByTimeAsync(302_000); + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(5); + await vi.advanceTimersByTimeAsync(302_000); + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(6); + }); + + it('cancels queued polling on deactivation but preserves a merged manual refresh', async () => { + const release = manager.registerPolling(repositoryPath); + await vi.advanceTimersByTimeAsync(2000); + release(); + await vi.advanceTimersByTimeAsync(100); + expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled(); + const releaseAgain = manager.registerPolling(repositoryPath); + await vi.advanceTimersByTimeAsync(2000); + const manual = manager.refresh(repositoryPath, { layers: ['basic'], force: true }); + releaseAgain(); + await vi.advanceTimersByTimeAsync(100); + await manual; + expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1); + }); + + it('drops a queued poll if the document becomes hidden during debounce', async () => { + const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(false); + try { + manager.registerPolling(repositoryPath); + await vi.advanceTimersByTimeAsync(2000); + hidden.mockReturnValue(true); + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(100); + expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled(); + } finally { + hidden.mockRestore(); + } + }); + + it('notifies error recovery even when the branch and files stay unchanged', async () => { + const first = manager.refresh(repositoryPath, { layers: ['basic'], force: true }); + await vi.advanceTimersByTimeAsync(100); + await first; + const seen = vi.fn(); + manager.subscribe(repositoryPath, seen, { immediate: false, layers: ['basic'] }); + gitApiMocks.isGitRepository.mockRejectedValueOnce(new Error('Offline')); + const failed = manager.refresh(repositoryPath, { layers: ['basic'], force: true }).catch(() => {}); + await vi.advanceTimersByTimeAsync(100); + await failed; + expect(seen.mock.lastCall?.[0].error).toBe('Offline'); + const recovered = manager.refresh(repositoryPath, { layers: ['basic'], force: true, silent: true }); + await vi.advanceTimersByTimeAsync(100); + await recovered; + expect(seen).toHaveBeenCalledTimes(2); + expect(seen.mock.lastCall?.[0].error).toBeNull(); + }); + + it('refreshes stale basic data on activation even with the default infinite TTL', async () => { + manager.setCacheConfig({ basic: Infinity }); + const first = manager.refresh(repositoryPath, { layers: ['basic'], force: true }); + await vi.advanceTimersByTimeAsync(100); + await first; + await vi.advanceTimersByTimeAsync(30_000); + manager.registerPolling(repositoryPath); + gitApiMocks.getRepositoryBasic.mockResolvedValueOnce({ current_branch: 'external-change' }); + const activate = manager.refresh(repositoryPath, { layers: ['basic'], reason: 'visibility' }); + await vi.advanceTimersByTimeAsync(100); + await activate; + expect(manager.getState(repositoryPath)?.currentBranch).toBe('external-change'); + expect(globalEventBus.emit).toHaveBeenLastCalledWith('git:state:changed', + expect.objectContaining({ changedLayers: ['basic'] })); + }); + it('merges duplicate mount refreshes for the same repository and layer', async () => { const first = manager.refresh(repositoryPath, { layers: ['basic', 'status'], diff --git a/src/web-ui/src/tools/git/state/GitStateManager.ts b/src/web-ui/src/tools/git/state/GitStateManager.ts index d3199d9da0..58600c06a3 100644 --- a/src/web-ui/src/tools/git/state/GitStateManager.ts +++ b/src/web-ui/src/tools/git/state/GitStateManager.ts @@ -21,6 +21,8 @@ import { gitRepositoryUntrustedPath } from '@/infrastructure/api/errors/TauriCom import { gitEventService } from '../services/GitEventService'; import { globalEventBus } from '@/infrastructure/event-bus'; import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; +import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { isRemoteWorkspace } from '@/shared/types/global-state'; import { GitState, GitStateLayer, @@ -52,22 +54,25 @@ interface SubscriberEntry { * the app — in an integrated terminal, an external Git client, a script, or * triggered by the agent itself. None of those emit our in-process * `branch:changed` / `operation:completed` events, so the UI can stay stale - * indefinitely. We therefore run a low-frequency poll against any repository - * that currently has at least one active subscriber, so the branch chip, - * welcome panel and Git scene all pick up external changes promptly. - * - * The poll hits the lightweight `basic` endpoint (`git branch --show-current` - * plus repo name), so it is cheap even at 2 s cadence. Heavy `status` and - * `detailed` layers keep their longer TTLs and are only refreshed on demand - * or by user-driven events. + * indefinitely. Only active consumers request polling; passive subscriptions + * still receive changes without causing IO. Remote reads open SSH exec channels, + * so they use a slower cadence and never overlap on the same connection. */ const POLL_INTERVAL_MS = 2000; +const REMOTE_POLL_INTERVAL_MS = 30_000; +const MAX_POLL_BACKOFF_MS = 300_000; + +interface PollSchedule { + failures: number; + nextAttemptAt: number; +} interface PendingRefresh { layers: Set; force: boolean; reason: RefreshReason; source?: string; + pollOnly: boolean; resolve: () => void; reject: (error: Error) => void; } @@ -81,6 +86,9 @@ export class GitStateManager { private states = new Map(); private subscribers = new Map>(); + private pollingCounts = new Map(); + private pollSchedules = new Map(); + private pollingConnections = new Set(); private windowFocusRefreshCounts = new Map(); private refreshDebounceTimers = new Map>(); private refreshLocks = new Map>(); @@ -128,8 +136,6 @@ export class GitStateManager { this.subscribers.set(normalizedPath, new Set()); } - const firstSubscriberForRepo = this.subscribers.get(normalizedPath)!.size === 0; - const entry: SubscriberEntry = { callback, options }; this.subscribers.get(normalizedPath)!.add(entry); @@ -140,23 +146,76 @@ export class GitStateManager { } } - if (firstSubscriberForRepo) { - this.ensurePollingStarted(); - } - return () => { const set = this.subscribers.get(normalizedPath); if (!set) return; set.delete(entry); if (set.size === 0) { this.subscribers.delete(normalizedPath); - if (this.subscribers.size === 0) { - this.stopPolling(); + } + }; + } + + /** Active consumers opt into IO separately from observing cached state. */ + registerPolling(scope: GitWorkspaceScope): () => void { + const key = this.normalizePath(scope); + this.pollingCounts.set(key, (this.pollingCounts.get(key) ?? 0) + 1); + if (!this.pollSchedules.has(key)) { + this.pollSchedules.set(key, { + failures: 0, + nextAttemptAt: Date.now() + this.pollPolicy(scope).intervalMs, + }); + } + // An activation refresh must not be swallowed by the basic layer's Infinity + // TTL. Keep a recent read reusable when several consumers mount together. + const lastRead = this.states.get(key)?.lastRefreshTime.basic ?? 0; + if (Date.now() - lastRead >= this.pollPolicy(scope).intervalMs) { + this.invalidateCache(scope, ['basic']); + } + this.ensurePollingStarted(); + return () => { + const count = this.pollingCounts.get(key) ?? 0; + if (count > 1) { + this.pollingCounts.set(key, count - 1); + } else { + this.pollingCounts.delete(key); + // Use the captured surface key; cleanup may run after a device switch. + // A manual/operation refresh merged into this batch must still execute. + const pending = this.pendingRefreshes.get(key); + if (pending?.pollOnly) { + clearTimeout(this.refreshDebounceTimers.get(key)); + this.refreshDebounceTimers.delete(key); + this.pendingRefreshes.delete(key); + pending.resolve(); } + if (this.pollingCounts.size === 0) this.stopPolling(); } }; } + private pollPolicy(scope: GitWorkspaceScope): { intervalMs: number; connectionId?: string } { + const workspace = workspaceManager.getState().openedWorkspaces.get(scope.workspaceId); + if (!workspace || isRemoteWorkspace(workspace)) { + // Missing catalog metadata must never select the aggressive local cadence. + return { + intervalMs: REMOTE_POLL_INTERVAL_MS, + connectionId: workspace?.connectionId ?? scope.workspaceId, + }; + } + return { intervalMs: POLL_INTERVAL_MS }; + } + + private scheduleNextPoll(key: string, failed: boolean): void { + const failures = failed ? Math.min((this.pollSchedules.get(key)?.failures ?? 0) + 1, 8) : 0; + this.pollSchedules.set(key, { + failures, + nextAttemptAt: Date.now() + Math.min( + this.pollPolicy(this.scopeForKey(key)).intervalMs * 2 ** failures, + MAX_POLL_BACKOFF_MS, + ), + }); + } + /** * Get current state synchronously (cached). */ @@ -294,6 +353,9 @@ export class GitStateManager { this.stopPolling(); this.subscribers.clear(); + this.pollingCounts.clear(); + this.pollSchedules.clear(); + this.pollingConnections.clear(); this.states.clear(); @@ -338,6 +400,7 @@ export class GitStateManager { let pending = this.pendingRefreshes.get(repositoryPath); if (pending) { + pending.pollOnly = pending.pollOnly && reason === 'interval'; for (const layer of layers) { pending.layers.add(layer); @@ -355,6 +418,7 @@ export class GitStateManager { force, reason, source, + pollOnly: reason === 'interval', resolve: () => {}, reject: () => {}, }; @@ -398,6 +462,14 @@ export class GitStateManager { this.pendingRefreshes.delete(repositoryPath); this.refreshDebounceTimers.delete(repositoryPath); + if (pending.pollOnly && ( + !this.pollingCounts.has(repositoryPath) || document.hidden || isPeerDeviceModeActive() || + gitWorkspaceKey(this.scopeForKey(repositoryPath)) !== repositoryPath + )) { + pending.resolve(); + return; + } + const { layers, force, reason, source, resolve, reject } = pending; try { @@ -507,22 +579,24 @@ export class GitStateManager { lastRefreshTime: newLastRefreshTime, isRefreshing: false, refreshingLayers: new Set(), - ...(probedTrust ? { repositoryTrustRequired: false } : {}), + ...(probedTrust ? { repositoryTrustRequired: false, error: null } : {}), }); + if (probedTrust) this.scheduleNextPoll(repositoryPath, false); const finalState = this.getState(this.scopeForKey(repositoryPath))!; const comparison = compareStates(prevState, finalState); if (comparison.hasChanges) { this.notifySubscribers(repositoryPath, finalState, prevState, comparison.changedLayers); + this.emitStateChanged(repositoryPath, finalState, comparison.changedLayers, reason); + } else if (prevState.error !== finalState.error) { + this.notifySubscribers(repositoryPath, finalState, prevState, layersToRefresh); } - - this.emitStateChanged(repositoryPath, finalState, layersToRefresh, reason); - } catch (error) { probeOutcome = 'error'; probeError = error instanceof Error ? error.message : String(error); + this.scheduleNextPoll(repositoryPath, true); // The ownership wall reaches here as a stable code, not as prose. Say // what happened instead of showing the raw code to the user. // @@ -558,7 +632,7 @@ export class GitStateManager { // that would drop a blocked repository back to "not a repository" — the // status read throws before `isRepository` is ever set — and send the // user to initialize one over a repository that is already there. - this.updateState(repositoryPath, { + const failedState = this.updateState(repositoryPath, { isRefreshing: false, refreshingLayers: new Set(), error: errorMessage, @@ -568,6 +642,10 @@ export class GitStateManager { ? { repositoryTrustRequired: false } : {}), }); + if (prevState.error !== failedState.error || + prevState.repositoryTrustRequired !== failedState.repositoryTrustRequired) { + this.notifySubscribers(repositoryPath, failedState, prevState, layersToRefresh); + } throw error; } finally { @@ -646,65 +724,60 @@ export class GitStateManager { repositoryPath: string, layersToRefresh: GitStateLayer[] ): Promise { - try { - const isRepo = await gitAPI.isGitRepository(this.scopeForKey(repositoryPath)); - - // The probe answers `true` for a repository Git refuses on ownership - // grounds — local and remote alike — so `false` here really is "no - // repository", and clearing the trust flag states a fact rather than - // hiding one. The ownership case falls through to the status read below, - // whose stable error sets the flag in the caller's catch. - if (!isRepo) { - this.updateState(repositoryPath, { - isRepository: false, - repositoryTrustRequired: false, - currentBranch: null, - ahead: 0, - behind: 0, - hasChanges: false, - staged: [], - unstaged: [], - untracked: [], - conflicts: [], - }); - return; - } - - const shouldRefreshStatus = layersToRefresh.includes('status'); - if (!shouldRefreshStatus) { - const repository = await gitAPI.getRepositoryBasic(this.scopeForKey(repositoryPath)); - const currentState = this.getOrCreateState(this.scopeForKey(repositoryPath)); - this.updateState(repositoryPath, { - isRepository: true, - currentBranch: repository.current_branch || repository.branch || null, - hasChanges: currentState.hasChanges, - }); - return; - } - - const status = await gitAPI.getStatus(this.scopeForKey(repositoryPath), 'git_state_manager'); - - const hasChanges = - (status.staged?.length || 0) > 0 || - (status.unstaged?.length || 0) > 0 || - (status.untracked?.length || 0) > 0; + const isRepo = await gitAPI.isGitRepository(this.scopeForKey(repositoryPath)); + + // The probe answers `true` for a repository Git refuses on ownership + // grounds — local and remote alike — so `false` here really is "no + // repository", and clearing the trust flag states a fact rather than + // hiding one. The ownership case falls through to the status read below, + // whose stable error sets the flag in the caller's catch. + if (!isRepo) { + this.updateState(repositoryPath, { + isRepository: false, + repositoryTrustRequired: false, + currentBranch: null, + ahead: 0, + behind: 0, + hasChanges: false, + staged: [], + unstaged: [], + untracked: [], + conflicts: [], + }); + return; + } + const shouldRefreshStatus = layersToRefresh.includes('status'); + if (!shouldRefreshStatus) { + const repository = await gitAPI.getRepositoryBasic(this.scopeForKey(repositoryPath)); + const currentState = this.getOrCreateState(this.scopeForKey(repositoryPath)); this.updateState(repositoryPath, { isRepository: true, - currentBranch: status.current_branch || null, - ahead: status.ahead || 0, - behind: status.behind || 0, - hasChanges, - staged: status.staged || [], - unstaged: status.unstaged || [], - untracked: status.untracked || [], - conflicts: status.conflicts || [], + currentBranch: repository.current_branch || repository.branch || null, + hasChanges: currentState.hasChanges, }); - - } catch (error) { - log.error('Failed to refresh basic and status', { repositoryPath, error }); - throw error; + return; } + + const status = await gitAPI.getStatus(this.scopeForKey(repositoryPath), 'git_state_manager'); + + const hasChanges = + (status.staged?.length || 0) > 0 || + (status.unstaged?.length || 0) > 0 || + (status.untracked?.length || 0) > 0; + + this.updateState(repositoryPath, { + isRepository: true, + currentBranch: status.current_branch || null, + ahead: status.ahead || 0, + behind: status.behind || 0, + hasChanges, + staged: status.staged || [], + unstaged: status.unstaged || [], + untracked: status.untracked || [], + conflicts: status.conflicts || [], + }); + } /** @@ -1001,7 +1074,7 @@ export class GitStateManager { * Start the interval-based poll if not already running. The poll is global * (one timer for all repositories) and pauses while the document is hidden * to avoid unnecessary work in background tabs. It only refreshes repos - * that currently have subscribers and always uses a silent basic-layer + * that currently have polling consumers and always uses a silent basic-layer * refresh so status/detailed caches are not thrashed. */ private ensurePollingStarted(): void { @@ -1011,7 +1084,7 @@ export class GitStateManager { const handleVisibility = (): void => { if (document.hidden) { this.stopPollingTimerOnly(); - } else if (this.subscribers.size > 0) { + } else if (this.pollingCounts.size > 0) { // Kick off an immediate refresh so we don't lag by up to one // interval after becoming visible, then resume the timer. this.pollTick(); @@ -1033,7 +1106,7 @@ export class GitStateManager { } private startPollingTimer(): void { - if (this.pollTimer !== null || this.subscribers.size === 0) return; + if (this.pollTimer !== null || this.pollingCounts.size === 0) return; this.pollTimer = setInterval(() => { this.pollTick(); }, POLL_INTERVAL_MS); @@ -1060,8 +1133,14 @@ export class GitStateManager { // on the transport's state sync instead of issuing local git calls. return; } - for (const repoKey of this.subscribers.keys()) { + for (const repoKey of this.pollingCounts.keys()) { const scope = this.scopeForKey(repoKey); + if (gitWorkspaceKey(scope) !== repoKey) continue; + if ((this.pollSchedules.get(repoKey)?.nextAttemptAt ?? 0) > Date.now() || + this.refreshLocks.has(repoKey) || this.pendingRefreshes.has(repoKey)) continue; + const { connectionId } = this.pollPolicy(scope); + if (connectionId && this.pollingConnections.has(connectionId)) continue; + if (connectionId) this.pollingConnections.add(connectionId); // Invalidate the basic-layer timestamp so the non-forced refresh below // bypasses the Infinity TTL and actually hits Git. We only invalidate // `basic` — status/detailed keep their longer TTLs and are refreshed @@ -1071,8 +1150,10 @@ export class GitStateManager { layers: ['basic'], reason: 'interval', silent: true, - }).catch((error) => { - log.debug('Poll-tick refresh failed', { repositoryPath: repoKey, error }); + }).catch(() => { + // doRefresh reports the failure and schedules exponential retry backoff. + }).finally(() => { + if (connectionId) this.pollingConnections.delete(connectionId); }); } } diff --git a/src/web-ui/src/tools/git/state/types.ts b/src/web-ui/src/tools/git/state/types.ts index 3390dd813d..6a06bed3ac 100644 --- a/src/web-ui/src/tools/git/state/types.ts +++ b/src/web-ui/src/tools/git/state/types.ts @@ -231,6 +231,7 @@ export function compareStates( const statusFields: (keyof GitState)[] = ['staged', 'unstaged', 'untracked', 'conflicts']; for (const field of statusFields) { + if (prevState[field] === newState[field]) continue; const prevVal = JSON.stringify(prevState[field]); const newVal = JSON.stringify(newState[field]); if (prevVal !== newVal) { @@ -244,6 +245,7 @@ export function compareStates( const detailedFields: (keyof GitState)[] = ['branches', 'commits']; for (const field of detailedFields) { + if (prevState[field] === newState[field]) continue; const prevVal = JSON.stringify(prevState[field]); const newVal = JSON.stringify(newState[field]); if (prevVal !== newVal) {