Skip to content

fix: stop spinning while waiting for trailers, and document the ordering - #43

Merged
passcod merged 2 commits into
mainfrom
claude/trailers-no-spin
Aug 5, 2026
Merged

fix: stop spinning while waiting for trailers, and document the ordering#43
passcod merged 2 commits into
mainfrom
claude/trailers-no-spin

Conversation

@passcod

@passcod passcod commented Aug 5, 2026

Copy link
Copy Markdown
Owner

🤖 Closes #31 — but not the way that issue proposed, because the premise turned out to be wrong.

The ordering is correct

The current spec proposal for trailers (whatwg/fetch#1940) says the promise resolves after the body is completed, and therefore "will not resolve if the body is not consumed". So await res.trailers before reading the body is a deadlock the caller wrote, and faith waiting is the specified behaviour. My first plan was to drain the body internally so the intuitive order would work; that would have been a deviation from the spec dressed up as a fix.

What was wrong is how the waiting happened, and that nothing told anyone about it.

The spin

The wait was yield_now() in a loop over an RwLock, for a wait the spec makes unbounded by design. Half a second of waiting cost half a second of CPU, kept Node's event loop alive, and survived a test framework's own timeout — in the conformance harness this presented as a CI job that hung with no diagnosis.

It's now a tokio::sync::watch channel:

  • trailers that already arrived return without yielding
  • a waiter parks until the body ends, and can be cancelled while parked
  • a promise held from before the body is read resolves when the body ends, so "one task reads the body, another awaits trailers" works

Measured in the test: 1ms of CPU across 500ms of waiting, against roughly 500ms before.

discard() no longer leaves it pending forever

Discarding the body bypassed the stream that collects trailers — on a multiplexed connection it cancels the stream before any could arrive, and draining an HTTP/1 body there reads frames directly. Either way no trailers are coming, so discard() now settles the question as null instead of leaving a waiter parked for the lifetime of the process.

That's a protocol-independent rule rather than "real trailers on HTTP/1, null on HTTP/2", which is what capturing them opportunistically would have produced.

Documented where a caller would look

The getter's doc comment (and so index.d.ts), wrapper.d.ts, and the README's Response.trailers section — each pointing at the spec, so it reads as specified behaviour rather than a faith quirk. Nothing mentioned the ordering before, which is what made the wedge easy to hit.

Tests

test/trailers.test.js, against a local cleartext HTTP/1.1 origin that emits a trailer: trailers arrive after the body, a body with none resolves to null, the promise stays pending until the body is consumed and costs no CPU while pending, a promise held from before the read resolves when the body ends, and a discarded body answers null.

The CPU assertion is the one that pins the actual bug. Its threshold is loose (200ms across a 500ms wait) because it only needs to sit well under the ~500ms a spin costs.

Cleartext on purpose: the first draft used the shared test CA and failed on macOS and Windows with InvalidCertificate(EkuError) — rustls rejecting that certificate's extended key usage, on platforms where nothing had used it before, since the HTTP/3 tests that generate it are Linux-only. Trailers have nothing to do with TLS, so the fixture is gone from this test rather than papered over. The certificate itself is worth a separate look; it is fine on Linux and rejected elsewhere.

1391 assertions pass, including these 10.

…means

Closes #31, but not the way that issue proposed. Waiting for the body first is
correct: the fetch spec's trailers proposal
(whatwg/fetch#1940) says the promise resolves after the body
is completed, and so "will not resolve if the body is not consumed". So the ordering
stays; what was wrong is how the waiting was done and that nothing said so.

The wait was a `yield_now` loop over a lock, for a wait the spec makes unbounded by
design. Half a second of waiting cost half a second of CPU, kept Node's event loop
alive, and survived a test framework's own timeout -- in the conformance harness this
presented as a job that hung with no diagnosis. It is now a watch channel: trailers
that already arrived return without yielding, a waiter parks until the body ends, and
the future can be cancelled while it waits. Measured at 1ms of CPU across 500ms of
waiting, against ~500ms before.

discard() now settles the question as "none" instead of leaving it pending forever.
On a multiplexed connection it cancels the stream before trailers could arrive, and
draining an HTTP/1 body there bypasses the stream that would have collected them --
either way no trailers are coming, and a caller who discarded the body and then
awaited trailers used to wait forever.

The ordering is documented where a caller would look: the getter, wrapper.d.ts and
the README's Response.trailers section, each pointing at the spec so it reads as the
specified behaviour rather than a quirk. Nothing said it before, which is what made
the wedge easy to hit.

The test pins both halves: that the promise stays pending until the body is consumed,
that it costs no CPU while pending, that a promise held from before the read resolves
when the body ends, and that a discarded body answers null.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@passcod
passcod force-pushed the claude/trailers-no-spin branch from 2d0b8a7 to 9270197 Compare August 5, 2026 15:25
@passcod
passcod enabled auto-merge August 5, 2026 15:34
@passcod
passcod merged commit 4986bcc into main Aug 5, 2026
28 checks passed
@passcod
passcod deleted the claude/trailers-no-spin branch August 5, 2026 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

response.trailers spins forever if the body isn't consumed first, with no error path and outside the request timeout

1 participant