Skip to content

sink: detach JSSink controller when assignToStream throws - #36783

Merged
Jarred-Sumner merged 1 commit into
mainfrom
claude/farm/cce39db7/jssink-assign-to-stream-uaf
Aug 2, 2026
Merged

sink: detach JSSink controller when assignToStream throws#36783
Jarred-Sumner merged 1 commit into
mainfrom
claude/farm/cce39db7/jssink-assign-to-stream-uaf

Conversation

@robobun

@robobun robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

What

JSSink::assign_to_stream now detaches the freshly created JSReadable*SinkController (nulling its m_sinkPtr) when the C++ stream-pump setup returns an error, before returning to the caller.

Why

The generated ${name}__assignToStream functions create the controller with m_sinkPtr = sinkPtr and then call into GlobalObject::assignToStreamreadDirectStream / readStreamIntoSink. If that setup throws (for example a direct ReadableStream whose pull getter throws), the controller is never started, so nothing ever calls end()/close() to null m_sinkPtr. The caller's error path (Writable::init for Bun.spawn) then releases and frees the native sink. When the controller is later swept, its destructor runs ${name}__controllerDetached / ${name}__finalize on freed memory.

ASAN report:

heap-use-after-free on address 0x799feed81c78
READ of size 1
  #0 JSSink<FileSink>::js_controller_detached  Sink.rs:567
  #1 FileSink__controllerDetached              generated_jssink.rs:179
  #2 JSReadableFileSinkController::~JSReadableFileSinkController()

freed by:
  #12 FileSink::deinit                         FileSink.rs:1142
  #16 Writable::pipe_release                   Writable.rs:70
  #17 Writable::init                           Writable.rs:339
  #18 spawn_maybe_sync                         js_bun_spawn_bindings.rs:1379

The fix is at the generic JSSink::assign_to_stream layer so it covers every sink type (FileSink, NetworkSink, FetchRequestBodySink, ...), not just the spawn path.

Repro

const { openSync, closeSync } = require("node:fs");
const fd = openSync("/tmp/out.txt", "w");
let armed = false;
const stream = new ReadableStream({
  type: "direct",
  get pull() { if (armed) throw new Error("pull unavailable"); return () => {}; },
});
armed = true;
try {
  Bun.spawn({ cmd: [process.execPath, "-e", "0"], stdio: [stream, fd, "ignore"] });
} catch {}
closeSync(fd);
Bun.gc(true);   // sweep -> controller dtor -> UAF

Tests

The two existing spawn.test.ts cases that cover the stdin-stream-setup-throws path now force a full GC in the child fixture so the controller destructor runs deterministically under debug+ASAN as well. Previously they were only failing on the release-asan lane (where the whole file has been quarantined as [ASAN] [TIMEOUT]), which is why this went unnoticed.

bun bd test test/js/bun/spawn/spawn.test.ts -t "stdin stream setup fails"

fails on main (ASAN heap-use-after-free in the child's stderr) and passes with this change. spawn-stdin-readable-stream-edge-cases.test.ts and body-stream.test.ts continue to pass.

FileSink__assignToStream (and the other generated ${name}__assignToStream
functions) create a JSReadable*SinkController with m_sinkPtr set before
calling into the stream pump. When the pump setup throws (for example a
direct ReadableStream whose `pull` getter throws), the controller is never
started, so nothing ever calls end()/close() to null m_sinkPtr. The
caller's error path then frees the native sink, and when the controller is
later swept its destructor calls ${name}__controllerDetached /
${name}__finalize on freed memory.

Under ASAN this shows up as a heap-use-after-free in
JSSink<FileSink>::js_controller_detached from
JSReadableFileSinkController's destructor. Bun.spawn({stdio:[stream,..]})
with a throwing `get pull` is enough to reach it; the two existing
spawn.test.ts cases for this error path were failing on release-asan
lanes for this reason.

Fix it in the generic JSSink::assign_to_stream wrapper: when the extern
call returns an error, call JSSinkController__detachPtr on the freshly
created controller while the sink is still live, and clear the sink's
SourceHandle. The controller's later GC then sees m_sinkPtr==null and
skips the native finalize.

The two spawn tests now force a full GC in the child fixture so the
controller destructor runs deterministically under debug+ASAN as well.
@github-actions github-actions Bot added the claude label Aug 2, 2026
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 97900a9c-bbb7-4767-858e-549139bce5c9

📥 Commits

Reviewing files that changed from the base of the PR and between fdbaf06 and 35a4dea.

📒 Files selected for processing (2)
  • src/runtime/webcore/Sink.rs
  • test/js/bun/spawn/spawn.test.ts

Walkthrough

JSSink stream assignment now clears source state and detaches the controller after failure. Spawn tests trigger garbage collection in two stdin failure fixtures and verify empty stderr in one fixture.

Changes

JSSink cleanup and regression coverage

Layer / File(s) Summary
Handle controller assignment failures
src/runtime/webcore/Sink.rs
JSSink::assign_to_stream clears the source and detaches the controller pointer when assignment fails.
Exercise stdin setup failures
test/js/bun/spawn/spawn.test.ts
The stdin failure fixtures trigger garbage collection. One fixture asserts that stderr is empty.

Possibly related PRs

  • oven-sh/bun#36087: Introduces the JSSink-based stream-assignment machinery hardened by this change.
  • oven-sh/bun#36703: Updates readable-stream sink and controller cleanup during failure paths.
  • oven-sh/bun#36779: Addresses stream controller and source lifetime cleanup during assignment failure.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary fix: detaching the JSSink controller when assignToStream throws.
Description check ✅ Passed The description explains the fix, cause, impact, reproduction case, and verification steps, although it uses different headings than the template.
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.

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

@robobun

robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced under debug+ASAN with the repro in the PR body (the Bun.gc(true) makes the controller destructor run deterministically). Verified:

  • bun bd test test/js/bun/spawn/spawn.test.ts -t "stdin stream setup fails": 2 fail on main (ASAN heap-use-after-free in child stderr), 2 pass with this change
  • bun bd test test/js/bun/spawn/spawn-stdin-readable-stream-edge-cases.test.ts: 12 pass
  • bun bd test test/js/web/fetch/body-stream.test.ts: 9086 pass
  • cargo check -p bun_runtime --target x86_64-pc-windows-msvc: clean

Merged. One note from self-review: the two amended tests live in spawn.test.ts, which is currently [ ASAN ] [ TIMEOUT ] quarantined in test/expectations.txt, so the ASAN lane never runs them in CI. Left a note on #36780 (the active expectations.txt prune) so that PR can decide whether to un-quarantine the file or move those two tests into spawn-stdin-readable-stream-edge-cases.test.ts for ASAN coverage.

@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 issues found — the detach-on-error path mirrors the existing JSSink::detach pattern and controller->detach() is idempotent, so callers whose error handlers already tear down the sink aren't affected. Deferring to a human because this is a memory-safety change in the shared assign_to_stream path hit by every sink type (FileSink, NetworkSink, HTTPServerWritable, FetchRequestBodySink, ArrayBufferSink).

What was reviewed:

  • Traced the error return through ${name}__assignToStreamGlobalObject::assignToStream: failures come back as an encoded JSC::Exception cell, which JSValue::to_error() recognizes (bindings.cpp:4742), so non-Error throws are also caught.
  • Verified JSSinkController__detachPtrdetach() uses std::exchange(m_sinkPtr, nullptr) and the controller's m_onClose/m_onDestroy are both unset at this point (created with 0, never start()ed), so no user JS re-enters during the new detach.
  • Checked the other assign_to_stream callers (s3/client.rs, Blob.rs, Writable.rs) — their error branches match on the same .to_error() and any subsequent detach is a no-op against m_sinkPtr == nullptr / SourceHandle::None.
Extended reasoning...

Overview

The PR adds an early-return branch to the generic JSSink<T>::assign_to_stream (src/runtime/webcore/Sink.rs) that fires when the C++ ${name}__assignToStream created a controller (bits != 0) but the stream-pump setup returned an error. In that case it clears the sink's SourceHandle and calls JSSinkController__detachPtr on the just-created controller so its m_sinkPtr is nulled before the caller frees the native sink. Two existing spawn tests are hardened with Bun.gc(true) and an expect(stderr).toBe("") so the ASAN heap-use-after-free surfaces deterministically in debug+ASAN.

Security risks

None. This is a use-after-free fix; no auth/crypto/permissions/parsing surface is touched.

Level of scrutiny

High. Sink.rs is the shared JSSink glue that every sink type (FileSink, NetworkSink, the three HTTPServerWritable variants, FetchRequestBodySink, ArrayBufferSink) routes through, and the fix sits at the JSC/GC boundary where the destructor runs ${name}__finalize(m_sinkPtr). Per the repo's review guidance, memory-safety changes in shared native paths warrant a human look even when the mechanism reads correctly.

Other factors

  • The new code is a near-verbatim copy of the existing JSSink::detach JSController arm (same call_check_slow + controller_abi::detach_ptr sequence), so it follows an established pattern.
  • controller->detach() (generate-jssink.ts:657) uses std::exchange and null-guards every effect: at this point m_onDestroy == 0 (passed as 0 in ${controller}::create), m_onClose/m_weakReadableStream are unset (start() was never reached), and ${name}__controllerDetached sees SourceHandle::None (cleared just before) so it no-ops. No user JS runs, no double-free.
  • to_error() matches JSC::Exception cells (bindings.cpp:4741-4745), which is exactly what GlobalObject::assignToStream returns on failure (ZigGlobalObject.cpp:3029-3032), so the guard is not sensitive to what value was thrown.
  • The other error-path callers (s3/client.rs:1092, Writable.rs:228/336, Blob.rs) branch on the same .to_error(); the new detach makes their subsequent teardown a safe no-op rather than changing observable behavior.
  • The test change adds Bun.gc(true) inside the child fixture and asserts empty stderr, converting a release-ASAN-only quarantine failure into a deterministic debug+ASAN regression test. The PR body reports it fails on main and passes with the fix.

I did not find anything wrong; deferring only because of the criticality of the shared code path.

@Jarred-Sumner
Jarred-Sumner merged commit db9b9c7 into main Aug 2, 2026
54 of 55 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/farm/cce39db7/jssink-assign-to-stream-uaf branch August 2, 2026 14:24
robobun added a commit that referenced this pull request Aug 2, 2026
Rebased onto main which now has #36783 (sink: detach JSSink controller
when assignToStream throws) fixing the heap-use-after-free, so the two
'leaves a ... stdout fd open' tests no longer need skipIf(isASAN).
spawn.test.ts reverts to main's version; all 145 tests now run on ASAN.
robobun added a commit that referenced this pull request Aug 2, 2026
Rebased onto main which now has #36783 (sink: detach JSSink controller
when assignToStream throws) fixing the heap-use-after-free, so the two
'leaves a ... stdout fd open' tests no longer need skipIf(isASAN).
spawn.test.ts reverts to main's version; all 145 tests now run on ASAN.
Jarred-Sumner pushed a commit that referenced this pull request Aug 3, 2026
Empirically re-derived which `test/expectations.txt` entries are still
needed by removing all 29 and running the full CI matrix ([build
87834](https://buildkite.com/bun/bun/builds/87834)).

## Result: 29 entries → 3

### Kept (3): still fail on the named lane

| entry | lane | observed failure (build 87834) |
|---|---|---|
| `test/bundler/native-plugin.test.ts` | WINDOWS | MSB8020: ClangCL
build tools not found (agent image gap) |
| `test/js/node/test/parallel/test-net-pingpong.js` | WINDOWS |
named-pipe half-close: count 1000 !== 1001 |
| `test/js/node/test/sequential/test-net-listen-shared-ports.js` | LINUX
| SO_REUSEPORT shared-listener semantics; passes on macOS/Windows |

### Deleted (6): vendored Node tests that fail deterministically on
every lane

These can never pass as vendored; removing the files instead of
re-quarantining.

| file | reason |
|---|---|
| `test-stream-wrap.js`, `test-stream-wrap-drain.js`,
`test-stream-wrap-encoding.js` | require `internal/js_stream_socket`
which Bun does not implement |
| `test-net-connect-keepalive.js`, `test-net-server-keepalive.js` |
assert `_handle.setKeepAlive` receives seconds (libuv convention); Bun's
`_handle` is Bun.Socket (ms). End-to-end TCP_KEEPIDLE coverage is in
`test/js/bun/net/socket.test.ts` |
| `test-set-http-max-http-headers.js` | spawns
`test-http-max-http-headers.js` which is not vendored |

### Moved to `no-validate-exceptions.txt` (7): fail only via
unchecked-exception assertions

These now **run** on ASAN with `validateExceptionChecks` off, instead of
being removed from the run entirely.

| file | unchecked exception scope |
|---|---|
| `test/integration/next-pages/test/dev-server-ssr-100.test.ts` |
`JSOrderedHashTable::getImpl` → `executeBoundCall` |
| `test/integration/next-pages/test/dev-server.test.ts` | same |
| `test/integration/next-pages/test/next-build.test.ts` | same |
| `test/js/third_party/next-auth/next-auth.test.ts` | same |
| `test/napi/napi.test.ts` | `Process_functionDlopen`
(BunProcess.cpp:397) |
| `test/cli/run/require-cache.test.ts` | `NapiClass::finishCreation`
(NapiClass.cpp:120) |
| `test/cli/inspect/inspect.test.ts` | `getOwnNonIndexPropertyNames` →
`JSObjectInlines::get` (inspector Runtime.evaluate) |

Also bumped `esm-fixture-leak-small.mjs` ASAN threshold 400→500 MB
(build 87834 measured 407 MB; ASAN quarantine overhead) so
`require-cache.test.ts` passes end to end on ASAN.

### Removed (13): now pass on their named lane

| entry | was scoped to | now passes on |
|---|---|---|
| `test/js/node/test/parallel/test-repl-close.js` | WINDOWS-AARCH64 |
windows 11 aarch64 |
| `test/js/node/test/parallel/test-tls-connect-memleak.js` |
LINUX-X64-MUSL | alpine 3.23 x64 + aarch64 |
| `test/js/bun/spawn/spawn-maxbuf.test.ts` | (all) | every lane (also
fixed for debug in #36782) |
| `test/js/bun/spawn/spawn.test.ts` | ASAN | every lane
(heap-use-after-free fixed in #36783) |
| `test/js/sql/tls-sql.test.ts` | ASAN | debian 13 x64-asan |
| `test/js/node/url/pathToFileURL.test.ts` | ASAN | debian 13 x64-asan |
| `test/js/node/fs/abort-signal-leak-read-write-file.test.ts` | ASAN |
debian 13 x64-asan |
| `test/js/web/streams/streams-leak.test.ts` | ASAN | debian 13 x64-asan
|
| `test/js/node/test/parallel/test-net-server-listen-path.js` | WINDOWS
| windows 2019 x64 + 11 aarch64 |
| `test/js/node/test/parallel/test-net-pipe-connect-errors.js` | WINDOWS
| fixed in #36786 |
| `test/js/node/test/parallel/test-net-client-bind-twice.js` | WINDOWS |
fixed in #36786 |
| `test/js/node/test/parallel/test-net-server-reset.js` | WINDOWS |
fixed in #36786 |
| `test/js/bun/io/fetch/fetch-abort-slow-connect.test.ts` | DARWIN |
darwin 26 aarch64 + 14 x64 |

### Caveats

- `test-tls-connect-memleak.js` and `fetch-abort-slow-connect.test.ts`
were `FLAKY` quarantines; both passed in probe build 87834 and
confirmation builds 87845 / 87860 / 87868.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 4 · docs-only change; test-proof not
applicable

<!-- robobun:evidence:end -->
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