test --isolate: end the outgoing file's process stdio sinks at the global swap

RO
24d ago
+216-6

Fixes #37968

Problem

  • Intermittent unhandled error: EEXIST: file already exists, epoll_ctl at new WriteStream (internal:fs/streams:244) while process.stderr is created inside a bun test --isolate file (Linux, stderr on a pipe). The file then fails with "Cannot call describe() after the test run has completed".
  • Under --isolate every file gets a fresh global. Its first touch of process.stdout/process.stderr builds 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_ADD fail with EEXIST.

Fix

  • constructStdioWriteStream (BunProcess.cpp) hands the new sink to Bun__trackProcessStdioSinkForTestIsolation. Outside --isolate it returns at once. Under it, the sink arms its AbortHandle on the VM's root context, as a Bun.ModuleGraph file sink already does.
  • The swap's sweep after retireForTestIsolation ends the sink: buffered output is flushed, the dup closed, the epoll entry removed. The sweep before the swap skips these sinks (on_abort records them, stop_active_handles_for_test_isolation re-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 --isolate runs every file in a fresh global of one VM. Between files swap_global_for_test_isolation runs two sweeps over the root context's AbortHandles: 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 AbortHandle is the per-owner link into a ScriptExecutionContext. stop_handles unlinks 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.stdin is 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 epoll CTL_DEL is 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 ActiveHandle registry. #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