From 7a6935785630737ef649a372cef0af09fc0d4c19 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:25:42 -0700 Subject: [PATCH] fix(worktree): widen git-common watch on event-batch overflow (#17916) --- .../worktree-base-directory-marker-poller.ts | 289 ++++++++++++++++++ .../ipc/worktree-base-directory-poller.ts | 287 +---------------- .../worktree-base-directory-watch-events.ts | 90 ++++++ .../worktree-base-directory-watcher.test.ts | 40 +++ .../ipc/worktree-base-directory-watcher.ts | 88 +----- .../ipc/worktree-git-common-narrow-watch.ts | 24 +- .../ipc/worktree-git-common-watch.test.ts | 39 +++ src/main/ipc/worktree-git-common-watch.ts | 6 +- 8 files changed, 508 insertions(+), 355 deletions(-) create mode 100644 src/main/ipc/worktree-base-directory-marker-poller.ts create mode 100644 src/main/ipc/worktree-base-directory-watch-events.ts diff --git a/src/main/ipc/worktree-base-directory-marker-poller.ts b/src/main/ipc/worktree-base-directory-marker-poller.ts new file mode 100644 index 00000000000..dba1c4df03c --- /dev/null +++ b/src/main/ipc/worktree-base-directory-marker-poller.ts @@ -0,0 +1,289 @@ +import { readdir, stat } from 'node:fs/promises' +import type { Dirent } from 'node:fs' +import { join } from 'node:path' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { forEachWithConcurrency } from '../../shared/map-with-concurrency' +import type { + WorktreeBaseRepoWatchConfig, + WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' +import type { + WorktreeBasePollerOptions, + WorktreeBasePollEvent, + WorktreeBaseSubscription, + WorktreePollerWindowVisibility +} from './worktree-base-directory-poller' + +// Why: the mtime gate is an optimization, not a correctness boundary — some +// filesystems have coarse dir timestamps, and pending `.git` markers expire. +// A periodic ungated scan guarantees eventual convergence. +export const WORKTREE_BASE_BACKSTOP_TICKS = 15 + +// Why: a `.git` completion marker lands within moments of its worktree dir +// (git writes it before populating the checkout). Dirs that never get one are +// not worktrees; stop re-statting them after this many ticks and let the +// backstop scan cover the pathological case. +const PENDING_MARKER_MAX_TICKS = 300 + +// Why: matches the git-common poller's fan-out bound (#17828) — bounded +// concurrency turns hundreds of serial round trips into a handful of batches +// without dumping every candidate onto libuv's 4-thread pool at once. +const MARKER_PROBE_CONCURRENCY = 8 + +function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { + return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` +} + +async function dirSignature(path: string): Promise { + try { + return statSignature(await stat(path)) + } catch { + return 'missing' + } +} + +async function hasGitMarker(dir: string): Promise { + try { + await stat(join(dir, '.git')) + return true + } catch { + return false + } +} + +type BaseSnapshot = { + // worktree-candidate dir → whether its `.git` completion marker exists + markers: Map + // 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[] +} + +async function readdirSafe(path: string): Promise { + try { + return await readdir(path, { withFileTypes: true }) + } catch { + return [] + } +} + +// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested +// repo's container, mirroring what worktree-base-directory-event-filter +// matches: `/.git` completion markers and `` deletions. +async function snapshotBase( + rootPath: string, + repos: ReadonlyMap +): 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( + configs + .filter((config) => config.nestWorkspaces) + .map((config) => normalizeRuntimePathForComparison(config.repoName)) + ) + + // Root vanished or unreadable: readdirSafe yields [], producing the same + // empty markers/candidates result as the old watcher's error path. + const rootEntries = await readdirSafe(rootPath) + + const candidates: string[] = [] + for (const entry of rootEntries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) { + continue + } + const entryPath = join(rootPath, entry.name) + if (includeFlat) { + candidates.push(entryPath) + } + if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { + gateDirs.push(entryPath) + gateSignatures.push(await dirSignature(entryPath)) + const subEntries = await readdirSafe(entryPath) + for (const sub of subEntries) { + if (sub.isDirectory() || sub.isSymbolicLink()) { + candidates.push(join(entryPath, sub.name)) + } + } + } + } + + await forEachWithConcurrency(candidates, MARKER_PROBE_CONCURRENCY, async (dir) => { + markers.set(dir, await hasGitMarker(dir)) + }) + return { markers, gateDirs, gateSignatures } +} + +function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + for (const [dir, marker] of next.markers) { + if (marker && prev.markers.get(dir) !== true) { + events.push({ type: 'create', path: join(dir, '.git') }) + } + } + for (const dir of prev.markers.keys()) { + if (!next.markers.has(dir)) { + events.push({ type: 'delete', path: dir }) + } + } + return events +} + +export async function startBasePoller( + target: WorktreeBaseWatchTarget, + getRepos: () => ReadonlyMap, + onEvents: (events: WorktreeBasePollEvent[]) => void, + pollIntervalMs: number, + visibility: WorktreePollerWindowVisibility, + options: WorktreeBasePollerOptions +): Promise { + let disposed = false + let ticking = false + let tickCount = 0 + let snapshot = await snapshotBase(target.path, getRepos()) + let timer: ReturnType | null = null + let parkedWhileHidden = false + const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS + // dir → first probe tick; null means backstop scans only + const markerProbeStartedAt = new Map() + for (const [dir, marker] of snapshot.markers) { + if (!marker) { + markerProbeStartedAt.set(dir, 0) + } + } + + const fullScan = async (): Promise => { + options.onFullScan?.() + const next = await snapshotBase(target.path, getRepos()) + await options.onSnapshotTaken?.(tickCount) + if (disposed) { + return + } + const events = diffBase(snapshot, next) + for (const [dir, marker] of next.markers) { + if (marker) { + markerProbeStartedAt.delete(dir) + } else if (!markerProbeStartedAt.has(dir)) { + markerProbeStartedAt.set(dir, tickCount) + } + } + for (const dir of markerProbeStartedAt.keys()) { + if (!next.markers.has(dir)) { + markerProbeStartedAt.delete(dir) + } + } + snapshot = next + if (events.length > 0) { + onEvents(events) + } + } + + const checkPendingMarkers = async (): Promise => { + const events: WorktreeBasePollEvent[] = [] + for (const [dir, firstSeenTick] of markerProbeStartedAt) { + if (firstSeenTick === null) { + continue + } + if (tickCount - firstSeenTick > pendingMarkerMaxTicks) { + markerProbeStartedAt.set(dir, null) + continue + } + options.onPendingMarkerProbe?.(join(dir, '.git')) + if (await hasGitMarker(dir)) { + markerProbeStartedAt.delete(dir) + snapshot.markers.set(dir, true) + events.push({ type: 'create', path: join(dir, '.git') }) + } + } + if (!disposed && events.length > 0) { + onEvents(events) + } + } + + const poll = async (forceFullScan = false): Promise => { + tickCount++ + if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) { + await fullScan() + return + } + // Idle fast path: when the dirs whose listings define the candidate set + // are untouched, skip the readdir + per-candidate stat fan-out entirely. + const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) + const gateChanged = + signatures.length !== snapshot.gateSignatures.length || + signatures.some((sig, index) => sig !== snapshot.gateSignatures[index]) + if (gateChanged) { + await fullScan() + return + } + if (markerProbeStartedAt.size > 0) { + await checkPendingMarkers() + } + } + + const tick = async (forceFullScan = false): Promise => { + timer = null + if (disposed) { + return + } + if (!visibility.isWindowVisible()) { + parkedWhileHidden = true + return + } + if (ticking) { + return + } + ticking = true + // Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not + // gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick. + const startedAt = Date.now() + try { + await poll(forceFullScan) + } catch { + // Transient fs error: keep the previous snapshot and retry next tick. + } finally { + ticking = false + } + if (!disposed) { + // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would + // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for + // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. + const nextDelay = Math.max( + 0, + Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) + ) + timer = setTimeout(() => void tick(), nextDelay) + timer.unref?.() + } + } + + const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { + if (disposed || !parkedWhileHidden) { + return + } + parkedWhileHidden = false + // Why: the ordinary dir-signature gate can miss same-granule changes made + // while hidden; resume must diff a fresh full snapshot against the baseline. + void tick(true) + }) + + timer = setTimeout(() => void tick(), pollIntervalMs) + timer.unref?.() + + return { + unsubscribe: async () => { + disposed = true + if (timer) { + clearTimeout(timer) + } + unsubscribeVisibility() + } + } +} diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index 201d9782fba..39c5b5f48c0 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -1,15 +1,13 @@ -import { readdir, stat } from 'node:fs/promises' -import type { Dirent } from 'node:fs' -import { join } from 'node:path' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { forEachWithConcurrency } from '../../shared/map-with-concurrency' import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { WorktreeBaseRepoWatchConfig, WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { startBasePoller } from './worktree-base-directory-marker-poller' import { startGitCommonWatch } from './worktree-git-common-watch' +export { WORKTREE_BASE_BACKSTOP_TICKS } from './worktree-base-directory-marker-poller' + export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path: string } export type WorktreeBaseSubscription = { unsubscribe: () => Promise } @@ -63,6 +61,8 @@ export type WorktreeBasePollerOptions = { visibility?: WorktreePollerWindowVisibility getGitStatusRefPaths?: () => readonly string[] onWatchError?: (error: Error) => void + /** Called when the watcher child dropped an event batch (git-common narrow watch only). */ + onOverflow?: () => void /** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */ onFullScan?: () => void /** Test hook: called before a pending `.git` marker stat. */ @@ -83,280 +83,6 @@ export type WorktreeBasePollerOptions = { // Orca's own worktree operations notify the renderer directly. export const WORKTREE_BASE_POLL_INTERVAL_MS = 2_000 -// Why: the mtime gate is an optimization, not a correctness boundary — some -// filesystems have coarse dir timestamps, and pending `.git` markers expire. -// A periodic ungated scan guarantees eventual convergence. -export const WORKTREE_BASE_BACKSTOP_TICKS = 15 - -// Why: a `.git` completion marker lands within moments of its worktree dir -// (git writes it before populating the checkout). Dirs that never get one are -// not worktrees; stop re-statting them after this many ticks and let the -// backstop scan cover the pathological case. -const PENDING_MARKER_MAX_TICKS = 300 - -// Why: matches the git-common poller's fan-out bound (#17828) — bounded -// concurrency turns hundreds of serial round trips into a handful of batches -// without dumping every candidate onto libuv's 4-thread pool at once. -const MARKER_PROBE_CONCURRENCY = 8 - -function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { - return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` -} - -async function dirSignature(path: string): Promise { - try { - return statSignature(await stat(path)) - } catch { - return 'missing' - } -} - -async function hasGitMarker(dir: string): Promise { - try { - await stat(join(dir, '.git')) - return true - } catch { - return false - } -} - -type BaseSnapshot = { - // worktree-candidate dir → whether its `.git` completion marker exists - markers: Map - // 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[] -} - -async function readdirSafe(path: string): Promise { - try { - return await readdir(path, { withFileTypes: true }) - } catch { - return [] - } -} - -// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested -// repo's container, mirroring what worktree-base-directory-event-filter -// matches: `/.git` completion markers and `` deletions. -async function snapshotBase( - rootPath: string, - repos: ReadonlyMap -): 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( - configs - .filter((config) => config.nestWorkspaces) - .map((config) => normalizeRuntimePathForComparison(config.repoName)) - ) - - // Root vanished or unreadable: readdirSafe yields [], producing the same - // empty markers/candidates result as the old watcher's error path. - const rootEntries = await readdirSafe(rootPath) - - const candidates: string[] = [] - for (const entry of rootEntries) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) { - continue - } - const entryPath = join(rootPath, entry.name) - if (includeFlat) { - candidates.push(entryPath) - } - if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { - gateDirs.push(entryPath) - gateSignatures.push(await dirSignature(entryPath)) - const subEntries = await readdirSafe(entryPath) - for (const sub of subEntries) { - if (sub.isDirectory() || sub.isSymbolicLink()) { - candidates.push(join(entryPath, sub.name)) - } - } - } - } - - await forEachWithConcurrency(candidates, MARKER_PROBE_CONCURRENCY, async (dir) => { - markers.set(dir, await hasGitMarker(dir)) - }) - return { markers, gateDirs, gateSignatures } -} - -function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] { - const events: WorktreeBasePollEvent[] = [] - for (const [dir, marker] of next.markers) { - if (marker && prev.markers.get(dir) !== true) { - events.push({ type: 'create', path: join(dir, '.git') }) - } - } - for (const dir of prev.markers.keys()) { - if (!next.markers.has(dir)) { - events.push({ type: 'delete', path: dir }) - } - } - return events -} - -async function startBasePoller( - target: WorktreeBaseWatchTarget, - getRepos: () => ReadonlyMap, - onEvents: (events: WorktreeBasePollEvent[]) => void, - pollIntervalMs: number, - visibility: WorktreePollerWindowVisibility, - options: WorktreeBasePollerOptions -): Promise { - let disposed = false - let ticking = false - let tickCount = 0 - let snapshot = await snapshotBase(target.path, getRepos()) - let timer: ReturnType | null = null - let parkedWhileHidden = false - const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS - // dir → first probe tick; null means backstop scans only - const markerProbeStartedAt = new Map() - for (const [dir, marker] of snapshot.markers) { - if (!marker) { - markerProbeStartedAt.set(dir, 0) - } - } - - const fullScan = async (): Promise => { - options.onFullScan?.() - const next = await snapshotBase(target.path, getRepos()) - await options.onSnapshotTaken?.(tickCount) - if (disposed) { - return - } - const events = diffBase(snapshot, next) - for (const [dir, marker] of next.markers) { - if (marker) { - markerProbeStartedAt.delete(dir) - } else if (!markerProbeStartedAt.has(dir)) { - markerProbeStartedAt.set(dir, tickCount) - } - } - for (const dir of markerProbeStartedAt.keys()) { - if (!next.markers.has(dir)) { - markerProbeStartedAt.delete(dir) - } - } - snapshot = next - if (events.length > 0) { - onEvents(events) - } - } - - const checkPendingMarkers = async (): Promise => { - const events: WorktreeBasePollEvent[] = [] - for (const [dir, firstSeenTick] of markerProbeStartedAt) { - if (firstSeenTick === null) { - continue - } - if (tickCount - firstSeenTick > pendingMarkerMaxTicks) { - markerProbeStartedAt.set(dir, null) - continue - } - options.onPendingMarkerProbe?.(join(dir, '.git')) - if (await hasGitMarker(dir)) { - markerProbeStartedAt.delete(dir) - snapshot.markers.set(dir, true) - events.push({ type: 'create', path: join(dir, '.git') }) - } - } - if (!disposed && events.length > 0) { - onEvents(events) - } - } - - const poll = async (forceFullScan = false): Promise => { - tickCount++ - if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) { - await fullScan() - return - } - // Idle fast path: when the dirs whose listings define the candidate set - // are untouched, skip the readdir + per-candidate stat fan-out entirely. - const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) - const gateChanged = - signatures.length !== snapshot.gateSignatures.length || - signatures.some((sig, index) => sig !== snapshot.gateSignatures[index]) - if (gateChanged) { - await fullScan() - return - } - if (markerProbeStartedAt.size > 0) { - await checkPendingMarkers() - } - } - - const tick = async (forceFullScan = false): Promise => { - timer = null - if (disposed) { - return - } - if (!visibility.isWindowVisible()) { - parkedWhileHidden = true - return - } - if (ticking) { - return - } - ticking = true - // Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not - // gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick. - const startedAt = Date.now() - try { - await poll(forceFullScan) - } catch { - // Transient fs error: keep the previous snapshot and retry next tick. - } finally { - ticking = false - } - if (!disposed) { - // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would - // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for - // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. - const nextDelay = Math.max( - 0, - Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) - ) - timer = setTimeout(() => void tick(), nextDelay) - timer.unref?.() - } - } - - const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { - if (disposed || !parkedWhileHidden) { - return - } - parkedWhileHidden = false - // Why: the ordinary dir-signature gate can miss same-granule changes made - // while hidden; resume must diff a fresh full snapshot against the baseline. - void tick(true) - }) - - timer = setTimeout(() => void tick(), pollIntervalMs) - timer.unref?.() - - return { - unsubscribe: async () => { - disposed = true - if (timer) { - clearTimeout(timer) - } - unsubscribeVisibility() - } - } -} - /** Watches the shallow paths a worktree base target cares about and emits * watcher-shaped events. Resolves once the baseline (snapshot or narrow * native subscription) is established. */ @@ -378,7 +104,8 @@ export async function startWorktreeBaseDirectoryPoller( visibility, options.onFullScan, options.getGitStatusRefPaths, - options.onWatchError + options.onWatchError, + options.onOverflow ) } return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options) diff --git a/src/main/ipc/worktree-base-directory-watch-events.ts b/src/main/ipc/worktree-base-directory-watch-events.ts new file mode 100644 index 00000000000..caed5627b2b --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watch-events.ts @@ -0,0 +1,90 @@ +import { + collectLocalWorktreeBaseChanges, + collectRemoteWorktreeBaseChanges, + hasCollectedWorktreeBaseChanges +} from './worktree-base-directory-change-collector' +import { + scheduleWorktreeBaseNotification, + type WorktreeBaseNotificationWatch +} from './worktree-base-directory-notifications' +import { + invalidateActiveGitStatusRefResolution, + invalidateGitStatusRefResolutionForPaths +} from './worktree-git-status-ref-watch' +import type { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown' + +export type ActiveWatch = WorktreeBaseNotificationWatch & { + subscription: { unsubscribe: () => Promise } + gitStatusRefPaths: Set + watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown +} + +export function handleLocalWatchEvents( + watch: ActiveWatch, + error: Error | null, + events: { type: 'create' | 'update' | 'delete'; path: string }[], + getActiveWatches: () => Iterable +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + if (error) { + console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) + invalidateActiveGitStatusRefResolution(watch, getActiveWatches) + if (watch.watcherFailureRefresh.consume()) { + scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) + } + return + } + watch.watcherFailureRefresh.reset() + invalidateGitStatusRefResolutionForPaths( + watch, + events.map((event) => event.path), + getActiveWatches + ) + const changes = collectLocalWorktreeBaseChanges(watch, events) + if (hasCollectedWorktreeBaseChanges(changes)) { + scheduleWorktreeBaseNotification(watch, changes) + } +} + +// Why: after a dropped event batch nothing about the prior state can be +// trusted — widen unconditionally (structural + status + head-identity), +// same shape as the remote overflow branch below, bypassing the watcher-error +// cooldown so a burst of overflows during one bulk op cannot suppress the +// refresh the fleet actually needs. +export function handleWatchOverflow( + watch: ActiveWatch, + getActiveWatches: () => Iterable +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + invalidateActiveGitStatusRefResolution(watch, getActiveWatches) + scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) +} + +export function handleRemoteWatchEvents( + watch: ActiveWatch, + events: Parameters[1], + getActiveWatches: () => Iterable +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + invalidateGitStatusRefResolutionForPaths( + watch, + events.flatMap((event) => + event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath] + ), + getActiveWatches + ) + const changes = collectRemoteWorktreeBaseChanges(watch, events) + if (changes.overflow) { + handleWatchOverflow(watch, getActiveWatches) + return + } + if (hasCollectedWorktreeBaseChanges(changes)) { + scheduleWorktreeBaseNotification(watch, changes) + } +} diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index 5d701b91211..a23bc901bdc 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -404,6 +404,46 @@ describe('worktree base directory watcher', () => { expect(notifyWorktreesChanged).toHaveBeenCalledOnce() }) + it('widens an overflowed local git-common watch to a structural refresh', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow + + const request = { + worktreeId: `repo-1::${PROJECT_ROOT}`, + worktreePath: PROJECT_ROOT, + executionHostId: 'local', + branch: 'refs/heads/feature', + upstreamName: 'origin/feature' + } + const resolve = vi.fn(async () => 'refs/remotes/origin/feature') + await setWorktreeGitStatusRefWatch(request, resolve) + + onOverflow?.() + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + // Overflow is definite proof of loss, not a possibly-transient error — it + // invalidates the cached ref resolution unconditionally. + await setWorktreeGitStatusRefWatch(request, resolve) + expect(resolve).toHaveBeenCalledTimes(2) + }) + + it('does not throttle repeated overflow refreshes the way watcher-error refreshes are throttled', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow + + onOverflow?.() + await vi.advanceTimersByTimeAsync(300) + onOverflow?.() + await vi.advanceTimersByTimeAsync(300) + + // A watcher-error burst within the 60s cooldown window collapses to one + // refresh (see "throttles repeated structural refreshes from watcher + // failures" above); overflow must not inherit that gate, since a bulk op + // can legitimately overflow more than once before it settles. + expect(notifyWorktreesChanged).toHaveBeenCalledTimes(2) + }) + it('keeps linked HEAD and lock metadata structural', async () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index 57a231e89b2..abb0d51782c 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -6,16 +6,9 @@ import { disposeWorktreeHeadIdentityRefreshState, refreshWorktreeHeadIdentities } from './worktree-head-identity-refresh' -import { - collectLocalWorktreeBaseChanges, - collectRemoteWorktreeBaseChanges, - hasCollectedWorktreeBaseChanges -} from './worktree-base-directory-change-collector' import { clearPendingWorktreeBaseNotifications, - scheduleWorktreeBaseNotification, - supportsWorktreeHeadIdentityRefresh, - type WorktreeBaseNotificationWatch + supportsWorktreeHeadIdentityRefresh } from './worktree-base-directory-notifications' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import { EMPTY_HEAD_IDENTITY_SCOPE } from './worktree-head-identity-scope' @@ -30,18 +23,16 @@ import { import { applyActiveGitStatusRefBinding, clearActiveGitStatusRefBinding, - invalidateActiveGitStatusRefResolution, - invalidateGitStatusRefResolutionForPaths, updateActiveGitStatusRefBinding, type GitStatusRefBindingRequest } from './worktree-git-status-ref-watch' import { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown' - -type ActiveWatch = WorktreeBaseNotificationWatch & { - subscription: { unsubscribe: () => Promise } - gitStatusRefPaths: Set - watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown -} +import { + handleLocalWatchEvents, + handleRemoteWatchEvents, + handleWatchOverflow, + type ActiveWatch +} from './worktree-base-directory-watch-events' const activeWatches = new Map() let syncGeneration = 0 @@ -54,59 +45,6 @@ export function setWorktreeGitStatusRefWatch( return updateActiveGitStatusRefBinding(args, () => activeWatches.values(), resolveUpstreamRef) } -function handleLocalWatchEvents( - watch: ActiveWatch, - error: Error | null, - events: { type: 'create' | 'update' | 'delete'; path: string }[] -): void { - if (watch.disposed || watch.mainWindow.isDestroyed()) { - return - } - if (error) { - console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) - invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values()) - if (watch.watcherFailureRefresh.consume()) { - scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) - } - return - } - watch.watcherFailureRefresh.reset() - invalidateGitStatusRefResolutionForPaths( - watch, - events.map((event) => event.path), - () => activeWatches.values() - ) - const changes = collectLocalWorktreeBaseChanges(watch, events) - if (hasCollectedWorktreeBaseChanges(changes)) { - scheduleWorktreeBaseNotification(watch, changes) - } -} - -function handleRemoteWatchEvents( - watch: ActiveWatch, - events: Parameters[1] -): void { - if (watch.disposed || watch.mainWindow.isDestroyed()) { - return - } - invalidateGitStatusRefResolutionForPaths( - watch, - events.flatMap((event) => - event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath] - ), - () => activeWatches.values() - ) - const changes = collectRemoteWorktreeBaseChanges(watch, events) - if (changes.overflow) { - invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values()) - scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) - return - } - if (hasCollectedWorktreeBaseChanges(changes)) { - scheduleWorktreeBaseNotification(watch, changes) - } -} - function createActiveWatch( target: WorktreeBaseWatchTarget, mainWindow: BrowserWindow, @@ -146,7 +84,7 @@ async function subscribeTarget( if (!currentWatch || currentWatch.disposed) { return } - handleRemoteWatchEvents(currentWatch, events) + handleRemoteWatchEvents(currentWatch, events, () => activeWatches.values()) }) activeWatch = createActiveWatch( target, @@ -167,7 +105,7 @@ async function subscribeTarget( (events) => { const currentWatch = activeWatches.get(target.key) ?? activeWatch if (currentWatch && !currentWatch.disposed) { - handleLocalWatchEvents(currentWatch, null, events) + handleLocalWatchEvents(currentWatch, null, events, () => activeWatches.values()) } }, { @@ -178,7 +116,13 @@ async function subscribeTarget( onWatchError: (error) => { const currentWatch = activeWatches.get(target.key) ?? activeWatch if (currentWatch && !currentWatch.disposed) { - handleLocalWatchEvents(currentWatch, error, []) + handleLocalWatchEvents(currentWatch, error, [], () => activeWatches.values()) + } + }, + onOverflow: () => { + const currentWatch = activeWatches.get(target.key) ?? activeWatch + if (currentWatch) { + handleWatchOverflow(currentWatch, () => activeWatches.values()) } } } diff --git a/src/main/ipc/worktree-git-common-narrow-watch.ts b/src/main/ipc/worktree-git-common-narrow-watch.ts index 99e245998a1..fa7c402c5ec 100644 --- a/src/main/ipc/worktree-git-common-narrow-watch.ts +++ b/src/main/ipc/worktree-git-common-narrow-watch.ts @@ -24,7 +24,12 @@ export async function startGitCommonNarrowWatch( platform: NodeJS.Platform, visibility: WorktreePollerWindowVisibility, onFullScan?: () => void, - onWatchError?: (error: Error) => void + onWatchError?: (error: Error) => void, + // Why: a dropped event batch (>5,000 events, e.g. a fleet-wide bulk op) is a + // harder loss signal than a transient error — nothing about the prior state + // can be trusted, so this bypasses onWatchError's failure cooldown instead + // of reusing it. + onOverflow?: () => void ): Promise { const worktreesDir = join(target.path, 'worktrees') const watcherOptions = platform === 'win32' ? { backend: 'windows' as const } : {} @@ -227,6 +232,23 @@ export async function startGitCommonNarrowWatch( onEvents([{ type: 'update', path: worktreesDir }]) } } + }, + // Why: the watcher child drops the whole batch past 5,000 events + // (native FSEvents overflow maps to the same op) instead of reporting + // which paths changed. Unlike a transient error, this is definite + // proof of loss, so it always widens rather than falling back to the + // failure-cooldown-gated onWatchError path. + onOverflow: () => { + if (disposed || !active || generation !== nativeSubscriptionGeneration) { + return + } + if (onOverflow) { + onOverflow() + } else if (onWatchError) { + onWatchError(new Error('Git common watcher overflowed')) + } else { + onEvents([{ type: 'update', path: worktreesDir }]) + } } } ) diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index 619133263de..bad700b5445 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -526,6 +526,45 @@ describe('worktree git-common narrow watch (local native platforms)', () => { expect(narrowSubscription().unsubscribe).not.toHaveBeenCalled() }) + it('routes a dropped event batch through the dedicated overflow callback', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const received: WorktreeBasePollEvent[][] = [] + const onOverflow = vi.fn() + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin', + alwaysVisible, + undefined, + () => [], + undefined, + onOverflow + ) + cleanups.push(() => watch.unsubscribe()) + + narrowSubscription().hooks.onOverflow?.() + + expect(onOverflow).toHaveBeenCalledOnce() + // The dedicated callback owns the refresh; the generic event/error paths + // must not also fire so the caller cannot double-count the same loss. + expect(received).toEqual([]) + expect(narrowSubscription().unsubscribe).not.toHaveBeenCalled() + }) + + it('falls back to a structural change when no overflow callback is wired', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + narrowSubscription().hooks.onOverflow?.() + + expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir }) + }) + it('arms via existence polling when the worktrees dir appears later', async () => { installSubscribeMock() const commonDir = await makeCommonDir(false) diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 8ed696872f7..d719ca257bb 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -31,7 +31,8 @@ export async function startGitCommonWatch( visibility: WorktreePollerWindowVisibility, onFullScan?: () => void, getStatusRefPaths: () => readonly string[] = () => [], - onWatchError?: (error: Error) => void + onWatchError?: (error: Error) => void, + onOverflow?: () => void ): Promise { if (supportsNarrowWatch(platform)) { const [narrowWatch, primaryWatch] = await Promise.all([ @@ -42,7 +43,8 @@ export async function startGitCommonWatch( platform, visibility, onFullScan, - onWatchError + onWatchError, + onOverflow ), startGitCommonPrimaryWatch( target.path,