A symlink whose target path passes through a regular file (ENOTDIR) silently disables watching of its directory and prevents `ready`
Describe the bug
If a watched directory contains a symlink whose target path passes through a
regular file (so realpath() fails with ENOTDIR), chokidar silently stops
watching that directory and the watcher's ready event never fires. No
error event is emitted, and no events are ever delivered for sibling files
in that directory.
A merely dangling symlink (ENOENT) behaves fine, which makes this easy to
misdiagnose. Links like this occur in practice when build output changes
shape (e.g. a stale dist/lib.js/index.js link after dist/lib.js became a
file).
Versions (please complete the following information):
- Chokidar version: 5.0.0 (readdirp 5.1.1); also reproduced on 4.0.3
- Node version: 26.7.0
- OS version: Linux (arm64); also reproduced under Bun 1.3.14
To Reproduce:
import { mkdtempSync, writeFileSync, symlinkSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import chokidar from "chokidar";
const root = mkdtempSync(join(tmpdir(), "enotdir-"));
writeFileSync(join(root, "regular.txt"), "hi\n");
// Target path passes THROUGH regular.txt → realpath() rejects with ENOTDIR.
// (Replace with a dangling target, e.g. join(root, "missing"), and
// everything works — that's the control case.)
symlinkSync(join(root, "regular.txt", "nope"), join(root, "poison"));
const w = chokidar.watch(root, { followSymlinks: false });
w.on("ready", () => console.log("ready"));
w.on("error", (e) => console.log("error", e.code));
w.on("all", (ev, p) => console.log(ev, p));
setTimeout(() => writeFileSync(join(root, "regular.txt"), "edit\n"), 500);
// Expected: "ready", then add/change for regular.txt (or at minimum an
// "error" so the caller can react).
// Observed: only the initial addDir for the root. No ready, no error, no
// add/change for regular.txt — ever.Happy to contribute this as a test case for the test suite if that's useful.
Expected behavior
Either treat a failed realpath() on a symlink entry the same as the ENOENT
case (skip the entry, keep watching the rest of the directory, fire ready),
or surface an error event — anything but silent partial coverage with
ready withheld.
Additional context
The mechanism appears to be in readdirp rather than chokidar itself:
_getEntryType() calls realpath() on symlink entries and routes failures to
_onError(), whose NORMAL_FLOW_ERRORS set (ENOENT, EPERM, EACCES,
ELOOP, recursive) turns known errnos into warn — but anything else,
including ENOTDIR, calls destroy(err) on the stream (readdirp 5.1.1
index.js). The destroyed listing takes the directory's watches and the
pending ready accounting with it. A one-line fix may be adding ENOTDIR to
the survivable set — a symlink whose target can't resolve isn't a directory to
descend either way — but I'm happy to move this issue to the readdirp repo or
send a PR to either project if you'd prefer.
Found while building peruse (a local directory viewer that watches served
directories); we currently work around it by detecting the stalled scan and
covering affected directories with fs.watch.
Source: paulmillr/chokidar