Skip to content

spawn(windows): release StaticPipeWriter start() ref when buffer-stdin write completes - #35297

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/37c14955/static-pipe-writer-win-leak
Jul 24, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
farm/37c14955/static-pipe-writer-win-leak

Conversation

@robobun

@robobun robobun commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator

On Windows, Bun.spawn/Bun.spawnSync with a non-empty ArrayBuffer/Blob stdin leaked one StaticPipeWriter allocation per spawn.

Cause

StaticPipeWriter::start() takes a +1 and sets started = true. #30875 added a release_start_ref block to on_write() that clears started and derefs once the buffer drains, but gated it behind #[cfg(not(windows))]:

https://github.com/oven-sh/bun/blob/892b1dabc6/src/spawn/static_pipe_writer.rs#L228-L241

On Windows, when the uv_write completes before the child exits (the common ordering for a small buffer):

on_write_complete(status=0)
  -> Parent::on_write(Pending)             (release_start_ref gated out)
     -> writer.close()
        -> BaseWindowsPipeWriter::close -> on_close_source
           -> StaticPipeWriter::on_close -> Subprocess::on_close_io(Stdin)
              stdin <- Writable::Ignore; buffer.deref()   (create()'s +1)
  scopeguard deref                                         (write()'s +1)

Net: started is still true but stdin is Writable::Ignore, so the other release site, take_pending_start_writer (called from on_process_exit / close_io), matches nothing and start()'s +1 is stranded. The error arm of on_write_complete (e.g. ERROR_BROKEN_PIPE when the child closes stdin without reading) reaches on_close via close() without ever calling Parent::on_write, so start()'s +1 is stranded there the same way.

ShellSubprocess has the same leak for cmd < ${buf} stdin since its on_process_exit/close_io never look at started at all and depend entirely on on_write's release.

Fix

  • StaticPipeWriter::on_write: drop the cfg(not(windows)) gates so release_start_ref runs on every platform. WindowsBufferedWriter never passes EndOfFile, so the existing status != EndOfFile guard is a no-op there and is left for the POSIX path (PosixBufferedWriter::_on_write still touches self after the callback on EndOfFile).
  • StaticPipeWriter::on_close (Windows only): claim started and deref after on_close_io for paths that reach close() without going through on_write (the on_write_complete error arm). write()'s +1, held by that callback's scopeguard, keeps self live past the release. This is gated to Windows because on POSIX drain_buffered_data may call on_error() -> close() -> on_close and then on_write() on the same object with no extra ref held; io: harden POSIX pipe writer error paths against owner-dropping callbacks #34697 changes that ordering and the gate can go once it lands.
  • Subprocess::take_pending_start_writer: clear started when it claims the pointer. The three release sites are then mutually exclusive via started, covering the ordering where on_process_exit closes the pipe while a uv_write whose I/O already completed is still queued (libuv delivers that callback with its real status after uv_close, not ECANCELED).
  • WindowsBufferedWriter::on_write_complete: saturating_sub for has_pending_data so the late-success-after-close ordering does not underflow in debug builds.

SecurityScanSubprocess already derefs and writes started = false immediately after start(), so both new release sites see started = false; no change in its refcount balance.

Verification

Verified on Windows x64 by instrumenting Drop with a live counter and spawning 400 children per mode: unfixed leaves 400 live writers after each of the drain (64 B into sort) and reject (256 KB into cmd /c exit) workloads; fixed returns to 0 on both, in release and debug builds.

Also verified on Windows x64: spawn.test.ts -t 'stdin|Uint8Array|Blob' (45/45), spawnSync.test.ts (6/6), file-io.test.ts (26/26), child_process.test.ts (41/41), and on Linux: bunshell.test.ts (414/414), bun-security-scanner-workspaces.test.ts (3/3). rust:check-all is green across all 10 targets.

Found while working on #35286, which scopes its change to the empty-buffer path and does not overlap.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

The PR updates buffer-backed subprocess stdin writer reference handling across pending-start, write, and close callbacks, hardens Windows pending-data accounting, and adds Windows-only RSS regression tests for draining and rejecting child stdin consumers.

Stdin writer lifecycle

Layer / File(s) Summary
Writer start-reference handling
src/runtime/api/bun/subprocess.rs, src/spawn/static_pipe_writer.rs, src/io/PipeWriter.rs
Pending writers explicitly clear their started token when claimed; write completion and Windows close handling release the corresponding reference, while pending-data calculation avoids subtraction underflow.
Windows stdin leak regression coverage
test/js/bun/spawn/spawn-buffer-stdin-leak-fixture.ts, test/js/bun/spawn/spawn-buffer-stdin-leak.test.ts
A fixture measures RSS growth across warmed-up spawn batches, and Windows-only tests cover draining and rejecting stdin consumers.

Possibly related PRs

  • oven-sh/bun#34697: Hardens POSIX writer error and poll callbacks around parent references and conditional started releases.
  • oven-sh/bun#35286: Updates StaticPipeWriter write-end lifecycle behavior for empty-stdin pipe drain and close handling.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main change: fixing Windows buffer-stdin start-ref release in StaticPipeWriter.
Description check ✅ Passed The description is detailed and covers the fix and verification, but it does not use the repository's exact PR template headings.

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

Comment thread src/spawn/static_pipe_writer.rs

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/spawn/spawn-buffer-stdin-leak-fixture.ts`:
- Around line 38-41: Document the purpose of the 50 ms delay in settle():
explain that it allows finalization to complete after forced garbage collection
before RSS is measured, and retain the existing GC and sleep behavior.
- Around line 15-17: Expand spawn-buffer-stdin-leak-fixture.ts at lines 15-17 to
parameterize stdin construction for Buffer, ArrayBuffer, and Blob inputs, and
add a Bun.spawnSync measurement path at lines 26-31 while preserving both drain
and reject modes. Update spawn-buffer-stdin-leak.test.ts lines 38-64 to execute
both close-path modes across every supported stdin type and both Bun.spawn and
Bun.spawnSync APIs.

In `@test/js/bun/spawn/spawn-buffer-stdin-leak.test.ts`:
- Line 50: Remove the explicit 120_000 per-test timeout overrides at both
referenced test cases, allowing the repository-managed timeout policy to apply.
Do not alter the test behavior or surrounding assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a790339e-2b2c-4422-8b9d-703d9bba9b9a

📥 Commits

Reviewing files that changed from the base of the PR and between 892b1da and 0c928d0.

📒 Files selected for processing (4)
  • src/runtime/api/bun/subprocess.rs
  • src/spawn/static_pipe_writer.rs
  • test/js/bun/spawn/spawn-buffer-stdin-leak-fixture.ts
  • test/js/bun/spawn/spawn-buffer-stdin-leak.test.ts

Comment thread test/js/bun/spawn/spawn-buffer-stdin-leak-fixture.ts Outdated
Comment thread test/js/bun/spawn/spawn-buffer-stdin-leak-fixture.ts Outdated
Comment thread test/js/bun/spawn/spawn-buffer-stdin-leak.test.ts Outdated
@robobun

robobun commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:42 PM PT - Jul 23rd, 2026

❌ @robobun, your commit 87ce158 has 1 failures in Build #78892 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35297

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

bun-35297 --bun

Comment thread src/runtime/api/bun/subprocess.rs
@robobun
robobun force-pushed the farm/37c14955/static-pipe-writer-win-leak branch from 3e81ba8 to 576a55b Compare July 23, 2026 17:36

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/spawn/spawn-buffer-stdin-leak-fixture.ts`:
- Around line 32-34: Update the child-process completion handling around
proc.exited so each fixture child rejects when its exit code is nonzero, while
still resolving successful exits. Ensure Promise.all(procs) therefore fails when
any drain child fails and only passes after successful on_write execution.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3448ebfa-ed6a-4305-9794-5b1f8295bb3a

📥 Commits

Reviewing files that changed from the base of the PR and between 3518bf0 and 576a55b.

📒 Files selected for processing (5)
  • src/io/PipeWriter.rs
  • src/runtime/api/bun/subprocess.rs
  • src/spawn/static_pipe_writer.rs
  • test/js/bun/spawn/spawn-buffer-stdin-leak-fixture.ts
  • test/js/bun/spawn/spawn-buffer-stdin-leak.test.ts

Comment thread test/js/bun/spawn/spawn-buffer-stdin-leak-fixture.ts Outdated
@robobun
robobun force-pushed the farm/37c14955/static-pipe-writer-win-leak branch from 576a55b to a9afac1 Compare July 23, 2026 17:41
Comment thread test/js/bun/spawn/spawn-buffer-stdin-leak.test.ts Outdated
@robobun
robobun force-pushed the farm/37c14955/static-pipe-writer-win-leak branch from a9afac1 to 14b431c Compare July 23, 2026 17:57

@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.

All three earlier findings are addressed and I didn't find anything new, but this is intrusive-refcount lifecycle in the Windows spawn path — worth a human pass on the release-site mutual exclusion.

What was reviewed:

  • Traced start()'s +1 through the three release sites (on_write, Windows on_close, take_pending_start_writer) — all clear started before deref, so they're mutually exclusive; no double-deref found on the drain, error-arm, or exit-first orderings.
  • Checked on_write → writer.close() → on_close re-entry on Windows: started is already false in on_close, so the inner release is a no-op.
  • Confirmed SecurityScanSubprocess sets started = false immediately after start(), so both new release sites are no-ops there.
  • Verified on_write_complete's scopeguard holds write()'s +1 across the new on_close deref on the error arm.
Extended reasoning...

Overview

Fixes a Windows-only per-spawn leak of StaticPipeWriter when Bun.spawn/spawnSync is given a non-empty ArrayBuffer/Blob stdin. Touches src/spawn/static_pipe_writer.rs (removes cfg(not(windows)) gates on the on_write release, adds a Windows-only release in on_close), src/runtime/api/bun/subprocess.rs (take_pending_start_writer now clears started when it claims the pointer), and src/io/PipeWriter.rs (saturating_sub for the exit-first late-callback ordering — a fix I flagged in a prior run). Adds a Windows-only RSS-delta leak test with two modes covering the two release paths.

Security risks

None. This is internal refcount bookkeeping on a native writer struct; no user-controlled input reaches the changed logic beyond the buffer bytes themselves, which are only written to the child's stdin pipe as before.

Level of scrutiny

High. This is exactly REVIEW.md's most-blocked category — intrusive refcounts with multiple release sites made mutually exclusive via a started boolean, across three consumers (Subprocess, ShellSubprocess, SecurityScanSubprocess) and two platform-specific writer backends with different callback orderings. The PR description traces every path carefully and my own tracing agrees, but the correctness argument leans on libuv delivery-order guarantees (queued write callback after uv_close fires with real status, not ECANCELED) and on write()'s scopeguard keeping the object live across the new on_close deref. A maintainer with Windows-spawn context should confirm those invariants.

Other factors

All three of my earlier inline findings (error-arm leak, PipeWriter.rs:1513 debug underflow, test.concurrent) were addressed in follow-up commits, and all CodeRabbit threads are resolved. The new test has verified fail-before (USE_SYSTEM_BUN=1: 2.5 MB / 28 MB) and pass-after (~0 KB / ~350 KB) numbers in the PR body, with ASAN/debug-branched thresholds well below the unfixed leak. The POSIX on_close gate is explicitly justified (POSIX drain_buffered_data calls on_error then on_write with no extra ref held) with a note that #34697 will let it be dropped.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/spawn/static_pipe_writer.rs`:
- Line 318: Remove the unconditional eprintln!("SPW_DROP") debug print from the
buffer-backed writer destruction path in static pipe writer handling, leaving
the destruction behavior otherwise unchanged.

In `@test/js/bun/spawn/spawn-buffer-stdin-leak.test.ts`:
- Line 40: Update the test suite declaration for the leak measurements from
describe.concurrent.skipIf to describe.skipIf, preserving the existing Windows
condition so the two 6,000-process fixtures run sequentially.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e9a851c1-1dfa-41b9-bc73-c908a99884e9

📥 Commits

Reviewing files that changed from the base of the PR and between a9afac1 and 0be6a78.

📒 Files selected for processing (5)
  • src/io/PipeWriter.rs
  • src/runtime/api/bun/subprocess.rs
  • src/spawn/static_pipe_writer.rs
  • test/js/bun/spawn/spawn-buffer-stdin-leak-fixture.ts
  • test/js/bun/spawn/spawn-buffer-stdin-leak.test.ts

Comment thread src/spawn/static_pipe_writer.rs Outdated
Comment thread test/js/bun/spawn/spawn-buffer-stdin-leak.test.ts Outdated
Comment thread src/spawn/static_pipe_writer.rs Outdated
@robobun
robobun force-pushed the farm/37c14955/static-pipe-writer-win-leak branch from 0be6a78 to ddf245e Compare July 23, 2026 19:31

@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.

No new findings from me. My earlier comments (debug-only saturating_sub underflow, test.concurrent, and the leftover eprintln!("SPW_DROP")) are all addressed in the current diff — the temp print is gone and Drop now decrements a proper LIVE_COUNT atomic that the reworked test observes directly instead of RSS. Deferring to a human on the core change: the started-token refcount balance now spans three mutually-exclusive release sites (on_write, Windows-only on_close, and take_pending_start_writer) across several libuv callback orderings, which is squarely in the intrusive-refcount / lifecycle territory that warrants a maintainer's eyes.

What was reviewed: traced start()'s +1 through all three release sites for the write-first, exit-first, and error-arm orderings on Windows and confirmed each clears started before deref so no double-release; checked that on_write's ungated release on POSIX is behavior-preserving (it was already cfg(not(windows))); confirmed SecurityScanSubprocess and ShellSubprocess owners are not broken by the new on_close release (both see started == false or benefit from the fix); verified the new LIVE_COUNT atomic is only read via bun:internal-for-testing and the fixture polls with a bounded loop rather than a bare sleep.

Extended reasoning...

Overview

The PR fixes a Windows-only per-spawn leak of StaticPipeWriter when Bun.spawn is given an ArrayBuffer/Blob stdin. The fix un-gates the release_start_ref block in StaticPipeWriter::on_write (previously #[cfg(not(windows))]), adds a Windows-only release in on_close for the error arm that never reaches on_write, and makes Subprocess::take_pending_start_writer clear started when it claims the pointer so the three sites are mutually exclusive. It also adds a saturating_sub in WindowsBufferedWriter::on_write_complete to avoid a debug-only underflow on the exit-first ordering, a LIVE_COUNT atomic exposed via bun:internal-for-testing, and a Windows-only leak test that asserts the live count returns to baseline.

Since my last review pass the temp eprintln!("SPW_DROP") was removed and the test approach was reworked from RSS deltas (6000 spawns, 120s timeout) to a direct live-count assertion (400 spawns, 60s timeout, serial), which is both faster and exact.

Security risks

None identified. No user-controlled data reaches new parsing or allocation paths; the change only adjusts when an existing intrusive deref fires. The new LIVE_COUNT static and its JS binding are read-only introspection gated behind bun:internal-for-testing.

Level of scrutiny

High. This is native intrusive-refcount lifecycle code — REVIEW.md's most-blocked category — on a Windows path the author cannot run locally (per the evidence footer). The correctness argument depends on which of on_write_complete (success/error), on_close, and on_process_exit fire in which order, and on write()'s scopeguard +1 keeping the object live past each new deref. The reasoning in the PR description and inline comments is detailed and I did not find a hole in it, but a wrong call here is a UAF or a re-introduced leak, so a maintainer familiar with the Windows spawn/libuv lifecycle should confirm the ordering claims (in particular that on_close on Windows is always reached under write()'s scopeguard or another outstanding ref).

Other factors

  • All prior review-thread items (mine and CodeRabbit's) are addressed in the current diff; the one CodeRabbit thread still shown unresolved (SPW_DROP) is stale — the line is gone.
  • POSIX behavior is unchanged: the on_write release was already active there, and the new on_close release is #[cfg(windows)].
  • The new test is Windows-only, uses a bounded poll for callback drain, checks child exit codes per batch, and asserts an exact leaked == 0 against the native counter — no RSS thresholds to flake on.
  • ShellSubprocess and SecurityScanSubprocess share StaticPipeWriter<P>; the PR description accounts for both, and the changed code is generic over P so neither needed a per-owner edit.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Delete the test and instrumentation code

…l write path

On Windows, Bun.spawn/spawnSync with an ArrayBuffer/Blob stdin leaked one
StaticPipeWriter per spawn.

StaticPipeWriter::start() takes a +1 and sets started=true. #30875 added a
release_start_ref block to on_write() that clears started and derefs once
the buffer drains, but gated it behind cfg(not(windows)). On Windows both
terminal arms of WindowsBufferedWriter::on_write_complete reach
on_close_io (via close() -> on_close_source -> StaticPipeWriter::on_close)
with started still set, flipping stdin to Writable::Ignore so the other
release site (take_pending_start_writer, called from on_process_exit /
close_io) matches nothing and start()'s +1 is stranded.

- on_write: drop the cfg(not(windows)) gates so release_start_ref runs on
  every platform.
- on_close (Windows only): claim started for paths that reach close()
  without going through on_write (the on_write_complete error arm).
  write()'s +1, held by that callback's scopeguard, keeps self live past
  the deref. Gated to Windows because POSIX drain_buffered_data may call
  on_error() -> close() -> on_close and then on_write() on the same object
  with no extra ref held.
- take_pending_start_writer: clear started when it claims the pointer so a
  uv_write whose I/O already completed before uv_close (delivered with its
  real status, not ECANCELED) does not double-release.
- WindowsBufferedWriter::on_write_complete: saturating_sub for
  has_pending_data so the late-success-after-close ordering does not
  underflow in debug builds.
@robobun
robobun force-pushed the farm/37c14955/static-pipe-writer-win-leak branch from f8f4ec7 to 87ce158 Compare July 23, 2026 21:40
@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Removed the test files and the live-count instrumentation; the diff is now just the three src/ changes.

Comment thread src/spawn/static_pipe_writer.rs
@Jarred-Sumner
Jarred-Sumner merged commit e9e9885 into main Jul 24, 2026
51 of 53 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/37c14955/static-pipe-writer-win-leak branch July 24, 2026 02:50
Jarred-Sumner pushed a commit that referenced this pull request Aug 13, 2026
…ails on POSIX (#37774)

### Problem
- On POSIX, a Buffer or Blob passed as a child's `stdin` leaks the
native writer that pumps it into the child when the write fails before
the buffer drains, typically `EPIPE` because the child closed its stdin
without reading it.
- Reached by the shell's `cmd < ${buffer}` redirect on every POSIX
configuration, and by `Bun.spawn` always on macOS but on Linux only when
memfd is unavailable.
- Under LeakSanitizer the shell's stranded writers appear as `Direct
leak of 1024 byte(s) in 2 object(s)` from
`StaticPipeWriter<ShellSubprocess>::create`; with refcount logging the
count stays at 1 forever after `onError(err=EPIPE: Broken pipe
(send()))` and `onClose()`.
- Cause: the writer holds a ref on itself while a write is in flight,
and only a drained buffer or the owner closing it at exit released that
ref. After a failed write the writer closes itself and the owner empties
its slot, so nothing that could release the ref can still reach the
writer.

### Fix
- The writer's close callback releases the in-flight ref on every
platform, not only Windows. Sound because close is the last callback a
parent receives for an error, and every release site claims the same
token first, so exactly one of them (drain completion, close after a
failure, or the owner) releases.
- The POSIX drain loop now only writes and cannot invoke callbacks
itself; an error is always returned and reported once by the caller.
This deletes the arm that #35297 cited when it fixed the same leak on
Windows only; the arm was unreachable, so no behaviour changes here.
- Two rarer error paths get the same report-then-close shape: a failed
poll re-registration used to report without closing, and a zero-byte
write closed without releasing. Both are reasoned about, not tested. Out
of scope, pre-existing: on Windows a write failing synchronously inside
`start()` still strands the ref; tracked separately.
- Verification: a new ASAN-lane test runs a fixture under
`detect_leaks=1` that drives both owners through failing and draining
writes. It fails 4 of 4 runs without the fix with the report above and
passes 5 of 5 with it. LSan only reports the shell-owned writers; the
`Bun.spawn` cases exercise the same teardown without being what fails.

### Background
- The static pipe writer is the native object that writes one fixed
in-memory buffer (a Buffer or Blob `stdin`) into a child's stdin pipe.
It is refcounted: one ref lives in the owning process's stdin slot, a
second is taken when the write starts so the object outlives the write.
- It has two owners: a `Bun.spawn` subprocess and a shell subprocess for
the `< ${buffer}` redirect. On Linux `Bun.spawn` normally hands the
child a memfd and never creates the writer, which is why the test
disables memfd; the shell redirect always uses it.
- The buffered pipe writer underneath reports to its parent through
three callbacks: bytes drained, error, closed. Parents are written
assuming closed is the last one they receive, so freeing the object
there is safe only if nothing underneath touches it afterwards.
- The `started` flag is the token for the in-flight ref: whichever site
swaps it to false does the release, so three possible release sites
cannot release twice.
- `EPIPE` is what writing to a pipe returns once the reader has closed
its end; it is how a child closing its stdin reaches the parent while
the buffer is still being written.

<!-- 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/spawn/spawn-stdin-pipe-fd-leak.test.ts

<!-- robobun:evidence:end -->

<details>
<summary>Original description</summary>

On POSIX, a Buffer/Blob `stdin` whose write fails before it has drained
(typically EPIPE, because the child closed its stdin without reading it)
leaks the native `StaticPipeWriter` that pumps the buffer into the
child. Two owners share that writer: the shell's `cmd < ${buffer}`
redirect, which uses it on every POSIX configuration, and `Bun.spawn`,
which on Linux only uses it when memfd is unavailable
(`BUN_FEATURE_FLAG_DISABLE_MEMFD=1`, or no `memfd_create`; otherwise the
child gets a memfd) and on macOS always.

### Repro

```ts
// BUN_FEATURE_FLAG_DISABLE_MEMFD=1 BUN_DEBUG_StaticPipeWriter=1 BUN_DEBUG_ref_count=1 bun-debug run repro.ts
const big = Buffer.alloc(1 << 20, 0x61);
const proc = Bun.spawn({ cmd: ["sh", "-c", "exec 0<&-; sleep 0.3"], stdin: big, stdout: "ignore" });
console.log("exit", await proc.exited);
```

Before, for the writer:

```
StaticPipeWriter(0x..) start()
0x..   ref 1 -> 2                      start()
StaticPipeWriter(0x..) onError(err=EPIPE: Broken pipe (send()))
StaticPipeWriter(0x..) onClose()
0x.. deref 2 -> 1                      owner drops create()'s ref in on_close_io
```

and the count stays at 1 forever. After, the same trace continues with
`deref 1 -> 0`. `$\`cmd < ${big}\`` with a command that exits without
reading stdin shows the same before/after (no memfd flag involved), and
under LeakSanitizer the shell's stranded writers come out as

```
Direct leak of 1024 byte(s) in 2 object(s) allocated from:
    ...
    #12 <StaticPipeWriter<ShellSubprocess>>::create src/spawn/static_pipe_writer.rs:110
    #13 <shell::subproc::Writable>::init src/runtime/shell/subproc.rs:1149
```

### Cause

`start()` takes a ref on the writer and sets `started`; that ref was
released from `on_write` when the buffer drained, or by the owner
(`Subprocess::on_process_exit` / `close_io`) if it closed the writer
while the slot still held it. A failed write takes neither route:
`PosixBufferedWriter::_on_error` runs `on_error` and then `close()`,
`on_close` tells the owner, the owner empties its slot and drops
`create()`'s ref, and nothing is left that can find the writer to
release `start()`'s ref.

#35297 fixed exactly this on Windows by releasing in `on_close`, and
left POSIX alone because of the shape of
`PosixPipeWriter::drain_buffered_data`: it had an arm that, for an error
after a partial drain, called `on_error` inline (closing the writer) and
returned `Wrote(n)`, after which `on_poll` delivered `on_write(n,
Drained)` to the same object, so a release in `on_close` looked unsafe.
That arm was in fact unreachable (`try_write` reports a short write as
`Pending`, never as `Wrote`, so the drain loop never gets to an error
with bytes already drained), but as written it was the documented reason
the POSIX release site did not exist.

### Fix

* `drain_buffered_data` (src/io/PipeWriter.rs) loses that arm and takes
`&self`, so it structurally cannot dispatch callbacks: an error is
always returned as `Err` and reported once by the caller, and `on_close`
is the last callback a parent sees. That is the contract the parents are
written against (`FileSink::on_close` releases its keep-alive as "the
last thing", `Terminal::on_writer_close` derefs the terminal,
`FileSink::on_auto_flush` documents `flush()` returning `Err` as the
error contract); this makes it explicit rather than a property of which
arms happen to be reachable. No behaviour changes here.
* `StaticPipeWriter::on_close` (src/spawn/static_pipe_writer.rs) claims
and releases `start()`'s ref on every platform, not just Windows. This
is the behavioural fix. The release is the last access: the frames
underneath it (`close_impl` -> `close` -> `_on_error` -> `on_poll`) do
nothing with the writer after the callback, which is the same shape
`SecurityScanSubprocess` (whose owner ref is the last one) already
relies on in `on_close_io` today. `on_write` keeps taking the token
before it closes and the owners keep taking it before they close, so
exactly one site releases the ref.
* `PosixBufferedWriter::register_poll` reports a failed re-registration
through `_on_error` (report, then `close()`), like every other error
report and like the streaming writer's `register_poll` already did,
instead of reporting and leaving the writer open. Raised in review: that
(ENOMEM-class) failure was the one report not followed by the `on_close`
that now releases the ref; `Bun.spawn` would still have picked the
writer up at exit, the shell had nothing to pick it up with. Reasoned
about rather than tested, since it needs the registration syscall itself
to fail.
* The `EndOfFile` arm of `on_write` releases the ref as well (non-final,
since the owner's ref is dropped by the `on_close` the buffered writer
delivers right after) instead of closing the writer itself and leaving
the ref outstanding. That arm needs `write(2)` to return 0 on a pipe, so
it is reasoned about rather than tested; it is the same change #37755
makes to this arm.

Known gap, pre-existing and not touched here: on Windows a `uv_write`
that fails synchronously inside `start()` closes the writer (`on_close`
runs, the owner drops its ref) before `start()` sets `started`, which
strands the ref the same way. Fixing it needs `SecurityScanSubprocess`'s
post-`start()` release to become token-aware as well, so it is tracked
separately.

Related: #34697 proposes the same `drain_buffered_data` change as one
item of a broader hardening pass, but is 600+ commits behind with
conflicts in three files. #37755 moves these callbacks onto raw `this`
pointers and leaves the error chain (and this leak) alone; the two
conflict textually in `on_close` / `on_poll` but compose, whichever
lands second needs to keep the unconditional release in `on_close` and
the `Err` return in `drain_buffered_data`.

### Test

`test/js/bun/spawn/spawn-stdin-pipe-fd-leak.test.ts` runs a fixture on
the ASAN lane with `detect_leaks=1` (the setup from
shell-worker-terminate-leak.test.ts). The fixture drives both owners
through both endings: children that close their stdin, report it and
wait to be killed (so the write fails while the child is alive and the
exit path cannot be what cleans up), and children that drain it; memfd
is disabled so that the `Bun.spawn` scenarios reach the writer at all.
Unfixed it fails every time (4 of 4) with the report quoted above: the
two shell-owned writers. The two stranded `Bun.spawn`-owned writers from
the same run have their refcount stuck at 1 just the same (checked with
`BUN_DEBUG_ref_count`, and their `Subprocess` structs are freed before
exit), but LSan still finds their address somewhere and does not report
them, so those scenarios only exercise the same teardown under ASAN.
Fixed, the fixture is clean, 5 of 5 runs, about 0.7s each.

An earlier revision used a live-writer counter in
`bun:internal-for-testing`; it was removed per review in favour of LSan.

### Verification

Debug (ASAN) build: the file above, plus spawn.test.ts,
spawnSync.test.ts, spawn-empty-arrayBufferOrBlob, spawn-streaming-stdin,
spawn-stdin-readable-stream, spawn-stdin-destroy,
spawn-pipe-start-error, spawn-many-teardown, memfd-disabled,
bunshell.test.ts (418 pass), bunshell-instance, epipe,
shell-blocking-pipe, shell-write-fault, file-io, pipeline_stack,
filesink.test.ts, terminal.test.ts, terminal-spawn,
child_process.test.ts and bun-security-scanner-workspaces all pass.
`cargo check` of bun_io and bun_spawn for `x86_64-pc-windows-msvc` and
`aarch64-apple-darwin`, `cargo clippy` on both, and `cargo fmt --check`
are clean. CI on the two previous revisions (the first of which ran its
tests on every lane, Windows included) was green apart from flaky tests
that passed on retry.

</details>
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.

2 participants