Skip to content

Bun.file().slice(): keep the size unknown for a slice with no end - #45859

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/14da5756/file-slice-open-end-size
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/14da5756/file-slice-open-end-size

Conversation

@robobun

@robobun robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #1311

Problem

  • Bun.file(p).slice(10).size is 4503599627370485 for a 16-byte file that nothing has stat'd. text() still gives the 6 bytes.
  • Blob::get_slice (src/runtime/webcore/Blob.rs) takes self.size as the default end. For such a file that is MAX_SIZE ("not known"), and get_slice_from stores end - start as a length.

Fix

Background

  • A Blob is a window (offset, size) on a store. A Bun.file() store holds a path and one cached stat, shared with its slices.
  • resolve_size() writes the size from that stat into the Blob that asks. Reads then stop there.
  • Rejected: a stat in slice() (the types promise no I/O) and a range check in the .size getter (a hot path).

Downsides

Notes

Reproduction

import fs from "node:fs";
fs.writeFileSync("/tmp/f.txt", "0123456789abcdef"); // 16 bytes
const s = Bun.file("/tmp/f.txt").slice(10);
console.log(s.size);         // 4503599627370485 (MAX_SIZE - 10), expected 6
console.log(await s.text()); // "abcdef"
console.log(s.size);         // still 4503599627370485
new Uint8Array(s.size);      // RangeError: Out of memory

The same output on 1.3.13, 1.4.2 and a release build of main (017b243). A slice of a handle that was stat'd before (f.size; f.slice(10).size) already gives 6. #1311 reported this in 2022 (2251799813685215 for slice(32) then). It was closed in 2023. Its repro reads blob.size before the slice, which now hides the bug.

What changes

Release builds of main (017b243) and of this branch, Linux x64, 16-byte file. MAX is MAX_SIZE (2^52 - 1).

expression (nothing stat'd the file) main this PR
slice(10).size, also for Bun.file(fd), Bun.file(url), fs.openAsBlob(p), a BuildArtifact on disk MAX - 10 6
slice(10).slice(2).size MAX - 12 4
slice(10, Infinity), slice(10, undefined), slice(10, "text/plain") .size MAX - 10 6
slice(16).size, slice(20).size MAX - 16, MAX - 20 0, 0
.size after text(), bytes(), stream() or exists() on the same slice MAX - 10 6
structuredClone(slice).size, deserialize(serialize(slice)).size (bun:jsc, node:v8) MAX - 10 6
size field on the wire (u64, little endian) f5ffffffffff0f00 ffffffffffff0f00
Response.blob(), Request.blob(), new Blob([slice]), new File([slice]), formData.get() .size MAX - 10 6
expect(slice).toHaveLength(6) fails (received 1.79e308) passes
Bun.file(missing).slice(10).size MAX - 10 0
Bun.stdin.slice(10).size with a pipe MAX - 10 Infinity
Bun.serve HEAD of new Response(Bun.file(fifo).slice(10)): Content-Length 4503599627370485 0, as the whole FIFO

structuredClone(Bun.file(p).slice(10)).size was 6 on 1.3.13. It is MAX - 10 since the size field went on the wire (1.4.2 has it).

Not changed: slice(), slice(0), slice(10, 12), every slice of an in-memory Blob, every slice of a handle that was stat'd before, every S3 slice. The bytes that text(), bytes(), stream(), fetch uploads and Bun.serve GET send for a slice that nothing asked are the same as on main.

Why this is a draft

.size of an open-ended slice was a field read. It now goes through resolve_size(), as the whole Bun.file() does. resolve_size() writes the size of the cached stat (or 0 after a failed stat) into the Blob that asks, and every later read of that Blob stops at that size. On main an open-ended slice holds MAX_SIZE - n, so nothing resolves it and its reads run to the end of the file. The whole handle has this problem today (#4930, #23902). This PR alone gives it to open-ended slices.

f = Bun.file(p) over 0123456789abcdef, s = f.slice(10), nothing stat'd. Main and this PR are release builds. The two right columns are debug builds of this branch with the named PRs merged in.

order main this PR alone with #39763 with #43897 and #43910
s.size MAX - 10 6 6 6
file missing: s.size or await s.exists(), create the file, s.text() abcdef "" abcdef abcdef
file empty: s.size, write 16 bytes, s.text() abcdef "" "" abcdef
s.size or await s.exists(), append XYZ, s.text() abcdefXYZ abcdef abcdef abcdefXYZ
tail loop on one handle, 4 rounds: t = f.slice(off); t.size; await t.text(), append every append "" after round 1 "" after round 1 every append
/proc/version: t = f.slice(6); t.size, then t.text() or f.text() the bytes "" "" the bytes
3-byte destination: d = Bun.file(dest).slice(2); d.size; await Bun.write(d, Bun.file(src)) 14 bytes written 0 written, file left empty 0 written, file left empty 14 bytes written
the same tail loop, but it reads only if (t.size > 0) every append nothing after round 1 nothing after round 1 nothing after round 1
s.size, append XYZ, f.size 19 16 16 16
s.size, unlink, await f.exists() false true true true
Bun.inspect(new Response(s)), append XYZ, f.size Response {, 19 Response (6 bytes) {, 16 the same the same
multipart body of a FormData with s, append XYZ, f.size 19 16 16 16

Question for a maintainer: may a .size read, a print or a multipart body of an open-ended slice fill the stat that its parent keeps? #44504 removed that effect from clone(). If the answer is no, this rule needs a size for file views that writes nothing to the store.

Left for other changes

  • A negative index on a handle that nothing stat'd (slice(-4), slice(0, -4), slice(10, -2)). These keep the results of main here. Bun.file().slice(): count a negative index back from the file's real end #41257 counts them from the real end. One wrong number moves: slice(10).slice(0, -2).size is MAX - 2 (was MAX - 12).
  • A start that the file system cannot seek to (2^45 on ext4). The reader ignores the failed seek and reads from byte 0, on main and here. .size of such a slice is now 0. slice(2 ** 52 - 2).text() returns the whole file as the other far starts do (main: 1 byte).
  • A bounded end past the end of the file: slice(10, 1000).size stays 990.
  • S3. resolve_size() stores 0 for an S3 Blob of unknown size (s3: leave the size of an S3 blob unknown in resolve_size #43934), so an S3 slice of unknown size would read 1 byte after a matcher asked for its length. slice() leaves S3 as on main: .size stays MAX - 10 and the Range header stays bytes=10-4503599627370494.
  • A payload that a released build wrote with serialize() carries MAX_SIZE - n. The receiver keeps it as a length, as on main (checked with a payload from 1.4.2).
  • Bun.write(dest, slice) copies the whole source file (Honor Bun.file().slice() bounds in Bun.write #41209).
  • On macOS fstat of a pipe reports the buffered bytes, and resolve_file_stat takes a size above 0 as a length. So .size of an open-ended pipe slice can be a number there. Not run: this comes from reading the code.

Cost, measured

Release builds of the merge base (017b243) and of this branch, Linux x64. perf, valgrind, strace and bloaty are not in the container. The counts come from gdb: single-step from the entry of the function to its return (callees included), catch syscall, and the callees seen while stepping.

  • .size getter, known size (WebCore::BlobPrototype__sizeGetterWrap): 38 -> 38 instructions for an in-memory Blob, a stat'd Bun.file(), a slice of a stat'd file and slice(0, 5) of an un-stat'd file. The BuildArtifact getter: 38 -> 38. objdump of BlobPrototype__sizeGetterWrap, BuildArtifactPrototype__sizeGetterWrap and S3ClientPrototype__size: 0 lines differ.
  • .size of an open-ended slice of an un-stat'd file: first read 39 -> 488 instructions and 0 -> 1 stat syscalls. The first .size of a whole Bun.file() costs 488 and 1 on both builds. Store already stat'd: 39 -> 88 instructions, 0 syscalls. Later reads: 39 -> 38.
  • slice() (WebCore::BlobPrototype__sliceCallback, calls 200 to 202 of a loop): in-memory open-ended 391 -> 397, in-memory bounded 399 -> 406, stat'd file 405 -> 411, un-stat'd bounded 413 -> 420 instructions. Each has 1 more executed conditional branch (43 -> 44 for the first). Un-stat'd open-ended, the path of the fix: 405 -> 417 instructions, 46 -> 50 branches.
  • allocations per slice(): 1 -> 1 mi_malloc of 104 bytes (the Blob), 1 -> 1 JS cell (heapStats over 100000 slices). size_of::<Blob>() stays 104: no field is added.
  • stat syscalls (N = 50 minus N = 0): 50 slices never read 0 -> 0. 50 reads of .size on one slice 0 -> 1. .size of 50 slices of one handle 0 -> 1. First .size of 50 new handles 0 -> 50 (50 whole handles: 50 on both). f.size then 50 slices 1 -> 1. A multipart body with one open-ended slice 0 -> 2 (with a whole file: 2 on both).
  • release binary: get_slice 1548 -> 1713 bytes, get_slice_from 647 -> 651 bytes. No other symbol changes size. The .text section (0x4d993e5 bytes) and the stripped file (106911456 bytes) have the same size on both builds, because the padding between functions takes the 169 bytes.

Serialization version

SERIALIZATION_VERSION stays 4. The layout is the same. Version 4 already defines MAX_SIZE in the size field as "unknown", and the receiver then stats the file. An open-ended slice now writes that value.

Other open PRs on these lines

Each merge below was built and run here (debug builds of this branch with the PR merged in).

Suites

Debug build of this branch, no failure: bun-file, bun-serve-file, blob, blob-cow, body, body-clone, structured-clone-blob-file, bun-file-read, bun-file-fd-read, bun-file-exists, fetch-file-upload, inspect, FormData, websocket-blob.

On a release build of main, 10 of the 13 new tests in bun-file.test.ts fail. The other 3 are guards: slice(-n), the pipe read, S3. On a debug build of main's src/, the new HEAD test fails with Content-Length: 4503599627370485.

Timeouts of the debug build in this container, none of them in a test with an open-ended slice: serve-file-slice-read-error (also on a debug build of main), one GC test of bun-write, three long tests of bun-build-api, and two concurrent tests of bun-stdin-slice in 1 run of 6 (the same with main's binary).

Not run: Windows and macOS. The source change has no platform-gated line. The pipe read test skips Windows, as the test it mirrors does. The pipe size test runs there and compares the slice with the whole pipe.


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/util/bun-file.test.ts, test/js/bun/http/bun-serve-file.test.ts

slice(start) of a Bun.file() that nothing has stat'd subtracted the start
from MAX_SIZE, the marker for a size that is not known, and stored the
result as the length of the slice. `.size` was 4503599627370485 for
slice(10).

get_slice now gives get_slice_from the size of the slice. For a slice of a
file or a pipe that runs to an unknown end, that size stays MAX_SIZE. The
readers that already test for MAX_SIZE then handle the slice as they
handle the whole file: `.size` stats on the first read, structuredClone
writes an unknown size, HEAD of a FIFO slice answers as the whole FIFO.

A start that counts back from the end and an S3 slice keep the length
they had.
@robobun

robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:38 AM PT - Oct 18th, 2026

❌ @robobun, your commit f2b6673 has 2 failures in Build #128019 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 45859

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

bun-45859 --bun

@robobun

robobun commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator Author

Status: draft. This rule must land in one unit with #43897 and #43910. The description says why (Downsides, and "Why this is a draft" in Notes).

Reproduction on 1.3.13, 1.4.2 and a release build of main (017b243), Linux x64:

printf '0123456789abcdef' > /tmp/f.txt
bun -e 'console.log(Bun.file("/tmp/f.txt").slice(10).size)'
# 1.3.13, 1.4.2, main: 4503599627370485. This branch: 6.

Fail-before and pass-after: bun bd test test/js/bun/util/bun-file.test.ts test/js/bun/http/bun-serve-file.test.ts. With main's src/, 10 tests of bun-file.test.ts and the new HEAD test fail. With this branch, both files pass.

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.

1 participant