diff --git a/src/main/ipc/git-metadata-poll-scheduler.ts b/src/main/ipc/git-metadata-poll-scheduler.ts index b4a983d0bd5..56ecc6e70e6 100644 --- a/src/main/ipc/git-metadata-poll-scheduler.ts +++ b/src/main/ipc/git-metadata-poll-scheduler.ts @@ -67,6 +67,7 @@ export class GitMetadataPollScheduler { private nextId = 1 private activePolls = 0 private activeFilesystemOperations = 0 + private resetGeneration = 0 constructor( private readonly concurrency = GIT_METADATA_FILESYSTEM_CONCURRENCY, @@ -160,6 +161,24 @@ export class GitMetadataPollScheduler { return promise } + 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 @@ -252,6 +271,7 @@ export class GitMetadataPollScheduler { } const isVisibilityCatchUp = poll.pendingVisibilityCatchUp poll.pendingVisibilityCatchUp = false + const resetGeneration = this.resetGeneration poll.running = true this.activePolls++ let result: void | Promise @@ -265,6 +285,9 @@ 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) { @@ -281,8 +304,12 @@ 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 888d56ab3d6..75c50baddba 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, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, 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,11 +18,16 @@ 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() +}) + // Records every stat target so a test can assert which paths a parked poll stopped touching. const { statCalls } = vi.hoisted(() => ({ statCalls: [] as string[] })) @@ -94,6 +99,7 @@ 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() @@ -662,6 +668,7 @@ 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 {