usePolling: silent 100% CPU busy-loop when a relatively-watched file's tree (incl. process CWD) is deleted — repros on v3/v4/v5
Summary
When chokidar watches a single file by relative path with usePolling: true, and the watched file's directory tree — including the process's working directory — is deleted, the watcher enters a silent, unbounded, zero-delay retry loop that pins a CPU core (~100%) forever, saturating the main thread and the entire libuv threadpool with tens of thousands of fs operations per second.
Reproduced on v3.6.0, v4.0.3, and v5.0.0, on Node 24.15.0 and 26.0.0 (Linux, ext4).
Real-world impact: @stoplight/prism-cli (mock server) watches its OpenAPI spec exactly this way (relative path, CHOKIDAR_USEPOLLING=1, no error handler). One such server left running in a git worktree went from idle to a constant ~107% CPU the moment the worktree was deleted, and burned a full core silently for 22+ hours before being found.
Reproduction
repro.mjs:
import { watch } from 'chokidar';
const w = watch('sub/spec.yaml', { usePolling: true }); // relative path
w.on('all', (ev, p) => console.log(ev, p));
w.on('error', (e) => console.log('error', e?.code));
setInterval(() => {}, 60000); // keep process alive (stand-in for a server)mkdir -p /tmp/repro/wt/sub && echo hi > /tmp/repro/wt/sub/spec.yaml
cd /tmp/repro/wt
node /path/to/repro.mjs & # (with chokidar installed next to repro.mjs)
sleep 2
rm -rf /tmp/repro/wt # delete the tree INCLUDING the process CWD
# -> the node process is now pinned at ~100% CPU forever, with no outputMeasured CPU of the watcher process over 3 s, ~4 s after the deletion:
| chokidar | watch path | mode | CPU |
|---|---|---|---|
| 3.6.0 | relative | polling | ~106% |
| 3.6.0 | absolute | polling | 0% |
| 3.6.0 | relative | inotify (usePolling: false) |
0% |
| 4.0.3 | relative | polling | ~97% |
| 5.0.0 | relative | polling | ~98% |
| 5.0.0 (Node 24.15.0) | relative | polling | ~108% |
Thread profile of the spinning process: main thread ~100% plus all 4 default libuv threadpool workers busy; ~70k eventfd/pipe wakeups per second (≈35k async fs completions/sec). No output is ever produced after the single unlink event.
Mechanism (traced line-by-line on v3.6.0; the same logic survives verbatim in v5.0.0)
- Polling notices the deletion;
_remove()runs the single-watched-file branch — "if the only watched file is removed, watch for its return" — and re-adds the watch (v5.0.0index.js~L721-723; v3.6.0index.js~L856-858). add()'s failure path walks up to the parent: when_addToNodeFsreturns the failed path, the.then()immediately re-callsthis.add(sp.dirname(item), sp.basename(_origAdd || item))— with no delay (v5.0.0index.js~L371-378; v3.6.0index.js~L449-460).- With a relative watch path the walk-up terminates at
'.', which is a fixed point:path.dirname('.') === '.'. And at'.'the failure is persistent:stat('.')succeeds (Linux resolves.through the deleted-but-still-referenced CWD inode), but the follow-uprealpath('.')throwsENOENT(it resolves through Node's cached, now-staleprocess.cwd()string). Failure → step 2 fires again with identical arguments → infinite zero-delay loop. - The loop is silent: every error is
ENOENT, which_handleErrorintentionally does not emit, so even a registerederrorlistener hears nothing.
Each iteration costs one stat plus several lstats (the realpath component walk) on the threadpool, which matches the measured ~35k fs completions/sec.
Expected behavior
Retry at the polling interval (or give up and emit an error) — never a zero-delay loop. Two small fixes would each break the cycle:
- In the
add()walk-up, stop whensp.dirname(item) === item(fixed point at'.'or/), and/or - Delay the failure-path re-add by
options.interval(or any backoff) instead of recursing synchronously in the.then().
Environment
- chokidar 3.6.0 / 4.0.3 / 5.0.0
- Node.js 26.0.0 (host) and 24.15.0 (container) — behavior identical
- Linux 6.17 (Ubuntu), ext4
Source: paulmillr/chokidar