#1410·tsup

`dts` build: a worker that dies without posting a message leaves the build promise pending — tsup exits 0 having emitted no declarations

Author: hotlongCreated Aug 31, 2026Updated Aug 31, 2026

Summary

In dtsTask (src/index.ts), the DTS worker's promise is settled only from worker.on('message', ...). There is no 'error' handler and no 'exit' handler.

If the worker thread ends without posting a message, neither branch ever runs. The promise stays pending forever, Promise.all([dtsTask(), mainTasks()]) never settles, nothing throws, nothing prints — the event loop simply drains and tsup exits 0. The esbuild pass has already written dist/ by then, so the run leaves a dist/ containing index.js and zero .d.ts files, and reports success.

Silent success is materially worse than a crash here. Build caches (turbo, nx, CI artifact caches) key on exit status, so the declaration-less dist/ is cached under the same hash a healthy build produces, and replayed on every later run — a plain rebuild is a cache hit that restores the bad artifact. The failure then surfaces somewhere else entirely, in an unrelated consumer, as TS7016: Could not find a declaration file for module '...', which reads as that consumer's fault.

Reproduction

Deterministic, about 30 seconds, no memory pressure needed:

bash
mkdir tsup-dts-worker-repro && cd tsup-dts-worker-repro
npm init -y >/dev/null
npm i -D [email protected] [email protected]

mkdir src
printf 'export interface Greeting { readonly to: string }\nexport const greet = (g: Greeting): string => `hello ${g.to}`;\n' > src/index.ts
printf '{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "declaration": true, "skipLibCheck": true } }\n' > tsconfig.json

# A preload that makes the DTS worker die without posting a message.
# --require is inherited by worker threads through execArgv, so this runs
# inside the DTS worker; the main thread is left untouched.
printf "const { isMainThread } = require('node:worker_threads');\nif (!isMainThread) process.exit(0);\n" > kill-dts-worker.cjs

# 1) healthy build, for comparison
npx tsup src/index.ts --dts --format esm; echo "exit=$?"; ls dist

# 2) identical build, DTS worker dies message-less
rm -rf dist
node --require ./kill-dts-worker.cjs ./node_modules/tsup/dist/cli-default.js \
  src/index.ts --dts --format esm; echo "exit=$?"; ls dist

The preload stands in for any real cause of a message-less worker death — a process.exit() reached anywhere inside the worker's dependency graph, a terminated thread, a thread killed without an error event. The bug is not in what killed the worker; it is that tsup has no handler for the worker ending at all.

Observed

Run 1 (healthy):

ESM dist/index.js     72.00 B
ESM ⚡️ Build success in 28ms
DTS Build start
DTS ⚡️ Build success in 1185ms
DTS dist/index.d.ts   128.00 B
exit=0
dist: index.d.ts  index.js

Run 2 (worker dies message-less):

CLI Building entry: src/index.ts
CLI Using tsconfig: tsconfig.json
CLI tsup v8.5.1
CLI Target: es2022
ESM Build start
ESM dist/index.js     72.00 B
ESM ⚡️ Build success in 36ms
exit=0
dist: index.js

Output simply stops. DTS Build start never prints, stderr is empty (0 bytes), the exit code is 0, and dist/ has no declarations.

Expected

A DTS pass that produced nothing should fail the build: non-zero exit with a message naming the DTS worker.

Cause

src/index.ts, inside dtsTask (the worker block is around lines 214-254 on main today):

javascript
const worker = new Worker(path.join(__dirname, './rollup.js'))

const terminateWorker = () => {
  if (options.watch) return
  worker.terminate()
}

worker.postMessage({ /* ... */ })

worker.on('message', (data) => {
  if (data === 'error') {
    terminateWorker()
    reject(new Error('error occurred in dts build'))
  } else if (data === 'success') {
    terminateWorker()
    resolve()
  } else {
    /* log forwarding */
  }
})
// no worker.on('error'), no worker.on('exit')

Two Node-level worker outcomes are unhandled, and they fail differently:

  1. The worker exits without posting a message. Only 'exit' fires; nothing listens; the promise stays pending. Measured: exit 0, no output at all — the silent case above.
  2. The worker emits 'error' (it threw, failed to start, or hit ERR_WORKER_OUT_OF_MEMORY). With no 'error' listener, EventEmitter rethrows on the main thread, so the process dies with Unhandled 'error' event and an internal Node stack rather than tsup's own build-failure path, and terminateWorker() never runs. Measured on Node v22.22.2:
node:events:497
      throw er; // Unhandled 'error' event
      ^
Error [ERR_WORKER_OUT_OF_MEMORY]: Worker terminated due to reaching memory limit: JS heap out of memory
    at [kOnExit] (node:internal/worker:316:26)

Case 2 at least exits non-zero; case 1 is the one that poisons build caches.

Suggested fix

Register the two missing handlers on the worker:

diff
   worker.on('message', (data) => {
     // ...unchanged...
   })
+  worker.on('error', reject)
+  worker.on('exit', (code) => {
+    reject(
+      new Error(`dts build worker exited with code ${code} without reporting a result`),
+    )
+  })

Either handler alone closes the observed case; the pair closes the class.

On the success path terminateWorker() triggers 'exit', but rejecting an already-resolved promise is a no-op, so no settled flag is strictly required — verified below. A boolean guard works equally well if you prefer it explicit.

Verification of the patch

Applied exactly the diff above to the installed tsup/dist/index.js (8.5.1) and re-ran both builds:

Run Before After
Worker dies message-less exit 0, silent, no .d.ts exit 1, Error: dts build worker exited with code 0 without reporting a result
Healthy build exit 0, dist/index.d.ts emitted exit 0, dist/index.d.ts emitted (unchanged)
Worker OOM exit 1, Unhandled 'error' event, Node internal stack exit 1, rejected through tsup's own path: Worker terminated due to reaching memory limit

Environment

  • tsup 8.5.1 (latest on npm; dist/index.js and src/index.ts on main both carry the shape above)
  • Node v22.22.2, Linux x64 (Ubuntu 24.04)
  • npm, clean standalone install

Related, not duplicates

egoist/tsup#920 and egoist/tsup#1325 report the DTS worker running out of memory — the loud path (case 2). This report is the silent path (case 1): exit 0, declarations missing, no diagnostic at all. The same two handlers improve case 2's diagnostic as a side effect.

I am happy to open a PR with the patch if that is useful.