Skip to content

Bun.serve: serve character-device Bun.file() bodies instead of closing with zero bytes - #34257

Open
robobun wants to merge 5 commits into
mainfrom
farm/51ca02e5/serve-chardev-eperm
Open

Bun.serve: serve character-device Bun.file() bodies instead of closing with zero bytes#34257
robobun wants to merge 5 commits into
mainfrom
farm/51ca02e5/serve-chardev-eperm

Conversation

@robobun

@robobun robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Repro

import * as net from "node:net";
const srv = Bun.serve({ port: 0, fetch: () => new Response(Bun.file("/dev/null")) });
const s = net.connect(srv.port, "127.0.0.1", () =>
  s.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"));
let n = 0; s.on("data", d => n += d.length); s.on("error", () => {});
s.on("close", () => { console.log(n); srv.stop(true); });
// before: 0   (connection closed with no status line, no headers, no error() callback)
// after:  117 (HTTP/1.1 200 OK + headers + Content-Length: 0)

strace shows epoll_ctl(EPOLL_CTL_ADD, <chardev fd>) = -1 EPERM followed by the socket being force-closed before any byte is written. Node's http + createReadStream("/dev/null") serves an empty 200. Bun.file("/dev/null").text() already returns "" correctly because the whole-blob reader runs on the threadpool and never touches the main loop's epoll.

Cause

RequestContext::do_sendfile classifies S_ISCHR as (FileType::Pipe, pollable = true) and hands the fd to FileResponseStream, whose BufferedReader registers it with the uWS event loop's epoll. For character devices whose file_operations have no .poll hook (/dev/null, /dev/zero, /dev/full) Linux returns EPERM from epoll_ctl(EPOLL_CTL_ADD). PosixBufferedReader::register_poll dispatches that through on_reader_error, which FileResponseStream::fail_with handles by resp.force_close(), discarding any corked headers. on_file_stream_error in RequestContext only cleans up after the socket is already gone, so the handler's error() callback never sees it.

FIFOs take the same (Pipe, true) branch but are epollable, which is why they work.

Fix

PosixBufferedReader::start now handles an EPERM from the initial poll registration by dropping the never-registered FilePoll and continuing on the non-pollable read path. The shell's IOWriter::__start already does this for EPERM/EINVAL on the writer side; this brings the reader to parity. Devices that epoll rejects are exactly those with no .poll hook, which poll(2) treats as always-readable (DEFAULT_POLLMASK), so the blocking-read loop drives them to EOF without readiness notifications. TTYs and other pollable character devices keep the epoll path.

Two follow-on changes make the serve path produce a well-formed response once the reader succeeds:

  • RequestContext::do_sendfile no longer writes Content-Length for non-regular files (the stat size is meaningless, and a Content-Length: 0 header followed by streamed body bytes is a framing violation). It also passes the user's .slice() length through to FileResponseStream for non-regular files so Bun.file("/dev/zero").slice(0, n) stops after n bytes instead of streaming indefinitely.
  • FileResponseStream::finish ends an immediately-EOF stream with resp.end(b"") rather than end_without_body(), so uWS supplies the missing framing when no Content-Length was written and the client is not left waiting.

Verification

$ USE_SYSTEM_BUN=1 bun test test/js/bun/http/bun-serve-file.test.ts -t "character-device"
(fail) serving a character-device Bun.file from fetch() > /dev/null serves an empty 200 response
  Expected: "HTTP/1.1 200 OK"  Received: ""
(fail) serving a character-device Bun.file from fetch() > /dev/zero with .slice() serves the sliced length
  Expected: "HTTP/1.1 200 OK"  Received: ""

$ bun bd test test/js/bun/http/bun-serve-file.test.ts -t "character-device"
(pass) serving a character-device Bun.file from fetch() > /dev/null serves an empty 200 response
(pass) serving a character-device Bun.file from fetch() > /dev/zero with .slice() serves the sliced length

The full bun-serve-file.test.ts suite (including the existing FIFO backpressure test) still passes.

Adjacent to #34242 (procfs regular files with st_size == 0), which touches overlapping lines in RequestContext.rs / FileResponseStream.rs for a different stat-size bug; the two compose.


no test proof · iteration 5 · 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

…g with zero bytes

On Linux, returning new Response(Bun.file("/dev/null")) (or any
character device whose file_operations lack .poll, such as /dev/zero or
/dev/full) from a Bun.serve fetch handler closed the connection with no
status line, no headers, and no error() callback. strace showed
epoll_ctl(EPOLL_CTL_ADD, chardev-fd) returning EPERM followed by a
pre-header teardown.

do_sendfile classifies S_ISCHR as (FileType::Pipe, pollable = true) and
hands the fd to FileResponseStream, whose BufferedReader tries to
register it with the main event loop's epoll. For /dev/null-class
devices epoll_ctl returns EPERM; PosixBufferedReader::register_poll
dispatched that through on_reader_error, which FileResponseStream's
fail_with handled by force-closing the socket and discarding any corked
headers. The handler's error() callback is never consulted because
on_file_stream_error only cleans up after the socket is already gone.

PosixBufferedReader::start now recovers from an EPERM on the initial
registration by dropping the never-registered FilePoll and continuing on
the non-pollable (blocking-read) path, the same fallback IOWriter
already uses for EPERM/EINVAL on the writer side. The character devices
epoll rejects are exactly those with no .poll hook, which the kernel
treats as always-readable, so the non-pollable read loop drives them to
EOF without needing readiness notifications.

For the serve path itself, RequestContext::do_sendfile no longer writes
Content-Length for non-regular files (the stat size is meaningless
there) and passes the user's .slice() length through to
FileResponseStream so a sliced /dev/zero stops after that many bytes
instead of streaming forever. FileResponseStream::finish now ends an
immediately-EOF stream with resp.end("") rather than
end_without_body(), so uWS supplies the missing framing and the client
is not left waiting on a headerless body.
@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:47 PM PT - Jul 16th, 2026

@Jarred-Sumner, your commit 05fad3d has 4 failures in Build #74322 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34257

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

bun-34257 --bun

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Serving non-regular files now handles unsupported poll registration, selects appropriate HTTP framing, emits explicit empty responses at EOF, and adds Linux raw TCP regression coverage for /dev/null and /dev/zero.

Character-device file serving

Layer / File(s) Summary
Poll registration fallback
src/io/PipeReader.rs
Poll registration is centralized in try_register_poll(), with unsupported descriptors falling back to direct fd handling.
Non-regular response framing
src/runtime/server/RequestContext.rs, src/runtime/server/FileResponseStream.rs
Non-regular files avoid unknown content lengths, preserve known slice lengths, and finalize with an explicit empty body.
Character-device HTTP tests
test/js/bun/http/bun-serve-file.test.ts
Raw TCP tests verify valid empty /dev/null responses and sliced zero-filled /dev/zero responses without server errors.

Possibly related PRs

  • oven-sh/bun#34177: Also changes PosixBufferedReader polling and registration behavior.
  • oven-sh/bun#34242: Also changes special-file response framing and empty-EOF handling.

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 matches the main change: serving character-device Bun.file bodies instead of closing the connection.
Description check ✅ Passed The description covers the fix and verification in detail, though it doesn't use the template headings verbatim.

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.

I didn't find any bugs, but this touches shared I/O infrastructure (PosixBufferedReader::start is used by subprocess/shell/file readers) and changes HTTP response framing, so it's worth a human look.

What was reviewed:

  • EPERM fallback in start(): verified close_impl(.., close_fd=false) releases the never-registered FilePoll without closing the fd before handle is reset to Fd(fd).
  • end_without_bodyend(b"") in finish(): checked that the chunked-EOF path is only reached via on_reader_done for non-regular files; regular-file EOF still ends via on_read_chunk.
  • Ruled out: non-regular file body capped to 0 when JS reads .size first — original_size is captured before the stat-size clamp.
Extended reasoning...

Overview

Four files: PosixBufferedReader gains a try_register_poll split so start() can catch EPERM from epoll_ctl(ADD) on Linux and fall back to the non-pollable read path (mirroring IOWriter::__start). RequestContext::do_sendfile stops emitting Content-Length for non-regular files and threads an explicit .slice() length through as the FileResponseStream cap. FileResponseStream::finish swaps end_without_body for end(b"") so uWS supplies chunked framing when no length header was written. Two new tests cover /dev/null and /dev/zero.slice() via raw TCP + fetch.

Security risks

None identified. No user-controlled parsing, no auth/crypto. The path is server-side response streaming of an fd the handler already opened.

Level of scrutiny

High. PosixBufferedReader::start is on the hot path for every pollable-fd reader in the runtime (subprocess stdout/stderr, shell pipes, FIFO responses), and FileResponseStream::finish / set_needs_content_length(is_regular) change framing for all non-regular file bodies, not just character devices. The reasoning is sound and the existing FIFO backpressure test is claimed to still pass, but a maintainer should confirm the framing change is desired for FIFOs (previously Content-Length: <stat_size>, now chunked) and that the end(b"") swap doesn't regress any path that previously relied on end_without_body semantics.

Other factors

  • The EPERM fallback is #[cfg(linux|android)]-gated but the new tests are describe.skipIf(isWindows), so they also run on macOS — worth confirming kqueue doesn't hit an analogous registration failure there (or that /dev/null registers cleanly on Darwin).
  • The PR notes overlap with #34242 in the same functions; merge order may matter.
  • CI build was still in progress at review time; no green build to reference.

macOS kqueue returns EINVAL (not EPERM) when registering a character
device like /dev/null for EVFILT_READ, so the same zero-byte-close
reproduces there. Extend the fallback to cover EINVAL on all POSIX in
addition to EPERM on Linux, matching the existing IOWriter::__start
handling.
@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch on macOS: kqueue returns EINVAL (not EPERM) when registering /dev/null for EVFILT_READ, and the zero-byte close reproduces there too with released bun. c45a338 extends the fallback to cover EINVAL on all POSIX in addition to EPERM on Linux, matching what IOWriter::__start already does.

On the FIFO framing change: before this PR, a FIFO body from the fetch handler wrote Content-Length: 0 (from original_size.min(stat_size) = min(MAX_SIZE, 0)) and then streamed body bytes via resp.write(), which is a framing violation once any bytes actually arrive. Switching to chunked for non-regular files makes the framing match the body. The existing FIFO backpressure test in bun-serve-file.test.ts still passes.

Comment thread src/io/PipeReader.rs

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

The macOS EINVAL concern from my earlier comment is addressed in c45a338 — the fallback now matches both branches of IOWriter::__start. I didn't find further issues, but this touches core I/O poll-registration fallback (shared by every PosixBufferedReader::start caller) plus HTTP framing semantics, so it's worth a human look.

Checked: close_impl(.., false) drops the never-registered FilePoll without closing the fd before it's reinstated as PollOrFd::Fd; the end_without_bodyend(b"") path in finish() is only reached when no EOF chunk set RESPONSE_DONE, so regular files with a written Content-Length still terminate via on_read_chunk; the new original_size != MAX_SIZE branch only adds a cap for sliced non-regular files and leaves the unsliced None path unchanged.

Extended reasoning...

Overview

Four files: PosixBufferedReader::start in src/io/PipeReader.rs gains an EPERM/EINVAL fallback that drops the failed FilePoll and continues on the non-pollable read path; FileResponseStream::finish swaps end_without_body for end(b""); RequestContext::do_sendfile gates needs_content_length on is_regular and threads an explicit .slice() length through to the stream for non-regular fds; two new tests in bun-serve-file.test.ts cover /dev/null and sliced /dev/zero.

My earlier inline comment flagged that the first revision only handled Linux EPERM and would fail the new tests on macOS (kqueue → EINVAL). c45a338 added the unconditional-POSIX EINVAL arm; the current fd_not_pollable predicate now mirrors both branches of IOWriter::__start.

Security risks

None identified. No user-controlled input reaches the new code paths beyond the fd's stat mode, which was already being classified. The Content-Length change removes a pre-existing framing violation (CL:0 followed by streamed FIFO bytes) rather than introducing one.

Level of scrutiny

High. PosixBufferedReader::start is shared by subprocess pipes, shell readers, and file-response streaming, so widening the set of registration errors that silently fall back to blocking reads affects every consumer. The finish() framing change and the set_needs_content_length(is_regular) change alter wire-level HTTP behavior for all non-regular file bodies (FIFOs included, not just character devices). These are well-reasoned and covered by the existing FIFO backpressure test plus the two new tests, but they are behavior changes in production-critical paths, not mechanical fixes.

Other factors

  • try_register_poll calls enable_keeping_process_alive before register_with_fd; on the fallback path the poll is torn down via close_impl(.., close_fd=false). I did not fully trace whether that path balances the keep-alive ref — worth a glance from someone who owns FilePoll.
  • CI on c45a338 is still building with unrelated-looking build-cpp failures on aarch64-android and x64-musl-baseline; test jobs haven't reported yet.
  • The PR notes overlap with #34242 in the same functions.

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

The macOS EINVAL branch is in place now — c45a338 addresses the earlier finding and matches both arms of IOWriter::__start. No further issues found, but this reaches into shared I/O plumbing and changes HTTP framing, so it's worth a human look.

Checked: close_impl(.., false) on the never-registered FilePoll frees the allocation without closing the fd before handle is restored to Fd(fd); the .size-read-before-serve path on non-regular files was examined and does not zero-cap the response; set_needs_content_length(is_regular) only drops the bogus Content-Length: 0 that FIFOs already emitted.

Extended reasoning...

Overview

Four files: PosixBufferedReader::start in src/io/PipeReader.rs gains an EPERM/EINVAL fallback (refactored via a new try_register_poll); RequestContext::do_sendfile stops writing Content-Length for non-regular files and threads the user's .slice() length through as a cap; FileResponseStream::finish swaps end_without_body() for end(b"") so an immediately-EOF chunked stream terminates; two new tests in bun-serve-file.test.ts cover /dev/null and /dev/zero.

Security risks

None identified. No untrusted-input parsing, no auth/crypto surface. The framing change (chunked instead of Content-Length: 0 for non-regular files) is a correctness fix — the old behavior was already an HTTP framing violation when body bytes followed a zero Content-Length.

Level of scrutiny

Medium-high. PosixBufferedReader::start is shared infrastructure — the shell PipeReader, FileReader, subprocess pipes, and FileResponseStream all call it. The fallback is scoped to the initial registration in start() and mirrors an existing pattern in IOWriter::__start, so risk is bounded, but it silently changes error handling for every caller that passes is_pollable=true on a non-pollable fd. The end_without_bodyend(b"") swap in finish() and the Content-Length gating on is_regular are user-visible protocol changes for FIFO/socket bodies. The author notes this composes with #34242, which touches overlapping lines.

Other factors

My earlier finding (Linux-only #[cfg] on the EPERM arm leaving macOS broken) was addressed in c45a338; the current fallback matches IOWriter::__start's two-branch shape (EINVAL on all POSIX, EPERM on Linux/Android). I traced close_impl(None, None, false) on the failed-registration path: it deinit_force_unregisters the FilePoll allocation and, with close_fd=false, leaves the fd open before handle is set back to PollOrFd::Fd(fd). A finder raised whether reading .size on a non-regular Bun.file before serving would cap the response at 0 — ruled out. The FIFO backpressure test and the rest of bun-serve-file.test.ts reportedly still pass, but the framing change for FIFOs and the shared-reader fallback are the pieces I'd want a maintainer to sanity-check.

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green. The new character-device tests in bun-serve-file.test.ts pass on every Linux, macOS, and Windows lane across builds 73338, 73450, and 74322 (5 of 6 darwin shards on 74322 included).

Red lanes are unrelated to this change:

  • Build 74322: one darwin 14 x64 shard (darwin-pretzel-x64-1) timed out. Every one of the 84 pre-existing Bun.file in serve routes tests failed with ConnectionRefused to the shared beforeAll server (0 pass across 3 retries), bake/dev-and-prod.test.ts timed out the same way, and bake/deinitialization.test.ts (pre-existing) also failed. The other darwin 14 x64 shard on the same build passed, as did all darwin x64 shards on the earlier builds of this diff. The new character-device tests use their own per-test server and are not in that describe block.
  • test-worker-message-port-transfer-terminate.js (debian asan) and test-net-connect-memleak.js (alpine) are pre-existing main failures, reported separately.
  • The remaining entries are single-attempt flakes that passed on retry.

Build 73338 was red only on two build-cpp lanes that failed to download the WebKit prebuilt tarball; that resolved on the next build.

Ready for review.

@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 on this pass, but this touches shared low-level I/O (PosixBufferedReader::start, used by shell/subprocess/file-response readers) and changes HTTP framing for all non-regular file bodies, so it's worth a human look.

What was reviewed:

  • The EPERM/EINVAL fallback now matches both branches of IOWriter::__start; my earlier macOS comment is addressed.
  • close_impl(.., false) on the failed-registration FilePoll deinits the poll allocation without closing the underlying fd before it's restored to PollOrFd::Fd(fd).
  • The end_without_body()end(b"") swap in finish() is guarded by !RESPONSE_DONE, so the streamed-EOF path (which already end()s in on_read_chunk) is unaffected; only immediately-EOF non-regular fds hit it.
  • Candidate "non-regular body capped at 0 if JS read .size first" was examined and ruled out.
Extended reasoning...

Overview

Four files: PosixBufferedReader::start gains an EPERM/EINVAL recovery path that drops a never-registered FilePoll and continues on the non-pollable read loop (mirroring the existing IOWriter::__start writer-side fallback); RequestContext::do_sendfile stops writing Content-Length for non-regular files and forwards an explicit .slice() length as the stream cap; FileResponseStream::finish switches end_without_body()end(b"") so an immediately-EOF stream with no Content-Length still emits valid framing; two new tests cover /dev/null and /dev/zero.slice().

Security risks

None identified. The change widens what fds the reader will attempt to read on the non-pollable path, but only for fds the caller already opened and handed in; no new user-controlled input reaches a parser or allocation size.

Level of scrutiny

High. PosixBufferedReader::start is shared by subprocess stdout/stderr, shell pipe readers, and HTTP file responses — a regression here would affect all of them. The set_needs_content_length(is_regular) change also alters framing for FIFO/socket bodies that previously (incorrectly) wrote Content-Length: 0; that's a behavior change for a whole class of responses, not just /dev/null. The register_polltry_register_poll refactor is behavior-preserving for existing callers (the public register_poll still dispatches on_reader_error on failure), but a maintainer should confirm the enable_keeping_process_alive counter incremented before the failed register_with_fd is correctly balanced by deinit_force_unregister on the fallback path.

Other factors

My prior review flagged the missing macOS EINVAL branch; c45a338 addressed it and the current diff matches both IOWriter::__start arms. CI is reported green on Linux/macOS/Windows including the existing FIFO backpressure test in the same file. The bug-hunting system raised and refuted a ".size read before serving caps at 0" concern. Given the reach of PosixBufferedReader and the framing change, deferring rather than approving.

Jarred-Sumner added a commit that referenced this pull request Aug 4, 2026
…nt-Length: 0 (#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: #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>
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