mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(worktree): widen git-common watch on event-batch overflow (#17916)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user