fetch: send an empty Bun.file() slice without sendfile and close its file
Problem
fetch()with the bodyBun.file(path).slice(0, 0)leaks one file descriptor per call (file of 32 KiB or more, plainhttp:). At a limit of 4096 descriptors, upload 4,087 rejects withEMFILE.HTTPRequestBody::detach(src/runtime/webcore/fetch/FetchTasklet.rs:236) closes the file onlyif sendfile.offset.max(sendfile.remain) > 0. An empty window (offset and length of the slice) at offset 0 has both at 0.- That window also calls
sendfile(2)with length 0. macOS and FreeBSD read it as "until end of file" (kernel source, not run).
Fix
detachcloses the descriptor when it is valid, then setsFd::INVALID.- For an empty window,
fetch_impl(src/runtime/webcore/fetch.rs:1630) closes the file at once and sends the empty body, with the same request head. - Verified:
test/js/bun/http/fetch-file-upload.test.ts(10 new cases, 6 fail on main). Alsofetch-redirect,fetch-args,fetch-compress,body-clone. - Self-reviewed: 14 concerns raised, 13 addressed. Not done: a tracking issue for the descriptor lifetime of a body with bytes (see Notes).
Background
- Over plain http, a
Bun.file()body of 32 KiB or more usessendfile(2).FetchTasklet(one perfetch()) owns the file descriptor. - Considered the
detachchange alone: the zero-lengthsendfile(2)stays. Considered an owning descriptor type: it guards only exits that nothing reaches today.
Downsides
- A sendfile upload with bytes runs 2 more instructions in
fetch_impl(1,113 to 1,115), 3 fewer at teardown. Release.textis unchanged. - A PUT with an empty file slice on a stale keep-alive socket is now retried. It rejected with
ECONNRESETbefore. SendFile::writestill has no guard for length 0.fetch_implis its only producer.
Notes
No user reported this. It was found by reading the code.
Repro (Linux, no network)
import { readdirSync, writeFileSync, unlinkSync } from "node:fs";
const path = `/tmp/sf_${process.pid}.bin`;
writeFileSync(path, Buffer.alloc(64 * 1024, "a"));
const server = Bun.serve({ port: 0, async fetch(req) { return new Response(String((await req.arrayBuffer()).byteLength)); } });
const fds = () => readdirSync("/proc/self/fd").length;
async function run(label, mk) {
for (let i = 0; i < 5; i++) await (await fetch(server.url, { method: "POST", body: mk() })).text();
const before = fds();
for (let i = 0; i < 50; i++) await (await fetch(server.url, { method: "POST", body: mk() })).text();
console.log(label, "fd delta over 50 calls:", fds() - before);
}
await run("whole file", () => Bun.file(path));
await run("slice(0, 40000)", () => Bun.file(path).slice(0, 40000));
await run("slice(0, 0)", () => Bun.file(path).slice(0, 0));
await run("slice(100, 100)", () => Bun.file(path).slice(100, 100));
server.stop(true);
unlinkSync(path);
Main prints 0, 0, 50, 0. This branch prints 0, 0, 0, 0.
Which inputs leak on main. Every window with offset 0 and size 0 on a file-backed Blob:
slice(0, 0),slice(undefined, 0),Bun.file(fd).slice(0, 0), aRequestbody, and an empty slice of a directory of 32 KiB or more.- A whole-file handle whose size was read as 0 before the file was written. Example:
if (!(await f.exists())) await Bun.write(f, data), thenfetch(url, { body: f }). It leaks 50 of 50 on main and 0 here. Its upload is still empty on both builds. That stale size is the state of #4930, and #39763 works on it. - The leak does not need a response. A failed connection and an abort right after the call leak too.
slice(100, 100)does not leak, because its offset is not 0.
How long until EMFILE. Bun raises the soft descriptor limit to the hard limit. At a hard limit of 4096, main completes 4,086 uploads of slice(0, 0) and upload 4,087 rejects. An unrelated openSync then fails too. With ulimit -n 256, upload 247 rejects. This branch completes 6,000 in both cases.
The gate is old. It is in the Zig code of #3125 (May 2023): if (@max(this.Sendfile.offset, this.Sendfile.remain) > 0) _ = close(this.Sendfile.fd);. The Rust port kept it.
Test proof (debug builds, the 10 new cases)
| build | result |
|---|---|
| main | 6 fail: five leak cases with an empty window (fd delta 16 of 16) and the in-flight count |
this branch without the exit in fetch_impl (only the detach change) | 1 fails: the in-flight count |
the exit without its close | 7 fail: six leak cases and the in-flight count |
| the exit tests the Blob size, not the clamped window | 1 fails: the in-flight count, on the past-the-end row |
detach without its close | 1 fails: the slice(0, 40000) control |
| this branch | 10 pass. 200 reruns on a release build and 15 on the debug build had no failure |
With both changes, no empty window reaches detach. The slice(0, 40000) case is the test of the detach change. It passes on main too, so it is a control.
The two "sends nothing after the request head" cases pass on Linux with and without the fix. Linux sendfile(2) with a count of 0 sends nothing. They are for macOS. XNU sendfile() sends "until EOF if *nbytes == 0", and SendFile::write passes remain as that length (src/http/SendFile.rs:102, FreeBSD at line 79). I did not run a macOS or FreeBSD host. The macOS CI lanes are the first run of these cases there.
Numbers (main is e655c58032, release builds unless noted)
- fd delta over 50 uploads (whole / slice(0,40000) / slice(0,0) / slice(100,100)): main 0 / 0 / 50 / 0, PR 0 / 0 / 0 / 0. Refused connection: main 50, PR 0. Abort right after the call: main leaves 50 descriptors on the body file, PR 0.
- Syscalls on the body file per empty-window upload (openat / fstat / read / lseek / sendfile / close): main slice(0,0) 1 / 1 / 0 / 0 / 1 / 0, PR 1 / 1 / 0 / 0 / 0 / 1. slice(100,100): main 1 / 1 / 0 / 0 / 1 / 1, PR 1 / 1 / 0 / 0 / 0 / 1. Windows with bytes and files under 32 KiB: the same on both. (gdb
catch syscall, descriptor resolved to the file, slope over 10 and 30 requests.) - Instructions executed per call (gdb
nexti, 5 uploads each, all equal):fetch_implfor a sendfile upload with bytes 1,113 to 1,115, for an empty window 1,088 to 1,081, for a string body 986 to 986, for no body 954 to 954. Tasklet teardown: sendfile with bytes 227 to 224, string body 201 to 201. read_filecalls per 50 empty-window uploads of a 64 KiB file: 0 (1 KiB control: 50). The new exit callsclose(2)and nothing else, so it adds no allocation.- Descriptors on the body file held by 20 parked uploads (slice(0,0) / slice(100,100) / slice(0,40000)): main 20 / 20 / 20, PR 0 / 0 / 20.
[sys] closelines on the body file per request, debug builds (whole / slice(0,40000) / slice(0,0) / slice(100,100)): main 1 / 1 / 0 / 1, PR 1 / 1 / 1 / 1. No EBADF assertion.- Bytes after the
Content-Length: 0head (slice(0,0) / slice(100,100)): Linux 0 / 0 on both. The request heads for POST, PUT and GET are byte-identical on main and on the PR. - Size:
.text65,658,709 B on both.fetch_impl::<false>23,718 to 23,797 B,detach352 to 351 B, the tasklet drop glue 3,589 to 3,557 B (size -A,nm -S). - PUT with an empty slice on a stale keep-alive socket, rejected of 20: main 10 (a 1 KiB file on main: 0), PR 0. POST: 10 on both.
Also run. bun run rust:check-all: 12 targets, 0 failed. On the debug build: fetch-redirect.test.ts 30 pass, fetch-args.test.ts 85 pass, fetch-compress.test.ts 32 pass, body-clone.test.ts 100 pass. In fetch.test.ts the file-body, upload and Content-Length cases pass. Its other failures in my container are 5 s timeouts of (with gc) cases in the debug build, three cases that need the network, and the permission cases, which fail for root.
Kept as on main. The position of a caller's Bun.file(fd) descriptor does not move. An empty slice of a directory of 32 KiB or more still resolves with an empty body.
Not in this PR
- The descriptor lifetime of a file body with bytes.
fetch()opens the file before the request is queued, and the tasklet closes it when the response ends. Concurrent uploads hold one descriptor each. Withulimit -n 512, 1,000 concurrent uploads of a 64 KiB file complete 9 to 123 on main and 19 to 187 here (three runs each). The rest reject withEMFILE. ABufferorBun.file(p).stream()body completes 1,000. This PR does not change that. - The sendfile descriptor has no owning type between
fetch_impland the tasklet. No exit in between is reachable with a sendfile body today. SendFile::writehas no guard for a length of 0. #38406 and #41494 re-arm or reuse a sendfile body and must keepremain > 0.- A
slice(a, a)witha > 0of a large directory still reachessendfile(2)and rejects withEINVAL, as on main. - #41905 edits the same block of
fetch.rs. The overlap is one hunk.
no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/http/fetch-file-upload.test.ts