io: unregister a FilePoll from the loop it was registered with (spawnSync double close)

Closed
RO
2mo ago
+115-7

Problem

  • The x64-asan bun test --parallel workers abort with panic: assertion failed: err.is_none() in bun_sys::FdExt::close, called from bun_io::Closer::run. The deferred close finds the fd already closed. It hit 15 of the last 16 asan builds, always in the worker that runs test/regression/issue/20753.test.js right after 19652.test.ts (a Bun.spawnSync).
  • Bun.spawnSync points vm.event_loop_handle at its private loop for the whole call (SpawnSyncEventLoop::prepare). The GC epilogue runs inside that window, on the first JS allocation after the wait (create_resource_usage_object), and finalizes the previous test file's process.stderr FileSink. FilePoll::unregister resolves the loop through vm.event_loop_handle, so the EPOLL_CTL_DEL goes to the private loop's epoll (src/io/posix_event_loop.rs, deinit_possibly_defer). ENOENT is swallowed. The fd is a dup() of fd 2, so closing it leaves the entry in the main epoll. The next Bun.file(2).writer() dups to the same number, EPOLL_CTL_ADD fails with EEXIST, FileSink::setup closes the fd, and the writer's Drop closes it again through the Closer.

Fix

  • FilePoll remembers the loop it registered with (registered_loop). Every later epoll_ctl/kevent and every poll-count update for that poll goes to that loop, not to whatever loop the caller resolves at teardown time. The field is cleared when the registration is removed.
  • FileSink::setup no longer closes the fd itself when writer.start fails on POSIX. The writer's poll already holds the fd and closes it with the writer; Terminal::init and the spawn stdin path rely on that, and setup was the one caller that closed it a second time.
  • Correct because an epoll registration belongs to the epoll instance that holds it, and the kernel only drops it when the last fd for that open file closes: for a dup of stdio that never happens while the process runs, so the DEL has to reach the right instance.
  • Verified: test/js/bun/spawn/spawnSync.test.ts (new test, fails on main: the fixture aborts with this panic). The 68-file CI bucket that crashed reproduces locally with the runner's environment in about 1 of 4 runs before the fix and 0 of 10 after. Also ran test/js/bun/spawn/, test/js/bun/io/, test/js/node/child_process/.

Background

  • FilePoll (src/io/posix_event_loop.rs) is one epoll/kqueue registration for an fd. It is pool-allocated and its owner (a PipeReader, PipeWriter, Process) registers, re-arms and unregisters it. EventLoopCtx::loop_mut() resolves "the loop" from the VM's current handle.
  • Bun.spawnSync runs the child's I/O on a separate uws loop so the main loop's timers and sockets do not fire while JS is blocked. To get readers and writers created during the call onto that loop, it swaps the VM's loop handle for the duration of the call.
  • bun_io::Closer closes an fd on the work pool so the event loop thread never blocks in close(2). FdExt::close asserts the close succeeds: EBADF means the number was closed by someone else first.
  • Bun.file(fd).writer() dups the fd and registers the dup for writability. process.stdout and process.stderr are built on it, so every --isolate test file creates and later finalizes one such dup of fd 1 and fd 2.
Notes

The panic was visible in the worker log but not in annotations: the runner re-runs the crashed files alone, they pass, and the job is marked flaky. Builds 106463 through 106643 all carry it.

Reproduction needed the runner's exact environment. BUN_GARBAGE_COLLECTOR_LEVEL=1 is what makes expect() request a collection that then completes inside the following spawnSync. With that variable the 68-file bucket (bun test --parallel=7 on 8 cores) reproduces in about 1 of 4 runs.

Event timeline for the failing fd from an instrumented asan build (fd close history with stack traces):

epoll ADD ok (default loop)            FileSink writer for dup(2) registered on the main epoll
epoll DEL ENOENT (OTHER loop)          FileSink::finalize from the GC epilogue inside spawnSync
Closer::close scheduled                fd handed to the work pool
FdExt::close (work pool)               fd closed; the main epoll keeps (pipe, fd)
epoll ADD EEXIST (default loop)        next Bun.file(2).writer() dups to the same number
FileSink::setup closes the fd          first close
Closer::close scheduled (writer Drop)  second close -> EBADF -> panic

The GC epilogue stack: create_resource_usage_object -> allocateSlowCase -> Heap::collectIfNecessaryOrDefer -> Heap::collectInMutatorThread -> sweepPreciseAllocations -> FileSink::finalize -> PosixStreamingWriter::drop -> PollOrFd::close -> FilePoll::deinit_force_unregister.

The poll-count drift is the same mechanism: deactivate and disable_keeping_process_alive decremented the private loop's counters for a main-loop poll. #40508 fixed one instance of that drift for keep-alive refs; this routes every counter update for a poll to its own loop.

On macOS kqueue knotes are per fd, so closing the dup removes them and the EEXIST symptom cannot appear there. The test is Linux-only for that reason. The wrong-loop EV_DELETE and the counter drift applied on macOS too and are fixed by the same change.

Suites run with the debug build: test/js/bun/spawn/, test/js/bun/io/, test/js/node/child_process/, test/js/web/streams/ (FileSink/FileReader users), test/cli/test/isolation.test.ts.


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