sink: detach JSSink controller when assignToStream throws - #36783
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughJSSink 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. ChangesJSSink cleanup and regression coverage
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Reproduced under debug+ASAN with the repro in the PR body (the
Merged. One note from self-review: the two amended tests live in |
There was a problem hiding this comment.
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 encodedJSC::Exceptioncell, whichJSValue::to_error()recognizes (bindings.cpp:4742), so non-Errorthrows are also caught. - Verified
JSSinkController__detachPtr→detach()usesstd::exchange(m_sinkPtr, nullptr)and the controller'sm_onClose/m_onDestroyare both unset at this point (created with 0, neverstart()ed), so no user JS re-enters during the new detach. - Checked the other
assign_to_streamcallers (s3/client.rs, Blob.rs, Writable.rs) — their error branches match on the same.to_error()and any subsequent detach is a no-op againstm_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::detachJSControllerarm (samecall_check_slow+controller_abi::detach_ptrsequence), so it follows an established pattern. controller->detach()(generate-jssink.ts:657) usesstd::exchangeand null-guards every effect: at this pointm_onDestroy == 0(passed as 0 in${controller}::create),m_onClose/m_weakReadableStreamare unset (start()was never reached), and${name}__controllerDetachedseesSourceHandle::None(cleared just before) so it no-ops. No user JS runs, no double-free.to_error()matchesJSC::Exceptioncells (bindings.cpp:4741-4745), which is exactly whatGlobalObject::assignToStreamreturns 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.
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.
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.
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 -->
…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 -->
What
JSSink::assign_to_streamnow detaches the freshly createdJSReadable*SinkController(nulling itsm_sinkPtr) when the C++ stream-pump setup returns an error, before returning to the caller.Why
The generated
${name}__assignToStreamfunctions create the controller withm_sinkPtr = sinkPtrand then call intoGlobalObject::assignToStream→readDirectStream/readStreamIntoSink. If that setup throws (for example a directReadableStreamwhosepullgetter throws), the controller is never started, so nothing ever callsend()/close()to nullm_sinkPtr. The caller's error path (Writable::initforBun.spawn) then releases and frees the native sink. When the controller is later swept, its destructor runs${name}__controllerDetached/${name}__finalizeon freed memory.ASAN report:
The fix is at the generic
JSSink::assign_to_streamlayer so it covers every sink type (FileSink,NetworkSink,FetchRequestBodySink, ...), not just the spawn path.Repro
Tests
The two existing
spawn.test.tscases 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.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.tsandbody-stream.test.tscontinue to pass.