Repository navigation
Description
Filed out of #12078 (the rollout of scripts/check-dts-emitted.mjs across every tsup-built package), on the dispatching PM's ruling that patching a third-party dependency is a maintainer decision and not an implementer's. Nothing here is a patch; this card asks for a decision to report the defect upstream, and carries the reproduction so the report can be written from it.
The defect, in tsup's own code
tsup 8.5.1 runs DTS generation in a worker_threads.Worker (node_modules/tsup/dist/index.js, the dtsTask inside Promise.all([dtsTask(), mainTasks()])) and settles the build promise only from that worker's message events:
await new Promise((resolve, reject) => {
const worker = new Worker(path.join(__dirname, './rollup.js'));
worker.postMessage({ ... });
worker.on('message', (data) => {
if (data === 'error') { terminateWorker(); reject(...); }
else if (data === 'success') { terminateWorker(); resolve(); }
...
});
// no worker.on('error'), no worker.on('exit')
});There is no error handler and no exit handler. A worker that dies without posting a message — OOM under memory pressure is the observed shape — settles neither branch: the promise never settles, the event loop drains, and node exits 0 with the esbuild pass having already written dist/. The run leaves a dist/ holding index.js / index.mjs / maps and zero .d.ts, and reports success.
Why it matters to us specifically
turbo caches only successful tasks. Exit 0 on a run with OS_SKIP_DTS unset hashes as — because it is — an ordinary full build, so the declaration-less dist/** is cached under the ordinary hash and replayed on every later run; a plain rebuild is a cache HIT that restores it, and only --force replaces the entry. The symptom lands on someone else's diff, as TS7016: Could not find a declaration file for module '...'.
Reproduced end to end on @objectstack/plugin-auth in #11907 / PR #12076: build exit 0, turbo 26 successful, 26 total, dist/ with zero declarations, cached under the same hash a healthy build produces.
The upstream fix being proposed for report
Register both missing handlers on the worker, so a death that posts no message rejects the build promise instead of leaving it pending:
worker.on('error', reject)— the worker threw or failed to start.worker.on('exit', code)— reject when the promise has not already settled, whatever the code, since a clean exit without asuccessmessage is still a DTS pass that produced nothing.
Either one alone closes the observed OOM shape; the pair closes the class.
What we already have, and why this is a complement
PR #12076 landed scripts/check-dts-emitted.mjs (asserts every declaration path the manifest promises is present and non-empty, no-op under OS_SKIP_DTS), and #12078 rolls it into all 67 tsup-built packages' build scripts. That protects this repo now, on our schedule. An upstream fix ends the class for everyone and lets the guard become redundancy rather than the only barrier. They are complements, not alternatives — this card does not propose removing the guard.
Decision asked for
Whether to open the upstream report (tsup issue / PR), and by whom. ⛔ Note what is explicitly not proposed here: carrying a patched tsup in this repo. A patched third-party dependency is a standing supply-chain and maintenance obligation, and that call belongs to the maintainer.
Activity
os-steve commentedon Aug 26, 2026
Maintainer ruling recorded — A: open the upstream report; a seat drafts, the maintainer reviews and submits
Provenance: maintainer, 2026-08-26, live PM chat (decision-inbox batch 3, session
session_01JANH3y7qe3MD8aLaLXci8N), verbatim: 「其他接受你的建议」 — accepting the presented recommendation A.dist/, cached by turbo under the healthy hash), plus the two-line suggested fix (worker.on('error', reject)+worker.on('exit', …)rejecting an unsettled promise). Posted on THIS card for the maintainer to review and submit under the maintainer's own name (外部署名属维护者).State:
needs-user-decision→pm:queue(domain:devxstands — drafting is a small, mechanical dispatch).Generated by Claude Code
os-project-manager commentedon Aug 30, 2026
Draft upstream report for tsup — awaiting maintainer review and submission
Per the ruling on this card (A: a seat drafts, the maintainer submits under their own name), below is the complete, copy-pasteable text for the upstream tsup issue. Nothing has been posted to any repository outside this one. No ObjectStack issue numbers, links or jargon appear in the draft.
Verification done before drafting (all measured today, not recalled)
pnpm-lock.yamlonorigin/main: tsup@8.5.1 (rootpackage.json:^8.5.1)dist-tags.latest, published 2025-11-12. The pin is already the newest releasetsup/dist/index.jsworker.on("message", ...)only — noerror, noexithandler. Defect presentegoist/tsup@main,src/index.ts(fetched raw today)dist/withindex.jsand zero.d.tsPremise stands. The defect reproduces on the version this repo pins, which is also the newest published version and matches upstream
main.One correction to this card's text, carried into the draft
The card (and the header comment in the in-repo guard) names OOM as the observed shape of the silent death. Measured on Node v22.22.2, that attribution is wrong, in the direction that matters:
process.exit, was terminated, died message-less) fires only'exit'. Nothing listens. Promise pending, event loop drains, exit 0, silent. This is the silent-success defect.'error'withERR_WORKER_OUT_OF_MEMORY. With no'error'listener, Node's EventEmitter rethrows it, so the process dies loudly:Unhandled 'error' event, exit 1. Measured both with per-workerresourceLimitsand with a process-wide--max-old-space-size.So OOM does not produce the exit-0 shape on Node 22. Had the draft asserted it, the upstream maintainer would have run it, seen a crash, and closed the report as unreproducible. The draft therefore reports the class — any message-less worker death — with a deterministic repro that does not depend on memory pressure, and reports the unhandled-
'error'path as the second, separate consequence of the same missing pair.Both are still fixed by exactly the two lines this card proposes.
Duplicate check
One targeted search of
egoist/tsupissues found no report of this silent-success behaviour. The nearest neighbours are OOM crash reports (egoist/tsup#920ERR_WORKER_OUT_OF_MEMORY,egoist/tsup#1325dts OOM) — the loud path, a different symptom. The draft references them as related, not duplicates. The search was semantic, not exhaustive; a quick tracker search before submitting is still worth the maintainer's minute.BEGIN upstream text (copy from here)
Title:
dtsbuild: a worker that dies without posting a message leaves the build promise pending — tsup exits 0 having emitted no declarationsBody:
Summary
In
dtsTask(src/index.ts), the DTS worker's promise is settled only fromworker.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 writtendist/by then, so the run leaves adist/containingindex.jsand zero.d.tsfiles, 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, asTS7016: Could not find a declaration file for module '...', which reads as that consumer's fault.Reproduction
Deterministic, about 30 seconds, no memory pressure needed:
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):
Run 2 (worker dies message-less):
Output simply stops.
DTS Build startnever prints, stderr is empty (0 bytes), the exit code is 0, anddist/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, insidedtsTask(the worker block is around lines 214-254 onmaintoday):Two Node-level worker outcomes are unhandled, and they fail differently:
'exit'fires; nothing listens; the promise stays pending. Measured: exit 0, no output at all — the silent case above.'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: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 nosettledflag 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:.d.tsError: dts build worker exited with code 0 without reporting a resultdist/index.d.tsemitteddist/index.d.tsemitted (unchanged)Unhandled 'error' event, Node internal stackWorker terminated due to reaching memory limitEnvironment
dist/index.jsandsrc/index.tsonmainboth carry the shape above)Related, not duplicates
egoist/tsup#920andegoist/tsup#1325report 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.
END upstream text
Generated by Claude Code
os-project-manager commentedon Aug 30, 2026
os-dev-report
{ "issue": 12167, "status": "done", "branch": "none - drafting task, no code change, no worktree created", "pr": null, "premise_still_valid": true, "summary": "Drafting task per the maintainer's ruling A (2026-08-26). Re-derived the reproduction against the tsup this repo actually pins today and found the premise intact: origin/main resolves tsup@8.5.1, which is also npm's dist-tags.latest (published 2025-11-12), and egoist/tsup@main src/index.ts still registers only worker.on('message') with no error and no exit handler. Built a deterministic end-to-end reproduction on a clean standalone tsup 8.5.1 install (a --require preload that exits the DTS worker thread, since --require is inherited by workers via execArgv): the build exits 0 with an empty stderr and a dist/ holding index.js and zero .d.ts. Applied the proposed two-line patch to that install and confirmed it converts the run to exit 1 while leaving the healthy build unchanged. Posted the complete upstream issue text as a comment on this card for the maintainer to submit under their own name; nothing was posted to any repository outside this one, and option C was not proposed. One correction to the card is carried into the draft and filed separately: OOM is NOT the silent shape - an OOM'd worker emits ERR_WORKER_OUT_OF_MEMORY as an 'error' event which, unhandled, exits 1 loudly. Asserting OOM upstream would have handed the tsup maintainer an unreproducible report.", "tests": "No repo build/test needed - zero files changed in any ObjectStack repo (git status --porcelain on the shared checkout: clean; git worktree list: no new worktree). Verification was of the third-party defect, all measured today on Node v22.22.2 / Linux x64 / Ubuntu 24.04. (1) Version derivation: `git show origin/main:pnpm-lock.yaml | grep tsup@` -> `tsup@8.5.1`; root package.json `\"tsup\": \"^8.5.1\"`; npm registry `dist-tags.latest` = 8.5.1, so the pin is already newest. (2) Defect present in the installed bundle: `worker.on(\"message\"` = 1 hit, `worker.on(\"error\"` / `worker.on(\"exit\"` = 0 hits in tsup/dist/index.js. (3) Not fixed upstream: raw fetch of egoist/tsup@main src/index.ts (http=200) shows the same block, lines ~214-254. (4) Real reproduction, clean standalone install of tsup@8.5.1 + typescript@5.9.3: baseline build exit=0 with dist/index.d.ts emitted; identical build with a preload that runs process.exit(0) when !isMainThread gives exit=0, stdout 177 bytes ending at 'ESM Build success', stderr 0 bytes, dist/ = index.js only, d.ts count 0. (5) Patch verification, mutation confirmed on disk before each run by grep counts (error-handler 1, exit-handler 1, message-handler 1 after the edit; 0/0 after restore, and `cmp -s` byte-identical to the pre-mutation copy): patched + killed worker -> exit=1, `Error: dts build worker exited with code 0 without reporting a result`; patched + healthy -> exit=0 with dist/index.d.ts, confirming the terminate-triggered 'exit' reject is a post-settle no-op. (6) Death-mode table behind the correction: message-less exit from CJS -> exit 0 silent; message-less exit under ESM top-level await -> exit 13 with an unsettled-TLA warning; worker OOM with resourceLimits 16MB -> exit 1 `Unhandled 'error' event` ERR_WORKER_OUT_OF_MEMORY; worker OOM under process-wide --max-old-space-size=64 -> exit 1, same shape; OOM with both handlers installed -> clean rejection, `BUILD FAILED: Worker terminated due to reaching memory limit`. Draft comment read back from the API after posting: 10017 bytes, byte-identical to the local source apart from the platform collapsing one blank line before the footer - nothing eaten by the sanitizer.", "mcp_calls": "6 - issue_read get, issue_read get_comments, search_issues (egoist/tsup dedupe), add_issue_comment (the draft), search_issues (in-repo dedupe before filing), issue_write create, issue_write update. Repo-scoped REST GET works for this seat (comment read-back returned 200) but the search API returns 403, so both dedupe searches went through MCP.", "open_questions": [], "out_of_scope_findings": [ "filed as #13509 (finding): scripts/check-dts-emitted.mjs names OOM as the observed silent-build shape in both its header comment and the failure message it prints, and its printed remediation leads with a --max-old-space-size retry - measurement shows an OOM'd worker is the loud exit-1 path, so the advice targets a mode that cannot produce this failure. Docs/message only; the guard's logic is correct and untouched." ] }Delivery note: the drafted upstream text is the preceding comment, marked BEGIN/END for copy-paste. No upstream issue was opened - the external signature is the maintainer's.
Generated by Claude Code
os-project-manager commentedon Aug 30, 2026
✅ ACCEPT · R32 ·
domain:devxPM seat — report-only, ⛔ no PR, zero files changedDrafting task per the maintainer's ruling A. The draft is posted above for review and submission under the maintainer's own name; ⛔ nothing was posted outside this repository, and ⛔ option C was not proposed.
⭐⭐⭐ The re-derivation caught a false cause that would have gone upstream
I made "re-derive against the tsup this repo pins today" a precondition because the card is several rounds old. It paid, and not in the way I expected. The premise held — but one of the card's stated facts is wrong:
⇒ "Asserting OOM upstream would have handed the tsup maintainer an unreproducible report."
⭐ Sit with what that avoided. This deliverable goes out under the maintainer's name, to a third-party project. A report whose stated cause cannot produce the described symptom gets closed as invalid, and the cost is not a CI cycle — it is credibility with an upstream maintainer, spent on our behalf, unrecoverably.
The death-mode table that establishes it, all measured today on Node v22.22.2:
resourceLimits16MBUnhandled 'error' eventERR_WORKER_OUT_OF_MEMORY--max-old-space-size=64The premise itself, verified rather than assumed
origin/mainresolves tsup@8.5.1, which is also npm'sdist-tags.latest(published 2025-11-12) ⇒ ⭐ the pin is already newest, so "upgrade first" is not an available answer.worker.on("message"= 1 hit,worker.on("error"/worker.on("exit"= 0 hits.egoist/tsup@mainsrc/index.ts(http 200) shows the same block at ~214-254.⭐⭐ The patch was verified before being proposed, not merely described
A clean standalone
tsup@8.5.1+typescript@5.9.3install, with a deterministic reproduction — a--requirepreload that exits the DTS worker thread, exploiting that--requireis inherited by workers viaexecArgv. Baseline emitsdist/index.d.ts; the killed-worker run gives exit 0, stderr 0 bytes,dist/=index.jsonly, d.ts count 0.Then the proposed two-line patch applied to that install: killed worker → exit 1 with
Error: dts build worker exited with code 0 without reporting a result; healthy build → exit 0 withindex.d.tsstill emitted, confirming the terminate-triggered'exit'reject is a post-settle no-op.⭐ That last check is the one an upstream maintainer will actually ask about — "does your fix break the happy path?" — and it is answered before they ask. Mutation confirmed on disk by grep counts before each run, restore proven byte-identical with
cmp -s.Filed
#13509 —
scripts/check-dts-emitted.mjsnames OOM as the observed silent-build shape in both its header comment and its printed failure message, and its remediation leads with a--max-old-space-sizeretry. Measurement says an OOM'd worker is the loud exit-1 path ⇒ ⛔ the advice targets a mode that cannot produce this failure.Hygiene
Zero files changed in any ObjectStack repo — shared checkout
git status --porcelainclean,git worktree listshows no new worktree. Draft read back from the API after posting: 10017 bytes, byte-identical apart from the platform collapsing one blank line before the footer.⛔ This card is not closed by this comment — the deliverable is a draft awaiting the maintainer's review and submission. It closes when that submission happens, or when the maintainer decides not to send it.
Generated by Claude Code
claude commentedon Aug 31, 2026
State correction ·
domain:devxPM seat —pm:dispatched⇒pm:awaiting-maintainerThe ACCEPT above (15:35:23Z) ends with an explicit fence:
Correct, and unchanged. But the card has been wearing
pm:dispatchedwith an assignee ever since— telling the state machine a dev is in flight when the drafting work is finished and accepted.
That is this seat's own half-state residue, and it is being cleared on the record rather than
quietly.
Why
pm:awaiting-maintainerand not something elseThe state model's distinctions, applied one at a time:
needs-user-decision— the decision is already made. Ruling A was recorded on2026-08-26 (「其他接受你的建议」): a seat drafts, the maintainer reviews and submits under their
own name. Nothing is awaiting a judgement.
pm:on-hold— that is "decided, and the answer is not now". The answer here is yes,send it.
pm:awaiting-maintainer— decision made; the only remaining action is one human actionoutside GitHub: posting the drafted issue to the upstream
tsuprepository under themaintainer's own name. That is precisely what this state is for.
Per the state model,
pm:awaiting-maintaineris mutually exclusive with the otherpm:*states,so
pm:dispatchedcomes off in the same write. The cross-repo ledger and the exit for thisstate belong to the 项目总监席 — this seat only enters the state.
What the maintainer needs, in one place
ObjectStack issue numbers, links or jargon.
which is also npm's
dist-tags.latest, and upstreamsrc/index.tsstill registers only themessagehandler — noerror, noexit.goes from exit 0 with a declaration-less
dist/to exit 1 with a real error, and the happy pathstill emits
index.d.ts.One correction the draft already absorbs
ERR_WORKER_OUT_OF_MEMORYand exitsloudly. This matters beyond the draft — it is why #13509 was filed:
check-dts-emitted.mjsnames OOM as the observed silent-build shape in both its header and its failure message, and leads
its remediation with a
--max-old-space-sizeretry. ⇒ a guard whose logic is correct, telling anoperator to do something that cannot help, for a failure mode that is not the one occurring.
⛔ Ungraded, unassigned — triage's to route.
Generated by Claude Code
hotlong commentedon Aug 31, 2026
✅ SUBMITTED upstream — egoist/tsup#1410. Card closes.
Executed by the director seat (session
local_c32e5a25-0581-4665-952f-60c1be74b250) on the maintainer's in-session delegation, 2026-08-31, verbatim: 「帮我处理 os#12167」 — completing the 2026-08-26 ruling A (seat drafts, maintainer reviews and submits; the submission was performed via the maintainer-operatedhotlongaccount, which the post carries).Pre-submission checks, run fresh:
The in-repo guard (
check-dts-emitted.mjs, #12076/#12078) stays in place as the compensating control until the upstream fix ships — as this card itself prescribed (complements, not alternatives).pm:awaiting-maintainerexit: the awaited maintainer action has happened; label off, closing completed.claude commentedon Aug 31, 2026
Correction to this card's body, from a measurement taken today — posted by the
domain:devxexecution PM seat (#6023), sessionsession_01Pk26oZ12t5N1hwGW1m1MgC.⛔ This card's body is left as filed. It is the record of what was believed at the time, and rewriting it would falsify that record. This comment is the correction; the body stays.
This card's body says, verbatim:
⛔ That is not what happens. Measured on Node v22.22.2 / linux x64 against a harness carrying tsup's exact promise shape (a
Workerwith onlymessageregistered — noerror, noexit):process.exit(), CJS callerexitprocess.exit(), ESM caller (top-level await)exitresourceLimitserror--max-old-space-sizeerror⇒ Node delivers worker heap exhaustion as an
errorevent (ERR_WORKER_OUT_OF_MEMORY), and anerrorevent with no listener is rethrown by EventEmitter. An OOM'd DTS pass exits non-zero and prints a stack — it is the loud sibling, and turbo never caches it. Only the first row is silent, and the caller's module system is load-bearing: the same message-less death exits 13 with a warning under ESM and 0 in silence under CJS. tsup's CLI is CJS, which is why the observed shape was a silent one.⭐ The practical consequence, and the reason this was worth correcting rather than shrugging at: the remedy that followed from the OOM reading — build with more headroom — can never converge, because the exit-0 shape is not a memory problem.
errorevent (because that run exited 0), and therefore that memory pressure is not a demonstrated cause of this shape. ⛔ No cause is assigned here.The guard's header and its printed failure message — which cite this card — are corrected to match in #13898.
Generated by Claude Code