fix(worktree-poller): sample the scan gate before its listing (#13772)

A write landing during a full scan was recorded by the post-scan gate stat,
so the next tick saw a clean gate and the create/delete waited up to 15 ticks
(~30s) for the backstop. Sample each gate dir's signature before its listing
so a racing write reads as stale and forces one rescan on the next tick.

Also fixes the flaky 'retires stale marker probes' spec: a folder caught
mid-rm opened a pending-marker window that the swallowed gate change kept
alive for the whole backstop gap (43 probes vs the 30 budget).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-11 01:14:28 -07:00
committed by GitHub
co-authored by Orca
parent 9b79cc1b9e
commit 8fad723f84
2 changed files with 53 additions and 8 deletions
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { writeFileSync } from 'node:fs'
import { mkdirSync, writeFileSync } from 'node:fs'
import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -265,6 +265,44 @@ describe('worktree base directory poller', () => {
).toHaveLength(2)
})
it('rescans on the next tick when a write races an in-flight full scan', async () => {
const root = await makeRoot()
const worktree = join(root, 'raced')
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('base', root)
const snapshotTicks: number[] = []
let raced = false
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{
pollIntervalMs: 0,
onSnapshotTaken: (tick) => {
snapshotTicks.push(tick)
if (raced) {
return
}
raced = true
mkdirSync(worktree)
writeFileSync(join(worktree, '.git'), 'gitdir: elsewhere')
}
}
)
cleanups.push(() => poller.unsubscribe())
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git'))
)
// A write landing after a scan's listings must leave the gate stale, so the
// next tick rescans instead of deferring the create to the backstop.
expect(snapshotTicks.slice(0, 2)).toEqual([
WORKTREE_BASE_BACKSTOP_TICKS,
WORKTREE_BASE_BACKSTOP_TICKS + 1
])
})
it('parks base scans while hidden and losslessly detects changes on resume', async () => {
const root = await makeRoot()
const visibility = createVisibilityHarness()
+14 -7
View File
@@ -65,6 +65,8 @@ export type WorktreeBasePollerOptions = {
onFullScan?: () => void
/** Test hook: called before a pending `.git` marker stat. */
onPendingMarkerProbe?: (path: string) => void
/** Test hook: awaited with the tick after a full scan's listings, to land a racing write. */
onSnapshotTaken?: (tick: number) => void | Promise<void>
/** Test hook: overrides the fast-probe window. */
pendingMarkerMaxTicks?: number
}
@@ -117,6 +119,8 @@ type BaseSnapshot = {
// dirs whose listing determines the candidate set: the root plus any
// nested repo containers. Their stat signatures gate the next full scan.
gateDirs: string[]
// index-aligned with gateDirs, each sampled *before* that dir's listing
gateSignatures: string[]
}
// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested
@@ -128,6 +132,10 @@ async function snapshotBase(
): Promise<BaseSnapshot> {
const markers = new Map<string, boolean>()
const gateDirs = [rootPath]
// Why: sampling the signature before the listing makes a write that races the
// scan look stale next tick (one redundant rescan) instead of invisible until
// the backstop, which is up to 15 ticks of missed creates/deletes.
const gateSignatures = [await dirSignature(rootPath)]
const configs = [...repos.values()]
const includeFlat = configs.some((config) => !config.nestWorkspaces)
const nestedRepoNames = new Set(
@@ -142,7 +150,7 @@ async function snapshotBase(
} catch {
// Root vanished: an empty snapshot diffs into delete events for every
// previously-known worktree dir, matching the old watcher's error path.
return { markers, gateDirs }
return { markers, gateDirs, gateSignatures }
}
const candidates: string[] = []
@@ -156,6 +164,7 @@ async function snapshotBase(
}
if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) {
gateDirs.push(entryPath)
gateSignatures.push(await dirSignature(entryPath))
let subEntries
try {
subEntries = await readdir(entryPath, { withFileTypes: true })
@@ -173,7 +182,7 @@ async function snapshotBase(
for (const dir of candidates) {
markers.set(dir, await hasGitMarker(dir))
}
return { markers, gateDirs }
return { markers, gateDirs, gateSignatures }
}
function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] {
@@ -203,7 +212,6 @@ async function startBasePoller(
let ticking = false
let tickCount = 0
let snapshot = await snapshotBase(target.path, getRepos())
let gateSignatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
let timer: ReturnType<typeof setTimeout> | null = null
let parkedWhileHidden = false
const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS
@@ -218,7 +226,7 @@ async function startBasePoller(
const fullScan = async (): Promise<void> => {
options.onFullScan?.()
const next = await snapshotBase(target.path, getRepos())
const nextSignatures = await Promise.all(next.gateDirs.map(dirSignature))
await options.onSnapshotTaken?.(tickCount)
if (disposed) {
return
}
@@ -236,7 +244,6 @@ async function startBasePoller(
}
}
snapshot = next
gateSignatures = nextSignatures
if (events.length > 0) {
onEvents(events)
}
@@ -274,8 +281,8 @@ async function startBasePoller(
// are untouched, skip the readdir + per-candidate stat fan-out entirely.
const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
const gateChanged =
signatures.length !== gateSignatures.length ||
signatures.some((sig, index) => sig !== gateSignatures[index])
signatures.length !== snapshot.gateSignatures.length ||
signatures.some((sig, index) => sig !== snapshot.gateSignatures[index])
if (gateChanged) {
await fullScan()
return