Skip to content

Extend ForeignRef<T> to the rest of the owned C/C++ FFI handles - #33887

Merged
Jarred-Sumner merged 3 commits into
claude/foreign-owned-fetch-headersfrom
claude/foreign-owned-batch
Jul 10, 2026
Merged

Extend ForeignRef<T> to the rest of the owned C/C++ FFI handles#33887
Jarred-Sumner merged 3 commits into
claude/foreign-owned-fetch-headersfrom
claude/foreign-owned-batch

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Stacked on #33820, which introduces ForeignRef<T> and converts FetchHeaders.

What this does

Extends the owned-handle pattern to every remaining opaque FFI type that Rust holds an
ownership unit of, and generates the boilerplate instead of copying it.

1. Owned handles for 16 more types — each replaces a hand-rolled owner, a scopeguard,
or a bare raw pointer with a #[repr(transparent)] newtype whose Drop calls the C
release function. Also flips 314 &mut self receivers to &self on opaque_ffi! ZSTs:
those types are UnsafeCell-backed, so &T carries no noalias and C mutates through
it — the &mut asserted an exclusivity that was never true and never needed.

2. ForeignRef<T, R = DefaultRelease> — a release-marker parameter. ForeignOwned
admits one release per type, so an object with two ownership disciplines could not use it
twice: libarchive's struct archive is freed by archive_read_free when opened for
reading and archive_write_free when opened for writing, and the write side had degraded
into a bespoke owner with a hand-written Drop. It is now
ForeignRef<sys::Archive, WriteFree>. The parameter defaults, so no existing
ForeignRef<T> changed.

3. foreign_handle! emits the newtype plus adopt / adopt_ptr / as_ptr / leak /
raw. That block had been hand-copied onto 17 types; a missing mem::forget in one copy
is a double-free the other sixteen would not reveal. Net -437 lines.

adopt and adopt_ptr are now unsafe. Most hand-written copies were safe private fns,
but adopting a pointer whose ownership unit you were not given is UB, so the obligation
belongs at the call site — all 29 now carry a SAFETY: comment naming the producer.

4. The remaining C handles, converted after reading the C and C++ rather than the
names: ENGINE, X509, X509_STORE, X509_STORE_CTX, SSL_SESSION, spng_ctx,
WebPDemuxer, WebPMux, and CookieMapRef folded onto ForeignRef.

The certificate handles are careful about where the ref comes from.
SSL_get_peer_certificate, X509_up_ref, X509_STORE_CTX_get1_issuer and d2i_X509
hand over a +1 and are adopted; SSL_get_certificate, sk_X509_value and
SSL_CTX_get_cert_store return borrows and stay raw pointers.
SSL_set0_verify_cert_store takes ownership, so that path leaks the handle rather than
dropping it.

Two bugs this surfaced

  • cppbind mapped JSC::SourceProvider to the owning handle, so the generated safe
    wrapper passed the address of a Rust stack slot to C++ ->deref(). No caller today, but
    it also produced two conflicting extern "C" declarations of one symbol.
  • Flipping Response::upgrade to &self silently moved method resolution to
    ResponseLike::upgrade, which boxes its argument a second time. Rust probes the receiver
    by-value first, so an inherent &mut self method beats a trait method there; once
    flipped it no longer matches. Only an arity mismatch made it visible.

Restores the debug-only corrupted-HandleSlot assert that Strong::destroy carried before
it became a ForeignRef; a bad slot otherwise faults inside JSC with no Rust frame.

Deliberately not converted

type why
AbortSignal, NapiEnv, JSCArrayBuffer already owned by bun_ptr::ExternalShared, which models ref/deref, not a single unit
Blob (standalone_graph) the opaque decl is an erased stand-in for a type declared a tier up
JSPropertyIteratorImpl freed by its enclosing struct's Drop
Channel (c-ares) see below
Loop, Heap, App thread/process-lifetime singletons

ares_destroy() invokes query->callback(query->arg, ARES_EDESTRUCTION, 0, NULL) for every
pending query, and those callbacks re-enter RefPtr::deref. Resolver declares ref_count
before channel, and Rust drops fields in declaration order, so a Drop-based owner would
free the refcount's debug tables and then let the callbacks read them. The open-coded
teardown in Drop for GlobalData is load-bearing. Converting it safely means declaring
channel first, or keeping an explicit drop(self.channel.take()); left for a follow-up.

spng_ctx_free, WebPDemuxDelete and WebPMuxDelete stay unsafe externs behind a plain
wrapper: a safe fn taking &sys::T would let safe code free a context the handle owns.

Verification

  • cargo build -p bun_bin clean. cargo check is not sufficient here: it stops before
    codegen, so it never evaluates the const { assert!(size_of::<T>() == 0) } guards inside
    opaque_deref*, and a rename that turns NonNull<X> from "the C object" into "an 8-byte
    Rust struct" typechecks fine.
  • bun bd clean, no warnings.
  • Drove each converted subsystem end to end: TLS handshake + peer certificate + session +
    the rejectUnauthorized reject path, PNG and WebP encode/decode round-trips (output
    byte-identical to before), cookie get/set through Bun.serve routes, bun pm pack and
    tarball extraction, bun:ffi cc, --bytecode, .npmrc regex, HMR WebSocket upgrade,
    Bun.connect failure path. Each hammered a few hundred iterations under Bun.gc(true)
    to check refcount balance.
  • Also adds a verify skill capturing the build-and-drive recipe.

@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator
Updated 1:05 AM PT - Jul 10th, 2026

@Jarred-Sumner, your commit ee92c7f is building: #71428

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. [Unsoundness] libarchive RAII owners expose safe explicit-free methods #31972 - This PR directly addresses the unsoundness by converting libarchive RAII owners (ReadArchive, WriteArchive, OwnedEntry) to ForeignRef<T>, which encapsulates the free call inside Drop and removes the safe explicit-free methods that allowed double-free.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #31972

🤖 Generated with Claude Code

Comment thread src/libarchive/lib.rs
Comment on lines 577 to 583
/// `archive` is a live handle from `read_new()`/`write_new()`.
#[inline]
pub fn new2(archive: &Archive) -> Self {
Self(
core::ptr::NonNull::new(Entry::new2(archive))
.expect("archive_entry_new2 returned null"),
)
}
#[inline]
pub fn as_ptr(&self) -> *mut Entry {
self.0.as_ptr()
// SAFETY: `archive_entry_new2` hands back the sole ownership unit.
unsafe { Self::adopt_ptr(Entry::new2(archive)) }
.expect("archive_entry_new2 returned null")
}

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.

🟡 OwnedEntry::new2(archive: &Archive) kept its textual signature, but this PR repurposed lib::Archive from the opaque ZST to the read-owning ForeignRef handle — so a WriteArchive (which now derefs to &sys::Archive) can no longer be passed, contradicting the doc comment "live handle from read_new()/write_new()". The underlying Entry::new2 was correctly updated to take &sys::Archive; this wrapper should match. No callers today, so no runtime impact — just an incomplete refactor.

Extended reasoning...

What changed

Before this PR, lib::Archive was the opaque_ffi! ZST for libarchive's struct archive, and both ReadArchive and WriteArchive implemented Deref<Target = Archive>. So OwnedEntry::new2(archive: &Archive) accepted either kind of handle, matching its doc comment: "archive is a live handle from read_new()/write_new()".

This PR renamed the ZST to sys::Archive and repurposed lib::Archive as the read-owning ForeignRef handle (via foreign_handle! { pub struct Archive(sys::Archive) via archive_free_release; }). WriteArchive now derefs to &sys::Archive, not &Archive. The underlying Entry::new2 was correctly updated at line 491 to take archive: &sys::Archive — but OwnedEntry::new2 at line 579 kept the textual spelling &Archive, whose meaning silently narrowed to "read-owning handle only".

Step-by-step

  1. WriteArchive::new() returns a WriteArchive, which is ForeignRef<sys::Archive, WriteFree>.
  2. WriteArchive implements Deref<Target = sys::Archive> (line 555), so &*write_archive is a &sys::Archive.
  3. OwnedEntry::new2 requires &Archive — the read-owning ForeignRef<sys::Archive, DefaultRelease> — which &sys::Archive does not coerce to.
  4. A caller with a WriteArchive therefore gets a type error and must fall back to raw Entry::new2 + manual OwnedEntry::adopt_ptr.
  5. Meanwhile the body Entry::new2(archive) still compiles because Archive implements Deref<Target = sys::Archive> (line 186), so auto-deref hides the over-constraint.

Why this matters

archive_entry_new2 exists specifically to inherit the archive's charset-conversion context, and it's most commonly used on the write side (setting entry pathnames before archive_write_header). So the over-constraint is backwards from the likely use case. The doc comment on line 577 is now false: a write_new() handle cannot be passed.

Impact and fix

Grep confirms zero callers of OwnedEntry::new2 in tree (only OwnedEntry::new() is used, at Archive.rs:329), so there's no compile break or runtime impact — this is dead code with a signature that contradicts its own doc, introduced by the type rename.

The fix is a one-token change: pub fn new2(archive: &sys::Archive) -> Self, matching Entry::new2. Both Archive and WriteArchive deref to &sys::Archive, so both would then be accepted.

Comment thread src/jsc/URL.rs
Comment on lines +96 to +105
.map(|p| unsafe { Self::adopt_ptr(p) })
}

pub fn from_utf8(input: &[u8]) -> Option<NonNull<URL>> {
pub fn from_utf8(input: &[u8]) -> Option<Self> {
Self::from_string(String::borrow_utf8(input))
}

pub fn from_string(str: String) -> Option<NonNull<URL>> {
pub fn from_string(str: String) -> Option<Self> {
let mut input = str;
NonNull::new(URL__fromString(&mut input))
// SAFETY: `URL__fromString` transfers a fresh `new WTF::URL` (or null) to us.

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.

🟡 The URL Parsing (bun_jsc::URL) section in src/CLAUDE.md (lines ~151-160) still documents URL::from_utf8 returning Option<NonNull<URL>> and instructs callers to unsafe { URL::destroy(url.as_ptr()) }, but this PR changes those constructors to return an owning Option<Self> handle and deletes URL::destroy() entirely. Please update that snippet to reflect the new Drop-backed handle so future contributors don't go looking for a nonexistent destroy().

Extended reasoning...

What changed vs. what the docs say

This PR converts bun_jsc::URL to the foreign_handle! pattern: URL::from_utf8, URL::from_string, and URL::from_js now return Option<Self> — a #[repr(transparent)] newtype over ForeignRef<sys::URL> whose Drop calls URL__deinit. The explicit URL::destroy(this: *mut Self) method is deleted (see the src/jsc/URL.rs hunk removing pub unsafe fn destroy).

However, src/CLAUDE.md at lines 151-160 still shows the old API:

let url = URL::from_utf8(href)?;                  // Option<NonNull<URL>>
// caller owns the C++ object — destroy it when done:
// unsafe { URL::destroy(url.as_ptr()) }

The return-type annotation is wrong (it's now Option<URL>, an owning handle), and the destroy instruction references a method that no longer exists.

Step-by-step proof

  1. src/jsc/URL.rs in this PR: pub fn from_utf8(input: &[u8]) -> Option<Self> (was Option<NonNull<URL>>).
  2. src/jsc/URL.rs in this PR: the pub unsafe fn destroy(this: *mut Self) block is removed; URL__deinit is now reached only through foreign_handle!'s generated Drop.
  3. src/CLAUDE.md lines 158-160 (unchanged in this PR — not in the changed-files list): still says Option<NonNull<URL>> and unsafe { URL::destroy(url.as_ptr()) }.
  4. The PR's own SocketAddress.rs hunk demonstrates the new usage (let Some(url) = URL::from_string(...) with no explicit destroy — Drop handles it), which is exactly what the doc example should now show.

Why this matters

src/CLAUDE.md is contributor-facing guidance loaded into agent context. The repo's own review guidelines (root CLAUDE.md → .claude/docs/landing-prs.md §Docs) call for sweeping the same PR for READMEs/CLAUDE.md that describe the old state and updating them. A contributor following the current example would try to call a nonexistent URL::destroy and get a compile error, or worse, assume they need to hand-roll a scopeguard as SocketAddress.rs used to do — the exact pattern this PR is eliminating.

Impact / severity

This is documentation staleness, not a runtime defect — nothing breaks if merged as-is, and the compiler will catch anyone who copies the stale snippet. Marked nit: worth fixing in the same PR per the repo's docs-sweep guidance, but not merge-blocking.

Suggested fix

Update src/CLAUDE.md lines ~158-160 to something like:

let url = URL::from_utf8(href)?;   // Option<URL> — owning handle, Drop frees the C++ object

and drop the URL::destroy line.

Comment on lines +11 to +18
Put driver scripts under `~/code/tmp/**` — Santa blocks unsigned executables elsewhere.

## Two ways to invoke the debug build

| Need | Use |
|---|---|
| run a script, stay in the repo | `bun bd run /path/to/drive.js` |
| any command, from another cwd | `/Users/jarred/code/bun/build/debug/bun-debug <cmd>` |

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.

🟡 This skill file hardcodes contributor-specific absolute paths (/Users/jarred/code/bun/build/debug/bun-debug on line 18, ~/code/bun-3 on line 58) and a macOS-only Santa MDM constraint (line 11). Since .claude/skills/ is checked in and loaded by every contributor's Claude Code session, agents on other machines will follow these instructions verbatim and hit ENOENT — consider using repo-relative ./build/debug/bun-debug or marking these as environment-specific placeholders.

Extended reasoning...

What the issue is

The new .claude/skills/verify/SKILL.md contains three environment-specific references:

  • Line 11: Put driver scripts under ~/code/tmp/** — Santa blocks unsigned executables elsewhere. — Santa is a macOS-only binary-authorization MDM tool. Contributors on Linux (or macOS without corporate MDM) have no such restriction, and ~/code/tmp/ may not exist.
  • Line 18: | any command, from another cwd | /Users/jarred/code/bun/build/debug/bun-debug <cmd> | — an absolute path rooted at one specific contributor's home directory.
  • Line 58: ~/code/bun-3 tracks main and usually has a built build/debug/bun-debug — references a second personal checkout that won't exist on other machines.

Why it matters here

Unlike a personal note or a scratch script, .claude/skills/verify/SKILL.md is checked into the repository. Per Claude Code's skill mechanism, every contributor's agent will load this file and follow its instructions verbatim when asked to verify a change. The PR description explicitly says "Also adds a verify skill capturing the build-and-drive recipe", so the intent is for this to be shared tooling.

Concrete failure

A contributor on Linux with the repo cloned at /home/alice/bun invokes the verify skill. The agent, following line 18's instruction for "any command, from another cwd", runs:

/Users/jarred/code/bun/build/debug/bun-debug pm pack

This fails with No such file or directory. Similarly, an agent trying to "compare against a baseline binary" per line 58 will cd ~/code/bun-3 and fail. The Santa reference on line 11 will lead a Linux agent to needlessly restrict where it writes driver scripts.

Why nothing prevents it

There is no indirection or placeholder marker — the paths are presented as literal instructions in a table and prose. The skill's own header says "Build Bun and drive the changed code", positioning it as general-purpose. Nothing in the file signals "adjust these paths for your machine."

Suggested fix

Replace the absolute path with the repo-relative form the rest of the repo already uses (CLAUDE.md documents ./build/debug/bun-debug), and either drop the Santa/~/code/bun-3 references or annotate them as environment-specific. For line 18 specifically, something like $PWD/build/debug/bun-debug (run from the repo root) or <repo>/build/debug/bun-debug would be portable.

This is documentation rather than runtime code — nothing crashes at build or test time — so it's a nit, but checked-in agent instructions should work for everyone who clones the repo.

@Jarred-Sumner
Jarred-Sumner force-pushed the claude/foreign-owned-fetch-headers branch from 7fd7042 to c6d453b Compare July 10, 2026 04:47
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/foreign-owned-batch branch from 64ec3b5 to ac32919 Compare July 10, 2026 04:47
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/foreign-owned-fetch-headers branch from c6d453b to 4f14bfa Compare July 10, 2026 06:47
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/foreign-owned-batch branch from ac32919 to b78068c Compare July 10, 2026 06:47
Extends the ForeignRef<T> pattern from FetchHeaders to 16 more opaque FFI
types. Each moves its opaque_ffi! ZST into a `sys` module and exposes a
release function, replacing hand-rolled owners (OwnedSslCtx, OwnedDecompressor)
and bare raw pointers.

Also flips 314 `&mut self` receivers to `&self` on opaque ZSTs. Those types are
UnsafeCell-backed and !Freeze, so `&T` carries no noalias and C mutates through
it; `&mut self` asserted an exclusivity that was never true and never needed.

The receiver flip is only sound when a method hands back nothing aliasing real
memory. Four do, and keep `&mut self`: ConnectingSocket::ext, Timer::ext,
ListenSocket::ext, and JSUint8Array::slice all return `&mut T` into storage the
ZST receiver does not cover.

Two fixes fall out of the rename:

- cppbind mapped JSC::SourceProvider to the owning handle, so the generated
  safe wrapper passed the address of a Rust stack slot to C++ `->deref()`.
  It now names the sys:: ZST, matching the WebCore::EventLoopTask entry.
- Flipping Response::upgrade to `&self` moved method resolution to
  ResponseLike::upgrade, which boxes its argument a second time. The DevServer
  call site now names the inherent method explicitly.

Restores the debug-only corrupted-HandleSlot assert that Strong::destroy
carried before it became a ForeignRef; a bad slot otherwise faults inside
JSC with no Rust frame.

Adds a verify skill capturing the build-and-drive recipe.
Two changes to bun_opaque, then 18 types stop hand-rolling their ownership
boilerplate.

ForeignRef<T> gains a release-marker parameter, ForeignRef<T, R = DefaultRelease>.
ForeignOwned admits exactly one release per type, so a foreign object with two
ownership disciplines could not use it twice: libarchive's `struct archive` is
freed by archive_read_free when opened for reading and archive_write_free when
opened for writing, and the write side had degraded into a bespoke owner with a
hand-written Drop. It is now ForeignRef<sys::Archive, WriteFree>. The parameter
defaults, so every existing ForeignRef<T> is unchanged.

foreign_handle! emits the newtype, its ForeignOwned impl, and adopt / adopt_ptr /
as_ptr / leak / raw. That block had been copied onto 17 types by hand; a missing
mem::forget in one copy is a double-free the other sixteen would not reveal.
Net -437 lines.

adopt and adopt_ptr are now unsafe. Most hand-written copies were safe private
fns, but adopting a pointer whose ownership unit you were not given is UB, so the
obligation belongs at the call site. All 29 call sites now carry a SAFETY comment
naming the producer that hands over the unit.

WriteArchive and OwnedEntry lose their hand-written Drop impls, leaving two
hand-rolled owners in the tree (CookieMapRef, CppWebSocketRef).
Nine file-disjoint shards classified 17 opaque types by reading the C and C++,
then converted the ones Rust actually owns a unit of.

Converted:
  ENGINE, X509, X509_STORE, X509_STORE_CTX  (boringssl)
  SSL_SESSION                               (tls_socket_functions)
  spng_ctx                                  (PNG codec)
  WebPDemuxer, WebPMux                      (WebP codec)
  CookieMapRef                              (folded onto ForeignRef, name kept)

Each replaces a hand-written scopeguard or Drop. The certificate handles are
careful about where the ref comes from: SSL_get_peer_certificate, X509_up_ref,
X509_STORE_CTX_get1_issuer and d2i_X509 hand over a +1 and are adopted;
SSL_get_certificate, sk_X509_value and SSL_CTX_get_cert_store return borrows and
stay raw pointers. SSL_set0_verify_cert_store takes ownership, so that path
leaks the handle rather than dropping it.

Not converted, each for a reason found in the C++:
  AbortSignal, NapiEnv, JSCArrayBuffer  already owned by bun_ptr::ExternalShared,
                                        which models ref/deref, not one unit
  Blob                                  the opaque decl is an erased stand-in for
                                        a type declared a tier up
  JSPropertyIteratorImpl                freed by its enclosing struct's Drop
  Channel (c-ares)                      ares_destroy() fires ARES_EDESTRUCTION
                                        callbacks that re-enter RefPtr::deref;
                                        Resolver declares ref_count before
                                        channel, so a Drop-based owner would free
                                        the refcount's debug tables first and
                                        then read them. The existing open-coded
                                        teardown in Drop for GlobalData is
                                        load-bearing.
  Loop, Heap, App                       thread/process-lifetime singletons

spng_ctx_free, WebPDemuxDelete and WebPMuxDelete stay unsafe externs behind a
plain wrapper: a safe fn taking &sys::T would let safe code free a context the
handle still owns.
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/foreign-owned-fetch-headers branch from 4f14bfa to ee92c7f Compare July 10, 2026 08:05
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/foreign-owned-batch branch from b78068c to 32f1300 Compare July 10, 2026 08:05
@Jarred-Sumner
Jarred-Sumner merged commit 59f77ea into claude/foreign-owned-fetch-headers Jul 10, 2026
70 of 79 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/foreign-owned-batch branch July 10, 2026 22:46
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