fix(worktree): widen git-common watch on event-batch overflow (#17916)

This commit is contained in:
Neil
2026-09-01 20:25:42 -07:00
committed by GitHub
parent a7db6c336b
commit 7a69357856
8 changed files with 508 additions and 355 deletions
@@ -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<string> {
try {
return statSignature(await stat(path))
} catch {
return 'missing'
}
}
async function hasGitMarker(dir: string): Promise<boolean> {
try {
await stat(join(dir, '.git'))
return true
} catch {
return false
}
}
type BaseSnapshot = {
// worktree-candidate dir → whether its `.git` completion marker exists
markers: Map<string, boolean>
// 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<Dirent[]> {
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: `<wt>/.git` completion markers and `<wt>` deletions.
async function snapshotBase(
rootPath: string,
repos: ReadonlyMap<string, WorktreeBaseRepoWatchConfig>
): 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(
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<string, WorktreeBaseRepoWatchConfig>,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
options: WorktreeBasePollerOptions
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let tickCount = 0
let snapshot = await snapshotBase(target.path, getRepos())
let timer: ReturnType<typeof setTimeout> | 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<string, number | null>()
for (const [dir, marker] of snapshot.markers) {
if (!marker) {
markerProbeStartedAt.set(dir, 0)
}
}
const fullScan = async (): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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()
}
}
}
+7 -280
View File
@@ -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<void> }
@@ -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<string> {
try {
return statSignature(await stat(path))
} catch {
return 'missing'
}
}
async function hasGitMarker(dir: string): Promise<boolean> {
try {
await stat(join(dir, '.git'))
return true
} catch {
return false
}
}
type BaseSnapshot = {
// worktree-candidate dir → whether its `.git` completion marker exists
markers: Map<string, boolean>
// 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<Dirent[]> {
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: `<wt>/.git` completion markers and `<wt>` deletions.
async function snapshotBase(
rootPath: string,
repos: ReadonlyMap<string, WorktreeBaseRepoWatchConfig>
): 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(
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<string, WorktreeBaseRepoWatchConfig>,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
options: WorktreeBasePollerOptions
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let tickCount = 0
let snapshot = await snapshotBase(target.path, getRepos())
let timer: ReturnType<typeof setTimeout> | 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<string, number | null>()
for (const [dir, marker] of snapshot.markers) {
if (!marker) {
markerProbeStartedAt.set(dir, 0)
}
}
const fullScan = async (): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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)
@@ -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<void> }
gitStatusRefPaths: Set<string>
watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown
}
export function handleLocalWatchEvents(
watch: ActiveWatch,
error: Error | null,
events: { type: 'create' | 'update' | 'delete'; path: string }[],
getActiveWatches: () => Iterable<ActiveWatch>
): 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<ActiveWatch>
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
invalidateActiveGitStatusRefResolution(watch, getActiveWatches)
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
}
export function handleRemoteWatchEvents(
watch: ActiveWatch,
events: Parameters<typeof collectRemoteWorktreeBaseChanges>[1],
getActiveWatches: () => Iterable<ActiveWatch>
): 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)
}
}
@@ -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)
+16 -72
View File
@@ -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<void> }
gitStatusRefPaths: Set<string>
watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown
}
import {
handleLocalWatchEvents,
handleRemoteWatchEvents,
handleWatchOverflow,
type ActiveWatch
} from './worktree-base-directory-watch-events'
const activeWatches = new Map<string, ActiveWatch>()
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<typeof collectRemoteWorktreeBaseChanges>[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())
}
}
}
@@ -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<WorktreeBaseSubscription> {
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 }])
}
}
}
)
@@ -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)
+4 -2
View File
@@ -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<WorktreeBaseSubscription> {
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,