fix(core): a lone failing cached call no longer kills the process (#670) - #676
Merged
Merged
Conversation
Adding `cache: { ttl }` to a stitch turned a handled vendor failure into
an unhandled promise rejection, which under Node's default
`--unhandled-rejections=throw` terminates the process. `.safe()` returned
an honest `ok: false` and the program died anyway; the same failure with
no `cache` block produced none.
The coalescer's leader rejects one shared promise to release its waiters.
With no concurrent caller there are no waiters, so nothing ever attached
a handler. That made it invisible in the shape a test takes — a
concurrent burst, where a follower's `await` catches the rejection by
accident — and fatal in the shape production has, a staggered backlog
where every call is its own leader. 20 staggered calls against a failing
vendor produced 20 unhandled rejections; the same 20 as a burst produced
none.
The shared promise now carries a terminal no-op handler from the moment
it is created, so being unobserved is never fatal. The handler is
attached to a derived promise and discarded, so a follower's `await`
still sees the same rejection, same tick, same error identity — the
channel #653 wants to hand failures down is untouched.
`import { stitch }` gains 11 gzip bytes, and `main` had one to spare, so
the budget takes a minimum step (21.50 → 21.55 KB). 21.5 KB is also a
rounding boundary, so the advertised figure moves ~21 → ~22 kB across the
six sites under the `bundle-advertised-size` tether — any core-path byte
would have moved it.
Closes #670
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #670. A cached stitch whose vendor fails, with no concurrent caller, emitted an unhandled promise rejection — which under Node's default
--unhandled-rejections=throwterminates the process. The caller had already handled the failure:.safe()returned an honestok: false, and the program died anyway.Reproduced first, on unpatched
mainThe issue's 14-line reproduction, run against
main's source:STILL ALIVE— the line after the handled call — never printed. After:The issue's severity table reproduces exactly, and every row is now zero:
coalesce: falseThat second row is the whole reason this survived: a test exercises coalescing with a concurrent burst, and a follower's
awaitcatches the rejection by accident. A webhook backlog or a retry drain arrives staggered, where every call is its own leader.The fix, and why this shape
One statement, in
InflightCoalescer.join, attached to the shared promise where it is created:The reasoning, since "add a
.catch" is exactly the move that deserves scrutiny:It swallows nothing a follower needs.
.catch()returns a derived promise, which is discarded.entry.promise— the object a follower awaits — is untouched. A follower'sawait claim.promisestill rejects, same tick, same error identity. Verified rather than asserted: with three concurrent callers against a failing vendor, all three still receive their own honestHTTP 503(never thecache: leader run failedsentinel) and the adapter is still called three times, before and after. What is retired is the coalescer's own liability, not the channel.That matters because of #653. #653 asks for the leader's rejection to be shared with joiners. A smaller fix was available and rejected for this reason: resolving a sentinel instead of rejecting would remove the hazard outright and save bytes, but it deletes the rejection channel #653 would build on. This leaves it intact.
Why at construction rather than in
fail(). Attaching where the promise is born makes safety structural — it does not depend on who happens to join, or when, or on the order in whichfailand a latejoininterleave. The promise is never in an unobserved-rejection state for a single tick.Why not "only reject when
waiters > 0", the issue's other suggestion. It leaves the promise permanently pending, which trades a crash for a hang and is a worse trap for the next reader. Andrefsis not a reliable proxy for "someone is listening": it is decremented by aborts, so a run can haverefs === 1with a promise someone still holds. Rejecting unconditionally keepsfail()'s contract honest; the guard makes it harmless.A note on the sentinel.
claim.fail(new Error('cache: leader run failed'))never reaches a user — the follower catches it and ignores it. It is a control signal meaning "don't wait for me", not an error report. Marking that signal handled is not a loss of information.What I deliberately did not change
LeaderClaim.promiseis never read. The leader hassettle/fail— the write end — and awaiting its own promise before settling would deadlock. Dropping the field is tempting and I implemented it, then reverted it: it is a public type change on thestitchapi/cachesubpath (LeaderClaimis inlib/cache.d.ts's export list), and measured at 3 gzip bytes it pays for none of the budget below. That is a decision worth making on its own, not as a rider on a crash fix. The interface now carries a comment saying why the field must never be awaited.Regression tests — three, and all three fail without the fix
Reverting
packages/core/src/cache.tstoorigin/mainand leaving the tests in place:with the failures reading, respectively,
expected [ Error: cache: leader run failed ] to deeply equal []andexpected 'handled: ok=false\n' to contain 'STILL ALIVE'. Restored:56 passed (56).Two in-process and one subprocess, because they prove different things:
cache-internals.spec.ts— the coalescer's own contract: a lonejoin+fail, no engine involved.cache.spec.ts, engine level — mirrors the precedent atcache.spec.ts:339-361(the LRU-eviction guard, which already established that fire-and-forget promises in the cache path must not surface). Written as a single sequential call, as the issue insists: a burst hides the bug, which is precisely why the existing "a leader failure is NOT shared" test a few lines above never caught it.unhandledRejectionevent does not fire. What a caller experiences is the exit code, and a test runner installs handlers of its own — so an in-process test can pass while the same code still kills a real program. This one bundlessrc/with esbuild (already a devDependency, already howscripts/bundle-size.mjsmeasures), writes the issue's reproduction, and spawns a barenodewithNODE_OPTIONSstripped so the default--unhandled-rejections=throwreally is in force. It asserts exit0and that stdout reaches the line after the call. Bundling fromsrcrather thanlibmeans it needs no priorpnpm buildand tests the working tree. Cost: ~140 ms.Neighbours checked
ok=false/false/false, messages["HTTP 503","HTTP 503","HTTP 503"], 3 adapter calls. Identical before and after. Failure is still not shared (each follower re-runs independently) — that is a coalesced failure is not shared, and astoresilently un-poolspool: 'host'#653's subject, unchanged here.{"hit":1}. Identical before and after.A second finding — reported, not fixed
The
import { stitch }budget is charging for a chunk a consumer's bundler would split out.cache.ts's own header says it is "shipped behind thestitchapi/cachesubpath soimport { stitch }pulls none of it", and at runtime that is true — the engine reaches it by a lazyimport('./cache')only when a stitch carries acacheblock. Butbundle-size.mjsmeasures with esbuild and nosplitting, so the dynamic import is inlined and counted. Measured both ways on this branch:cache-*.jsSo a real bundler keeps ~3.3 KB gzip of cache code out of the initial payload of
import { stitch }, and the advertised figure overstates by roughly that. I have not touched the gate — changing its methodology would move the advertised number by ~3 kB, which is a far bigger maintainer-visible decision than this fix and does not belong on a crash fix. Flagging it so it can get its own issue.Bundle budget — raised, and the advertised figure moves
Stated up front because the gate's own comment says a raise must be deliberate.
mainimport { stitch }A minimum step, not the ~0.2 KB this gate usually restores — matching #477/#524/#485 and, most recently, #675: this is a fix squeezing past a full ceiling, not a new capability, and
mainhad run down to one byte. Sizing a larger step is the maintainer's call, not a bug fix's; I will happily widen it if you prefer the #620 philosophy ("so the next small core-path fix is not gated on a budget PR of its own"), because at 41 B and 14 B the next one certainly will be.There is no version of this that fits under a zero-byte ceiling. The guard is ~11 gzip bytes; the cheaper alternatives were measured and rejected above on correctness grounds, not size.
The advertised figure moves — and not because of this change. 21.5 KB is both the budget and a rounding boundary (22016 B), and
mainmeasured 22015 B: one byte below both. Any core-path byte takesimport { stitch }from ~21 → ~22 kB. Nine figures across the six sites under thebundle-advertised-sizetether — both READMEs, the installation and principles pages, the home-page metrics component, and the docs' source blurb — propagated by hand and then verified with the tether, not assumed:Two things worth knowing about that propagation:
~21 kB brotlimoved with them and is more accurate for it: the whole entry's brotli is 21.6 KB, which rounds to 22, not 21. It was stale before.QUERYis a read, not a write (Refs #462) #675 raises the same budget and moves the same nine figures, so these two will conflict onbundle-size.mjsand the six doc files. Whichever lands second should re-run the tether rather than trust the merge; the doc half is a pure value swap either way.yakir.lockis not touched here — its baseline was already behind on five tethers before this branch, andcheckpasses on site agreement.Verification
Every gate, run to completion:
testcheck:typescheck:types-d(tsd)check:lintcheck:contractcheck:formatcheck:changelogcheck:docs-links/docs/...link resolvescheck:sizecheck:unknown-keyscheck:exports(attw)check:release--tier executableNothing failed. CHANGELOG entry under
Unreleased → Fixed.🤖 Generated with Claude Code