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

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::assignToStream → readDirectStream / 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}__assignToStream → GlobalObject::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__detachPtr → detach() 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 -->
Jarred-Sumner pushed a commit that referenced this pull request Aug 17, 2026
…r segfault when a handler throws inside transform()) (#39416)

### Problem

- `HTMLRewriter.transform(response)` kills the process with `panic(main
thread): Segmentation fault at address 0x0` when an element handler
throws and the input was drained inside `transform()`. Under ASan:
`member call on null pointer of type 'JSC::JSCell'` in
`JSSinkController__detachPtr`, reached from `RewriterPipe::fail ->
detach_input_source -> JSSink<RewriterPipe>::detach`, with
`JSSink::assign_to_stream` still on the stack.
- Inputs that drain inside `transform()`: a `Response` whose `.body`
getter was touched first (in-memory, `Blob`, or a fully received
`fetch()` response), a `ReadableStream` with a chunk already queued in
`start()` (default or `type: "bytes"`), and a `type: "direct"` stream
whose `pull()` writes synchronously. The same handler throwing on a
chunk that arrives later rejects the output body as expected.
- Regression from f68e504 (1.4 canaries; 1.3.14 is fine). Cause in
`src/runtime/webcore/Sink.rs`, `JSSink::assign_to_stream`: the generated
`${Sink}__assignToStream` created the controller, wrote it to a `void**`
out-param, and started the pump in the same call; Rust pre-seeded the
sink's source with `JSController(JSValue::ZERO)` and copied the
out-param into it only after the call returned. The pump drains already
queued data before returning, so the pipe's `fail()` ran while the
source still held the placeholder. `SourceHandle::close`/`ready`
special-cased `ZERO`; `JSSink::detach` did not and handed the empty
value to `detachPtr`, which downcasts it as a cell.
- Before f68e504 the C++ side wrote the controller straight into the
sink's signal slot before starting the pump, which is why 1.3 does not
crash.

### Fix

- Split the generated entry point so the ordering is fixed by
construction: `${Sink}__createController(sinkPtr)` returns the
controller cell, and one shared
`JSSinkController__assignToStream(stream, controller)` starts the pump
(`src/codegen/generate-jssink.ts`). `JSSink::assign_to_stream` creates
the controller, stores `JSController(controller)` as the sink's source,
and then starts the pump, so the source holds the real controller
whenever the pump can run sink or user code. The six callers are
unchanged.
- A failure mid-drain therefore detaches the real controller, which runs
the pump's `onClose` and cancels the input, exactly what already
happened when the failing chunk arrived later.
- `JSController(ZERO)` no longer exists, so its special cases in
`SourceHandle::close`/`ready` are deleted; `js_controller_detached`
clears the slot only for the controller it holds. The setup-throws path
(#36783: a direct stream's `pull` getter throwing leaves the controller
attached to a sink the caller is about to free) detaches the controller
returned by create; the detach reaches `js_controller_detached`, which
is what clears the slot, so there is a single clearing path and it is
identity-checked. The stale `${Sink}__assignToStream` prototypes in
`headers.h` are removed.
- This is the shared `JSSink` path, so a mid-drain `close()`/`detach()`
from `HTTPResponseSink`, `FetchRequestBodySink`, `FileSink` or
`NetworkSink` now reaches the pump too (the 1.3 behaviour) instead of
being dropped until the stream drains to EOF. I did not find a way to
trigger a mid-drain failure for those sinks deterministically from JS
(`Bun.write("/dev/full", stream)` rejects through `FileSink`'s own path
without touching the source), so the regression test uses HTMLRewriter;
the code it exercises is the shared function.
- Verified with `test/js/workerd/html-rewriter.test.js` ("error inside
element handler rejects the body when the input is drained inside
transform()"): seven input shapes in one spawned fixture, each must
reject with the handler error and survive a full GC, and the still-open
input must have its `cancel()` called once. Without the fix the fixture
segfaults on the first shape (`USE_SYSTEM_BUN=1`); with it every line
prints and stderr is empty.
- Also run against the debug+ASan build of this version: the four
HTMLRewriter test files and the spawn stdin `ReadableStream` suites
including the #36783 `stdin stream setup fails` tests (252 pass),
streams/ArrayBufferSink/FileSink (264 pass), `bun-write.test.js` (49
pass; the one failure is a 256 MB file copy that times out in this
container on any build), the Bun.serve streaming suites plus
`serve.test.ts` (430 pass; the 4 failures reproduce with the unmodified
binary: IPv6 `localhost`, running as root), and the fetch body stream
suites (9134 pass; 4 h3 backpressure tests time out under load and pass
when run alone).

### Background

- A `JSSink` is a native byte consumer (`RewriterPipe` for HTMLRewriter
input, the HTTP response sink, the fetch request body sink, `FileSink`,
the S3 `NetworkSink`) exposed to the stream machinery through a small JS
cell, the `JSReadable*SinkController`, whose `m_sinkPtr` points at the
native sink.
- `assign_to_stream` is how a sink consumes a JS `ReadableStream` that
has no native fast path: the pump (`readStreamIntoSink`, or
`readDirectStream` for `type: "direct"`) reads the stream and calls the
sink's `write()` through the controller. Anything already queued is
written before the pump call returns; the rest arrives later and the
call returns a promise.
- `SourceHandle` is the sink's handle to whatever feeds it. For the pump
it is `JSController(cell)`; the sink calls `ready()` on it to resume the
pump after backpressure, and `detach()` (`detachPtr`) to cut it off,
which nulls `m_sinkPtr` and runs the pump's `onClose` so the pump
cancels the upstream stream. The pump ignores the sink's `write()`
return value on failure, so `detach()` is the only way a failing sink
stops it.
- `js_controller_detached` is the reverse edge: the controller tells the
sink it is going away (`close()`/`end()` or its GC destructor) and the
sink drops its handle, so a dead cell is never decoded.

<details>
<summary>Probe results on the unfixed canary (1.4.0-canary.1, 8326d1b)
and the fixed debug build</summary>

Unfixed, handler throws on `<a>`:

```
no-touch (new Response(html))                  rejected: handler-throw
body-touch (void res.body first)               Segmentation fault at address 0x0
blob-body-touch                                Segmentation fault at address 0x0
fetch + body touch after the body arrived      Segmentation fault at address 0x0
ReadableStream, string enqueued in start()     Segmentation fault at address 0x0
ReadableStream, bytes enqueued in start()      Segmentation fault at address 0x0
type: "bytes", chunk enqueued in start()       Segmentation fault at address 0x0
type: "direct", pull() writes synchronously    Segmentation fault at address 0x0
same shapes with the chunk delivered later     rejected: handler-throw
```

Fixed (debug + ASan): every shape above prints `rejected:
handler-throw`, exit 0, nothing from the sanitizer. For a stream left
open with one chunk queued, the upstream `cancel()` is invoked once,
both when the handler throws during `transform()` and when it throws on
a later chunk; the unfixed build already did this for the later chunk.

</details>

<details>
<summary>First version of this PR (52ddca2)</summary>

The first revision kept the `void**` protocol and aimed the out-pointer
at the `JSController` payload inside the sink's own source slot
(`JSValue` is `repr(transparent)` over the encoded bits), with
`JSController(ZERO)` remaining as the state between the pre-seed and the
C++ store and a matching `ZERO` check added to `JSSink::detach`. It
fixed the crash and CI was green (build 99906), but it kept the sentinel
alive at three sites and relied on a pointer into an enum payload
crossing the FFI boundary. The current revision removes the sentinel
instead; the test is unchanged.

</details>

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

---

**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/workerd/html-rewriter.test.js

<!-- 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