Skip to content

test(body-clone): create the FIFO in-process, give its reader no stdin pipe - #43862

Open
robobun wants to merge 2 commits into
mainfrom
robobun/7401cad4/body-clone-fifo-hang
Open

robobun wants to merge 2 commits into
mainfrom
robobun/7401cad4/body-clone-fifo-hang

Conversation

@robobun

@robobun robobun commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • test/js/web/fetch/body-clone.test.ts, test "a body over a FIFO opened by path is still teed", times out at the 90000 ms deadline with killed 1 dangling process in 35 of 85 recent builds (119960 to 120061), 33 on alpine 3.23 aarch64. Its first statement, Bun.spawnSync({ cmd: ["mkfifo", fifo] }), spins (the bug of spawnSync: release polls and keep-alives on the loop they counted on #40078). The dangling process is the mkfifo zombie.
  • It also fails with EPIPE: broken pipe, write on debian lanes (build 119808). cloneInChild() writes to the stdin of every child, but the FIFO child never reads stdin. If the test process stalls after the spawn, the child exits first and flush() rejects.

Fix

  • The test creates the FIFO with the mkfifo helper (test/mkfifo.ts), as fetch.test.ts does. The file no longer calls spawnSync.
  • cloneInChild() takes feedStdin. The FIFO child gets stdin: "ignore" and no writes.
  • Verified: bun bd test test/js/web/fetch/body-clone.test.ts (85 pass). The Notes reproduce each failure with the old test.

Background

  • Bun.spawnSync waits on a private event loop. A loop whose poll count is 0 returns from its tick without epoll_wait. spawnSync: release polls and keep-alives on the loop they counted on #40078 shows how a GC finalizer that runs during the wait subtracts a main-loop poll from the private loop. A later spawnSync then never sees its child exit.
  • The test is about Response.clone() over a FIFO, not about how the FIFO is made.

Downsides

Notes

Timeout, reproduced with the unmodified test. Put the fixture of #40078 (three Bun.stderr.writer() sinks that the GC finalizes inside a warmed-up spawnSync) as a test in front of the FIFO test. Run with stderr on a pipe:

$ BUN_JSC_slowPathAllocsBetweenGCs=5 bun test --timeout=6000 body-clone-e2e.test.ts -t "make drift|FIFO" 2>&1 | cat
collectedDuringCall 3
(pass) ... > make drift
killed 1 dangling process
(fail) ... > a body over a FIFO opened by path is still teed [6021.63ms]
  ^ this test timed out after 6000ms.

This is the CI output (build 120061, alpine 3.23 aarch64, shard 5: killed 1 dangling process, [90001.50ms], this test timed out after 90000ms). Release canary 6808912 (linux x64 musl), 367d939 (linux x64 glibc) and a debug build all give it. With the test of this PR behind the same fixture, the FIFO test passes (37 ms on the release build, 1.6 s on the debug build).

Why the CI history looks the way it does. The spin needs a GC sweep inside an earlier spawnSync of the same worker process, so it follows allocation history. Workers take the sorted file list in contiguous ranges.

  • 247 sampled builds from 113000 to 117515: 0 hits. First hit: build 117516 (2026-09-18). The file moved from shard 13 (build 116800) to shard 5 (build 118600) in that window.
  • 60 sampled builds from 118930 to 119640: 0 hits. Hits again from build 119650.
  • In all six failing buckets I read (builds 118600, 118900, 118920, 119650, 119980, 120061) the file sorted before body-clone.test.ts is a test/js/third_party/jsonwebtoken/*.test.js file. In the two buckets from the quiet window (builds 119100, 119300) it is grpc-js/test-deadline.test.ts.
  • No x64 run here hit it without the fixture: 0 of 230 runs of the 70-file bucket of build 120061 under bun test --parallel=3 (musl canary, busybox sh), 0 of 1800 runs of the three-process scenario under load.

EPIPE. A busy wait of 30 ms between Bun.spawn() and the first proc.stdin.write() in the old helper gives EPIPE at flush() in 12 of 12 runs (musl release build). Without the wait it shows up in 48 of 1500 runs under load, at end() or at flush(). The two stdin tests keep the pipe: their child reads stdin to EOF, so it cannot exit before 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/web/fetch/body-clone.test.ts

@robobun

robobun commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:45 PM PT - Sep 23rd, 2026

✅ @robobun, your commit 059ad0c8dcd6cb7a65e3cb0dfca7e382e3e91cf2 passed in Build #120137! 🎉


🧪   To try this PR locally:

bunx bun-pr 43862

That installs a local version of the PR into your bun-43862 executable, so you can run:

bun-43862 --bun

…n pipe

"a body over a FIFO opened by path is still teed" fails in two ways in
the parallel batch.

It times out at the bun:test deadline with "killed 1 dangling process",
mostly on alpine aarch64. Its first statement was
Bun.spawnSync(["mkfifo", fifo]). That call spins when a GC finalizer
has left the poll count of spawnSync's isolated loop off (#40078). The
test now creates the FIFO with the mkfifo test helper, like
fetch.test.ts and streams.test.js, and no longer calls spawnSync.

It fails with "EPIPE: broken pipe, write" when the test process stalls
after the spawn. cloneInChild() wrote to the stdin of every child, but
the FIFO child never reads stdin and can exit first. The helper now
pipes stdin only to a child that reads it.
@robobun
robobun force-pushed the robobun/7401cad4/body-clone-fifo-hang branch from f78f221 to acc16d6 Compare September 24, 2026 00:46
@robobun robobun changed the title test: find where the body-clone FIFO test stalls on alpine aarch64 test(body-clone): create the FIFO in-process, give its reader no stdin pipe Sep 24, 2026
@robobun
robobun marked this pull request as ready for review September 24, 2026 00:50
@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 1 billable file and costs up to $0.25.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 3 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 9afc11a3-0331-44e2-92e6-ed51a677eac3

📥 Commits

Reviewing files that changed from the base of the PR and between c936de3 and 059ad0c.

📒 Files selected for processing (1)
  • test/js/web/fetch/body-clone.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Both failures of the FIFO test reproduce on linux x64 (release canaries 6808912 musl and 367d939 glibc, and a debug build):

  • Timeout with killed 1 dangling process: put the drift fixture of spawnSync: release polls and keep-alives on the loop they counted on #40078 as a test in front of the unmodified FIFO test. Run with BUN_JSC_slowPathAllocsBetweenGCs=5 and stderr on a pipe. Bun.spawnSync({ cmd: ["mkfifo", fifo] }) spins until the test deadline.
  • EPIPE: broken pipe, write: add a 30 ms busy wait between Bun.spawn() and the first proc.stdin.write() in cloneInChild().

With this PR the FIFO test passes in both setups. The spawnSync spin itself is the subject of #40078, not of this PR.

PR: #43862

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

LGTM — small, test-only change that swaps a spawned mkfifo for the in-repo FFI helper and stops feeding stdin to a child that never reads it; the inline note is a naming nit, not a correctness problem.

Extended reasoning...

The diff touches only test/js/web/fetch/body-clone.test.ts: cloneInChild gains an options object with feedStdin, and the FIFO test creates its FIFO via the existing mkfifo helper (test/mkfifo.ts, mapped in test/tsconfig.json and already used by fetch.test.ts and others). All three call sites of cloneInChild were checked — the two stdin tests pass no second argument and keep the default pipe, and the FIFO test uses the new { args, feedStdin: false } shape, so no positional array is silently dropped. With stdin: feedStdin ? "pipe" : "ignore" the proc.stdin type resolves to FileSink | undefined via WritableToIO, so the if (stdin) guard narrows correctly and the writes are skipped only for the FIFO child. No security-sensitive surface is touched, no CODEOWNERS entry covers this path, and the file is not listed in test/expectations.txt changes; the only surviving finding is the misleading bothBodiesReadStdin name applied to the FIFO child, which is cosmetic.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 test/js/web/fetch/body-clone.test.ts — A reader of the FIFO test is told its child read stdin when it has no stdin at all, which misleads the next person debugging this file. The expected object at test/js/web/fetch/body-clone.test.ts:1170 is named bothBodiesReadStdin, and :1189 asserts the FIFO child (spawned with stdin: "ignore" at :1156) against it. On base the FIFO child at least had a stdin pipe; this diff makes the name contradict the code. Fix: rename to something store-neutral such as bothBodiesSeeWholeInput (and use it at :1173, :1177, :1189), or give the FIFO test its own expected object.

    Why this was flagged

    test/js/web/fetch/body-clone.test.ts:1170 defines bothBodiesReadStdin. This diff changes the FIFO child to stdin: "ignore" (:1156 with feedStdin: false at :1189) so that child cannot read stdin, yet :1189 still asserts toEqual(bothBodiesReadStdin). The repo's landing rules (as summarized to this review) say a name or comment that contradicts code is a correctness bug for readers, and the PR description's own sweep for stale state missed it. Population: every maintainer reading this describe block; rate: each time the test is read or extended. The dismissal said base already reused the name, but base's FIFO child did receive a stdin pipe with 'hello world' written to it, so the name was only misleading there and is outright false now. Remedy: rename the constant to describe the invariant (both bodies see the whole input) rather than the input source.

    Verification: nit — triggered whenever someone reads the FIFO test; nothing executes wrongly. Verified in /home/claude/bun/test/js/web/fetch/body-clone.test.ts: line 1170 const bothBodiesReadStdin = { stdout: \["hello world","hello world"]`, stderr: "", exitCode: 0 };; line 1189-1191 asserts the FIFO child's result toEqual(bothBodiesReadStdin)while passingfeedStdin: false`, which line 1156…

The FIFO child has no stdin, so bothBodiesReadStdin no longer described
it. Both bodies see the whole input, whatever the store is.
@robobun

robobun commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

Renamed the constant to bothBodiesSeeWholeInput in 059ad0c. The FIFO child has no stdin, so the old name no longer described it. bun bd test test/js/web/fetch/body-clone.test.ts passes (85 tests).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — small, test-only change that swaps a spawned mkfifo for the in-process FFI helper and stops feeding stdin to the FIFO-reading child.

What was reviewed:

  • cloneInChild in test/js/web/fetch/body-clone.test.ts: with feedStdin: false the child gets stdin: "ignore" and proc.stdin is undefined, so the if (stdin) guard skips the write/flush/end; the two stdin tests keep the default and are unchanged in behavior.
  • The mkfifo import resolves through test/tsconfig.json to test/mkfifo.ts, the same helper fetch.test.ts already uses; it throws on failure rather than returning silently, so the removed exitCode assertion is not a lost check.
  • FIFO ordering: the sh -c printf writer is spawned before the reader child, both ends open before either proceeds, and writer.exited is still asserted; the test stays gated by test.skipIf(isWindows).
Extended reasoning...

The diff touches one test file, test/js/web/fetch/body-clone.test.ts, across two commits: it replaces Bun.spawnSync(["mkfifo"]) with the in-process mkfifo helper, adds a feedStdin option to cloneInChild so the FIFO child is spawned with stdin "ignore" and receives no pipe writes, and renames the shared expected-result constant. No runtime source is changed and no security-sensitive surface is involved. The change follows an existing pattern in fetch.test.ts, the file is not covered by CODEOWNERS, the bug hunt ran to a dry streak with no findings, and the only commit after the earlier automated comment is the constant rename, so an approval is a reasonable decision here.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant