Summary
The Nx daemon installs nx@latest into a fresh temp directory (~60 MB) on every daemon start, and the cleanup on shutdown can never complete because it is an un-awaited async rm immediately followed by process.exit(0).
The result is one leaked ~60 MB directory per daemon lifetime, on graceful shutdown as well as hard kills. On a machine with many workspace roots this accumulates silently until the disk fills. I measured 88 leaked directories / ~5.4 GB on one dev machine, which surfaced as an unrelated ENOTEMPTY/ENOSPC failure in a test suite.
Both defects are present and identical in 22.7.7 and 23.1.1 (I diffed the published dist of both).
Defect 1 — unconditional nx@latest install at daemon boot
dist/src/daemon/server/server.js fires two background primers immediately after the server starts listening:
// Kick off Nx Console check in background to prime the cache
handleGetNxConsoleStatus().catch(() => {
// Ignore errors, this is a background operation
});
// Kick off AI agents outdated check in background to prime the cache
handleGetConfigureAiAgentsStatus().catch(() => {
// Ignore errors, this is a background operation
});
Both paths call getLatestNxTmpPath() (dist/src/daemon/server/latest-nx.js), which runs installPackageToTmpAsync('nx', 'latest') → a full package-manager install of nx@latest into a tmp.dirSync() directory (~60 MB, dominated by the native @nx/nx-* binary).
This is speculative work: an unconditional network fetch plus a ~60 MB install performed at every daemon boot, in order to maybe show a prompt. Nothing requested it. The log signature is [LATEST-NX]: Pulling latest Nx... roughly 2 ms after New daemon starting from:.
Note this also means the daemon resolves, installs, and then require.resolves + imports code from an unpinned nx@latest at runtime, independent of the workspace's pinned version.
Defect 2 — shutdown cleanup is un-awaited and loses the race with process.exit
dist/src/daemon/server/shutdown-utils.js:
async function performShutdown(server, reason, sockets) {
try {
...
// Clean up shared latest Nx installation
cleanupLatestNx(); // <-- synchronous call, schedules async rm, returns immediately
// Flush analytics before exiting
flushAnalytics();
serverLogger.log(`Server stopped because: "${reason}"`);
} finally {
process.exit(0); // <-- process terminates before the rm's callbacks run
}
}
cleanupLatestNx (dist/src/daemon/server/latest-nx.js) is not async and does not await:
function cleanupLatestNx() {
if (cleanupFn) {
serverLogger.log('[LATEST-NX]: Cleaning up latest Nx installation from', latestNxTmpPath);
cleanupFn(); // <-- returns a floating promise; the rm never completes
}
latestNxTmpPath = null;
cleanupFn = null;
}
cleanupFn is the cleanup from createTempNpmDirectory(), i.e. async () => { await rm(dir, { recursive: true, force: true }) }.
Because process.exit() discards pending event-loop work rather than draining it, and because removing a node_modules tree is on the order of 10⁴ unlink/rmdir syscalls, the cleanup is not a race that sometimes loses — it structurally cannot finish.
Two consequences worth calling out:
- It leaks on graceful shutdown (3 h inactivity timeout,
SIGINT/SIGTERM/SIGHUP, restart-on-change), not only on hard kills.
- The log line is emitted before the work and unconditionally, so the daemon log reports a cleanup that never happened. In my logs, 66 distinct directories had a
[LATEST-NX]: Cleaning up latest Nx installation from … line, and every one I sampled was still on disk more than 21 hours later.
Reproduction
- In any Nx workspace, with the daemon enabled (i.e. not CI/Docker), run any command that starts the daemon, e.g.
npx nx show projects.
- Observe a new temp directory appear, containing only
package.json, a lockfile, and node_modules:
$ cat /tmp/tmp-<pid>-<rand>/package.json
{
"devDependencies": {
"nx": "^23.1.1"
}
}
$ du -sh /tmp/tmp-<pid>-<rand>
61M /tmp/tmp-<pid>-<rand>
- Stop the daemon gracefully:
npx nx daemon --stop (or kill -TERM <daemon pid>).
- The daemon log records the cleanup:
[LATEST-NX]: Cleaning up latest Nx installation from /tmp/tmp-<pid>-<rand>
- The directory is still there.
Repeat, and a new ~60 MB directory accumulates each time. Note the daemon's inactivity timeout is 3 h (SERVER_INACTIVITY_TIMEOUT_MS = 10800000), so in normal use this recurs on its own, per workspace root, without any explicit restart.
Expected
Either no unconditional nx@latest install at boot, or a temp install that is actually removed on shutdown.
Actual
One ~60 MB directory leaked per daemon lifetime, per workspace root, with a log line falsely indicating it was cleaned up.
Impact / accrual
The unit of accrual is one directory per daemon lifetime per workspace root. Two multipliers, neither bounded:
- Breadth — one daemon per workspace root, no sharing. Repos using git worktrees (or a repo plus a submodule that is its own Nx workspace) multiply this.
- Depth — the 3 h inactivity timeout means a long-lived checkout accrues one leak per idle cycle. On my machine a single long-lived checkout accounted for 16 of them.
Measured across 39 workspace roots: 55 pulls, 30 roots leaking exactly once, one root leaking 16 times; 88 directories / ~5.4 GB total. Because /tmp is shared and (on macOS) reaped only at boot, the eventual ENOSPC surfaces far away from the cause — in my case as a confusing ENOTEMPTY in an unrelated test suite.
TMPDIR is in DAEMON_ENV_VARS_EXCLUSIONS (dist/src/daemon/client/daemon-environment.js), so the daemon falls back to the global /tmp rather than any per-user temp location the user may have configured.
Workaround
NX_USE_LOCAL=true short-circuits both primers to the local implementation before any install, and it is not in the daemon env exclusion list, so it propagates to the daemon. Existing daemons must be restarted for it to take effect.
Suggested fixes
-
Await the cleanup. Make cleanupLatestNx async, return the promise, and await it in performShutdown before process.exit(0) (ideally with a timeout so shutdown can't hang). This is the minimal fix. Moving the log line to after the awaited rm would also stop the log from reporting a cleanup that didn't happen.
-
Better: remove the need for cleanup at all. This directory is logically a cache, but it is given a per-process identity (tmp-<pid>-<rand>), so every daemon must both create and destroy its own copy — and every failed destroy is permanent garbage. Installing to a stable, version-keyed path (e.g. ~/.cache/nx/latest-nx/<version>/) would let daemons reuse one copy, bound the steady state to one directory per nx version instead of one per process, and eliminate the exit-time cleanup entirely. A release you never have to perform can't be skipped.
-
Consider gating the speculative install. Fetching and installing nx@latest on every daemon boot to decide whether to show a prompt is a large unconditional cost (network + ~60 MB + disk churn) for an occasional benefit. Caching the answer across daemon lifetimes, or only doing the work when the prompt is actually about to be shown, would avoid most of these installs.
Environment
Nx: 22.7.7 (defect also verified in 23.1.1)
Node: 26.5.0
Package manager: bun 1.3.13
OS: macOS 26.2 arm64
Daemon: enabled (local, non-CI)
Summary
The Nx daemon installs
nx@latestinto a fresh temp directory (~60 MB) on every daemon start, and the cleanup on shutdown can never complete because it is an un-awaited asyncrmimmediately followed byprocess.exit(0).The result is one leaked ~60 MB directory per daemon lifetime, on graceful shutdown as well as hard kills. On a machine with many workspace roots this accumulates silently until the disk fills. I measured 88 leaked directories / ~5.4 GB on one dev machine, which surfaced as an unrelated
ENOTEMPTY/ENOSPCfailure in a test suite.Both defects are present and identical in 22.7.7 and 23.1.1 (I diffed the published
distof both).Defect 1 — unconditional
nx@latestinstall at daemon bootdist/src/daemon/server/server.jsfires two background primers immediately after the server starts listening:Both paths call
getLatestNxTmpPath()(dist/src/daemon/server/latest-nx.js), which runsinstallPackageToTmpAsync('nx', 'latest')→ a full package-manager install ofnx@latestinto atmp.dirSync()directory (~60 MB, dominated by the native@nx/nx-*binary).This is speculative work: an unconditional network fetch plus a ~60 MB install performed at every daemon boot, in order to maybe show a prompt. Nothing requested it. The log signature is
[LATEST-NX]: Pulling latest Nx...roughly 2 ms afterNew daemon starting from:.Note this also means the daemon resolves, installs, and then
require.resolves + imports code from an unpinnednx@latestat runtime, independent of the workspace's pinned version.Defect 2 — shutdown cleanup is un-awaited and loses the race with
process.exitdist/src/daemon/server/shutdown-utils.js:cleanupLatestNx(dist/src/daemon/server/latest-nx.js) is notasyncand does not await:cleanupFnis thecleanupfromcreateTempNpmDirectory(), i.e.async () => { await rm(dir, { recursive: true, force: true }) }.Because
process.exit()discards pending event-loop work rather than draining it, and because removing anode_modulestree is on the order of 10⁴unlink/rmdirsyscalls, the cleanup is not a race that sometimes loses — it structurally cannot finish.Two consequences worth calling out:
SIGINT/SIGTERM/SIGHUP, restart-on-change), not only on hard kills.[LATEST-NX]: Cleaning up latest Nx installation from …line, and every one I sampled was still on disk more than 21 hours later.Reproduction
npx nx show projects.package.json, a lockfile, andnode_modules:npx nx daemon --stop(orkill -TERM <daemon pid>).Repeat, and a new ~60 MB directory accumulates each time. Note the daemon's inactivity timeout is 3 h (
SERVER_INACTIVITY_TIMEOUT_MS = 10800000), so in normal use this recurs on its own, per workspace root, without any explicit restart.Expected
Either no unconditional
nx@latestinstall at boot, or a temp install that is actually removed on shutdown.Actual
One ~60 MB directory leaked per daemon lifetime, per workspace root, with a log line falsely indicating it was cleaned up.
Impact / accrual
The unit of accrual is one directory per daemon lifetime per workspace root. Two multipliers, neither bounded:
Measured across 39 workspace roots: 55 pulls, 30 roots leaking exactly once, one root leaking 16 times; 88 directories / ~5.4 GB total. Because
/tmpis shared and (on macOS) reaped only at boot, the eventualENOSPCsurfaces far away from the cause — in my case as a confusingENOTEMPTYin an unrelated test suite.TMPDIRis inDAEMON_ENV_VARS_EXCLUSIONS(dist/src/daemon/client/daemon-environment.js), so the daemon falls back to the global/tmprather than any per-user temp location the user may have configured.Workaround
NX_USE_LOCAL=trueshort-circuits both primers to the local implementation before any install, and it is not in the daemon env exclusion list, so it propagates to the daemon. Existing daemons must be restarted for it to take effect.Suggested fixes
Await the cleanup. Make
cleanupLatestNxasync, return the promise, andawaitit inperformShutdownbeforeprocess.exit(0)(ideally with a timeout so shutdown can't hang). This is the minimal fix. Moving the log line to after the awaitedrmwould also stop the log from reporting a cleanup that didn't happen.Better: remove the need for cleanup at all. This directory is logically a cache, but it is given a per-process identity (
tmp-<pid>-<rand>), so every daemon must both create and destroy its own copy — and every failed destroy is permanent garbage. Installing to a stable, version-keyed path (e.g.~/.cache/nx/latest-nx/<version>/) would let daemons reuse one copy, bound the steady state to one directory pernxversion instead of one per process, and eliminate the exit-time cleanup entirely. A release you never have to perform can't be skipped.Consider gating the speculative install. Fetching and installing
nx@lateston every daemon boot to decide whether to show a prompt is a large unconditional cost (network + ~60 MB + disk churn) for an occasional benefit. Caching the answer across daemon lifetimes, or only doing the work when the prompt is actually about to be shown, would avoid most of these installs.Environment