fix(core): native watcher rewrite + daemon hardening for daemon-on e2e#35204
Merged
Conversation
✅ Deploy Preview for nx-docs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for nx-dev ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Contributor
|
View your CI Pipeline Execution ↗ for commit e5f1459
☁️ Nx Cloud last updated this comment at |
bc19187 to
de9f51e
Compare
dbd39d2 to
2106fcc
Compare
2106fcc to
1364b2b
Compare
b6fe6fe to
1346c1a
Compare
4d1c720 to
142e8dd
Compare
142e8dd to
fe18068
Compare
fb4170a to
9693e88
Compare
When resetInternalState cleared cachedSerializedProjectGraphPromise, an in-flight stale compute that hit chainToLatest read it back as undefined. The 'if (stale) return stale' guard treats undefined as falsy, so the compute fell through and committed stale/partial data to module state. Kick off a successor compute inside resetInternalState (and as a defensive fallback inside chainToLatest) so a real promise is always available for in-flight stale computes to chain onto. Also adds [CI-DEBUG] logs around resetInternalStateIfNxDepsMissing so we can see which path triggered a reset in CI logs.
…persist resetInternalStateIfNxDepsMissing was triggering on every cold-start race: while the first compute was still mid-flight, project-graph.json didn't exist yet AND cachedPromise was set, so the condition matched. Reset would tear down the in-flight compute and force a redundant recompute — wasteful, and could leave callers awaiting a torn-down promise mid-write. Gate the reset on a cacheHasBeenPersisted flag set only after a successful persistProjectGraphToDisk. Now 'project-graph.json missing' only counts as an external wipe (rather than 'we haven't written yet'). Also stop unconditionally resetting on stat errors — a transient FS hiccup shouldn't nuke daemon state.
The reset is called either from a failing compute's catch (which is already returning errorResult) or from a confirmed external wipe. In both cases, the next CLI request or watcher event will trigger a fresh kickOffRecompute organically. The chainToLatest defensive path already handles the narrow case of a concurrent stale compute hitting an undefined cache mid-reset.
… Remove kind Two related Rust-side fixes for silent file drops during 'git checkout': 1. merge_event no longer prioritizes Delete. The accumulator now keeps only the most recent observation per path. Previously 'Delete always wins' meant an unlink+create sequence (what git uses for some tracked-file updates) ended up in the accumulator as Delete only — the workspace context's index then removed the (still-existing) file, and downstream isProjectPublic excluded the project from release because its package.json was missing from the file map. 2. transform_event_to_watch_events only short-circuits to Delete on a stat failure when the notify event_kind is actually Remove. Other kinds (Modify, Create, rename-To) can transiently fail their stat during atomic-rename windows; falling through to the platform path classifies them correctly from event_kind alone. The macOS fallback for Err metadata changed from Delete to Update for the same reason. Adds 13 tests to packages/nx/src/native/watch/watcher.rs: - 10 unit tests for merge_event covering every transition. - 3 real-fs integration tests (tempdir + notify) verifying that unlink+create, plain update, and rm produce the expected accumulator state end-to-end.
Refactors Watcher::watch into a thin napi wrapper around watch_inner, which takes a generic Box<dyn Fn> callback instead of a napi ThreadsafeFunction. Tests can now exercise the full pipeline (inotify -> notify-rs -> ProcMsg channel -> EventIngestor -> accumulator -> idle-window flush -> callback) without a JS runtime. Replaces the earlier mix of pure-rust merge_event unit tests and a parallel notify-rs setup with five end-to-end tests that perform real fs operations through tempfile and assert on what the watcher's own callback emits: - unlink_then_create_does_not_emit_delete (the regression) - plain_update_does_not_emit_delete (sanity) - rm_yields_delete (sanity) - create_then_rm_yields_delete (sanity) - fresh_create_does_not_emit_delete (sanity) Each test waits one IDLE_WINDOW + a small margin after its fs ops before reading the captured events, so timing is bounded by the real inotify -> channel hop.
Previously the processing thread dropped any extra ForceFlush messages it found while draining the channel, leaving their reply senders to be discarded. Concurrent callers waited the full 500ms recv_timeout in force_flush_pending() and returned an empty Vec — under load (multiple daemon clients) some flushes silently saw stale results. Collect every queued reply channel, snapshot the accumulator once, and send the same snapshot to all of them. Reset the accumulator only when at least one reply was delivered (matching the prior 'if every caller gave up, retain events for the next flush' contract). Also strips the [CI-DEBUG] instrumentation we added while diagnosing the watcher regression — the daemon log dump in the e2e test, the per-event log in dispatchWorkspaceChanges, and ~20 [CI-DEBUG] lines in project-graph-incremental-recomputation.ts. The actual fixes from that investigation (cacheHasBeenPersisted gate, chainToLatest defensive kickOff, last-wins merge_event, EventKind::Remove gate in transform_event_to_watch_events) stay. Adds a regression test that fires 8 concurrent force_flush_pending calls and asserts the whole batch finishes well under the 500ms timeout — proving every caller got a reply.
The 'last event wins' rule made fresh writes classify as Update on Linux because fs::write fires both IN_CREATE and IN_MODIFY in close succession; with last-wins, the Update overrode the Create and we lost the more informative classification. The Create-over-Update precedence existed in the original logic; the bug was missing rules for transitions involving Delete. The new table keeps the original Create-over-Update rule and adds: (Delete, Create) → Create (file came back, e.g. git unlink+create) (Delete, Update) → Update (file came back with new content) Adds tests for the two atomic-update patterns we care about: - git_style_unlink_then_write_yields_create (the original regression) - vim_style_atomic_rename_yields_create (write to .swp + rename over) - fresh_create_yields_create (tightened from 'not Delete' to 'Create') All 7 watcher tests now drive the public Watcher API end-to-end with real fs ops on a tempdir.
…assification The notify-rs inotify backend fires three events for a single fs::rename: - Modify(Name(From)) for the source path - Modify(Name(To)) for the destination path - Modify(Name(Both)) coalesced event with both paths The third event was falling through to the generic Modify(_) → Update arm in create_watch_event_internal, overriding the source path's Delete with an Update. Skip RenameMode::Both and RenameMode::Any explicitly — the From/To pair already classifies correctly. Restore the macOS path's Err(_) → EventType::delete classification. FSEvents on macOS doesn't reliably surface EventKind::Remove(_) for file removals, so the cross-platform early-return that catches Linux removals can miss them; falling back to Delete on stat failure matches the pre-fix behavior so real removals classify correctly there. Adds three new fs-event tests through the public Watcher API: - multi_file_burst_all_appear_in_one_flush - rename_yields_delete_for_old_and_create_for_new - hardcoded_ignored_paths_never_reach_callback The strict-type fs-classification tests are gated to non-macOS because FSEvents has different semantics — it coalesces events and uses st_mtime vs st_birthtime to differentiate Create from Update, so the exact classification diverges from the inotify-derived one even when the 'file exists vs gone' answer agrees. The platform-agnostic concurrent_force_flush and hardcoded_ignored_paths tests still run on all platforms. Adds a Drop impl on Watcher that sets stop_flag so the processing thread winds down within ~1s of the watcher going out of scope rather than living until the host process exits — useful for tests that spawn many short-lived watchers.
On macOS `/tmp` is a symlink to `/private/tmp`, so `tempdir()` hands back `/tmp/.tmpXXX` but FSEvents (and notify-rs's path canonicalization) deliver events as `/private/tmp/.tmpXXX/...`. The WatchFilterer scopes the synthetic gitignore (built from the hardcoded ignore patterns) by `path.starts_with(origin)`. With the symlinked origin this check fails — events under `node_modules` etc. slipped past the filter and reached the callback, breaking hardcoded_ignored_paths_never_reach_callback on macOS. Canonicalize the test's origin so both sides agree. On Linux this is a no-op since tempdir paths are already canonical. Removes the cfg(not(target_os = "macos")) gates on the strict-type fs-classification tests now that they pass on macOS too.
ff1bcf9 to
8108cd8
Compare
Refactors the watcher's threading model so the flush loop owns
everything it needs and the Watcher struct only holds the bits the
outside world talks to.
Before, the calling thread created the filterer and notify watcher,
wrapped the watcher in Arc<Mutex<>> so the spawned thread could
register new directory watches, then handed both to the thread. The
struct kept a notify_watcher field purely to keep the FSEvents channel
sender alive on macOS (a workaround for the closure not capturing the
watcher when no cfg-active references referenced it on darwin).
Now the flush loop creates the filterer and notify watcher locally on
its own stack, registers watch paths, signals readiness via a oneshot
channel, and then runs a crossbeam_channel::select! loop:
- notify_rx (internal to the loop) carries notify events.
- force_flush_rx (struct holds the tx) carries force-flush requests.
- default(deadline) drives the IDLE_WINDOW + MAX_WAIT debounce.
Shutdown is now implicit. When the Watcher struct drops or stop() is
called, the only external sender (the force-flush tx) drops; the
loop's force_flush_rx select arm reports Disconnected and the loop
exits at most one debounce window later. The notify watcher drops
with the loop's stack, releasing the OS subscription. No more
stop_flag, no more SHUTDOWN_POLL, no more Drop impl, no more
Arc<Mutex<>>, no more notify_watcher field on the struct.
The struct collapses to:
- origin / additional_globs / use_ignore (config the loop reads at
startup)
- force_flush_tx: Mutex<Option<Sender<...>>> (interior mutability so
stop() can drop the sender via &self)
Other touch-ups:
- ProcMsg enum gone — separate channels carry separate concerns.
- EventIngestor no longer holds the watcher; the Linux/Windows
new-directory backfill takes &mut watcher as a parameter on ingest.
- register_watches/register_and_backfill_new_dirs take &mut watcher
directly; no more Mutex locking.
- Imports collapsed into multi-item use statements.
All 10 watcher tests still pass through the public Watcher API.
- Drop EventIngestor struct: it was a thin wrapper around references that already live in run_flush_loop's scope. Replace with a free function ingest_event taking the references as parameters. - Use crate::native::logger::enable_logger() instead of inlining tracing_subscriber setup. - Trim verbose comments. Each remaining one explains a non-obvious WHY: the merge_event rules, the force_flush_tx Mutex<Option> rationale, the ready-signal handshake, the run_flush_loop ownership story. Narrative comments that re-stated visible code structure are gone.
…dler notify-rs blanket-implements EventHandler for FnMut, so a one-line closure does the same job as the NotifyForwarder struct + its EventHandler impl. The struct was just a wrapper around 'send the event into a channel', which is the closure body now. (notify-rs also has a built-in EventHandler impl for crossbeam_channel::Sender, but it's gated behind the 'crossbeam-channel' feature which we don't enable.)
WatchPipeline::new takes ownership of session setup (filterer, notify
watcher, registered paths) on the calling thread. If anything fails
it returns Result<Self, String>, so watch_inner can surface startup
errors directly via its Result return — no more inline 'match { Err(e)
=> ready_tx.send; return; }' patterns and no ready-channel handshake
needed. The pipeline is then moved into the spawned thread that runs
run_flush_loop.
run_flush_loop is now just the select-loop body + accumulator state.
It destructures the pipeline at entry so the loop body reads with
plain locals (filterer, notify_rx, etc.) rather than session.field.
…ction The pipeline is the thing that runs. `pipeline.run(...)` reads more naturally than a free function taking the pipeline as its first parameter. Behavior unchanged.
…via self The destructure was rebinding self.field to bare locals so the loop body could read 'filterer' instead of 'self.filterer'. Drop it and access fields directly through 'mut self' — the borrow checker handles disjoint-field borrows, so &mut self.watcher coexists with &self.filterer in the same call. Same behavior, one fewer let binding to read past.
ingest_event was a free function with 9 parameters, 5 of which were WatchPipeline fields. Making it a &mut self method drops those 5 parameters from every call site — the loop body's two ingest calls shrink from 8 lines each to 5. The crossbeam_channel::select! recv arm releases its receiver borrow before the match arm body runs, so calling self.ingest_event(...) inside the Ok arm doesn't conflict with recv(self.notify_rx).
register_watches now propagates notify::ErrorKind::MaxFilesWatch (the notify-rs name for inotify ENOSPC). At startup the error bubbles through WatchPipeline::new's Result; at runtime ingest_event surfaces it as a callback Err with a message containing 'inotify_add_watch' so the daemon-side fallback at project-graph.ts:432 fires (it falls back to non-daemon mode when this substring appears in the error). Other watch errors keep their warn-level log: a single bad path shouldn't disable the entire watcher. Also moves new_directories_from_event and register_and_backfill_new_dirs to be methods on WatchPipeline so they no longer thread filterer / ignore_globs / origin / watcher through their parameter lists.
- accumulator, burst_start, flush_deadline are now fields on WatchPipeline rather than locals in run(). merge_event, snapshot_events, reset_burst, ingest_event become &mut self methods that no longer thread the accumulator etc. through their parameter lists. - new_directories_from_event and register_and_backfill_new_dirs also become methods so they don't pass filterer/origin/ignore_globs/watcher through every call. - Path import hoisted to the top of the file (was cfg-gated to non-macos). - Unused tracing::trace import removed. - Docstrings and inline comments trimmed across the file: keep the WHY notes for the merge_event rules and the inotify_add_watch error string, drop narrative that re-stated visible code structure.
Four logs visible at NX_NATIVE_LOGGING=debug:
- 'watching started' (Watcher::watch_inner)
- 'watching stopped' (Watcher::stop)
- 'registering watches for new directories' (mid-flight new-dir
backfill on Linux/Windows)
- 'idle-window emitting events' / 'force-flush emitting events'
(each callback invocation, with event count)
Strip the watcher-origin prefix at construction time so the internal event struct holds an already-relative path, instead of carrying the full origin string on every event for later stripping at the JS boundary. Public WatchEvent shape is unchanged.
Contributor
🐳 We have a release for that!This PR has a release associated with it. You can try it out using this command: npx create-nx-workspace@23.0.0-pr.35204.e5f1459 my-workspaceOr just copy this version and use it in your own command: 23.0.0-pr.35204.e5f1459
To request a new release for this pull request, mention someone from the Nx team or the |
Contributor
|
This pull request has already been merged/closed. If you experience issues related to these changes, please open a new issue referencing this pull request. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Current Behavior
This branch started as a fix for a flaky
e2e/nx/src/watch.test.ts. Once we flippedNX_DAEMON='true'in the e2e harness, several long-latent daemon issues surfaced; they're fixed in the same PR. The most user-visible regression —nx release versionsilently dropping projects from a fixed release group aftergit checkout— is the last item below and is whate2e/release/src/multiple-release-branches.test.tsexercises.Native watcher.
nx watchdropped and duplicated events on Linux/Windows:watchexec.config.pathset()registered new directories asynchronously — files written before the real inotify registration were lost.notifyevent fired a callback (no debounce) so one logical write produced multiplenx watchinvocations.notifyFileWatcherSocketstwice per event (eagerly for updates/deletes, again post-recompute for creates), doubling invocations.getProjectsAndGlobalChangeslooked changed files up infileMap.projectFileMap, so brand-new files fell through toglobalFilesand--projects=foowatchers never matched.nx:test-nativedidn't compile on master (walker test usednix::unistd::mkfifobehind a feature that wasn't enabled).FsEventWatcherdropped immediately because the closure had no cfg-active references on darwin.Daemon (surfaced once it ran end-to-end in CI):
7.
getPluginsSeparatedreusedpendingPluginsPromisevia??=after the plugins-config hash changed, serving stale plugins forever afternx add.8. The auto-recompute path updated the in-memory graph but never persisted it; subprocesses reading the disk cache saw stale data (flaky
eslint dependency-checks).9.
startInBackgroundhad an fd race: a concurrentreset()could nullthis._out/this._errwhile we awaitedopen(), crashing the spawn withCannot read properties of null (reading 'fd').10. The daemon's env-reflection was additive only —
NX_*vars set by one CLI invocation persisted forever, leaking (e.g.,NX_PREFER_NODE_STRIP_TYPES=truefrom a singlenx reportpoisoned every later plugin load). Plugin worker'ssetWorkerEnvhad the same bug.11. Several e2e cache-hit assertions matched
[local cache], but the daemon-on path emits[existing outputs match the cache, left as is](output kept rather than restored).Daemon graph cache (more flake hunting after the watcher rewrite landed):
12.
resetInternalStateIfNxDepsMissingran before everygetCachedSerializedProjectGraphPromiseand reset state wheneverproject-graph.jsonwas missing AND a cached promise existed — but on cold start, every request before the first persist matches that condition. It was firing constantly, tearing down in-flight first computes and forcing a redundant recompute. Worse, itscatchbranch unconditionally reset on anyfileExistserror.13. When
resetInternalStatefires from insideprocessCollectedUpdatedAndDeletedFiles's catch (e.g.,retrieveWorkspaceFilesthrows on partial disk state),cachedSerializedProjectGraphPromiseis set toundefined. A concurrent stale compute that hitschainToLatestafter the reset would read backundefined; theif (stale) return staleguard treatsundefinedas falsy, so the stale compute falls through and commits its (now-stale) data to module state.Force-flush concurrency (flagged by Graphite review):
14. The processing thread dropped any extra
ForceFlushmessages it found while draining the channel (theProcMsg::ForceFlush(_) => { /* drop the extras */ }arm). Their reply senders were silently discarded; those callers waited the full 500 msrecv_timeoutand returned an empty Vec. Concurrent CLI requests would each see that latency hit.Watcher event misclassification (the
nx release versionfailure onmultiple-release-branches.test.ts):15.
git checkoutdoesunlink + writeon some tracked files. inotify firesIN_DELETEthenIN_CREATEfor the same path; both landed in the watcher's accumulator within one burst.16.
merge_event's priority rule was Delete > Create > Update — meaning aCreatearriving after aDeletefor the same path was silently dropped. The accumulator emitted only the Delete.17. Downstream
updateFilesInContexton the JS side then removed the (still-existing) file from the Rust workspace context's index.multiGlobWithWorkspaceContextno longer returned it. Plugins didn't process it. The project was missing from the resulting graph, with no error.18.
release versionthen evaluatedisProjectPublic, which checks for the project'spackage.jsonin the file map. With the file gone from the index, the check returnedfalse, the project was excluded as not-public, and the release group ran with one fewer project than expected.19. Two related classification bugs surfaced during the diagnosis: (a) the early-return for
!meta_existsemitted Delete unconditionally, even forModify/Createevents whose stat happened to fail during a transient atomic-rename window; (b) notify-rs delivers a third coalesced rename event (Modify(Name(Both))) that fell through to the genericModify(_) → Updatearm, overriding the per-sideFromDelete on the source path of anfs::rename.Maven (separate but bundled):
20. Tests asserted on
BUILD SUCCESSin batch mode, but the resident batch runner'sBatchExecutionListener.sessionEndedis a no-op — Maven's footer is replaced byNx Maven Summary. Those assertions could never match.Solution
Native watcher
Architecture
The watcher is one Rust struct (
packages/nx/src/native/watch/watcher.rs) that owns:notify::RecommendedWatcher(the OS-level subscription),mpsc::Sender<ProcMsg>shared by the notify event handler and the napi force-flush method,Receiver.How events are streamed
Notify side.
notify::recommended_watcher(NotifyForwarder { tx })hands every fs event toNotifyForwarder::handle_event, which wraps it asProcMsg::Notify(event)and pushes it onto the channel. No work is done on the notify thread beyond the send.Processing thread. A dedicated thread runs an infinite loop with three branches:
ProcMsg::Notify(event)— filter through gitignore/nxignore (watch_filterer.rs), run the new-directory fast path on Linux/Windows if the event is aCreate(Folder), transform notify's kinds into ourEventType(Create/Update/Delete), and merge each resultingWatchEventInternalinto a per-pathHashMapaccumulator. The merge rules are:(_, Delete)→ Delete (file is gone, final state wins)(_, Create)→ Create (file is in its initial state from our perspective; overrides earlier Update or Delete — the regression case 15-18 above)(Delete, Update)→ Update (file came back with new content)(Create, Update)/(Update, Update)→ keep existing (Create wins on Linux's IN_CREATE+IN_MODIFY pair)ProcMsg::ForceFlush(reply)— drain whatever notify has buffered (while let Ok(msg) = rx.try_recv()so anything in flight at the moment of the request is included), collect every queued ForceFlush reply channel encountered during the drain, snapshot the accumulator, and send the same snapshot to all collected callers (closes refactor(utils):remove projectName from the arguments #14 above). Used by the daemon to absorb pending events before serving a cached project graph (closes the IDLE_WINDOW race where a 99 ms-old event would otherwise miss the next read).Err(RecvTimeoutError::Timeout)— the wake-up deadline elapsed; emit the accumulator to JS via the napiThreadsafeFunction(callback_tsfn.call(Ok(events), NonBlocking)), clear it, and reset.Trailing-edge debounce. Each
Notifyingest updates a singleflush_deadline = min(now + IDLE_WINDOW, burst_start + MAX_WAIT):IDLE_WINDOW = 100 ms— flush when the channel goes quiet for this long. Resets on every arriving event, so any burst with gaps <100 ms coalesces into one flush.MAX_WAIT = 500 ms— starvation cap from the first event of the burst.rx.recv_timeout(wait)either picks up the next message or returnsTimeoutexactly atflush_deadline. No separate timers, no cross-flush state, norecent_pathsbook-keeping.flush_deadline = None) the loop polls onSHUTDOWN_POLLsostop_flagis observed promptly.napi tsfn handoff. The processing thread invokes
callback_tsfn.call(Ok(Vec<WatchEvent>), NonBlocking)to schedule the JS callback on Node's main thread. The notify thread itself never crosses into JS — only the processing thread does, and only at flush time.Watcher::watchis now a thin wrapper around an internalwatch_innerthat takes a generic callback, so unit tests can exercise the same loop without a JS runtime.force_flush_pending(synchronous drain). The daemon callswatcher.force_flush_pending()from JS before reading the cached project graph. JS-side napi method creates async_channel::<Vec<WatchEvent>>(1)reply pair and sendsProcMsg::ForceFlush(reply_tx)on the same channel notify events flow through. The processing thread sees the request in order with any buffered notify events, drains, takes the accumulator, and replies. Concurrent callers all receive the same snapshot (closes refactor(utils):remove projectName from the arguments #14).Event classification (
types.rs::transform_event_to_watch_events)!meta_exists(metadata)) only short-circuits toEventType::Deletewhen the notify event_kind is actuallyRemove(_). ForModify/Createevents whose stat happened to fail during a transient atomic-rename window, we fall through to the platform-specific path which derives the type fromevent_kindalone (closes #19a).Modify(Name(RenameMode::Both | Any))— the coalesced rename event notify-rs delivers in addition to per-sideFrom/Toevents — is now skipped, so it can't override theFromDelete on the source path of a rename (closes #19b).New-directory fast path (Linux/Windows)
When notify reports a
Create(Folder)on inotify/ReadDirectoryChangesW (which only watch the dirs they were given), the processing thread:watcher.watch(new_dir, NonRecursive)synchronously — the OS watch is active the moment this returns.EventType::Create.mkdir -p a/b/c/dstill hooks every level).Because (1) is synchronous, files written between
mkdirand thewatch()call are caught by the re-walk in (2). Notify events for those same files arriving later just merge into the same accumulator entry — no duplication. macOS doesn't need this path:FsEventWatcheris recursive.notify_watcheris held asOption<Arc<Mutex<RecommendedWatcher>>>on the struct so the processing-thread closure can clone theArc; otherwise the macOSmove-closure (which has no cfg-active references) wouldn't capture the watcher andFsEventWatcherwould drop the momentwatch()returned.Daemon
getPluginsSeparatedclearspendingPluginsPromiseand tears down workers whennx.json#plugins's hash changes.persistProjectGraphToDiskhelper called fromkickOffRecomputeandgetCachedSerializedProjectGraphPromise.this._out/this._errafter both opens resolve, pass the localfds tospawn.NX_*env reflection. NewapplyDaemonEnvFromClient(newEnv)helper writes new keys AND deletes anyNX_-prefixed key the daemon has that the client doesn't (skipping daemon-side exclusions and required settings).scheduleTimeoutId→ in-flight promise for self-documenting recompute scheduling.notifyFileWatcherSocketsregistration at daemon startup; one notification per batch.getProjectsAndGlobalChangesusescreateProjectRootMappings+findProjectForPath, cached by reference identity oncurrentProjectGraph.cacheHasBeenPersistedflag set inpersistProjectGraphToDisk.resetInternalStateIfNxDepsMissingreturns early if the flag is false — before the first successful write, a missingproject-graph.jsonis the expected state. Itscatchbranch no longer auto-resets on transient stat errors.chainToLatestdefensive kickOff (closes Workspace schematic should include code formatting #13). When a stale compute hitschainToLatestand findscachedSerializedProjectGraphPromise === undefined(becauseresetInternalStateran), it now kicks off a successor synchronously and returns that promise instead ofundefined. Prevents the "stale compute commits stale data" path that the falsy guard let through.Test suite
NX_DAEMON='true'is the default ine2e/utils/command-utils.tsso CI exercises the daemon path.[existing outputs match the cache, left as is]for the daemon-on no-op restore path. One assertion incache.test.ts:216reverted to[local cache]because that test adds an extra file todist/, invalidating the outputs hash and forcing a real restore.Successfully ran target Xinstead ofBUILD SUCCESS.verbose: truedropped where it only added Maven-Xdebug noise.tree-killand waits forclosebefore reading output. Default wait shortened from 6s/8s → 1s/2s now that debounce is deterministic.std::os::unix::net::UnixListener::bindinstead ofnix::unistd::mkfifo— sameis_hashable_filerejection, nonixfeature flag needed.packages/nx/src/native/watch/watcher.rs) driveWatcher::watch_innerend-to-end with real fs ops on a tempdir — inotify/FSEvents → notify-rs → ProcMsg channel → EventIngestor → accumulator → callback. Cover: git-style unlink+write, vim-style atomic rename, plain in-place update, fresh create, rm, create+rm, cross-name rename, multi-file burst coalescing, hardcoded-ignored paths never reach callback, and 8-way concurrentforce_flush_pending(regression for refactor(utils):remove projectName from the arguments #14). Tests use canonicalized tempdir paths so the synthetic-gitignore filter scopes correctly on macOS where/tmpsymlinks to/private/tmp.Testing
pnpm nx run e2e-nx:e2e-ci--src/watch.test.ts --skip-nx-cache— 8/8 passed.pnpm nx run e2e-js:e2e-ci--src/js-strip-types.test.ts— 3/3 (was failing on test 3 before the env-reflection fix).pnpm nx run e2e-maven:e2e-ci--src/maven-batch.test.ts— 3/3 (was failing on test 1 + 3 before the assertion fix).pnpm nx run e2e-release:e2e-ci--src/multiple-release-branches.test.ts— 2/2 (was failing both before the merge-event fix; reproduced locally and confirmed via the daemon log dump thatgit checkoutwas emitting a stray Delete for one of the package.json files).pnpm nx run nx:test-native— 349 tests pass, including the 10 new watcher tests on Linux and macOS.Related Issue(s)
N/A — flaky-test investigation that grew into a daemon-stability hardening pass.