From 8fad723f84ea7e1e852fc4b30dca5b21085fdf7e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:14:28 -0700 Subject: [PATCH] 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 --- .../worktree-base-directory-poller.test.ts | 40 ++++++++++++++++++- .../ipc/worktree-base-directory-poller.ts | 21 ++++++---- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/main/ipc/worktree-base-directory-poller.test.ts b/src/main/ipc/worktree-base-directory-poller.test.ts index e28823adbc8..101d48e22cb 100644 --- a/src/main/ipc/worktree-base-directory-poller.test.ts +++ b/src/main/ipc/worktree-base-directory-poller.test.ts @@ -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() diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index b5076c2ed7f..42ee184b811 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -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 /** 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 { const markers = new Map() 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 | null = null let parkedWhileHidden = false const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS @@ -218,7 +226,7 @@ async function startBasePoller( const fullScan = async (): Promise => { 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