test --isolate: end the outgoing file's process stdio sinks at the global swap
RO
24d ago+216+216+012345678901234567890123456789-6-6-0123456789
Fixes #37968
Problem
- Intermittent unhandled
error: EEXIST: file already exists, epoll_ctlatnew WriteStream (internal:fs/streams:244)whileprocess.stderris created inside abun test --isolatefile (Linux, stderr on a pipe). The file then fails with "Cannot call describe() after the test run has completed". - Under
--isolateevery file gets a fresh global. Its first touch ofprocess.stdout/process.stderrbuilds a new sink:Bun.file(fd).writer()dups the stdio fd and registers the dup with epoll at construction (PosixStreamingWriter::start,src/io/PipeWriter.rs). Nothing ended the outgoing file's sinks at the swap, so each file leaked one dup per stream plus its epoll registration (the reporter's suite reached 45+ open dups of the stderr pipe). - epoll keys an interest entry by {file description, fd number}. A stale entry whose fd number is reused for a later dup of the same pipe makes the next
EPOLL_CTL_ADDfail with EEXIST.
Fix
constructStdioWriteStream(BunProcess.cpp) hands the new sink toBun__trackProcessStdioSinkForTestIsolation. Outside--isolateit returns at once. Under it, the sink arms itsAbortHandleon the VM's root context, as aBun.ModuleGraphfile sink already does.- The swap's sweep after
retireForTestIsolationends the sink: buffered output is flushed, the dup closed, the epoll entry removed. The sweep before the swap skips these sinks (on_abortrecords them,stop_active_handles_for_test_isolationre-arms them), because the file's socket close handlers still run in between and may write to stderr. - At VM teardown the sink keeps the existing native close. A backpressured
end()there would leave a poll and a keep-alive ref behind with no loop to drain them. - Verified:
test/cli/test/isolation.test.ts, "stdio sinks do not leak a dup'd fd per file" (counts dups of fd 1 and fd 2 across 4 isolate files via/proc; released bun counts 2,3,3,4 and the fix holds them constant) and "a socket close handler can still write to process.stderr at the swap" (the output was dropped when the sinks ended in the first sweep). Also the full isolation suite,filesink.test.ts, and the process stdio suites.
Background
bun test --isolateruns every file in a fresh global of one VM. Between filesswap_global_for_test_isolationruns two sweeps over the root context'sAbortHandles: one before the swap, while the file's close handlers may still run, and one after the realm is retired, when nothing enters its script again.- An
AbortHandleis the per-owner link into aScriptExecutionContext.stop_handlesunlinks each handle before its callback, so a callback cannot re-arm itself inside the sweep. The caller re-arms the stdio sinks right after. - On POSIX the stdio sinks are forced synchronous after creation, but the epoll registration made at construction stayed. That registration is what leaked and collided.
Downsides
- Output order across files under heavy stdout backpressure is unchanged: a backpressured
end()drains on the loop while the next file starts, as released bun already does with--isolate. A drain barrier would instead stall between files when the reader stops reading. Left for a maintainer decision. process.stdinis not covered. Its reader polls fd 0 itself, with no dup, and a second file that reads stdin still hits EEXIST. Pre-existing and separate.
Notes
- The dup's
close(2)runs on a work-pool thread on Linux (Closer::close), while the epollCTL_DELis synchronous. The fd-count fixture records the first file's count as a baseline and later files poll, with a deadline, until the previous file's deferred close lands. A leaked dup never closes, so the unfixed build still fails. - Earlier revisions tracked the sinks in the
ActiveHandleregistry. #42590 replaced that registry with context-owned handles, and the PR was ported to them in a merge from main. - #37128 replaces the per-global sinks with one VM-lifetime sink per fd. If it lands first, the per-file end here no longer applies.
no test proof · iteration 5 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/test/isolation.test.ts