Skip to content

Bun.serve: write Response.statusText as the reason phrase and drop the HM placeholder - #36003

Open
robobun wants to merge 4 commits into
mainfrom
claude/farm-442a7a0c-serve-status-text
Open

Bun.serve: write Response.statusText as the reason phrase and drop the HM placeholder#36003
robobun wants to merge 4 commits into
mainfrom
claude/farm-442a7a0c-serve-status-text

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Bun.serve wrote the placeholder reason phrase HM for every status code missing from its built-in table, and never serialized the handler's statusText, so applications could not override it.

Repro

import net from "node:net";
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch(req) {
  const c = Number(new URL(req.url).pathname.slice(1));
  return new Response("x", { status: c, statusText: "App Supplied Reason" });
}});
const line = p => new Promise(res => {
  const s = net.connect(server.port, "127.0.0.1", () =>
    s.write(`GET /${p} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`));
  let b = ""; s.on("data", d => (b += d.toString("latin1")));
  s.on("end", () => res(b.split("\r\n")[0]));
});
for (const c of [201, 419, 499, 520, 599]) console.log(c, JSON.stringify(await line(c)));
server.stop(true);

Before:

201 "HTTP/1.1 201 Created"
419 "HTTP/1.1 419 HM"
499 "HTTP/1.1 499 HM"
520 "HTTP/1.1 520 HM"
599 "HTTP/1.1 599 HM"

The statusText never appears; even for 201 the canned Created wins.

Cause

RequestContext::do_write_status (and StaticRoute / server::write_status) only looked up HTTPStatusText::get(code) and fell back to "{code} HM". Response.init.status_text was stored but never read on the server side.

Fix

HTTPStatusText::format(buf, code, status_text) now builds the status-line token: it writes a non-empty status_text that passes the RFC 9112 reason-phrase byte check (so a CR/LF cannot split the response), otherwise falls back to the table, otherwise emits an empty reason phrase ("<code> "). RequestContext::render_metadata reads the Response's status_text and threads it through do_write_status; StaticRoute snapshots it next to status_code so routes: { "/": new Response(...) } honours it too. Range/precondition overrides (206/304/405/412/416) keep passing an empty slice and get the canonical phrase.

After:

201 "HTTP/1.1 201 App Supplied Reason"
419 "HTTP/1.1 419 App Supplied Reason"
499 "HTTP/1.1 499 App Supplied Reason"
520 "HTTP/1.1 520 App Supplied Reason"
599 "HTTP/1.1 599 App Supplied Reason"

and with no statusText:

201 "HTTP/1.1 201 Created"
599 "HTTP/1.1 599 "

NodeHTTPResponse.rs is left alone; the node:http JS layer already defaults an unset statusMessage to STATUS_CODES[code] || "unknown", and #35017 is touching that file.

Verification

bun bd test test/js/bun/http/serve.test.ts -t "status line reason phrase"

Both new tests fail on main (HTTP/1.1 599 HM, statusText === "HM") and pass here. The existing should return <code> <phrase> loop, bun-serve-routes.test.ts, bun-serve-file.test.ts, bun-serve-headers.test.ts and the static-route stress tests continue to pass.


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/http/serve.test.ts

Fixes #13817

…e HM placeholder

Bun.serve's status-line writer only consulted the built-in reason-phrase
table; the statusText a handler sets on its Response never reached the wire,
and any code not in the table (419/420/444/499, Cloudflare 520-530, 599)
was sent as 'HTTP/1.1 NNN HM'.

HTTPStatusText gains a format() helper that emits '<code> <reason>' for an
app-supplied statusText (after the RFC 9112 reason-phrase byte check, so a
CR/LF in statusText cannot split the response), then falls back to the table,
then to an empty reason phrase. RequestContext::render_metadata now reads the
Response's statusText and threads it into do_write_status; StaticRoute
snapshots it alongside status_code so routes: { ... } sees it too. FileRoute
and the range-override paths pass an empty slice and keep the canonical
phrase.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b302bb84-e601-4184-a689-75adf9e73cfa

📥 Commits

Reviewing files that changed from the base of the PR and between 4908072 and b6948d2.

📒 Files selected for processing (3)
  • src/runtime/server/FileRoute.rs
  • test/js/bun/http/serve-stream-body-error.test.ts
  • test/js/bun/http/serve.test.ts

Walkthrough

Changes

The server now formats HTTP status lines from validated custom reason phrases, stores response status text for static routes, propagates it through dynamic and file responses, and tests custom, fallback, and CR/LF-filtered reason phrases.

HTTP status reason phrases

Layer / File(s) Summary
Status-line formatting contract
src/runtime/server/HTTPStatusText.rs, src/runtime/server/mod.rs
Adds reason-phrase validation and fixed-buffer formatting, then routes generic status writes through the formatter.
Static route status-text storage
src/runtime/server/StaticRoute.rs, src/runtime/server/HTMLBundle.rs
Stores response status text on static routes, preserves it across construction and cloning, and passes it during status writing.
Dynamic response propagation and validation
src/runtime/server/RequestContext.rs, src/runtime/server/FileRoute.rs, test/js/bun/http/serve.test.ts
Passes explicit status-text slices through dynamic and file response paths and validates custom, fallback, and CR/LF-filtered status lines.

Suggested reviewers: jarred-sumner

🚥 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 main change: serializing Response.statusText as the reason phrase and removing the HM fallback.
Description check ✅ Passed The description covers what changed and includes verification details, though the exact template headings are not used verbatim.

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

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:32 PM PT - Jul 26th, 2026

@robobun, your commit b6948d2 has 1 failures in Build #82812 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    bun-linux-x6472.48 MB71.95 MB+544.0 KB
    bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    bun-windows-x6480.26 MB79.70 MB+573.5 KB
    bun-windows-aarch6470.86 MB70.34 MB+533.5 KB

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


🧪   To try this PR locally:

bunx bun-pr 36003

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

bun-36003 --bun

Comment thread src/runtime/server/HTTPStatusText.rs Outdated
Comment thread src/runtime/server/HTTPStatusText.rs Outdated
Comment thread src/runtime/server/RequestContext.rs Outdated
Comment thread src/runtime/server/HTTPStatusText.rs
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

The diff is ready; the new status line reason phrase tests fail on main and pass on this branch (locally and on the ASAN lane of build 82812). serve.test.ts, bun-serve-routes.test.ts, bun-serve-file.test.ts, bun-serve-headers.test.ts, the static-route stress tests, and the updated ASAN serve-stream-body-error.test.ts are all green.

Build 82812's only hard failure is the binary-size check, which is comparing against a stale canary (#79916 at ae4b17d, the last passed main build). This branch is based on 44f6469, twelve commits later, and the ~540 KB delta comes from #32602 (node:quic on lsquic), #31823 (inspector DevTools server) and #31827 (node:repl on acorn) on main, not from the ~200-line status-text change here. The four test-file entries (bake/deinitialization, webview-chrome, test-http-server-connections-checking-leak, test-fs-read-stream-pos) are in the flaky bucket and passed on retry.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. can't set statusText #13817 - Reports that setting statusText on a Response in Bun.serve has no effect on the wire; this PR fixes statusText serialization

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #13817

🤖 Generated with Claude Code

Comment thread src/runtime/server/FileRoute.rs Outdated

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/server/FileRoute.rs (1)

144-218: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

FileRoute still drops custom Response.statusText for Bun.file() routes. from_js never captures response.get_init_status_text(), FileRoute has no status_text field, and write_status_code always passes &[] to the HTTP/1.x writers. So new Response(Bun.file(...), { statusText: "Custom" }) will still emit the default/canned reason phrase instead of the caller’s text. Mirror StaticRoute here and add a regression test for a Bun.file()-backed route.

🤖 Prompt for 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.

In `@src/runtime/server/FileRoute.rs` around lines 144 - 218, Update FileRoute to
preserve custom Response status text: add a status_text field, capture
response.get_init_status_text() in from_js when constructing a response-backed
route, and pass it through write_status_code to the HTTP/1.x writers instead of
always using an empty slice. Mirror StaticRoute’s implementation and add a
regression test covering a Bun.file()-backed Response with a custom statusText.
🤖 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.

Outside diff comments:
In `@src/runtime/server/FileRoute.rs`:
- Around line 144-218: Update FileRoute to preserve custom Response status text:
add a status_text field, capture response.get_init_status_text() in from_js when
constructing a response-backed route, and pass it through write_status_code to
the HTTP/1.x writers instead of always using an empty slice. Mirror
StaticRoute’s implementation and add a regression test covering a
Bun.file()-backed Response with a custom statusText.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c34063ce-f83f-4aa2-9a7a-35233d3adf16

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and 4908072.

📒 Files selected for processing (7)
  • src/runtime/server/FileRoute.rs
  • src/runtime/server/HTMLBundle.rs
  • src/runtime/server/HTTPStatusText.rs
  • src/runtime/server/RequestContext.rs
  • src/runtime/server/StaticRoute.rs
  • src/runtime/server/mod.rs
  • test/js/bun/http/serve.test.ts

Comment thread src/runtime/server/HTTPStatusText.rs
Mirrors StaticRoute so a Bun.file()-backed routes: entry honours the
handler's statusText. The route-computed 206/304/412/416 overrides keep the
canonical phrase. Also updates serve-stream-body-error.test.ts which asserted
the old 'HM' placeholder for status 597.

@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 earlier findings (the FileRoute sibling gap and the stale 597 HM assertion) are addressed in b6948d2, and this pass found nothing new.

What was reviewed:

  • HTTPStatusText::format buffer arithmetic (256-byte buf vs. max 5-digit code + truncated reason) — no overflow.
  • is_valid_reason_phrase against RFC 9112 §4 — correctly rejects CR/LF/NUL, so response splitting via statusText is blocked (covered by the /injection cases).
  • Borrow lifetime of status_text_slice in render_metadataswap_init_headers only touches init.headers, and BunString is Copy with no Drop, so the +0 view stays valid through do_write_status.
  • All write_status free-function callers updated; server-computed overrides (206/304/405/412/416) still get the canonical phrase via &[].
Extended reasoning...

Overview

Threads Response.statusText through Bun.serve's status-line writer and replaces the HM placeholder with an empty reason phrase for unknown codes. New HTTPStatusText::format centralizes the "<code> <reason>" construction; RequestContext::do_write_status, StaticRoute::do_write_status, FileRoute::write_status_code, and the shared server::write_status all route through it. StaticRoute and FileRoute gain a status_text: Box<[u8]> snapshot (populated in from_js, defaulted elsewhere, cloned/counted in clone/memory_cost); HTMLBundle's construction site defaults it. Tests add a raw-socket describe block covering dynamic/static/file routes, CR-LF injection, and a fetch() round-trip; the ASAN-gated serve-stream-body-error assertion is updated from 597 HM to 597 .

Security risks

The one risk is HTTP response splitting via a statusText containing CR/LF. is_valid_reason_phrase implements the RFC 9112 §4 byte set (HTAB / SP / VCHAR / obs-text) and rejects any control byte other than HTAB, so a statusText with \r\n falls through to the canned/empty phrase. The /injection and /injection-unknown test cases assert this on the wire. No other security-sensitive surface is touched.

Level of scrutiny

Moderate. render_metadata is on the per-response hot path, but the added work is a BunString bitwise copy, a possible Latin-1→UTF-8 transcode of a short string, and a bounded copy_from_slice into a 256-byte stack buffer — no allocation on the common all-ASCII path (to_utf8_without_ref returns a never_free borrowed slice). No new unsafe, no refcount changes, no GC-visible fields. The struct-field additions are plain owned Box<[u8]> with default Drop.

Other factors

This is a re-review after b6948d2 addressed both of my earlier comments: FileRoute now snapshots status_text alongside status_code and threads it through only when the route's own status is emitted (206/304/412/416 keep the canonical phrase), and the stale 597 HM snapshot assertion is updated. I verified all callers of the free write_status were updated (only FileRoute/StaticRoute call it), that swap_init_headers mutates only init.headers so the +0 status_text borrow in render_metadata is sound, and that bun_core::String is Copy with no Drop so the +0 handle is not spuriously deref'd. The buffer math in format is safe for any u16 code. CI build 82812 is still queued (earlier builds failed at agent provisioning, not at compile/test), but the author reports local green including the ASAN-gated test.

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.

can't set statusText

2 participants