Repository navigation
FileSink: close a file before end() or Bun.write() reports it closed #46088
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// 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 | ||
|
|
@@ -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(); | ||
|
|
@@ -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), | ||
| ); | ||
|
robobun marked this conversation as resolved.
Resolved
|
||
| return; | ||
| } | ||
|
Comment on lines
+623
to
+637
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Every Why this was flaggedA 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 |
||
| 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(); | ||
|
|
||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Why this was flaggedA 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 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 |
||
| // A restart onto a pollable fd does not pass through `on_close`. | ||
| if let Some(previous) = self.file.replace(file) { | ||
| previous.fd.close(); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -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), | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -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)); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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-writtenimpl Drop(lines 94-101), andCloseStreamFile::runat 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.