webcore: remove unsafe from Response/Request/Body/ReadableStream/FileReader/Sink - #40411
Jarred-Sumner wants to merge 8 commits into
Conversation
There was a problem hiding this comment.
Thanks for fixing the into_js doc comment. This run found no further issues. Given the scope — 51 files rewriting ownership/refcounting across Response/Request/Body/ReadableStream/FileReader/Sink, new primitives (OwnedThis, SourceRef, finalize_owner/destroy_weakly_held, LockedRead), and codegen changes — a maintainer look is still warranted.
What was reviewed: refcount balance across NewSource::retain/SourceRef::drop/finalize and the FileReader read_ref/reader_refs paths; weak_ptr rewrite (Cell<u32>, finalize_owner) against Request/Response finalize; WTFString ref transfer in Value/AnyBlob (clone-then-drop vs. old ManuallyDrop); RequestContext.sink OwnedThis drop ordering vs. re-entrant JS in do_render_stream/handle_resolve_stream; BodyMixin LockedRead split so readableStreamTo* runs after the body borrow releases.
Extended reasoning...
Overview
This PR is part of a stacked series removing unsafe from src/runtime/webcore/. It touches 51 files (~515KB diff) and rewrites the ownership model for Response, Request, Body, ReadableStream sources (ByteStream/ByteBlobLoader/FileReader), and JSSink. Key structural changes: Body.value becomes a JsCell<Value> accessed via closure-scoped with_mut; Value::WTFStringImpl holds an owned WTFString (Drop releases the ref) instead of a raw *mut StringImpl with hand-managed ref/deref; NewSource<C> becomes CellRefCounted with a SourceRef<C> handle and SourceContext methods flip to &self; bun_ptr::weak_ptr is rewritten around Cell<u32> with a safe HasWeakPtrData trait and finalize_owner(Box<T>); Response gains CellRefCounted + destroy_weakly_held; RequestContext.sink becomes JsCell<Option<OwnedThis<…>>>; codegen (generate-jssink.ts, generate-classes.ts) emits new __getThis/take_ptr/FinalizeReceiver/sink_handle_from_id helpers. BodyMixin readers are refactored to return LockedRead/BodyRead so JS-running work happens after the body borrow is released.
Security risks
No direct security surface change (no auth/crypto/parsing of untrusted formats). The risk profile is memory safety: refcount imbalance, use-after-free on GC finalize / re-entrant JS, double-drop of WTFString refs, and aliasing under JsCell::with_mut re-entry. The PR description explicitly flags several parity-left aliasing shapes (e.g. Value::resolve inside with_mut) as owned by sibling PRs.
Level of scrutiny
High. This is core-runtime memory-management code on GC-interacting hot paths (new Response, .text(), Bun.serve streaming). Mistakes surface as UAF/leaks under GC pressure or re-entrancy, which the test suite may not catch deterministically. The author reports extensive Debug+ASAN test coverage and perf measurements, but the sheer breadth of ownership-model changes and the stacked/shared-primitive nature make this a change a maintainer should sign off on.
Other factors
My previous run flagged only a doc-comment nit on Response::into_js; commit 9cc27334 addressed it (the diff now shows the single correct sentence). No bugs were found in the current run. The PR is stacked on #40221 and shares primitives with several sibling PRs, so a maintainer should also confirm the merge/retarget plan. I checked the drop-ordering in RequestContext where owned_sink is taken before re-entrant JS runs and dropped last — the &mut from sink_mut() into the same allocation is held across that span, which is the same aliasing shape as before (documented in the field's safety note) but worth a maintainer's eye.
e3cc592 to
957214c
Compare
9cc2733 to
cb3798d
Compare
|
Updated 8:18 AM PT - Sep 8th, 2026
❌ @Jarred-Sumner, your commit 421f258 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 40411That installs a local version of the PR into your bun-40411 --bun |
65296c6 to
6f49cdf
Compare
cb3798d to
e7200a3
Compare
6f49cdf to
178f765
Compare
4bea94f to
6af4641
Compare
aaa2ee2 to
e231232
Compare
…reams to zero unsafe
Body is now `JsCell<Value>` reached through closure-scoped borrows;
BodyMixin getters split into a borrow-scoped front half and a script
half (`LockedRead`) that runs once the borrow is released.
Response is CellRefCounted with its weak-pointer finalize protocol in
bun_ptr (`HasWeakPtrData`, `finalize_owner`, `destroy_weakly_held`);
`to_js`/`into_js`/`to_js_retained` hand a typed `RefPtr` to the wrapper
and to native holders (FetchTasklet, HTMLRewriter). The abort listener is
a `NativeAbortListener` registration. Request finalizes through the same
weak-pointer protocol and owns its pooled body as a `BodyHiveHandle`.
`NewSource<C>` is CellRefCounted with a `SelfRoot`; `SourceRef<C>` is
the counted native reference (FileReader's in-flight read ref and the
reader's re-entrancy refs are typed slots). FileReader's BufferedReader
parent is the enclosing Source (`impl_buffered_reader_parent!` borrow =
this mode with a nested reader path; `read_from`/`close_from`/
`start_from`/`unpause_from` dispatch without a live reader borrow).
JSSink host functions resolve `this` in the generated thunks
(`${name}__getThis`); `__finalize`/`__controllerFinalize` dispatch on
`JsSinkType::FINALIZE` (ThisPtr / &mut / Box); the SinkID map and
`Bun__NativeTransformSink__writeBytes` / `Bun__onSinkDestroyed` are
generated. `assign_to_stream` installs the controller through a caller
closure. HTTPServerWritable's teardown is its `Drop`, `abort` returns the
source for the caller to close, the byte-list pool slot is a `PoolGuard`,
and RequestContext owns the sink as `OwnedThis`.
BakeResponse / wasm_streaming / Crypto exports are HOST_EXPORTs.
Lower layers: `RefPtr::from_box`, `OwnedThis`, `JsCell::into_inner`,
`ObjectPool::try_get`, `String::into_wtf` / `From<WTFString>`,
`ExternalShared::into_raw`, `StoreRef::set_mime_type`,
`JSValue::as_direct` checks `is_cell`, generated `take_ptr` for class
wrappers, `boxed_taskable!`.
…e_ref, HTTP3 generic gone, JsSinkType::close_with_error takes ThisPtr with the raw-pointer split in the generated close thunk, NetworkSink fail_from_js_pump on JsCell pending
…ll sites use parent
…rough NewSource::new; HTTPServerWritable names core::ptr::NonNull
6af4641 to
421f258
Compare
What
Same programme as #40055 … #40405. Stacked on #40221 (base branch
claude/blob-zero-unsafe→ #40213); retarget as those land. Shares a few primitives with #40202/#40204/#40228 (HeadersRefinbun_jsc,AbortListenerRegistration/listen_native,OwnedThis,impl_buffered_reader_parent!borrow = this,boxed_taskable!) — same hunks.src/runtime/webcore/:Response.rs30 → 0,Body.rs10 → 0,BakeResponse.rs12 → 0,ArrayBufferSink.rs3 → 0,Crypto.rs/ObjectURLRegistry.rs/ByteBlobLoader.rs→ 0;Request.rs20 → 1,ReadableStream.rs23 → 1,FileReader.rs27 → 1,Sink.rs17 → 1,wasm_streaming.rs5 → 1 (each residual is an all-safe fnextern block);streams.rs26 → 7 (see below). Collateral:FetchTasklet.rs−13,RequestContext.rs−4,s3/client.rs−2.Body { value: JsCell<Value> },BodyMixin::body(&self)/body_value()return aLockedReadso thereadableStreamTo*builtins and producer start hooks run after the body borrow is released;Value::WTFStringImpl(WTFString)owns its ref;Response::{to_js(self), into_js(Box<Self>), to_js_retained -> (JSValue, RefPtr<Response>), make_maybe_pooled}; body hive slot allocated viajsc_hooks::body_hive_alloc;HTMLRewriterholds a typedRefPtr<Response>;BakeResponseClass__*,getBodyStreamOrBytesForWasmStreaming,CryptoObject__createareHOST_EXPORTs.NewSource::new(ctx, global) -> RefPtr<Self>,SourceRef<C>,SourceContextall&self,finalize(self: Box<Self>); FileReader callbacks takeThisPtr<Source>and dispatch reads throughBufferedReader::*_from(owner);StreamResult::Pending(BackRef<JsCell<Pending>>);generate-jssink.tsemits${name}__getThis,FinalizeReceiverdispatch,sink_handle_from_id;JSSink::assign_to_stream(.., set_source: impl FnOnce(SourceHandle));HTTPServerWritable::abort(&mut self) -> SourceHandle(caller closes it),destroy→Drop;RequestContext.sink: JsCell<Option<OwnedThis<JSSink<..>>>>.bun_ptr::weak_ptrrewritten (WeakPtrData(Cell<u32>), safeHasWeakPtrData,finalize_owner(Box<T>),unsafe fn destroy_weakly_held);RefPtr::from_box;JsCell::into_inner;ObjectPool::try_get;String::into_wtf/From<WTFString>;image::codecs::Encoded::as_slice(&self).Not zero —
streams.rskeeps 6 real sites that belong to sibling PRs' types:SourceHandle::ShellWritable(BackRef<_, Mut>).get_mut()(#40228 makesShellSubprocessaRootback-ref) andNetworkSink::on_writable/end_from_stream(*mut Self)×5 (#40252 rewrites NetworkSink). They go when those land.Left at parity and noted:
Value::resolve/to_error_instancestill run inside a bodywith_mutin FetchTasklet/html_rewriter/BodyAbortListener::on_abort(same aliasing shape as before; owned by #40202/#40203);CellRefCounted::deref_nn(NonNull)is a safe fn (pre-existing). Pre-existing:JSBakeResponse.cpppassesnullptrforbake_ssr_has_jsx, which both old and new code write through.Perf (release,
taskset -c 56-63,perf stat -e instructions:u, min of 7 vs the base built identically):new Response(x)×1e6 — string −5.2 %, ArrayBuffer −4.2 %, Blob −5.9 %;Response.json×1e6 −2.4 %;req.text()64 KB ×1e4 −0.75 %; Bun.serve static 300k req (oha) −0.6 % server instructions; 256 MB Blob stream pull ×8 within noise; barenew Request(url, {body})+0.26 % (one extra 168-byte move into the pooled slot). malloc counts identical (1 per Response(string/blob), 2 per Response(ArrayBuffer), 4 per Response.json).Testing
Debug+ASAN, per-file
--timeout 120000: response, body, body-stream (9086), body-mixin-errors, fetch.stream, blob, blob-cow, wasm-streaming, stream-fast-path, fetch-abort-stream-body, fetch-stream-cancel-leak, fetch-response-finalizer-sweep, body-stream-excess, server-response-stream-leak, request/response-cyclic-reference, streams/* (compression, native-source-onclose-leak, pipeTo-, readable-stream-, streams-string-limit, streams.test.js, sync-pull-fast-path, transform-stream-leak), direct-readable-stream, serve.test (296; root-range-port is a root-user artifact), serve-body-leak, bun-serve-static, bun-serve-html, bun-server, filesink, bun-write, node-stream*, html-rewriter (+leak, +end-error), FormData, response-to-bake-response, spawn, spawn-streaming-stdin, s3, abort, inspect — pass. fetch.test.ts 359/5 (root-permission ×4 + the known abort-timing red). Hand-driven: body-type × reader matrix, clone + tee, streaming Request into a local server, mid-pull cancel for File/Blob/bytes sources, ArrayBufferSink +Bun.write(Response(stream)), 1e4 Responses + GC (collected), Worker terminated mid-read ×5 — exit 0, no ASAN output. clippy clean on bun_runtime/bun_ptr/bun_jsc/bun_io/bun_collections/bun_core/bun_event_loop;rust-check-allwindows-msvc + aarch64-darwin pass.