blob: hand the read handler's pointer to on_read_bytes instead of &mut self - #37681
Conversation
…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.
|
Updated 5:35 PM PT - Aug 11th, 2026
✅ @robobun, your commit 039d46e3cc682910bebefa0e6c4dd13a58495afa passed in 🧪 To try this PR locally: bunx bun-pr 37681That installs a local version of the PR into your bun-37681 --bun |
|
Status: reproduced the shape with a standalone Miri reduction (Tree Borrows and Stacked Borrows both reject the |
|
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 (5)
WalkthroughChangesBlob read ownership
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
LGTM — a targeted receiver-type change (&mut self → this: *mut Self) that fixes a real aliasing-model violation without changing runtime behavior.
What was reviewed
- Confirmed
BlobReadChainis the onlyReadBytesHandlerimplementor and all fourread_bytes_to_handlerdelivery sites (file call/cancel, S3 callback, in-memory) now hand the rawctxthrough unchanged — same pointer value as before, just no protected&muton 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 allowlistedsrc/jsc/webcore_types.rsentry coordinates cleanly with #37672. - New image test is hermetic (local
Bun.serveS3 endpoint, proxy env restored infinally); 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_handleris not new behavior — it was already whatBlobReadChain::startrelied 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.servefor S3, proxy env vars saved/restored infinally) and cover the S3 success/error arms, the synchronous in-memory arm, and the worker-cancel arm — the three delivery paths the existingBun.file()test didn't reach.
### 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>
What
ReadBytesHandler::on_read_bytes(src/runtime/webcore/Blob.rs) took&mut self. Its only implementor,BlobReadChainin src/runtime/image/Image.rs, used that receiver to reclaim the Box it lives in:This is the
Bun.Imagesource path forBun.file()/Bun.s3()/ zero-length in-memory Blobs. No crash is known; it is an aliasing-model violation (latent UB), found while convertingBlob::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_runtimecannot run under Miri, so here is a reduction with exactly this shape (a trait methodon_read_bytes(&mut self)thatBox::from_raws its receiver, dispatched asH::on_read_bytes(unsafe { &mut *ctx }, ..)like the sites inread_bytes_to_handler), next to the shape this PR switches to:Fix
The trait method becomes
unsafe fn on_read_bytes(this: *mut Self, result), the shapeReadFileCompletion::run(ctx: *mut Self, ..)in blob/read_file.rs already uses for the same job:read_bytes_to_handlerpasses thectxit was given straight through at its four delivery sites (file completion, file cancel, S3 callback, synchronous in-memory), and the Image impl doesheap::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::startleaks the chain whenread_bytes_to_handlerreturnsErr. It does not: the onlyErrsource is the S3 branch, andexecute_simple_s3_requestonly returnsErrwhen 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 onread_bytes_to_handlerinstead of being implicit.Tests
test/internal/source-lints/self-receiver-reclaim.test.tspins the shape tree-wide (heap::take/heap::destroy/Box::from_rawofselfor a pointer spelled fromself). It fails on main withsrc/runtime/image/Image.rs:1385: heap::take(std::ptr::from_mut::<Self>(self)and passes with this change.Blob::deinitin 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 (localBun.serveendpoint, success andNoSuchKey) and a zero-length in-memory slice (synchronous delivery), the two deliveries the existingBun.file()test does not reach. The S3 client applies an ambientHTTP_PROXYregardless ofNO_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 toldECANCELED, frees itself) runs under the debug/ASAN build.Debug (ASAN) build:
image.test.ts94 pass,image-adversarial.test.ts61 pass (the concurrentBlobReadChaincase included),worker-refused-completion.test.ts16 pass,test/internal/source-lints/82 pass;cargo clippy -p bun_runtimeclean on the touched files.