End HTTP/2 responses on their final DATA frame - #5874
Conversation
Node's http2 compat layer responds with `waitForTrailers: true` on every response and then sends the empty trailers HEADERS frame — the frame that carries END_STREAM — from a setImmediate. A stream destroyed before that setImmediate runs drops the trailers silently and terminates as RST_STREAM(NO_ERROR) with a truncated body, which Chromium treats as neither complete nor failed and waits on forever. `endStreamOnFinalDataFrame` wraps the request listener the realm-server's h2 server is constructed with and declines the trailers, so END_STREAM rides the last DATA frame. Nothing here sends HTTP trailers, so the response bytes are unchanged. Also label the pending tokens on the three test waiters whose single call site serves every operation of its kind, so the host's moment-of-timeout dump names the operation rather than repeating one stack frame: `fetcher` carries the method and URL in flight, `store-service` the store operation and its id, and `realm:incremental-indexing` the realm whose index event has not landed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ooR7A4u4BpLufmYBzPnJe
Preview deploymentsHost Test Results 1 files ±0 1 suites ±0 2h 37m 34s ⏱️ + 5m 6s Results for commit 948b518. ± Comparison against earlier commit f10888f. Realm Server Test Results 1 files ± 0 1 suites ±0 12m 30s ⏱️ -22s Results for commit 948b518. ± Comparison against earlier commit f10888f. |
There was a problem hiding this comment.
Pull request overview
This PR addresses intermittent host test timeouts caused by Chromium hanging on HTTP/2 responses that end via Node’s deferred empty-trailers frame (rather than END_STREAM on the final DATA frame). It also improves test-timeout diagnostics by adding per-operation labels to test-waiter tokens so the “pending waiters” dump is actionable.
Changes:
- Wrap the realm-server HTTP/2 request listener to disable
waitForTrailers, ensuring END_STREAM is carried on the final DATA frame. - Add a realm-server test that probes for
wantTrailersto verify responses don’t take the trailers path. - Extend runtime-common test-waiter plumbing to accept per-item labels, and apply labels to fetcher/store/indexing waiters to improve timeout diagnostics.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/runtime-common/test-waiters.ts | Extends waiter API to support per-item labels for better pending-waiter diagnostics. |
| packages/runtime-common/fetcher.ts | Adds a per-request label to the fetcher waiter token. |
| packages/realm-server/tests/listener-dispatcher-test.ts | Adds an HTTP/2 regression test asserting no trailers path (wantTrailers) is taken. |
| packages/realm-server/server.ts | Wraps the HTTP/2 request listener to force END_STREAM on the final DATA frame by disabling waitForTrailers. |
| packages/host/app/services/store.ts | Labels store-operation waiter tokens to identify which store operation is pending at timeout. |
| packages/host/app/services/realm.ts | Labels realm incremental-indexing waiter token with the realm URL. |
| .devcontainer/claude-web-h2-preload.cjs | Updates comment to reflect that realm-server now applies the trailer-avoidance itself. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The label is printed by the host's timeout diagnostics, which land in CI logs, and a query string can carry a credential — a Matrix OpenID exchange puts an access token in one. Identifying an outstanding request needs the request's shape, not its values, so the label keeps the method, the target and the query keys and replaces each value with a placeholder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ooR7A4u4BpLufmYBzPnJe
backspace
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] Reviewed the h2 change against node 24.17's http2 compat layer and against the env-mode topology the host shards actually run in, plus every withTestWaiters / beginAsync call site and the moment-of-timeout dump the new labels feed. I did not run the host suite.
No blocking issues. The wrapper is behavior-preserving — nothing in the repo sends trailers, and hosted staging/prod take createListener's cert-less HTTP/1.1 branch, so the blast radius is local dev and CI. What needs work is prose rather than code: the mechanism comment states a causal chain whose middle link I could not reproduce, and it names Chromium as the peer in a topology where Traefik is.
Recommendations:
- Narrow the
endStreamOnFinalDataFramecomment to the part that holds up, or name the stream destroyer that opens the window — thread on that comment block inpackages/realm-server/server.ts. - Answer whether the mechanism survives the Traefik hop in env mode — same thread. That is what decides whether this PR should claim the 60s host-test timeouts.
- Fix the
describeRequestparenthetical and settle the redaction policy against its twin inhost/tests/helpers/setup.ts— thread onpackages/runtime-common/fetcher.ts. - Give the
createwaiter label a per-operation discriminator — thread onpackages/host/app/services/store.ts.
Adjacent, not asked of this PR:
packages/host/app/utils/editor/monaco-test-waiter.tsalready receives anoperationstring per token and callswaiter.beginAsync()without it, somonaco-renderingis the same one-call-site-many-operations case with the label already in hand.- In standard local dev, vite terminates TLS itself (
devHttpsConfiginpackages/host/vite.config.mjs) and therefore serves h2 from node too; underBOXEL_ENVIRONMENTit is plain HTTP behind Traefik. If the deferral matters, that is the other node h2 server in the picture.
The comment asserted a causal chain from the deferred trailers frame to a hung browser. Its bookends hold — the compat layer always defers END_STREAM onto a setImmediate-emitted frame, and a response whose END_STREAM never arrives strands its peer — but nothing here is known to destroy a stream inside that window, and the peer in environment mode is Traefik rather than a browser. State the contract and leave reachability open; declining a deferral nothing uses stands on its own. Also drop the fetcher label's reference to a request that path cannot see: `MatrixClient#request` calls the global fetch, so its URL never reaches this waiter. Note the unredacted twin in the host's fetch-debugging helper. Give the `create` waiter label a type discriminator, so two creates into one realm no longer produce identical labels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ooR7A4u4BpLufmYBzPnJe
Node's http2 compat layer — the half that turns an
Http2Streaminto the(req, res)pair Koa is written against — responds withwaitForTrailers: trueunconditionally. That moves END_STREAM off the final DATA frame and onto an empty trailers HEADERS frame emitted from asetImmediate(finishSendTrailersinlib/internal/http2/core.js), which drops that frame when the stream is already destroyed. A response whose END_STREAM never arrives leaves its peer holding a body it can neither complete nor fail.Nothing in this repo sends HTTP trailers, so the deferral buys nothing.
endStreamOnFinalDataFramewraps the request listenercreateSecureServeris given and declines it, so the last DATA frame carries END_STREAM itself. Response bytes are unchanged.It wraps the request listener rather than the
streamevent because the compat listener node registers runs first and responds synchronously for a request the app answers without awaiting — astreamlistener added afterwards misses exactly those.allowHTTP1means it also sees HTTP/1.1 requests, whosereshas no backing stream; those pass through untouched.What this does and does not claim
This started as a fix for the 60s host-test timeouts on shard 16 of run 32796026625. It should not be read that way, and the code comment no longer says so:
{endStream: false, waitForTrailers: true, sendDate: true}on node 24, and the new test pins that declining it works.wantTrailers600 times, flush 600 trailers frames, and deliver 600 complete responses — nothing destroyed early. Forcing the window (destroying the stream from awantTrailerslistener registered after the compat layer's) still gives the peerendplusclose rst=0. Two independent attempts have failed to reach the state that would strand a peer.BOXEL_ENVIRONMENT—cion the host-test shards — Traefik fronts the realm server and negotiates h2 by ALPN to anhttps://host.docker.internal:<port>upstream (registerService(..., { http2: true }));server.tssays as much, "Traefik is the only client". A truncated response would land on Traefik's Go client first, and whether it forwards a stream it can neither complete nor fail is an open question.So: this removes a window rather than reasoning about its reachability, at no behavioral cost. Whether it moves the timeouts is unproven.
Diagnostics
Independent of the above, and the part most likely to help next time.
describePendingWaiterinhost/tests/helpers/setup.tsprefers a token's label over its stack frame, but no call site passed one, so a moment-of-timeout dump repeated the same frame once per pending token. Labelled now:fetcher→ the method and target of the request in flight, query values replaced with[redacted]store-service→ the store operation and its subject (persistAndUpdate <id>,create <type> in <realm>, …)realm:incremental-indexing→ the realm whose index event has not landedIf a 60s timeout recurs, the dump names the stuck request instead of pointing at
fetcher.ts.Known gaps
describeFetchRequestinhost/tests/helpers/setup.tsprints full URLs with query values into the same dump, so the two lines still disagree on redaction policy. Itsurlis load-bearing for test-realm routing, not only logging, so redacting it is a separate change; noted in the comment rather than done here.monaco-test-waiter.tsalready receives anoperationstring per token and drops it atbeginAsync()— the same one-call-site-many-operations case, label already in hand. Not touched here.Verification
listener-dispatcher-test.tsdrives the realcreateListenerand assertswantTrailers— which node emits only for a response that took the trailers path — never fires. All 12 tests in that module pass; with the wrapper removed only the new one fails, while the response body still arrives intact.lintandlint:typesclean forrealm-server,host, andruntime-common.🤖 Generated with Claude Code
https://claude.ai/code/session_016ooR7A4u4BpLufmYBzPnJe