Skip to content

fetch(h2): send the request body when no 100 Continue arrives - #43385

Open
robobun wants to merge 8 commits into
mainfrom
robobun/dbed29af/h2-expect-continue-timeout
Open

robobun wants to merge 8 commits into
mainfrom
robobun/dbed29af/h2-expect-continue-timeout

Conversation

@robobun

@robobun robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A fetch POST with expect: 100-continue over HTTP/2 (protocol: "http2") sends HEADERS and holds the body until 100 Continue or a final status arrives. If neither arrives, no DATA frame follows. Only the idle timeout ends the request (The operation timed out, code 23, after 360 s).
  • nginx never sends 100 Continue on HTTP/2 (ngx_http_test_expect returns early for h2 streams), so this is the normal path there.
  • write_request sets Stream.awaiting_continue (src/http/h2_client/encode.rs:193). Only decode_header_block clears it (src/http/h2_client/dispatch.rs:683).

Fix

  • While a stream holds its body, rearm_timeout arms the session's socket timer for its next tick. On that tick, release_held_bodies sends each body held for at least 1 s and arms the idle timeout again.
  • The measured wait is about 4 s on a new connection and for sequential requests, 1 to 5 s otherwise. The HTTP thread has no finer clock than the 4 s uSockets tick. curl and Go wait 1 s. Bun's h1 and h3 clients do not hold the body.
  • Verified: two new tests in test/js/web/fetch/fetch-http2-client.test.ts time out without the fix. All 77 tests in the file pass on Linux, the four Expect tests on Windows (debug builds).

Background

  • Expect: 100-continue asks the origin for 100 Continue before the client sends the body. After a final status instead, the client sends no body.
  • ClientSession is one HTTP/2 connection of fetch, with one Stream per request, on the HTTP thread.
  • uSockets gives each socket one timeout, on a 4 s tick. rearm_timeout uses it as the session's idle timeout.
Notes

Question for a maintainer: keep the hold, or delete it? This PR keeps it and puts a bound on it. The other option is to delete the hold (about 40 lines less in encode.rs and dispatch.rs). Then the h2 client sends the body at once, as the h1 and h3 clients do, and no request waits. The cost is the case that Expect: 100-continue with final status before 100 skips body upload tests: an origin that rejects early no longer saves the upload. HTTP/2 flow control already limits that upload to one stream window (64 KB by default). If the answer is delete, I will replace this PR with that change.

Report: a fuzz run found this (31 of 4,500 cells, every hang had this shape). There is no GitHub issue. It is not a regression: 1.4.2 and main behave the same.

Related, both closed without a merge and without a maintainer decision: #35781 (hold the body on the h1 client too) and #35788 (do not forward Expect at all).

A 100 or a final status that arrives before the tick behaves as before.

Measurements with this PR, against an h2 origin that never sends 100:

#0 started@0ms     took 4057ms -> got 7 bytes, expect=100-continue
#1 started@4057ms  took 3998ms -> got 7 bytes, expect=100-continue
#2 started@8055ms  took 4004ms -> got 7 bytes, expect=100-continue

The first tick comes 4 s after the loop gets its first socket, so a new process waits about 4 s. A request that starts when the previous one ends starts just after a tick, so it waits 4 s too. A caller deadline shorter than the wait (for example AbortSignal.timeout(3000)) still fires first.

A 1 s wait needs a fine timer that the HTTP thread does not have. On epoll and kqueue, uSockets has no us_timer_t. The only clock is the timeout wheel, which is also what the timeout option of fetch uses.

Why continue_timer_armed is a field and is not derived when the timer fires: if the origin resets the held stream, nothing arms the timer again (RST_STREAM does not count as progress). The tick then finds no held stream. Without the field, that tick is taken for the idle timeout and fails every other request on the connection. The second new test covers this. With the field removed, its sibling request fails with The operation timed out (code 23).

The idle timeout does not run during a hold. It restarts from zero when the hold ends, as it does after any write. So an idle deadline slips by at most one hold.

The existing hold test (withholds the body until 100 arrives) now asserts that the DATA frame comes after the server wrote the 100. The three Expect tests share one raw frame server helper, withRawH2Frames.

Under Expect: 100-continue the h2 client does not send content-length (the body can be abandoned). This PR does not change that, so a body that the timer releases also goes without content-length.

Self-reviewed. The review agreed with a bound on the hold. It asked for the measured wait, the nginx fact, the related PRs, and the keep or delete question in this description. It also led to the second test and to the single pass in rearm_timeout.


[human-review] gate passed · iteration 1 · 7 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/web/fetch/fetch-http2-client.test.ts"
bun test v1.4.3 (b52d51348)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [1181.13ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [976.47ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [1286.33ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [692.04ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [1744.46ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > ReadableStream body that does not match its declared content-length rejects [943.11ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA fr
... (truncated)

release without fix: 3 failed, 4 skipped
bun test v1.4.3-canary.1 (b52d51348)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [70.86ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [66.87ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [64.32ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frames [96.78ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [104.62ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > connection-specific request headers are stripped before HPACK [74.13ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response trailers are consumed without breaking the body [78.64ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > cold-start: parallel requests coalesce onto one TLS connect [95.99ms]
(pass) fetch() over HTTP
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/web/fetch/fetch-http2-client.test.ts"
bun test v1.4.3 (b52d51348)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [1207.15ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [1683.13ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [1650.50ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [730.97ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [1968.10ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frames [2142.97ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > ReadableStream body that does not match its declared content-length r
... (truncated)

release with fix: 4 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 895ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/8] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[2/8] gen cpp.rs (cppbind)
[2/8] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:8
   �[1m�[94m|�[0m
�[1m�[94m41�[0m �[91m- �[0mname = �[91m"bun_shim_impl"�[0m
�[1m�[94m41�[0m �[92m+ �[0mname = �[92m"bun-shim-impl"�[0m
   �[1m�[94m|�[0m
�[1m�[33mwarning�[0m: `bun_shim_impl` (manifest) generated 1 warning
�[1m�[33mwarning�[0m�[1m: `feature(generic_const_exprs)` is not supported with the next-generation trait solver�[0m
 �[1m�[94m--> �[0
... (truncated)
diff hotspot
src/http/H2Client.rs                         |   3 +
 src/http/HTTPContext.rs                      |   3 +
 src/http/h2_client/ClientSession.rs          |  50 ++++++-
 src/http/h2_client/Stream.rs                 |   6 +-
 src/http/h2_client/dispatch.rs               |   5 +-
 src/http/h2_client/encode.rs                 |   4 +-
 test/js/web/fetch/fetch-http2-client.test.ts | 208 +++++++++++++++++++--------
 7 files changed, 208 insertions(+), 71 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                          reads  edits  tests
src/http/H2Client.rs                              2      1     34
src/http/HTTPContext.rs                           1      0     34
src/http/h2_client/ClientSession.rs               4      0     34
src/http/h2_client/Stream.rs                      1      1     33
src/http/h2_client/dispatch.rs                    3      0     33
src/http/h2_client/encode.rs                      1      0     33
test/js/web/fetch/fetch-http2-client.test.ts      7      2     33

With Expect: 100-continue the HTTP/2 client held the request body until a
100 or a final status arrived. Nothing else ended the wait, so an origin
that never answers the expectation got no DATA frame and the request ended
only with the idle timeout.

While a body is held, the session now arms its socket timer for the next
tick. The first tick at least 1 s after the HEADERS sends the body as if
100 Continue had arrived.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 81041e36-d899-48fd-af25-714a675cf7d5

📥 Commits

Reviewing files that changed from the base of the PR and between 062c980 and 05739c3.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 116b67eb-b338-4e1d-9061-9a15b28069db

📥 Commits

Reviewing files that changed from the base of the PR and between 88e06c6 and 062c980.

📒 Files selected for processing (1)
  • src/http/HTTPContext.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.


Walkthrough

The HTTP/2 client now tracks Expect: 100-continue wait times and releases held request bodies after a one-second timeout. Timeout handling distinguishes continuation timers from idle timers. Tests verify frame order, body transmission after the wait, and sibling-stream behavior.

Changes

Expect Continue Timeout

Layer / File(s) Summary
Track continuation state
src/http/H2Client.rs, src/http/h2_client/Stream.rs, src/http/h2_client/encode.rs, src/http/h2_client/dispatch.rs
The client records when a request body begins waiting for 100 Continue. It clears the wait state when it receives 100 Continue or a final response.
Handle session timers
src/http/h2_client/ClientSession.rs, src/http/HTTPContext.rs
ClientSession prioritizes continuation timers and releases held bodies after the configured timeout. HTTPContext continues idle-timeout handling when the session does not handle the timeout.
Validate frame and stream behavior
test/js/web/fetch/fetch-http2-client.test.ts
The test harness exposes parsed frames. Tests cover delayed body transmission, timeout release for fixed and streaming bodies, and sibling requests when a held request is reset.

Suggested reviewers: cirospaciari

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 062c9

The change addresses delayed HTTP/2 request bodies, and the inspected timeout paths retain connection monitoring. No concrete merge-blocking risk is established.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: sending the HTTP/2 request body when no 100 Continue response arrives.
Description check ✅ Passed The description thoroughly explains the problem, fix, behavior, design considerations, and verification results. It does not use the exact template headings, but it contains the required information a…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduction (bun 1.4.3 canary and a debug build of main, Linux x64):

  1. Start an HTTP/2 origin that uses the stream event of node:http2 and answers when the request body ends. It never sends 100 Continue.
  2. Run fetch(url, { method: "POST", body: "nr1-nr2", headers: { expect: "100-continue" }, protocol: "http2", tls: { ca } }).

Before: the origin gets no DATA frame, and there is no response after 10 s. The request ends only with the 360 s idle timeout.
With this PR: got 7 bytes, expect=100-continue after about 4 s.

Tests: bun bd test test/js/web/fetch/fetch-http2-client.test.ts -t "Expect: 100-continue". The two new tests time out without the fix.

CI: the diff is green. Head 05739c3397 has main merged in, with no code change. In Buildkite 122536 (finished) the only red job is debian 13 x64-asan - test-bun, and its only red test is test/js/bun/spawn/spawn.test.ts (an idle reader stopped at the highwater mark does not keep the process alive). This PR does not touch that code. The same test is red in the final builds of three other PRs that merged on 2026-10-01 (Buildkite 122179, 122284, 122378). The rerun, Buildkite 122584 (finished), shows the same one red job and test. fetch-http2-client.test.ts fails on no job of either build.

One question needs a maintainer: keep the hold with this bound, or delete the hold so that the h2 client sends the body at once, as the h1 and h3 clients do. The Notes block of the description has the numbers for both.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the generalized ClientSession::enter in src/http/h2_client/ClientSession.rs: the closure result is a plain bool computed before the socket_ref_owed release, so the SAFETY comment still holds for the new on_timeout entry point, and a fail_all teardown inside release_held_bodies is handled by the same owed-ref path as the other entry points. The on_timeout == true early return in HTTPContext.rs leaves the socket alive with a freshly armed timer, so it does not strand a dead socket.

Extended reasoning...

Findings were reported inline (a confirmed idle-timeout regression for sibling streams plus two test nits), so this body only records what else was examined. I re-read the enter generalization against its SAFETY comment: result is produced by the closure before the owed socket ref is released, the return type is bool with no borrow of the session, and release_held_bodies calling fail_all follows the same teardown contract as on_close/on_data. I also traced the Handler::on_timeout change: when ClientSession::on_timeout returns true the session has already re-armed the socket timer in rearm_timeout, so skipping mark_socket_as_dead is correct; when it returns false the prior idle-timeout failure path is unchanged. The content-length concern was ruled out earlier in this run. A human should still weigh the confirmed idle-timeout finding and the maintainer question in the PR description (keep vs delete the hold).

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 test/js/web/fetch/fetch-http2-client.test.ts — nit: maintainers get no test that fails if the 1 s hold bound is dropped, so the clause that keeps Expect meaningful is unverified. release_held_bodies only clears streams held for EXPECT_CONTINUE_TIMEOUT (ClientSession.rs:966-968); removing that comparison still passes every test. The existing "withholds the body until 100 arrives" test records client frames in seen but never checks that the DATA came after the server wrote the 100. Fix: assert ordering against the server's own write, e.g. push a marker into seen when the 100 is written at test line 1289 and require firstData to be past it, so a release before the 100 fails the test.

    Extended reasoning...

    REVIEW.md asks that deleting each load-bearing clause of a fix breaks at least one test. The new mechanism has two clauses: the continue_timer_armed guard (covered by the sibling-reset test) and the now.saturating_duration_since(since) >= EXPECT_CONTINUE_TIMEOUT comparison at src/http/h2_client/ClientSession.rs:966-968. Without the comparison, a body queued a few milliseconds before a 4 s sweep tick is released on that tick, before the origin has had any chance to send 100, which defeats the expectation the header asks for. The two new tests (lines 1365 and 1436) use servers that never send 100, so they pass with or without the comparison. The pre-existing test at line 1263 sends the 100 after 20 ms and asserts only seen[0].type === 1, firstData > 0 and seen[firstData].len === 20; these hold whether the DATA was released by the 100 or by a tick, because seen contains client-to-server frames only and has no record of when the 100 was written. The clause is therefore untested. Recording the server's write as a marker in seen and asserting firstData is past it makes the…

    Verification: nit. Triggering condition: a maintainer deletes or breaks the >= EXPECT_CONTINUE_TIMEOUT comparison; the suite stays green. Mechanism verified: release_held_bodies (src/http/h2_client/ClientSession.rs:962-971) clears awaiting_continue only when now.saturating_duration_since(since) >= EXPECT_CONTINUE_TIMEOUT; that comparison is the sole thing that makes the constant's documented contract…

Comment thread src/http/h2_client/ClientSession.rs
Comment thread test/js/web/fetch/fetch-http2-client.test.ts Outdated
withRawH2Frames hands every client frame to a callback. withRawH2Server is
now a wrapper over it, and the three Expect: 100-continue tests use it
instead of their own copies of the parse loop. The hold test now records
where the server wrote the 100 and asserts that the DATA frame comes after
it.
Comment thread src/http/H2Client.rs Outdated
@robobun

robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:38 PM PT - Sep 18th, 2026

✅ @robobun, your commit 88e06c638b48fe7bb6f1184a6996a6be57e50a44 passed in Build #118065! 🎉


🧪   To try this PR locally:

bunx bun-pr 43385

That installs a local version of the PR into your bun-43385 executable, so you can run:

bun-43385 --bun

Comment thread src/http/h2_client/ClientSession.rs Outdated
Comment thread src/http/h2_client/ClientSession.rs Outdated
Comment thread src/http/h2_client/ClientSession.rs Outdated
Comment thread src/http/h2_client/ClientSession.rs Outdated
Comment thread src/http/h2_client/Stream.rs Outdated
@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

On the finding in the review body (no test fails if the 1 s bound is dropped):

  • The order assertion is in d567a38. The hold test now records where the server wrote the 100 and asserts ["HEADERS", "100", "DATA 20"], so a body that leaves before the 100 fails it.
  • I did not add a test for the >= EXPECT_CONTINUE_TIMEOUT comparison itself. It only changes the result when a 4 s tick lands less than 1 s after the HEADERS. A test cannot set the phase of that tick. It would have to sleep for several seconds to aim at it, and under load it would miss the window and pass with or without the comparison.

The two inline threads are answered and resolved. The comment length findings are fixed in 88e06c6.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants