Skip to content

kylecannon/rolldown-watch-fd-bug

Repository files navigation

rolldown watch() fd retention bug — reproduction

Two bugs in rolldown's watch() mode, fully reproducible with synthetic files. No external codebase needed.

Quick Start

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 issue

Environment

Tested 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

Bug 1: watch() retains ~8.5 fds per source directory, breaking child_process.spawn()

Script: repro-fd-retention.mjs

Description

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.

Run

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 growth

Expected output (50 libs)

Generating 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

Scaling data

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.

Key observations

  • 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. Only watcher.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.

Bug 2: watcher.close() deadlocks when called inside its event callback

Script: repro-close-deadlock.mjs

Description

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.)

Run

node repro-close-deadlock.mjs
# Hangs for 10 seconds, then prints deadlock confirmation and exits

Expected output

Generating 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.
──────────────────────────────────────────────────

Workaround: watch.watcher.usePolling avoids fd retention

Script: repro-usePolling-fix.mjs

Description

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.

Run

node repro-usePolling-fix.mjs

Expected output

── default watcher ──

  fd delta:                         +10710
  spawn /bin/echo:                  FAILED (EBADF)

── usePolling: true ──

  fd delta:                         +0
  spawn /bin/echo:                  OK

Trade-off

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.


Real-world impact

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:node executor calls fork() to run the built service → EBADF
  • tsc --watch spawned 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).


How the repro works

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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors