diff --git a/src/main/ipc/git-metadata-poll-scheduler.ts b/src/main/ipc/git-metadata-poll-scheduler.ts index 8f32f89eac1..b4a983d0bd5 100644 --- a/src/main/ipc/git-metadata-poll-scheduler.ts +++ b/src/main/ipc/git-metadata-poll-scheduler.ts @@ -67,14 +67,11 @@ export class GitMetadataPollScheduler { private nextId = 1 private activePolls = 0 private activeFilesystemOperations = 0 - private resetGeneration = 0 - private now: () => number constructor( private readonly concurrency = GIT_METADATA_FILESYSTEM_CONCURRENCY, - now: () => number = () => performance.now() + private readonly now: () => number = () => performance.now() ) { - this.now = now if (!Number.isInteger(concurrency) || concurrency < 1) { throw new Error('Git metadata scheduler concurrency must be a positive integer') } @@ -163,28 +160,6 @@ export class GitMetadataPollScheduler { return promise } - setNowForTests(now: () => number): void { - this.now = now - } - - resetForTests(): void { - this.resetGeneration++ - if (this.timer) { - clearTimeout(this.timer) - this.timer = null - } - for (const poll of this.polls.values()) { - poll.disposed = true - poll.unsubscribeVisibility() - poll.queued = false - } - this.polls.clear() - this.pollQueue.length = 0 - this.filesystemQueue.length = 0 - this.activePolls = 0 - this.activeFilesystemOperations = 0 - } - private unsubscribePoll(poll: ScheduledPoll): void { if (poll.disposed) { return @@ -277,7 +252,6 @@ export class GitMetadataPollScheduler { } const isVisibilityCatchUp = poll.pendingVisibilityCatchUp poll.pendingVisibilityCatchUp = false - const resetGeneration = this.resetGeneration poll.running = true this.activePolls++ let result: void | Promise @@ -291,9 +265,6 @@ export class GitMetadataPollScheduler { // Each poller owns its transient-error policy; keep the host scheduler alive. }) .finally(() => { - if (resetGeneration !== this.resetGeneration) { - return - } poll.running = false this.activePolls-- if (!poll.disposed && poll.rerunPending) { @@ -310,12 +281,8 @@ export class GitMetadataPollScheduler { private drainFilesystemQueue(): void { while (this.activeFilesystemOperations < this.concurrency && this.filesystemQueue.length > 0) { const operation = this.filesystemQueue.shift()! - const resetGeneration = this.resetGeneration this.activeFilesystemOperations++ void operation.execute().finally(() => { - if (resetGeneration !== this.resetGeneration) { - return - } this.activeFilesystemOperations-- this.drainFilesystemQueue() }) diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index 7646628e1ff..df783358a2a 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { appendFile, mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises' import type * as NodeFsPromises from 'node:fs/promises' import { performance as nodePerformance } from 'node:perf_hooks' @@ -18,17 +18,10 @@ import type { } from './worktree-base-directory-poller' import { startGitCommonWatch } from './worktree-git-common-watch' import { startGitCommonPolling } from './worktree-git-common-polling' -import { gitMetadataPollScheduler } from './git-metadata-poll-scheduler' vi.mock('./parcel-watcher-process', () => ({ subscribeViaWatcherProcess: vi.fn() })) - -beforeEach(() => { - gitMetadataPollScheduler.resetForTests() - gitMetadataPollScheduler.setNowForTests(() => Date.now()) -}) - // Records every stat target so a test can assert which paths a parked poll stopped touching. const { statCalls } = vi.hoisted(() => ({ statCalls: [] as string[] })) @@ -54,6 +47,20 @@ const alwaysVisible: WorktreePollerWindowVisibility = { onWindowBecameVisible: () => () => {} } +async function replaceWorktreesRoot( + commonDir: string, + worktreesDir: string, + retainedEntry: string +): Promise { + await rm(worktreesDir, { recursive: true }) + // Linux can reuse a just-deleted directory inode. Reserve that inode with a + // sibling before recreating the watched root so reconciliation sees a replacement. + const inodeReservation = join(commonDir, 'worktrees-replacement-inode') + await mkdir(inodeReservation) + await mkdir(retainedEntry, { recursive: true }) + await rm(inodeReservation, { recursive: true, force: true }) +} + function createVisibilityHarness(): { source: WorktreePollerWindowVisibility hide: () => void @@ -100,7 +107,6 @@ describe('worktree git-common narrow watch (darwin)', () => { afterEach(async () => { await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) - gitMetadataPollScheduler.resetForTests() childSubscriptions = [] statCalls.length = 0 subscribeMock.mockReset() @@ -524,7 +530,6 @@ describe('worktree git-common narrow watch (darwin)', () => { it('re-arms the native stream when the root is replaced with the same child names', async () => { vi.useFakeTimers() - vi.clearAllTimers() const restorePerformanceNow = vi .spyOn(nodePerformance, 'now') .mockImplementation(() => Date.now()) @@ -538,18 +543,9 @@ describe('worktree git-common narrow watch (darwin)', () => { await startWatch(commonDir, received) const staleSubscription = childSubscriptions[0] - await rm(worktreesDir, { recursive: true }) - await mkdir(retainedEntry, { recursive: true }) + await replaceWorktreesRoot(commonDir, worktreesDir, retainedEntry) await vi.advanceTimersByTimeAsync(POLL_MS * RECONCILIATION_TICKS * 4) - await vi.runOnlyPendingTimersAsync() - // Native filesystem promises settle outside the fake timer callback; wait for the - // re-subscribe observable rather than relying on timer advancement to flush them. - await vi.waitFor( - () => { - expect(subscribeMock).toHaveBeenCalledTimes(2) - }, - { timeout: 1_000 } - ) + expect(subscribeMock).toHaveBeenCalledTimes(2) expect(staleSubscription.unsubscribe).toHaveBeenCalledOnce() const beforeStaleEvent = received.length @@ -568,7 +564,6 @@ describe('worktree git-common narrow watch (darwin)', () => { it('disposes an in-flight stale resubscribe and fences its interruption hook', async () => { vi.useFakeTimers() - vi.clearAllTimers() const restorePerformanceNow = vi .spyOn(nodePerformance, 'now') .mockImplementation(() => Date.now()) @@ -589,31 +584,16 @@ describe('worktree git-common narrow watch (darwin)', () => { const onWatchError = vi.fn() await startWatch(commonDir, received, () => [], onWatchError) - await rm(worktreesDir, { recursive: true }) - await mkdir(retainedEntry, { recursive: true }) + await replaceWorktreesRoot(commonDir, worktreesDir, retainedEntry) await vi.advanceTimersByTimeAsync(POLL_MS * RECONCILIATION_TICKS * 4) - await vi.runOnlyPendingTimersAsync() - await vi.waitFor( - () => { - expect(subscribeMock).toHaveBeenCalledTimes(2) - }, - { timeout: 1_000 } - ) + expect(subscribeMock).toHaveBeenCalledTimes(2) const stalePendingSubscription = childSubscriptions[1] - await rm(worktreesDir, { recursive: true }) - await mkdir(retainedEntry, { recursive: true }) + await replaceWorktreesRoot(commonDir, worktreesDir, retainedEntry) await vi.advanceTimersByTimeAsync(POLL_MS * RECONCILIATION_TICKS * 4) - await vi.waitFor( - () => { - expect( - received - .flat() - .filter((event) => event.type === 'create' && event.path === worktreesDir) - ).toHaveLength(2) - }, - { timeout: 1_000 } - ) + expect( + received.flat().filter((event) => event.type === 'create' && event.path === worktreesDir) + ).toHaveLength(2) const beforeStaleInterruption = received.length stalePendingSubscription.hooks.onInterruption?.() @@ -626,12 +606,7 @@ describe('worktree git-common narrow watch (darwin)', () => { } }) await vi.advanceTimersByTimeAsync(POLL_MS) - await vi.waitFor( - () => { - expect(subscribeMock).toHaveBeenCalledTimes(3) - }, - { timeout: 1_000 } - ) + expect(subscribeMock).toHaveBeenCalledTimes(3) expect(stalePendingSubscription.unsubscribe).toHaveBeenCalledOnce() const immediateEntry = join(worktreesDir, 'current-generation') @@ -671,7 +646,6 @@ describe('worktree git-common polling gate (non-darwin)', () => { afterEach(async () => { await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) - gitMetadataPollScheduler.resetForTests() }) async function makePollingCommonDir(): Promise {