Repository navigation
Conversation
The test "leaves fds 0-2 open when it has no FileSink" destroys a warmup tty.WriteStream, waits for its 'close' event, and then opens files until open() fails with EMFILE. On Linux the FileSink of that stream closes its dup of the descriptor on a thread pool thread, after 'close'. When that close runs after the last open(), one descriptor is free, and one of the three streams under test gets a FileSink. The script now finds the descriptor that the warmup stream opened and waits until it is closed before it takes every descriptor.
|
No actionable comments were generated in the recent review. WalkthroughThe TTY test now waits for sink descriptors opened by a warmup ChangesTTY descriptor exhaustion test
Possibly related PRs
Suggested reviewers: Severity of issue fixed: Low Merge Risk:
|
|
Updated 10:05 PM PT - Nov 7th, 2026
✅ @robobun, your commit 21e5b149095d47e6f79e928c7a15a610315a71d1 passed in 🧪 To try this PR locally: bunx bun-pr 48167That installs a local version of the PR into your bun-48167 --bun |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
LGTM — a small, self-contained de-flake of a single test; the inline note is a nit, not a blocker.
Extended reasoning...
The diff touches only test/js/node/tty.test.ts (+15/-1), replacing a hardcoded ulimit value with a shared constant and making the child script snapshot open fds around the warmup tty.WriteStream so it can wait for the FileSink's dup to actually close before the EMFILE loop. It touches no production code and no security-sensitive surface. The fd diff is correctly a no-op when exhaustDescriptors is false (Array(0) yields an empty list), the scan range matches the ulimit so every child descriptor is visible, and the existing assertions on sinks/destroyed/open are unchanged, so the test still protects the same property. The changed path is not covered by CODEOWNERS and the hunt ran dry with only a deadline-on-poll nit outstanding.
|
Status: ready for review. Test-only change. CI: no test failed. In build 138485, 184 of 188 jobs passed: each Linux lane, the ASAN lane, Windows, and macOS arm64. The other 4 jobs are How I reproduced it. On a release build of main (2596ac5), run the one test 300 times, 8 at once: seq 300 | xargs -P 8 -I{} sh -c 'bun test test/js/node/tty.test.ts -t "no FileSink" >/dev/null 2>&1 && echo PASS || echo FAIL' | sort | uniq -c
|
If the FileSink never closed its descriptor, the child script waited until the test reached its time limit, with no output. The wait now stops after 10 s with an error that names the descriptor that is open.
There was a problem hiding this comment.
LGTM — the second commit bounds the wait, which was the only thing I had flagged, and the fix matches the mechanism in source.
What was reviewed:
- Confirmed the comment's claim in
src/io/pipes.rs: on non-macOS POSIXPollOrFd::close_implalways handsclose(2)to the work pool (Closer::close->WorkPool::schedule_owned) and runson_close_fnin the same frame, so there is no event to await and a bounded poll is the right shape. - Checked the
exhaustDescriptors === falsepath:openFds()returns[], sosinkFdsis empty and the loop is skipped; the unlimited test and Windows run no new logic. - Checked the deadline path: the
throwinside the async IIFE surfaces as an unhandled rejection, so a leaked descriptor fails the test with the fd number on stderr rather than a harness timeout.
Extended reasoning...
The diff touches only test/js/node/tty.test.ts, inside the destroyStdioStreams helper: it hoists the ulimit value into a constant, snapshots open fds around the warmup tty.WriteStream construction, and after 'close' polls on setImmediate with a 10 s deadline until the sink's dup'd fd is actually closed before the EMFILE exhaustion loop. It touches no production code and no security-sensitive surface. Approve was decided by the change being small and self-contained, the cross-thread close claim being verifiable in src/io/pipes.rs and src/io/lib.rs, the prior inline nit being addressed by the new commit, and no CODEOWNER or outstanding third-party objection on the file.
Problem
test/js/node/tty.test.ts, "leaves fds 0-2 open when it has no FileSink" (tty: keep fds 0-2 open on destroy, and give WriteStream its own prototype #47395), passes only on a retry in 11 of the last 400 CI builds, on every Linux lane. The child prints"sinks":[true,true,false,false]or[true,false,true,false], not[true,false,false,false].tty.WriteStream, awaits'close', then opens files untilEMFILE. On Linux the stream'sFileSinkcloses its descriptor on a pool thread after it reports the close (src/io/pipes.rs:108). A close after the lastopen()frees a descriptor, and one stream under test gets aFileSink.Fix
'close'it waits until that descriptor is closed.ulimit -n 64a scan of 0 to 63 sees every descriptor. Without the limit nothing is scanned, so Windows runs no new code.bun bd test test/js/node/tty.test.ts: 15 pass.Background
tty.WriteStreamwrites through aFileSink, which on POSIX owns a dup of the fd. If the dup fails, the stream writes withnode:fsto the fd itself.PollOrFd::close_implhands close(2) of a pipe to the work pool and callson_closein the same frame. io: close a pipe inPollOrFdbefore it reports closed (POSIX) #46800 proposes to close in place and waits for a ruling. This change holds either way.Behaviour change: none
Notes
Reproduction. Run the one test many times, 8 at once, on a release build:
Main (2596ac5): 267 PASS, 33 FAIL. The failures print
[true,true,false,false](14),[true,false,true,false](14) and[true,false,false,true](5). An earlier run of the same command gave 284 PASS and 16 FAIL. This branch: 1000 PASS. On a debug build, 4 at once: 24 of 24 PASS.Probes of the child script.
'close', descriptor 6 is still open in 5 of 6 runs. It is closed onesetImmediatelater. Debug build: it is closed at'close'in 4 of 4 runs.The first attempt. I moved
warmup.destroy()below the construction of the three streams, so that no close was in flight. A release build passed 1000 of 1000 runs. A debug build fails every run withFATAL: bun-debug failed to load bundled version of "internal:process/tick_ring": a stream without aFileSinkcallsprocess.nextTickin its constructor, and the debug build reads that module from disk at the first call. On main the warmup'sdestroy()makes that first call before the descriptors are gone. So the warmup stays as it is, and the script waits for its descriptor.CI sightings. 11 of 400 builds, #137568 to #138371 (Nov 4 to Nov 7): 137823, 137881, 137894, 137969, 138047, 138063, 138093, 138140, 138213, 138333, 138350. Lanes: debian 13 x64 (3), alpine 3.23 aarch64 (3), debian 13 aarch64 (2), ubuntu 25.04 aarch64 (2), alpine 3.23 x64 (1). Output:
[true,true,false,false]6,[true,false,true,false]5. The first is 137823 (Nov 5, 22:35 UTC). The 140 scanned builds before it have none.macOS.
close_implcloses in place there when the poll is writable and nonblocking. The 400 builds have no sighting on a macOS lane. I did not run macOS. The wait loop does not run when the descriptor is already closed.If the descriptor never closed, that would be a descriptor leak in
FileSink. The wait stops after 10 s and throwsopen after 'close': fd <n>. The child exits with code 1, and the message is in thestderrfield of the failed assertion. CI gives each test of this file 90 s (270 s on the ASAN lane), so the 10 s limit reports first there. A copy of the script that keeps one more descriptor in the list printserror: open after 'close': fd 2and exits with 1.After the deadline was added (second commit): release build, 8 at once, 1000 of 1000 runs pass. Debug build:
bun bd test test/js/node/tty.test.ts15 pass, and the one test 16 of 16 runs, 4 at once.no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/tty.test.ts