@swc/cli: swcDir() leaks a Piscina worker pool per invocation when used as a library

Open Beginner friendly
#126 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
75/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Quiet
Tech stack
node.js, typescript
Domain
cli, tooling

Research direction

Start in packages/cli/src/swc/dir.ts at initialCompilation(), then trace the swcDir() call path and Piscina worker lifecycle. Run the supplied repeated swcDir() reproduction and compare process thread counts before and after the initial batch. Done means repeated library invocations no longer leave worker pools behind, while the watch path remains behavior-neutral.

Written by the indexing model from the issue text.

Description

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. ~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);
  2. inspector NodeWorker enumeration showed every worker parked at piscina/dist/worker.js;
  3. 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.

Dominant language
TypeScript
Stars
86
Forks
44
Avg merge
3d 49m
Merged PRs (30d)
1

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from swc-project/pkgs

All issues in swc-project/pkgs

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.