Files
orca/src/main/ipc/worktree-git-common-polling.ts
T
NeilandClaude 8e8698ad98 Worktree list safety sweep skips unchanged worktrees instead of re-reading all of them every 30s (#31253)
* perf(worktrees): gate the 30s git-common reconciliation sweep on each entry's dir signature

The native-watch reconciliation forced an ungated stat of every leaf of every
worktree admin entry each tick (~7 stats/entry). It now uses the entry-dir
signature gate plus the two leaves git writes in place (gitdir, logs/HEAD),
runs the ungated sweep every 10 ticks (~5 min), and falls back to sweeping
every tick if a sweep ever finds a change the dir signature missed.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(worktrees): only distrust the reconciliation dir gate on a confirmed, repeated miss

Detect a missed change on any entry rescan, including ones the in-place leaf
probes trigger, not just full sweeps. Re-stat the admin dir after the leaf
rescan so a rename racing the scan isn't counted, ignore transient missing
leaves, and require misses on two separate ticks before sweeping every tick.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-20 16:21:46 -07:00

264 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { forEachWithConcurrency } from '../../shared/map-with-concurrency'
import { PRIMARY_CHECKOUT_METADATA_FILES } from './worktree-git-common-metadata-files'
import {
diffGitCommon,
gitCommonDirectoryIdentity,
type GitCommonSnapshot
} from './worktree-git-common-snapshot-diff'
import type {
WorktreeBasePollEvent,
WorktreeBaseSubscription,
WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
import {
gitCommonDirectorySignature,
gitCommonFileSignature,
snapshotGitCommonEntry,
type GitCommonEntrySnapshot
} from './worktree-git-common-entry-snapshot'
// Why: the entry-dir signature gate can miss same-granule index rewrites on
// coarse-mtime filesystems; a periodic ungated re-stat bounds that miss the
// same way the base poller's backstop rescan does.
const INDEX_BACKSTOP_TICKS = 15
// Why: an unbounded fan-out across every worktree admin entry queues thousands
// of ops on libuv's 4-thread default pool, starving every other main-process
// fs call for the scan's duration (#17828). 8 mirrors the existing
// head-identity/exact-ref-probe pools — enough to saturate typical local
// disks without monopolizing the pool. Since snapshotGitCommonEntry's own
// entry-dir gate (see worktree-git-common-entry-snapshot.ts) keeps most ticks
// down to 1 stat per unchanged entry, real in-flight is now bounded by this
// limit rather than limit × per-entry stat count.
const GIT_COMMON_SNAPSHOT_CONCURRENCY = 8
async function snapshotStatusRefSignatures(
paths: ReadonlySet<string>
): Promise<Map<string, string>> {
const signatures = new Map<string, string>()
await forEachWithConcurrency([...paths], GIT_COMMON_SNAPSHOT_CONCURRENCY, async (path) => {
const signature = await gitCommonFileSignature(path)
if (signature !== null) {
signatures.set(path, signature)
}
})
return signatures
}
async function snapshotPrimaryCheckoutSignatures(
commonDirPath: string
): Promise<Map<string, string>> {
const signatures = new Map<string, string>()
await Promise.all(
PRIMARY_CHECKOUT_METADATA_FILES.map(async (name) => {
const signature = await gitCommonFileSignature(join(commonDirPath, name))
if (signature !== null) {
signatures.set(name, signature)
}
})
)
return signatures
}
async function snapshotGitCommon(
commonDirPath: string,
previous?: GitCommonSnapshot,
includePrimary = true,
forceFullScan = false,
statusRefPaths = new Set<string>(),
statInPlaceLeaves = false,
onDirGateMiss?: () => void
): Promise<GitCommonSnapshot> {
const worktreesDir = join(commonDirPath, 'worktrees')
const [worktreesDirSignature, primarySignatures, statusRefSignatures] = await Promise.all([
gitCommonDirectorySignature(worktreesDir),
includePrimary ? snapshotPrimaryCheckoutSignatures(commonDirPath) : new Map<string, string>(),
snapshotStatusRefSignatures(statusRefPaths)
])
// Why: enumerate the worktrees dir EVERY tick rather than gating the readdir on its stat signature.
// A single readdir of a small dir is negligible next to the per-entry structural stats that already
// run each tick, and the signature gate could miss a same-granule add+remove on a coarse-mtime/FAT
// filesystem (its size/mtime/ino/ctime all collide), leaving a linked worktree add/remove undetected
// until the ~30s index backstop (#9882 review). The listing is the authoritative add/remove signal.
let entryPaths: string[]
try {
const entries = await readdir(worktreesDir, { withFileTypes: true })
entryPaths = entries
.filter((entry) => entry.isDirectory())
.map((entry) => join(worktreesDir, entry.name))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
// Dir genuinely absent (no linked worktrees, or all removed) → authoritative empty listing.
entryPaths = []
} else {
// Why: a TRANSIENT readdir failure (EIO/ESTALE/EMFILE, network/SSH hiccup) must not masquerade as
// "every worktree removed" — that would emit false delete events (and false creates next tick).
// Reuse the known entries so per-entry stats still run; a real removal surfaces as that entry's own
// stat miss (handled in snapshotGitCommonEntry), and the next successful readdir catches any add.
entryPaths = previous ? [...previous.entries.keys()] : []
}
}
const entries = new Map<string, GitCommonEntrySnapshot>()
await forEachWithConcurrency(entryPaths, GIT_COMMON_SNAPSHOT_CONCURRENCY, async (entryPath) => {
const previousEntry = previous?.entries.get(entryPath)
entries.set(
entryPath,
await snapshotGitCommonEntry(
entryPath,
previousEntry,
forceFullScan,
statInPlaceLeaves,
onDirGateMiss
)
)
})
// Why: the expensive per-entry `index` read stays gated on each entry's own dir signature; onFullScan
// now reflects an ungated index-metadata backstop fan-out (forceFullScan) — the real periodic cost —
// rather than the always-run worktrees-dir readdir.
return {
worktreesDirSignature,
worktreesDirIdentity: gitCommonDirectoryIdentity(worktreesDirSignature),
entries,
primarySignatures,
statusRefPaths,
statusRefSignatures,
didFullScan: forceFullScan
}
}
export type GitCommonPollingOptions = {
/**
* Native-watch reconciliation: gate entries on their dir signature plus the
* in-place leaves each tick, and run the ungated sweep every N ticks.
*/
reconciliationFullScanEveryTicks?: number
}
// Why: require the miss on two separate ticks so one unlucky timing can't
// disable the gate for the whole catalog.
const DIR_GATE_MISS_TICKS_TO_DISTRUST = 2
export async function startGitCommonPolling(
commonDirPath: string,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
onFullScan?: () => void,
includePrimary = true,
getStatusRefPaths: () => readonly string[] = () => [],
options: GitCommonPollingOptions = {}
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let tickCount = 0
const reconciliationFullScanEveryTicks = options.reconciliationFullScanEveryTicks
const isReconciliation = reconciliationFullScanEveryTicks !== undefined
// Why: a filesystem whose dir mtime ignores child renames (FAT/exFAT, some network
// mounts) would hide changes until the slow sweep; once one is seen, sweep every tick.
let entryDirGateTrusted = true
let dirGateMissTicks = 0
let snapshot = await snapshotGitCommon(
commonDirPath,
undefined,
includePrimary,
false,
new Set(getStatusRefPaths())
)
let timer: ReturnType<typeof setTimeout> | null = null
let parkedWhileHidden = false
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 cadence is start-to-start, not gap-after-completion (which would
// land each visible refresh a full scan-duration late every tick).
const startedAt = Date.now()
tickCount++
const shouldForceFullScan =
(isReconciliation && !entryDirGateTrusted) ||
forceFullScan ||
tickCount % (reconciliationFullScanEveryTicks ?? INDEX_BACKSTOP_TICKS) === 0
let dirGateMissed = false
try {
const next = await snapshotGitCommon(
commonDirPath,
snapshot,
includePrimary,
shouldForceFullScan,
new Set(getStatusRefPaths()),
isReconciliation,
isReconciliation
? () => {
dirGateMissed = true
}
: undefined
)
if (disposed) {
return
}
if (dirGateMissed && ++dirGateMissTicks >= DIR_GATE_MISS_TICKS_TO_DISTRUST) {
entryDirGateTrusted = false
}
if (next.didFullScan) {
onFullScan?.()
}
const events = diffGitCommon(commonDirPath, snapshot, next)
snapshot = next
if (events.length > 0) {
onEvents(events)
}
} 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: a linked index can change without its parent dir signature moving;
// force the leaf read when diffing the retained pre-hide snapshot.
void tick(true)
})
timer = setTimeout(() => void tick(), pollIntervalMs)
timer.unref?.()
return {
unsubscribe: async () => {
disposed = true
if (timer) {
clearTimeout(timer)
}
unsubscribeVisibility()
}
}
}