Skip to content

webcore: remove unsafe from Response/Request/Body/ReadableStream/FileReader/Sink - #40411

Open
Jarred-Sumner wants to merge 8 commits into
claude/blob-zero-unsafefrom
claude/webstreams-zero-unsafe
Open

Jarred-Sumner wants to merge 8 commits into
claude/blob-zero-unsafefrom
claude/webstreams-zero-unsafe

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

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 (HeadersRef in bun_jsc, AbortListenerRegistration/listen_native, OwnedThis, impl_buffered_reader_parent! borrow = this, boxed_taskable!) — same hunks.

src/runtime/webcore/: Response.rs 30 → 0, Body.rs 10 → 0, BakeResponse.rs 12 → 0, ArrayBufferSink.rs 3 → 0, Crypto.rs/ObjectURLRegistry.rs/ByteBlobLoader.rs → 0; Request.rs 20 → 1, ReadableStream.rs 23 → 1, FileReader.rs 27 → 1, Sink.rs 17 → 1, wasm_streaming.rs 5 → 1 (each residual is an all-safe fn extern block); streams.rs 26 → 7 (see below). Collateral: FetchTasklet.rs −13, RequestContext.rs −4, s3/client.rs −2.

  • Body/Response/Request: Body { value: JsCell<Value> }, BodyMixin::body(&self)/body_value() return a LockedRead so the readableStreamTo* 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 via jsc_hooks::body_hive_alloc; HTMLRewriter holds a typed RefPtr<Response>; BakeResponseClass__*, getBodyStreamOrBytesForWasmStreaming, CryptoObject__create are HOST_EXPORTs.
  • Sources/sinks: NewSource::new(ctx, global) -> RefPtr<Self>, SourceRef<C>, SourceContext all &self, finalize(self: Box<Self>); FileReader callbacks take ThisPtr<Source> and dispatch reads through BufferedReader::*_from(owner); StreamResult::Pending(BackRef<JsCell<Pending>>); generate-jssink.ts emits ${name}__getThis, FinalizeReceiver dispatch, 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<..>>>>.
  • Primitives: bun_ptr::weak_ptr rewritten (WeakPtrData(Cell<u32>), safe HasWeakPtrData, 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.rs keeps 6 real sites that belong to sibling PRs' types: SourceHandle::ShellWritable(BackRef<_, Mut>).get_mut() (#40228 makes ShellSubprocess a Root back-ref) and NetworkSink::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_instance still run inside a body with_mut in 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.cpp passes nullptr for bake_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; bare new 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-all windows-msvc + aarch64-darwin pass.

Comment thread src/runtime/webcore/Response.rs Outdated

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

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.

@robobun

robobun commented Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 8:18 AM PT - Sep 8th, 2026

❌ @Jarred-Sumner, your commit 421f258 has 1 failures in Build #112925 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 40411

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

bun-40411 --bun

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

Code review found no issues

No high-confidence issues detected in this change.

@Jarred-Sumner
Jarred-Sumner force-pushed the claude/blob-zero-unsafe branch 3 times, most recently from 65296c6 to 6f49cdf Compare August 29, 2026 09:27
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/webstreams-zero-unsafe branch from cb3798d to e7200a3 Compare August 29, 2026 09:49
Comment thread src/runtime/webcore/streams.rs Outdated

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/runtime/webcore/FileReader.rs Outdated

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

Code review found no issues

No high-confidence issues detected in this change.

@Jarred-Sumner
Jarred-Sumner force-pushed the claude/blob-zero-unsafe branch from 6f49cdf to 178f765 Compare September 6, 2026 20:15
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/webstreams-zero-unsafe branch from 4bea94f to 6af4641 Compare September 6, 2026 21:18

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

Code review found no issues

No high-confidence issues detected in this change.

@Jarred-Sumner
Jarred-Sumner force-pushed the claude/blob-zero-unsafe branch 3 times, most recently from aaa2ee2 to e231232 Compare September 8, 2026 12:40
Jarred-Sumner and others added 8 commits September 8, 2026 12:52
…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
…rough NewSource::new; HTTPServerWritable names core::ptr::NonNull
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/webstreams-zero-unsafe branch from 6af4641 to 421f258 Compare September 8, 2026 13:04

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

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants