http(h2/h3): detach the stream before a terminal header-time progress update
What does this PR do?
Fixes a use-after-free in the HTTP/2 (and HTTP/3) fetch client when a server completes a response at header time without closing the stream.
Problem
deliver_stream follows a detach-before-terminal discipline: a terminal progress update (has_more == false) frees the ThreadlocalAsyncHTTP that embeds the HTTPClient, so every path that can dispatch one first clears stream.client / client.h2 and removes the stream. The header-progress call missed one case: a response whose headers carry Content-Length: 0 but still announce a body, e.g. a text/event-stream content-type (handle_response_metadata returns ContinueStreaming for SSE regardless of content length). state.is_done() is then already true at header time (total_body_received >= content_length, 0 >= 0), so the headerProgress update at h2_client/ClientSession.rs:1061 dispatched a terminal result and freed the client while the stream was still attached to the session. The very next statement reads the freed client.state, and any later frame for that stream id (DATA, RST_STREAM, connection close) re-enters the freed client and can dispatch a second terminal result into the freed tasklet.
On a debug build the first terminal dispatch trips the teardown assertion deterministically:
panic: assertion failed: client.h2.is_none()
<bun_http::async_http::AsyncHTTP>::on_async_http_callback_raw src/http/AsyncHTTP.rs:754
<bun_http::HTTPClient>::send_progress_update_multiplexed src/http/lib.rs:4292
<bun_http::h2_client::client_session::ClientSession>::deliver_stream src/http/h2_client/ClientSession.rs:1061
The HTTP/3 client session has the same hole at the same point in its deliver (h3_client/ClientSession.rs).
Reachability: fetch only uses HTTP/2/3 when opted in (protocol: "http2", the CLI flag, or the experimental env flags), so this does not affect default-configuration fetch.
Fix
Fold client.state.is_done() into the existing headers-complete-the-response branch in both sessions. The zero-length case now detaches the stream and finishes through finish_stream exactly like HEAD/204/304 and END_STREAM-at-headers already do: the response delivers complete with an empty body (same outcome as the HTTP/1.1 path for the identical response, where the terminal progress update also fires at header time), the Content-Length check in finish_stream still enforces RFC 9113 section 8.1.1 if stray DATA arrived in the same read, and the deliver loop RST_STREAMs the still-open stream so the server releases its slot. With the branch handling is_done(), the header-progress update below it can no longer be terminal (state.fail is only ever set by paths that dispatch and detach immediately).
(Correction from self-review: an earlier revision of this description claimed the HTTP/3 fold also changed redirect handling. It does not. handle_response_metadata returns Finished for every redirect-pending response (src/http/lib.rs:5260-5269), so the redirect sub-branch in the HTTP/3 session was already reachable before this change; the new is_done() condition only adds the non-redirect zero-length case.)
How did you verify your code works?
New test in test/js/web/fetch/fetch-http2-adversarial.test.ts: a raw h2 server answers with HEADERS (END_HEADERS only, no END_STREAM) carrying content-length: 0 and content-type: text/event-stream. Before the fix the child process aborts with the assertion above; after, fetch resolves with a complete empty 200 response and the child exits cleanly.
Also ran fetch-http2-adversarial (16 pass), fetch-http2-client (62 pass), fetch-http2-leak (7 pass), fetch-http3-client (53 pass), fetch-http3-adversarial (27 pass), and the undici-h2 / wpt-h2 suites against the debug+ASAN build.