test(git-watch): reserve replacement root inode

This commit is contained in:
Neil
2026-08-23 21:17:24 -07:00
parent c5c7898977
commit 962a0dddcf
2 changed files with 25 additions and 84 deletions
+1 -34
View File
@@ -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<void>
@@ -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()
})
+24 -50
View File
@@ -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<void> {
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<string> {