Skip to content

Daemon registers two native watchers over the workspace root; each kernel registration costs ~0.64x file-op throughput on Windows/macOS #36810

Description

@christopher-buss

Current Behavior

The daemon registers two independent native watchers over the same workspace root: the source watcher (packages/nx/src/daemon/server/watcher.ts, watchWorkspace) and the outputs watcher (watchOutputFiles). Each one is a separate Watcher instance with its own kernel registration.

On Windows (and macOS) every file operation under a watched tree pays for each matching registration: the kernel builds the notification, copies it into that registration's buffer and completes its I/O. Measured with a plain fs.watch(dir, { recursive: true }) that does nothing with the events, creating 300 1 KB files per round, interleaved with an unwatched control directory on the same volume, medians over 4 rounds:

idle recursive watchers on the tree creates/s (watched) creates/s (control) ratio
0 4,178 4,170 1.00
1 2,711 4,237 0.64
4 2,436 4,220 0.58
16 1,953 4,110 0.48

So two registrations put the floor at roughly 0.6x before the daemon does any work. With the daemon running, the same benchmark inside the workspace root measures 0.32x of the volume-root rate; with nx daemon --stop it measures 1.04x. Ruled out as the cause: Defender (an exact exclusion on the benchmark directory changes nothing), project-graph recomputation (a gitignored out/ path is taxed identically), plugin-worker respawn (NX_ISOLATE_PLUGINS=false changes nothing).

Every editor save, git checkout, build output write and test run in the workspace pays this.

Environment for the numbers: Windows 11 Pro 26100, ReFS Dev Drive (NTFS shows the same per-watcher ratio), native watcher with a single recursive registration per Watcher (the macOS code path applied to Windows — see the linked PR).

Expected Behavior

One kernel registration per workspace. Both consumers can be fed from a single event stream: the outputs watcher only needs events under known output paths and uses useIgnore = false, so a single watcher with useIgnore = false plus a userland split (gitignore-filtered stream → workspace changes; output-path-filtered stream → outputs tracking) gives both what they have today at half the kernel cost.

Related, same file: packages/nx/src/daemon/client/daemon-environment.js strips NX_NATIVE_LOGGING from the client env (DAEMON_ENV_VARS_EXCLUSIONS) and then forces NX_NATIVE_LOGGING=nx=debug as the daemon default (DAEMON_ENV_OVERRIDABLE_SETTINGS). The native watcher logs one DEBUG line per event per watcher, so every filesystem event in the workspace writes two lines to daemon.log and nothing the user sets can turn it off (daemon.log reached 526 MB on this workspace). Defaulting to nx=error, or honoring the client value, is a one-line change.

GitHub Repo

No response

Steps to Reproduce

  1. Any workspace on Windows or macOS with the daemon running.

  2. Create files in a loop inside the workspace root and in a directory outside it on the same volume, interleaved, and compare rates:

    // node ratio.mjs "repo=D:/ws/bench" "root=D:/bench"
    import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
    const N = 300, ROUNDS = 7, buf = Buffer.alloc(1024, 0x61)
    const paths = process.argv.slice(2).map(a => { const [label, dir] = a.split('='); return { label, dir, rates: [] } })
    const median = a => [...a].sort((x, y) => x - y)[a.length >> 1]
    for (let k = 0; k < ROUNDS; k++)
      for (const p of paths) {
        rmSync(p.dir, { recursive: true, force: true }); mkdirSync(p.dir, { recursive: true })
        const t = performance.now()
        for (let i = 0; i < N; i++) writeFileSync(`${p.dir}/f${i}.ts`, buf)
        p.rates.push(N / ((performance.now() - t) / 1000))
        rmSync(p.dir, { recursive: true, force: true })
      }
    const ref = median(paths.at(-1).rates)
    for (const p of paths) console.log(p.label, Math.round(median(p.rates)), '/s', (median(p.rates) / ref).toFixed(2))
  3. nx daemon --stop, repeat step 2.

  4. Optionally spawn N node -e "require('fs').watch(process.argv[1], {recursive:true}, ()=>{}); setInterval(()=>{}, 1e6)" <dir> processes on a fresh directory and repeat to see the per-registration cost in isolation.

Nx Report

Node           : 26.5.1
OS             : win32-x64
Native Target  : x86_64-windows
pnpm           : 11.21.0
daemon         : Available
nx             : 23.1.1
@nx/js         : 23.1.1
@nx/workspace  : 23.1.1
@nx/devkit     : 23.1.1
@nx/vitest     : 23.1.1
typescript     : 6.0.3

Failure Logs

No response

Package Manager Version

pnpm 11.21.0

Operating System

  • macOS
  • Linux
  • Windows
  • Other (Please specify)

Additional Information

Related:

The forced NX_NATIVE_LOGGING=nx=debug default has no existing report.


Investigated, measured and written by Claude Fable 5 (Anthropic) running in Claude Code, on behalf of and reviewed by @christopher-buss.

Metadata

Metadata

Assignees

Labels

priority: highHigh Priority (important issues which affect many people severely)scope: corecore nx functionalitytype: bug

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions