Cut fseventsd load: fix macOS exclusion cap, drop wide always-on FSEvents streams (#7068)

* Cut fseventsd load: fix macOS exclusion cap, drop wide always-on FSEvents streams

- @parcel/watcher maps plain-path ignores to FSEventStreamSetExclusionPaths,
  but macOS caps exclusions at 8 per stream and fails closed. Orca passed 9,
  silently disabling ALL daemon-side exclusions: fseventsd delivered every
  node_modules/.git event to each per-worktree explorer stream (measured 16x
  client CPU). Cap plain paths at 8 (extras demoted to userspace globs) via a
  shared filesystem-watcher-ignore module, also unifying the runtime
  file-watcher host list so identical roots share one native stream.
- worktree-base-directory-watcher subscribed recursive FSEvents streams over
  every repo's ENTIRE workspace root (all worktrees) and whole common .git
  (objects included) with glob-only ignores (zero daemon-side exclusion),
  just to observe a few shallow paths. Replace with a 2s readdir/stat poller
  (zero fseventsd clients, measured 70x less delivery CPU); SSH targets keep
  provider.watch unchanged.
- serve-sim state watcher held an app-lifetime fs.watch on $TMPDIR (the
  noisiest dir on the system); its existing 250ms existence poll makes the
  parent watch redundant, so drop it.

Co-authored-by: Orca <help@stably.ai>

* Eliminate base-poller idle cost: mtime-gated ticks + narrow macOS worktree-metadata stream

- base targets: tick is now one stat of each gate dir (root + nested
  containers); the readdir + marker fan-out runs only when a gate dir
  changed, with a 30s ungated backstop and a bounded pending-marker
  recheck list. Idle tick 600us -> 1.1us at 146 worktrees.
- git-common targets on macOS: replace the 2s entry sweep with one narrow
  native stream on <common>/.git/worktrees (tiny, rare-churn tree): zero
  idle syscalls and external worktree add/remove/HEAD detection back to
  sub-second. Re-arms via existence poll across git worktree prune
  deleting/recreating the dir. Win/Linux keep the listing poll (no
  fseventsd there; a Windows dir handle could block prune).
- split git-common logic into worktree-git-common-watch.ts (max-lines).

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-01 23:04:13 -07:00 committed by GitHub
parent 7d3944c8ed
commit 7f1e280436
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 944 additions and 87 deletions

View File

@ -34,7 +34,7 @@ describe('ServeSimStateWatcher', () => {
const parentDir = await mkdtemp(join(tmpdir(), 'orca-serve-sim-watch-'))
cleanupPaths.push(parentDir)
const stateDir = join(parentDir, 'serve-sim')
const watcher = new ServeSimStateWatcher({ parentDir, stateDir })
const watcher = new ServeSimStateWatcher({ stateDir })
const events: ServeSimStateDetectedEvent[] = []
watcher.bindPty('pty-1', 'worktree-1')

View File

@ -68,19 +68,16 @@ function helperInstanceKey(info: ServeSimHelperInfo): string {
export class ServeSimStateWatcher {
private readonly stateDir: string
private readonly parentDir: string
private readonly ptyToWorktree = new Map<string, string>()
private readonly ptyBuffers = new Map<string, string>()
private readonly seenExternalKeys = new Set<string>()
private readonly orcaManagedHelperKeys = new Set<string>()
private readonly listeners = new Set<(event: ServeSimStateDetectedEvent) => void>()
private parentWatcher: FSWatcher | null = null
private stateWatcher: FSWatcher | null = null
private stateDirPoll: ReturnType<typeof setInterval> | null = null
constructor(options: { stateDir?: string; parentDir?: string } = {}) {
constructor(options: { stateDir?: string } = {}) {
this.stateDir = options.stateDir ?? DEFAULT_STATE_DIR
this.parentDir = options.parentDir ?? tmpdir()
}
onDetected(listener: (event: ServeSimStateDetectedEvent) => void): () => void {
@ -156,25 +153,20 @@ export class ServeSimStateWatcher {
}
start(): void {
if (this.parentWatcher || this.stateWatcher) {
if (this.stateDirPoll || this.stateWatcher) {
return
}
try {
// Why: $TMPDIR/serve-sim/ may not exist until the first terminal `serve-sim --detach`.
// Watch the parent tmpdir and attach to serve-sim/ when it appears (or watch it directly if present).
// Poll for it instead of fs.watch on the parent tmpdir: watching $TMPDIR
// registers a permanent FSEvents client on the system's highest-churn
// directory, while an existence poll costs the daemon nothing.
this.attachStateDirWatch()
if (this.stateWatcher) {
this.scanExistingStateFiles()
return
}
this.parentWatcher = watch(this.parentDir, (_event, filename) => {
if (!filename || String(filename) !== 'serve-sim') {
return
}
this.attachStateDirWatch()
this.scanExistingStateFiles()
})
this.stateDirPoll = setInterval(() => {
this.attachStateDirWatch()
this.scanExistingStateFiles()
@ -186,12 +178,10 @@ export class ServeSimStateWatcher {
}
stop(): void {
this.parentWatcher?.close()
this.stateWatcher?.close()
if (this.stateDirPoll) {
clearInterval(this.stateDirPoll)
}
this.parentWatcher = null
this.stateWatcher = null
this.stateDirPoll = null
this.ptyToWorktree.clear()

View File

@ -0,0 +1,39 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT,
WATCHER_IGNORE_DIRS,
buildParcelWatcherIgnoreOption
} from './filesystem-watcher-ignore'
const realPlatform = process.platform
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform })
}
describe('buildParcelWatcherIgnoreOption', () => {
afterEach(() => {
setPlatform(realPlatform)
})
it('keeps at most 8 plain paths on macOS so FSEventStreamSetExclusionPaths succeeds', () => {
setPlatform('darwin')
const option = buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS)
// @parcel/watcher passes non-glob entries to FSEventStreamSetExclusionPaths,
// which rejects the WHOLE set past 8 paths — silently disabling daemon-side
// exclusion of node_modules/.git churn.
const plainPaths = option.filter((entry) => !entry.includes('*'))
expect(plainPaths.length).toBeLessThanOrEqual(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
// Every ignore dir must still be covered, as a path or as a glob.
for (const dir of WATCHER_IGNORE_DIRS) {
expect(
option.some((entry) => entry === dir || entry === `**/${dir}` || entry === `**/${dir}/**`)
).toBe(true)
}
})
it('passes the plain list through on other platforms', () => {
setPlatform('linux')
expect(buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS)).toEqual(WATCHER_IGNORE_DIRS)
})
})

View File

@ -0,0 +1,36 @@
// Canonical ignore list for recursive worktree watchers (mirrors VS Code's
// predefined recursive-watch excludes). Shared by the explorer watcher and the
// runtime file-watcher host so every @parcel/watcher subscription gets the
// same high-churn exclusions.
export const WATCHER_IGNORE_DIRS: string[] = [
'.git',
'node_modules',
'dist',
'build',
'.next',
'.cache',
'target',
'.venv',
'__pycache__'
]
// Why: macOS FSEventStreamSetExclusionPaths accepts at most 8 paths and fails
// closed — one entry over the cap and @parcel/watcher silently loses ALL
// daemon-side exclusions, so fseventsd delivers every node_modules/.git event
// to this process (measured ~29x client CPU plus daemon-side delivery load).
// Keep the 8 highest-churn dirs as plain paths (daemon-excluded) and demote
// the rest to globs (userspace-filtered). Ordering of WATCHER_IGNORE_DIRS is
// therefore meaningful: the first 8 get true daemon-side exclusion on macOS.
export const MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT = 8
export function buildParcelWatcherIgnoreOption(ignoreDirs: readonly string[]): string[] {
if (process.platform !== 'darwin') {
return [...ignoreDirs]
}
return [
...ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT),
...ignoreDirs
.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
.flatMap((dir) => [`**/${dir}`, `**/${dir}/**`])
]
}

View File

@ -13,24 +13,11 @@ import { createWslWatcher } from './filesystem-watcher-wsl'
import type { WatchedRoot } from './filesystem-watcher-wsl'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch'
// ── Ignore patterns ──────────────────────────────────────────────────
// Why: high-churn directories are suppressed at the native watcher level
// so events never leave the OS kernel. This list is separate from the
// File Explorer display filter (which only hides rows). Directories like
// `dist` and `build` remain visible in the tree but will not auto-refresh.
const WATCHER_IGNORE_DIRS: string[] = [
'.git',
'node_modules',
'dist',
'build',
'.next',
'.cache',
'__pycache__',
'target',
'.venv'
]
// Why: high-churn directories are suppressed at the native watcher level so
// events never leave the OS/daemon. This list is separate from the File
// Explorer display filter (which only hides rows). Directories like `dist`
// and `build` remain visible in the tree but will not auto-refresh.
import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOption } from './filesystem-watcher-ignore'
// ── Debounce helpers ─────────────────────────────────────────────────
@ -309,7 +296,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise<Watched
let errorCleanedUp = false
const watcherOptions = {
ignore: WATCHER_IGNORE_DIRS,
ignore: buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS),
// Why: Parcel checks Watchman before the native Windows backend by
// default, and Windows prints a shell-level "watchman not recognized"
// error for that probe. Pinning the backend keeps local watches quiet.

View File

@ -0,0 +1,312 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
startWorktreeBaseDirectoryPoller,
type WorktreeBasePollEvent
} from './worktree-base-directory-poller'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
const POLL_MS = 25
function makeTarget(
kind: 'base' | 'git-common',
path: string,
config: Partial<WorktreeBaseRepoWatchConfig> = {}
): WorktreeBaseWatchTarget {
const repoConfig: WorktreeBaseRepoWatchConfig = {
repoId: 'repo-1',
repoName: 'project',
nestWorkspaces: false,
...config
}
return {
key: `${kind}:local:${path}`,
kind,
path,
repos: new Map([[repoConfig.repoId, repoConfig]])
}
}
async function waitForEvents(
events: WorktreeBasePollEvent[][],
predicate: (flat: WorktreeBasePollEvent[]) => boolean
): Promise<WorktreeBasePollEvent[]> {
await vi.waitFor(
() => {
if (!predicate(events.flat())) {
throw new Error('expected poll events not observed yet')
}
},
{ timeout: 5_000, interval: 20 }
)
return events.flat()
}
describe('worktree base directory poller', () => {
const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
})
async function makeRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-base-poller-'))
cleanups.push(() => rm(root, { recursive: true, force: true }))
// Why: macOS tmpdir lives behind the /var -> /private/var symlink and
// native watcher events report resolved paths; production targets are
// realpath'd the same way (canonicalizeExistingPath).
return realpath(root)
}
it('emits a .git marker create and a worktree delete for flat layouts', async () => {
const root = await makeRoot()
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('base', root)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{ pollIntervalMs: POLL_MS }
)
cleanups.push(() => poller.unsubscribe())
const worktree = join(root, 'external-1')
await mkdir(worktree)
await writeFile(join(worktree, '.git'), 'gitdir: elsewhere')
const afterCreate = await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git'))
)
expect(
afterCreate.filter((event) => event.type === 'create' && event.path.endsWith('.git'))
).toHaveLength(1)
await rm(worktree, { recursive: true })
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'delete' && event.path === worktree)
)
})
it('emits the marker only after it appears for slow checkouts', async () => {
const root = await makeRoot()
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('base', root)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{ pollIntervalMs: POLL_MS }
)
cleanups.push(() => poller.unsubscribe())
const worktree = join(root, 'external-2')
await mkdir(worktree)
// Give the poller time to observe the marker-less dir first.
await new Promise((resolve) => setTimeout(resolve, POLL_MS * 3))
expect(received.flat()).toHaveLength(0)
await writeFile(join(worktree, '.git'), 'gitdir: elsewhere')
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git'))
)
})
it('scans nested repo containers for nested layouts', async () => {
const root = await makeRoot()
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('base', root, { nestWorkspaces: true })
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{ pollIntervalMs: POLL_MS }
)
cleanups.push(() => poller.unsubscribe())
const worktree = join(root, 'project', 'external-3')
await mkdir(worktree, { recursive: true })
await writeFile(join(worktree, '.git'), 'gitdir: elsewhere')
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'create' && event.path === join(worktree, '.git'))
)
})
it('skips full scans while the gate dirs are untouched', async () => {
const root = await makeRoot()
const worktree = join(root, 'external-idle')
await mkdir(worktree)
await writeFile(join(worktree, '.git'), 'gitdir: elsewhere')
const received: WorktreeBasePollEvent[][] = []
const fullScans: number[] = []
const target = makeTarget('base', root)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{ pollIntervalMs: POLL_MS, onFullScan: () => fullScans.push(Date.now()) }
)
cleanups.push(() => poller.unsubscribe())
// ~8 idle ticks: under the backstop cadence, so the gate should skip
// every full scan (each tick is just gate-dir stats).
await new Promise((resolve) => setTimeout(resolve, POLL_MS * 8))
expect(fullScans).toHaveLength(0)
expect(received.flat()).toHaveLength(0)
// Touching the root (new entry) flips the gate and triggers a scan.
await mkdir(join(root, 'external-new'))
await writeFile(join(root, 'external-new', '.git'), 'gitdir: elsewhere')
await waitForEvents(received, (flat) =>
flat.some(
(event) => event.type === 'create' && event.path === join(root, 'external-new', '.git')
)
)
expect(fullScans.length).toBeGreaterThan(0)
})
it('reports git-common worktrees metadata adds, updates, and removals via polling', async () => {
const commonDir = await makeRoot()
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('git-common', commonDir)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
// Force the non-darwin poll path so this test is deterministic on all CI.
{ pollIntervalMs: POLL_MS, platform: 'linux' }
)
cleanups.push(() => poller.unsubscribe())
const entry = join(commonDir, 'worktrees', 'external-4')
await mkdir(entry, { recursive: true })
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'create' && event.path === entry)
)
// HEAD/gitdir metadata writes land as new files in the entry dir, which
// bumps the entry dir mtime the poller compares.
await new Promise((resolve) => setTimeout(resolve, 10))
await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main')
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'update' && event.path === entry)
)
await rm(entry, { recursive: true })
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'delete' && event.path === entry)
)
})
it('emits deletes for all known worktrees when the root vanishes', async () => {
const root = await makeRoot()
const worktree = join(root, 'external-5')
await mkdir(worktree)
await writeFile(join(worktree, '.git'), 'gitdir: elsewhere')
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('base', root)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{ pollIntervalMs: POLL_MS }
)
cleanups.push(() => poller.unsubscribe())
await rm(root, { recursive: true, force: true })
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'delete' && event.path === worktree)
)
})
describe.runIf(process.platform === 'darwin')('macOS narrow git-common stream', () => {
it('delivers instant add/update/remove without polling', async () => {
const commonDir = await makeRoot()
await mkdir(join(commonDir, 'worktrees'))
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('git-common', commonDir)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{ pollIntervalMs: POLL_MS, platform: 'darwin' }
)
cleanups.push(() => poller.unsubscribe())
const entry = join(commonDir, 'worktrees', 'wt-a')
await mkdir(entry)
await waitForEvents(received, (flat) => flat.some((event) => event.path === entry))
await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main')
await waitForEvents(received, (flat) =>
flat.some((event) => event.path === join(entry, 'HEAD'))
)
await rm(entry, { recursive: true })
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'delete' && event.path === entry)
)
})
it('arms via existence polling when the worktrees dir appears later', async () => {
const commonDir = await makeRoot()
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('git-common', commonDir)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{ pollIntervalMs: POLL_MS, platform: 'darwin' }
)
cleanups.push(() => poller.unsubscribe())
const worktreesDir = join(commonDir, 'worktrees')
await mkdir(worktreesDir)
// The dir appearing is itself surfaced (a first worktree was added).
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'create' && event.path === worktreesDir)
)
// And the narrow stream is live afterwards: entry adds are seen.
const entry = join(worktreesDir, 'wt-b')
await mkdir(entry)
await waitForEvents(received, (flat) => flat.some((event) => event.path === entry))
})
it('keeps watching across worktrees dir delete and recreate', async () => {
const commonDir = await makeRoot()
await mkdir(join(commonDir, 'worktrees'))
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('git-common', commonDir)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{ pollIntervalMs: POLL_MS, platform: 'darwin' }
)
cleanups.push(() => poller.unsubscribe())
// Simulate `git worktree prune` removing the empty dir, then a new add
// recreating it. The stream re-arms via the existence poll; the repo
// gets notified on recreate, and subsequent entries are seen live.
const worktreesDir = join(commonDir, 'worktrees')
await rm(worktreesDir, { recursive: true })
await new Promise((resolve) => setTimeout(resolve, 100))
await mkdir(join(worktreesDir, 'wt-c'), { recursive: true })
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'create' && event.path === worktreesDir)
)
const laterEntry = join(worktreesDir, 'wt-d')
await mkdir(laterEntry)
await waitForEvents(received, (flat) => flat.some((event) => event.path === laterEntry))
})
})
})

View File

@ -0,0 +1,263 @@
import { readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
import { startGitCommonWatch } from './worktree-git-common-watch'
export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path: string }
export type WorktreeBaseSubscription = { unsubscribe: () => Promise<void> }
export type WorktreeBasePollerOptions = {
pollIntervalMs?: number
platform?: NodeJS.Platform
/** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */
onFullScan?: () => void
}
// Why: these targets used to be recursive FSEvents subscriptions spanning the
// entire workspace root (every worktree's full tree) and the repo's whole
// common .git (objects included), forcing fseventsd to deliver all of that
// churn to Orca just to observe a handful of shallow paths. The replacements
// register at most one tiny-scope native stream (macOS git-common) and
// otherwise poll with a dir-mtime gate, so idle cost is a couple of stat
// calls per tick. 2s is fast enough for external `git worktree add/remove`;
// 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
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[]
}
// 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]
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))
)
let rootEntries
try {
rootEntries = await readdir(rootPath, { withFileTypes: true })
} catch {
// Root vanished: an empty snapshot diffs into delete events for every
// previously-known worktree dir, matching the old watcher's error path.
return { markers, gateDirs }
}
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)
let subEntries
try {
subEntries = await readdir(entryPath, { withFileTypes: true })
} catch {
subEntries = []
}
for (const sub of subEntries) {
if (sub.isDirectory() || sub.isSymbolicLink()) {
candidates.push(join(entryPath, sub.name))
}
}
}
}
for (const dir of candidates) {
markers.set(dir, await hasGitMarker(dir))
}
return { markers, gateDirs }
}
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,
onFullScan?: () => void
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let tickCount = 0
let snapshot = await snapshotBase(target.path, getRepos())
let gateSignatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
// dir → tick when first seen without a `.git` marker
const pendingMarkers = new Map<string, number>()
for (const [dir, marker] of snapshot.markers) {
if (!marker) {
pendingMarkers.set(dir, 0)
}
}
const fullScan = async (): Promise<void> => {
onFullScan?.()
const next = await snapshotBase(target.path, getRepos())
const nextSignatures = await Promise.all(next.gateDirs.map(dirSignature))
if (disposed) {
return
}
const events = diffBase(snapshot, next)
for (const [dir, marker] of next.markers) {
if (marker) {
pendingMarkers.delete(dir)
} else if (!pendingMarkers.has(dir)) {
pendingMarkers.set(dir, tickCount)
}
}
for (const [dir, firstSeenTick] of pendingMarkers) {
if (!next.markers.has(dir) || tickCount - firstSeenTick > PENDING_MARKER_MAX_TICKS) {
pendingMarkers.delete(dir)
}
}
snapshot = next
gateSignatures = nextSignatures
if (events.length > 0) {
onEvents(events)
}
}
const checkPendingMarkers = async (): Promise<void> => {
const events: WorktreeBasePollEvent[] = []
for (const dir of pendingMarkers.keys()) {
if (await hasGitMarker(dir)) {
pendingMarkers.delete(dir)
snapshot.markers.set(dir, true)
events.push({ type: 'create', path: join(dir, '.git') })
}
}
if (!disposed && events.length > 0) {
onEvents(events)
}
}
const tick = async (): Promise<void> => {
tickCount++
if (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 !== gateSignatures.length ||
signatures.some((sig, index) => sig !== gateSignatures[index])
if (gateChanged) {
await fullScan()
return
}
if (pendingMarkers.size > 0) {
await checkPendingMarkers()
}
}
const timer = setInterval(() => {
if (disposed || ticking) {
return
}
ticking = true
void tick()
.catch(() => {
// Transient fs error: keep the previous snapshot and retry next tick.
})
.finally(() => {
ticking = false
})
}, pollIntervalMs)
timer.unref?.()
return {
unsubscribe: async () => {
disposed = true
clearInterval(timer)
}
}
}
/** 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. */
export async function startWorktreeBaseDirectoryPoller(
target: WorktreeBaseWatchTarget,
getRepos: () => ReadonlyMap<string, WorktreeBaseRepoWatchConfig>,
onEvents: (events: WorktreeBasePollEvent[]) => void,
options: WorktreeBasePollerOptions = {}
): Promise<WorktreeBaseSubscription> {
const pollIntervalMs = options.pollIntervalMs ?? WORKTREE_BASE_POLL_INTERVAL_MS
const platform = options.platform ?? process.platform
if (target.kind === 'git-common') {
return startGitCommonWatch(target, onEvents, pollIntervalMs, platform, options.onFullScan)
}
return startBasePoller(target, getRepos, onEvents, pollIntervalMs, options.onFullScan)
}

View File

@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { join, sep } from 'node:path'
import type { Event as WatcherEvent, SubscribeCallback } from '@parcel/watcher'
import type { GlobalSettings, Repo } from '../../shared/types'
import type { WorktreeBasePollEvent } from './worktree-base-directory-poller'
vi.mock('fs/promises', () => ({
readFile: vi.fn(async () => ''),
@ -9,8 +9,8 @@ vi.mock('fs/promises', () => ({
stat: vi.fn(async () => ({ isDirectory: () => true }))
}))
vi.mock('@parcel/watcher', () => ({
subscribe: vi.fn()
vi.mock('./worktree-base-directory-poller', () => ({
startWorktreeBaseDirectoryPoller: vi.fn()
}))
vi.mock('./worktree-remote', () => ({
@ -21,18 +21,18 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn()
}))
import { subscribe } from '@parcel/watcher'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { notifyWorktreesChanged } from './worktree-remote'
import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller'
import {
disposeWorktreeBaseDirectoryWatchers,
syncWorktreeBaseDirectoryWatchers
} from './worktree-base-directory-watcher'
import { matchingWorktreeBaseRepoIds } from './worktree-base-directory-event-filter'
type WatcherCallback = SubscribeCallback
type PollerCallback = (events: WorktreeBasePollEvent[]) => void
const watcherCallbacks = new Map<string, WatcherCallback>()
const watcherCallbacks = new Map<string, PollerCallback>()
const unsubscribeMocks = new Map<string, ReturnType<typeof vi.fn>>()
const absolutePath = (...parts: string[]): string => join(sep, ...parts)
const WORKTREE_ROOT = absolutePath('workspace', 'worktrees')
@ -69,12 +69,12 @@ function makeWindow(options: { destroyed?: () => boolean } = {}) {
}
}
function emit(root: string, events: WatcherEvent[]): void {
function emit(root: string, events: WorktreeBasePollEvent[]): void {
const callback = watcherCallbacks.get(root)
if (!callback) {
throw new Error(`No watcher callback for ${root}`)
throw new Error(`No poller callback for ${root}`)
}
callback(null, events)
callback(events)
}
describe('worktree base directory watcher', () => {
@ -83,12 +83,14 @@ describe('worktree base directory watcher', () => {
watcherCallbacks.clear()
unsubscribeMocks.clear()
vi.mocked(getSshFilesystemProvider).mockReturnValue(undefined)
vi.mocked(subscribe).mockImplementation(async (root, callback) => {
const unsubscribe = vi.fn(async () => {})
watcherCallbacks.set(root, callback)
unsubscribeMocks.set(root, unsubscribe)
return { unsubscribe }
})
vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementation(
async (target, _getRepos, onEvents) => {
const unsubscribe = vi.fn(async () => {})
watcherCallbacks.set(target.path, onEvents)
unsubscribeMocks.set(target.path, unsubscribe)
return { unsubscribe }
}
)
})
afterEach(async () => {
@ -103,7 +105,7 @@ describe('worktree base directory watcher', () => {
emit(WORKTREE_ROOT, [
{ type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104') },
{ type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') }
] as WatcherEvent[])
])
await vi.advanceTimersByTimeAsync(300)
@ -120,7 +122,7 @@ describe('worktree base directory watcher', () => {
emit(WORKTREE_ROOT, [
{ type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') }
] as WatcherEvent[])
])
destroyed = true
await vi.advanceTimersByTimeAsync(300)
@ -132,7 +134,7 @@ describe('worktree base directory watcher', () => {
emit(WORKTREE_ROOT, [
{ type: 'update', path: join(WORKTREE_ROOT, 'project', 'existing', 'src', 'file.ts') }
] as WatcherEvent[])
])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).not.toHaveBeenCalled()
@ -143,7 +145,7 @@ describe('worktree base directory watcher', () => {
emit(PROJECT_GIT_COMMON_DIR, [
{ type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'gitdir') }
] as WatcherEvent[])
])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
@ -158,7 +160,7 @@ describe('worktree base directory watcher', () => {
makeWindow() as never
)
expect(subscribe).not.toHaveBeenCalled()
expect(startWorktreeBaseDirectoryPoller).not.toHaveBeenCalled()
})
it('uses the remote sibling root for default SSH worktree roots', async () => {
@ -180,7 +182,7 @@ describe('worktree base directory watcher', () => {
makeWindow() as never
)
expect(subscribe).not.toHaveBeenCalled()
expect(startWorktreeBaseDirectoryPoller).not.toHaveBeenCalled()
expect(remoteWatch).toHaveBeenCalledWith('/home/alice', expect.any(Function))
remoteCallbacks.get('/home/alice')?.([
{
@ -209,12 +211,12 @@ describe('worktree base directory watcher', () => {
})
it('unsubscribes a watcher that finishes installing after disposal starts', async () => {
let resolveSubscribe: (subscription: { unsubscribe: () => Promise<void> }) => void = () => {}
let resolveInstall: (subscription: { unsubscribe: () => Promise<void> }) => void = () => {}
const unsubscribe = vi.fn(async () => {})
vi.mocked(subscribe).mockImplementationOnce(
vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementationOnce(
async () =>
new Promise((resolve) => {
resolveSubscribe = resolve
resolveInstall = resolve
})
)
@ -222,9 +224,9 @@ describe('worktree base directory watcher', () => {
makeStore([makeRepo()]) as never,
makeWindow() as never
)
await vi.waitFor(() => expect(subscribe).toHaveBeenCalled())
await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalled())
const disposePromise = disposeWorktreeBaseDirectoryWatchers()
resolveSubscribe({ unsubscribe })
resolveInstall({ unsubscribe })
await syncPromise
await disposePromise
@ -243,13 +245,13 @@ describe('worktree base directory watcher', () => {
matchingWorktreeBaseRepoIds(target, {
type: 'create',
path: join(WORKTREE_ROOT, 'external-5104', '.git')
} as WatcherEvent)
})
).toEqual(['repo-1'])
expect(
matchingWorktreeBaseRepoIds(target, {
type: 'update',
path: join(WORKTREE_ROOT, 'external-5104', 'src', 'file.ts')
} as WatcherEvent)
})
).toEqual([])
})
})

View File

@ -1,5 +1,4 @@
import type { BrowserWindow } from 'electron'
import type { AsyncSubscription } from '@parcel/watcher'
import type { FsChangeEvent } from '../../shared/types'
import type { Store } from '../persistence'
import { notifyWorktreesChanged } from './worktree-remote'
@ -12,10 +11,11 @@ import {
buildWorktreeBaseDirectoryWatchTargets,
clearWorktreeBaseDirectoryWatchTargetWarnings
} from './worktree-base-directory-watch-targets'
import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller'
type ActiveWatch = WorktreeBaseWatchTarget & {
mainWindow: BrowserWindow
subscription: Pick<AsyncSubscription, 'unsubscribe'>
subscription: { unsubscribe: () => Promise<void> }
notifyTimer: ReturnType<typeof setTimeout> | null
pendingRepoIds: Set<string>
disposed: boolean
@ -137,19 +137,19 @@ async function subscribeTarget(
return activeWatch
}
const watcher = await import('@parcel/watcher')
const subscription = await watcher.subscribe(
target.path,
(error, events) => {
// Why: a recursive native watcher here forced fseventsd to deliver every
// event under the whole workspace root (all worktrees) / whole common .git
// (objects included) just to observe a few shallow paths. The poller reads
// exactly those paths and registers zero fseventsd clients.
const subscription = await startWorktreeBaseDirectoryPoller(
target,
() => (activeWatches.get(target.key) ?? activeWatch)?.repos ?? target.repos,
(events) => {
const currentWatch = activeWatches.get(target.key) ?? activeWatch
if (!currentWatch || currentWatch.disposed) {
return
}
handleLocalWatchEvents(currentWatch, error, events)
},
{
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**', '**/.cache/**'],
...(process.platform === 'win32' ? { backend: 'windows' as const } : {})
handleLocalWatchEvents(currentWatch, null, events)
}
)
activeWatch = {

View File

@ -0,0 +1,233 @@
import { readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
import type {
WorktreeBasePollEvent,
WorktreeBaseSubscription
} from './worktree-base-directory-poller'
// Watches a repo's `<common>/.git/worktrees` metadata — the only subtree the
// git-common event filter consumes.
// macOS: a narrow native stream rooted there — a tiny, rare-churn tree —
// gives instant detection with zero idle cost and zero wide-scope fseventsd
// delivery. Other platforms: dir-listing poll (no fseventsd to protect, and
// on Windows an open directory handle on `worktrees/` could interfere with
// `git worktree prune` removing it).
async function snapshotGitCommon(commonDirPath: string): Promise<Map<string, number>> {
const mtimes = new Map<string, number>()
const worktreesDir = join(commonDirPath, 'worktrees')
let entries
try {
entries = await readdir(worktreesDir, { withFileTypes: true })
} catch {
// Missing worktrees dir is normal for repos without linked worktrees.
return mtimes
}
for (const entry of entries) {
const entryPath = join(worktreesDir, entry.name)
try {
// Entry-dir mtime covers the metadata writes the old recursive watcher
// reacted to (HEAD/gitdir/locked are written via rename into the entry
// dir, which bumps its mtime).
mtimes.set(entryPath, (await stat(entryPath)).mtimeMs)
} catch {
// Entry removed between readdir and stat.
}
}
return mtimes
}
function diffGitCommon(
prev: Map<string, number>,
next: Map<string, number>
): WorktreeBasePollEvent[] {
const events: WorktreeBasePollEvent[] = []
for (const [entryPath, mtime] of next) {
const prevMtime = prev.get(entryPath)
if (prevMtime === undefined) {
events.push({ type: 'create', path: entryPath })
} else if (prevMtime !== mtime) {
events.push({ type: 'update', path: entryPath })
}
}
for (const entryPath of prev.keys()) {
if (!next.has(entryPath)) {
events.push({ type: 'delete', path: entryPath })
}
}
return events
}
async function startGitCommonPoller(
target: WorktreeBaseWatchTarget,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
onFullScan?: () => void
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let snapshot = await snapshotGitCommon(target.path)
const timer = setInterval(() => {
if (disposed || ticking) {
return
}
ticking = true
onFullScan?.()
void snapshotGitCommon(target.path)
.then((next) => {
if (disposed) {
return
}
const events = diffGitCommon(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
})
}, pollIntervalMs)
timer.unref?.()
return {
unsubscribe: async () => {
disposed = true
clearInterval(timer)
}
}
}
async function startGitCommonNarrowWatch(
target: WorktreeBaseWatchTarget,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number
): Promise<WorktreeBaseSubscription> {
const worktreesDir = join(target.path, 'worktrees')
let disposed = false
let subscription: WorktreeBaseSubscription | null = null
let existenceTimer: ReturnType<typeof setInterval> | null = null
let subscribing = false
const stopExistencePoll = (): void => {
if (existenceTimer) {
clearInterval(existenceTimer)
existenceTimer = null
}
}
const armExistencePoll = (): void => {
if (disposed || existenceTimer) {
return
}
existenceTimer = setInterval(() => {
if (disposed || subscribing || subscription) {
return
}
subscribing = true
void trySubscribe()
.then((installed) => {
if (installed && !disposed) {
stopExistencePoll()
// The dir appearing means a first linked worktree was just
// registered; surface it so the repo's worktree list refreshes.
onEvents([{ type: 'create', path: worktreesDir }])
}
})
.finally(() => {
subscribing = false
})
}, pollIntervalMs)
existenceTimer.unref?.()
}
const trySubscribe = async (): Promise<boolean> => {
try {
const s = await stat(worktreesDir)
if (!s.isDirectory()) {
return false
}
} catch {
return false
}
let errored = false
// Why: parcel tears its native stream down when the watched root is
// deleted (e.g. `git worktree prune` removing an empty worktrees dir) —
// sometimes surfaced as an error, sometimes as a delete event for the
// root. Either way: notify, drop the dead stream, and let the existence
// poll re-arm when a future worktree add recreates the dir.
const teardownAndRearm = (): void => {
errored = true
const current = subscription
subscription = null
if (current) {
void current.unsubscribe().catch(() => {})
}
armExistencePoll()
}
try {
const watcher = await import('@parcel/watcher')
const sub = await watcher.subscribe(worktreesDir, (error, events) => {
if (disposed) {
return
}
if (error) {
onEvents([{ type: 'update', path: worktreesDir }])
teardownAndRearm()
return
}
if (events.length > 0) {
const rootGone = events.some(
(event) => event.type === 'delete' && event.path === worktreesDir
)
onEvents(events.map((event) => ({ type: event.type, path: event.path })))
if (rootGone) {
teardownAndRearm()
}
}
})
if (disposed || errored) {
void sub.unsubscribe().catch(() => {})
return !errored
}
subscription = { unsubscribe: () => sub.unsubscribe() }
return true
} catch {
return false
}
}
if (!(await trySubscribe())) {
armExistencePoll()
}
return {
unsubscribe: async () => {
disposed = true
stopExistencePoll()
const current = subscription
subscription = null
if (current) {
await current.unsubscribe().catch(() => {})
}
}
}
}
export async function startGitCommonWatch(
target: WorktreeBaseWatchTarget,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
platform: NodeJS.Platform,
onFullScan?: () => void
): Promise<WorktreeBaseSubscription> {
if (platform === 'darwin') {
return startGitCommonNarrowWatch(target, onEvents, pollIntervalMs)
}
return startGitCommonPoller(target, onEvents, pollIntervalMs, onFullScan)
}

View File

@ -8,20 +8,15 @@ import { join } from 'node:path'
import { app } from 'electron'
import type { FsChangeEvent } from '../../shared/types'
import type { FileWatcherHostMessage, FileWatcherWorkerMessage } from './file-watcher-worker'
// Why: shares the explorer watcher's canonical ignore list, pre-shaped so the
// macOS daemon-side exclusion-path cap (8) is respected — over the cap ALL
// exclusions silently fail and fseventsd delivers full node_modules churn.
import {
WATCHER_IGNORE_DIRS,
buildParcelWatcherIgnoreOption
} from '../ipc/filesystem-watcher-ignore'
// Mirrors VS Code's predefined recursive-watch excludes: skip churny generated
// trees at crawl time so the watcher never traverses them.
const RUNTIME_FILE_WATCH_IGNORE = [
'.git',
'node_modules',
'dist',
'build',
'.next',
'.cache',
'__pycache__',
'target',
'.venv'
]
const RUNTIME_FILE_WATCH_IGNORE = buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS)
// Why: clean teardown is async (the worker awaits subscription.unsubscribe()
// before closing its port and exiting). Wait this long for the worker to exit on