`dts` build: a worker that dies without posting a message leaves the build promise pending — tsup exits 0 having emitted no declarations
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:
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 distThe 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.jsRun 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.jsOutput 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):
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:
- 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. - The worker emits
'error'(it threw, failed to start, or hitERR_WORKER_OUT_OF_MEMORY). With no'error'listener,EventEmitterrethrows on the main thread, so the process dies withUnhandled 'error' eventand an internal Node stack rather than tsup's own build-failure path, andterminateWorker()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:
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.jsandsrc/index.tsonmainboth 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.
Source: egoist/tsup