Skip to content

Bun.serve(http3): abort the request when the client sends STOP_SENDING with its upload still open - #42901

Open
robobun wants to merge 2 commits into
mainfrom
robobun/0988b594/h3-stop-sending-aborts-request
Open

robobun wants to merge 2 commits into
mainfrom
robobun/0988b594/h3-stop-sending-aborts-request

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Over HTTP/3, a client sends STOP_SENDING for the response while its request side is still open (an upload without FIN). Bun.serve does not abort the request: req.signal stays silent and server.pendingRequests stays at 1 until the upload ends or the connection closes. Fixes Bun.serve(http3): STOP_SENDING does not abort the request while the request side is open #42889.
  • On STOP_SENDING, lsquic queues RESET_STREAM for the response, drops the stream from its write queue and calls on_reset(how = 1). us_quic_on_reset (packages/bun-usockets/src/quic.c:742) ignored how == 1, because the fetch() client shares the callback. A response that waits for on_stream_writable is never told, and on_stream_close (which fires onAborted) never runs.

Fix

  • On a server context, on_reset(how = 1) now sends STOP_SENDING for the client's upload with the code the client sent, and closes the stream. on_stream_close then aborts the request (RFC 9114 section 4.1.1). The fetch() client keeps its behaviour: its read half stays open for a response that arrives together with STOP_SENDING.
  • lsquic called on_reset(how = 1) before it queued RESET_STREAM and before it cleared the stream's stale sending flags. A close from inside the callback sent a FIN in place of the reset, and the STOP_SENDING it queued was cleared. The new patch patches/lsquic/stop-sending-on-reset-after-reset.patch moves the callback after both. node:quic only records the code in its on_reset, so the order does not change it.
  • Browsers and curl send RESET_STREAM together with STOP_SENDING. They take the how == 0 path, which already closed the stream. This change is for a client that cancels the response alone.
  • Verified: test/js/bun/http/serve-http3.test.ts (new test "req.signal aborts on client STOP_SENDING while the request body is still open", times out on stock bun). Also all of serve-http3.test.ts, test/js/node/quic/, the fetch-http3-* client tests and the node:quic stop/reset/close parallel tests.

Background

  • A QUIC stream has two halves. STOP_SENDING asks the peer to reset the half it sends on. RESET_STREAM ends the half the sender sends on. A FIN ends it in order.
  • lsquic drives the application with callbacks on a stream_if: on_write when the stream can take data, on_reset(how) when the peer resets a half, on_close when both halves are done. Bun.serve and fetch() share one stream_if in quic.c. node:quic has its own in node_quic_shim.c.
  • vendor/lsquic is fetched from a pinned tarball and patched at build time by scripts/build/deps/lsquic.ts.
Notes
  • Trace with BUN_DEBUG_lsquic=1 before the fix: after RX STOP_SENDING the server generates RESET_STREAM, then nothing for stream 0 until the connection closes. drop_buffered_data (lsquic_stream.c) removes the stream from the write queue, so on_write never runs.
  • A deferral without the lsquic patch was tried: set a flag and lsquic_stream_wantwrite(stream, 1) in on_reset, then stop and close from on_write. It does not work: with upstream order, drop_buffered_data runs after on_reset and clears the want-write. The test times out and the request aborts only on idle timeout.
  • With the patch, the server sends RESET_STREAM(0x10c) and STOP_SENDING(0x10c) for stream 0. The node:quic client sees stream reset with code 268 and closes the stream.
  • The how == 0 branch (peer RESET_STREAM) closes with a plain lsquic_stream_close, which FINs the response half when the headers were sent. That is the existing behaviour and is not changed here.
  • Bun.serve(http3): reset the stream when lsquic refuses the response headers or a body write #42895 handles a different part of the same issue: a write after STOP_SENDING gets -1 from lsquic. That does not help a response that is already buffered in full and only waits for the stream to drain.
  • Self-reviewed: 2 concerns raised. One addressed (the deferral above was tried and ruled out, and the comment in quic.c now says why). One deferred (the how == 0 FIN-in-place-of-reset shape, noted above).

[policy-decision:dep] gate passed · iteration 1 · 4 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/bun/http/serve-http3.test.ts"
ninja: Entering directory `/workspace/bun/build/debug'
[1/100] gen ErrorCode+*.h
[2/15] cc obj/packages/bun-usockets/src/quic.c.o
[3/15] cxx obj/unified/UnifiedSource-src_jsc_modules-0.cpp.o
[4/15] cxx obj/src/jsc/bindings/webcore/SerializedScriptValue.cpp.o
[5/15] cxx obj/unified/UnifiedSource-src_jsc_bindings-8.cpp.o
[6/15] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[7/15] cxx obj/unified/UnifiedSource-src_jsc_bindings-9.cpp.o
[8/15] cxx obj/unified/UnifiedSource-src_jsc_bindings-19.cpp.o
[9/15] gen cpp.rs (cppbind)
[10/15] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 242 extern-C blocks audited
[10/15] cargo bun_runtime → libbun_runtime.a
FAILED: rust-target/x86_64-unknown-linux-gnu/debug/libbun_runtime.a /workspace/bun/build/debug/rust-target/x86_64-unknown-linux-gnu/debug/libbun_runtime.a 
/workspace/bun/build/release/bun /workspace/bun/scripts/build/stream.ts rust --console --cwd=/workspace/bun --env=CARGO_TERM_COLO
... (truncated)

release without fix: 1 skipped
bun test v1.4.3-canary.1 (571eafcce)

test/js/bun/http/serve-http3.test.ts:
(node:129690) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun --trace-warnings ...` to show where the warning was created)
(pass) Bun.serve HTTP/3 > basic GET [147.42ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [149.27ms]
(pass) Bun.serve HTTP/3 > 204 with no body [135.75ms]
(pass) Bun.serve HTTP/3 > query string is preserved [149.19ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [144.14ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [133.90ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [138.34ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [147.23ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [1043.20ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without Content-Length [143.78ms]
(pass) Bun.serve HTTP/3 > unknown route returns 404 [147.40ms]
(pass) Bun.serve HTTP/3 > routes: handler with :params [142.92ms]
(pass) Bun.serve HTTP/3
... (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/bun/http/serve-http3.test.ts"
bun test v1.4.3 (09bb54630)

test/js/bun/http/serve-http3.test.ts:
(node:139202) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun-debug --trace-warnings ...` to show where the warning was created)
(pass) Bun.serve HTTP/3 > basic GET [1961.37ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1767.96ms]
(pass) Bun.serve HTTP/3 > 204 with no body [2111.56ms]
(pass) Bun.serve HTTP/3 > query string is preserved [1896.34ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [1554.96ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [2206.19ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [1918.51ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [2113.81ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2289.83ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without Co
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1174ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/80] gen ErrorCode+*.h
[2/15] cc obj/packages/bun-usockets/src/quic.c.o
[3/15] cxx obj/src/jsc/bindings/webcore/SerializedScriptValue.cpp.o
[4/15] cxx obj/unified/UnifiedSource-src_jsc_modules-0.cpp.o
[5/15] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[6/15] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[7/15] cxx obj/unified/UnifiedSource-src_jsc_bindings-2.cpp.o
[8/15] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 242 extern-C blocks audited
[9/15] gen cpp.rs (cppbind)
[9/15] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_base64 v0.0.0 (/w
... (truncated)
diff hotspot
packages/bun-usockets/src/quic.c                   | 23 +++++++---
 .../lsquic/stop-sending-on-reset-after-reset.patch | 34 +++++++++++++++
 scripts/build/deps/lsquic.ts                       |  6 +++
 test/js/bun/http/serve-http3.test.ts               | 51 ++++++++++++++++++++++
 4 files changed, 109 insertions(+), 5 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                                    reads  edits  tests
packages/bun-usockets/src/quic.c                            2      4     14
patches/lsquic/stop-sending-on-reset-after-reset.patch      0      0     13
scripts/build/deps/lsquic.ts                                1      2     13
test/js/bun/http/serve-http3.test.ts                        2      2     13

…G with its upload still open

On STOP_SENDING, lsquic queues RESET_STREAM for the response and drops the stream from its write queue. The server got on_reset(how=1) and ignored it, so a response that waited for the stream to drain was never told, and the request stayed in pendingRequests until the upload ended or the connection closed.

On the server, on_reset(how=1) now stops the client's upload with the code the client sent and closes the stream, so on_stream_close aborts the request (RFC 9114 section 4.1.1). The client keeps the old behaviour: its read half stays open for a response that arrives together with STOP_SENDING.

lsquic called on_reset(how=1) before it queued RESET_STREAM and before it cleared the stream's stale sending flags. A close from inside the callback sent a FIN in place of the reset, and the STOP_SENDING it queued was cleared. A new patch moves the callback after both.
@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 048949db-dc82-4971-8fe4-99aeb6132d98

📥 Commits

Reviewing files that changed from the base of the PR and between c6b7fcb and 571eafc.

📒 Files selected for processing (4)
  • packages/bun-usockets/src/quic.c
  • patches/lsquic/stop-sending-on-reset-after-reset.patch
  • scripts/build/deps/lsquic.ts
  • test/js/bun/http/serve-http3.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The change updates HTTP/3 reset handling for both reset directions, reorders lsquic reset callbacks, wires the dependency patch, and adds coverage for client cancellation while a request upload remains open.

Changes

HTTP/3 reset lifecycle

Layer / File(s) Summary
QUIC reset direction handling
packages/bun-usockets/src/quic.c
us_quic_on_reset handles STOP_SENDING separately for clients and servers. RESET_STREAM continues to close the stream.
lsquic reset ordering and HTTP/3 validation
patches/lsquic/stop-sending-on-reset-after-reset.patch, scripts/build/deps/lsquic.ts, test/js/bun/http/serve-http3.test.ts
lsquic invokes on_reset after queuing RESET_STREAM and clearing sending state. The test verifies request abortion, response reset, upload stoppage, stream closure with error code 268, and removal from pendingRequests.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 571ea

No actionable merge-blocking risk is identified for the HTTP/3 reset lifecycle change.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address issue #42889. packages/bun-usockets/src/quic.c handles server-side STOP_SENDING by sending STOP_SENDING with the peer error code and closing the stream. The lsquic patch queu…
Out of Scope Changes check ✅ Passed The changed files support issue #42889. The QUIC callback change and lsquic patch implement the reset and close behavior. The build script enables the required lsquic patch. The HTTP/3 test verifies t…
Title check ✅ Passed The title clearly describes the main change: aborting an HTTP/3 request when the client sends STOP_SENDING while the upload remains open.
Description check ✅ Passed The description explains the problem, implementation, expected behavior, affected components, and verification results. It does not use the template headings exactly, but it provides the required info…

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


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

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:08 AM PT - Sep 16th, 2026

❌ @robobun, your commit e8c101a has 2 failures in Build #116470 (All Failures):

  • test/js/bun/http/serve-pending-promise-abort-leak.test.ts - code 1 on 🐧 13 x64-asan
  • 📦 Binary size — 1 over 0.50 MB
  • targetthis build canary: main #116425
    sizeΔ
    bun-darwin-aarch6460.10 MB60.48 MB-388.2 KB
    bun-darwin-x6466.00 MB66.19 MB-192.1 KB
    bun-linux-aarch6476.62 MB76.37 MB+256.1 KB
    bun-linux-x6476.54 MB76.84 MB-299.9 KB
    bun-linux-aarch64-musl69.76 MB69.32 MB+448.0 KB
    bun-linux-x64-musl70.63 MB70.81 MB-192.0 KB
    bun-linux-aarch64-android83.53 MB83.15 MB+384.4 KB
    bun-linux-x64-android85.98 MB86.20 MB-223.9 KB
    bun-freebsd-x6488.09 MB88.28 MB-192.0 KB
    ❌ bun-freebsd-aarch6491.45 MB89.67 MB+1.78 MB
    bun-windows-x6483.18 MB83.49 MB-314.5 KB
    bun-windows-aarch6472.41 MB72.59 MB-184.0 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 42901

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

bun-42901 --bun

@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.

I reviewed this PR and didn't find any bugs. Because it reorders an lsquic-internal callback via a new vendor patch and changes server-side QUIC stream teardown shared with the fetch() client and node:quic, a human look at the protocol semantics would still be worthwhile.

What was reviewed:

  • us_quic_on_reset in quic.c: the client path (is_client) still returns early on how == 1, so fetch() keeps its read-half-open behavior; the server path sends STOP_SENDING with the peer's code and closes so on_stream_close fires.
  • The lsquic patch: moving on_reset(how=1) after RESET_STREAM is queued and sending flags are cleared; the other in-tree on_reset consumer (node:quic shim) only records the error code, so the reorder does not change it.
  • Checked whether a redundant STOP_SENDING on an already-finished/reset request half or a STOP_SENDING arriving in the same engine pass as HEADERS could misbehave — lsquic drops the redundant frame and the close path still runs on_stream_close.
  • The new test awaits the abort event and polls pendingRequests with a bounded sleep loop rather than a fixed delay; maxIdleTimeout: 10 is client-side only.
Extended reasoning...

Overview

The PR touches four files: packages/bun-usockets/src/quic.c (rewrites us_quic_on_reset so a server context responds to a lone STOP_SENDING by sending STOP_SENDING for the client's upload and closing the stream), a new vendor patch patches/lsquic/stop-sending-on-reset-after-reset.patch (moves the on_reset(how=1) callback in lsquic_stream_stop_sending_in after RESET_STREAM is queued and the sending flags are cleared), registration of that patch in scripts/build/deps/lsquic.ts with an explanatory comment, and one new test in test/js/bun/http/serve-http3.test.ts. The behavior change is scoped to the server how == 1 branch; the how == 0 branch and the client branch are preserved as before.

Security risks

No auth, crypto, or credential handling is involved. The main risk class is protocol state handling on peer-controlled input: a peer sending STOP_SENDING can now cause the server to issue STOP_SENDING + RESET_STREAM and close the stream, which is the RFC 9114 §4.1.1 prescribed outcome and reduces (not increases) resource retention — previously the request stayed pending until the upload ended or the connection idled out. The error code is echoed from lsquic_stream_get_error_code, which is a uint64 passed straight to lsquic; no parsing of untrusted data is added.

Level of scrutiny

Medium-high. The lsquic patch reorders an upstream callback relative to internal flag manipulation and maybe_finish_stream/maybe_schedule_call_on_close, which affects every on_reset consumer in the binary (Bun.serve, fetch(), node:quic). I could not build or run the test here because vendor/ is not fetched in this checkout, so correctness of the reorder rests on reading the patch and the PR's described trace rather than an executed verification. A human familiar with the lsquic stream state machine should confirm that calling lsquic_stream_close from the relocated callback position interacts correctly with maybe_finish_stream that follows it, and that the "FIN in place of reset" symptom from the how == 0 path (acknowledged in the PR as unchanged) is acceptable.

Other factors

The test is added to the existing serve-http3.test.ts file, uses port: 0, await using for cleanup, awaits the abort event rather than sleeping, and polls pendingRequests in a bounded loop. It transfers >64KB of an 8MB response, which is likely acceptable but may be slow on debug+ASAN. The vendored patch is small and the lsquic.ts comment explains what upstream behavior it corrects, consistent with the repo's vendoring guidance, though it lacks an upstream issue link. The bug hunt exited on dry_streak with no findings, and the four ruled-out candidates all concerned edge cases in the same on_reset path.

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

On the open question about the relocated callback: the close from the new position does the following, checked with BUN_DEBUG_lsquic=1 on the new test. stream_shutdown_write sees SMQF_SEND_RST already set and does not queue a FIN. stream_shutdown_read queues STOP_SENDING and sets STREAM_U_READ_DONE. maybe_schedule_call_on_close then schedules on_close, which runs in the next service pass. The maybe_finish_stream that follows is a no-op at that point: the RESET_STREAM is not acked yet, and the stream is freed later once it is. The wire shows RESET_STREAM(0x10c) and STOP_SENDING(0x10c) for the stream, and the client sees stream reset with code 268.

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

CI state for a reviewer: every test lane that exercises this change is green on both runs (builds 116448 and 116470). The red items are not from this diff:

  • binary-size: bun-freebsd-aarch64 reports +1.78 MB against canary main #116425. Build 116430 on an unrelated branch reports the identical 91.45 MB vs 89.67 MB, so the baseline is the outlier.
  • test/js/bun/http/serve-pending-promise-abort-leak.test.ts on debian x64-asan is an HTTP/1 GC test that also fails on main. It is reported separately.

The rest are flakes that passed on retry.

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.

Bun.serve(http3): STOP_SENDING does not abort the request while the request side is open

1 participant