Skip to content

Bun.serve(http3): send CONNECTION_CLOSE on abrupt stop of an idle connection - #34038

Merged
Jarred-Sumner merged 3 commits into
mainfrom
farm/10546329/h3-stop-connection-close
Jul 12, 2026
Merged

Jarred-Sumner merged 3 commits into
mainfrom
farm/10546329/h3-stop-connection-close

Conversation

@robobun

@robobun robobun commented Jul 12, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes test/js/bun/http/serve-http3.test.ts going intermittently red, most often as POST body without Content-Length still reaches the handler failing with HTTP3StreamReset after ~8-10s.

Reproduction

// 1. H3 server A at port P, one fetch to warm the client session
// 2. server.stop(true)               <- conn is idle here
// 3. H3 server B at the SAME port P
// 4. fetch with a ReadableStream body
//    -> HTTP3StreamReset after ~9s

On a release build step 2 usually runs while the server still has ACKs scheduled, so the bug is masked; under debug/ASAN or a loaded CI runner the connection is idle and the POST stalls every time.

Root cause

server.stop(true) reaches us_quic_listen_socket_close(), which called lsquic_conn_close() on each live conn and then closed the UDP fd. For a server connection, ietf_full_conn_ci_close sets IFC_CLOSING and only schedules SF_SEND_CONN_CLOSE when conn_ok_to_close(); the tick that actually packs the frame (the end_write block in lsquic_full_conn_ietf.c) additionally requires one of:

  • IFC_GOAWAY_CLOSE, or
  • a received CONNECTION_CLOSE, or
  • lsquic_send_ctl_n_scheduled() > 0

An idle server conn satisfies none of those, so the UDP fd was closed with nothing on the wire. The client's pooled session stays in ClientContext.sessions until the negotiated idle timeout. If a later listener binds the same ephemeral port before that fires, fetch({protocol:'http3'}) matches the dead session and enqueues on it. Non-stream bodies recover via retry_or_fail; a ReadableStream body has already been drained into lsquic's send buffer and retry_or_fail refuses on is_streaming_body, so it surfaces HTTP3StreamReset once the idle alarm fires.

The pattern has been present since HTTP/3 support landed (#29768 for the server side, #29795 for the pooled client); it turns into a test failure whenever two ephemeral ports collide inside one idle-timeout window.

Fix

us_quic_listen_socket_close() now calls lsquic_conn_abort() instead of lsquic_conn_close(). IFC_ABORTED is in IFC_IMMEDIATE_CLOSE_FLAGS, which takes the immediate_close path and unconditionally packs a CONNECTION_CLOSE (transport error NO_ERROR, "user aborted connection").

The test file's withCustomServer fixtures now server.stop(true) on stdin close and withCustomServer gives them a moment to do so before kill(), so every server process in the file exits through this path.

Verification

New lifecycle test lets the server conn go idle, stops abruptly, rebinds the same port in-process, then POSTs a ReadableStream body:

# without packages/ change
(fail) server.stop(true) sends CONNECTION_CLOSE on an idle H3 connection [4766ms]
# with packages/ change
(pass) server.stop(true) sends CONNECTION_CLOSE on an idle H3 connection [1713ms]

Across the full file with BUN_DEBUG_h3_client=1 on a debug build, client-session close breakdown:

connects idle-timeout (status=4) CONNECTION_CLOSE (status=8)
before 43 29 10
after 46 2 42

The two remaining timeout closes are the intentional AbortSignal.timeout probes against a stopped port; those sessions are still handshaking and recover on retransmission.


[review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/bun/http/serve-http3.test.ts"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (304079670)

test/js/bun/http/serve-http3.test.ts:
(pass) Bun.serve HTTP/3 > basic GET [1669.34ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1769.09ms]
(pass) Bun.serve HTTP/3 > 204 with no body [1648.64ms]
(pass) Bun.serve HTTP/3 > query string is preserved [1627.15ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [1726.89ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [1872.41ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [1631.50ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [1631.31ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2540.7
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (887d0bef6)

test/js/bun/http/serve-http3.test.ts:
(pass) Bun.serve HTTP/3 > basic GET [150.04ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [135.34ms]
(pass) Bun.serve HTTP/3 > 204 with no body [135.24ms]
(pass) Bun.serve HTTP/3 > query string is preserved [135.18ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [136.75ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [134.72ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [134.80ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [137.19ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [1035.49ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without Content-Length [132.29ms]
(pass) Bun.serve HTTP/3 > unknown route returns 404 [135.57ms]
(pass) Bun.serve HTTP/3 > routes: handler with :params [134.53ms]
(pass) Bun.serve HTTP/3 > routes: per-method handler [135.74ms]
(pass) Bun.serve HTTP/3 > routes: method-specific '/*' falls through to fetch() on other methods [133.48ms]
(pass) Bun.serve H
... (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/mechgate.xml" "test/js/bun/http/serve-http3.test.ts"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (304079670)

test/js/bun/http/serve-http3.test.ts:
(pass) Bun.serve HTTP/3 > basic GET [1655.54ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1688.02ms]
(pass) Bun.serve HTTP/3 > 204 with no body [1758.76ms]
(pass) Bun.serve HTTP/3 > query string is preserved [1610.79ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [1732.70ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [1708.12ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [1686.35ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [1675.08ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2582.9
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 688ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (6473ms)
Bundle modules (27ms)
Postprocesss modules (21ms)
Bundle Functions (706ms)
Generate Code (75ms)

[7.32s] Bundled "src/js" for production
  1912 kb
  162 internal modules
  12 native modules
  90 internal functions across 19 files
[1/7] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version:
... (truncated)
diff hotspot
packages/bun-usockets/src/quic.c     | 14 +++++-
 test/js/bun/http/serve-http3.test.ts | 96 ++++++++++++++++++++++++++++++++++--
 2 files changed, 103 insertions(+), 7 deletions(-)

gate history · 3 passed · 0 rejected · iteration 1

evidence per changed file
file                                  reads  edits  tests
packages/bun-usockets/src/quic.c          6      7      0
test/js/bun/http/serve-http3.test.ts      3     15      0

…nection

server.stop(true) routes to us_quic_listen_socket_close(), which called
lsquic_conn_close() on every live connection before closing the UDP fd.
For a server connection, ietf_full_conn_ci_close only schedules
SF_SEND_CONN_CLOSE when conn_ok_to_close() holds, and the tick that
packs the frame (end_write in lsquic_full_conn_ietf.c) additionally
requires IFC_GOAWAY_CLOSE, a received CONNECTION_CLOSE, or scheduled
packets. An idle server conn has none of those, so abrupt stop closed
the fd without ever telling the peer.

The client's pooled session then lingers until the negotiated idle
timeout. If another listener binds the same ephemeral port before that
fires, fetch({protocol:'http3'}) matches and enqueues on the dead
session. Non-streaming bodies recover via retry_or_fail; a
ReadableStream body has already been drained into lsquic's send buffer
and cannot, so it surfaces HTTP3StreamReset once the idle alarm fires.

This is the mechanism behind serve-http3.test.ts intermittently failing
'POST body without Content-Length still reaches the handler' (and
occasionally other tests) since the native H3 client landed.

Use lsquic_conn_abort() instead, which sets IFC_ABORTED and takes the
IFC_IMMEDIATE_CLOSE_FLAGS path that unconditionally packs
CONNECTION_CLOSE. The test file's withCustomServer fixtures are also
given stdin-end handlers so every server process goes through
stop(true) on teardown.

A lifecycle test pins the behaviour: let the server conn go idle,
stop(true), rebind the same port in-process, and POST a
ReadableStream body.
@robobun

robobun commented Jul 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:51 AM PT - Jul 12th, 2026

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


🧪   To try this PR locally:

bunx bun-pr 34038

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

bun-34038 --bun

@coderabbitai

coderabbitai Bot commented Jul 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The QUIC listener now aborts live connections to ensure CONNECTION_CLOSE is transmitted before UDP closure. HTTP/3 test servers wait for stdin-driven shutdown, and a regression test verifies pooled idle-session reuse after stop/restart.

HTTP/3 shutdown lifecycle

Layer / File(s) Summary
Abort live QUIC connections
packages/bun-usockets/src/quic.c
us_quic_listen_socket_close() calls lsquic_conn_abort() for each live connection and documents the updated close behavior.
Coordinate custom-server teardown
test/js/bun/http/serve-http3.test.ts
Embedded servers stop on stdin end, while withCustomServer waits before terminating the process.
Cover idle connection shutdown
test/js/bun/http/serve-http3.test.ts
A new test stops and restarts an H3 server, then verifies a streamed POST over a reused session succeeds.

Possibly related PRs

  • oven-sh/bun#33622: Updates HTTP/3 shutdown timing and lifecycle assertions in the same test suite.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly states the HTTP/3 shutdown fix and matches the main code/test changes.
Description check ✅ Passed The description includes the problem, reproduction, root cause, fix, and verification, covering the template's required content.

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

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/http/serve-http3.test.ts`:
- Line 340: Replace the duplicated stdin end handlers in the six fixture scripts
with a shared STOP_ON_STDIN_END constant, and make the handler async so it
awaits server.stop(true) before calling process.exit(0). Remove the fixed 50ms
timeout while preserving the existing shutdown behavior across all affected
templates.
- Around line 934-938: Update the restart synchronization around the second
waitForStderr call to use a marker guaranteed to be emitted only by the
restarted start(port) invocation, rather than the stale /PORT=(\d+)/ match
retained in the stderr buffer. Preserve the existing port capture needed for the
initial startup and ensure the subsequent wait proves the restart completed
before continuing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 142a1f1a-c797-411a-a93e-7bcebdc6497e

📥 Commits

Reviewing files that changed from the base of the PR and between 2e2230a and 123559b.

📒 Files selected for processing (2)
  • packages/bun-usockets/src/quic.c
  • test/js/bun/http/serve-http3.test.ts

Comment thread test/js/bun/http/serve-http3.test.ts Outdated
Comment thread test/js/bun/http/serve-http3.test.ts Outdated
Comment thread test/js/bun/http/serve-http3.test.ts Outdated
Comment thread test/js/bun/http/serve-http3.test.ts Outdated
- hoist the stdin-end stop(true) handler into a shared STOP_ON_STDIN_END
  constant and use it from every fixture script (including the original
  withServer fixture) so teardown stays consistent
- wait on a RESTARTED marker rather than a second /PORT=/ match, which
  resolved against the stale startup line in waitForStderr's accumulated
  buffer
- read the restart command via process.stdin.on('data') like the sibling
  scripts; the previous for-await-console reader is a separate stdin
  consumer so the on('end') handler never fired and the fixture was
  SIGKILLed after the 1s grace period

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

LGTM — both of my earlier notes are addressed in 887d0be (the new test now reads commands via process.stdin.on('data') so on('end') fires, and the restart wait uses the unique /RESTARTED/ marker).

Checked that lsquic_conn_abort is only reached on the abrupt-stop path where the UDP fd is closed on the very next line, so the abort-vs-close distinction can't leak into any surviving state. Also confirmed the graceful us_quic_socket_context_shutdown path still uses lsquic_engine_cooldown (GOAWAY) and is untouched.

Extended reasoning...

Overview

Two files: packages/bun-usockets/src/quic.c swaps lsquic_conn_close() → lsquic_conn_abort() in us_quic_listen_socket_close() (the abrupt server.stop(true) path), and test/js/bun/http/serve-http3.test.ts gains a STOP_ON_STDIN_END constant applied to seven fixture scripts, a 1s grace period in withCustomServer teardown, and a new lifecycle test that reproduces the idle-conn / port-reuse race.

Security risks

None. This is server-side shutdown of QUIC connections that are about to have their UDP fd closed regardless. Sending an explicit CONNECTION_CLOSE is strictly more informative to peers than the previous silent fd close; there's no auth, crypto, or input-parsing surface touched.

Level of scrutiny

Medium. The native change is one function call in the abrupt-close path, and the surrounding code already documented the intent ("Send CONNECTION_CLOSE on every live conn before the fd disappears") — this makes the implementation actually match that comment for idle server connections. The immediately-following us_udp_socket_close(ls->udp) means any behavioral difference between close and abort is moot: the fd is gone either way, so abort's "immediate_close → always pack CONNECTION_CLOSE" is the only observable delta. The graceful-drain path (us_quic_socket_context_shutdown) is unchanged and still uses cooldown/GOAWAY.

Other factors

  • I reviewed the previous revision and left two inline notes; both are fixed in 887d0be. The new test now uses process.stdin.on('data') (flowing mode, so the 'end' handler in STOP_ON_STDIN_END fires) and waits on a unique /RESTARTED/ marker emitted after start(port) instead of the vacuous second /PORT=/ match.
  • CodeRabbit's dedup suggestion was taken (STOP_ON_STDIN_END constant, 7 sites); the author explained why setTimeout(100) is kept over await server.stop(true) (flush is synchronous inside us_quic_listen_socket_close; the delay is for loopback delivery, which stop()'s promise doesn't track).
  • The PR includes a regression test verified to fail on ASAN without the quic.c change and pass with it, plus a before/after breakdown of client-session close reasons across the whole file.
  • The bug hunting system found no issues on this revision.
  • No CODEOWNERS entry covers packages/bun-usockets/.

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

No issues found after the fixes in 887d0be — the two test-fixture nits I raised (stdin 'end' handler not firing, vacuous /PORT=/ wait) are both resolved. Deferring to a human for the native change: swapping lsquic_conn_close → lsquic_conn_abort in us_quic_listen_socket_close() is small but changes wire-level QUIC shutdown semantics, and correctness hinges on the lsquic IFC_IMMEDIATE_CLOSE_FLAGS reasoning in the PR description.

What was reviewed:

  • Traced lsquic_conn_abort vs lsquic_conn_close against the end_write / immediate_close conditions described in the comment — the rationale is internally consistent.
  • Checked that the ctx->conns iteration in us_quic_listen_socket_close is safe under lsquic_conn_abort (abort only sets a flag; on_conn_closed unlinks later via us_quic_process, not during the loop).
  • Verified the new test now uses process.stdin.on('data') + shared STOP_ON_STDIN_END and waits on /RESTARTED/, so teardown and restart sync are non-vacuous.
Extended reasoning...

Overview

Two files: packages/bun-usockets/src/quic.c (one call site changed from lsquic_conn_close to lsquic_conn_abort inside us_quic_listen_socket_close, plus a 10-line explanatory comment) and test/js/bun/http/serve-http3.test.ts (new STOP_ON_STDIN_END constant applied at 7 fixture sites, a 1s grace period in withCustomServer teardown, and a new lifecycle test that stops/restarts an H3 server on the same port and POSTs a ReadableStream body). The goal is to fix intermittent HTTP3StreamReset failures caused by the client's pooled H3 session outliving an abruptly-stopped server whose UDP fd closed without a CONNECTION_CLOSE frame on the wire.

Security risks

None identified. The change doesn't touch input parsing, TLS verification, or auth. lsquic_conn_abort sends CONNECTION_CLOSE with transport error NO_ERROR — same wire frame as a graceful close, just guaranteed to be packed via the IFC_IMMEDIATE_CLOSE_FLAGS path rather than gated on send-ctl state.

Level of scrutiny

This is native C in the QUIC transport glue layer, affecting what goes on the wire when server.stop(true) runs with live H3 connections. The diff is one function call, but its correctness rests on a specific reading of lsquic's ietf_full_conn_ci_close / end_write state machine (documented in the new comment and PR description). I checked that the iteration over ctx->conns doesn't UAF — lsquic_conn_abort only sets IFC_ABORTED; the list mutation happens later in on_conn_closed via us_quic_process after the loop completes. The reasoning looks sound, but someone familiar with the lsquic vendor code should confirm the immediate_close path behaves as described for server-side idle conns.

Other factors

All four prior review threads (two from me, two from coderabbit) were addressed in 887d0be and are resolved: the STOP_ON_STDIN_END dedup, the switch from for await console to process.stdin.on('data') so the end handler actually fires, and the /RESTARTED/ marker replacing the vacuous second /PORT=/ wait. The latest commit (3040796) is an empty CI retrigger. The PR includes before/after evidence showing the new test fails on debug/ASAN without the quic.c change and passes with it, plus a session-close breakdown (idle-timeout closes drop from 29→2 across the file). Given the protocol-level nature of the native change, I'm not comfortable approving without a human look at the lsquic semantics.

@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: serve-http3.test.ts (the file this PR fixes) is green on every lane across both build 72221 and the retriggered 72232.

The remaining reds are unrelated to this diff, which only touches us_quic_listen_socket_close() (the H3 server's abrupt-stop path) and the H3 test file:

test lane failure
no-orphans.test.ts macOS 14 x64 --no-orphans (perl): fast-exit intermediate timeout (also seen on main in build 72043)
websocket.test.js macOS 14 x64 instances should be finalized when GC'd 90s timeout
node-http-backpressure-max.test.ts macOS 14 x64 120s timeout
test-fs-promises-file-handle-readFile.js alpine FileHandle GC warning (also on main in builds 72010, 72043)
napi.test.ts Windows x64-baseline napi_wrap > has the right lifetime GC-condition flake
test-repl-close.js Windows x64-baseline EPIPE
bun-run-dir.test.ts alpine aarch64 extra newline in install stderr

Three of the four macOS 14 x64 failures are plain timeouts on a single shard, which points at a slow box rather than a code path this PR reaches. Ready for review/merge.

@Jarred-Sumner
Jarred-Sumner merged commit fa1fe9e into main Jul 12, 2026
76 of 79 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/10546329/h3-stop-connection-close branch July 12, 2026 23:50
Jarred-Sumner pushed a commit that referenced this pull request Sep 14, 2026
…quest handler (#42619)

### Problem
- `server.stop()` or `server.stop(true)` from an HTTP/3 request handler,
or from a microtask the handler resolved (the end of a `using server`
scope counts), runs inside `lsquic_engine_process_conns` and enters the
engine again. lsquic forbids that. A debug build aborts:
`lsquic_engine_send_unsent_packets(lsquic_engine_t *): Assertion
'!((engine)->pub.enp_flags & ENPUB_PROC)' failed.`
- Release, `stop(true)`: `us_quic_listen_socket_close`
(`packages/bun-usockets/src/quic.c:917`) closes the UDP fd before the
running tick packs the CONNECTION_CLOSE. Every peer waits for its idle
timer (10 s to 30 s).
- Release, `stop()` on a connection that already served a request: the
process spins at 100% CPU and the request in the handler never
completes.

### Fix
- One enter/leave pair brackets every engine call that can run callbacks
(`process_conns`, `send_unsent_packets`). While it is held,
`us_quic_socket_context_shutdown` and `us_quic_listen_socket_close` only
record the request. `leave` finishes it: GOAWAY or CONNECTION_CLOSE,
flush, then the fd.
- `us_quic_socket_context_listen` releases a pending fd before it binds
to the same port, so `stop(true)` then `Bun.serve` on that port in one
turn still works. A stop from a timer is unchanged.
- Verified: `test/js/bun/http/serve-http3.test.ts` (9 new tests, release
main fails 6, debug main fails 9).
- Self-reviewed: the review asked for the graceful path in the same PR.
Done. Supersedes #34447.

### Background
- lsquic drives every connection from `lsquic_engine_process_conns`.
Request handlers and the microtasks they resolve run inside it.
- `lsquic_conn_abort` only sets a flag. The connection's tick sees it
after the handler returns and packs the CONNECTION_CLOSE. The engine
sends it before `process_conns` returns.
- `ctx->processing` in `quic.c` is set while the engine is on the stack.

<details><summary>Notes</summary>

**Where this comes from.** No user report and no CI incident. #34038
made an abrupt stop send CONNECTION_CLOSE for an idle connection when
the caller is off the stack. A probe of the same stop with a request in
the handler found the cases above.

**Timings, release 1.4.3-canary.1, client and server in one process,
request in the handler**

| call | from a timer | from the handler, before | from the handler,
after |
| --- | --- | --- | --- |
| `stop(true)`, streamed POST | `HTTP3StreamReset` at once |
`HTTP3StreamReset` @10 s (warm connection) or @30 s (cold) | at once |
| `stop(true)`, GET, old port dead | `HTTP3HandshakeFailed` @10 s | @20
s | @10 s |
| `stop()`, cold connection | response arrives | response arrives | same
|
| `stop()`, warm connection | response arrives | 100% CPU, never
completes (stopped after 60 s) | response arrives |

The stall for `stop(true)` is the smaller of the server `idleTimeout`
(30 s when it is 0) and the client's `max_idle_timeout`, and it hits
every connection of the server. A client that re-sends the request adds
its 10 s handshake timeout against the closed port. That retry is by
design for an idempotent request (`fetch-http3-client.test.ts`, `retries
on a fresh session when a pooled session is stale (port reuse)`). After
the fix the client log shows `conn_close status=8` at once.

**Why the `stop(true)` tests use a `ReadableStream` body.** The fetch
client never re-sends a streamed body, so `HTTP3StreamReset` surfaces as
soon as the client sees the connection close. With any other body the
retry hides the difference behind a 10 s handshake timeout.

**Why not keep the fd open in every case.** `stop(true)` promises that
the port is free when it returns, and `server.stop(true); server =
Bun.serve({ port })` is a common restart pattern. A deferred fd still
holds the port, and these sockets do not set `SO_REUSEPORT`. The first
version of this change failed that rebind inside a handler with `Failed
to listen on UDP port N for HTTP/3`. One test pins it. Only in that case
the stopped server's connections go silent, as they did before. A bind
to another port leaves the pending fd alone (one more test).

**Engine calls outside the pair.** `lsquic_engine_cooldown` (makes no
callbacks), `lsquic_engine_packet_in` and `lsquic_engine_connect` (their
callbacks stay in C), and `lsquic_engine_destroy` in
`us_quic_socket_context_free`.

**Excluded, not new.** `server.stop()` followed by `server.stop(true)`:
the first call takes `h3_listener`, so the second never reaches
`us_quic_listen_socket_close` for HTTP/3.

**Relation to #34447.** It wraps the abrupt path's engine calls in `if
(!processing)` and defers the graceful path with its own latch and hook.
It still closes the fd first on `stop(true)` (its test comment says the
client then hangs), and it describes the graceful case as benign on
release. This PR keeps one latch per call and one hook, and ports its
two graceful tests with a warm-connection variant.

**Relation to #42579.** Its new client test stops a server after one
`setImmediate` hop to stay off this path. After this PR the hop is not
needed.

**Suites run on the debug ASAN build:** `serve-http3` (62 pass),
`fetch-http3-client` (56), `serve-protocols` (20),
`fetch-http3-adversarial` (27), `fetch-http3-cold-post` (2),
`fetch-http3-syscall-fault` (3).

</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 0 · 2 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 11 FAILED
$ 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 (6a92015)

test/js/bun/http/serve-http3.test.ts:
(node:1831379) 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 [1433.51ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1535.24ms]
(pass) Bun.serve HTTP/3 > 204 with no body [1423.30ms]
(pass) Bun.serve HTTP/3 > query string is preserved [1345.95ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [1369.07ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [1374.92ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [1562.40ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [1396.90ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2251.74ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without C
... (truncated)

release without fix: 6 failed, 1 skipped
bun test v1.4.3-canary.1 (b7616e5)

test/js/bun/http/serve-http3.test.ts:
(node:1832208) 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 [133.48ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [134.22ms]
(pass) Bun.serve HTTP/3 > 204 with no body [132.61ms]
(pass) Bun.serve HTTP/3 > query string is preserved [133.55ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [137.20ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [133.74ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [131.62ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [133.16ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [1029.34ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without Content-Length [140.71ms]
(pass) Bun.serve HTTP/3 > unknown route returns 404 [130.97ms]
(pass) Bun.serve HTTP/3 > routes: handler with :params [131.55ms]
(pass) Bun.serve HTTP/
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
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 (6a92015)

test/js/bun/http/serve-http3.test.ts:
(node:1834975) 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 [1367.83ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1318.78ms]
(pass) Bun.serve HTTP/3 > 204 with no body [1425.18ms]
(pass) Bun.serve HTTP/3 > query string is preserved [1385.53ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [1317.12ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [1423.12ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [1372.72ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [1330.87ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2307.59ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without C
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 662ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/124] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[2/124] gen cpp.rs (cppbind)
[2/124] 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 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/workspace/bun/src/
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
packages/bun-usockets/src/quic.c     | 116 +++++++++++++++++---
 test/js/bun/http/serve-http3.test.ts | 199 +++++++++++++++++++++++++++++++++++
 2 files changed, 300 insertions(+), 15 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                  reads  edits  tests
packages/bun-usockets/src/quic.c          7      7     31
test/js/bun/http/serve-http3.test.ts      4      5     31
```

</details>

<!-- robobun:evidence:end -->
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
…quest handler (oven-sh#42619)

### Problem
- `server.stop()` or `server.stop(true)` from an HTTP/3 request handler,
or from a microtask the handler resolved (the end of a `using server`
scope counts), runs inside `lsquic_engine_process_conns` and enters the
engine again. lsquic forbids that. A debug build aborts:
`lsquic_engine_send_unsent_packets(lsquic_engine_t *): Assertion
'!((engine)->pub.enp_flags & ENPUB_PROC)' failed.`
- Release, `stop(true)`: `us_quic_listen_socket_close`
(`packages/bun-usockets/src/quic.c:917`) closes the UDP fd before the
running tick packs the CONNECTION_CLOSE. Every peer waits for its idle
timer (10 s to 30 s).
- Release, `stop()` on a connection that already served a request: the
process spins at 100% CPU and the request in the handler never
completes.

### Fix
- One enter/leave pair brackets every engine call that can run callbacks
(`process_conns`, `send_unsent_packets`). While it is held,
`us_quic_socket_context_shutdown` and `us_quic_listen_socket_close` only
record the request. `leave` finishes it: GOAWAY or CONNECTION_CLOSE,
flush, then the fd.
- `us_quic_socket_context_listen` releases a pending fd before it binds
to the same port, so `stop(true)` then `Bun.serve` on that port in one
turn still works. A stop from a timer is unchanged.
- Verified: `test/js/bun/http/serve-http3.test.ts` (9 new tests, release
main fails 6, debug main fails 9).
- Self-reviewed: the review asked for the graceful path in the same PR.
Done. Supersedes oven-sh#34447.

### Background
- lsquic drives every connection from `lsquic_engine_process_conns`.
Request handlers and the microtasks they resolve run inside it.
- `lsquic_conn_abort` only sets a flag. The connection's tick sees it
after the handler returns and packs the CONNECTION_CLOSE. The engine
sends it before `process_conns` returns.
- `ctx->processing` in `quic.c` is set while the engine is on the stack.

<details><summary>Notes</summary>

**Where this comes from.** No user report and no CI incident. oven-sh#34038
made an abrupt stop send CONNECTION_CLOSE for an idle connection when
the caller is off the stack. A probe of the same stop with a request in
the handler found the cases above.

**Timings, release 1.4.3-canary.1, client and server in one process,
request in the handler**

| call | from a timer | from the handler, before | from the handler,
after |
| --- | --- | --- | --- |
| `stop(true)`, streamed POST | `HTTP3StreamReset` at once |
`HTTP3StreamReset` @10 s (warm connection) or @30 s (cold) | at once |
| `stop(true)`, GET, old port dead | `HTTP3HandshakeFailed` @10 s | @20
s | @10 s |
| `stop()`, cold connection | response arrives | response arrives | same
|
| `stop()`, warm connection | response arrives | 100% CPU, never
completes (stopped after 60 s) | response arrives |

The stall for `stop(true)` is the smaller of the server `idleTimeout`
(30 s when it is 0) and the client's `max_idle_timeout`, and it hits
every connection of the server. A client that re-sends the request adds
its 10 s handshake timeout against the closed port. That retry is by
design for an idempotent request (`fetch-http3-client.test.ts`, `retries
on a fresh session when a pooled session is stale (port reuse)`). After
the fix the client log shows `conn_close status=8` at once.

**Why the `stop(true)` tests use a `ReadableStream` body.** The fetch
client never re-sends a streamed body, so `HTTP3StreamReset` surfaces as
soon as the client sees the connection close. With any other body the
retry hides the difference behind a 10 s handshake timeout.

**Why not keep the fd open in every case.** `stop(true)` promises that
the port is free when it returns, and `server.stop(true); server =
Bun.serve({ port })` is a common restart pattern. A deferred fd still
holds the port, and these sockets do not set `SO_REUSEPORT`. The first
version of this change failed that rebind inside a handler with `Failed
to listen on UDP port N for HTTP/3`. One test pins it. Only in that case
the stopped server's connections go silent, as they did before. A bind
to another port leaves the pending fd alone (one more test).

**Engine calls outside the pair.** `lsquic_engine_cooldown` (makes no
callbacks), `lsquic_engine_packet_in` and `lsquic_engine_connect` (their
callbacks stay in C), and `lsquic_engine_destroy` in
`us_quic_socket_context_free`.

**Excluded, not new.** `server.stop()` followed by `server.stop(true)`:
the first call takes `h3_listener`, so the second never reaches
`us_quic_listen_socket_close` for HTTP/3.

**Relation to oven-sh#34447.** It wraps the abrupt path's engine calls in `if
(!processing)` and defers the graceful path with its own latch and hook.
It still closes the fd first on `stop(true)` (its test comment says the
client then hangs), and it describes the graceful case as benign on
release. This PR keeps one latch per call and one hook, and ports its
two graceful tests with a warm-connection variant.

**Relation to oven-sh#42579.** Its new client test stops a server after one
`setImmediate` hop to stay off this path. After this PR the hop is not
needed.

**Suites run on the debug ASAN build:** `serve-http3` (62 pass),
`fetch-http3-client` (56), `serve-protocols` (20),
`fetch-http3-adversarial` (27), `fetch-http3-cold-post` (2),
`fetch-http3-syscall-fault` (3).

</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 0 · 2 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 11 FAILED
$ 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 (6a92015)

test/js/bun/http/serve-http3.test.ts:
(node:1831379) 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 [1433.51ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1535.24ms]
(pass) Bun.serve HTTP/3 > 204 with no body [1423.30ms]
(pass) Bun.serve HTTP/3 > query string is preserved [1345.95ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [1369.07ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [1374.92ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [1562.40ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [1396.90ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2251.74ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without C
... (truncated)

release without fix: 6 failed, 1 skipped
bun test v1.4.3-canary.1 (b7616e5)

test/js/bun/http/serve-http3.test.ts:
(node:1832208) 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 [133.48ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [134.22ms]
(pass) Bun.serve HTTP/3 > 204 with no body [132.61ms]
(pass) Bun.serve HTTP/3 > query string is preserved [133.55ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [137.20ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [133.74ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [131.62ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [133.16ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [1029.34ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without Content-Length [140.71ms]
(pass) Bun.serve HTTP/3 > unknown route returns 404 [130.97ms]
(pass) Bun.serve HTTP/3 > routes: handler with :params [131.55ms]
(pass) Bun.serve HTTP/
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
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 (6a92015)

test/js/bun/http/serve-http3.test.ts:
(node:1834975) 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 [1367.83ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1318.78ms]
(pass) Bun.serve HTTP/3 > 204 with no body [1425.18ms]
(pass) Bun.serve HTTP/3 > query string is preserved [1385.53ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [1317.12ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [1423.12ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [1372.72ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [1330.87ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2307.59ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without C
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 662ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/124] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[2/124] gen cpp.rs (cppbind)
[2/124] 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 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/workspace/bun/src/
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
packages/bun-usockets/src/quic.c     | 116 +++++++++++++++++---
 test/js/bun/http/serve-http3.test.ts | 199 +++++++++++++++++++++++++++++++++++
 2 files changed, 300 insertions(+), 15 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                  reads  edits  tests
packages/bun-usockets/src/quic.c          7      7     31
test/js/bun/http/serve-http3.test.ts      4      5     31
```

</details>

<!-- robobun:evidence:end -->
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