initialCompilation() in packages/cli/src/swc/dir.ts constructs a new Piscina({...}) and never destroys it. Harmless for one-shot CLI runs (the process exits), but swcDir() is also consumed as a library by long-lived processes — @nestjs/cli's swc builder re-invokes it on every file-add event in watch mode.
Each leaked pool permanently pins minThreads (= cores / 2) eagerly-spawned idle workers — piscina's default idleTimeout never reaps below the minimum. Observed in a week-long nest start --watch -b swc session on macOS:
- ~1,700 idle
WorkerThreads, ~15 GB physical footprint (vmmap: dominated by tens of thousands of idle V8 isolate regions), one kqueue + one directory fd each (lsof);
- inspector
NodeWorker enumeration showed every worker parked at piscina/dist/worker.js;
- thread count grows monotonically with src file-add events (renames and git checkouts count — atomic writes surface as
add).
Repro without nest:
const swcDir = require("@swc/cli/lib/swc/dir").default;
(async () => {
for (let i = 0; i < 10; i++) {
await swcDir({
cliOptions: {
filenames: ["src"], outDir: "dist", sync: false, watch: false,
extensions: [".ts"], copyFiles: false, includeDotfiles: false,
stripLeadingPaths: false, quiet: true,
},
swcOptions: {},
});
// thread count grows by ~cores/2 per iteration:
// macOS: ps -M <pid> | wc -l Linux: ls /proc/<pid>/task | wc -l
}
})();
The watch path compiles via dirWorker directly and never uses this pool, so destroying it after the initial batch (e.g. .finally(() => workers.destroy())) is behavior-neutral.
This issue was investigated and drafted by Claude; I'm following up and available for a PR if wanted.
initialCompilation()inpackages/cli/src/swc/dir.tsconstructs anew Piscina({...})and never destroys it. Harmless for one-shot CLI runs (the process exits), butswcDir()is also consumed as a library by long-lived processes —@nestjs/cli's swc builder re-invokes it on every file-add event in watch mode.Each leaked pool permanently pins
minThreads(= cores / 2) eagerly-spawned idle workers — piscina's defaultidleTimeoutnever reaps below the minimum. Observed in a week-longnest start --watch -b swcsession on macOS:WorkerThreads, ~15 GB physical footprint (vmmap: dominated by tens of thousands of idle V8 isolate regions), one kqueue + one directory fd each (lsof);NodeWorkerenumeration showed every worker parked atpiscina/dist/worker.js;add).Repro without nest:
The watch path compiles via
dirWorkerdirectly and never uses this pool, so destroying it after the initial batch (e.g..finally(() => workers.destroy())) is behavior-neutral.This issue was investigated and drafted by Claude; I'm following up and available for a PR if wanted.