Skip to content

fs: back createWriteStream(path) with a FileSink that adopts the fd - #35993

Open
robobun wants to merge 32 commits into
mainfrom
farm/d9a91b9d/fs-writestream-filesink
Open

fs: back createWriteStream(path) with a FileSink that adopts the fd#35993
robobun wants to merge 32 commits into
mainfrom
farm/d9a91b9d/fs-writestream-filesink

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes #21252

Reproduction

const { createWriteStream } = await import("node:fs");
const N = 20000; const line = "x".repeat(80) + "\n";
const p = `/tmp/wsappend-${process.pid}.out`;
const ws = createWriteStream(p);
let drains = 0; const t0 = performance.now();
for (let i = 0; i < N; i++) { if (!ws.write(line)) await new Promise(r => ws.once("drain", () => { drains++; r(); })); }
await new Promise(r => ws.end(r));
console.log(JSON.stringify({ rt: process.versions.bun ?? "node", N, ms: +(performance.now() - t0).toFixed(0), drains }));
20000 x 81-byte string lines wall clock write(2) syscalls
bun (before) 309 ms 40001
bun (this PR) 14 ms 420
node 23 ms 106

100000 x 81-byte: bun 1600 ms -> 59 ms; node 70 ms.

Cause

fs.createWriteStream(path)._write dispatched every chunk to the thread pool via fs.write: each ws.write(line) cost one write(2) to the file plus one 8-byte eventfd wake back to the JS thread. With this._writev = undefined set in the constructor (#16422), buffered chunks never coalesced either, so N small writes were ~2N write(2) calls.

Fix

FileSink: adopt a caller-supplied fd on POSIX

Bun.file(fd).writer() on POSIX previously dup()ed the fd (via open_for_writing's Fd arm) and closed the dup on end(); on Windows it already adopted the fd with owns_fd = false. This PR brings POSIX in line:

  • PosixStreamingWriter gains a close_fd: bool (mirroring PosixBufferedWriter and the Windows writers' owns_fd), threaded through close() / close_without_reporting() into PollOrFd::close_impl.
  • open_for_writing adopts a borrowed fd instead of dup'ing it and does not close it on an error path.
  • FileSink::setup() records the adopted fd on self.fd (so sink._getFd() returns it instead of -1) and sets writer.close_fd = false for fd-backed sinks.

So Bun.file(fd).writer() now holds exactly one caller-owned fd on every platform and sink.end() leaves it open. WindowsStreamingWriter::end() no longer skips close() for a borrowed fd, so on_close fires and the keep-alive self-ref taken on the first Pending write is released.

FileSink: writev(chunks: ArrayBufferView[])

A new sink.writev(chunks) method reserves outgoing capacity once, appends each slice, and drains once, so a batched drain is one write(2) on POSIX (one uv_fs_write on Windows) instead of a JS-side Buffer.concat.

WriteStream: route writes through the FileSink

For the default createWriteStream(path) case (no options.fs, no options.fd, no options.start), wrap the fd that fs.open returns in Bun.file(fd).writer() and route _write through the sink and _writev through the new sink.writev. Each call writes into the sink's buffer and flushes so the bytes are on disk before the Writable callback fires (test-file-write-stream.js / test-file-write-stream2.js read the file in the 'drain' handler). Deferring the callback via nextTick lets the Writable buffer fill so _writev drains the batch in one pass, and destroyed is rechecked before the deferred completion. flags / mode are honoured because the fd still comes from fs.open, and the existing fs.close / {flush: true} / {autoClose: false} paths are unchanged; close() just ends the sink (a no-op on the fd) first.

The sink is not created when options.fs, options.fd or options.start is present, or when fs.write has been replaced before the fd opens (so error-injection patches like test-fs-write-stream-err.js still see their patch). Those fall back to the per-chunk fs.write path.

For a pollable fd (FIFO, socket, pipe), FileSink writes via its poll-driven path rather than a blocking write(2) on the JS thread, and the flush returns a promise, so the Writable callback is deferred until the bytes drain; the event loop is not blocked. For a regular file on the JS thread the write is synchronous, which matches Bun.file().writer()'s existing contract.

Related

Same motivation as #31764; that PR keeps the thread-pool fs.writev path and additionally teaches native fs.writev / fs.readv to batch past IOV_MAX, which is still independently useful for bare fs.writev and for options.fs implementations that forward to it.

Verification

New tests in test/js/node/fs/fs.test.ts:

  • coalesces many small writes instead of dispatching one syscall per chunk (Linux only): 5000 x 81-byte writes, asserts the /proc/self/io syscw delta is under 1250 (stock bun: ~10000), file size and bytesWritten are byte-exact, and the fd is closed after 'close'.
  • holds a single fd for the stream's lifetime (Linux only, subprocess): /proc/self/fd count is exactly +1 while the stream is open and +0 after 'close'.
  • many small writes produce byte-exact output and bytesWritten: multi-byte UTF-8 chunks.
  • routes writes through fs.write so a monkey-patch is honoured: a patched fs.write sees both writes.

The first test fails on stock bun (Received: 10001) and passes with this change. The vendored test-fs-write-stream* / test-file-write-stream* scripts, fs.WriteStream / createWriteStream tests, node-stream.test.js, child-process-stdio.test.js, process-stdio, tty, and bun-write suites run identically to main. bun run rust:check-all passes on all targets.


no test proof · iteration 10 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/util/filesink.test.ts test/js/node/fs/fs.test.ts

fs.createWriteStream(path) dispatched every write() to the thread pool
via fs.write: each chunk cost one write(2) to the file plus one 8-byte
eventfd wake back to the JS thread, with no coalescing. 20000 x 81-byte
writes were ~40000 write syscalls and ~12x slower than Node, which
batches buffered chunks through _writev.

Back the default path-based WriteStream with Bun.file(fd).writer() once
fs.open completes. _write and _writev write into the FileSink's
in-process buffer and complete synchronously, so the Writable machinery
never needs to buffer and there is no thread-pool round-trip per chunk.
A _final flushes the sink before 'finish'. flags/mode are honoured
because the fd still comes from fs.open, and the fd is closed via the
existing close() path after the sink drains. decodeStrings is disabled
on this path so UTF-8 strings reach the sink without a Buffer.from
round-trip; other encodings are decoded in _write.

The FileSink buffer is not enabled when options.fs, options.fd,
options.start or a monkey-patched open() is present; those keep the
fs.write path. The stdio fast path is unchanged.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e60891a1-045a-428a-acc8-0a0135fab9ab

📥 Commits

Reviewing files that changed from the base of the PR and between 4531be8 and 0fe7392.

📒 Files selected for processing (4)
  • src/runtime/webcore/FileSink.rs
  • src/runtime/webcore/Sink.rs
  • test/js/bun/util/filesink.test.ts
  • test/js/node/fs/fs.test.ts

Walkthrough

Changes

WriteStream file sink and writev support

Layer / File(s) Summary
Descriptor borrowing and teardown
src/io/openForWriting.rs, src/io/PipeWriter.rs, src/runtime/webcore/FileSink.rs, src/runtime/webcore/Blob.rs
Reuses borrowed descriptors and makes platform writer shutdown close descriptors conditionally.
File sink setup and WriteStream integration
src/runtime/webcore/FileSink.rs, src/js/internal/fs/streams.ts
Creates sink-backed writes for eligible streams, propagates descriptor ownership, tracks write state, and ends the sink during close.
Sink writev API and bindings
src/runtime/webcore/Sink.rs, src/runtime/webcore/FileSink.rs, src/codegen/generate-jssink.ts, src/jsc/bindings/headers.h, packages/bun-types/*
Adds validated JavaScript and FileSink writev support, generated bindings, and public type declarations.
Native writev I/O
src/io/PipeWriter.rs, src/sys/lib.rs
Adds platform-specific writev buffering, partial-write handling, synchronous paths, and nonblocking vectored writes.
FileSink and WriteStream validation
test/js/bun/util/filesink.test.ts, test/js/node/fs/fs.test.ts
Tests ordered writes, invalid and detached buffers, lifecycle handling, coalescing, descriptor lifetime, output counts, errors, and patched fs.write behavior.

Possibly related PRs

  • oven-sh/bun#36066: Updates FileSink::setup descriptor ownership behavior for console and stdio paths.

Suggested reviewers: cirospaciari, jarred-sumner, alii

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Clear, specific title that matches the main change: createWriteStream now routes through a FileSink and adopts the fd.
Description check ✅ Passed The description is detailed and includes the required verification info, though it doesn't use the template headings verbatim.
Linked Issues check ✅ Passed The changes address #21252 by batching writes through FileSink/writev while preserving output, bytesWritten, backpressure, and error handling.
Out of Scope Changes check ✅ Passed The extra API, codegen, type, and runtime changes all support FileSink writev and fd ownership, so no unrelated scope is evident.

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

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:27 AM PT - Jul 27th, 2026

@robobun, your commit 0fe7392801f6250841d2a3f7dd3c4d501aa8a903 passed in Build #83423! 🎉


🧪   To try this PR locally:

bunx bun-pr 35993

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

bun-35993 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Slow copy over network on bun 1.2.x #21252 - createWriteStream with many small writes is ~7x slower in 1.2.x; buffered FileSink eliminates the per-chunk thread pool overhead
  2. Stream never finishes, downloaded files are corrupted #17115 - Piping into createWriteStream produces corrupted/oversized files and never finishes; proper buffering and _final flush fix data integrity and completion
  3. Ip-location-api updatedb.mjs broken as of bun 1.2.0 #18662 - createWriteStream backpressure regression in 1.2.0 causes premature process exit with incomplete writes; FileSink buffering and _final flush ensure all data is written before stream finishes

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #21252
Fixes #17115
Fixes #18662

🤖 Generated with Claude Code

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Checked each:

  • Slow copy over network on bun 1.2.x #21252 (7x slower line-by-line createWriteStream writes since 1.2.0): yes, same root cause. Bisected there to node fs compat pr #2 #16422, which is where the per-chunk fs.write dispatch was introduced; 1.1.x used a FileSink-backed path and was faster than Node on that workload. Added Fixes #21252.
  • Ip-location-api updatedb.mjs broken as of bun 1.2.0 #18662 (ip-location-api updatedb regression since 1.2.0): very likely, but I'm not adding a Fixes line without running their repro. The 2-2.5x slowdown is the same per-chunk dispatch cost, and the "backpressure triggers earlier" symptom goes away here because _write completes synchronously so state.length drops to 0 before write() returns. The "process exits silently" part may be a separate drain-never-fires bug on the old path; worth verifying independently.
  • Stream never finishes, downloaded files are corrupted #17115 (ytdl-core pipe(createWriteStream) corruption, output always 41943044 bytes): not the same bug. That's a data-duplication / stream-accounting issue, not a per-chunk-dispatch cost; this PR replaces the write path but I haven't reproduced the corruption so I'm not claiming it.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fs: batch small WriteStream writes via _writev and fix retry position #31764 - Both PRs optimize fs.createWriteStream write throughput by addressing the same root cause (_writev disabled in node fs compat pr #2 #16422); fs: batch small WriteStream writes via _writev and fix retry position #31764 re-enables _writev batching while this PR routes writes through a FileSink

🤖 Generated with Claude Code

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Re #31764: covered in the "Related" section above. Same symptom, different layer: #31764 re-enables _writev so buffered chunks coalesce into one fs.writev per drain cycle (still one thread-pool dispatch per cycle, Node's approach); this PR routes into the FileSink buffer so _write completes synchronously and there is no thread-pool round-trip at all for the default createWriteStream(path) case. #31764's native IOV_MAX handling and the writevAll offset fix are still independently useful for the options.start / options.fs fallback path and for bare fs.writev, so that PR isn't obsoleted.

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

Beyond the inline findings, I also checked: the _writev string path with decodeStrings:false — chunks are converted to Buffer before size is accumulated, so bytesWritten counts bytes; the fallback when Bun.file(fd).writer() throws in streamConstruct — the added Buffer.from(data, encoding) in _write's else branch covers the now-possible string input; and the /proc/self/io syscw test against concurrent tests in the same file — ruled out as a flake source.

Extended reasoning...

This PR reroutes the default createWriteStream(path) through a FileSink, which is a high-traffic Node compat surface with subtle interactions (decodeStrings, autoClose, fd ownership, close ordering). The inline findings — particularly the 2× fd consumption / autoClose:false dup leak on POSIX and the fdClosed race in the syscw test — are substantive enough to warrant a human look before landing. The note above records what else was examined so a follow-up pass doesn't re-derive it.

Comment thread src/js/internal/fs/streams.ts Outdated
Comment thread test/js/node/fs/fs.test.ts
Comment thread test/js/node/fs/fs.test.ts
… batching

Rework of the previous FileSink-backed approach. Bun.file(fd).writer()
dup()s the fd on POSIX, so every WriteStream would hold two fds (and
leak the dup under {autoClose:false}), and FileSink buffers meant data
was not on disk when 'drain' fired, which several upstream Node tests
rely on.

Instead, keep the existing fs.open/close path and write on the JS thread
with a captured writeSync: _write writes its chunk and defers completion
via nextTick so the Writable buffer fills, then _writev concatenates the
batch into a single writeSync. The result is ~2 write(2) per drain cycle
instead of one thread-pool dispatch plus eventfd wake per chunk, data is
on disk before each completion callback, and the stream holds a single
fd.

The sync path is used for the default createWriteStream(path)/fd case
with no options.fs and no options.start. It falls back to the per-chunk
fs.write path at first write when fs.write/fs.writev have been
monkey-patched, so error-injection tests that replace fs.write still see
their patch.
Comment thread src/js/internal/fs/streams.ts Outdated
Comment thread src/js/internal/fs/streams.ts Outdated
@robobun robobun changed the title fs: buffer createWriteStream(path) writes through a FileSink fs: coalesce createWriteStream(path) writes via synchronous writeSync batching Jul 26, 2026
Comment thread src/js/internal/fs/streams.ts Outdated
Comment thread src/js/internal/fs/streams.ts Outdated
…p the thread-pool write

Also reset the retries counter on a non-zero write, matching the async
writeAll/writevAll twins.
Comment thread src/js/internal/fs/streams.ts Outdated
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: Linux green; the two Windows failures on build #83232 (test-file-write-stream2.js and many small writes, Windows returns a pending Promise per write) are fixed in b47349c by the native FileSink.writev and by awaiting sink.end() in close(). Verified locally on windows-x64: all 8 vendored test-*-write-stream* scripts, the createWriteStream suite, the FileSink suite, and console-write pass. Ready for review.

@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 `@src/js/internal/fs/streams.ts`:
- Around line 577-595: Update writeAllSync’s deferred completion path to recheck
stream.destroyed before invoking the callback, matching the destroyed-stream and
kIoDone handling used by writeAll and writevAll. Report
$ERR_STREAM_DESTROYED("write") instead of success when destruction occurs after
the synchronous write but before process.nextTick executes.
🪄 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: 27e405ce-08a1-42e5-9c90-f99e111c8b3d

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and 396cce8.

📒 Files selected for processing (2)
  • src/js/internal/fs/streams.ts
  • test/js/node/fs/fs.test.ts

Comment thread src/js/internal/fs/streams.ts Outdated
Match the async writeAll/writevAll twins: if destroy() runs between the
synchronous write and the nextTick completion, report
ERR_STREAM_DESTROYED to the write callback instead of success.
Comment thread src/js/internal/fs/streams.ts Outdated
Comment thread test/js/node/fs/fs.test.ts
createWriteStream(fifoPath) or createWriteStream('/dev/stdout') has
fd == null at constructor time but opens a pipe/char-device; writeSync
on the JS thread would block the event loop once the kernel pipe buffer
fills. fstat the fd in _construct's open callback and clear kSyncWrite
unless it is a regular file.

Also run the single-fd test in a subprocess so a GC finalizer closing an
fd from an earlier test cannot skew the /proc/self/fd delta, and add a
FIFO test pinning the event loop stays responsive while a blocking pipe
write is pending.
Comment thread src/js/internal/fs/streams.ts Outdated
robobun added 2 commits July 26, 2026 19:41
process.exit(0) while a thread-pool fs.write is blocked in write(2)
raced VM teardown and panicked with 'enqueueTaskConcurrent: VM has
terminated' on the asan lane. Wait for 'alive', then drain the FIFO so
the child's pending write completes and it exits on its own.

[skip size check]
Comment thread src/js/internal/fs/streams.ts Outdated
Comment thread src/js/internal/fs/streams.ts Outdated
Drop the writeSync-on-JS-thread approach: isFile() cannot tell an NFS /
FUSE / sshfs-backed regular file from a local one, and write(2) on those
blocks the event loop for the full network latency. libuv thread-pools
all file I/O for this reason.

Keep the coalescing win without the regression: stop shadowing the
prototype _writev (the own-property undefined set in #16422 was what
disabled batching), and have _writev drain the buffered batch as a
single thread-pool fs.write on the concatenated buffer so the default
path does not depend on fs.writev's IOV_MAX handling. A custom
options.fs that supplies writev still gets writevAll. Also fix
writevAll passing this.pos instead of its pos parameter on
partial-write retries.

[skip size check]
robobun and others added 3 commits July 27, 2026 10:47
[skip size check]
so sink.write() and flush()/AutoFlusher for a regular file also probe
pwritev2(RWF_NOWAIT) on Linux, matching writev(). force_sync still
issues blocking sys::write.

[skip size check]

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/io/PipeWriter.rs:2648-2653 — 🔴 075d741's fix for the borrowed-pipe close now has end() call self.on_close_source() for a borrowed Source::Pipe, but WindowsStreamingWriter::on_close_source (line 2062) does self.source = None, which drops the Box<uv::Pipe> while the uv_pipe_t is still linked in libuv's loop handle queue (never uv_close'd) — the exact hazard the comment at PipeWriter.rs:1392-1394 documents ("would Drop the prior Box WITHOUT uv_close, leaving libuv with a dangling handle → UAF on next loop tick"). Reachable via Bun.file(pipeFd).writer().end() on Windows: Blob.rs:1874 sets owns_fd=false and w.start(fd, true)Source::openSource::Pipe for a named-pipe fd. Pre-PR the !owns_fd guard early-returned and Dropclose_without_reporting()close() uv_close'd via the Pipe arm at :1282-1289. Either fire Parent::on_close here without taking the source (so Drop still uv_close's it), or dup borrowed pipe fds upfront on Windows the way POSIX now dups pollable fds so the sink owns its uv handle.

    Extended reasoning...

    What the bug is

    075d741 addressed the earlier review's "b47349c makes end() uv_close the caller's borrowed pipe handle" comment by having WindowsStreamingWriter::end() skip close() for a borrowed non-File source and call self.on_close_source() directly:

    if !self.owns_fd && !matches!(self.source, Some(Source::File(_) | Source::SyncFile(_))) {
        // uv_close on a borrowed pipe/tty would close the caller's
        // handle; fire on_close without touching it.
        self.on_close_source();
        return;
    }

    But WindowsStreamingWriter::on_close_source (PipeWriter.rs:2062-2069) is:

    fn on_close_source(&mut self) {
        self.source = None;
        ...
        unsafe { Parent::on_close(self.parent) };
    }

    For Source::Pipe(Box<uv::Pipe>), self.source = None drops the Box and frees the uv_pipe_t allocation. That handle was registered with the loop's handle queue by uv_pipe_init/uv_pipe_open in Source::open_pipe and has never been uv_close'd, so libuv is left with a dangling handle-queue link and a stale loop->handles entry. There is no Drop impl on uv::Pipe or Source that calls uv_close (grep confirms none). This is precisely the hazard the codebase's own comment at PipeWriter.rs:1392-1394 (set_pipe) documents:

    The assignment below would Drop the prior Box WITHOUT uv_close, leaving libuv with a dangling handle → UAF on next loop tick.

    (Source::Tty is NonNull<uv_tty_t>, a non-owning backref, so dropping it leaks the handle rather than freeing it — the load-bearing UAF is the Pipe arm.)

    The specific code path

    On Windows, Bun.file(pipeFd).writer() where pipeFd is a pipe/named-pipe handle:

    1. Blob::get_writer's #[cfg(windows)] branch (Blob.rs:1872-1880, pre-existing, not touched by this PR) does w.owns_fd = !matches!(pathlike, Fd(_))false, then w.start(fd, true).
    2. start(fd, true)Source::open(loop_, fd)uv_guess_handle(fd)NamedPipeSource::open_pipeuv_pipe_init(loop, pipe) + uv_pipe_open(pipe, fd). The handle is now linked into the loop's handle_queue; self.source = Some(Source::Pipe(Box<Pipe>)).
    3. sink.end()end_from_jswriter.flush() returns Wrote(0) (no pending data) → writer.end().
    4. WindowsStreamingWriter::end(): is_done = true; !has_pending_data()!owns_fd && source is Pipenew branchself.on_close_source().
    5. on_close_source: self.source = NoneBox<uv::Pipe> deallocated. uv_close was never called.
    6. Next uv_run iteration (or uv_walk/uv_loop_close) walks loop->handle_queue and dereferences the freed uv_pipe_t.

    The same shape is reachable via this PR's new createWriteStream(namedPipePath) path when fs.open returns a named-pipe fd: FileSink::setup() sets w.owns_fd = false (FileSink.rs:668) and w.start(fd, pollable) creates Source::Pipe.

    Why existing code doesn't prevent it

    on_close_source is normally reached only as the terminal step of close() — after the Source::Pipe arm at :1282-1289 has into_raw'd the Box and handed it to uv_close(on_pipe_close) (which reclaims and frees it in the close callback). Calling on_close_source directly with a live Source::Pipe bypasses that step. There is no Drop for uv::Pipe that calls uv_close. And once self.source = None, the later WindowsStreamingWriter::Dropclose_without_reporting() sees get_fd() == INVALID and skips, so nothing ever uv_close's the handle.

    Why this is a regression

    Pre-PR, end() had if !self.owns_fd { return; } — the source stayed alive, and when the WindowsStreamingWriter was later dropped (FileSink::deinit → drop writerDrop at :2660 → close_without_reporting()close()), the Source::Pipe arm at :1282-1289 properly uv_close'd the handle via into_raw + on_pipe_close. That closed the caller's fd (a correctness bug this PR set out to fix) and leaked the keep-alive ref — but it was memory-safe. 075d741 trades that for a use-after-free.

    Step-by-step proof

    // Windows
    const [r, w] = createSocketPair(); // or any named-pipe handle
    const sink = Bun.file(w).writer();  // owns_fd=false, Source::Pipe(Box<Pipe>) init'd on the loop
    await sink.end();                   // end() → on_close_source() → Box freed, no uv_close
    setImmediate(() => {});             // next uv_run tick walks handle_queue → deref freed uv_pipe_t

    Under ASAN this is a heap-use-after-free in uv_run; in release it corrupts the loop's intrusive handle list.

    How to fix

    The uv handle's memory must stay alive until a uv_close callback fires. Two options:

    1. Don't drop the source here. Add a variant that fires Parent::on_close without self.source = None (or set closed_without_reporting = true and leave the source in place). Then Dropclose_without_reporting()close() will uv_close the handle when the writer is torn down. This restores pre-PR memory safety but re-closes the caller's fd at Drop time — which per the comment at :1376-1381 is unavoidable once uv_pipe_open has adopted it.
    2. Dup borrowed pipe fds upfront on Windows, mirroring what FileSink::setup now does on POSIX for pollable fds (bun_sys::dup_with_flagsowns_fd = true). Then the sink owns its own handle, end() reaches the ordinary close() path, and the caller's fd is never touched.

Comment thread src/runtime/webcore/Sink.rs
Comment thread src/io/PipeWriter.rs
Comment thread test/js/bun/util/filesink.test.ts Outdated
…dows end() for borrowed Pipe; test error codes

- PosixStreamingWriter::writev advances a start index instead of
  Vec::remove(0).
- Default JsSinkType::writev_bytes treats Writable::Backpressure(n) as
  progress (adds n, continues, returns Backpressure(total) at the end)
  so an HTTP response controller.writev does not drop tail chunks once
  the socket backs up.
- Windows end() for a borrowed Pipe/Tty returns without dropping the
  source (Drop -> close() uv_close's it); File/SyncFile still reach
  close() which honours owns_fd via detach_borrowed_fd.
- writev argument-validation test asserts ERR_INVALID_ARG_TYPE.

[skip size check]
@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Re the Windows borrowed-Pipe on_close_source UAF: 3731912 reverts that branch to return without touching the source (the pre-PR shape for borrowed pipe/tty), so the Box<uv::Pipe> stays alive until Drop -> close() -> uv_close. File/SyncFile still reach close(), which honours owns_fd via detach_borrowed_fd(), so createWriteStream(path) (regular file) releases the keep-alive ref on end() on Windows.

Comment thread src/io/PipeWriter.rs
Comment thread src/io/PipeWriter.rs
Comment thread src/runtime/webcore/FileSink.rs Outdated

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

🤖 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 `@src/io/PipeWriter.rs`:
- Around line 1008-1017: Update the iovec construction in PipeWriter’s
buffer-writing path to use clippy-approved pointer conversions instead of direct
as casts, preserving the existing buffer addresses and lengths. In the unsafe
block around the write operation near the referenced lines, use the appropriate
raw-pointer conversion that changes constness and add a safety comment
documenting the invariants that make the operation valid.

In `@test/js/bun/util/filesink.test.ts`:
- Around line 182-190: Update the “writev rejects non-ArrayBufferView entries”
test to be async and ensure the writer is always torn down with await sink.end()
in a finally block, preserving both invalid-input assertions while preventing
cleanup errors or pending resources from escaping.
🪄 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: 3160a29c-5ecb-4811-b5da-9d547dfbfd1f

📥 Commits

Reviewing files that changed from the base of the PR and between cb26f1f and 0b699d1.

📒 Files selected for processing (10)
  • packages/bun-types/s3.d.ts
  • src/codegen/generate-jssink.ts
  • src/io/PipeWriter.rs
  • src/js/internal/fs/streams.ts
  • src/jsc/bindings/headers.h
  • src/runtime/webcore/FileSink.rs
  • src/runtime/webcore/Sink.rs
  • src/sys/lib.rs
  • test/js/bun/util/filesink.test.ts
  • test/js/node/fs/fs.test.ts

Comment thread src/io/PipeWriter.rs
Comment thread test/js/bun/util/filesink.test.ts Outdated
robobun added 2 commits July 27, 2026 12:05
…fd after start(); clippy casts

on_auto_flush no longer short-circuits on done.get() so a regular-file
Pending after end_from_js (RWF_NOWAIT EAGAIN) is drained by the deferred
microtask, and once drained with done set it ends the writer so on_close
fires and the keep-alive ref is released.

FileSink::setup now writes the writer's close_fd/owns_fd flag after
w.start() returns, so start()'s internal close() of the previous handle
still uses the previous handle's flag.

[skip size check]
Comment thread src/io/PipeWriter.rs
Comment thread packages/bun-types/s3.d.ts
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/FileSink.rs Outdated
Comment thread test/js/bun/util/filesink.test.ts Outdated
Comment thread src/io/PipeWriter.rs

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/webcore/Sink.rs (1)

547-578: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Do not keep this live across get_length/get_index. Both calls can enter JS (including proxy/getter paths), so a callback can close or mutate the sink before writev_bytes runs. Collect and validate the chunks first, then reacquire this and recheck pending error immediately before writev_bytes.

🤖 Prompt for 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.

In `@src/runtime/webcore/Sink.rs` around lines 547 - 578, The writev flow
currently retains the sink across JS-entering get_length and get_index calls. In
the method containing this.sink and writev_bytes, collect and validate all
chunks before reacquiring this; then call get_this again and recheck
get_pending_error immediately before writev_bytes, so callbacks can close or
mutate the sink safely.

Source: Coding guidelines

🤖 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 `@src/runtime/webcore/FileSink.rs`:
- Around line 702-718: The fallback error paths in FileSink.rs (lines 702-718)
and Blob.rs (lines 1897-1909) manually close the file descriptor while leaving
the writer ownership flag set. Update the corresponding writer-start error
handling to clear close_fd on Unix or owns_fd on Windows after the explicit
close, ensuring teardown cannot close the descriptor again.

In `@test/js/bun/util/filesink.test.ts`:
- Around line 532-563: Guard the named-pipe test beginning with
“Bun.file(fd).writer() on a named pipe” so it is skipped when the platform is
not Windows, using the file’s existing platform-guard convention. Also await a
promise resolved by the server connection’s first data event before asserting
received equals “hello”, and reject it on connection or server errors so the
assertion does not depend on the GC polling loop’s timing.

---

Outside diff comments:
In `@src/runtime/webcore/Sink.rs`:
- Around line 547-578: The writev flow currently retains the sink across
JS-entering get_length and get_index calls. In the method containing this.sink
and writev_bytes, collect and validate all chunks before reacquiring this; then
call get_this again and recheck get_pending_error immediately before
writev_bytes, so callbacks can close or mutate the sink safely.
🪄 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: 36c1aa7d-34d3-4add-9079-3039f23fcdb9

📥 Commits

Reviewing files that changed from the base of the PR and between 0b699d1 and 4531be8.

📒 Files selected for processing (6)
  • packages/bun-types/bun.d.ts
  • src/io/PipeWriter.rs
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/FileSink.rs
  • src/runtime/webcore/Sink.rs
  • test/js/bun/util/filesink.test.ts

Comment thread src/runtime/webcore/FileSink.rs
Comment thread test/js/bun/util/filesink.test.ts
Comment thread test/js/node/fs/fs.test.ts

@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 new findings this pass, but this touches fd ownership and writer teardown across POSIX/Windows plus new public writev API on all sinks — worth a human look before merge.

What was reviewed:

  • fd ownership: open_for_writing borrowed-fd path no longer closes on error; close_fd/owns_fd threading through setup() error and success arms; POSIX pollable-fd dup and Windows pipe/tty DuplicateHandle so end() reaches on_close and releases the keep-alive ref.
  • js_writev: accessor getters run before get_this/slice extraction; MarkedArgumentBuffer roots each item; detached buffers seen as zero-length (test covers this).
  • PosixStreamingWriter::writev iovec advance on partial write stays in bounds; FileType::Pipe routed to writev_buffered so blocking pipes never stall the JS thread.
  • streams.ts: kFileSink gated on !fastPath && fd == null && start === undefined && autoClose !== false and fs.write === write at open time; close() ends the sink then falls through to the existing fs.close/flush path.
Extended reasoning...

Overview

13 files: native fd-ownership semantics for Bun.file(fd).writer() on POSIX (adopt instead of dup for non-pollable fds; dup pollable fds and Windows pipe/tty so owns_fd stays true), a new close_fd flag on PosixStreamingWriter threaded through close()/close_without_reporting(), a new writev() method on all six sink prototypes (codegen + Rust host-fn + .d.ts), PosixStreamingWriter::writev/WindowsStreamingWriter::writev implementations, writev_nonblocking in bun_sys, and a kFileSink fast path in createWriteStream(path) that routes _write/_writev through the sink instead of the thread-pool fs.write.

Security risks

None identified. No auth/crypto/permissions surface. Untrusted-input handling in js_writev (array length, per-item type) is validated before allocation with a MAX_CHUNKS cap; slices are extracted only after all accessor JS has run and rooted, so a getter that detaches a buffer is seen as zero-length rather than dereferenced stale.

Level of scrutiny

High. This changes fd lifetime semantics on a hot Node-compat path, adds cross-platform writer teardown branches that went through three revisions on this PR (UAF → keep-alive leak → dup-before-open), and introduces new public API surface on every sink. The try_write refactor also changes the FileType::File arm from sys::write to sys::write_nonblocking — a real behaviour change for regular-file sinks on Linux (RWF_NOWAIT), which the PR relies on for the on_auto_flush retry loop.

Other factors

The PR has been through ~10 iterations with every prior finding (Windows borrowed-pipe keep-alive leak, on_auto_flush done short-circuit, setup() error-path double-close, js_writev re-entrancy ordering, FileType::Pipe blocking writev) addressed and marked resolved. Test coverage is solid (syscall-count assertion, fd-count subprocess, /dev/full error path, monkey-patch fallback, Windows named-pipe liveCount, detach-via-getter). But the scope — native memory/fd ownership, GC-sensitive host-fn ordering, and a Node-compat behaviour change (regular-file writes now synchronous on the JS thread) — is beyond what I should approve unattended.

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.

Slow copy over network on bun 1.2.x

2 participants