Skip to content

Commit 9655770

Browse files
authored
Merge pull request #3391 from kev1n77/fmy/bugfix
fix(git): reduce remote polling overhead and redundant state events
2 parents a9d2b37 + 9e2320c commit 9655770

8 files changed

Lines changed: 383 additions & 92 deletions

File tree

‎src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,11 @@ describe('getWorkspaceGitBasicInfoOptions', () => {
6262
});
6363
});
6464

65-
it('keeps remote workspace rows on the existing default git refresh behavior', () => {
65+
it('defers inactive remote workspace rows until activation too', () => {
6666
expect(getWorkspaceGitBasicInfoOptions(createWorkspace(WorkspaceKind.Remote), false))
67-
.toBeUndefined();
67+
.toMatchObject({ isActive: false, refreshOnMount: false, refreshOnActive: true });
68+
expect(getWorkspaceGitBasicInfoOptions(createWorkspace(WorkspaceKind.Remote), true))
69+
.toMatchObject({ isActive: true, refreshOnMount: true, refreshOnActive: true });
6870
});
6971

7072
it('suppresses only mount refresh during a history session transition', () => {

‎src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isRemoteWorkspace, type WorkspaceInfo } from '@/shared/types';
1+
import type { WorkspaceInfo } from '@/shared/types';
22
import type { GitBasicInfoOptions } from '@/tools/git/hooks/useGitState';
33

44
export const WORKSPACE_GIT_BASIC_INFO_SOURCE = 'workspace_item_git_basic_info';
@@ -10,13 +10,9 @@ export const WORKSPACE_GIT_PENDING_CANCEL_SOURCES = [
1010
export const WORKSPACE_GIT_PENDING_CANCEL_REASONS = ['mount', 'visibility'] as const;
1111

1212
export function getWorkspaceGitBasicInfoOptions(
13-
workspace: WorkspaceInfo,
13+
_workspace: WorkspaceInfo,
1414
isActive: boolean
1515
): GitBasicInfoOptions | undefined {
16-
if (isRemoteWorkspace(workspace)) {
17-
return undefined;
18-
}
19-
2016
return {
2117
isActive,
2218
refreshOnMount: isActive,

‎src/web-ui/src/tools/git/AGENTS.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Git state and refresh verification
2+
3+
For changes to Git state subscriptions, polling, refresh scheduling, or workspace
4+
row activation, run the focused contracts from the repository root:
5+
6+
```bash
7+
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
8+
```
9+
10+
Subscriptions observe state; only active consumers register polling demand.
11+
Keep remote polling conservative, serialize background polls per SSH connection,
12+
and preserve manual/operation refreshes during retry backoff. Unchanged refreshes
13+
must not emit state-change events. Retain repository trust error/recovery behavior.
14+
15+
These tests mock host Git calls. Report live SSH and peer-device verification
16+
separately; fake timers and mocked transports are not remote integration evidence.

‎src/web-ui/src/tools/git/hooks/useGitState.test.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useGitState } from './useGitState';
1010
const gitStateManagerMock = vi.hoisted(() => ({
1111
getState: vi.fn(() => null),
1212
subscribe: vi.fn(() => vi.fn()),
13+
registerPolling: vi.fn(() => vi.fn()),
1314
registerWindowFocusRefresh: vi.fn(() => vi.fn()),
1415
refresh: vi.fn(() => Promise.resolve()),
1516
cancelPendingRefresh: vi.fn(() => false),
@@ -67,6 +68,8 @@ describe('useGitState visibility refresh', () => {
6768
});
6869

6970
expect(gitStateManagerMock.refresh).not.toHaveBeenCalled();
71+
expect(gitStateManagerMock.subscribe).toHaveBeenCalledTimes(1);
72+
expect(gitStateManagerMock.registerPolling).not.toHaveBeenCalled();
7073
expect(gitStateManagerMock.registerWindowFocusRefresh).not.toHaveBeenCalled();
7174

7275
await act(async () => {
@@ -83,6 +86,13 @@ describe('useGitState visibility refresh', () => {
8386
},
8487
);
8588
expect(gitStateManagerMock.registerWindowFocusRefresh).toHaveBeenCalledTimes(1);
89+
expect(gitStateManagerMock.registerPolling).toHaveBeenCalledTimes(1);
90+
const releasePolling = gitStateManagerMock.registerPolling.mock.results[0].value;
91+
await act(async () => {
92+
root.render(<GitStateHarness isActive={false} />);
93+
});
94+
expect(releasePolling).toHaveBeenCalledTimes(1);
95+
expect(gitStateManagerMock.subscribe).toHaveBeenCalledTimes(1);
8696
});
8797

8898
it('cancels and replays active automatic refreshes when mount refresh is temporarily suppressed', async () => {

‎src/web-ui/src/tools/git/hooks/useGitState.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ export function useGitState(options: UseGitStateOptions): UseGitStateReturn {
122122

123123
const isFirstMountRef = useRef(true);
124124

125+
useEffect(() => {
126+
if (!normalizedPath.workspaceId || !isActive) return;
127+
return gitStateManager.registerPolling(normalizedPath);
128+
}, [isActive, normalizedPath]);
129+
125130
useEffect(() => {
126131
if (!normalizedPath.workspaceId || !isActive || !participateInWindowFocusRefresh) {
127132
return;

‎src/web-ui/src/tools/git/state/GitStateManager.test.ts‎

Lines changed: 182 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
33
import { GitStateManager } from './GitStateManager';
44
import type { GitStateLayer } from './types';
5+
import { globalEventBus } from '@/infrastructure/event-bus';
6+
7+
const workspaceCatalog = vi.hoisted(() => new Map<string, { workspaceKind: string; connectionId?: string }>());
8+
const peerMode = vi.hoisted(() => ({ active: false }));
9+
vi.mock('@/infrastructure/services/business/workspaceManager', () => ({
10+
workspaceManager: { getState: () => ({ openedWorkspaces: workspaceCatalog }) },
11+
}));
12+
vi.mock('@/infrastructure/peer-device/peerModeFlag', () => ({
13+
isPeerDeviceModeActive: () => peerMode.active,
14+
}));
515

616
const gitApiMocks = vi.hoisted(() => ({
717
isGitRepository: vi.fn(),
@@ -59,6 +69,10 @@ describe('GitStateManager refresh performance guards', () => {
5969

6070
beforeEach(() => {
6171
vi.useFakeTimers();
72+
workspaceCatalog.clear();
73+
workspaceCatalog.set('workspace-1', { workspaceKind: 'normal' });
74+
workspaceCatalog.set('workspace-2', { workspaceKind: 'normal' });
75+
peerMode.active = false;
6276
GitStateManager.resetInstance();
6377
manager = GitStateManager.getInstance();
6478
manager.setCacheConfig({ basic: 0, status: 0, detailed: 0 });
@@ -103,10 +117,10 @@ describe('GitStateManager refresh performance guards', () => {
103117
const addListener = vi.spyOn(document, 'addEventListener');
104118
const removeListener = vi.spyOn(document, 'removeEventListener');
105119
try {
106-
const unsubscribe = manager.subscribe(repositoryPath, () => {});
120+
const unsubscribe = manager.registerPolling(repositoryPath);
107121
hidden.mockReturnValue(true);
108122
document.dispatchEvent(new Event('visibilitychange'));
109-
const unsubscribeOther = manager.subscribe({ workspaceId: 'workspace-2' }, () => {});
123+
const unsubscribeOther = manager.registerPolling({ workspaceId: 'workspace-2' });
110124
await vi.advanceTimersByTimeAsync(4100);
111125
expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
112126
const listeners = addListener.mock.calls.filter(([type]) => type === 'visibilitychange');
@@ -131,7 +145,7 @@ describe('GitStateManager refresh performance guards', () => {
131145
it('does not start polling for an initially hidden document', async () => {
132146
const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
133147
try {
134-
manager.subscribe(repositoryPath, () => {});
148+
manager.registerPolling(repositoryPath);
135149
await vi.advanceTimersByTimeAsync(4100);
136150
expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
137151
hidden.mockReturnValue(false);
@@ -164,6 +178,171 @@ describe('GitStateManager refresh performance guards', () => {
164178
});
165179
});
166180

181+
it('keeps passive subscriptions free of IO and reference-counts active demand', async () => {
182+
manager.subscribe(repositoryPath, () => {});
183+
await vi.advanceTimersByTimeAsync(60_000);
184+
expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
185+
const releaseA = manager.registerPolling(repositoryPath);
186+
const releaseB = manager.registerPolling(repositoryPath);
187+
releaseA();
188+
await vi.advanceTimersByTimeAsync(2100);
189+
expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(1);
190+
releaseB();
191+
await vi.advanceTimersByTimeAsync(60_000);
192+
expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(1);
193+
});
194+
195+
it('polls active SSH workspaces slowly and never broadcasts unchanged results', async () => {
196+
workspaceCatalog.set('workspace-1', { workspaceKind: 'remote', connectionId: 'ssh-a' });
197+
workspaceCatalog.set('workspace-2', { workspaceKind: 'remote', connectionId: 'ssh-b' });
198+
for (const workspaceId of ['workspace-1', 'workspace-2', 'workspace-3']) {
199+
manager.subscribe({ workspaceId }, () => {});
200+
}
201+
manager.registerPolling(repositoryPath);
202+
await vi.advanceTimersByTimeAsync(29_999);
203+
expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
204+
await vi.advanceTimersByTimeAsync(101);
205+
expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(1);
206+
vi.mocked(globalEventBus.emit).mockClear();
207+
gitEventServiceMock.emit.mockClear();
208+
await vi.advanceTimersByTimeAsync(36_000);
209+
expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(2);
210+
expect(globalEventBus.emit).not.toHaveBeenCalled();
211+
expect(gitEventServiceMock.emit).not.toHaveBeenCalled();
212+
expect(gitApiMocks.getStatus).not.toHaveBeenCalled();
213+
});
214+
215+
it('serializes background reads on one SSH connection without starving another repository', async () => {
216+
for (const workspaceId of ['workspace-1', 'workspace-2']) {
217+
workspaceCatalog.set(workspaceId, { workspaceKind: 'remote', connectionId: 'ssh-a' });
218+
manager.registerPolling({ workspaceId });
219+
}
220+
const read = deferred<any>();
221+
gitApiMocks.getRepositoryBasic.mockReturnValueOnce(read.promise);
222+
await vi.advanceTimersByTimeAsync(40_000);
223+
expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1);
224+
read.resolve({ current_branch: 'main' });
225+
await vi.advanceTimersByTimeAsync(2100);
226+
expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(2);
227+
expect(gitApiMocks.isGitRepository.mock.calls.map(([scope]) => scope.workspaceId))
228+
.toEqual(['workspace-1', 'workspace-2']);
229+
});
230+
231+
it('backs off failures, allows immediate manual recovery and clears the visible error', async () => {
232+
workspaceCatalog.set('workspace-1', { workspaceKind: 'remote', connectionId: 'ssh-a' });
233+
const seen = vi.fn();
234+
manager.subscribe(repositoryPath, seen);
235+
manager.registerPolling(repositoryPath);
236+
gitApiMocks.isGitRepository.mockRejectedValueOnce(new Error('SSH disconnected'));
237+
await vi.advanceTimersByTimeAsync(30_100);
238+
expect(seen.mock.lastCall?.[0].error).toBe('SSH disconnected');
239+
await vi.advanceTimersByTimeAsync(59_000);
240+
expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1);
241+
const retry = manager.refresh(repositoryPath, { layers: ['basic'], force: true });
242+
await vi.advanceTimersByTimeAsync(100);
243+
await retry;
244+
expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(2);
245+
expect(seen.mock.lastCall?.[0].error).toBeNull();
246+
await vi.advanceTimersByTimeAsync(33_000);
247+
expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(3);
248+
});
249+
250+
it('preserves peer-device polling suppression and uses conservative cadence without metadata', async () => {
251+
workspaceCatalog.clear();
252+
manager.registerPolling(repositoryPath);
253+
await vi.advanceTimersByTimeAsync(6000);
254+
expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
255+
peerMode.active = true;
256+
await vi.advanceTimersByTimeAsync(60_000);
257+
expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
258+
peerMode.active = false;
259+
});
260+
261+
it('increases offline retry delays and caps them at five minutes', async () => {
262+
workspaceCatalog.set('workspace-1', { workspaceKind: 'remote', connectionId: 'ssh-a' });
263+
gitApiMocks.isGitRepository.mockRejectedValue(new Error('SSH disconnected'));
264+
manager.registerPolling(repositoryPath);
265+
await vi.advanceTimersByTimeAsync(30_100);
266+
expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1);
267+
await vi.advanceTimersByTimeAsync(60_000);
268+
expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1);
269+
await vi.advanceTimersByTimeAsync(2000);
270+
expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(2);
271+
await vi.advanceTimersByTimeAsync(120_000);
272+
expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(2);
273+
await vi.advanceTimersByTimeAsync(2000);
274+
expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(3);
275+
await vi.advanceTimersByTimeAsync(242_000);
276+
expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(4);
277+
await vi.advanceTimersByTimeAsync(302_000);
278+
expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(5);
279+
await vi.advanceTimersByTimeAsync(302_000);
280+
expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(6);
281+
});
282+
283+
it('cancels queued polling on deactivation but preserves a merged manual refresh', async () => {
284+
const release = manager.registerPolling(repositoryPath);
285+
await vi.advanceTimersByTimeAsync(2000);
286+
release();
287+
await vi.advanceTimersByTimeAsync(100);
288+
expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
289+
const releaseAgain = manager.registerPolling(repositoryPath);
290+
await vi.advanceTimersByTimeAsync(2000);
291+
const manual = manager.refresh(repositoryPath, { layers: ['basic'], force: true });
292+
releaseAgain();
293+
await vi.advanceTimersByTimeAsync(100);
294+
await manual;
295+
expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1);
296+
});
297+
298+
it('drops a queued poll if the document becomes hidden during debounce', async () => {
299+
const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(false);
300+
try {
301+
manager.registerPolling(repositoryPath);
302+
await vi.advanceTimersByTimeAsync(2000);
303+
hidden.mockReturnValue(true);
304+
document.dispatchEvent(new Event('visibilitychange'));
305+
await vi.advanceTimersByTimeAsync(100);
306+
expect(gitApiMocks.isGitRepository).not.toHaveBeenCalled();
307+
} finally {
308+
hidden.mockRestore();
309+
}
310+
});
311+
312+
it('notifies error recovery even when the branch and files stay unchanged', async () => {
313+
const first = manager.refresh(repositoryPath, { layers: ['basic'], force: true });
314+
await vi.advanceTimersByTimeAsync(100);
315+
await first;
316+
const seen = vi.fn();
317+
manager.subscribe(repositoryPath, seen, { immediate: false, layers: ['basic'] });
318+
gitApiMocks.isGitRepository.mockRejectedValueOnce(new Error('Offline'));
319+
const failed = manager.refresh(repositoryPath, { layers: ['basic'], force: true }).catch(() => {});
320+
await vi.advanceTimersByTimeAsync(100);
321+
await failed;
322+
expect(seen.mock.lastCall?.[0].error).toBe('Offline');
323+
const recovered = manager.refresh(repositoryPath, { layers: ['basic'], force: true, silent: true });
324+
await vi.advanceTimersByTimeAsync(100);
325+
await recovered;
326+
expect(seen).toHaveBeenCalledTimes(2);
327+
expect(seen.mock.lastCall?.[0].error).toBeNull();
328+
});
329+
330+
it('refreshes stale basic data on activation even with the default infinite TTL', async () => {
331+
manager.setCacheConfig({ basic: Infinity });
332+
const first = manager.refresh(repositoryPath, { layers: ['basic'], force: true });
333+
await vi.advanceTimersByTimeAsync(100);
334+
await first;
335+
await vi.advanceTimersByTimeAsync(30_000);
336+
manager.registerPolling(repositoryPath);
337+
gitApiMocks.getRepositoryBasic.mockResolvedValueOnce({ current_branch: 'external-change' });
338+
const activate = manager.refresh(repositoryPath, { layers: ['basic'], reason: 'visibility' });
339+
await vi.advanceTimersByTimeAsync(100);
340+
await activate;
341+
expect(manager.getState(repositoryPath)?.currentBranch).toBe('external-change');
342+
expect(globalEventBus.emit).toHaveBeenLastCalledWith('git:state:changed',
343+
expect.objectContaining({ changedLayers: ['basic'] }));
344+
});
345+
167346
it('merges duplicate mount refreshes for the same repository and layer', async () => {
168347
const first = manager.refresh(repositoryPath, {
169348
layers: ['basic', 'status'],

0 commit comments

Comments
 (0)