Skip to content

blob: hand the read handler's pointer to on_read_bytes instead of &mut self - #37681

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/1a5c610b/read-bytes-handler-ownership
Aug 12, 2026
Merged

blob: hand the read handler's pointer to on_read_bytes instead of &mut self#37681
Jarred-Sumner merged 1 commit into
mainfrom
farm/1a5c610b/read-bytes-handler-ownership

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

What

ReadBytesHandler::on_read_bytes (src/runtime/webcore/Blob.rs) took &mut self. Its only implementor, BlobReadChain in src/runtime/image/Image.rs, used that receiver to reclaim the Box it lives in:

fn on_read_bytes(&mut self, result: ReadBytesResult) {
    let boxed = unsafe { bun_core::heap::take(std::ptr::from_mut::<Self>(self)) };
    boxed.on_read_bytes_impl(result);   // Box freed here, while `&mut self` is still on the stack
}

This is the Bun.Image source path for Bun.file() / Bun.s3() / zero-length in-memory Blobs. No crash is known; it is an aliasing-model violation (latent UB), found while converting Blob::deinit (#37672), which has the same shape.

Why it is wrong

A reference argument is protected for the whole call under both aliasing models, and deallocating protected memory is UB even if the reference is never used again. bun_runtime cannot run under Miri, so here is a reduction with exactly this shape (a trait method on_read_bytes(&mut self) that Box::from_raws its receiver, dispatched as H::on_read_bytes(unsafe { &mut *ctx }, ..) like the sites in read_bytes_to_handler), next to the shape this PR switches to:

# Tree Borrows (the flags `bun run rust:miri` uses)
test tests::by_ptr_receiver ... ok
test tests::by_ref_receiver ... error: Undefined Behavior: deallocation through <144833> at alloc47733[0x0] is forbidden
     = help: the allocation of the accessed tag <144833> also contains the strongly protected tag <144822>
     = help: the strongly protected tag <144822> disallows deallocations
  18 |         let boxed = unsafe { Box::from_raw(std::ptr::from_mut::<Self>(self)) };
help: the strongly protected tag <144822> was created here, in the initial state Reserved
  17 |     fn on_read_bytes(&mut self, bytes: Vec<u8>) {

# Stacked Borrows
test tests::by_ref_receiver ... error: Undefined Behavior: deallocating while item [Unique for <131080>] is strongly protected
test tests::by_ptr_receiver ... ok

Fix

The trait method becomes unsafe fn on_read_bytes(this: *mut Self, result), the shape ReadFileCompletion::run(ctx: *mut Self, ..) in blob/read_file.rs already uses for the same job: read_bytes_to_handler passes the ctx it was given straight through at its four delivery sites (file completion, file cancel, S3 callback, synchronous in-memory), and the Image impl does heap::take(this). The only frame on the stack when the Box is freed now holds a raw pointer, which is what both models allow. No behaviour change; the pointer value delivered is the same one as before.

The handoff also asked whether BlobReadChain::start leaks the chain when read_bytes_to_handler returns Err. It does not: the only Err source is the S3 branch, and execute_simple_s3_request only returns Err when the callback itself did, i.e. after it has already delivered to (and consumed) the handler; the file branch is infallible and the in-memory branch delivers before returning. That "exactly one delivery, whatever the return value" contract is what the ownership transfer relies on, so it is now written down on read_bytes_to_handler instead of being implicit.

Tests

  • test/internal/source-lints/self-receiver-reclaim.test.ts pins the shape tree-wide (heap::take / heap::destroy / Box::from_raw of self or a pointer spelled from self). It fails on main with src/runtime/image/Image.rs:1385: heap::take(std::ptr::from_mut::<Self>(self) and passes with this change. Blob::deinit in src/jsc/webcore_types.rs is the one other instance; it is allowlisted with a ratchet and is being converted in blob: delete Blob::deinit, which freed the allocation through &mut self #37672. That PR adds the same lint file with the allowlist the other way round, so whichever of the two lands second drops the remaining allowlist entry (the ratchet test says so when it happens); the two PRs do not otherwise touch the same lines.
  • test/js/bun/image/image.test.ts: new case for an S3-backed source (local Bun.serve endpoint, success and NoSuchKey) and a zero-length in-memory slice (synchronous delivery), the two deliveries the existing Bun.file() test does not reach. The S3 client applies an ambient HTTP_PROXY regardless of NO_PROXY (S3Client ignores NO_PROXY when HTTP_PROXY is set #32045), so the test blanks the proxy variables around the S3 calls.
  • test/js/web/workers/worker-refused-completion.test.ts: new row with an image read in flight when the worker goes away, so the cancel delivery (handler told ECANCELED, frees itself) runs under the debug/ASAN build.

Debug (ASAN) build: image.test.ts 94 pass, image-adversarial.test.ts 61 pass (the concurrent BlobReadChain case included), worker-refused-completion.test.ts 16 pass, test/internal/source-lints/ 82 pass; cargo clippy -p bun_runtime clean on the touched files.

…t self

ReadBytesHandler::on_read_bytes took `&mut self`, and its only implementor,
BlobReadChain in Image.rs, reclaimed the Box it lives in through that
receiver (heap::take(ptr::from_mut(self))). Freeing the allocation behind a
reference argument while the call is still running is rejected by both
Stacked Borrows and Tree Borrows, whether or not the reference is used again.

The trait method now takes `this: *mut Self`, the same shape as
ReadFileCompletion::run next to it: read_bytes_to_handler passes the ctx
pointer it was given straight through at all four delivery sites (file
completion, file cancel, S3 callback, synchronous in-memory), and the Image
impl reclaims that pointer. The contract that exactly one delivery happens
regardless of the return value is documented on read_bytes_to_handler.

A source lint pins the shape tree-wide; Blob::deinit, which is being
converted in a separate change, is allowlisted with a ratchet. Image tests
now also cover the S3 and zero-length in-memory deliveries, and the
worker-refusal suite gets a row for the cancel delivery.
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:35 PM PT - Aug 11th, 2026

@robobun, your commit 039d46e3cc682910bebefa0e6c4dd13a58495afa passed in Build #92643! 🎉


🧪   To try this PR locally:

bunx bun-pr 37681

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

bun-37681 --bun

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced the shape with a standalone Miri reduction (Tree Borrows and Stacked Borrows both reject the &mut self receiver freeing its own Box; the raw-pointer receiver passes, output in the description). The in-tree instance is pinned by test/internal/source-lints/self-receiver-reclaim.test.ts, which fails on main at src/runtime/image/Image.rs:1385 and passes here. Coordinates with #37672, which adds the same lint with the allowlist the other way round; whichever lands second drops the leftover allowlist entry.

@coderabbitai

coderabbitai Bot commented Aug 12, 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: 74c0b5fd-598a-4deb-a06b-78bcf20b4b13

📥 Commits

Reviewing files that changed from the base of the PR and between ae33ace and 039d46e.

📒 Files selected for processing (5)
  • src/runtime/image/Image.rs
  • src/runtime/webcore/Blob.rs
  • test/internal/source-lints/self-receiver-reclaim.test.ts
  • test/js/bun/image/image.test.ts
  • test/js/web/workers/worker-refused-completion.test.ts

Walkthrough

Changes

Blob read ownership

Layer / File(s) Summary
Callback contract and delivery paths
src/runtime/webcore/Blob.rs
ReadBytesHandler now receives an unsafe raw pointer. Synchronous, file, cancellation, and S3 paths consume the context exactly once.
Image read-chain reclamation
src/runtime/image/Image.rs
BlobReadChain passes the leaked pointer directly to the dispatcher. The callback reclaims it before processing the result.
Ownership lint and image coverage
test/internal/source-lints/self-receiver-reclaim.test.ts, test/js/bun/image/image.test.ts, test/js/web/workers/worker-refused-completion.test.ts
The lint detects receiver reclamation patterns. Tests cover S3, Blob, empty data, missing objects, and worker refusal paths.

Possibly related PRs

  • oven-sh/bun#37625: Both PRs modify Blob.rs and address Blob read-chain ownership documentation or behavior.

Suggested reviewers: jarred-sumner, dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change from a protected mutable reference to a raw pointer in the read handler callback.
Description check ✅ Passed The description explains the problem, fix, ownership contract, affected paths, and verification results, although its headings differ from 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.

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

LGTM — a targeted receiver-type change (&mut selfthis: *mut Self) that fixes a real aliasing-model violation without changing runtime behavior.

What was reviewed

  • Confirmed BlobReadChain is the only ReadBytesHandler implementor and all four read_bytes_to_handler delivery sites (file call/cancel, S3 callback, in-memory) now hand the raw ctx through unchanged — same pointer value as before, just no protected &mut on the stack when the Box is freed.
  • Checked the new source-lint follows the sibling lints' conventions (globAllSources().rust, tracked-file filter, ratchet allowlist, regex self-test with positive and negative fixtures) and that the allowlisted src/jsc/webcore_types.rs entry coordinates cleanly with #37672.
  • New image test is hermetic (local Bun.serve S3 endpoint, proxy env restored in finally); worker-refused-completion row slots into the existing table.
Extended reasoning...

Overview

The PR changes ReadBytesHandler::on_read_bytes in src/runtime/webcore/Blob.rs from taking &mut self to unsafe fn on_read_bytes(this: *mut Self, ..), updates the four call sites in read_bytes_to_handler to pass the raw ctx pointer directly instead of &mut *ctx, and updates the sole implementor (BlobReadChain in src/runtime/image/Image.rs) to heap::take(this) instead of heap::take(ptr::from_mut(self)). It also adds a tree-wide source-lint pinning the shape, an S3/zero-length image test covering the two previously-unexercised delivery paths, and a worker-teardown row for the cancel path.

Security risks

None. This is a receiver-type refactor on an internal trait; no user-facing API surface, no parsing of untrusted input, no auth/crypto/permission code touched. The pointer value delivered to the handler is byte-identical to before.

Level of scrutiny

Medium — it touches unsafe FFI/heap code, but the change is mechanical and follows the codebase's own documented pattern for exactly this hazard (src/CLAUDE.md's "Pointer provenance at FFI boundaries" section, and the existing ReadFileCompletion::run(ctx: *mut Self, ..) shape the trait doc now points at). The PR description includes a Miri reduction confirming both Tree Borrows and Stacked Borrows reject the old shape and accept the new one.

Other factors

  • The trait has exactly one implementor (grep-verified), so the whole class is fixed here.
  • The documented "exactly one delivery, whatever this returns" contract on read_bytes_to_handler is not new behavior — it was already what BlobReadChain::start relied on; the PR writes it down. If that contract were ever wrong it would be a pre-existing bug, not one introduced here.
  • The source-lint is defensive rather than load-bearing: it has a self-test asserting both banned and allowed spellings, a non-empty-scan guard against vacuous passes, and a ratchet so the one allowlisted instance can't silently multiply.
  • Tests are hermetic (local Bun.serve for S3, proxy env vars saved/restored in finally) and cover the S3 success/error arms, the synchronous in-memory arm, and the worker-cancel arm — the three delivery paths the existing Bun.file() test didn't reach.

@Jarred-Sumner
Jarred-Sumner merged commit 99f16a8 into main Aug 12, 2026
50 of 51 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/1a5c610b/read-bytes-handler-ownership branch August 12, 2026 00:42
Jarred-Sumner added a commit that referenced this pull request Aug 12, 2026
### Problem

The JSSink finalize chain frees the sink while reference arguments to it
are still live. At `3fc747a7da`:

* generated thunk `extern "C" fn ${name}__finalize(this: &mut ${name})`
(src/codegen/generate-jssink.ts), called from `~JS${name}`,
`~JSReadable${name}Controller` and `${name}__doClose`
* `JSSink::js_finalize(this: &mut T)` (src/runtime/webcore/Sink.rs)
* `JsSinkType::finalize(&mut self)` (src/runtime/webcore/Sink.rs), whose
impls do the actual release:
* `ArrayBufferSink` (src/runtime/webcore/ArrayBufferSink.rs):
`Self::finalize(ptr::from_mut(self))` -> `destroy` -> `heap::take`,
unconditionally. The comment on the impl said the C export owned the
free; this call is the free.
* `FileSink` (src/runtime/webcore/FileSink.rs): the inherent
`finalize(&mut self)` ends in `FileSink::deref(ptr::from_mut(self))`,
which runs `deinit` -> `heap::take` whenever the wrapper's +1 was the
last ref, i.e. on an ordinary GC sweep of a sink nothing else holds. The
header comment argued this was fine because the `&mut` carries write
provenance, which is true but is not the problem.
* `FetchRequestBodySink`
(src/runtime/webcore/fetch/FetchRequestBodySink.rs): drops the tasklet
ref taken in `start_request_stream`. The tasklet owns the sink
allocation, so if that ref is the last one, `FetchTasklet::deinit` ->
`clear_data` -> `clear_sink` -> `heap::take(sink)` frees `*self` inside
the call. That is the fallback path for a pump that never settled; it is
reachable at least on worker teardown: phase B of `VirtualMachine`
teardown releases the aborted fetch's other refs on the tasklet, and
phase C then destroys the heap, sweeping the controller with `m_sinkPtr`
still set because `JSSinkController__onClose` does not run the detaching
JS callback once termination is pending.
* `HTTPServerWritable`, `NetworkSink` and `RewriterPipe` do not free
anything here (their allocations are owned by the `RequestContext`, the
S3 wrapper and the pipe's own refcount respectively).

A reference passed as an argument has to stay dereferenceable until the
call returns. Freeing it from inside the call is undefined behaviour
under both aliasing models whether or not the reference is used again
(Stacked Borrows: `deallocating while item is strongly protected`; Tree
Borrows, which `bun run rust:miri` uses, rejects it the same way), and
that protector is the model behind the `dereferenceable` attribute rustc
puts on every `&`/`&mut` argument, so the optimizer may legitimately
move a load through any of the three frames past the free. No crash is
known from this; ASAN only has something to catch if the optimizer
actually takes that liberty, which the unoptimized debug build never
does, so it is not observable as a runtime test. Same family as #37672,
#37681, #37685, #37693, #37705 and #37551; #37705's description leaves
this chain out explicitly because it needs a change to the generated
thunk.

### Fix

The whole chain takes the raw pointer, which is what the C++ side has
anyway (`void* m_sinkPtr`):

* generate-jssink.ts emits `pub unsafe extern "C" fn
${name}__finalize(this: *mut ${name})` forwarding to `js_finalize`; the
ABI is unchanged, so JSSink.cpp is untouched.
* `JSSink::js_finalize(this: *mut T)` forwards to the trait.
* `JsSinkType::finalize` becomes `unsafe fn finalize(this: *mut Self)`,
documented as "the cell is giving up its claim; this may free the sink",
the same shape as `HTTPServerWritable::abort(this: *mut Self)` and the
FileSink PipeWriter callbacks.
* The three freeing impls release through the pointer without forming a
reference to the allocation: `ArrayBufferSink` calls `destroy` directly
(the inherent `finalize` wrapper, whose only caller was the trait impl,
is deleted); `FileSink::finalize(this: *mut FileSink)` keeps the same
body with per-statement `(*this).field` access, like `on_close` in the
same file (the file header no longer claims the `&mut` version was
sound; the rationale lives once, on the trait method);
`FetchRequestBodySink::finalize(this: *mut Self)` takes `task` out
through the pointer and does not touch it after the deref.
* `HTTPServerWritable` and `NetworkSink` reborrow inside their own impl
to call the unchanged inherent `finalize(&mut self)`; that borrow ends
before the impl returns and nothing under it frees, which the SAFETY
comments state. `RewriterPipe`'s impl stays empty.

Every impl performs the same operations in the same order as before; the
only thing that moves is the type the pointer travels as.
`js_controller_detached`, `js_close` and `js_end_with_sink` still take
`&mut`: nothing frees under them (the `controller_detached` contract on
the trait already requires deferring a last-owner free for that reason).
`FileSink::assign_to_stream`'s `FileSinkRef` guard also derefs from a
`&mut self` frame, but its ref is balanced against one it took itself
and every caller (subprocess stdin setup) holds its own ref across the
call, so it can never be the one that frees; left alone. Sites with the
same shape outside this chain
(`S3UploadStreamWrapper::handle_{resolve,reject}_stream`,
`FetchTasklet::write_end_request`) are not sink frames and are reported
separately.

### Tests

test/internal/source-lints/jssink-finalize-raw-ptr.test.ts scans every
`impl ... JsSinkType for ...` block for a `finalize` item and requires
`unsafe fn finalize(<ident>: *mut Self)`, checks the other frames by
signature (trait declaration, `js_finalize`, the codegen template, and
the three inherent methods that perform the free, which `pub` tells
apart from the trait impls in the same files), and checks its own
patterns against positive and negative spellings. With src/ restored to
`main` it reports:

```
src/runtime/api/html_rewriter.rs:1650: impl JsSinkType for RewriterPipe: fn finalize(&mut self) (line 1661)
src/runtime/webcore/ArrayBufferSink.rs:213: impl JsSinkType for ArrayBufferSink: fn finalize(&mut self) (line 221)
src/runtime/webcore/fetch/FetchRequestBodySink.rs:274: impl JsSinkType for FetchRequestBodySink: fn finalize(&mut self) (line 281)
src/runtime/webcore/FileSink.rs:1283: impl JsSinkType for FileSink: fn finalize(&mut self) (line 1294)
src/runtime/webcore/streams.rs:2104: impl JsSinkType for HTTPServerWritable: fn finalize(&mut self) (line 2119)
src/runtime/webcore/streams.rs:2523: impl JsSinkType for NetworkSink: fn finalize(&mut self) (line 2530)
src/runtime/webcore/Sink.rs: JsSinkType::finalize declaration does not take the sink as `*mut`
src/runtime/webcore/Sink.rs: JSSink::js_finalize does not take the sink as `*mut`
src/codegen/generate-jssink.ts: generated `${name}__finalize` thunk does not take the sink as `*mut`
src/runtime/webcore/FileSink.rs: FileSink::finalize does not take the sink as `*mut`
src/runtime/webcore/fetch/FetchRequestBodySink.rs: FetchRequestBodySink::finalize does not take the sink as `*mut`
```

(`ArrayBufferSink::destroy` already took `*mut` on `main`; its entry is
a ratchet.)

The behaviour itself is the existing coverage of each finalize path; see
below.

### Verification

Debug (ASAN) build on Linux: `cargo clippy -p bun_runtime` and `rustfmt
--check` on the touched files are clean; the generated thunks have the
new signature. Passing: test/internal/source-lints/ (all 18 files),
test/js/bun/util/arraybuffersink.test.ts and filesink.test.ts (wrapper
sweep and prototype `.close()` for the two Box/refcount sinks),
test/js/bun/spawn/spawn.test.ts (stdin `FileSink` via
`assign_to_stream`), test/js/web/fetch/body-stream.test.ts,
fetch-abort-stream-body.test.ts and fetch-stream-cancel-leak.test.ts
(`FetchRequestBodySink`),
test/js/bun/http/serve-response-stream-sink-leak,
serve-direct-readable-stream, serve-stream-reject-flush-leak and
serve-async-stream-client-abort (`HTTPServerWritable` controller
teardown), test/js/web/fetch/server-response-stream-leak.test.ts,
test/js/web/streams/streams.test.js,
test/js/workerd/html-rewriter.test.js and html-rewriter-leak.test.ts
(`RewriterPipe`), test/js/bun/s3/s3-stream-error-gc.test.ts and
s3-argument-validation.test.ts. The S3 upload tests that would drive
`NetworkSink` (s3.test.ts, s3-storage-class.test.ts) cannot connect from
this environment and fail identically on the released binary, so that
impl (a one-line forward to the unchanged inherent method) is left to
CI.

Overlap with the sibling lints, each of which documents these sites as
tracked separately: #37685 / #37693 / #37705 add
`self-receiver-teardown.test.ts` with
`src/runtime/webcore/ArrayBufferSink.rs: 1` allowlisted for the
`Self::finalize(ptr::from_mut(self))` line this PR removes, and #37703
adds `self-receiver-release.test.ts` with
`src/runtime/webcore/FileSink.rs: 2` allowlisted for the two derefs
inside the old `FileSink::finalize(&mut self)` (running that lint
against this branch reports FileSink.rs at 0). Whichever side lands
second deletes the entry; nothing else conflicts (#37703's
FetchRequestBodySink.rs hunk is `end_from_stream`, a different
function). #34999 and #35528 edit the body of `FileSink::finalize`
textually but keep the receiver.

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
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