Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/io/PipeWriter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,8 @@ pub struct PosixStreamingWriter<Parent: PosixStreamingWriterParent> {
pub is_done: bool,
pub(crate) closed_without_reporting: bool,
pub force_sync: bool,
/// `false`: closing the writer leaves the fd open for the parent, which closes it.
pub close_fd: bool,
/// Last reported `WriteStatus == Pending` (i.e. write(2) returned EAGAIN).
backed_up: core::cell::Cell<bool>,
rwf_unsupported: core::cell::Cell<bool>,
Expand All @@ -698,6 +700,7 @@ impl<Parent: PosixStreamingWriterParent> Default for PosixStreamingWriter<Parent
is_done: false,
closed_without_reporting: false,
force_sync: false,
close_fd: true,
backed_up: core::cell::Cell::new(false),
rwf_unsupported: core::cell::Cell::new(false),
}
Expand Down Expand Up @@ -878,7 +881,8 @@ impl<Parent: PosixStreamingWriterParent> PosixStreamingWriter<Parent> {
if self.get_fd() != Fd::INVALID {
debug_assert!(!self.closed_without_reporting);
self.closed_without_reporting = true;
self.handle.close(None, None::<fn(*mut c_void)>);
self.handle
.close_impl(None, None::<fn(*mut c_void)>, self.close_fd);
}
}

Expand Down Expand Up @@ -1127,10 +1131,11 @@ impl<Parent: PosixStreamingWriterParent> PosixStreamingWriter<Parent> {
}

let parent = self.parent;
self.handle.close(
self.handle.close_impl(
Some(parent.cast()),
// SAFETY: parent was set via set_parent with a *mut Parent.
Some(|ctx: *mut c_void| unsafe { Parent::on_close(ctx.cast::<Parent>()) }),
self.close_fd,
);
}

Expand Down
121 changes: 121 additions & 0 deletions src/runtime/webcore/FileSink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,76 @@ pub struct FileSink {
pub(crate) js_sink_ref: JsCell<bun_jsc::strong::Optional>,
/// Armed while a file this sink opened for a `Bun.ModuleGraph`'s script is open.
abort_handle: bun_jsc::AbortHandle,
/// A file this sink opened (an fd that is not polled): `on_close` closes it, not the writer.
#[cfg(unix)]
file: Cell<Option<OwnedFile>>,
}

#[cfg(unix)]
#[derive(Clone, Copy)]
struct OwnedFile {
fd: Fd,
/// The script that opened it.
opener: bun_jsc::ContextId,
}

/// The close of a `Bun.write(dest, stream)` file, on the work pool. `then` settles the promise.
#[cfg(unix)]
struct CloseStreamFile;

/// Closes on drop as well: a job that is released unrun still closes its file.
#[cfg(unix)]
struct StreamFile(Fd);

#[cfg(unix)]
impl Drop for StreamFile {
fn drop(&mut self) {
let fd = core::mem::replace(&mut self.0, Fd::INVALID);
if fd != Fd::INVALID {
fd.close();
Comment on lines +91 to +99

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.

🟡 nit (optional): maintainers get a second hand-rolled fd-owning type where bun_sys::File already closes on Drop. src/runtime/webcore/FileSink.rs:91-99 defines StreamFile(Fd) with its own Drop plus a mem::replace in run (FileSink.rs:127), duplicating what bun_sys::File (src/sys/file.rs:12-23) provides. Fix: make CloseStreamFile::OffThread a bun_sys::File (or wrap one) so the job and its release-unrun path both close through the one owning type, keeping the explicit close in run so the fd is released on the pool rather than at Drop.

Why this was flagged

src/CLAUDE.md asks runtime code to prefer bun_sys::File, which owns an fd and closes it on Drop, over hand-written ownership. The diff adds StreamFile at src/runtime/webcore/FileSink.rs:91-99 with a manual Drop that does the same thing, and run at FileSink.rs:127 repeats the mem::replace(.., Fd::INVALID).close() idiom. No user-visible failure; this is a convention and duplication nit only. On the base branch neither type exists, so nothing regresses.

Verification: nit. The diff adds struct StreamFile(Fd) at /home/claude/bun/src/runtime/webcore/FileSink.rs:91 with a hand-written impl Drop (lines 94-101), and CloseStreamFile::run at line 127 repeats the idiom. bun_sys::File (/home/claude/bun/src/sys/file.rs:11-23) is already exactly this. Nothing breaks at runtime either way; this is duplication of an existing owning type.

}
}
}

/// One ref on the sink whose file is being closed.
#[cfg(unix)]
struct ClosingSink(*mut FileSink);

// SAFETY: a `FileSink` ref; used and dropped on the sink's JS thread only.
#[cfg(unix)]
unsafe impl bun_jsc::job::JsAffine for ClosingSink {}

#[cfg(unix)]
impl Drop for ClosingSink {
fn drop(&mut self) {
// A job that is released without `then` reports nothing: the pipe and the keep-alive ref go here.
// SAFETY: the ref `on_close` took for this job keeps the sink live until the `deref`.
unsafe {
(*self.0).release_pipe();
FileSink::clear_keep_alive_ref(self.0);
FileSink::deref(self.0);
}
}
}

#[cfg(unix)]
impl bun_jsc::JobContext for CloseStreamFile {
type OffThread = StreamFile;
type Js = ClosingSink;

fn run(
file: &mut StreamFile,
done: bun_jsc::Completion<Self>,
) -> Option<bun_jsc::Completion<Self>> {
core::mem::replace(&mut file.0, Fd::INVALID).close();
Some(done)
}

fn then(_: StreamFile, sink: ClosingSink, _: &bun_jsc::JsThread<'_>) -> JsResult<()> {
// SAFETY: `sink` holds a ref, on the canonical pointer `on_close` was called with.
unsafe { FileSink::report_closed(sink.0) };
Ok(())
}
}

// `bun.ptr.RefCount(FileSink, "ref_count", deinit, .{})` — intrusive single-thread
Expand Down Expand Up @@ -193,6 +263,11 @@ impl Options {
impl Drop for FileSink {
fn drop(&mut self) {
LIVE_COUNT.fetch_sub(1, Ordering::Relaxed);
// Never ended: `on_close` did not run, and nothing is told that this file is closed.
#[cfg(unix)]
if let Some(file) = self.file.take() {
bun_io::Closer::close(file.fd, ());
}
self.release_pipe();
if let Some(global) = self.js_global() {
let vm = global.bun_vm().as_mut();
Expand Down Expand Up @@ -544,6 +619,35 @@ impl FileSink {
let mut src = *(*this).source.get();
src.close(err);

#[cfg(unix)]
if let Some(file) = (*this).file.take() {
// Only `Bun.write(dest, stream)` reports through a promise, which can wait for the pool.
let global = (*this).js_global().filter(|global| {
(*this).pipe.get().has_done() && !global.bun_vm().is_shutting_down()
});
if let Some(global) = global {
(*this).ref_();
let opener = global.bun_vm().context_of(file.opener);
bun_jsc::Job::<CloseStreamFile>::schedule(
&global.js_thread(opener),
StreamFile(file.fd),
ClosingSink(this),
);
Comment thread
robobun marked this conversation as resolved.
Resolved
return;
}
Comment on lines +623 to +637

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.

🟡 (optional) Every await Bun.write(path, stream) now settles only after a work-pool thread runs the close, so a busy pool delays it; on the base branch it settled at once. src/runtime/webcore/FileSink.rs:623-637 schedules Job::<CloseStreamFile> whenever pipe.has_done(), with no check that the file was replaced. A fresh file, a Bun.file(fd) dup or /dev/null needs no flush, yet still queues behind unrelated pool work. Fix: close in place when the sink created the file (or the destination is not a truncated regular file) and take the pool only for the O_TRUNC-over-existing case the description names, while still never reporting before the fd is closed.

Why this was flagged

A script calls Bun.write(newPath, response.body) or Bun.write(Bun.file(fd), stream) while the work pool is busy, e.g. two slow fs.promises.readFile calls on a container whose pool has get_thread_count() == 2 threads (src/bun_core/util.rs:2800-2847). The stream ends, FileSink::on_close runs, and src/runtime/webcore/FileSink.rs:623-637 takes the OwnedFile and schedules Job:: for any sink with pipe.has_done(), returning before report_closed. The promise is settled only in CloseStreamFile::then (FileSink.rs:138-141) after WorkPool::schedule (src/jsc/job.rs:381) hands the job to a free thread, so the await waits out the whole pool backlog. On the base branch the close was also queued on the pool but on_close settled the promise immediately. The PR description says a new file is closed in place and only a replaced file (O_EXCL -> EEXIST) takes the pool, but Options::flags() (FileSink.rs:252-260) has no O_EXCL probe, so the code does not implement that split; the test at test/js/bun/util/filesink.test.ts expects settledWithPoolParked: false even for "new file" rows.

Verification: src/runtime/webcore/FileSink.rs:931-935 sets self.file = Some(OwnedFile{..}) for every fd where !self.pollable.get(), with no O_EXCL/EEXIST distinction. FileSink.rs:622-637: in on_close, Job::<CloseStreamFile>::schedule(...); return;, so report_closed runs only in CloseStreamFile::then (line 139-142). On base, Closer::close(fd, ()) queued the close and immediately invoked on_close_fn, so the promise settled at once.

file.fd.close();
}
FileSink::report_closed(this);
}
}

/// The file is closed: settle the promise of `Bun.write(dest, stream)` and let go of the pipe.
/// SAFETY: as [`on_close`](Self::on_close), and the caller holds a ref of its own across the call.
unsafe fn report_closed(this: *mut FileSink) {
// SAFETY: caller contract — `this` is live with write+dealloc provenance.
unsafe {
#[cfg(unix)]
debug_assert!((*this).file.get().is_none());
(*this).settle_stream_done();
(*this).release_pipe();

Expand Down Expand Up @@ -823,6 +927,16 @@ impl FileSink {
.unwrap()
.set_flag(bun_io::FilePollFlag::Fifo);
}

let file = (!self.pollable.get()).then(|| OwnedFile {
fd,
opener: context.id(),
});
self.writer.with_mut(|w| w.close_fd = file.is_none());
Comment on lines +931 to +935

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.

🟣 pre-existing, not blocking: pre-existing: sinks on pipes, FIFOs and sockets (including subprocess.stdin) still report "closed" while close(2) is queued on the work pool, so a child waiting for EOF stays blocked until a pool thread is free. The fix only takes ownership of the fd when !self.pollable at FileSink.rs:931; every pollable handle keeps close_fd = true and goes through pipes.rs:107, which hands the fd to Closer and then calls on_close_fn at pipes.rs:115 in the same frame. Fix: make every POSIX FileSink close before it reports, e.g. let the sink own pollable fds too (or make close_impl report after the pool close completes), which the PR description claims for "every POSIX handle" but the code does not do.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A script spawns a child with stdin: "pipe", writes, and calls subprocess.stdin.end(); the stdin FileSink is on a pollable fd. FileSink::setup at src/runtime/webcore/FileSink.rs:931-935 only creates an OwnedFile when !self.pollable.get(), so for a pipe file is None and w.close_fd stays true. PosixStreamingWriter::close (src/io/PipeWriter.rs:1134) then calls close_impl with close_fd = true; on Linux close_async is true (src/io/pipes.rs:66), so the fd is queued on the pool via Closer::close at pipes.rs:108 and on_close_fn runs immediately at pipes.rs:115-118. FileSink::on_close finds no OwnedFile and reports closed at FileSink.rs:640 with the write end still open in this process, so a child reading stdin does not see EOF until a pool thread runs the close; with the pool busy the child blocks indefinitely. This is the same as the base branch; the PR fixes the regular-file sibling only, while its description says close_impl closes before it reports for every POSIX handle. The new test suite has no FIFO or pipe row, so nothing detects it.

Verification: pre-existing. Any POSIX FileSink on a pollable fd ends while the work pool is busy. At src/runtime/webcore/FileSink.rs:931-935 a pollable fd leaves close_fd = true and file = None; pipes.rs:107-108 hands the fd to Closer::close(fd, ()) and pipes.rs:115-118 invokes on_close_fn in the same frame. The base fails identically. The new filesink.test.ts rows are all regular files.

// A restart onto a pollable fd does not pass through `on_close`.
if let Some(previous) = self.file.replace(file) {
previous.fd.close();
}
}
}
}
Expand Down Expand Up @@ -1628,6 +1742,8 @@ impl FileSink {
pump_promise_ref: Cell::new(false),
js_sink_ref: JsCell::new(bun_jsc::strong::Optional::empty()),
abort_handle: bun_jsc::AbortHandle::for_owner::<FileSink>(),
#[cfg(unix)]
file: Cell::new(None),
}
}
}
Expand Down Expand Up @@ -1904,6 +2020,11 @@ impl FileSink {

// What the stream's code threw, which need not be an Error.
if let Some(err) = promise_result.to_error() {
// `Bun.write(dest, stream)` rejects its promise with it, once the file is closed.
if self.pipe.get().has_done() {
crate::dispatch::fold(self.handle_reject_stream(global_this, err));
return Ok(JSValue::UNDEFINED);
}
self.release_pipe();
return Err(global_this.throw_value(err));
}
Expand Down
Loading
Loading