io: unregister a FilePoll from the loop it was registered with (spawnSync double close)
Problem
- The x64-asan
bun test --parallelworkers abort withpanic: assertion failed: err.is_none()inbun_sys::FdExt::close, called frombun_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 runstest/regression/issue/20753.test.jsright after19652.test.ts(aBun.spawnSync). Bun.spawnSyncpointsvm.event_loop_handleat 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'sprocess.stderrFileSink.FilePoll::unregisterresolves the loop throughvm.event_loop_handle, so theEPOLL_CTL_DELgoes to the private loop's epoll (src/io/posix_event_loop.rs,deinit_possibly_defer). ENOENT is swallowed. The fd is adup()of fd 2, so closing it leaves the entry in the main epoll. The nextBun.file(2).writer()dups to the same number,EPOLL_CTL_ADDfails with EEXIST,FileSink::setupcloses the fd, and the writer's Drop closes it again through theCloser.
Fix
FilePollremembers the loop it registered with (registered_loop). Every laterepoll_ctl/keventand 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::setupno longer closes the fd itself whenwriter.startfails on POSIX. The writer's poll already holds the fd and closes it with the writer;Terminal::initand the spawn stdin path rely on that, andsetupwas 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 rantest/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 (aPipeReader,PipeWriter,Process) registers, re-arms and unregisters it.EventLoopCtx::loop_mut()resolves "the loop" from the VM's current handle.Bun.spawnSyncruns 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::Closercloses an fd on the work pool so the event loop thread never blocks inclose(2).FdExt::closeasserts 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.stdoutandprocess.stderrare built on it, so every--isolatetest 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