fix: stop spinning while waiting for trailers, and document the ordering - #43
Merged
Conversation
…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
force-pushed
the
claude/trailers-no-spin
branch
from
August 5, 2026 15:25
2d0b8a7 to
9270197
Compare
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.
🤖 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.trailersbefore 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 anRwLock, 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::watchchannel:Measured in the test: 1ms of CPU across 500ms of waiting, against roughly 500ms before.
discard()no longer leaves it pending foreverDiscarding 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 asnullinstead 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'sResponse.trailerssection — 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 tonull, 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 answersnull.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.