fix(FR-3214): kill the whole dev process group on shutdown so leaked watch orphans don't exhaust file descriptors#8156
Conversation
How to use the Graphite Merge QueueAdd either label to this PR to merge it via the merge queue:
You must have a Graphite account in order to use the merge queue. Sign up using this link. An organization admin has required the Graphite Merge Queue in this repository. Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue. This stack of pull requests is managed by Graphite. Learn more about stacking. |
There was a problem hiding this comment.
Pull request overview
This PR fixes EMFILE: too many open files failures that occur when running several repositories' Vite dev servers concurrently. The root cause (measured via lsof) is that vite-plugin-checker's TypeScript watch program installs a per-file watcher on every file in the program — including thousands of immutable dependency .d.ts files in the pnpm global store — consuming ~3,800 file descriptors per repo. The fix tells the checker's TS watch program not to watch the store (while still reading it for type-checking) via watchOptions.excludeDirectories/excludeFiles.
Because vite-plugin-checker 0.9.3 has no inline watchOptions API and the exclude spec must be an absolute, machine-specific store path (TS anchors **/-globs under the project basePath), the config is generated at dev startup into a gitignored react/tsconfig.checker.json that extends the base tsconfig.json, and the checker is pointed at it. It gracefully falls back to the base tsconfig for vite build, npm/yarn flat installs, or any write failure.
Changes:
- Added
resolveCheckerTsconfigPath()inreact/vite.config.tsthat (in serve mode) writes a derivedtsconfig.checker.jsonextending the base config withwatchOptionsexcludes for the resolved pnpm store path, and pointsvite-plugin-checkerat it. - Preserved original behavior via graceful fallback to the committed base tsconfig when no store path is available or the write fails.
- Added the derived
react/tsconfig.checker.jsonto.gitignore.
Reviewed changes
Copilot reviewed 1 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
react/vite.config.ts |
Adds resolveCheckerTsconfigPath() helper, computes checkerTsconfigPath in serve mode, and wires it into the checker() plugin; updates surrounding comments. |
.gitignore |
Ignores the machine-specific derived react/tsconfig.checker.json generated at dev startup. |
Notes on correctness reviewed: The derived file is written to react/tsconfig.checker.json and extends: './tsconfig.json' resolves to react/tsconfig.json (same directory), so inherited include/exclude/paths remain correct. The store path is normalized to forward slashes for cross-platform JSON validity, console.warn matches the existing pattern in this file (resolvePnpmStorePath), one-shot tsc/verify.sh/CI are unaffected (they use the base tsconfig), and build mode passes undefined so no file is written. No blocking defects were found.
…o dev doesn't exhaust file descriptors
A single vite dev process held ~5,900 open files, ~3,800 of them immutable
pnpm-store dependency .d.ts watched by vite-plugin-checker's TS watch program
(per-file kqueue, 1 FD/file, because FSEvents is failing on macOS). Running
several repos' dev servers at once multiplied that toward the kernel/launchd
open-file limit and failed with EMFILE ("too many watched files").
Generate a gitignored react/tsconfig.checker.json at dev startup that extends
the base tsconfig and adds watchOptions excluding the (machine-specific,
absolute) pnpm store root, then point vite-plugin-checker at it during serve.
The exclude only stops *watching* the store, not *reading* it, so type-checking
stays complete and source files keep their normal fast per-file watch.
Real-stack boot test: vite REG open files 5,884 -> 1,404 (-76%), files held
open under the pnpm store 3,825 -> 2, checker still 'Found 0 errors'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
f053174 to
ea7ed7d
Compare
…watch orphans don't exhaust file descriptors Replaces the previous pnpm-store watch-exclude approach: revert the tsconfig.checker.json generation in react/vite.config.ts and instead fix the root leak — dev.mjs now spawns concurrently as a detached process group leader, forwards SIGINT/SIGTERM/SIGHUP to the whole group via negative-PID kill, sweeps surviving orphans on child exit, and escalates to SIGKILL after a 5s grace period so nothing in the watch tree can outlive dev.mjs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ar6Rr2uEGLPBXcWNpASXDD
ea7ed7d to
555dee0
Compare
Coverage Report for root-coverage
File CoverageNo changed files found. |
There was a problem hiding this comment.
LGTM
Solid fix that targets the real root cause — dev.mjs not owning the whole watch tree. Making concurrently a detached process-group leader and signaling -pgid with a SIGKILL escalation cleanly covers the --kill-others, SIGHUP, and Windows-degrade paths. node --check passes, and the remaining points are all edge cases outside real-world usage, so this is good to merge.
Before merging, please just document these two residual risks in the PR description / a comment:
1. If dev.mjs is SIGKILLed or crashes, the group is orphaned (not a regression).
The whole fix assumes dev.mjs receives a catchable signal (TERM/INT/HUP) and lives long enough to run the sweep. Because the group is now detached, it's decoupled from dev.mjs's death and the terminal hangup — so a supervisor that sends SIGKILL (uncatchable) to dev.mjs skips the sweep and reproduces exactly this leak. Most IDEs/agents send SIGTERM, so real usage is covered, but this is the one hole left open.
2. The setsid escape assumption is implicit.
The fix relies entirely on every descendant staying in the concurrently group. If anything in the chain calls setsid() / spawns its own detached group, -pgid can't reach it and it still leaks. The current toolchain (concurrently/pnpm/nodemon/relay/vite/tsc) doesn't do this, so it holds today, but it's a hidden assumption that could silently reintroduce the leak on a future tooling change — worth a one-line comment.
FYI (no change needed):
- The exit handler signals
-child.pidafter the leader has already been reaped, so there's a theoretical PID/PGID reuse race, but the window is microtask-sized and negligible. - The missing
child.on('error')handler (when theconcurrentlybinary is absent) is a pre-existing gap, not a regression. - Verified and fine:
escalation ??=single-flight, no 5s wait on normal shutdown, and SIGKILL releasing FDs immediately.

Resolves #8047 (FR-3214)
Problem
Running several repos' dev servers at once fails with
EMFILE: too many open files. A major driver turned out to be leaked orphan watch processes accumulating across dev restarts: on a live machine,psshowed relay watch chains from dev sessions started 3 and 8 days earlier still alive withPPID 1—nodemon → pnpm run relay --watch → relay-compiler cli.js → native relay --watch binary— each holding its full set of file-watcher FDs forever.Root cause
dev.mjsforwarded SIGINT/SIGTERM to one PID only (child.kill(sig)on theconcurrentlyprocess), andconcurrently --kill-otherslikewise signals only its direct children. The relay watch tree is unusually deep (concurrently → pnpm → nodemon → pnpm → node cli.js → native relay binary), and per-PID signal relay breaks at the nodemon hop — killing the direct child orphans everything below it.The leak fires on every shutdown path that doesn't broadcast to the terminal's foreground process group:
concurrently --kill-othersafter one child diesFix
scripts/dev.mjsnow owns the whole tree's lifecycle:concurrentlyas a detached process-group leader (detached: true, non-Windows). Every descendant — however deep — inherits the group.process.kill(-child.pid, sig), so every member receives the signal directly, with no reliance on intermediate layers relaying it. (SIGHUP is now forwarded explicitly because a detached group no longer receives the terminal's hangup.)concurrentlyexit (covers the--kill-otherspath, where dev.mjs itself never received a signal), dev.mjs probes the group (kill(-pgid, 0)), SIGTERMs survivors, waits up to 5s, then SIGKILLs the remainder before exiting.Windows falls back to direct
child.kill(negative-PID kill throws there); macOS/Linux — where the leak was observed — get the full group semantics.This PR previously took a different approach (excluding the pnpm store from vite-plugin-checker's TS watch program via a generated
tsconfig.checker.json). That reduced per-process FDs but left the actual leak — orphaned watch trees — unfixed; it has been reverted in favor of this root-cause fix.Measurements (live, this machine)
Scenario 1 — single-PID SIGTERM to dev.mjs (the IDE-stop leak path):
kill -TERM <dev.mjs pid>PPID 1zombies)Scenario 2 —
kill -9one child (tsc) to triggerconcurrently --kill-others:--kill-othersSIGTERM cascade. 0 survivors.Verification
bash scripts/verify.sh—=== ALL PASS ===(Relay / Lint / Format / TypeScript).node --check scripts/dev.mjspasses.ps ax -o pid,ppid,pgidgroup-membership check before/after).🤖 Generated with Claude Code