Skip to content

Bun.serve: chunk-frame FIFO/pipe file bodies instead of sending Content-Length: 0 - #36243

Merged
Jarred-Sumner merged 7 commits into
mainfrom
farm/7a08f349/serve-fifo-content-length-desync
Aug 4, 2026
Merged

Bun.serve: chunk-frame FIFO/pipe file bodies instead of sending Content-Length: 0#36243
Jarred-Sumner merged 7 commits into
mainfrom
farm/7a08f349/serve-fifo-content-length-desync

Conversation

@robobun

@robobun robobun commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Repro

// mkfifo /tmp/p; (sleep 0.3; printf 'PIPEBYTES!' > /tmp/p) &
const srv = Bun.serve({ port: 0, fetch: r =>
  new URL(r.url).pathname === '/fifo'
    ? new Response(Bun.file('/tmp/p'))
    : new Response('SECOND-RESPONSE') });
// raw keep-alive client: GET /fifo, then GET /plain

Wire capture on one keep-alive connection, before:

HTTP/1.1 200 OK
content-type: application/octet-stream
content-disposition: filename="p"
content-length: 0
Date: ...

PIPEBYTES!HTTP/1.1 200 OK     <- pipe body glued onto response 2's status line
content-length: 15
...
SECOND-RESPONSE

The first response declares Content-Length: 0 and then still streams the FIFO's bytes after the head. A client that honours the declared length parses PIPEBYTES!... as the next response's status line (RFC 9112 6.3), i.e. a framing desync on the keep-alive connection.

Cause

RequestContext::do_sendfile classifies S_ISFIFO/S_ISCHR/S_ISSOCK as non-regular (is_regular = false), sets the blob size to min(original_size, st_size) = 0 (a FIFO stats as 0 bytes), then unconditionally sets needs_content_length = true, so render_metadata writes content-length: 0 and marks the header as written. FileResponseStream is then started with length: None and streams the fd to EOF via resp.write(); with the Content-Length mark set, uWS emits those bytes raw instead of chunk-framing them. Two framing decisions that disagree.

FileRoute (static file routes) already gates the Content-Length header on file_type == File, so only the fetch-handler path is affected.

Fix

Set needs_content_length only for regular files. With no Content-Length marked, uWS's write() enters chunked mode on the first body chunk (and end() writes a correct Content-Length: N when the whole body arrives in the single terminating read). The regular-file sendfile fast path is unchanged.

Verification

$ USE_SYSTEM_BUN=1 bun test test/js/bun/http/bun-serve-file.test.ts -t 'frames the body as chunked'
(fail) Response(Bun.file(FIFO)) frames the body as chunked, not Content-Length: 0
  { firstHasContentLength: true, firstIsChunked: false, gluedToNextStatusLine: true }

$ bun bd test test/js/bun/http/bun-serve-file.test.ts -t 'frames the body as chunked'
(pass) Response(Bun.file(FIFO)) frames the body as chunked, not Content-Length: 0

Full bun-serve-file.test.ts (104 tests, including the existing FIFO backpressure test) still passes.

Related: #34257 changes the same line for a different symptom (character-device epoll EPERM); the two compose.


no test proof · iteration 8 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/http/bun-serve-file.test.ts

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

FIFO response framing

Layer / File(s) Summary
Non-regular file length handling
src/runtime/server/RequestContext.rs
do_sendfile now requires Content-Length only for regular files.
FIFO wire-framing regression test
test/js/bun/http/bun-serve-file.test.ts
Adds FIFO filesystem helpers and verifies chunked framing and payload delivery over a raw TCP connection.

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 identifies the Bun.serve FIFO framing fix and the incorrect Content-Length: 0 behavior.
Description check ✅ Passed The description explains the cause, fix, reproduction, and verification results, although it does not use the exact template headings.

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

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Bun.serve: serve character-device Bun.file() bodies instead of closing with zero bytes #34257 - Both PRs make the identical one-line fix in do_sendfile (set_needs_content_length(true)set_needs_content_length(is_regular)) to prevent Content-Length: 0 from being emitted for non-regular file bodies (FIFOs, character devices, sockets)

🤖 Generated with Claude Code

@robobun

robobun commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Overlaps with #34257 on the set_needs_content_length(is_regular) line, intentionally (noted in the PR body). The two target different failure modes:

  • Bun.serve: serve character-device Bun.file() bodies instead of closing with zero bytes #34257: epoll_ctl EPERM on character devices force-closes the connection before any byte is written. Its primary change is in PosixBufferedReader::start; the needs_content_length change is a follow-on there and its tests cover /dev/null//dev/zero.
  • This PR: FIFO body bytes emitted past a Content-Length: 0 head on a keep-alive connection, which desyncs the next response's status line. The test here asserts the wire-level framing (chunked, no CL, no glue to the next status line) over a raw keep-alive socket.

#34257 currently has merge conflicts; this one is the minimal fix + desync test and is independently landable. Whichever lands first, the other rebases cleanly over it.

Comment thread test/js/bun/http/bun-serve-file.test.ts
Comment thread src/runtime/server/RequestContext.rs Outdated
Comment thread src/runtime/server/RequestContext.rs Outdated

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

My earlier writerFd cleanup nit is addressed and I didn't find further issues. The one-line runtime change brings do_sendfile in line with FileRoute (which already gates Content-Length on file_type == File), and regular-file responses are unaffected. Deferring to a maintainer given this is Bun.serve response-framing code and there's #34257 to sequence.

What was reviewed:

  • Traced needs_content_lengthrender_metadatamark_wrote_content_length_header; with the flag off, FileResponseStream's resp.write() chunk-frames as intended, and the is_regular sendfile fast path is untouched.
  • Confirmed FileRoute.rs:576 already does the equivalent gate, so this is convergence not divergence.
  • Test: try/finally now covers writerFd, the body-on-wire poll exits on socketClosed, hermetic (port: 0, tempDir, skipIf(isWindows)).
Extended reasoning...

Overview

One-line runtime change in src/runtime/server/RequestContext.rs: self.flags.set_needs_content_length(true)set_needs_content_length(is_regular) inside do_sendfile. Plus a ~90-line wire-level regression test in test/js/bun/http/bun-serve-file.test.ts that opens a FIFO, serves it via a fetch handler over a raw keep-alive TCP socket, and asserts the first response is chunk-framed (no Content-Length, not glued to the next status line).

Security risks

The bug being fixed is itself a mild security concern — an HTTP/1.1 keep-alive framing desync (body bytes emitted past a declared Content-Length: 0) is the shape of a response-smuggling primitive. The fix removes it. The change cannot introduce a new desync: it only stops writing a header that was wrong; uWS then falls back to standard chunked transfer-encoding. No user-controlled input is newly interpreted.

Level of scrutiny

Higher than the diff size suggests, because it's Bun.serve response-framing on the fetch-handler path. I traced the flag's single consumer (render_metadata, line ~3768) and the downstream FileResponseStream::start call: for !is_regular, length: None is passed and the stream uses resp.write(), which chunk-frames when mark_wrote_content_length_header() was never called. Regular files still hit set_needs_content_length(true) and the sendfile fast path unchanged. The sibling FileRoute path already gates the same header on file_type == FileType::File (FileRoute.rs:576), so this is bringing the two entry points into agreement rather than introducing new behavior.

Other factors

  • My previous review flagged the writerFd leak-on-failure; 4cd369c wrapped it in try/finally with a writerFd = undefined sentinel and added a socketClosed escape on the poll loop — verified in the current diff.
  • The comment-cop bot's complaints were addressed (inline comment dropped in a297e93); all threads resolved.
  • PR body shows the test fails under USE_SYSTEM_BUN=1 and passes with the fix, and the full 104-test file still passes.
  • Overlaps intentionally with open PR #34257 on the same line (different failure mode: char-device epoll EPERM vs. FIFO framing desync). A maintainer should decide which lands first / whether to fold them; either way they compose.

I'm not approving outright because HTTP response framing in Bun.serve is a core-enough path that a human should sign off, and the #34257 overlap is a coordination decision I shouldn't make.

@robobun

robobun commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 AM PT - Jul 29th, 2026

@Jarred-Sumner, your commit 175aae3 has 2 failures in Build #85261 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36243

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

bun-36243 --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.

LGTM — the one-line runtime change brings do_sendfile in line with FileRoute, which already gates Content-Length on file_type == File.

What was reviewed:

  • Traced needs_content_length through render_metadatamark_wrote_content_length_header() → uWS write() chunk-framing; regular-file and HEAD/empty paths (is_regular && remain == 0) are unchanged.
  • Confirmed FileResponseStream::start already receives length: None for non-regular files, so the prior Content-Length: 0 header was the only thing suppressing chunked framing.
  • Test: writerFd now released via try/finally (prior feedback addressed), the wire-capture promise resolves on close/error so a regression fails the assertion instead of timing out, and await using server disposes before the finally runs.
Extended reasoning...

Overview

One-line change in src/runtime/server/RequestContext.rs: do_sendfile now sets needs_content_length to is_regular instead of unconditionally true. For FIFOs/char-devices/sockets (S_ISFIFO/S_ISCHR/S_ISSOCK), stat.st_size is 0, so the old code wrote Content-Length: 0 and then streamed the fd to EOF via resp.write() — bytes past the declared length, which desyncs the next response on a keep-alive connection. With the flag off, render_metadata skips mark_wrote_content_length_header() and uWS chunk-frames the body writes. The test in test/js/bun/http/bun-serve-file.test.ts opens a raw TCP client, captures the wire bytes, and asserts the head has Transfer-Encoding: chunked, no Content-Length, and the body payload arrives.

Security risks

The bug being fixed is itself a mild HTTP desync (response-framing) hazard on keep-alive connections. The fix removes it. No new attack surface: the change only stops emitting a header that was wrong; it does not touch input parsing, auth, or TLS.

Level of scrutiny

Low-to-medium. The runtime change is a single boolean and mirrors the existing FileRoute behavior at FileRoute.rs:576 (if file_type == FileType::File && !resp.state().has_written_content_length_header()), so this is bringing the fetch-handler path in line with its sibling rather than introducing new logic. The regular-file sendfile fast path, the empty-regular-file early-end (is_regular && remain == 0), and Range handling are all gated on is_regular and unaffected.

Other factors

My prior review comment (fd leak on the failure path) was addressed in 4cd369c and refined in 022d2d8: writerFd is now wrapped in try/finally, and the wire-capture uses Promise.withResolvers resolved from data/close/error so a regression that never delivers the payload fails the assertion rather than spinning to timeout. The comment-cop feedback on the inline Rust comment was also addressed (comment removed; rationale lives in the commit message). The PR body notes the intentional overlap with #34257 and the two compose. The full bun-serve-file.test.ts suite (including the existing FIFO backpressure test that exercises the same FileResponseStream path) is reported passing.

@robobun

robobun commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Build #85261 (latest, on the merge-from-main): bun-serve-file.test.ts passes on every lane. The two [new] failures are unrelated main breaks (worker-transfer-terminate-stress.test.ts JSC ExceptionScope SIGABRT on x64-asan; test-fs-read-stream-pos.js timeout on aarch64), reported to main-break triage; the rest is pre-existing flake. Diff is ready for review.

Open question from above: happy to extract a shared FileType::from_stat_mode(mode) -> (FileType, bool) helper (de-dups three call sites) here, or keep this PR at the one-line framing fix and do the extraction as a follow-up.

robobun added 5 commits July 28, 2026 21:01
…nt-Length: 0

do_sendfile wrote Content-Length from the stat-derived blob size for every
file body and then streamed non-regular fds to EOF with no length bound.
A FIFO stats as 0 bytes, so the head said Content-Length: 0 while the pipe
bytes still went out after it. On a keep-alive connection those bytes land
where the client parses the next response's status line (RFC 9112 6.3).

Only set needs_content_length for regular files. With no Content-Length
marked, uWS's write()/end() path chunk-frames the body (or writes a correct
Content-Length when the whole body arrives in the single end() call), and
the regular-file sendfile fast path is unchanged.
Also exit the body-on-wire poll if the socket closes first, so a
regression that never delivers the body fails the assertion instead
of spinning to timeout.
The second-request assertion depended on the server cleanly terminating
the chunked body when the FIFO writer closes, which turns out to be
platform-dependent (Linux serves the pipelined request without a 0\r\n\r\n
terminator; macOS force-closes). That is a separate pre-existing behaviour
and not what this test covers, so drop the keep-alive leg and assert on
what this change actually fixes: no Content-Length in the head, body
framed as chunked, and no body bytes emitted past a Content-Length: 0.
@robobun
robobun force-pushed the farm/7a08f349/serve-fifo-content-length-desync branch from 022d2d8 to 6eba603 Compare July 28, 2026 21:01

@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 — one-line fix bringing the fetch-handler file path in line with what FileRoute already does for non-regular files.

Reviewed: do_sendfileis_regular is already the gate for the neighboring size-clamp and content-range blocks, and render_metadata only writes/marks Content-Length when this flag is set, so non-regular fds now fall through to uWS chunked mode instead of CL: 0 + raw body bytes. Regular-file sendfile path is unchanged. Test asserts wire-level framing (no CL, transfer-encoding: chunked, body delivered) over a raw socket; the earlier fd-leak nit is addressed with try/finally and the poll now resolves on socket close/error. CI reports bun-serve-file.test.ts green on all lanes.

Extended reasoning...

Overview

One production line in src/runtime/server/RequestContext.rs: self.flags.set_needs_content_length(true)set_needs_content_length(is_regular) inside do_sendfile. This stops the fetch-handler file-response path from writing Content-Length: 0 for FIFOs/character devices/sockets (whose st_size is 0) and then streaming the fd's bytes past that declared length — an HTTP/1.1 framing desync on keep-alive. With the flag off, render_metadata skips the CL header and mark_wrote_content_length_header(), so uWS's write() enters chunked mode. The static FileRoute path already gates CL on file_type == File; this brings the dynamic path to parity. A new wire-level test in bun-serve-file.test.ts opens a FIFO, serves it via a fetch handler, and asserts the raw response head over a Bun.connect socket.

Security risks

The bug being fixed is itself a response-framing / keep-alive desync (body bytes past a Content-Length: 0 head land where the client parses the next status line). The fix moves in the strictly safer direction: unknown-length bodies become chunked instead of mis-declared. Regular files keep their exact CL. No new attack surface introduced.

Level of scrutiny

Bun.serve HTTP framing is a critical path, but the change is a single boolean substitution using a local that already gates every neighboring block (b.size.set(...), needs_content_range, the sendfile.remain clamp). I traced the consumer at RequestContext.rs:3768-3771 — when the flag is false the CL header and the wrote-CL mark are simply skipped, which is exactly what lets uWS chunk-frame subsequent write() calls. The change cannot affect the regular-file fast path (is_regular == true there). Given the mechanical nature and the existing sibling pattern in FileRoute, this doesn't need a human to re-derive the reasoning.

Other factors

  • My earlier inline nit (writerFd only closed on the happy path) was addressed: the test body is now wrapped in try { ... } finally { closeSync(writerFd) }, and the wire-capture wait resolves on data containing the marker or on socket close/error, so a regressed build fails the assertion instead of spinning.
  • USE_SYSTEM_BUN=1 fail / bun bd pass verification is documented in the PR body; robobun reports bun-serve-file.test.ts green on all CI lanes including darwin.
  • Overlap with #34257 on this exact line is acknowledged in the PR body and thread; whichever lands first, the other rebases trivially. That coordination doesn't block this change.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we reuse more code from FileRoute?

@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

The piece that's actually duplicated between FileRoute::on and do_sendfile is the st_mode → (FileType, pollable) classification (FileRoute.rs:420-428, RequestContext.rs:1754-1785; a third copy lives in webcore/FileReader.rs:244-250). Beyond that the two paths diverge at every step:

  • error surface: req.set_yield(true) vs run_error_handler(js_err)
  • headers: FileRoute writes resp.write_header_int directly; do_sendfile sets needs_content_length/needs_content_range flags and defers to render_metadata
  • FileRoute has the whole If-None-Match/If-Modified-Since/ETag/stat_hash block; do_sendfile has none
  • fd lifetime: FileRoute uses a scopeguard and is always auto_close; do_sendfile derives it from PathOrFileDescriptor and closes on each early return
  • request-context glue: has_sendfile_ctx/has_abort_handler/clear on_data/on_abort: Some(..) are RequestContext-only

So a bigger shared helper would need ~5 callbacks and wouldn't shrink either site. The small win is a FileType::from_stat_mode(mode) -> (FileType, /*pollable*/ bool) next to the enum in src/io/posix_event_loop.rs, which all three call sites could use and which makes the "only regular files get Content-Length" gate fall out of the same value both paths match on.

Happy to add that helper here, or keep this PR to the one-line framing fix and do the extraction as a follow-up. Your call.

@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 — the fd-cleanup nit from the earlier pass is addressed, and the one-line is_regular gate matches what FileRoute already does.

What was reviewed:

  • render_metadata at RequestContext.rs:3768 — with the flag false, mark_wrote_content_length_header() is skipped so uWS chunk-frames write() calls.
  • Regular-file path unchanged: the fetch-handler empty-file test (returns 200 for empty files served from the fetch handler) still asserts content-length: 0, and is_regular is true there.
  • Test now resolves on close/error as well as body arrival, so a regressed build fails the bodyDelivered assertion instead of timing out; writerFd is released in finally.
Extended reasoning...

Overview

One-line change in RequestContext::do_sendfile (set_needs_content_length(true)set_needs_content_length(is_regular)) plus a wire-level framing test in bun-serve-file.test.ts. The bug: for FIFO/char-device/socket bodies, the fetch-handler path wrote content-length: 0 (from the 0-byte stat) and then streamed the fd to EOF via resp.write(), putting body bytes past the declared length and desyncing the next keep-alive response. The fix suppresses the Content-Length header for non-regular files so uWS chunk-frames the body — the same gate FileRoute already applies via file_type == File.

Security risks

The pre-fix behavior was itself the security-relevant issue (HTTP response desync on a keep-alive connection). The fix narrows behavior to match the sibling static-route path; no new attack surface.

Level of scrutiny

HTTP framing in Bun.serve is production-critical, but the change is a single boolean whose only effect is on the !is_regular branch. I traced needs_content_length to its consumer at render_metadata (RequestContext.rs:3768–3771): when false, neither write_header_int("content-length", ...) nor mark_wrote_content_length_header() runs, which is exactly what lets uWS enter chunked mode on the first body write. The is_regular = true path (including zero-byte regular files, covered by the existing "returns 200 for empty files served from the fetch handler" test asserting content-length: 0) is byte-identical to before.

Other factors

  • I reviewed an earlier revision and raised one nit (fd leak on failure paths); it was fixed by wrapping in try/finally and replacing the unbounded poll with a Promise.withResolvers that also resolves on socket close/error. The comment-cop feedback on the source comment was also addressed (comment removed).
  • The test asserts the strongest observable invariant (raw wire bytes: no CL header, transfer-encoding: chunked present, body delivered, no body-past-CL:0) rather than going through fetch(), which would mask the framing.
  • CI: bun-serve-file.test.ts passes on every lane that ran; the red build-bun steps are the unrelated "sibling build-cpp errored" infra failure.
  • Known overlap with #34257 on this exact line is acknowledged in the PR body; whichever lands first, the other rebases trivially.

@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: 1

🤖 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/bun-serve-file.test.ts`:
- Around line 1122-1147: Extend the keep-alive test around the existing captured
response and client connection to send a second GET request after the FIFO
response settles, then verify the subsequent response is parsed cleanly and
separately. Preserve the current assertions for chunked transfer, body delivery,
and absence of Content-Length, and ensure the client remains open until the
second response is received.
🪄 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: 3102c4c5-2713-4b0e-939b-a268965d24ec

📥 Commits

Reviewing files that changed from the base of the PR and between 4cd369c and 175aae3.

📒 Files selected for processing (2)
  • src/runtime/server/RequestContext.rs
  • test/js/bun/http/bun-serve-file.test.ts

Comment thread test/js/bun/http/bun-serve-file.test.ts
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflcits or close

@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Conflicts resolved in 0c84a70 (main refactored do_sendfile to compute blob_offset earlier and use a local sendfile var; the one-line set_needs_content_length(is_regular) change carries over cleanly). Rebuilt and re-verified: fail-before/pass-after hold, full bun-serve-file.test.ts green (105 pass).

@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 — one-token fix bringing do_sendfile into parity with FileRoute's existing file_type == File gate on Content-Length.

What was reviewed:

  • Confirmed FileRoute::on already gates content-length on file_type == FileType::File (FileRoute.rs:493), so this change makes the fetch-handler path match the static-route path.
  • Verified render_metadata only writes/marks Content-Length when the flag is set (RequestContext.rs:3947), so with the flag off uWS falls through to chunked framing as intended.
  • Test: writerFd cleanup now in try/finally (my earlier concern addressed); wireDone resolves on close/error so regressions fail the assertion rather than spinning; on the pre-fix build the payload still reaches the wire raw so hasContentLength: false is the assertion that fails.
Extended reasoning...

Overview

One-line source change in src/runtime/server/RequestContext.rs: self.flags.set_needs_content_length(true)set_needs_content_length(is_regular). is_regular is computed a few lines above via S::ISREG(mode). For FIFOs/char-devices/sockets, st_size is 0 but the body streams to EOF; emitting Content-Length: 0 and then raw-writing the body is an HTTP/1.1 framing desync. The sibling static-file-route path (FileRoute::on, line 493) already gates Content-Length on file_type == FileType::File, so this brings the two paths into agreement. The rest of do_sendfile already branches on is_regular for size clamping and content-range, so this is the last unconditional piece.

The test addition is a raw-TCP wire-level check: opens a FIFO r+, serves Bun.file(fifo) from a fetch handler, writes a payload into the FIFO, and asserts the response head has transfer-encoding: chunked and no content-length. The test is skipped on Windows (no mkfifo) and lives next to the existing FIFO backpressure test.

Security risks

None introduced. This removes a response-smuggling / framing-desync vector on keep-alive connections when a handler returns a FIFO/pipe body. No new input parsing.

Level of scrutiny

Low-to-medium. The source change is a single boolean substitution whose value is computed immediately above and already used to gate three neighboring decisions in the same function. The parallel code path (FileRoute) already does exactly this. CI on the target test file passed on every lane; the two build failures were unrelated main-break flakes.

Other factors

  • My prior inline comment (fd leak on the failure path) was addressed: writerFd is now closed in finally, and the wire poll resolves on socket close/error instead of spinning.
  • CodeRabbit's suggestion to add a second keep-alive request was declined with a concrete reason (macOS FIFO-EOF path force-closes the connection, a separate pre-existing issue) and CodeRabbit withdrew it.
  • The comment-cop nags were addressed (inline comment removed; rationale lives in the PR body).
  • Jarred asked for conflicts to be fixed, which was done via merge commit 0c84a70; the diff is unchanged in substance.
  • The open "extract FileType::from_stat_mode helper" question was explicitly offered as a follow-up; Jarred's response was to land as-is.
  • Overlap with #34257 is noted and intentional; whichever lands first, the other rebases cleanly.

@Jarred-Sumner
Jarred-Sumner merged commit b8d9477 into main Aug 4, 2026
47 of 51 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/7a08f349/serve-fifo-content-length-desync branch August 4, 2026 08:07
springmin pushed a commit to springmin/bun that referenced this pull request Aug 4, 2026
…nt-Length: 0 (oven-sh#36243)

### Repro

```js
// mkfifo /tmp/p; (sleep 0.3; printf 'PIPEBYTES!' > /tmp/p) &
const srv = Bun.serve({ port: 0, fetch: r =>
  new URL(r.url).pathname === '/fifo'
    ? new Response(Bun.file('/tmp/p'))
    : new Response('SECOND-RESPONSE') });
// raw keep-alive client: GET /fifo, then GET /plain
```

Wire capture on one keep-alive connection, before:

```
HTTP/1.1 200 OK
content-type: application/octet-stream
content-disposition: filename="p"
content-length: 0
Date: ...

PIPEBYTES!HTTP/1.1 200 OK     <- pipe body glued onto response 2's status line
content-length: 15
...
SECOND-RESPONSE
```

The first response declares `Content-Length: 0` and then still streams
the FIFO's bytes after the head. A client that honours the declared
length parses `PIPEBYTES!...` as the next response's status line (RFC
9112 6.3), i.e. a framing desync on the keep-alive connection.

### Cause

`RequestContext::do_sendfile` classifies `S_ISFIFO`/`S_ISCHR`/`S_ISSOCK`
as non-regular (`is_regular = false`), sets the blob size to
`min(original_size, st_size) = 0` (a FIFO stats as 0 bytes), then
unconditionally sets `needs_content_length = true`, so `render_metadata`
writes `content-length: 0` and marks the header as written.
`FileResponseStream` is then started with `length: None` and streams the
fd to EOF via `resp.write()`; with the Content-Length mark set, uWS
emits those bytes raw instead of chunk-framing them. Two framing
decisions that disagree.

`FileRoute` (static file routes) already gates the Content-Length header
on `file_type == File`, so only the fetch-handler path is affected.

### Fix

Set `needs_content_length` only for regular files. With no
Content-Length marked, uWS's `write()` enters chunked mode on the first
body chunk (and `end()` writes a correct `Content-Length: N` when the
whole body arrives in the single terminating read). The regular-file
sendfile fast path is unchanged.

### Verification

```
$ USE_SYSTEM_BUN=1 bun test test/js/bun/http/bun-serve-file.test.ts -t 'frames the body as chunked'
(fail) Response(Bun.file(FIFO)) frames the body as chunked, not Content-Length: 0
  { firstHasContentLength: true, firstIsChunked: false, gluedToNextStatusLine: true }

$ bun bd test test/js/bun/http/bun-serve-file.test.ts -t 'frames the body as chunked'
(pass) Response(Bun.file(FIFO)) frames the body as chunked, not Content-Length: 0
```

Full `bun-serve-file.test.ts` (104 tests, including the existing FIFO
backpressure test) still passes.

Related: oven-sh#34257 changes the same line for a different symptom
(character-device epoll `EPERM`); the two compose.

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

---

**no test proof** · iteration 8 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/http/bun-serve-file.test.ts

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

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
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