Skip to content

test(serve-http3): attach the fetch handlers before the wait for STOPPED - #44146

Open
robobun wants to merge 2 commits into
mainfrom
robobun/3807096a/serve-http3-stop-test-unhandled-rejection
Open

robobun wants to merge 2 commits into
mainfrom
robobun/3807096a/serve-http3-stop-test-unhandled-rejection

Conversation

@robobun

@robobun robobun commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • server.stop(true) %s inside an H3 handler sends CONNECTION_CLOSE in test/js/bun/http/serve-http3.test.ts can fail on the error that it expects. The runner prints TypeError: HTTP3StreamReset fetching "https://127.0.0.1:<port>/stop" and then (fail).
  • The test attaches handlers to the fetch promise only after it awaits the STOPPED line of the server (serve-http3.test.ts:1019-1029). The server writes STOPPED and sends CONNECTION_CLOSE in the same turn. When the rejection arrives first, the promise has no handler.

Fix

  • Attach .then(r => r.text()).catch(e => e.code) when the fetch is created. Nothing else changes.
  • Correct because the handlers exist before any event can settle the promise.
  • Verified: interleaved runs of the old and the new test on a loaded Linux x64 host. Release build: 9 of 100 executions hit the unhandled rejection before, 0 of 100 after. Debug build: 1 of 30 before, 0 of 30 after.
  • Not fixed: the 5 s timeouts of this file under load. Both tests hit them at the same rate (see Notes).

Background

  • The fetch client runs on the HTTP thread. The test reads the stderr pipe on the JS thread. The rejection and the STOPPED line arrive by independent paths.
  • bun test fails the running test when a rejected promise has no handler after the microtask queue drains. A handler attached in a later task is too late.
  • No other test in the file has this shape: a promise that is expected to reject and gets its handlers after another await.
Notes

The 5 s timeouts. This PR does not change them. Under the debug build they hit 5 of 30 executions of the old test and 5 of 30 of the new test, in the same minutes. In the instrumented runs the time went to the first request on a new connection, before the changed lines run. #41564 and #41497 cover the HTTP3HandshakeFailed failure under load.

Mechanism probe. A copy of the test whose server holds the STOPPED line back (setTimeout(() => console.error("STOPPED"), 300) after server.stop(true)):

shape result
old (handlers after the wait) fails 2 of 2 with the TypeError: HTTP3StreamReset output above
new (handlers at creation) passes 2 of 2

Natural order, no delay. A copy of the test with a timestamp per phase shows the order in each unhandled rejection failure. The (fail) line comes first and the STOPPED line arrives after it (for example fail at 186 ms, STOPPED seen at 276 ms).

Interleaved runs. The file from main against the file from this branch, filter -t 'server.stop\(true\) (synchronously|after an await) inside an H3 handler', 2 executions per run. Host: Linux x64, 16 cores, load average between 300 and 900 during the runs.

build test pass unhandled rejection 5 s timeout
release 1.4.3-canary.1+367d939d9, 50 runs old 90 9 1
release 1.4.3-canary.1+367d939d9, 50 runs new 100 0 0
debug (ASAN) of this branch, 15 runs old 24 1 5
debug (ASAN) of this branch, 15 runs new 25 0 5

Whole file. One run under the debug build on the same loaded host: 60 pass, 13 fail. The two changed cases pass. All 13 failures are 5 s timeouts in the first describe block. The rejections that arrive after each timeout carry HTTP3HandshakeFailed. I did not run the file on a quiet host.

Other promises in the file that are awaited late.

  • client RST mid-/big does not break the listener: .catch is attached in the same turn.
  • server.stop() inside an H3 handler drains the in-flight request and graceful stop: in-flight H3 requests complete after server.stop(): they expect fulfilment, so a rejection is a real failure.
  • req.signal aborts on client RST: the promise rejects only after the test calls ac.abort(), and the handler is attached in that turn.

The chain. .catch stays after .then, as before, so it also handles a rejection of r.text(). The promise that the test holds cannot reject.

The runner check, with the release build. A handler attached in the same turn or after await null passes. A handler attached after setImmediate fails the test with the rejection.

CI. The file is listed in test/flaky-tests.txt. CI retries a file that fails and annotates a pass on the retry as flaky.


[auto-merge] gate passed · iteration 1 · 1 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/bun/http/serve-http3.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "test/js/bun/http/serve-http3.test.ts"
bun test v1.4.3 (367d939d9)

test/js/bun/http/serve-http3.test.ts:
(node:42662) 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 [2590.88ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [2884.11ms]
(pass) Bun.serve HTTP/3 > 204 with no body [3653.35ms]
(pass) Bun.serve HTTP/3 > query string is preserved [2973.65ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [2840.40ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [3300.55ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [3990.98ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [2560.29ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [4394.92ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without Content-Length [1977.56ms]
(pass) Bun.serve HTTP/3 > unknown route returns 404 [3323.88ms]
(pass) Bun.serve HTTP/3 > routes: handler with :params [2233.98ms]
(pass) Bun.serve HTTP/3 > routes: per-method handler [2178.50ms]
(pass) Bun.serve HTTP/3 > routes: method-specific '/*' falls through to fetch() on other methods [2677.23ms]
(pass) Bun.serve HTTP/3 > ReadableStream response body [2433.40ms]
(pass) Bun.serve HTTP/3 > Bun.file response body [2291.13ms]
(pass) Bun.serve HTTP/3 > validation: http3 without tls throws [569.52ms]
(pass) Bun.serve HTTP/3 > connection-specific response headers are dropped on fetch, static and file routes [2562.74ms]
(pass) Bun.serve HTTP/3 > static route (Response value) is mirrored onto H3 [2679.87ms]
(pass) Bun.serve HTTP/3 > file route (Bun.file value) streams over H3 [2249.80ms]
(pass) Bun.serve HTTP/3 > validation
... (truncated)
Exit: 0
diff hotspot
test/js/bun/http/serve-http3.test.ts | 11 +++++++----
 1 file changed, 7 insertions(+), 4 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                  reads  edits  tests
test/js/bun/http/serve-http3.test.ts      3      1     14

The server subprocess writes STOPPED to stderr and sends CONNECTION_CLOSE
in the same turn. The two reach the test on different threads. When the
rejection of the fetch arrives first, the promise has no handler yet and
the runner fails the test with the HTTP3StreamReset error that the test
expects.

Attach the handlers when the fetch is created. The wait for STOPPED and
the assertion do not change.
@robobun

robobun commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green. Each CI build is red only because of one test in a file that this PR does not change.

How I reproduced the failure:

  1. A copy of the test whose server holds the STOPPED line back for 300 ms after server.stop(true). The old test fails 2 of 2 with TypeError: HTTP3StreamReset fetching "https://127.0.0.1:<port>/stop". The new test passes 2 of 2.
  2. No delay, on a loaded Linux x64 host, release build. The old test hit the unhandled rejection in 9 of 100 executions. The new test hit it in 0 of 100.

CI ran two times on the same diff. In each build 180 of 181 jobs passed, and no lane reports test/js/bun/http/serve-http3.test.ts as failed or flaky.

Build Red test Lane
121288 test/js/bun/spawn/spawn.test.ts debian 13 x64-asan
121293 test/js/bun/dns/resolve-dns.test.ts darwin aarch64

The lane that is red in one build is green in the other, so every lane passed with this diff at least one time. I do not plan another CI run.

The 5 s timeouts of this file under load are a separate problem. This PR does not change them.

@coderabbitai

coderabbitai Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

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

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: f8482043-8658-46ab-91ed-99e118733764

📥 Commits

Reviewing files that changed from the base of the PR and between a4f1429 and d1528b0.

📒 Files selected for processing (1)
  • test/js/bun/http/serve-http3.test.ts

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/3 server stop test now records the /stop request outcome before waiting for the STOPPED log. It then checks that the request ends with HTTP3StreamReset.

Changes

HTTP/3 stop test

Layer / File(s) Summary
Capture stop request outcome
test/js/bun/http/serve-http3.test.ts
The test attaches fulfillment and rejection handlers to the /stop request before waiting for STOPPED. It then asserts that the request ends with HTTP3StreamReset.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 716ba

The change addresses the reported unhandled rejection in the HTTP/3 stop test without changing production behavior. The separate load-related timeouts are unchanged; no merge-blocking risk is identified.

🚥 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 and concisely describes the main test change: attaching fetch handlers before waiting for the STOPPED log.
Description check ✅ Passed The description explains the problem, fix, verification results, and known limitations. It does not use the template headings exactly, but it provides the required information in equivalent sections.

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

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

@robobun

robobun commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:42 PM PT - Sep 27th, 2026

❌ @robobun, your commit 716ba24 has 1 failures in Build #121293 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 44146

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

bun-44146 --bun

miyaji255 pushed a commit to miyaji255/bun that referenced this pull request Sep 28, 2026
…ear and cannot corrupt them (oven-sh#41973)

### Problem
- `S.A + "k" + S.A + "k" + ...` over an inlined string enum member folds
in quadratic memory: 8 192 terms (49 KB) take 1.4 GB in `bun run`. Every
`A.B` reference shares the member's rope, so `join_strings`
(`src/ast/fold_string_addition.rs`) deep-cloned both ropes when either
operand was an `E::InlinedEnum`: each enum term re-cloned the
accumulator.
- `Template::fold` (`src/ast/e.rs`) and members named with `*/` (left
unwrapped by `wrap_inlined_enum`) had no guard and appended onto the
shared rope. ``enum A { B = "1" + "2", T = `t${B}t` }`` makes `A.B`
print `"12t"`. A second use panics on 1.4.3: `called Option::unwrap() on
a None value`, `Crashed while visiting`.

### Fix
- `s_enum` flattens the member's string (`resolve_rope_if_needed`)
before it stores the `EnumString`, the only place one is built. A shared
string is then never a rope.
- `join_strings` loses `has_inlined_enum_poison` and `clone_rope_nodes`
and links in O(1). 4 096 pairs: 1 545 MB → 9 MB.
- The src commits are oven-sh#38998 unchanged. This supersedes it, adds the
memory test, and bumps the transpiler cache version since affected
output changes.
- Verified:
`test/js/bun/transpiler/transpiler-enum-concat-chain-oom.test.ts` (new,
fails on 1.4.3), the oven-sh#38998 tests, the enum suites. Self-reviewed: 2
concerns raised, 2 addressed.

### Background
- String folding copies no bytes. `"a" + "b"` links the right `EString`
node onto the left through `next`/`end` (a rope). `EString::push` writes
to the rope's last node, so a rope needs one owner.
- The visit turns `A.B` into an `E::InlinedEnum` around a copy of the
member's root node. Later folds see a string literal.

<details><summary>Notes</summary>

- Fuzz-ledger finding oven-sh#44146. Curve before: 1 024 pairs 62 MB, 4 096 700
MB, 8 192 2.76 GB, 16 384 OOM-killed at 3 GB (x2 terms, x4 memory). `bun
build --minify-syntax` behaves the same. Not a regression: 1.3.14
behaves the same, and the Zig code this was ported from had the same
shape.
- The `*/` case on 1.4.3: `enum A { "*/" = "s" + "t" };
console.log(A["*/"] + "u", A["*/"] + "v")` panics the same way. With
members stored flat the unwrapped value is a single node, so it is safe
too.
- After the fix, debug+ASAN build, both chains of the test (top level
and inside an enum body): 1 024 pairs 7 MB, 4 096 9 MB, 16 384 22 MB, 65
536 70 MB. `bun run` of a 16 384-pair file peaks 8 MB over an empty
script.
- I first narrowed the clone to the side that is the enum member (also
linear). Storing members flat is simpler: it removes the clone, covers
`Template::fold`, the `*/` names and the bundler's cross-module
substitution at the one place the sharing starts, and costs one O(len)
copy per rope-valued member. esbuild stores enum string values flat too.
- `resolve_rope_if_needed` leaves `rope_len` (still the length) and a
stale `end` behind. `end` is only read from a node whose `next` is set,
and a push onto a copy of a flat node assigns both.
- The runtime transpiler cache is keyed on the source and the features
hash, not on bun's version. A file that hit the template corruption
without the panic was cached with the wrong output, so
`EXPECTED_VERSION` goes to 30.
- Out of scope, tracked separately: a `+` chain of template literals
that each have a substitution (`` `a${x}b` + `a${x}b` + ... ``) is also
quadratic under syntax minification (4 096 terms: 526 MB). That is
`concat_parts` re-copying the accumulated `parts` array at each step, a
different path that this change does not touch.
- Suites run on the debug build: `bundler_edgecase.test.ts`,
`bundler_minify.test.ts`, `bundler_string.test.ts`,
`esbuild/ts.test.ts`, `esbuild/default.test.ts` (enum/template/string
filter), `transpiler/transpiler.test.js`,
`transpiler-comma-chain-oom.test.ts`,
`cli/run/transpiler-cache.test.ts`. `cargo clippy` on `bun_ast` and
`bun_js_parser` is clean.

</details>

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

---

**no test proof** · iteration 1 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/bun/transpiler/transpiler-enum-concat-chain-oom.test.ts

<!-- robobun:evidence:end -->
@robobun

robobun commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator Author

@robobun wake up!!

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun wake up!!

@robobun

robobun commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator Author

I am here. This PR is ready, and it waits for a maintainer.

  • The change is test-only: test/js/bun/http/serve-http3.test.ts, 7 lines added and 4 removed.
  • CI ran two times on the same diff. In each build 180 of 181 jobs passed, and serve-http3.test.ts passed on every lane.
  • Each build has one red test in a file that this PR does not change. The lane that is red in one build is green in the other.
Build Red test Lane
121288 test/js/bun/spawn/spawn.test.ts debian 13 x64-asan
121293 test/js/bun/dns/resolve-dns.test.ts darwin aarch64

I did not start CI again. I already pushed one empty commit to re-run it, and I do not push a second one.

What the PR needs from you:

  1. An approving review.
  2. If you want a green build before the merge, retry the failed darwin aarch64 - test-bun job of build 121293.

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