io: leave the fd with the caller when a POSIX pipe writer fails to start
Merged
RO
2mo ago+316+316+012345678901234567890123456789-38-38-01234567890123456789
Problem
- ASAN test workers (
bun test --test-worker --isolate) die withpanic: assertion failed: err.is_none()in<bun_core::util::Fd as bun_sys::fd::FdExt>::close(src/sys/fd.rs:73), called frombun_io::closer::Closeron a thread pool thread: the deferredclose(2)got EBADF, so the fd number had already been closed by someone else. Seen three times across seven PR builds (for example build 94781); the worker's in-flight file is reported as the crash and passes on retry, so it never fails a build. - Cause:
PosixStreamingWriter::start(src/io/PipeWriter.rs) stores the fd in a freshly created poll and then registers it. Whenepoll_ctlfails it returnsErrwith the poll still holding the fd.FileSink::setup(src/runtime/webcore/FileSink.rs:679) closes the fd on thatErr, andBlob.writer()then releases the sink, whose writerDrophands the same fd number toCloser. Debug builds trip the assertion; release builds close whatever fd has been given that number in the meantime (reproduced: a/dev/nullfd opened right after came back EBADF). - What makes the registration fail in CI is the
EEXISTfrom #37968: under--isolateeach file'sprocess.stdout/stderrsink leaves a stale epoll entry keyed on a dup number that a later dup lands on. Sinceinternal/fs/streamswrapsBun.file(fd).writer()in a try/catch, that failure is silent and only the double close is visible. #38008 removes that trigger; this PR fixes the double close, which follows any registration failure (ENOSPC whenmax_user_watchesis exhausted, for instance). PosixBufferedWriter::starthas the same shape. Its callers happened to rely on the opposite convention (the writer closes the fd on failure), as didBun.Terminaland the subprocess stdinFileSink, so the two conventions coexisted and the callers disagreed about who owns the fd after a failedstart().
Fix
- A failed
start()on either POSIX writer releases the poll it created (PollOrFd::close_without_closing_fd, new) and leaves the fd with the caller. A poll left over from an earlierstart()is not touched: it still holds that earlier fd, which the writer does own (the shell re-arms its writer this way). - Why this direction: it makes
start()have no effect on failure, matches the Windows writer (a failed open never adopts the handle), and makes the fd's owner the same on every path: whoever passed it in closes it onErr, the writer closes it afterOk.FileSink::setupis already written this way and is now correct without changes. - Callers that relied on the writer closing the fd on failure now close it themselves:
Bun.Terminalcloses its pty write fd on every platform (the Windows branch already did),Writable::initcloses the stdin pipe after releasing the sink,StaticPipeWriter::startcloses itsstdio_result. The shellIOWriter's EINVAL/EPERM retry no longer needs to tear the poll down itself; it owns its fd separately and is otherwise unchanged. - The same callers also relied on the writer's later
close()reportingon_close, which an empty writer no longer does. ForBun.spawnwith a buffer stdin that report was what retired theWritable::Bufferslot; left in place it counts as pending activity and pins theSubprocesswrapper forever (caught in review). The spawn bindings now retire the slot on that error path themselves (on_close_io(Stdin)), on POSIX only since Windows adopts the pipe beforestart()and cannot fail there. The shell drops its subprocess outright on this path andBun.Terminaldestroys itself, so they needed nothing. close_implon Windows ignored itsclose_fdargument; it now honors it so the new helper means the same thing on every platform (no live Windows caller passesfalse;PollOrFdonly backs the POSIX reader and writers).- Verification:
test/js/bun/util/filesink.test.ts("whose registration fails closes the dup exactly once"): recreates the CI condition on a socketpair fd (close a sink's dup from under it, the next dup gets EEXIST), with the thread pool parked so the stray close has to land on the fd that reused the number. Unfixed debug and release builds reportreusedStillOpen: false; with the fix it stays open. Linux only, as kqueue drops registrations with the fd.test/js/bun/spawn/spawn-pipe-start-error.test.ts: an LD_PRELOAD shim fails every writableepoll_ctlregistration (Bun issues them throughsyscall(2)), and fixtures forstdin: "pipe", a buffer stdin (memfd disabled so the pipe writer path is taken) andnew Bun.Terminal()assert that the fd count and the number of liveSubprocess/Terminalwrappers return to baseline with an empty stderr. All three pass on bun 1.4.0 and with this PR; with the three caller-side closes removed each fixture reportsleakedFds: 1, and without the bindings change the buffer fixture reportsleakedWrappers: 1. 30/30 runs stable on the debug build.- Also run: filesink, bunshell, shell file-io/epipe/output, spawn, spawnSync, terminal, spawn stdin stream suites;
cargo fmt --checkandcargo clippyonbun_io/bun_spawn;cargo checkofbun_io,bun_spawnandbun_runtimeforx86_64-pc-windows-msvc.
Background
PollOrFdis the handle inside the POSIX pipe readers and writers: either a bare fd or aFilePoll, the event loop's epoll/kqueue registration, which records the fd it watches. Closing the handle unregisters the poll and then closes the fd, normally throughCloser, which performs theclose(2)on the thread pool so the event loop does not block on it. That delay is why the second close lands after the fd number has been reused.Fd::closeasserts thatclose(2)did not return EBADF in debug and ASAN builds, because for an internally owned fd EBADF means a double close and therefore a possible close of an unrelated fd. That assertion is the crash in the report and is intentionally left in place.FileSinkis the native writer behindBun.file(...).writer(),process.stdout/stderrand a piped subprocess stdin;StaticPipeWriterwrites a Buffer/Blob stdin into a child; both own aPosixStreamingWriter/PosixBufferedWriterfromsrc/io/PipeWriter.rs.- A
Subprocessholds a strong reference to its own JS wrapper while it has pending activity: a child that has not exited, or a stdio slot still in use. Each slot is retired throughon_close_io, which the stdio object reports when it closes; once the slots are retired and the child has exited, the reference is downgraded and the wrapper becomes collectable. - Registering a pollable fd can fail in production with ENOSPC (
fs.epoll.max_user_watches) or, as in #37968, EEXIST when epoll still holds an entry for the same open file under the same fd number; epoll keys entries on (open file, fd number), so an entry outlives an fd number that was closed while another fd kept the file open.
Bugs noticed on the way, tracked separately
ShellSubprocess::abort_after_failed_startleaks the not-yet-started stdout/stderr pipe fds when the buffered stdin writer fails to start (visible with the shim from the new test). Handed off.- The security scanner's handling of a failed
StaticPipeWriter::startreleases one ref too many; #37898 already changes that site.
no test proof · iteration 3 · 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/bun/spawn/spawn-pipe-start-error.test.ts