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:
parent
7d3944c8ed
commit
7f1e280436
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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}/**`])
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue