Skip to content

Bun.write: do not send a small write to the thread pool after a stat of the Bun.file() handle - #44056

Open
robobun wants to merge 2 commits into
mainfrom
robobun/dd856c59/bun-write-statted-handle-fast-path
Open

robobun wants to merge 2 commits into
mainfrom
robobun/dd856c59/bun-write-statted-handle-fast-path

Conversation

@robobun

@robobun robobun commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • f.size; Bun.write(f, "NEW"); process.exit(0) leaves the old bytes or an empty file (release build, 10 of 10). Without f.size the file holds NEW.
  • Cause: after the handle caches a regular file mode, the fast_path_ok gate in write_file_internal (src/runtime/webcore/Blob.rs:4679) sends a small write to the thread pool. process.exit() does not wait for it.

Fix

  • The gate no longer reads the cached mode. A string or typed array under 256 KiB, to a handle at offset 0, is written on the calling thread, like a path string.
  • Correct because a stat no longer selects the write path. Pool writes are still lost at process.exit().
  • Verified: 21 new tests in test/js/bun/io/bun-write.test.js (main fails 16), blob.test.ts, filesink.test.ts, image.test.ts.
  • Self-reviewed: 8 concerns raised, 8 addressed, except an it.todo for a Blob source that Bun.write: copy a file to an O_APPEND destination fd with the read/write loop #41685 owns.

Background

Downsides

  • After a stat, 100 small writes hold the JS thread for 651 µs on overlayfs (was 128), 282 µs on tmpfs (was 124).
  • Like a fresh handle, a statted handle now keeps the old bytes after a write that fails partway (EFBIG) and fulfils an empty write to a read-only fd (was EINVAL). Notes lists each case.
  • Per call: a path string runs 1 to 2 more instructions (591, was 590), a statted pool payload 53 to 90.
Notes

What selects the path on main

A use of the handle that runs resolve_file_stat stores st_mode on the file store that each view of the handle shares. After that, each write of a string or a typed array under 256 KiB to that handle goes to the thread pool. Measured with Bun.write(f, "NEW"); process.exit(0) after the use, release build of main, 10 runs per line:

first use of the handle file is NEW old bytes empty
none, await f.text(), await f.stat() 10 0 0
f.size, await f.exists(), f.lastModified 0 10 0
structuredClone(f), new Blob([f]).size, new Response(f).clone() 0 10 0
(await new Response(f).blob()).size, one read of new Response(f).body, a FormData body that holds f 0 10 0
await new Response(f).clone().text() 0 2 8

With this PR each line is 10, 0, 0. The empty file is an observed outcome. Its cause is read from the code and not traced: the pool path opens with O_TRUNC.

Bun.peek.status(Bun.write(f, "NEW")) is pending for each losing line and fulfilled for the others, on a debug build too. On a debug build the exit is slow and the bytes land in 130 of 130 runs on main. So each test asserts the promise state at return and the bytes.

One process.stdout.write() between the write and the exit hides the loss on a release build. The children in the exit tests do no I/O there and report through the exit code.

Same gate, other symptoms on main

Each row of this table has a test.

case main this PR
5 strings with no await to Bun.stdout and to Bun.stderr on a regular file, then process.exit() lost, or out of call order in the file, in call order
5 strings with no await to a statted Bun.file(fd) C0 C2 C1 C4 C3 C0 C1 C2 C3 C4
awaited 2000-byte string to a statted fd opened with "a", 10-byte file 4000 bytes, 1990 NUL 2010 bytes, 0 NUL
2000-byte string under ulimit -f 1, 3000-byte file, statted handle (rejects EFBIG on both) 512 new bytes 512 new bytes, then 2488 old bytes

These rows are measured by hand and have no test. The first one has the same mechanism as the Bun.stderr row above.

case main this PR
the --inspect banner with 2> file, 12 runs (src/js/internal/debugger.ts:235 calls Bun.write(Bun.stderr, ...) with no await) in order 1, out of order 10, empty 1 in order 12
Bun.write(Bun.stdout, "hello\n"); process.exit(0) with > out.txt, 20 runs with the line in the file 0 20
2000-byte string under ulimit -f 0, 3000-byte file, statted handle empty file the old file
relative path, stat, small string with no await, then process.chdir(): rounds of 30 where the file lands in the new directory 6 0

In each row the value of this PR is the value that a handle with no stat has on main.

Bun.file(path) and Bun.file(fd) never reach the stdio branch that #37128 adds. When #37128 lands, the rows for Bun.stdout and Bun.stderr belong to it.

Not changed

Routes to the pool that remain for each handle: a payload of 256 KiB or more, a Blob, Response, Request or Archive source, a destination with an offset, and the fallback after ENOENT or EAGAIN from the inline path. For these routes:

  • process.exit() right after the write loses it. A 300 KiB string and a Blob source to a fresh handle, a 300 KiB string to a path string and to a statted handle: written in 0 of 20 runs each, on main and here.
  • Writes with no await to one fd can land out of call order (Blob sources: 4 of 10 rounds on main, 10 of 10 here, a race on both). Call order holds among inline writes.
  • A 2000-byte Blob or Response to an fd opened with "a" gives 4000 bytes with 1990 NUL, and a 300 KiB string gives 614,400 bytes. Bun.write: copy a file to an O_APPEND destination fd with the read/write loop #41685 owns the fallocate in the pool path.
  • A relative path resolves when the pool runs the write.

The gate still reads the offset of the view. slice() resolves a negative start against the cached size, so f.slice(-20) of a 10-byte file has offset 0 only after a stat. For that destination a stat still selects the path: fulfilled after f.size, pending with no stat (main: pending for both). The pool path does not use the offset, so the bytes are the same on each path. The offset clause dates from #7470. Its removal is a separate decision for a maintainer, and this PR does not make it.

Other code that reads the cached mode:

site what it selects open PR
read_file.rs:757 an empty result for a regular file with st_size 0 #43871, #43910
write_file.rs:427 could_block for an fd destination #35953
Blob.rs:1275 the result of exists() #39763
read_file.rs:551 recv for a socket none
copy_file.rs:815, copy_file.rs:830 the kernel copy for a regular file, the FIFO branch none

The cached size as a byte budget (Bun.write(dest, Bun.file(src)) writes 10 bytes into a 10-byte file after dest.size) is #4930. #43910 owns it.

A pipe or a terminal destination was inline and stays inline. Two tests pin that. Windows: the block is under #[cfg(not(windows))] and each write goes through libuv. Read from the code, not measured. Image.write to a statted Bun.file() calls write_file_internal (src/runtime/image/Image.rs:1909) and now writes inline under 256 KiB, as it does for a path string.

Behaviour of a statted handle that moves to the behaviour of a fresh handle

observable main, statted this PR, statted fresh handle, main and this PR
promise state at return pending fulfilled fulfilled
f.lastModified read between the call and the await the old mtime, and it stays cached the new mtime the new mtime
openat flags O_WRONLY O_CREAT O_TRUNC O_NONBLOCK O_CLOEXEC the same without O_TRUNC, then ftruncate as this PR
fallocate for a payload over 1024 bytes 1 per write 0 0
a write that fails partway only the written bytes the written bytes, then the old bytes as this PR
empty write to a read-only fd rejects EINVAL fulfils 0 fulfils 0
mode of a file that the write creates, umask 022 0o644 0o644 0o644
the same, umask 0 0o666 0o664 0o664
empty payload with { mode: 0o600 }, file removed after the stat, umask 022 0o600 0o644 0o644
100,000-byte string to a path that is a regular file at the stat and a FIFO with a reader at the write the whole payload, once the first 8,192 bytes, then the whole payload as this PR

The mode option has no effect on a string or a typed array that is not empty, on main and here (#32885). No value of a fresh handle changed.

The last row is a race with the reader. The inline path writes until the pipe is full, gets EAGAIN, and the pool path writes the payload again from the start. #35953 and #36025 own that path. A ledger check of this PR found the case.

Measurements

Release builds of main (29d9638) and of this PR on that base, Linux x64, JIT off for the instruction counts. The branch is now rebased on a4f1429. Between the two commits main changed no POSIX code of the gate or of the two inline writers, and the numbers are not measured again. Tools: gdb (stepi, breakpoint hit counts), a ptrace syscall counter written for this, size, nm. strace, perf, valgrind, bloaty and hyperfine are not on the test machine.

what main this PR
instructions per call of write_file_internal, path string, string payload 590 591
path string, Uint8Array 383 385
fresh handle, string 632 629
fresh handle, Uint8Array 425 422
statted handle, string 1142 (the hand-off only) 629 (with the write)
statted handle, Uint8Array 1058 (the hand-off only) 422 (with the write)
statted handle, 256 KiB Uint8Array, instructions before the hand-off 341 431
statted handle, 256 KiB string 375 458
statted handle, Blob source 328 381
the same three for a fresh handle 438, 465, 388 431, 458, 381
stripped release binary, bytes 80,995,912 80,995,912
.text, bytes (size -A) 58,301,116 58,301,116
write_file_internal, bytes (nm -S) 19,220 19,007
WriteFile jobs for 5 small statted writes (debug build) 5 0
allocator calls per awaited small write, statted handle 6.1 1.0
allocator calls, fresh handle / path string 1.0 / 0 1.0 / 0
syscalls per awaited 100-byte write, statted path handle 10.9 4.0
of those on the JS thread 3.2 (futex, epoll_pwait2) 4.0 (openat, write, ftruncate, close)
syscalls per write, statted fd handle 7.5 1.0
syscalls per write, fresh handle and path string 4.0 4.0
Bun Pool threads after 1 statted write / after 100 2 / 12 0 / 0
VmSize after those 100 writes, kB 7,292,232 6,456,600

The path string rows grow by 1 and 2 instructions because the compiler allocates the registers of the smaller function in another way. The source of that path did not change. The counts before the hand-off leave out each callee that runs over 3000 instructions (the copy of the payload), the same callees on both builds.

JS-thread time for a burst of 100 writes with no await to a statted handle. Interleaved runs of the two builds, 10 rounds, minimum. The same measurement on a fresh handle differs by at most 2.4 % between the builds, which is the noise floor.

filesystem, payload in the 100 calls, main this PR until all settle, main this PR
overlayfs, 100 B 128 µs 651 µs 22,926 µs 664 µs
overlayfs, 262143 B 11,627 µs 1,568 µs 14,398 µs 1,583 µs
tmpfs, 100 B 124 µs 282 µs 690 µs 294 µs
tmpfs, 262143 B 11,196 µs 1,379 µs 13,308 µs 1,392 µs

For the large payload main spends more time in the calls, because the pool path copies each payload into a new store on the JS thread.

New tests: 5 child spawns and 144 in-process cells. Median of 5 runs of the new block on a debug build: 7.2 s, at a host load average of 260 to 330.

Other designs

  • Invert the clause, so that a known pipe goes to the pool. Pipes and terminals have been inline since the gate exists, and Bun.write(Bun.stdout, x) | cat keeps its order because of that.
  • Change each place that stores the mode. Other code reads the stored mode (table above), and the list of uses that store it grows with unrelated changes.
  • Make process.exit() wait for pool writes. A write to a full pipe that nothing drains then blocks the exit. Node does not wait either.
  • Leave it and tell callers to await. Awaited code still gets the NUL bytes on an append fd and the changed order on an fd.

Related PRs

PR relation
#35949 (closed) The same predicate. Its author closed it as superseded by #37128, with no maintainer review. It is back because #37128 does not touch the gate and does not cover a Bun.file() handle. Its tests covered Bun.stdout and Bun.stderr only
#37128 Sends Bun.write(Bun.stdout | Bun.stderr) through the stdio sink, before this gate
#41685 Owns the fallocate on an append fd in the pool path. Without its fix, its test appends a string above the preallocate threshold to stdout redirected with >> fails on main and passes with this PR, because the string no longer reaches the pool. A Blob source keeps that test on the pool path
#32890 Owns the empty write that truncates an fd destination (unchanged here: Bun.write(Bun.stdout, "") empties the file that stdout points to). Without its fix, its test does not drop concurrent in-flight writes when stdout is a file fails on main and passes with this PR, for the same reason
#42618 Owns the result of the final ftruncate in the inline writers
#43910 Lists this case under "Unchanged from main". It appends tests at the end of the same test file, this PR adds its block before them
#37792 Owns the test of this file that goes over its time limit and slows its siblings (see Suites)
#35953 Owns the prefix that the pool writes again after EAGAIN from the inline path, and could_block. Edits the same block and not the clause
#36025 Owns a FIFO that a path names. Edits the same block and not the clause
#32885 Owns the mode option. Edits the same block and not the clause

Suites

The test machine had a host load average of 110 to 400 during these runs.

Release build of this PR, Linux x64: the whole bun-write.test.js gives 103 pass, 3 skip, 0 fail in 4 of 4 runs at a load average of 150. In 4 other runs at a load average over 250, 17 to 20 tests went over their 5 s limit. The test file of main did the same in 1 of 4 runs (20 tests).

Debug build of this PR: the 21 new tests pass in 10 of 10 runs. The whole bun-write.test.js gives 101 pass and 5 fail in 4 of 5 runs (the other run: 100 and 6). The test file of main gives 80 pass and 5 fail in 3 of 3 runs. Each failure is a test that goes over its 5 s limit, in most runs these 5: should work when copyFileRange is not available > on large files, Bun.write(Bun.stdout, Bun.stdin) copies the whole pipe, and the three tests of does not truncate the fd. blob.test.ts 110 pass. filesink.test.ts 69. image.test.ts 103 pass, 5 skip. bun-file.test.ts 6. bun-file-exists.test.js 1. bun-file-read.test.ts 5. bun-file-fd-read.test.ts 3. vm-thread-door.test.ts 46. comment-cop.test.ts 2. bun-write-leak.test.ts fails on a local debug build with and without this change (320 MB at the first iteration against a limit of 256 MB, because the fixture detects ASAN by the binary name bun-asan). It passes on the release build of this PR.

No Windows or macOS machine ran a test for this before the push. In CI the new tests pass on each lane that runs them: Linux x64 and aarch64 (glibc, musl, ASAN) and macOS x64 and aarch64. The new tests skip Windows.


no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/io/bun-write.test.js

@robobun

robobun commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The branch is rebased on main (a4f1429). The change is green in CI. The two red tests do not come from this change.

How to reproduce, with a release build of main on Linux x64:

printf '0123456789' > /tmp/e1.txt
bun -e "const f = Bun.file('/tmp/e1.txt'); f.size; Bun.write(f, 'NEW'); process.exit(0)"
cat /tmp/e1.txt

The file holds 0123456789 in 10 of 10 runs. Without f.size it holds NEW in 10 of 10 runs. With this PR it holds NEW in both cases.

A debug build exits slowly, so the bytes usually land there. Bun.peek.status(Bun.write(f, 'NEW')) shows the difference on each build: pending after f.size on main, fulfilled with this PR.

CI, build 121354 at 1890c97: 179 of 181 jobs pass. test/js/bun/io/bun-write.test.js runs in a job that passes on each POSIX lane: Linux x64 and aarch64 (glibc, musl, ASAN) and macOS x64 and aarch64. The two jobs that fail:

  • test/js/bun/spawn/spawn.test.ts on debian 13 x64-asan, the test an idle reader stopped at the highwater mark does not keep the process alive (saturating writer). The child's stderr holds a LeakSanitizer warning (ptrace appears to be blocked). The same test fails on the same lane in 6 of the 10 other PR builds that I looked at from the same day (121351, 121349, 121347, 121346, 121342, 121336).
  • test/js/bun/spawn/spawn-stdio-syscall-error.test.ts on alpine 3.23 x64, the test node:child_process: stdout delivers every byte read before the error. Its fixture reads the stdout of a child and does not call Bun.write. The test passed on that lane in the two earlier builds of this PR (120983, 120998), and it passes 3 of 3 times on a debug build of this head on Linux x64.

Both are reported as breaks outside this PR. A new CI run gives the same result for the first one until it is repaired, so this PR has no retrigger commit.

@coderabbitai

coderabbitai Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: f6d149c2-3ad8-4903-9efc-44e2d947c979

📥 Commits

Reviewing files that changed from the base of the PR and between 578f5c8 and 1890c97.

📒 Files selected for processing (1)
  • src/runtime/webcore/Blob.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 5 remain after this review.


Walkthrough

On non-Windows runtimes, offset-zero regular-file Blob destinations with a nonzero mode now qualify for existing small-input fast writes. Tests cover write completion, size thresholds, ordering, failures, process exit, and output to files, pipes, and terminals.

Changes

Regular-file write completion

Layer / File(s) Summary
Enable the regular-file fast path
src/runtime/webcore/Blob.rs, test/js/bun/io/bun-write.test.js
The fast-path condition includes offset-zero regular-file Blobs with a nonzero mode. Tests cover small writes through Bun.write and f.write after different first-use operations.
Check write boundaries and ordering
test/js/bun/io/bun-write.test.js
Tests check the 256 KiB threshold, append and descriptor write ordering, file-size-limit failures, and writes after varied first-use operations.
Check subprocess and stream output
test/js/bun/io/bun-write.test.js
Subprocess tests check file-backed stdout and stderr, immediate process exit, pipe writes, and terminal writes for completion and output.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 1890c

Small Bun.write calls to a file that was previously statted now complete inline instead of going to the thread pool. This fixes the case where a write followed by an immediate process exit could leave old file contents. No merge-blocking risk was found.

🚥 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 main change: small writes to a statted Bun.file() handle no longer use the thread pool.
Description check ✅ Passed The description thoroughly explains the problem, cause, fix, scope, trade-offs, and verification results. It does not use the exact template headings, but it provides the required change summary and v…

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked whether routing stat'ed fd handles (Bun.stdout/stderr redirected to a file) through the inline path can clobber the file on a non-empty write — it cannot, since write_string_to_file_fast/write_bytes_to_file_fast only arm the ftruncate guard when NEEDS_OPEN or the payload is empty (the empty case is the posted finding). The Windows path is unaffected: the changed gate sits under #[cfg(not(windows))] and libuv handles every write there.

Extended reasoning...

The change removes the regular-file/stat clause from the fast-path gate in write_file_internal in src/runtime/webcore/Blob.rs and adds a ~280-line test block; it touches no auth, crypto, or injection surface. Verified findings are being posted inline (including a fast-path double-write on EAGAIN for mid-size pipe payloads and the empty-write truncate-failure widening), and three further verified findings were withheld, so this is not an approve. The note above records the two adjacent concerns examined and ruled out from the code.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/runtime/webcore/Blob.rs — Callers writing a 64 KiB to 256 KiB string to a non-blocking pipe fd can get the payload's prefix written twice, with the promise still resolving to the full length. The loop at Blob.rs:5194-5207 counts a partial write into written, then on EAGAIN sets needs_async and returns; write_file_internal at Blob.rs:4718 falls through to the pool, which rewrites the whole payload from byte 0 (write_file.rs:456-470). Fix: when any bytes were already written before EAGAIN, either hand the pool the remaining slice, or finish the write inline with a poll; apply the same to write_bytes_to_file_fast at Blob.rs:5278.

    Why this was flagged

    Trigger: Bun.write(Bun.stdout, s) or Bun.write(Bun.file(pipeFd), s) with a non-blocking pipe/socket whose buffer (64 KiB) is smaller than the payload (< 256 KiB). write(2) accepts part, then returns EAGAIN. Blob.rs:5197 has already advanced written; Blob.rs:5205-5207 sets needs_async and returns ZERO without reporting how much landed. Blob.rs:4718 then continues to the pool path, which writes the full bytes_blob (write_file.rs:456-470, do_write_loop), so the consumer receives the first chunk twice. The dismissal argued the newly fast-pathed population (regular files) never returns EAGAIN, which is true, but this PR is the change that touches the routing gate at Blob.rs:4685-4687 and the same commit is the moment the duplicated-prefix mechanism should be fixed rather than left in the path it just widened; the fd-backed stdio population (Bun.stdout when spawned with a pipe) reaches it at production rate. Remedy: pass the remaining slice / written count to the pool task, or drain inline with a writable poll.

    Verification: pre-existing (base already fails by the same route; this PR neither adds nor widens the EAGAIN-capable destinations). Trigger: Bun.write(Bun.file(pipeFd), s) / Bun.write(Bun.stdout, s) where the fd is a non-blocking pipe or socket and s is between the pipe buffer size (~64 KiB) and 256 KiB. Mechanism verified in /home/claude/bun/src/runtime/webcore/Blob.rs: write_string_to_file_fast…

Comment thread test/js/bun/io/bun-write.test.js Outdated
Comment thread test/js/bun/io/bun-write.test.js Outdated
Comment thread src/runtime/webcore/Blob.rs
@robobun

robobun commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:16 PM PT - Sep 28th, 2026

❌ @robobun, your commit 1890c97 has 2 failures in Build #121354 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 44056

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

bun-44056 --bun

@robobun

robobun commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

Reply to the review of 46571be.

The finding outside the diff (a payload of 64 KiB to 256 KiB to a non-blocking pipe fd, prefix written twice after EAGAIN): no change in this PR. The path is the same on main. A pipe, a socket and a terminal took the inline path before this PR, and a regular file does not return EAGAIN, so this PR adds no destination that can reach it. #35953 owns the fix. Its inline writers report the bytes that are already written, and the pool write resumes at that offset.

The three inline comments have a reply each. 578f5c8 changes the two tests: the terminal test fails when the PTY closes with no output and releases the terminal with await using, and the file size limit script no longer has the SIGXFSZ handler.

@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 re-reviewed after 578f5c8 and found no bugs; the two test nits from the earlier pass (terminal exit now rejects sawOutput under await using, SIGXFSZ handler gone) are addressed. A human look is still worthwhile because the fix deletes a gate whose original intent is ambiguous — the upstream clause reads like an inverted "known pipe, avoid blocking the main thread" check, and removing it versus inverting it is a design call with user-visible consequences (inline partial writes on EFBIG, empty writes to read-only fds now fulfil, more JS-thread time per statted small write).

What was reviewed: the widened fast_path_ok at Blob.rs:4685-4687 against both write_string_to_file_fast/write_bytes_to_file_fast variants — the fd (NEEDS_OPEN=false) path was already reachable for un-statted Bun.file(fd) handles, so a statted handle now follows an existing, exercised path rather than a new one; the as_file() calls below still cannot see a Bytes store (callers reject it first, unchanged). Checked the offset != 0, S3, and 256 KiB exclusions remain intact, and that last_modified reset and the EAGAIN/ENOENT needs_async fallback are unaffected. Test matrix asserts promise state plus on-disk bytes with toEqual on whole objects, drains pipes with Promise.all, and skips on Windows where the block is cfg'd out.

Extended reasoning...

The diff is a three-line change to the non-Windows synchronous fast-path gate in src/runtime/webcore/Blob.rs::write_file_internal plus ~280 lines of new tests in test/js/bun/io/bun-write.test.js; it touches no auth, crypto, or injection surface. The removed clause routed small writes to a stat'ed regular-file Blob to the thread pool; the PR argues that made writes lost at process.exit(), and the code now treats stat'ed handles exactly like fresh ones. The second commit addressed both of my prior test nits, and the third prior comment (ignored ftruncate result) was pre-existing. What keeps this from an approve is that the deleted heuristic has an upstream origin whose intent may have been the opposite polarity, so choosing removal over inversion is a maintainer decision, and the change measurably shifts EFBIG/read-only-fd/JS-thread-latency behavior for statted handles. No CODEOWNERS entry covers the changed files.

@robobun

robobun commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator Author

@robobun wake up!!

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun wake up!!

…of the Bun.file() handle

The gate of the inline write path in write_file_internal refused a
destination whose store had cached the mode of a regular file. Every use of
the handle that stats the file fills that cache: f.size, exists(),
lastModified, structuredClone(f), a Response or FormData body that holds f.
Bun.stdout and Bun.stderr on a regular file start with it. After that, a
write of a string or a typed array under 256 KiB went to the thread pool.

The gate no longer reads the cached mode. Such a write to a destination at
offset 0 is made on the calling thread, as it is for a path string and for a
handle that nothing has statted. A payload of 256 KiB or more, a Blob or
Response source, a destination with an offset, and an S3 destination keep
their path.
…he SIGXFSZ handler

The terminal test rejects its wait when the PTY closes before the output
arrives, and it releases the terminal with `await using`. Bun ignores SIGXFSZ
from startup (src/runtime/bin_entry/mod.rs), so the handler in the script of
the file size limit test had no effect.
@robobun
robobun force-pushed the robobun/dd856c59/bun-write-statted-handle-fast-path branch from 578f5c8 to 1890c97 Compare September 28, 2026 21:43
@robobun

robobun commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator Author

I am here. State of this PR:

  • Rebased on main (a4f1429), with no conflict. The new head is 1890c97.
  • Tests: the 21 new tests pass on a debug build of the new head. With main's gate, 16 of them fail.
  • CI: the last run (build 120998) had one red test, test/js/bun/s3/s3.test.ts. Main had the same failure, and test: replace the MinIO container with an S3 server on Bun.serve #44054 repaired it. The push of the rebase started a new run.
  • Review: the two bot reviews have no open finding, and each review thread has a reply.

Two decisions are for a maintainer. Both are in Notes of the PR body.

  1. Delete the clause or invert it. This PR deletes it. An inverted clause sends a known pipe to the thread pool, and Bun.write(Bun.stdout, x) | cat loses its call order.
  2. The offset == 0 clause stays. With it, a f.slice(-20) destination takes the inline path only after a stat. Its removal is not in this PR.

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.

2 participants