Files
orca/src/main/ipc/worktree-git-common-polling.ts
T
Neil 8b7d778a2e perf(git-common): bound the fs-stat fan-out in the worktree pollers (#17839)
* perf(git-common): bound the fs-stat fan-out in the worktree pollers

snapshotGitCommon and snapshotBase issued one fs op per candidate via
Promise.all/a serial loop, unbounded by worktree count. At 973 live
worktrees this queued ~6,800 concurrent stat calls (measured peak 6000
in a 1000-entry synthetic benchmark) onto libuv's 4-thread default
pool, starving every other main-process fs operation for the scan's
duration (~1s). Bound both to concurrency 8 via the existing
forEachWithConcurrency helper, matching the precedent in
exact-ref-probe.ts and worktree-head-identity-reader.ts. Peak
concurrent stats dropped 6000 -> 48 in the benchmark; wall time was
essentially unchanged (495ms -> 541ms), since the real bottleneck was
never total scan time but pool starvation of unrelated work.

Also make the no-native-watch and crash-fuse polling fallbacks in
worktree-git-common-watch.ts / worktree-git-common-narrow-watch.ts
self-calibrate their cadence: on platforms/paths where this poller is
the sole change signal, a fixed 2s cadence at hundreds of worktrees
approaches a permanent scan loop. Stretch the interval so a scan stays
a bounded fraction (10%) of its own cadence, capped at 30s, floored at
the configured base interval. Left the reconciliation backstop (fixed
30s cadence, already accepted) and checkPendingMarkers (bounded by
concurrent-worktree-creation count, not total count) untouched.

Fixes #17828

* perf(git-common): split the tripwire from the per-entry sweep cadence

Review on #17839 found a real staleness trade-off: adaptiveCadence
gated ALL detection (worktree add/remove, HEAD, dirty refs, AND
per-entry commit signals) behind one stretched interval, so on the
crash-fuse polling fallback the reviewer measured cadence sitting at
5.4-10s sustained and hitting the 30s cap once a single scan reached
3s at 973 worktrees -- worse than the pre-#17828 fixed ~2s+250ms
baseline for signals users notice immediately (sidebar worktree list,
branch labels).

Split snapshotGitCommon into a cheap structural "tripwire" (readdir,
worktreesDir signature, primary-file signatures, newly-appeared
entries -- ~5-6 fs ops, O(1) in worktree count) that always runs on
the fixed pollIntervalMs, and the O(n) per-entry sweep (commit/dirty
detection) that alone is gated by the adaptive cadence via a
nextSweepDueAt deadline. Existing, unchanged entries are carried over
by reference on a tripwire-only tick (no re-stat), so diffing produces
no spurious events; genuinely new entries are still stat'd immediately
so worktree add remains real-time. This keeps everything on one
ticking-flag-guarded loop (no new concurrency/race surface) --
scheduling stays fixed at pollIntervalMs; only nextSweepDueAt stretches.

Also drop the adaptive-cadence seed heuristic entirely: nextSweepDueAt
starts at 0, so the first regular tick after bootstrap sweeps
unconditionally on its own schedule instead of guessing an initial
interval from the bootstrap snapshot's duration (which could stretch
the very first tick to 10-30s on a slow disk).

Documented that worktree-git-common-watch.ts's adaptiveCadence call
site is unreachable in production (Electron only ships
darwin/linux/win32, both covered by NARROW_WATCH_PLATFORMS) rather
than implying it protects real users. The reachable path is the
narrow-watch crash-fuse fallback in worktree-git-common-narrow-watch.ts.

Filed #17878 to track the real long-term fix: periodically retrying
the upgrade back to the narrow watch after a crash-fuse trip, so the
degraded/polling state doesn't need to be tuned at all once the
underlying failure clears.

* perf(git-common): gate per-entry structural stats on the entry-dir signature

Every real git write inside a worktree admin entry (HEAD, index,
config.worktree, locked) goes through a lock file + rename, which moves
the entry directory's own mtime/ctime/size signature. Only `gitdir`
(worktree move/repair) is rewritten in place, and that's already covered
by the periodic ungated backstop (INDEX_BACKSTOP_TICKS). The previous
comment claiming structural leaves "change in place every tick" was
wrong; verified against git 2.55 across checkout, commit, amend, reset,
ref updates, stash, worktree lock/unlock, config --worktree, and index
writes.

Gate all six per-entry stats behind the entry dir's own signature instead
of stat-ing every leaf unconditionally every tick: an unchanged entry now
costs one stat per tick instead of six, and a changed one still costs six
(bounded by change rate, not worktree count). This also fixes the actual
in-flight fan-out: forEachWithConcurrency(entries, 8) previously still
issued 6 stats per in-flight entry (48 real concurrent ops); with the
gate, warm ticks issue ~1 stat per entry, so true in-flight tracks the
concurrency limit directly.

This makes the follow-up adaptive-cadence machinery from the prior commit
unnecessary: the crash-fuse and no-narrow-watch polling fallbacks no
longer need to stretch their own cadence, since a warm sweep across
hundreds of worktrees is now cheap regardless of interval. Revert both
call sites to a fixed pollIntervalMs and delete the adaptive-cadence
option, the split tripwire/sweep cadence, and the seed heuristic — none
of it earns its complexity once the real per-entry cost is fixed at the
source. Per-entry staleness on the crash-fuse path returns to a fixed 2s
+ 250ms debounce instead of the previous 5.4-30s adaptive stretch.

Refs #17828
2026-09-01 19:04:03 -07:00

230 lines
8.3 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>()
): 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))
})
// 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 = {
/** Reconciliation backstop: never trust the per-entry gate, re-stat every tick. */
forceFullScanEveryTick?: boolean
}
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
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 =
options.forceFullScanEveryTick === true ||
forceFullScan ||
tickCount % INDEX_BACKSTOP_TICKS === 0
try {
const next = await snapshotGitCommon(
commonDirPath,
snapshot,
includePrimary,
shouldForceFullScan,
new Set(getStatusRefPaths())
)
if (disposed) {
return
}
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()
}
}
}