blob: delete Blob::deinit, which freed the allocation through &mut self
Problem
Blob::deinit(&mut self)released the blob's resources and then, whenever the intrusive count was non-zero, freed the allocation it had been called through. A safe method call could therefore free memory.Blobis built by value in many places andref_countis public, so a by-valueBlobwith a count set made a safedeinit()free a stack address, and a&mutinto a JS-ownedBlobfreed it out from under the wrapper,BlobRefPtrorExternalShared<Blob>holding its counts.- Even on the intended path (last count released) freeing through the
&mut selfargument is invalid: a reduction of this shape fails under Miri withdeallocation through <tag> ... is forbidden ... the strongly protected tag disallows deallocations. This is why bun's other freeing teardowns takethis: *mut Self. - No in-tree caller misused it. This is a contract fix in the same family as #37597, #37681 and #37577, split out because it needed a caller audit.
Fix
deinit()is deleted. Without the free it did exactly what dropping aBlobalready does (Blobhas noDropimpl; store ref, name and content type release themselves), so the nine by-value callers now let theirBlobdrop, and two custom destructors that existed only to make this call go away.- The one free of a
Blob::newallocation is nowBlob__derefreleasing the last count, which destroys the*mut Blobit receives. Property to check: after this PR no safe function frees aBlob, and each converted call site releases the same things at the same point as before. - The structured-clone deserializer, the other caller that relied on the free, now keeps the blob by value until the record has fully parsed and heap-promotes it right before wrapping it, so every early return drops a local instead of running a raw-pointer guard.
- Verification: the source lint's allowlist entry for this line is removed (on
mainit reports exactly this line, with the fix zero); a new test deserializes every truncated prefix of three record kinds under the ASAN build, also run 20 times under leak detection; the existing suites for each converted drop site pass on the ASAN build.
Background
Blobcarries an intrusiveref_count. Count zero means a by-valueBlobowned by whatever struct holds it (aBody, a route, a download task).Blob::newmoves it to the heap with one count; JS wrappers, C++BlobRefPtrandExternalShared<Blob>hold further counts throughBlob__ref/Blob__deref.- A blob's resources (store ref, name, content type) are fields with their own destructors, so ordinary Rust drop fully tears down a by-value
Blob. Only a heap-promoted one needs an explicit free. - Freeing memory through a
&mut selfreceiver is undefined behaviour even when the caller holds the last count, because the reference protects the memory for the duration of the call. The tree's convention for teardowns that free is a rawthis: *mut Self. - The structured-clone deserializer reads a blob record in two halves: the store (bytes, file path, or empty), then a trailer (File flag, lastModified, name). A record cut short inside the trailer leaves a half-built blob that has to be released exactly once.
test/internal/source-lints/self-receiver-reclaim.test.tsis a ratchet: it scanssrc/for reclaiming a method's own receiver and its allowlist holds an exact count per file, so removing the last offender also requires removing its entry.
Original description
Problem
Blob::deinit(&mut self) (src/jsc/webcore_types.rs) released the store ref, the name and the content type, and then ended with
if self.is_heap_allocated() { // == ref_count != 0
unsafe { drop(bun_core::heap::take(std::ptr::from_mut::<Blob>(self))) };
}
so it freed whatever allocation it happened to be called through whenever the intrusive count was non-zero. Nothing about a &mut Blob supports that:
-
Blobis constructed by value all over the tree (Blob::dupe(),AnyBlob/Body::Value::Blobpayloads, stack locals),Defaultis public and so isref_count, so this was an invalid free with nounsafeanywhere in the caller:let mut b = bun_jsc::webcore_types::Blob::default(); b.ref_count = bun_ptr::RawRefCount::init(1); b.deinit(); // heap::take on a stack addressand a
&mut Blobinto a JS-owned heapBlob(src/runtime/image/Image.rs and src/runtime/api/Archive.rs form those today) turned a safedeinit()into freeing the object out from under the wrapper,BlobRefPtrorExternalShared<Blob>that owns its counts. -
Even on the intended path (
Blob__derefreleasing the last count) freeing through the&mut selfargument deallocates memory that a reference argument protects for the duration of the call. A standalone reduction of exactly this shape fails under Miri with both models: Tree Borrows (whatbun run rust:miriuses) reportsdeallocation through <tag> ... is forbidden ... the strongly protected tag disallows deallocations, pointing at the&mut selfreceiver; Stacked Borrows reportsdeallocating while item [Unique] is strongly protected. This is why the tree's other teardown functions that end in a free takethis: *mut Self(see the comments ondeinitin src/sql_jsc/postgres/PostgresSQLConnection.rs and src/sql_jsc/mysql/JSMySQLConnection.rs, and #37681 for the same conversion onReadBytesHandler).
No in-tree caller misused it; this is a contract fix in the same family as #37597 (Blob__ref/Blob__deref), #37681 and #37577, split out of #37597 because it needed a caller audit.
Fix
The audit of every Blob-typed deinit() call found two callers that relied on the free and nine that did not, and the nine all drop the Blob immediately after the call. Once the free is gone, deinit() is exactly what dropping a Blob already does (Blob has no Drop impl; StoreRef, OwnedStringCell and the content type Arc release themselves), so rather than keeping a method that duplicates the drop glue (Body.rs already carries three "never expose pub fn deinit(&mut self)" notes from the port), it is deleted:
- A
Blob::newallocation is freed in exactly one place:Blob__derefreleasing the last count now runsheap::destroyon the*mut Blobit receives (#37597 gave it that signature) instead of re-arming the count and callingdeinit().Blob::newandBlob__refdocument that nothing else frees it. on_structured_clone_deserialize(src/runtime/webcore/Blob.rs), the other caller that relied on the free, heap-promoted the blob before reading the record's trailer and used a raw-pointer scope guard callingdeinit()to free it on a truncated record. It now keeps the blob by value until the record has fully parsed and callsBlob::newimmediately before wrapping it, so every early return drops a local. Both scope guards, theunsafereborrow and theis_heap_allocatedassertion go away; the rest of the function is unchanged.- The by-value callers now just let their
Blobdrop:Body::Value'sDroparm and the twoto_readable_streamscope guards (Body.rs), the S3read_bytes_to_handlertask andS3BlobDownloadTask::drop(Blob.rs), theblob:import keep-alive in jsc_hooks.rs, andObjectURLRegistry::EntryandFileRoute, whose custom destructors existed only to make this call (FileRoutegoes back toCellRefCounted's defaultdestroy, which its docs prescribe for exactly this case). Each of these released the same things at the same point before; the only ordering change isS3BlobDownloadTask, whose blob now drops afterpoll_ref.unref()instead of before, and nothing in between looks at it.
ref_count staying pub is now only a hygiene question (one struct literal in Blob.rs needs it): no safe function's free depends on it any more, so it is left alone here. #37656 (open) makes the same by-value change to the deserializer as part of replacing BlobExt::to_js; the arms here are written to match it, and whichever lands second drops its copy of that hunk.
Tests
-
test/internal/source-lints/self-receiver-reclaim.test.ts (landed by #37681) bans reclaiming a method's own receiver through
heap::take/heap::destroy/Box::from_raw, and onmaincarries a ratcheted allowlist entry of exactly 1 for src/jsc/webcore_types.rs naming this PR. This PR deletes the entry, leaving the allowlist empty. With this test change andmain'ssrc/the lint reports exactlysrc/jsc/webcore_types.rs:465: heap::take(std::ptr::from_mut::<Blob>(self)and with the fix the tree is at zero (the ratchet test would also fail if the entry were left in place once the line is gone). The rationale sentence in unsafe-refcount-exports.test.ts that described the old
deinit()behaviour is updated to match. -
test/js/web/fetch/blob-file-name-ownership.test.ts gets a second test for the restructured deserializer: it serializes a
Filewith bytes, aBun.file()and an emptyFilethroughbun:jsc(same recordstructuredCloneproduces), deserializes every truncated prefix of each (which reaches every early return in the deserializer, including the trailer ones that previously went through the heap guard), and checks the full record still round-trips with an empty stderr, so a double free shows up under the ASAN build. The existing test in that file (4000 structuredClone round-trips, each finalized throughBlob__deref) covers the new free path.
Verification
On the debug (ASAN) build: the two test files above; for the converted drop sites, test/js/web/fetch/{body,body-stream,response}.test.ts and test/js/web/fetch/wpt/textstream-wpt.test.ts (Body), test/js/node/buffer-resolveObjectURL.test.ts, test/js/web/workers/worker_blob.test.ts and test/js/web/url/url.test.ts (object URL entries and blob: imports), test/js/bun/http/{bun-serve-file,bun-serve-static,serve-file-slice-read-error,tls-bunfile-leak}.test.ts (FileRoute, StaticRoute, the SSLConfig ExternalShared<Blob> fields), test/js/bun/s3/{s3-requester-pays,s3-connection-close,s3-insecure,s3-stream-error-gc}.test.ts against their local servers (S3BlobDownloadTask), test/js/bun/image/image.test.ts (the S3/file read task), and test/js/web/fetch/blob.test.ts, test/js/web/structured-clone-blob-file.test.ts, test/js/web/html/FormData.test.ts, test/js/web/websocket/websocket-blob.test.ts (C++ BlobRefPtr releasing through Blob__deref); plus test/js/bun/http/serve-body-leak.test.ts and the Sending Blob / Body.Value cases of test/js/web/fetch/fetch-leak.test.ts. Running the truncated-record sweep 20 times over under detect_leaks=1 reports no allocation from the blob or deserializer code. bun test test/internal/source-lints/ passes, and the lint fails with main's src/ as shown above. cargo clippy and cargo fmt --check on bun_jsc and bun_runtime are clean.