Two bugs in rolldown's watch() mode, fully reproducible with synthetic files. No external codebase needed.
git clone <this-repo>
cd rolldown-watch-fd-bug
pnpm install
node repro-fd-retention.mjs # Bug 1: fd retention → spawn breaks (60 libs default)
node repro-close-deadlock.mjs # Bug 2: watcher.close() deadlocks
node repro-usePolling-fix.mjs # Workaround: usePolling avoids the issueTested on:
- rolldown: 1.0.0-rc.13
- Node.js: v22.22.0
- OS: macOS 15 (Darwin 24.6.0, Apple Silicon arm64)
- Kernel fd limits:
kern.maxfilesperproc: 245760,ulimit -n: unlimited
Script: repro-fd-retention.mjs
After watch() triggers its first build and emits BUNDLE_END, the process retains approximately 8.5 open file descriptors per directory in the source tree. These are predominantly directory handles (~90%) with some file handles (~10%).
For projects with ~1,200+ source directories (typical for a large monorepo), the total fd count exceeds macOS's effective posix_spawn threshold (~10,240 fds, the OPEN_MAX constant). After this point, all child_process.spawn(), fork(), spawnSync(), and execFileSync() calls fail with EBADF, regardless of their stdio configuration.
One-shot build() with the identical config retains zero extra fds.
node repro-fd-retention.mjs # 60 libs, ~1500 dirs → spawn FAILS
node repro-fd-retention.mjs --libs 20 # 20 libs, ~500 dirs → spawn OK, shows fd growthGenerating 50 libraries × 17 subdirectories...
Files: 901
Directories: 1252
── build() (one-shot) ─────────────────────────────
fd delta: +0
spawn /bin/echo: OK
── watch() ────────────────────────────────────────
build time: 730ms
fd delta: +10710
fds per directory: 8.6
spawn /bin/echo: FAILED (EBADF)
── after watcher.close() ──────────────────────────
fd delta: +0
spawn /bin/echo: OK
── Summary ────────────────────────────────────────
Directories: 1252
build() fd retention: 0
watch() fd retention: 10710
fds per directory: 8.6
spawn after watch(): BROKEN
spawn after close(): OK
| Libraries | Directories | fd delta | fds/dir | spawn() works? |
|---|---|---|---|---|
| 10 | 254 | +2,152 | 8.5 | Yes |
| 20 | 504 | +4,290 | 8.5 | Yes |
| 30 | 754 | +6,432 | 8.5 | Yes |
| 40 | 1,004 | +8,572 | 8.5 | Yes |
| 50 | 1,254 | +10,712 | 8.5 | No (EBADF) |
The relationship is perfectly linear. Spawn breaks at ~10,240 total fds.
build()is not affected. One-shot builds retain zero extra fds.- The fds are released by
watcher.close(). They are held intentionally, not leaked. - ~90% of retained fds are directory handles, ~10% are file handles.
event.result.close()does not release any fds. Onlywatcher.close()does.- The trigger is directory count, not file count. 25,000 files in a flat structure (few directories) shows only +8 fds. 900 files across 1,252 directories shows +10,710 fds.
Script: repro-close-deadlock.mjs
Calling await watcher.close() from inside a watcher.on('event', ...) callback causes the returned promise to never resolve. The process hangs indefinitely.
This matters because the natural workaround for Bug 1 is to call watcher.close() in the BUNDLE_END handler to release fds before spawning a child process. Doing so deadlocks instead.
(watcher.close() works correctly when called outside the event handler — that's how repro-fd-retention.mjs works.)
node repro-close-deadlock.mjs
# Hangs for 10 seconds, then prints deadlock confirmation and exitsGenerating small project (10 libraries)...
Starting watch...
Build complete. fd delta: +710
event.result.close() called — fds unchanged
Calling watcher.close() from inside event handler...
(expected: resolves and releases fds)
(actual: hangs forever)
──────────────────────────────────────────────────
DEADLOCK: watcher.close() did not resolve after
10 seconds. The promise never settles when called
from inside the watcher event callback.
──────────────────────────────────────────────────
Script: repro-usePolling-fix.mjs
Setting watch: { watcher: { usePolling: true } } in the rolldown config switches the file watcher from the default native backend to a polling-based watcher. The polling watcher does not retain directory handles, so the fd count stays at baseline and child_process works normally.
node repro-usePolling-fix.mjs── default watcher ──
fd delta: +10710
spawn /bin/echo: FAILED (EBADF)
── usePolling: true ──
fd delta: +0
spawn /bin/echo: OK
Polling-based watching is less efficient than native file watching (higher CPU usage, latency between file change and detection). It works as a workaround but the underlying fd retention in the default watcher should still be fixed.
We discovered these bugs while integrating rolldown's watch() into an NX monorepo build executor for ~100 NestJS microservices. The typical service imports from ~90 shared libraries, creating ~23,000 source directories. After watch() builds:
- NX
@nx/js:nodeexecutor callsfork()to run the built service → EBADF tsc --watchspawned alongside for type checking → EBADF- Any post-build child process (linters, formatters, scripts) → EBADF
Every child_process call fails. The error message (EBADF) is misleading — it suggests a bad file descriptor rather than the actual cause (excessive fd retention by the watcher).
The generate.mjs script creates a synthetic monorepo:
src/
libs/
lib-0000/
src/
core/services/services.ts
core/utils/utils.ts
core/entities/entities.ts
core/interfaces/interfaces.ts
api/controllers/controllers.ts
api/dto/dto.ts
api/guards/guards.ts
infrastructure/repositories/repositories.ts
infrastructure/mappers/mappers.ts
domain/models/models.ts
domain/events/events.ts
common/decorators/decorators.ts
common/filters/filters.ts
common/pipes/pipes.ts
common/interceptors/interceptors.ts
__tests__/unit/unit.ts
__tests__/integration/integration.ts
index.ts ← barrel export
lib-0001/
src/index.ts → imports from lib-0000 ← chain dependency
...
...
app/
main.ts → imports from last lib ← entry point
Each library has 17 nested subdirectories. Libraries are chained via barrel imports so building from the entry point traverses the full directory tree.
The key variable is directory count. The file contents are trivial — simple TypeScript exports. The fd retention is triggered purely by the number of directories rolldown traverses during module resolution in watch mode.