shell: finish the command when its buffered stdin is the last stdio to close - #37799
shell: finish the command when its buffered stdin is the last stdio to close#37799robobun wants to merge 8 commits into
Conversation
…o close A subprocess Cmd with a Buffer/Blob stdin redirect completes once it has an exit code and stdin, stdout and stderr have all closed. The exit and the stdout/stderr closes each checked has_finished() and transitioned the Cmd to Done; the stdin close only set its flag. When the child exits without draining its stdin, the StaticPipeWriter's pending write fails only after the read end is gone, so the stdin close can be processed after the other three events and the command never completes. Cmd::buffered_input_close now shares the has_finished() -> Done tail with buffered_output_close and returns the Yield, which ShellSubprocess::on_static_pipe_writer_done drives. on_close_io releases the writer slot before signalling, since the trampoline can reach Cmd::deinit and free the subprocess.
|
Warning Review limit reached
Next review available in: 7 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
|
Updated 5:05 PM PT - Aug 12th, 2026
❌ @robobun, your commit 5b16549 has some failures in 🧪 To try this PR locally: bunx bun-pr 37799That installs a local version of the PR into your bun-37799 --bun |
|
Status: ready for review. CI build 93510 on the current head (5b16549) finished with 179 of 181 jobs passed and no test failures; the other 2 are macOS test shards that Buildkite expired without ever getting an agent, so they would need a retry of just those two jobs. (The earlier build 93088, with the shell fix and the first version of the tests, passed all 181 jobs including macOS; the current tests have since run green on the Linux, ASAN and both Windows lanes.) Reproduced with |
The Yield returned by the Cmd completion callbacks can reach Cmd::deinit, which frees the ShellSubprocess and recycles the Cmd's arena slot, so it must not run while a &mut to either is on the stack. Cmd::on_exit now returns its Yield like buffered_input_close/buffered_output_close, and the two ShellSubprocess callbacks that run them (the stdin writer close and the process exit handler) take `this: *mut Self`, the shape the PipeReader callbacks already use. on_close_io is left with the stdout/stderr reader path only.
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/runtime/shell/states/Cmd.rs:75-76— The doc comment onSubprocExec.interpstill referencesShellSubprocess::on_static_pipe_writer_done, which the follow-up commit c87211c removed and replaced withon_stdin_writer_close. The sibling doc onfinish_if_donewas updated to the new names (PipeReader::finish_after_state_set,on_stdin_writer_close/on_process_exit) — this one was just missed in the rename.Extended reasoning...
What the issue is
The doc comment on
SubprocExec.interpat Cmd.rs:75-76 was updated by this PR's first commit to say the returnedYieldis driven by "PipeReader::run_yield/ShellSubprocess::on_static_pipe_writer_done". The PR's second commit (c87211c) then removedon_static_pipe_writer_doneentirely and replaced it withon_stdin_writer_close(to fix the&mut self-across-free UB flagged in the earlier review), but this doc comment was not updated in that rename.Step-by-step proof
- Grep the tree for
on_static_pipe_writer_done: the only hit insrc/is this doc comment at Cmd.rs:76. No function by that name exists. - In subproc.rs, the
StaticPipeWriterProcess::on_close_iotrait shim now forwards toSelf::on_stdin_writer_close(this), and that function is what callsbuffered_input_close()and drivesy.run(&handle.interp). - The sibling doc on
finish_if_done(a few lines below in the same PR) correctly names the drivers as "PipeReader::finish_after_state_set,ShellSubprocess::on_stdin_writer_close/on_process_exit" — confirming the intended names and that this one comment was simply missed during the second commit's rename sweep. - The other comment updates in this PR (e.g. Cmd.rs:886 "via stdin
on_stdin_writer_close→buffered_input_close", subproc.rs:526 "re-enterson_stdin_writer_close") all use the new name.
Also note
PipeReader::run_yieldis slightly imprecise for the same reason — the actual caller that drives the trampoline for stdout/stderr isPipeReader::finish_after_state_set(which callsrun_yield_with), matching what thefinish_if_donedoc says.Why this matters
Per REVIEW.md ("One source of truth; update every consumer atomically" — renames must grep the whole repo), a doc comment that names a removed function will send the next reader to grep for something that no longer exists. It's not a runtime defect, so it doesn't block merge, but since the PR itself introduced both the reference and the rename, it's cleanest to fix here.
Fix
Align this comment with the
finish_if_donedoc:/// `Yield::Next(this_id)` for the caller (`PipeReader::finish_after_state_set`, /// `ShellSubprocess::on_stdin_writer_close` / `on_process_exit`) to drive.
- Grep the tree for
There was a problem hiding this comment.
I re-reviewed after c87211c/db06282 and confirmed the earlier &mut self-protector concern is addressed: on_stdin_writer_close and on_process_exit now take *mut Self end-to-end (the link_impl_ProcessExit! thunk binds this: *mut T, and the StaticPipeWriterProcess shim forwards raw), and the Cmd-side callbacks return their Yield so no &mut Cmd spans y.run(). No new issues found. Given this reworks subprocess-lifecycle teardown ordering with hand-reasoned refcount and Stacked-Borrows invariants, a maintainer look would still be worthwhile.
What was reviewed
finish_if_doneunification: same gate (nullinterpduring spawn) preserved from the oldbuffered_output_close/on_exit, andtransition_to_execstill resumes fromstate = Done.deinit_in_flight_iore-entry intoon_stdin_writer_close: withexecalready taken,buffered_input_closereturnsYield::suspended()andrun()onSuspendedis a no-op; the writer's create/start refs are still released once each.- Removed
Writable::Pipearm ofon_close_io: POSIXWritable::initnever returnsPipe, and WindowsFileSinkcloses go throughWritable::on_close— the arm was unreachable fromStaticPipeWriter's callback. y.run(&handle.interp):ParentRef<Interpreter>implementsDeref<Target=Interpreter>, matchingYield::run(&Interpreter).
Extended reasoning...
Overview
The PR fixes a hang where a shell command with a < ${buffer} stdin redirect never completes when the child exits without draining stdin and the stdin-close event happens to be delivered last. The fix makes Cmd::buffered_input_close a full completion path (mirroring buffered_output_close and on_exit) via a shared finish_if_done tail, and reshapes the ShellSubprocess callbacks that drive those Yields to take *mut Self so no &mut ShellSubprocess/&mut Cmd argument protector spans the trampoline that can synchronously free them. Three files: src/runtime/shell/states/Cmd.rs, src/runtime/shell/subproc.rs, and four new tests in test/js/bun/shell/bunshell.test.ts.
Security risks
None identified. This is internal event-ordering / lifecycle plumbing; no new user-controlled input parsing, no auth/crypto/permissions surface. The subprocess spawn path itself is unchanged.
Level of scrutiny
High. Per REVIEW.md this sits squarely in the most-blocked category (native memory safety, refcount balancing on every terminal path, self-freeing callbacks). The change hand-reasons about Stacked-Borrows protectors, RefPtr ref balances across create()/start(), and re-entrancy through deinit_in_flight_io. My earlier round found a real protector-UB issue that was fixed correctly, which itself signals this needs careful eyes. The PR description also flags rebase interactions with three other in-flight PRs (#37774, #37652, #36895), which a maintainer should coordinate.
Other factors
- The Stacked-Borrows fix I flagged was applied at both the stdin-close and process-exit paths (the latter being a pre-existing instance the PR now also cleans up). I verified
link_impl_ProcessExit!bindsthisas*mut T(src/dispatch/lib.rs:33), so the raw forward is genuine. - I traced the
deinit→deinit_in_flight_io→on_stdin_writer_closere-entry:execis taken toNonebefore the close fires, sobuffered_input_closeshort-circuits toYield::suspended(), andYield::runonSuspendedwith an empty pipeline stack returns immediately — no double transition or use-after-free there. - The removed
on_close_iostdin arms:Writable::Pipeis only produced on Windows and its close routes throughWritable::on_close(viaFileSink::source), andStaticPipeWriterProcess::on_close_iois only called for stdin — so the reduction to a stdout/stderr-onlyon_close_ioplus a dedicatedon_stdin_writer_closelooks correct. - Tests cover Buffer, Blob, pipeline parent, and
||parent, forcing the stdin-close-last ordering deterministically. The PR reports they time out on 1.4.0 and pass on the fix, and that the broader shell suite passes on debug ASAN. - All prior inline comments (mine and comment-cop) are resolved on the current head.
…s stdin semantics StaticPipeWriter::on_close now nulls its process backref before the single on_close_io dispatch, since the shell's impl can free the process (and run the rest of the script) from inside it; the trait doc says so. The shell tests release the helper holding the child's stdin only once the child's pid is gone, instead of after a fixed delay, and add a variant whose helper drains the redirect after the child has exited: it must receive all of it, and the command must wait for that.
…PHANS The ASAN CI lanes set BUN_FEATURE_FLAG_NO_ORPHANS, under which the child kills the helper holding its stdin as soon as it exits, so the draining variants never got a result. Drop the flag from the command's environment, have every helper record what it did (so a helper that died early no longer passes as one that exited on cue), keep its stderr for the failure message, and use a 4 MiB redirect so the write is pending regardless of socket buffer sizes.
There was a problem hiding this comment.
I re-reviewed after ede94fd and 4372d29 — the earlier findings (the &mut self protector across y.run, the ASAN-lane test failure, and the failure-path cleanup) are all addressed, and this pass found nothing new. Given the scope — self-freeing callbacks through raw pointers in the shell subprocess lifecycle, plus a contract change to StaticPipeWriter shared with Bun.spawn and the security scanner — a human look is still worthwhile.
What was reviewed:
on_stdin_writer_close/on_process_exitnow take*mut Selfend-to-end; verified no&mut ShellSubprocessor&mut Cmdprotector spansy.run(&handle.interp), matching thePipeReader::on_reader_doneshape.StaticPipeWriter::on_closenullsprocessbefore the single dispatch; checked the Windows tail (release_start_refderef) only touches the writer, whichwrite()'s +1 keeps alive past a freed process.buffered_input_closereturningYield::suspended()whenexecis already taken keeps thedeinit_in_flight_iore-entry a no-op.- Test
run()helper now releases the helper and joins the command infinally; theBUN_FEATURE_FLAG_NO_ORPHANSunset explains the earlier ASAN failure.
Extended reasoning...
Overview
Fixes a hang where await $cmd < ${buffer}`` never resolves when the child exits without draining stdin and the stdin-close event arrives after the exit and both output EOFs. Touches src/runtime/shell/states/Cmd.rs (unifies the four completion events into a shared `finish_if_done` tail), `src/runtime/shell/subproc.rs` (`on_stdin_writer_close` and `on_process_exit` take `*mut Self` and run the returned `Yield`; the stdin arm is split out of `on_close_io`), `src/spawn/static_pipe_writer.rs` (nulls the process backref before dispatching `on_close_io`, and documents that the impl may free it), and adds six tests to `bunshell.test.ts`.
Security risks
None identified. No new user-controlled input parsing; the change is control-flow ordering in an existing subprocess-lifecycle path. The StaticPipeWriter contract change is a tightening (null-before-dispatch) and does not weaken any check.
Level of scrutiny
High. This is memory-safety-sensitive unsafe Rust: raw *mut Self callbacks that may synchronously free their receiver, Stacked-Borrows argument-protector reasoning, and a shared component (StaticPipeWriter) used by Bun.spawn, the shell, and the install security scanner. The reasoning is careful and the shape mirrors the existing PipeReader::on_reader_done/finish_after_state_set pattern, but the class of bug (UAF via re-entrant teardown) is exactly what REVIEW.md flags as the most-blocked category, so it should not be auto-approved.
Other factors
- My two prior review rounds each found issues; all are resolved and the author's responses correctly diagnosed the ASAN-lane failure (
BUN_FEATURE_FLAG_NO_ORPHANSset byscripts/runner.node.mjs, not load). - The removed
Writable::Pipearm ofon_close_iowas verified unreachable per the PR description (shell never creates a FileSink stdin;Writable::init's POSIXStdio::Pipearm panics). - CI: build 93088 (fix alone) was green; 93409 (current tests) green apart from github.com download failures; 93437 (HEAD, adds only test failure-path cleanup) did not compile due to the same infra outage. No test signal against HEAD itself, though the delta from 93409 is test-only cleanup.
- The PR description names three overlapping open PRs (#37774, #37652, #36895) that will need coordination on merge.
There was a problem hiding this comment.
I reviewed this PR and didn't find new issues; the earlier concerns (the &mut self protector spanning y.run(), the ASAN-lane test failure under BUN_FEATURE_FLAG_NO_ORPHANS, and the failure-path helper cleanup) are addressed in the current revision. Given the amount of raw-pointer / self-freeing-callback reasoning across Cmd, ShellSubprocess, and the shared StaticPipeWriter, a human look is still worthwhile.
What was reviewed:
on_stdin_writer_close/on_process_exitnow take*mut Selfend-to-end and the trait shim / exit thunk forward raw, so no&mut ShellSubprocessor&mut Cmdprotector spans the trampoline that can reachCmd::deinit.StaticPipeWriter::on_closenullsprocessbefore the single dispatch; the writer's own&mut selfis kept live bystart()'s ref (or the onedeinit_in_flight_ioclaims) across the callback, so freeing the process there doesn't free the writer under it.- The
deinitre-entry viabuffered_input_closeafterexecis taken hits theExec::Nonearm and returnsYield::suspended()— no double transition. - The other two
StaticPipeWriterProcessimpls (Bun.spawn'sSubprocess, the install security scanner) receive the same pointer they got before; the trait's changed contract only widens what an impl may do.
Extended reasoning...
Overview
This PR fixes a hang in Bun's shell where await $cmd < ${largeBuffer}`` never resolves when the stdin-close event happens to arrive after the process exit and both output EOFs. The fix touches four files: src/runtime/shell/states/Cmd.rs (unifies the four completion-event tails into a shared `finish_if_done`; `buffered_input_close` now returns a `Yield` like the other three), `src/runtime/shell/subproc.rs` (`on_stdin_writer_close` replaces `on_static_pipe_writer_done` + the stdin arm of `on_close_io`; both it and `on_process_exit` take `*mut Self` and are forwarded raw so no `&mut` protector spans the trampoline that can free the subprocess), `src/spawn/static_pipe_writer.rs` (trait doc now states the impl may free the process; `on_close` nulls the backref before dispatch), and `test/js/bun/shell/bunshell.test.ts` (six new tests forcing stdin-close-last, covering Buffer/Blob × exiting/draining helper, plus pipeline and `||` parents).
Security risks
None identified. The change is internal lifecycle plumbing in the shell's subprocess state machine. No new user-controlled input parsing, no auth/crypto/permission surface. The StaticPipeWriter trait contract change is shared with Bun.spawn and the install security scanner, but those impls are unaffected (they get the same pointer they got before and don't free themselves from the callback).
Level of scrutiny
High. This is exactly the category REVIEW.md flags as most-blocked: raw *mut Self callbacks that may synchronously free their receiver, intrusive refcount balancing across three owners (the writer's create() ref, start() ref, and the one deinit_in_flight_io claims), and Stacked Borrows protector reasoning that already required one round of correction on this PR. The finish_if_done refactor is straightforward, but the surrounding pointer discipline (CmdHandle copy-out before the borrow, the exec-taken re-entry guard in buffered_input_close, the interp.is_null() spawn-frame gate) is load-bearing and easy to get subtly wrong. The six new tests are well-constructed (each helper records what it did so an early death can't pass; try/finally releases the helper and joins the command on the failure path; BUN_FEATURE_FLAG_NO_ORPHANS is dropped from the command's env), but they exercise a deliberately-forced ordering, so the memory-safety argument still rests on the code review.
Other factors
I reviewed this PR twice previously with concrete findings; all three were addressed (c87211c for the protector issue, ede94fd for the ASAN-lane orphan-kill, 4372d29 for the failure-path cleanup). The comment-cop nags were also resolved. The bug hunting system found nothing this run. CI on the current head (5b16549, an empty retrigger) is still building. The PR description is unusually thorough about the mechanism, the alternatives considered, and the interaction with three overlapping open PRs (#37774, #37652, #36895), which a human reviewer will want to weigh.
|
This came up again from the other direction (a child that exits without reading Two things about this PR's current state:
|
Problem
await $`cmd < ${buffer}`, where the child exits without reading a redirect bigger than the pipe, sometimes never resolves (2 of 8 runs on bun 1.4.0), and the process does not exit either, since the interpreter still counts as pending activity. It hangs every time if something holds the child's stdin open for a moment after the child exits.EPIPEonce the read end of the pipe is gone, which is at the same time as, or after, the exit and the stdout/stderr EOFs.Fix
&mut self(the shape the stdout/stderr readers already had; the exit path had the same latent problem), and the shared pipe writer nulls its process backref before its single close notification.||) force the stdin close to be the last event. All six time out without thesrc/changes and pass with them on bun 1.4.0 and a debug ASAN build; the existing shell, spawn and install-scanner tests still pass.Background
< ${buffer}(or Blob orResponse) redirect in the Bun shell is pumped into the child's stdin over a pipe by aStaticPipeWriter, a writerBun.spawnand the install security scanner also use. It reports its close to its owning process through a raw backref.Cmdthat spawned a subprocess tracks the exit code plus a closed flag per piped stdio and is finished only when all are in, whoever holds the pipes: a helper that inherited the child's stdin keeps the stdin side open after the child is gone, and the shell keeps pumping to it.Yield(resume this node, or nothing) that the caller runs; running it can complete the parent node and free theCmdand its subprocess, so nothing may still borrow either at that point.no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/shell/bunshell.test.ts
Original description
Repro
This never resolves on some runs (2 of 8 with bun 1.4.0 here, about 2 of 3 on a debug build). Whether it hangs depends on the order in which the event loop happens to deliver four events that all become ready when the child exits. This variant forces the problematic order and hangs every time: a helper keeps the child's stdin open (without reading it) for a moment after the child itself has exited.
Cause
A
Cmdrunning a subprocess is complete once it has an exit code and every piped stdio has closed (Cmd::has_finished). For a Buffer/Blob/Responseredirect, stdin is piped: the bytes are pumped into the child by aStaticPipeWriter, andBufferedIoClosed::stdinhas to be marked closed too. Three of the four events that can complete the command checkedhas_finished()and moved theCmdtoDone(Cmd::on_exit, andCmd::buffered_output_closefor stdout and stderr). The fourth,Cmd::buffered_input_close(reached from the writer's close throughShellSubprocess::on_close_ioandon_static_pipe_writer_done), only set the flag.When the child exits without draining its stdin, the pending write fails with EPIPE only once the pipe's read end is gone, so the stdin close is processed at the same time as, or after, the exit and the stdout/stderr EOFs. Whenever it lands last, the command is complete and nothing transitions it. The promise never settles, and since the interpreter still counts as pending activity the process does not exit either (the script above has to be killed), even though the child and all of its pipes are gone by then.
Debug trace of a hanging run (`BUN_DEBUG_SHELL=1 BUN_DEBUG_SHELL_SUBPROC=1 BUN_DEBUG_StaticPipeWriter=1`)
Fix
Cmd::buffered_input_close,buffered_output_closeandon_exitall end in the samefinish_if_donetail (has_finished()->state = Done->Yield::Next(this), including the existing "spawn has not returned yet" gate thattransition_to_execresumes from) and return theYieldto their caller. Previously only the output closes and the exit did this, andon_exitran the Yield itself.buffered_input_closestays a no-op oncedeinithas takenexec, which is the only way it is reached during teardown (deinit_in_flight_io).ShellSubprocesscallbacks that run those Yields,on_stdin_writer_close(replaceson_static_pipe_writer_doneplus the stdin arm ofon_close_io) andon_process_exit, takethis: *mut Selfand are forwarded raw by theStaticPipeWriterProcessshim and thelink_impl_ProcessExit!thunk. Running the Yield can reachCmd::deinit, which frees the subprocess and recycles the Cmd's arena slot, so no&mutto either may be on the stack at that point; this is the shapePipeReader::on_reader_done/finish_after_state_setalready use for stdout/stderr, and it also removes the pre-existing instance of the problem in the exit path.on_stdin_writer_closeempties theWritable::Bufferslot before signalling (the subprocess may be gone afterwards); droppingcreate()'s ref there cannot free the writer the callback is running inside of, because every path intoStaticPipeWriter::on_closeholdsstart()'s ref (or the onedeinit_in_flight_ioclaims) until after the callback returns.on_close_iois left with the stdout/stderr reader path, its only remaining caller. Its oldWritable::Pipearm was unreachable: the shell never creates a FileSink stdin, and a FileSink reports its close throughWritable::on_close, not this function.StaticPipeWriter(src/spawn/static_pipe_writer.rs, shared withBun.spawnand the install security scanner) so far documented its process backref as outliving the writer. The shell's callback can now free the process from insideon_close_ioand run the rest of the script while doing so; nothing in the writer used the backref after that call before either, but the trait doc now states the contract, andon_closenulls the field before the (single) dispatch so a future use after it faults in every impl instead of dangling only for the shell. The other two impls are unaffected (they get the same pointer they got before).Why this shape: the completion condition is a conjunction of four independently delivered events, so each of them has to be able to perform the transition, and making the stdin close do what the other three already do is the whole fix. Exactly one transition happens per command in any interleaving: the transition tears the subprocess down synchronously, and the other events have by then already been delivered (they are what made
has_finished()true). The alternative would be to close the stdin writer when the child exits, asBun.spawn'sSubprocessdoes for its buffer stdin; that would also end the hang, but it changes what<means: a process that inherited the child's stdin and outlives it would get a truncated redirect. Waiting for the writer keeps stdin consistent with how the shell already treats stdout/stderr capture (the command finishes when the pipes close, whoever holds them), and it is whatBufferedIoClosedtracking stdin was evidently written for; the new tests pin that choice.#37774 fixes a separate leak of the writer's
start()ref on the same EPIPE path; it also editsStaticPipeWriter::on_close, so one of the two needs a trivial rebase, and they compose (it releases the ref after the dispatch). #37652 refactors theSubprocExecfieldsfinish_if_donereads. #36895 adds ReadableStream stdin and calls the oldon_static_pipe_writer_donefrom the exit handler; on top of this PR that call would need to move to theCmdside, since the replacement runs the Yield. None of them covers this hang.Tests
test/js/bun/shell/bunshell.test.ts, "stdin redirect still held open by a helper after the command's process has exited". The child (bun -e) spawns a detached helper that inherits its stdin, writes its own pid to a file, prints to stdout and stderr and exits 3 without reading anything. The test waits until that pid is gone (the exit and both EOFs are then processed, or queued ahead of anything the helper can still cause) and only then releases the helper through a file, so the stdin close is always the last event, on every platform and without a fixed delay. Every helper records what it did once released, so a helper that died early (which also fails the pending write) cannot pass as one that exited on cue; the redirect is 4 MiB so the write is still pending when the child exits whatever the socket buffer size. The command's environment dropsBUN_FEATURE_FLAG_NO_ORPHANS, which the ASAN CI lanes set and under which the child kills the helper as it exits (that is what the first CI run of the draining cases showed). Cases, for a Buffer and a Blob redirect each:||, so the completion reaches aPipelineand aBinaryparent, not only aStmt.All six time out without the
src/changes and pass with them, both with and withoutBUN_FEATURE_FLAG_NO_ORPHANS=1in the test process's environment (bun 1.4.0 and a debug ASAN build). Also passing on the debug ASAN build: the rest ofbunshell.test.ts(422 tests),shell-worker-terminate-leak(coversdeinit_in_flight_ioclosing the writer mid-flight),shell-hang,epipe,lazy,yield,pipeline_stack,bunshell-file,shelloutput,exec; for the other users of the writer,spawn.test.tsplus the stdin spawn tests both with memfd and withBUN_FEATURE_FLAG_DISABLE_MEMFD=1(which forcesBun.spawnonto the pipe writer), andbun-install-security-provider.test.ts.