Conversation
… a write on macOS The 'readable' while paused test snapshotted one coalesced chunk. On Windows the parent's second write leaves one event loop turn after the first, so a busy parent delivers two chunks. The child now joins what read() returns and prints it on 'end'. The pipe backpressure run() helper rethrew every write error except EPIPE. macOS reports ENOTCONN when the child closes its end while the write is inside the kernel.
|
Warning Review limit reached
On-demand reviews are free for the next 10 days. After that, they cost $0.25 per reviewed file. Or wait 51 seconds for your next included review. View limit detailsLimit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (1)
Comment |
|
Status: fix for the How I reproduced it:
CI on this PR (build 114093):
|
There was a problem hiding this comment.
LGTM — test-only de-flake that follows the repo's own rules.
What was reviewed:
- The rewritten "readable while paused" test: still asserts the original property (data arrives via
read()while paused), addsisPaused()at'end', and drops only the non-contractual chunk-boundary assumption; the removedsetTimeout(1000)+exit(1)was the anti-pattern CLAUDE.md forbids. - Pipe draining now uses
Promise.all([stdout, stderr, exited])with stdout/stderr asserted before the exit code — matches harness convention. ignoreEpipewidening:ENOTCONNis gated onisMacOSonly, so Linux still fails loud; the WHY comment is present.
Extended reasoning...
Overview
This PR touches a single test file, test/js/node/process/process-stdin.test.ts, with two independent de-flake fixes and no runtime code changes. First, the "stdin with 'readable' event handler should receive data when paused" test is rewritten so the child accumulates read() chunks, waits for 'end', and prints a JSON summary; the parent drains stdout/stderr/exited concurrently and asserts the joined payload plus isPaused(). This replaces a setTimeout(1000)-driven process.exit(1) and a snapshot that assumed both writes arrive as one chunk. Second, the ignoreEpipe helper in the pipe-backpressure block now also swallows ENOTCONN on macOS, with a comment explaining XNU can surface it when the peer closes mid-send().
Security risks
None. This is test-only code with no changes to runtime behavior, no new dependencies, and no security-sensitive paths involved.
Level of scrutiny
Low-to-moderate. The main risk with de-flake PRs is silently weakening an assertion. I checked that against REVIEW.md's "keep asserting the property the original assertion protected": the original test's property was that a paused stdin still delivers data through 'readable'/read(). That is preserved and strengthened — the new test additionally asserts isPaused() is still true at 'end' and, by removing the exit timer, also fails if pause() lets the process exit before EOF. What was dropped (both writes arriving as exactly one buffer) is not a stream contract on any platform; the PR body's Windows analysis of WindowsStreamingWriter and test.concurrent scheduling explains why it flapped.
Other factors
The change lines up with the repo's test conventions point-for-point: Promise.all pipe drain, stdout/stderr asserted before exit code, bunExe()/bunEnv, isMacOS from harness rather than a hand-rolled check, and a load-bearing WHY comment on the platform branch. The ENOTCONN allowance is narrowly scoped (isMacOS && e?.code === "ENOTCONN"), so Linux and Windows semantics are unchanged and any other errno still throws — consistent with "map only the specific expected errno to the benign path". No CODEOWNERS entry covers this file, there are no prior review objections in the timeline, and the bug hunt exited on dry_streak with no findings.
|
Updated 8:04 PM PT - Sep 10th, 2026
❌ @robobun, your commit 1ea21b9 has 1 failures in 🧪 To try this PR locally: bunx bun-pr 42260That installs a local version of the PR into your bun-42260 --bun |
#44072) ### Problem - `test/js/bun/console/console-write.test.ts:134` fails on macOS with `- "caught EPIPE` / `+ "caught ENOTCONN`. - The case closes the reader while the child writes 8 MB. The macOS kernel fails the write that the close lands in with `ENOTCONN`, not `EPIPE`. - #43649 added the case and expects `EPIPE` only. Builds 121009 and 120123 were red: each attempt failed with this or with the unhandled rejection that #43679 fixes. ### Fix - On macOS the case accepts `caught EPIPE` or `caught ENOTCONN`. Other platforms accept `caught EPIPE` only. - The case still requires a rejected promise, no other stderr output, and exit code 0. - Verified on a CI Mac (macOS 26.6.1, M2), `main` canary, 150 runs of the file. `caught ENOTCONN` fails 7 runs of the old file and 0 runs of the new file. - `bun bd test test/js/bun/console/console-write.test.ts` passes on Linux. ### Background - `Bun.spawn` gives a child with `stdout: "pipe"` one end of an AF_UNIX socketpair. - XNU `sosend()` tests for `EPIPE`, then unlocks the socket to copy the bytes. `uipc_send()` then tests "connected" first and returns `ENOTCONN`. Each later write returns `EPIPE`. - Considered a retry in `try_write_with_write_fn` (`src/io/PipeWriter.rs:84`) so that the writer reports `EPIPE`. #40935 decided that Bun passes the kernel errno through, as Node does. Node gave `ENOTCONN` in 65 of 150 runs of this scenario. - The unhandled rejection still fails 67 of those 150 runs. With #43679 and this PR the file passes 100 of 100 runs. <details><summary>Notes</summary> **This PR changes one test.** No file under `src/` changes. **Kernel path** (xnu-12377, `bsd/kern`): - `sosendcheck()` (`uipc_socket.c`) tests `SS_CANTSENDMORE` first and returns `EPIPE`. - `sosend()` calls `socket_unlock()` before the `uiomove` copy and `socket_lock()` after it. It calls `pru_send` with no second state test. - `uipc_send()` (`uipc_usrreq.c`, `SOCK_STREAM` branch) tests `SS_ISCONNECTED` first (`ENOTCONN`) and `SS_CANTSENDMORE` second (`EPIPE`). - `soisdisconnected()` (`uipc_socket2.c`) clears `SS_ISCONNECTED` and sets `SS_CANTSENDMORE` in one step. - `write(2)` on a socket takes the same path (`soo_write()` calls `sosend()`). **C probe, no Bun and no Node.** An 8 MB write on an AF_UNIX socketpair with 512 KB buffers. The peer reads one chunk and closes. 500 runs each. | host | macOS | non-blocking `send()` | blocking `write()` | | --- | --- | --- | --- | | M2 mini | 26.6.1 arm64 | `ENOTCONN` 141 | `ENOTCONN` 396 | | M2 Ultra | 15.7.9 arm64 | `ENOTCONN` 442 | `ENOTCONN` 392 | | Intel i7 | 14.8.9 x64 | `ENOTCONN` 1 | `ENOTCONN` 287 | | Linux x64 | | `ENOTCONN` 0 of 300 | `ENOTCONN` 0 of 200 | All other runs gave `EPIPE`. So the errno is not specific to macOS 26 or to one machine. With the default socket buffer size the M2 mini gave `EPIPE` in 500 of 500 runs. One more write directly after the `ENOTCONN` returned `EPIPE` in 1659 of 1659 runs. A socket that was never connected returns `ENOTCONN` on each write. **The scenario of the test**, on the M2 mini (macOS 26.6.1), Bun `1.4.3-canary.1+37da174d5`, Node v26.3.0: | parent | child | `EPIPE` | `ENOTCONN` | | --- | --- | --- | --- | | bun | bun, `await console.write(big)` | 63 | 137 | | bun | bun, `process.stdout.write(big, cb)` | 66 | 84 | | bun | node, `process.stdout.write(big, cb)` | 145 | 55 | | node | node, `process.stdout.write(big, cb)` | 85 | 65 | On macOS 14.8.9 x64, node parent and node child: `ENOTCONN` in 18 of 200 runs. **Runs of the test file** on the M2 mini: | binary | test file | runs | failed | failures | | --- | --- | --- | --- | --- | | `main` canary | `main` | 150 | 76 | 7 `caught ENOTCONN` (one argument), 69 unhandled rejection (several arguments) | | `main` canary | this PR | 150 | 67 | 67 unhandled rejection (several arguments) | | #43679 (4133870) | #43679 | 100 | 3 | 3 `caught ENOTCONN` (one argument) | | #43679 (4133870) | #43679 merged with this PR | 100 | 0 | | The test on `main` stays flaky on macOS until #43679 merges. **The decision in #40935.** An earlier revision of #40935 folded `ENOTCONN` into `EPIPE` in `write_to_socket`. The merged commit (118fdd2) dropped the fold: "The kernel errno is passed through unchanged." Node accepts both codes on macOS in its own test (`test/js/node/test/parallel/test-cluster-concurrent-disconnect.js:27-33`). At that time the race was not reproduced (1100 runs, all `EPIPE`). The probes above reproduce it. This PR keeps the decision. **`process.platform` and not `isMacOS`.** #43679 changes the `harness` import line of this file. The test reads `process.platform` so that the two PRs merge in either order with no conflict (checked with `git merge-tree`). **The same errno in other tests.** In the annotations of the last 60 finished builds, `ENOTCONN` appears in the output of three test files: this one, `test/js/bun/util/filesink.test.ts` (#43794 removes the race there) and `test/js/node/process/process-stdin.test.ts` (#42260 ignores the code on macOS). </details> <!-- robobun:evidence:begin --> --- **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/bun/console/console-write.test.ts <!-- robobun:evidence:end -->
Problem
test/js/node/process/process-stdin.test.tsis red onmain. Windows: "stdin with 'readable' event handler should receive data when paused" getsgot chunk {"type":"Buffer","data":[97,98,99,10]}and a second chunk. The snapshot has one. macOS:22 pass, 0 fail, 42 errors, eachENOTCONN: socket is not connected, write.WindowsStreamingWriter::process_send(src/io/PipeWriter.rs:2260) keeps oneuv_writein flight, so the secondwrite()leaves one event loop turn later. Since make some tests faster #33622 made the tests concurrent, 12 moreBun.spawncalls come before that turn, and the child reads first.run()helper rethrows every write error exceptEPIPE. XNU returnsENOTCONNwhen the peer closes whilesend()is inside the kernel. FileReader: release the event loop while a pipe reader is stopped at its highwater mark #42038 added children that exit right after a read.Fix
read()returns and prints it on'end'. No exit timer: only stdin keeps the child alive.run()also ignoresENOTCONNon macOS.ENOTCONN(libuv remaps onlyEPROTOTYPE).Background
Bun.spawn({ stdin: "pipe" })gives the child a named pipe on Windows and asocketpairon POSIX (512 KB buffers on macOS,src/spawn_sys/spawn_process.rs:826).process.stdin.read()returns the bytes buffered at that moment.test.concurrentruns the synchronous start of every test before the event loop turns again.Notes
Windows
test.concurrent: 9 synchronousBun.spawncalls then sat between the first and the secondWriteFile. process.stdin: route throwing 'data'/'readable' listeners to uncaughtException instead of destroying stdin #34019 and FileReader: release the event loop while a pipe reader is stopped at its highwater mark #42038 added concurrent tests that also run on Windows, so it is 12 now. The lane went from a one-retry flake (onmainsince at least build 104713, 2026-08-24) to red on every attempt (113935).Bun.spawntakes about 6 ms, and the child's firstread()comes about 16 ms after its script starts. A parent that writes"abc\n","def\n",end()and then stays busy delivers one chunk when busy for 0, 10 or 20 ms and two chunks ("abc\n"at 15 ms,"def\n"right after the busy time) when busy for 40, 80 or 160 ms.-t) passes 10 of 10 runs on that VM. With the whole file it fails 10 of 10.macOS
sosend()checks the socket state (sosendcheck:SS_CANTSENDMOREfirst, soEPIPE), then drops the socket lock while it copies the user data, then callsuipc_send().uipc_send()testsSS_ISCONNECTEDfirst (ENOTCONN) andSS_CANTSENDMOREsecond (bsd/kern/uipc_usrreq.c).unp_disconnect()changes both flags at once. A close that lands during the copy givesENOTCONN. With 512 KB buffers the copy is long enough to hit.socketpair, 512 KB buffers, a child that reads twice and closes, and a non-blocking writer:ENOTCONNin 40 to 59 of 500 runs,EPIPEin the rest. The same script on Linux:EPIPEin 500 of 500.EPIPE, 1ENOTCONN. Linux:EPIPEonly.ignoreEpipedates from stdin: apply highwater backpressure to the pipe FileReader source #35977, whose children stop reading and wait 1.5 s before they exit. The parent's socket is full by then, so no write is in progress at the close. FirstENOTCONNin CI: build 113381, two hours after FileReader: release the event loop while a pipe reader is stopped at its highwater mark #42038 merged. None in themainbuilds from 2026-08-21 up to then. Seen since in 113463, 113483, 113533, 113549, 113565, 113566, 113625, 113635, 113709, 113725, 113732, 113737, 113771, 113795, 113805, 113824, 113831, 113843, 113899, 113906, 113910, 113926, 113933, 113935. All 25 builds contain FileReader: release the event loop while a pipe reader is stopped at its highwater mark #42038.run()call: 40 writes,flush()andend()reject with the same error. Forcing a mismatch inignoreEpipeon Linux gives the same signature:1 pass, 0 fail, 42 errors.ENOTCONNstays an error on Linux: the check is gated onisMacOS.Coverage
pause()lets the process exit before EOF. It also assertsisPaused()is stilltrueat'end'.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/process/process-stdin.test.ts