Skip to content

Remove 125 unsafe blocks from IPC, N-API, bun:ffi, node:zlib, c-ares, BoringSSL, node:quic and the parser - #47944

Merged
dylan-conway merged 35 commits into
mainfrom
claude/remove-unsafe-batch-12
Nov 5, 2026
Merged

dylan-conway merged 35 commits into
mainfrom
claude/remove-unsafe-batch-12

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

35 separate changes, one commit each: 10 in IPC, N-API, bun:ffi and their neighbours, 15 in node:zlib, the c-ares and BoringSSL bindings and node:quic, and 10 in the parser, the printer, CSS, Bun.Image and smaller crates. They follow #47291, #47317, #47338, #47382, #47393, #47414, #47871, #47893, #47895, #47901 and #47940 and work the same way: signatures name the type and the lifetime that the value already has, hand-written pointer code becomes plain ownership and borrows that the compiler checks, and what was never needed is deleted.

Together: 125 fewer unsafe blocks, 17 fewer unsafe fns and 4 fewer unsafe impls, +870/−1499 over 58 files. No safe function became unsafe, and no wrapper type was added. Where the compiled code changes lists every place where the program does different work.

# Change unsafe Diff
1 ipc: CHANNEL holds &'static IPCInstance, boxed once the transport is up −9 blocks, −1 unsafe fn +34/−74, 3 files
2 napi: Bun__napi_check_gc and NapiHandleScope__escape are safe fn −2 blocks +4/−7, 1 file
3 napi: entry points take the env and scope handles as Option<&T> or &T −7 blocks +92/−73, 1 file
4 napi: Finalizer::run and schedule borrow their env, and three more borrows −5 blocks, −1 unsafe impl +14/−27, 2 files
5 tcc: Config::options is Option<&'a ZStr> −2 blocks +9/−12, 2 files
6 ffi: no reference is made from an address only to turn it back into one −2 blocks, −1 unsafe fn +38/−64, 3 files
7 ipc: the JSON decoder's free callback takes &Cell<bool> −1 block +7/−9, 1 file
8 sql: the ssl_config_from_js and timer_heap hooks are safe fns −1 block, −4 unsafe fn +5/−6, 2 files
9 webview: both spawn functions take &VirtualMachine −2 blocks +10/−23, 2 files
10 crash_handler: uv_posix calls unsupported_uv_function as a Rust function −1 block +16/−17, 2 files
11 node:zlib: the write's completion owns its ref as a RefPtr<T> −4 blocks, −2 unsafe fn +13/−36, 2 files
12 node:zlib: borrow_input_copy borrows the copy through JsCell::get −1 block +4/−6, 1 file
13 node:zlib: NativeBrotli passes &raw mut self.next_in and keeps last_result as a c_int −5 blocks +11/−32, 1 file
14 c-ares: AresReply names its parser as an associated const −6 blocks +15/−27, 1 file
15 c-ares: get_sockaddr and get_name_info take sockaddr_storage −4 blocks +19/−52, 3 files
16 boringssl: Bun__X509__checkHost types its certificate and its out-parameter −2 blocks +7/−8, 1 file
17 boringssl_sys: X509_get_subject_name is a safe fn, and a stack free without fn-pointer transmutes −2 blocks, −3 unsafe fn +7/−18, 1 file
18 boringssl_sys: the digest getters return &'static EVP_MD, and EVP_DigestInit is a safe fn −3 blocks +35/−55, 3 files
19 net: BlockList__onStructuredCloneDestroy takes the RefPtr<BlockList> it releases −2 blocks, −2 unsafe fn +4/−14, 1 file
20 node:quic: on_new_conn uses the Conn it already has −4 blocks +5/−27, 2 files
21 node:quic: the loop-driver callbacks take &QuicEndpoint; lookup_cert stays typed −4 blocks, −3 unsafe fn +23/−62, 2 files
22 node:quic: QuicEndpoint::constructor reads its options before it creates the cell −6 blocks +81/−88, 1 file
23 node:quic: QuicSession::create and QuicStream::create return the reference they make −7 blocks +61/−75, 3 files
24 node:quic: a session holds Option<Conn> and a stream holds Option<Stream> −13 blocks +165/−181, 4 files
25 ast: Binding::to_expr takes &mut C and the method that wraps an identifier −3 blocks +18/−160, 5 files
26 js_parser: P::init returns the &mut P it wrote, and init_p! binds it −5 blocks +31/−50, 3 files
27 js_printer: the three write_reserved callers use print_slice −3 blocks +12/−47, 1 file
28 image, aot, mimalloc: imports that only write their out-parameters are safe fn −8 blocks +51/−72, 7 files
29 js_parser_jsc: MacroContext::call borrows self.macros and self.bump −2 blocks +6/−20, 1 file
30 ast, sql_jsc: remove three unsafe impl Send/Sync that nothing needs −3 unsafe impl +0/−15, 3 files
31 css: two needless unsafe in values/ident.rs −1 block, −1 unsafe fn +2/−11, 1 file
32 valkey, image, sql_jsc, ast: &Self guards, &Blob, &Symbol, a const array −3 blocks +17/−34, 4 files
33 aot, md: three byte views go through bun_core::slice_as_bytes −3 blocks +16/−22, 5 files
34 ast: Rope<'a> links its segments with Option<&'a mut Rope<'a>> −1 block +34/−56, 3 files
35 c-ares: ResolveHandler names its callback as an associated const −1 block +6/−21, 2 files

The commits can be reviewed one at a time.

Worth knowing before reading on:

  • Nothing that users can see changes. Under test/, a comment in spawn.ipc.test.ts named a function that change 1 deletes, and the inventory of the vm-thread-door source lint loses the entries of removed unsafe impls (changes 4 and 30).
  • Five changes end an aliasing or contract bug that had no visible effect.
    • Change 6: bun:ffi's toBuffer and toArrayBuffer made a &mut [u8] over memory that belongs to the caller.
    • Change 9: both spawn functions of the webview were safe functions that dereferenced a *mut VirtualMachine.
    • Change 15: get_sockaddr took a &mut sockaddr, 16 bytes, and wrote a 28-byte sockaddr_in6 through it.
    • Change 24: ten safe functions of node:quic took a raw lsquic pointer that they or a later function trusted.
    • Change 32: BlobReadChain::start held a &mut Blob across a call that can run JS.
  • The parser copies less: moving the parser's slot into its drop guard was a memcpy of 4,344 bytes per parse, and most callers made a second one to drop it. Both are gone (change 26), and P is 32 bytes smaller (25).
  • N-API entry points say what an add-on may pass (change 3): Option<&NapiEnv> has the ABI of a nullable pointer, null is None, and every null test is where it was.
  • In node:quic the lifetimes come from the owner: create returns a reference that borrows from the endpoint or the session (change 23), and a session holds Option<Conn> and a stream Option<Stream>, one pointer each, with six repeated null tests fewer (24).
  • Functions that C, C++ or add-ons call have typed parameters (changes 3, 7, 16, 19 and 21). Each message names the call sites and what they pass.
  • Imports that are safe fns: Bun__napi_check_gc and NapiHandleScope__escape (change 2), X509_get_subject_name (17), EVP_DigestInit and the digest getters, which return &'static EVP_MD (18), and four that only write their out-parameters (28). Each message says what the C side reads and writes, for every argument, in every state and on every thread. The other shims over NapiEnv stay unsafe imports, because they belong on the env's JS thread.
  • Dead code goes: IPCInstance::new and deinit (change 1), write_reserved, advance and advance_by (27), three declarations of BoringSSL functions that nothing calls (18), three unsafe impl Send/Sync (30) and an unsafe fn as_slice (31).
  • Parts of four changes are compiled only for Windows or macOS: 1 (the Windows arm of get_ipc_instance), 9 (the macOS HostProcess::spawn and the Windows arms of Chrome's spawn), 15 (the WinSock names) and 28 (mi_process_info on Windows).

Where the compiled code changes

I compared the release assembly (x86-64 Linux, --emit=asm) of all 134 crates of the workspace, before and after. For 95 of them the files are the same byte for byte. The other 39 I compared function by function, with names demangled and labels normalised. In 26 of the 39 (30,267 functions) no function differs: what differs is the debug information. 13 crates differ:

Crate Functions Differ Other constants or names only Same length Shorter Longer Instructions Branches Compares Calls Panic call sites
bun_ast 352 1 0 0 1 0 −555 −45 −45 −46 −9
bun_js_parser 1,361 374 327 4 34 9 +223 −24 −32 +12 −6
bun_bundler 1,983 38 26 0 6 6 +143 +5 +3 −8 −4
bun_runtime 11,204 131 76 12 21 22 −207 +2 +3 −16 −8
bun_js_printer 130 1 0 0 1 0 −11 0 0 −1 0
bun_install, bun_sourcemap_jsc 3,000 6 0 0 0 6 +97 +9 +9 −7 0
bun_sha_hmac 34 10 0 1 9 0 −54 0 0 −24 0
bun_boringssl_sys 14 3 0 1 2 0 −10 −2 −2 −1 −1
bun_ini 59 3 0 0 3 0 −24 −11 −12 −1 0
bun_js_parser_jsc 51 2 0 0 2 0 −20 0 0 0 0
bun_cares_sys 21 0 −6 0 0 0 0
bun_crash_handler 123 1 0 0 1 0 +5 0 0 +1 0
All 13 18,332 570 429 18 80 43 −419 −66 −76 −91 −28

The last five columns are net, and count the functions that exist on one side only: 38 only before and 27 only after, which were removed, renamed or inlined, or are no longer inlined. Where only constants differ, each is an offset into the parser's P and is 32 lower, because P is 32 bytes smaller (change 25). The names that differ:

  • Store::append has the same 112 instructions for E::Object and E::Array, and the compiler keeps one copy in bun_js_parser. That copy was named after E::Object and is named after E::Array, at 173 call sites.
  • The twelve do_resolve_cares load the address of another function (change 35, in the last table), and five functions of Bun.Image call a wrapper (change 28, below).

What costs something. No change adds an allocation, a lock or a reference count, and none adds a test to the source. These are the places where the compiled code does more than before:

  • Binding::to_expr is compiled once per wrapping method (change 25). One copy in bun_ast (505 instructions), which called the method through a function pointer, becomes four in bun_js_parser (210 to 223) and one in bun_bundler (225), which call it directly or have it inline. That is about 590 instructions of code more. Each copy is less than half as long as the one it replaces, and a call of it loses the indirect calls and an expect per array or object pattern. maybe_relocate_vars_to_top_level was one copy (134) for two instances of the parser and is two (130 each), because each calls its own to_expr.
  • Around it the inliner chooses differently. E::Array and E::Spread were stored through into_data_store (115 and 111) and two new_expr (77 each). They are stored through Store::append (112 and 108), which is inline in some parser functions and not in others, in both directions: parse_type_script_decorators and pfx_t_open_brace of the bundler's parser are 88 and 86 shorter, pfx_t_open_bracket of one instance 56 shorter, parse_prefix and parse_jsx_element of another 31 and 30 longer. In the bundler two from_bump_vec (101 and 100) are functions of their own.
  • has_globals_of_vitest (35) is a function of its own (change 26). It was inline in both _parse, which call it once each.
  • Bun.Image unpins its input through JSValue::unpin_array_buffer (change 28). Six call sites in five functions called the C++ function through a declaration of their own. They call the wrapper that bun_jsc has for it, as node:zlib and node:http do. The wrapper is one jump, which goes where the link inlines across crates.
  • The fields of EVP are laid out in another order (change 18). md is a reference now, and the compiler puts it first, so the context is at offset 8. EVP::drop (1 to 2), CryptoHasher::update and its export for node:crypto (1 more each) add 8 to a pointer that they used as it was, and EVP::final (40 to 43) keeps that address in one more register. byteLength, digest and getDigestSize are 1 longer for another reason: they load md for EVP_MD_size, where EVP_MD_CTX_size loaded it from the context after a null test.
  • The constructor of QuicEndpoint goes from 557 to 635 (change 22), with 3 branches, 3 compares and 3 call sites more. It reads its options before it makes the cell, so when a read throws it has to free the bind host and the Strong on the block list itself. Seven fields are stored once instead of twice. It runs once per endpoint.
  • The six uv_* stand-ins are 1 longer (change 10): they load the length of their name before the call that ends the process.

Checks. The source makes no check that it did not make before. These are all the other functions in which the listing shows more conditional branches, compares or panic call sites than before, and why:

Function Listing Why
In the printer: FmtAdapter::write_str and write_char, print_binding, print_identifier_utf16. 15 copies in bun_bundler, bun_install, bun_runtime and bun_sourcemap_jsc. 22 to 38, 59 to 71, 1,122 to 1,134, 106 to 114 and 133 to 162. +1 branch, +1 compare and −1 call per place that writes. Change 27. Each write called reserve_next and advance_by in bun_js_printer. The capacity test of the first and the store of the second are inline now, and growing the buffer is the only call left. The memcpy is the same. In write_str the compiler also skips it for an empty string.
FFI.toBuffer() 224 to 223, +1 compare Change 6. The compiler duplicates one compare.

Everything else that differs:

Change What differs Effect
1 get_ipc_instance goes from 197 to 188, with 2 calls fewer. Bun__closeChildIPC, send and emitHandleIPCMessage keep their length. CHANNEL is set after the socket is set up instead of before. Nothing reads it in between. When the set-up fails, no box is made or freed.
4 napi_create_promise goes from 76 to 75, with one call fewer: it reads the promise with value(), and stores *deferred after that read instead of before it. Same promise.
10 Apart from the above: the body of CrashHandler__unsupportedUVFunction (46 to 6) is unsupported_uv_function (45), and uv_posix::unsupported (2) forwards to it. Only on a path that ends the process.
11 run_task::handle goes from 3,119 to 3,101 with 3 branches, 3 calls and 3 panic sites fewer. The three release_unrun go from 69 to 63 with one of each fewer. The same count goes up and down at the same places.
12 The three write go from 517, 519 and 519 to 514, 516 and 516. The same range check, after the copy is filled instead of while it is borrowed.
13 native_brotli_impl::Context::do_work goes from 53 to 45. Same calls of brotli.
14 Six AresReply::parse, one jump each, go. The parser is called directly.
15 Resolver::reverse goes from 547 to 545, with one call fewer: for an address that does not parse it calls on_hostent and not the C thunk around it. Same error.
17 call_general_name_free goes from 10 to 2: it calls GENERAL_NAME_free by name, and loses a null test with its panic. GeneralNames::drop keeps its length and passes null where it passed that function. Same frees.
18 Apart from the above: Algorithm::md goes from 63 to 25, because its 16 calls are tail calls. The init of the 8 hashers goes from 28 to 26, and getByName, getFromOther and copy are 2 shorter: one call of EVP_MD_CTX_init fewer each, which EVP_DigestInit repeats. EVP::by_name goes from 53 to 48, tls_server_end_point_hash from 98 to 96 with one null test fewer. Same digests.
20 on_new_conn goes from 683 to 684. Two exits call Conn::abort_error, which calls lsquic, where they called lsquic. The two exits that call Conn::abort_silent shared a call site and have one each. Only when a connection is refused.
23, 24 QuicStream::bind_raw goes from 264 to 258 with a branch, a call and a panic site fewer. create and create_stream are 2 shorter, on_new_stream is 1 longer. QuicEndpoint.connect() goes from 1,032 to 1,035: the exit for a failed connect is laid out twice, so teardown has 2 call sites. Four functions keep their length. Same calls of lsquic. The result of create is tested for null instead of by a tag.
25 Apart from the above: two closures per parser instance (6 and 38) and the code in prepare_for_visit_pass that stored them go. to_expr on a P before prepare_for_visit_pass used to panic and now works. Nothing does that.
26 _parse goes from 5,133 to 5,075 and from 5,553 to 5,477, _scan_imports from 895 to 830 and from 930 to 865, to_lazy_export_ast from 582 to 569, parse_only from 1,674 to 1,662, the drop glue of the guard in _parse::<true> from 615 to 556, and each instance of P::init is 4 shorter. In bun_runtime, ContextParameter::of goes from 2,047 to 1,969 and write_inline_snapshots from 2,946 to 2,732. The stack frames of the eight callers of P::init named here are 2,208 to 4,736 bytes smaller. One or two copies of the whole P, 4,344 bytes, fewer per parse. write_inline_snapshots copied P in pieces, and copies fewer of them.
27 Apart from the above: advance_by (2) goes, and reserve_next goes from 19 to 10 because it returns nothing. Same bytes.
29 __bun_macro_context_call goes from 1,876 to 1,870 and its closure from 337 to 323. Same macro calls.
34 Rope::append goes from 24 to 22. In bun_ini, prepare_str goes from 1,030 to 1,018 with 9 branches fewer, str_to_rope from 143 to 136 and commit_rope_part from 66 to 61. Same ropes.
35 The twelve raw_callback (33 to 52) had the record type's thunk inline. They go, and the twelve thunks are functions under their own names, with the same instructions apart from change 14. c-ares calls the same code.

Every function that differs and is not named above differs by 18 instructions or fewer, and has the same or a smaller number of branches, compares and panic call sites. Changes 2, 3, 5, 7, 8, 9, 16, 19, 21, 30, 31, 32 and 33 do not show up in this comparison. What changes 1, 9, 15 and 28 do to code for Windows and macOS is not compiled on Linux.

The comparison covers x86-64 Linux only, one crate at a time. The build that ships also inlines across crates when it links.

The changes in detail

1. ipc: CHANNEL holds &'static IPCInstance, boxed once the transport is up

CHANNEL held Option<NonNull<IPCInstance>> and get_ipc_instance returned Option<*mut IPCInstance>, so send, close_child_ipc, global_object_connected_ipc and get_ipc_instance itself dereferenced a raw pointer. A channel that came up is never freed. The only free was IPCInstance::deinit, on the two paths where the socket (on Windows the pipe) could not be set up, and it was needed only because the box was made and published before that attempt.

get_ipc_instance sets the transport up on the RefPtr<SendQueue> it holds and boxes it after that has succeeded: Box::leak gives the &'static IPCInstance that CHANNEL stores and the function returns. On failure it detaches the queue and the RefPtr drops. IPCInstance::new and deinit are gone, and the socket group stays the &mut SocketGroup that spawn_ipc_group returns.

What differs when it runs: CHANNEL is set after from_fd and set_timeout (on Windows after windows_configure_client) instead of before. Only get_ipc_instance, global_object_connected_ipc and global_object_has_ipc read it, and none of them runs in between: us_socket_from_fd arms the poll, links the socket and may start the date header timer. The failing path neither allocates the box nor touches CHANNEL, and it releases the queue after the warning is printed instead of before.

The comment on the test of the failing path in spawn.ipc.test.ts named IPCInstance.deinit. It says what the path does now.

2. napi: Bun__napi_check_gc and NapiHandleScope__escape are safe fn

Each was declared with a raw handle pointer and called in an unsafe block from a safe method on &self, which passed its as_mut_ptr(). The handle is the only pointer they take. They take it by reference, as the safe fn shims over JSGlobalObject do:

  • Bun__napi_check_gc reads nm_version, which never changes, and asks the heap whether the current thread is doing GC work, which any thread may ask. It can abort the process.
  • NapiHandleScope__escape answers false once the escape slot is used or the scope is closed. It took the value as usize; it takes JSValue, which is repr(transparent) over it. The empty value is fine: the write barrier and the visitor skip a null cell.

The other shims over NapiEnv stay unsafe imports: they belong on the env's JS thread, and a &NapiEnv is also held on pool threads and by the collector.

Same calls with the same arguments.

3. napi: entry points take the env and scope handles as Option<&T> or &T

The 44 N-API functions written in Rust that begin with get_env!, preamble! or ungated! took env_: *mut NapiEnv, and get_env! made the reference with unsafe { $env.as_ref() }. napi_escape_handle did the same with its scope, napi_ref_threadsafe_function and napi_unref_threadsafe_function with the env in a debug assertion, and three functions that only Bun's C++ calls with &*env_ or as_ref(). napi_open_callback_scope and napi_close_callback_scope ignore their env and get the same type.

NapiEnv and NapiHandleScope are opaque_ffi! types: zero-sized, align 1 and !Freeze, so every non-null pointer is a valid reference to one, and Option<&T> has the ABI of a nullable pointer. The parameter types say what the macro did: null is None and still answers napi_invalid_arg.

Call sites of the three that addons cannot reach:

  • Bun__napi_register_cleanup_zig(env: &NapiEnv): this, in the body of the NapiEnv constructor (napi.h).
  • Bun__napi_run_finalizers_after_teardown_wait(env: &NapiEnv): this, in NapiEnv::cleanup() (napi.h).
  • Bun__napi_enqueue_finalizer(env: Option<&NapiEnv>, ..), five: this in enqueueRefFinalizer and doFinalizer (napi.h), an env dereferenced just before in drainOneRefFinalizer (NapiRef.cpp), one that node_api_post_finalizer has tested for null (napi.cpp), and RefPtr::get() in NapiFinalizer::call (napi_finalizer.cpp), for which it keeps its null test.

Every null test is where it was. type napi_env stays for the declarations of the functions written in C++ and for the callbacks into addons.

4. napi: Finalizer::run and schedule borrow their env, and three more borrows

Finalizer::run(&mut self) only reads its fields, and it made an unbounded &NapiEnv from self.env.get(). It takes &self and borrows the env from the NapiEnvRef it holds, so the reference lives as long as that ref. Its three callers own the Finalizer (two locals, one box just taken back) and drop mut.

NapiFinalizerTask::schedule made the same unbounded reference twice. Both go through the NapiEnvRef as well. Each borrow ends with its statement, before self is given away: vm_handle() returns a BorrowedRef, which has no lifetime, and bun_vm() returns &'static VirtualMachine. The one other use of global_this was global_this.bun_vm(), which is vm. as_mut() reads the thread-local itself and uses its receiver in a debug assertion only, so a release build runs the same code.

napi_async_work::run_from_js dereferenced env a second time for surface_exception. env_ref, made from the same pointer, is still in use after that line: the handle scope guard holds it to the end of the function.

napi_create_promise read the promise back through the raw pointer it had just made from its box. It reads the value first, with JSPromiseStrong::value(), and gives the box away after, so *deferred is stored after that read instead of before it. get().as_value(env.to_js()) reached the same value the long way: it downcast the value in the strong slot to *mut JSPromise and turned that pointer back into a JSValue. Two FFI calls go (the downcast, JSC__JSValue__asPromise, and NapiEnv__globalObject for an argument that as_value ignores), and with them the is_cell test and the null test in as_promise, the unwrap of its result and the null test in opaque_mut.

napi_node_version::release was a *const c_char to a literal, which needed an unsafe impl Sync for the static. It is a &'static [u8; 5], b"node\0": a thin pointer at the same offset, and the struct is Sync by itself. The inventory of the vm-thread-door lint drops the entry.

5. tcc: Config::options is Option<&'a ZStr>

Config::options was an Option<NonNull<ZStr>>, "kept as a raw ptr rather than a borrow", and State::init turned it back into a reference in two unsafe blocks on the caller's word that the string outlives the call. Both callers start from a &ZStr and build the Config in the argument of init, and tcc_set_options copies what it parses, so a borrow for the call says the same.

Config has a lifetime and holds the &ZStr. In CompileC::compile the string can borrow self.flags, so the *mut CompileC for the error handler is taken before that borrow starts instead of in the Config literal. The handler only pushes to deferred_errors.

Same pointers and the same calls.

6. ffi: no reference is made from an address only to turn it back into one

make_napi_env_if_needed cast the *mut c_void from make_napi_env_for_ffi to &'static NapiEnv. Its one caller hands the result to Function::compile, which turned it back into an address for add_symbol, where it is a symbol's value and is not dereferenced. 'static was true (makeNapiEnvForFFI returns &out.leakRef(), so the allocation is never freed), but nothing used the reference as a reference. The function returns NonNull::new(..) of the pointer it got, an Option<NonNull<c_void>> with the same niche, and compile passes as_ptr(). CompileC::compile already passes that pointer as it comes.

The comment at add_symbol called the env VM-owned; it names the leaked ref. The doc of make_napi_env_for_ffi called it "owned by this global" and ended "callers in bun_runtime cast to *mut NapiEnv", which neither caller does. It says that C++ leaks a ref, and the clause is gone.

to_buffer built a &mut [u8] over the caller's foreign memory for create_buffer_with_ctx, a private function with this one caller that split the slice into pointer and length again for C++. Its call of JSBuffer__bufferFromPointerAndLengthAndDeinit is written into to_buffer, with the pointer and length that get_ptr_slice returned. Debug builds lose the precondition check inside from_raw_parts_mut; release builds make the same call.

The helper declared that symbol itself, so the call was made from bun_runtime before too; the declaration stands at the head of to_buffer. JSValue::create_buffer_with_ctx in bun_jsc makes the same call but is neither generic nor #[inline], so it would stand between to_buffer and C++ wherever the linker does not inline it.

to_array_buffer built the same kind of slice only for ArrayBuffer::from_bytes, which stores the slice's pointer, its length in len and byte_len, and the type, and takes the other fields from Default. A struct literal sets the same four fields from the pointer and length of get_ptr_slice and takes the rest from Default too. The unsafe block stays for to_js_with_context, an unsafe fn. Debug builds lose the same check here.

noop_bytes_deallocator has an empty body and is not an unsafe fn. It still coerces to the unsafe extern "C" fn pointer that the deallocator slot holds.

7. ipc: the JSON decoder's free callback takes &Cell<bool>

json::decode_ipc_message wraps an ASCII line in an external string and gave the address of a local bool as the context of the string's free callback, which set the flag through the *mut bool in an unsafe block.

create_external is generic over the context and documents it as a raw pointer or &T. The flag is a Cell<bool>, the context is &Cell<bool> and the callback calls set(true). The callback has two callers: create_external itself when the line is too long, and ExternalStringImpl::freeExternalBuffer when the string is destroyed, with the m_freeCtx it stored at creation (releaseBufferEarly also calls it; nothing calls that).

The unsafe on create_external stays, and its comment names the flag too: the string carries the lifetime of neither borrow.

The same byte is stored and read back.

8. sql: the ssl_config_from_js and timer_heap hooks are safe fns

SqlRuntimeHooks::ssl_config_from_js was an unsafe fn(&JSGlobalObject, JSValue) and its one call sat in an unsafe block whose comment named no obligation ("may run JS getters / throw"). The one implementation calls SSLConfig::from_js and boxes the result, all safe. The field is a fn, the implementation and the call are safe.

timer_heap was an unsafe fn(*mut VirtualMachine) -> *mut c_void and states no obligation either: the one implementation ignores its argument and calls the safe timer_all(). The field and the implementation are a fn. The block at its one call stays, for the dereference of the result.

An unsafe fn does not coerce to a fn field, so an implementation of either hook has to be safe. The same indirect calls.

9. webview: both spawn functions take &VirtualMachine

ChromeProcess::spawn and the macOS HostProcess::spawn are safe functions that took vm: *mut VirtualMachine and dereferenced it twice. Each has one caller, which holds a &JSGlobalObject: Bun__Chrome__ensure passed bun_vm_ptr(), and Bun__WebViewHost__ensure cast the &'static VirtualMachine from bun_vm() to *mut.

Both callers pass global.bun_vm(). VirtualMachine::event_loop and EventLoopHandle::init are safe functions, so that block goes in each. The block that dereferences the raw transpiler.env stays. Its comment in ChromeProcess.rs called map a &mut Map slot; it is a field of the loader, and the sentence goes.

In a release build bun_vm() and bun_vm_ptr() are the same read of the thread-local, so the same code runs.

10. crash_handler: uv_posix calls unsupported_uv_function as a Rust function

uv_posix::unsupported reached the crash handler, which is Rust, through its C symbol. It declared CrashHandler__unsupportedUVFunction itself, returning () where the definition returns !, called it in an unsafe block with the pointer of its &'static CStr, and put unreachable! after the call.

The body of the symbol moves into pub fn unsupported_uv_function(name_bytes: &'static [u8]) -> !. The symbol, which __bun_throw_not_implemented in uv-posix-polyfills.c calls, keeps its cstr and calls that function. uv_posix calls it too and has no declaration of its own. Only the C symbol needed the NUL terminator, so unsupported, handle_type and ref_state take the name as &'static [u8], and the six literals are b"uv_close" and so on.

The literals are one byte shorter. The rest differs only on a path that ends the process: from uv_posix the length of the name comes with the slice instead of from a strlen, which is one more instruction before the call in each of the six. From C the counter is bumped after the strlen instead of before it, and the reported stack starts one frame deeper unless the function is inlined into the symbol.

11. node:zlib: the write's completion owns its ref as a RefPtr<T>

run_from_js_thread and release_unrun were unsafe fn(*mut T). Each turned the pointer into a ParentRef behind an expect and released the ref that write() took with unsafe { T::deref(this_ptr) }, once on each of their four exits.

Both now take RefPtr<T>, read the stream through it and drop it where the deref was, after poll_ref.unref. Their two callers, the dispatch arm and Taskable::release_unrun, keep one unsafe block each: RefPtr::from_raw on the queued pointer, which adds no ref. RefPtr::drop calls rc_deref, which the derive forwards to the same CellRefCounted::deref, so every path still releases exactly one ref. The two expect null tests are gone.

12. node:zlib: borrow_input_copy borrows the copy through JsCell::get

The slice was taken inside the with_mut closure and rebuilt with slice::from_raw_parts to get it out of the closure's &mut borrow.

The closure now only fills the copy and says whether the allocation worked. The slice is taken afterwards from input_copy().get(), a shared borrow that lasts as long as &self, which is what the signature already promised. The range check is the same one get(range), moved after the closure. write() does not touch input_copy between this call and set_buffers.

13. node:zlib: NativeBrotli passes &raw mut self.next_in and keeps last_result as a c_int

do_work copied next_in into a local, passed the local's address to brotli and then advanced the field with self.next_in.add(local - self.next_in), in both modes. It now passes the address of the field, as it does for next_out. brotli only advances *next_in and does not keep the address.

last_result was a union of the encoder's c_int and the decoder's BrotliDecoderResult, read with unsafe in three places. It is a c_int: the decode arm stores result as c_int and compares the enum value it still holds, and get_error_info compares with needs_more_input as c_int. The slot keeps its size and its initial value 0. The union had no other user.

14. c-ares: AresReply names its parser as an associated const

AresReply::parse was an unsafe fn whose six impls each forwarded their three arguments to the matching ares_parse_*_reply extern from inside an unsafe block.

The trait now has const PARSE: unsafe extern "C" fn(..), the shape HostentWithTtlsHandler::PARSE has, and each impl names its extern. The six externs have exactly that signature. ares_reply_callback, the only caller, keeps its unsafe block and calls (R::PARSE)(..), which is a direct call to the C function once the callback is monomorphised.

15. c-ares: get_sockaddr and get_name_info take sockaddr_storage

get_sockaddr took &mut sockaddr, 16 bytes, and wrote a 28-byte sockaddr_in6 through it. Its one caller, Resolver::global_lookup_service, cast its sockaddr_storage to that type in an unsafe block, twice. Both functions now take the sockaddr_storage the caller has. get_name_info takes it shared: ares_getnameinfo has const struct sockaddr * and copies the address. The first cast block in dns.rs goes, the second shrinks to &mut *request.

Inside get_sockaddr, the two address stores went through (&raw mut field).cast().write(..). They assign sin_addr.s_addr and sin6_addr.s6_addr, which libc and bun_windows_sys::ws2_32 both name so: the same 4-byte and 16-byte stores.

get_host_by_addr reported an unparsable address by calling its own C thunk with ARES_ENOTIMP. The thunk only turns the pointer back into &mut T and the status into Some(Error::ENOTIMP), so it calls ctx.on_hostent with that. Only Error::ENOTIMP names ARES_ENOTIMP now, so it is a plain const like its neighbours that only the enum names.

The comment on the winsock import in cares_sys is cut to the one line that still holds.

16. boringssl: Bun__X509__checkHost types its certificate and its out-parameter

x509 was a *mut X509 turned into a reference with unsafe { as_mut() }, and out_peer a *mut String that set_peer tested for null and wrote with unsafe { out_peer.write(..) }.

They are Option<&mut X509> and Option<&mut MaybeUninit<String>>: the same two null tests, and MaybeUninit::write stores without dropping, as ptr::write did. The function stays unsafe for host_ptr.

Its one caller is X509View::checkHost in ncrypto.cpp. It returns early when cert_ is null, and passes either null or the address of a local BunString that it initialises to the empty string and does not touch during the call. X509 is an opaque zero-sized !Unpin type, so &mut X509 claims nothing about the certificate's memory.

17. boringssl_sys: X509_get_subject_name is a safe fn, and a stack free without fn-pointer transmutes

X509_get_subject_name (crypto/x509/x509_cmp.cc:69) returns the address of a member and reads nothing. It is a safe fn over &X509. common_names is its only Rust caller.

GeneralNames::drop transmuted GENERAL_NAME_free to fn(*mut c_void) and passed it to sk_pop_free_ex as free_func, and its trampoline unwrapped that argument and transmuted it back before calling it. OPENSSL_sk_pop_free_ex (crypto/stack/stack.cc:112) only passes free_func on to the trampoline, so drop passes None and the trampoline calls GENERAL_NAME_free by name. That removes two transmutes and an expect, and the indirect call per element becomes a direct one.

18. boringssl_sys: the digest getters return &'static EVP_MD, and EVP_DigestInit is a safe fn

The 16 EVP_*() getters returned *const EVP_MD, so EVP::init was a safe fn with a # Safety section about its md pointer. Each getter returns the address of a static object (crypto/fipsmodule/digest/digests.cc.inc through DEFINE_DATA in delocate.h:62, and crypto/digest/digest_extra.cc:276, 301, 326, 359, 393 and 416), so they return &'static EVP_MD.

EVP_get_digestbyname (digest_extra.cc:218) and EVP_get_digestbynid (:79) walk one table (:43) and return what one of these getters or EVP_md5_sha1 returns, or null. EVP_md5_sha1 (:455) returns the address of a constant at file scope too. Both return Option<&'static EVP_MD>. Algorithm::md() and the EVP::md field follow. Option<&EVP_MD> is one word where Option<*const EVP_MD> was two.

A result that goes on to a *const EVP_MD parameter coerces, so those calls are unchanged. Two callers tested a result for null. EVP::by_name matches on the Option. In X509::tls_server_end_point_hash the test stood after a match with an EVP_sha256() arm and an EVP_get_digestbynid() arm. The second arm ends in ? and the test goes, so that arm has one null test as before and the first has none.

EVP_DigestInit (crypto/fipsmodule/digest/digest.cc.inc:162) starts with EVP_MD_CTX_init, which overwrites the three pointers of the context before anything reads them, then calls EVP_DigestInit_ex, which keeps type in ctx->digest (:157). It becomes safe fn(&mut EVP_MD_CTX, &'static EVP_MD). EVP::init calls it instead of EVP_MD_CTX_init followed by EVP_DigestInit_ex: the same work in one call. The new_evp! hashers in sha.rs called EVP_MD_CTX_init and then EVP_DigestInit. They lose the first call and the block around the second: one FFI call fewer per init, for the 8 types. EVP_MD_CTX_init has no Rust caller left and its declaration goes.

bun-sema links no BoringSSL and has weak stand-ins in src/sema/standalone/native_libraries.rs: its EVP_sha256 (:167) returns the address of a static, and its EVP_DigestInit (:199) aborts on any other type_ and overwrites the whole context, so both keep what the signatures promise. Its EVP_MD_CTX_init (:187) stays, because a test in that file calls it.

EVP::size asked EVP_MD_CTX_size(&self.ctx), which is EVP_MD_size(ctx->digest) after a null test. ctx.digest is self.md from init on, so it asks the new safe fn EVP_MD_size(&EVP_MD) (digest.cc.inc:36). ctx was a pub field that nothing outside EVP.rs used. It is private, so only EVP.rs can break that equality. EVP_MD_CTX_size has no caller left and its declaration goes. EVP_MD_do_all_sorted has no caller either, and its declaration goes too. The comment on md repeated its type and goes. EVP::reset is unchanged.

19. net: BlockList__onStructuredCloneDestroy takes the RefPtr<BlockList> it releases

The hook was unsafe extern "C" fn(*mut c_void) and ended in unsafe { BlockList::deref(ptr.cast()) }, the only use of that inherent unsafe fn deref.

It now takes bun_ptr::RefPtr<BlockList> by value, which is repr(transparent) over NonNull, so C++ passes the same pointer. It removes the table entry under the lock as before and then drops the RefPtr, which calls the same ThreadSafeRefCount::deref: one ref down per call, matching the one ref up in BlockList__onStructuredCloneSerialize. BlockList::deref goes.

SerializedScriptValue.cpp calls it in two places, the destructor (line 4300) and releaseSerializedRefs in create (line 4957). Both walk a vector whose only append (line 1440) stores the blockList->wrapped() that the line before passed to the serialize hook as &BlockList, and each vector is walked once.

20. node:quic: on_new_conn uses the Conn it already has

QuicEndpoint::on_new_conn wraps lsquic's pointer in a Conn at its top. Two later exits wrapped the same pointer again with unsafe { Conn::from_raw } and a null test to call abort_silent, and two more called lsquic_conn_abort_error on the raw pointer. All four use the first Conn.

Conn::abort_error takes a &CStr, so the two refusals pass c"" where they passed null. lsquic_conn_abort_error (lsquic_conn.c:208) replaces a null reason with "", so the frame is the same. The argument is now the address of a constant instead of zero.

No other crate names lsquic_conn_abort_error, so the declaration is private.

21. node:quic: the loop-driver callbacks take &QuicEndpoint; lookup_cert stays typed

Bun__nodeQuic__drainEndpoint and Bun__nodeQuic__processEndpoint were unsafe extern "C" fn(*mut c_void) that cast the pointer through unsafe fn from_driver_owner and called run_driver_pass(&self). They take &QuicEndpoint, and from_driver_owner goes.

Each has one call site, node_quic_shim.c:601 and :615, both under if (d->owner). owner is written only by us_nq_loop_register (:571), whose one caller, link_loop_driver, passes from_ref(self). unlink_loop_driver takes the node off the list before the endpoint can be freed. A walk that is under way has read its successor already, so it can still call an endpoint that the pass before it closed and unlinked; run_driver_pass returns at once for it, as before. The reference lives exactly as long as the &self of run_driver_pass did.

lookup_cert already held this: &QuicEndpoint and still ended in unsafe { get_ssl_ctx(owner, local) }, which turned the void* back into the same reference behind a second null test. The body of get_ssl_ctx moves to fn server_ssl_ctx(&self), and both callbacks call it.

lookup_cert was the one use of the form ctx: *mut c_void as r: &T of lsquic_callback!. The two arms for that form were otherwise only what the two short arms forwarded to, so the short arms hold the expansion themselves and the forwarding goes. No other use of the macro changes.

22. node:quic: QuicEndpoint::constructor reads its options before it creates the cell

The constructor built the struct with defaults, moved it into its JS cell, and then stored each option it read through the raw payload pointer, in six unsafe { (*raw).field.set(..) } blocks.

It now reads the options into locals first and puts them in the struct literal, so seven fields are stored once instead of twice. The two blocks that need the cell's address or the wrapper (the vtable, and state/stats) stay.

What changes in order: the six property reads (address, blockList, blockListPolicy, disableStatelessReset, statelessResetBurst, statelessResetRate) keep their order and their conversions, but run before the cell and the boxed vtable are allocated instead of after. If one of them throws, the cell and the vtable are never allocated, and the default bind host and the Strong on the block list are freed on return instead of when the GC finalizes the abandoned cell. Nothing could reach that cell, and its Drop has nothing to release at that point. The one caller, new Endpoint_(options) in quic.ts, passes a fresh null-prototype object of data properties.

23. node:quic: QuicSession::create and QuicStream::create return the reference they make

Both constructors form &*payload to finish setting the object up and then returned the raw pointer, so each caller went back through unsafe { (*session).x() } with "was just created" as the reason.

They return &'a Self, and 'a is the borrow of the owner. QuicSession::create takes endpoint: &'a QuicEndpoint, QuicStream::create takes session: &'a QuicSession, and create_stream borrows from &self and passes the reference on. global has a lifetime of its own. Both constructors took the owner as *mut, and all five call sites passed from_ref(self).cast_mut(). They pass self, and the cast stands at the one place in each constructor that stores the back-pointer. In every caller the owner is the caller's own &self, so no body can choose a longer 'a. That is how live_session and endpoint_ref bound what they return.

apply_server_session_options takes &QuicSession. Seven blocks go: in on_new_conn, apply_server_session_options, connect and connect_verneg_probe, two in on_remote_stream and one in open_stream. The large block in connect shrinks to the lsquic_conn_set_ctx call.

The payload belongs to the GC, and the compiler does not check that it lives for 'a. create roots the wrapper with set_strong before it returns, and only teardown (for a stream, also release_close_root) undoes that. After that the frame that has the reference holds the cell, which is what JsWrapper::payload_ref asks of its caller. Of the callers only connect tears the session down, and it returns right after. Nothing forms a &mut to either payload before the sweeper drops it.

Where a pointer is stored (sessions, pending_new_sessions, provisional, pending_verneg, streams, the conn and stream ctx, the two back-pointers) it is ptr::from_ref(x).cast_mut(), the form these files use for self. Every use of those pointers forms &T. The operations and their order are unchanged.

24. node:quic: a session holds Option<Conn> and a stream holds Option<Stream>

QuicSession::conn and QuicStream::raw were Cell<*mut _>. Nine places wrapped the field again with unsafe { from_raw(..) } before using it, among them the safe accessors conn() and ls(). Ten safe functions took a raw lsquic pointer that they or a later function trusted: bind_conn, cache_sockaddrs, conn_peer_addr, on_remote_stream, bind_raw, QuicEndpoint::on_new_conn, defer_stream_abort, QuicSession::create, create_stream and QuicStream::create. bind_conn, on_remote_stream and bind_raw wrapped their parameter, and connect wrapped at its end the pointer it had tested for null further up.

The fields are Cell<Option<Conn>> and Cell<Option<Stream>>, and those ten functions take the handle or an Option of it. 13 blocks go and none is added. In connect, from_raw joins the block that calls lsquic_engine_connect. The block in QuicEndpoint::on_new_conn moves into the on_new_conn callback with its SAFETY comment. What is left to prove sits where lsquic hands a pointer over (the on_new_conn callback, connect, on_new_stream, on_stream_read). The fields are cleared where they were set to null. DeferredAbort::ls stays a raw pointer with its own from_raw, because what keeps it alive is a different argument. defer_stream_abort stores ls.raw() in it.

In connect the local Conn is used after apply_options, as the raw pointer was. The SAFETY comment at from_raw says why no script runs in between when quic.ts calls.

Conn and Stream hold a NonNull and are repr(transparent), so the Option is one pointer with null as None. Each is_null() becomes is_none(), is_some() or if let Some, one for one. Six null tests go:

  • connect tested the pointer from lsquic_engine_connect, and again in from_raw at its end.
  • bind_conn matched on from_raw of its parameter.
  • on_remote_stream did the same.
  • refresh_conn_stats tested the field and then from_raw of it.
  • bind_raw had from_raw(..).expect(..). Its panic path goes too.
  • The on_new_conn callback tested its pointer, and QuicEndpoint::on_new_conn tested it again in from_raw.

report_close matches on the field instead of testing it in a guard and reading it again. 39 lines in lsquic_sys pass self.0.as_ptr(). Conn gains raw(), which Stream had. The other operations and their order are unchanged.

25. ast: Binding::to_expr takes &mut C and the method that wraps an identifier

to_expr took a *mut c_void context and a ToExprWrapper: a back reference to the arena and a fn pointer to a trampoline that cast the context back to &mut P or &mut ExportHoist. All four call sites hold that &mut and the arena, so they pass both, and name the method (Self::wrap_identifier_namespace, Self::wrap_identifier_hoisting, ExportHoist::wrap_identifier) instead of a trampoline around it.

to_expr is generic over the context and the function. The function is a zero-sized fn item, so nothing is passed for it and the call to it is direct.

The cost is code size: to_expr is compiled once per method (four copies in bun_js_parser, one in bun_bundler) and not once in bun_ast. In a --release build bun_js_parser grows by 526 instructions in 343,000 and bun_bundler by 78, and bun_ast shrinks by 553.

ToExprWrapper goes away, and with it the expect on its arena for every array and object pattern, the two fields of P that held it (32 bytes) and the code in prepare_for_visit_pass that filled them. One behaviour differs: to_expr on a P before prepare_for_visit_pass used to panic and now works.

26. js_parser: P::init returns the &mut P it wrote, and init_p! binds it

P::init(out: &mut MaybeUninit<P>, ..) returned Result<(), Error>. init_p! called it, moved the slot into a drop guard and gave the guard to the caller. Each of the four users of the macro then took its &mut P out with unsafe { __p.assume_init_mut() }, and so did the fifth caller of P::init, the copy of the macro that runtime/test_runner/snapshot.rs writes out by hand because the macro is local to its crate.

init cannot fail: its body has no ?, return, Err, unwrap, expect or panic!. And MaybeUninit::write returns the reference the callers want. So the signature is

pub fn init<'o>(out: &'o mut MaybeUninit<Self>, ..) -> &'o mut Self

The guard holds that reference and the macro binds the caller's p to it. The slot and the guard are locals of the expansion, which the caller cannot name. The guard's closure runs drop_in_place on the reference, which is what assume_init_drop does. snapshot.rs does the same by hand: its guard type InitializedParser holds the reference, and its Drop runs drop_in_place. Five unsafe blocks go, and with the ? the macro's hidden early return.

Compiled code, in a --release build of bun_js_parser (default flags, no LTO), the parent of the commit before this one against this commit, so with the 32 bytes that that commit takes off P:

  • Moving the slot into the guard was a memcpy of the whole P (4344 bytes) in every caller. _parse::<false> and both _scan_imports copied it a second time to drop it. All eight copies are gone.
  • Stack frames: _parse::<false> 13240 to 11016 bytes, _parse::<true> 12856 to 10648, _scan_imports 9480 to 5176, to_lazy_export_ast 10008 to 5816.
  • Instructions: _parse::<false> 5133 to 5075, _parse::<true> 5553 to 5477, _scan_imports::<false> 895 to 830, to_lazy_export_ast 582 to 569, and 4 fewer in each of the four instances of init.
  • init returns its first argument, and LLVM sees that. In the five callers that were read (both _parse, both _scan_imports, to_lazy_export_ast) the code after the call ignores the returned register and addresses p's fields off the stack pointer, as before, without a spilled pointer to p.
  • P is dropped as before: inline in _scan_imports, by a call to its drop glue in _parse::<false> and to_lazy_export_ast, and by a call to the guard's drop glue in _parse::<true>.

No cost was found in the assembly. Run time was not measured.

27. js_printer: the three write_reserved callers use print_slice

WriterTrait::write_reserved got a raw pointer to n spare bytes from reserve, copied the bytes there and committed them with advance. On BufferWriter, the only WriterContext, that is Vec::reserve, a copy and set_len, which is what print_slice does through Vec::extend_from_slice. The three callers use print_slice, two of them through self.print like the code around them, and write_reserved, advance and advance_by go away.

reserve has two more callers, print_ast and print_with_writer_and_platform, which pre-size the buffer and ignore the pointer, so reserve and reserve_next stay and return nothing.

print_slice is #[inline] down to the Vec. reserve_next and advance_by are not, and the printer is instantiated in other crates, so without LTO each write_reserved made two calls into bun_js_printer.

28. image, aot, mimalloc: imports that only write their out-parameters are safe fn

Four imported functions took raw pointers to locals of the caller, and every call was an unsafe block. Typed as references, the signature says that the pointers are valid:

  • JSC__JSValue__borrowBytesForOffThread (bindings.cpp) writes *out_ptr and *out_len, or neither, and reads neither.
  • tj3GetScalingFactors (turbojpeg.c) stores a constant in *n and returns its static table.
  • Bun__AOT__initializeOptions (BunAOTCompile.cpp) calls WTF::initialize(), which is a call_once, and assigns makeDefaultCompileOptions(): zeroes, three integers, a double and two enumerators of enums generated from the schema that also generates the Rust type. One of the integers comes from WTF::numberOfProcessorCores(), which caches its answer in a plain static int; two first calls at once both store the same value, and safe Rust could already make them through EngineOptions::new.
  • mi_process_info (mimalloc, src/util/stats.c) tests each of its eight pointers for null before it writes through it, so they are Option<&mut usize>.

&mut T and Option<&mut T> are passed like the pointers they replace.

Image.rs also had its own declaration of JSC__JSValue__unpinArrayBuffer. bun_jsc declares that function safe and wraps it as JSValue::unpin_array_buffer, which other files of bun_runtime call. The two calls in Image.rs use the wrapper. That is the removal of a duplicate declaration, not a proof: the compiler checks nothing new at those two sites, and the C++ function still wants a value that was pinned. In Image.rs that holds by construction, because Pin's field is private and a Pin is built only for a result of 2.

29. js_parser_jsc: MacroContext::call borrows self.macros and self.bump

call turned the map entry and the arena into *const Macro and *const Arena "so the closure does not extend &mut self", and the closure made references of them again with two unsafe blocks.

Both are borrows of one field each. What else call touches while they live is other fields (self.javascript_object, and before that self.env and self.transform_options), so the borrow checker accepts &Macro and &Arena as they are. call holds &mut self across the macro's JS as before, and Runner::run got the same two shared references.

disabled and vm were copied out of the entry "so the borrow can be released". They are read through macro_, vm after the disabled test and not before it.

30. ast, sql_jsc: remove three unsafe impl Send/Sync that nothing needs

JsonTape is five Vec<_, TapeAlloc> and a fieldless enum. TapeAlloc and what the element types hold (StoreStr, StoreRef<ObjectJSON>, StoreRef<ArrayJSON>) have Send and Sync impls of their own, none behind a cfg, so JsonTape is Send + Sync as an auto trait and its two impls said nothing more.

sql_jsc's handle to the runtime's SSLConfig was declared Send. No code asks for that. The type is not named outside the crate, and none of its four holders looks at the bound:

  • MySQLConnection sits in JSMySQLConnection as a JsCell<MySQLConnection>, and JsCell<T> is Send + Sync for every T.
  • PostgresSQLConnection and ConnectParams hold an Rc<SSLConfig>, which is never Send.
  • ConnectionCtorArgs is pub(crate), and nothing in the crate puts a bound on it.

Send occurs nowhere else in the code of sql_jsc or sql, and the crate has no platform cfg. Without the impl the handle is !Send, like the NonNull in it.

The entry for src/sql_jsc/jsc.rs leaves vm-thread-door.inventory.json.

31. css: two needless unsafe in values/ident.rs

IdentOrRef::as_ident built a &[u8] with from_raw_parts only to turn it into the *const [u8] that Ident stores. slice_from_raw_parts builds that pointer and no reference.

arena_slice_newtype! gave its three types an unsafe fn as_slice that calls the safe v(). It has no caller.

32. valkey, image, sql_jsc, ast: &Self guards, &Blob, &Symbol, a const array

  • JSValkeyClient::on_valkey_connect put self.as_ctx_ptr() in its drop guard and dereferenced it there. Both calls in the guard take &self, and &Self is Copy, so the guard holds self. The eight other drop guards in js_valkey.rs held BackRef::new(self) (or this) for the same kind of call. They hold the plain reference too, with no other change.
  • BlobReadChain::start made a &mut Blob of what as_::<Blob>() returned and only called read_bytes_to_handler(&self, ..) on it, which can run the handler, and through it JS, before it returns. as_class_ref is as_ plus the shared dereference, so the same tests run.
  • CachedStructure::build_from_columns made its array of MaybeUninit with MaybeUninit::uninit().assume_init(). [const { .. }; 70] is the safe spelling. In an optimised build neither form writes anything; at opt-level 0 (the debug build) the old form copied the whole array with a memcpy and the new one fills it with a memset, 70 × 32 = 2240 bytes either way.
  • Symbol::merge_contents_with only reads old, so it takes &Symbol. No unsafe goes away with this one: its caller makes both references from raw pointers.

33. aot, md: three byte views go through bun_core::slice_as_bytes

OutputSlice::put_words (&[u32]), the test helper bytes_of (&[u64]) and the markdown parser's copy of current_block_lines into block_bytes (&[VerbatimLine]) each built the &[u8] with from_raw_parts. slice_as_bytes is that same expression, #[inline(always)], behind the bound T: NoUninit.

bytes_of would only be another name for it, so it goes and its four call sites, all in tests, call slice_as_bytes.

VerbatimLine (#[repr(C)], three u32) gets the bound from impl_no_uninit!, which compiles only if rustc can prove that the type has no padding.

34. ast: Rope<'a> links its segments with Option<&'a mut Rope<'a>>

Rope::next was a *mut Rope. append walked the chain through StoreRef, and next_ref turned the pointer into a &'a Rope of any lifetime with an unsafe block for get_or_put_object.

Every segment is allocated in the arena that the head is allocated in (ropealloc in the INI parser; bun init has a single segment), so the link is a &'a mut into that arena. append walks a &mut Option<..> cursor, and get_or_put_object reads rope.next.as_deref().

append returned Result<*mut Rope, AllocError>, always Ok, and its three call sites dropped the pointer. It returns nothing.

The null niche keeps the layout. In a --release build of bun_ast, get_or_put_object compiles to the same instructions and append loses the two that built its return value.

35. c-ares: ResolveHandler names its callback as an associated const

ResolveHandler::raw_callback was an unsafe extern "C" fn in the trait. Its one impl, for ResolveInfoRequest<T>, forwarded its five arguments to T::RAW_CALLBACK from inside an unsafe block.

The trait now has const RAW_CALLBACK: unsafe extern "C" fn(..), the shape CAresRecordType::RAW_CALLBACK and AresReply::PARSE have, and the impl names T::RAW_CALLBACK. Channel::resolve, the only user, keeps its two unsafe blocks: it calls the constant for a bad name and hands it to ares_query otherwise. c-ares then calls the record type's thunk itself, and not a thunk that calls it.

How did you verify your code works?

  • Static checks on the final commits: cargo fmt --check, cargo clippy --workspace (0 warnings), the source lints and cargo check --workspace --release on Linux, and cargo check --workspace --release for x86_64-pc-windows-msvc, aarch64-apple-darwin and x86_64-unknown-freebsd, because four changes touch code that Linux does not compile.
  • Assembly: the comparison above.
  • After the assembly comparison and the tests had run, three more commits went into Remove 131 unsafe blocks from Blob, Bun.write, FileSink, fetch, Response and the S3 client #47895, which is under these, and a rebase brought in one more commit of main (install: send configured credentials to tarball URLs that the project names #47900). Together they touch 17 files. This PR touches one of them, Image.rs, in other functions, and all 35 patches here stayed as they were. Then changes 2 and 17 were narrowed: six imports that they had marked safe fn stay unsafe imports and are called as on main. I compared the release assembly of all crates before and after that, and no function differs. The static checks ran last.
  • 30 test files on the final code, on a debug build with ASAN, all started at once and each stopped after 50 seconds.
    • 24 files of Bun's own tests pass completely, 3,137 tests: macro-test, css.test.ts, image.test.ts, image-adversarial, bun-cryptohasher, x509, node-https-checkServerIdentity, blocklist-gc, napi-value-ffi, cc.test.ts and ptr-byte-offset of bun:ffi, ini.test.ts, valkey-gc, 4 files on IPC (spawn.ipc, spawn-ipc-gc, bun-ipc-inherit, child_process_ipc), 4 of test/js/node/zlib and 3 of test/js/node/quic.
    • 4 of Node's own tests for node:quic pass: test-quic-blocklist, test-quic-connection-limits, test-quic-endpoint-busy and test-quic-internal-endpoint-options.
    • node-dns.test.js passes 144 tests and fails 28, the same 28 as on a debug build without these commits. They need a DNS server, which I did not have.
    • snapshot.test.ts was stopped at 50 seconds, also when it ran alone. No test had failed by then. Its group inline snapshots, which runs the code that change 26 touches in snapshot.rs, passes when run by name: 35 tests, and 1 that the file skips.
  • 8 more files for the parser, the printer and their users, run the same way.
    • 6 pass completely, 964 tests: transpiler.test.js, decorators, decorator-metadata, es-decorators, and vitest-flavor and expect-deferred, whose callbacks go through ContextParameter::of.
    • bake/dev/esm.test.ts, for the bundler's to_expr, passes 12 tests and fails 7, the same 7 as on a debug build without these commits. They need happy-dom, which I did not have.
    • ffi.test.js was stopped at 50 seconds. No test had failed by then.

Generated by Claude Code

claude added 30 commits November 4, 2026 20:06
… is up

`CHANNEL` held `Option<NonNull<IPCInstance>>` and `get_ipc_instance` returned
`Option<*mut IPCInstance>`, so `send`, `close_child_ipc`,
`global_object_connected_ipc` and `get_ipc_instance` itself dereferenced a raw
pointer. A channel that came up is never freed. The only free was
`IPCInstance::deinit`, on the two paths where the socket (on Windows the pipe)
could not be set up, and it was needed only because the box was made and
published before that attempt.

`get_ipc_instance` sets the transport up on the `RefPtr<SendQueue>` it holds and
boxes it after that has succeeded: `Box::leak` gives the `&'static IPCInstance`
that `CHANNEL` stores and the function returns. On failure it detaches the queue
and the `RefPtr` drops. `IPCInstance::new` and `deinit` are gone, and the socket
group stays the `&mut SocketGroup` that `spawn_ipc_group` returns.

What differs when it runs: `CHANNEL` is set after `from_fd` and `set_timeout`
(on Windows after `windows_configure_client`) instead of before. Only
`get_ipc_instance`, `global_object_connected_ipc` and `global_object_has_ipc`
read it, and none of them runs in between: `us_socket_from_fd` arms the poll,
links the socket and may start the date header timer. The failing path neither
allocates the box nor touches `CHANNEL`, and it releases the queue after the
warning is printed instead of before.

The comment on the test of the failing path in spawn.ipc.test.ts named
`IPCInstance.deinit`. It says what the path does now.
Each was declared with a raw handle pointer and called in an `unsafe` block from
a safe method on `&self`, which passed its `as_mut_ptr()`. The handle is the
only pointer they take. They take it by reference, as the `safe fn` shims over
`JSGlobalObject` do:

- `Bun__napi_check_gc` reads `nm_version`, which never changes, and asks the
  heap whether the current thread is doing GC work, which any thread may ask. It
  can abort the process.
- `NapiHandleScope__escape` answers false once the escape slot is used or the
  scope is closed. It took the value as `usize`; it takes `JSValue`, which is
  `repr(transparent)` over it. The empty value is fine: the write barrier and
  the visitor skip a null cell.

The other shims over `NapiEnv` stay unsafe imports: they belong on the env's JS
thread, and a `&NapiEnv` is also held on pool threads and by the collector.

Same calls with the same arguments.
…`&T`

The 44 N-API functions written in Rust that begin with `get_env!`, `preamble!`
or `ungated!` took `env_: *mut NapiEnv`, and `get_env!` made the reference with
`unsafe { $env.as_ref() }`. `napi_escape_handle` did the same with its scope,
`napi_ref_threadsafe_function` and `napi_unref_threadsafe_function` with the env
in a debug assertion, and three functions that only Bun's C++ calls with
`&*env_` or `as_ref()`. `napi_open_callback_scope` and
`napi_close_callback_scope` ignore their env and get the same type.

`NapiEnv` and `NapiHandleScope` are `opaque_ffi!` types: zero-sized, align 1 and
`!Freeze`, so every non-null pointer is a valid reference to one, and
`Option<&T>` has the ABI of a nullable pointer. The parameter types say what the
macro did: null is `None` and still answers `napi_invalid_arg`.

Call sites of the three that addons cannot reach:
- `Bun__napi_register_cleanup_zig(env: &NapiEnv)`: `this`, in the body of the
  `NapiEnv` constructor (napi.h).
- `Bun__napi_run_finalizers_after_teardown_wait(env: &NapiEnv)`: `this`, in
  `NapiEnv::cleanup()` (napi.h).
- `Bun__napi_enqueue_finalizer(env: Option<&NapiEnv>, ..)`, five: `this` in
  `enqueueRefFinalizer` and `doFinalizer` (napi.h), an env dereferenced just
  before in `drainOneRefFinalizer` (NapiRef.cpp), one that
  `node_api_post_finalizer` has tested for null (napi.cpp), and `RefPtr::get()`
  in `NapiFinalizer::call` (napi_finalizer.cpp), for which it keeps its null test.

Every null test is where it was. `type napi_env` stays for the declarations of
the functions written in C++ and for the callbacks into addons.
…e borrows

`Finalizer::run(&mut self)` only reads its fields, and it made an unbounded
`&NapiEnv` from `self.env.get()`. It takes `&self` and borrows the env from the
`NapiEnvRef` it holds, so the reference lives as long as that ref. Its three
callers own the `Finalizer` (two locals, one box just taken back) and drop `mut`.

`NapiFinalizerTask::schedule` made the same unbounded reference twice. Both go
through the `NapiEnvRef` as well. Each borrow ends with its statement, before
`self` is given away: `vm_handle()` returns a `BorrowedRef`, which has no
lifetime, and `bun_vm()` returns `&'static VirtualMachine`. The one other use of
`global_this` was `global_this.bun_vm()`, which is `vm`. `as_mut()` reads the
thread-local itself and uses its receiver in a debug assertion only, so a release
build runs the same code.

`napi_async_work::run_from_js` dereferenced `env` a second time for
`surface_exception`. `env_ref`, made from the same pointer, is still in use after
that line: the handle scope guard holds it to the end of the function.

`napi_create_promise` read the promise back through the raw pointer it had just
made from its box. It reads the value first, with `JSPromiseStrong::value()`, and
gives the box away after, so `*deferred` is stored after that read instead of
before it. `get().as_value(env.to_js())` reached the same value the long way: it
downcast the value in the strong slot to `*mut JSPromise` and turned that pointer
back into a `JSValue`. Two FFI calls go (the downcast, `JSC__JSValue__asPromise`,
and `NapiEnv__globalObject` for an argument that `as_value` ignores), and with
them the `is_cell` test and the null test in `as_promise`, the unwrap of its
result and the null test in `opaque_mut`.

`napi_node_version::release` was a `*const c_char` to a literal, which needed an
`unsafe impl Sync` for the static. It is a `&'static [u8; 5]`, `b"node\0"`: a thin
pointer at the same offset, and the struct is `Sync` by itself. The inventory of
the `vm-thread-door` lint drops the entry.
`Config::options` was an `Option<NonNull<ZStr>>`, "kept as a raw ptr rather than
a borrow", and `State::init` turned it back into a reference in two `unsafe`
blocks on the caller's word that the string outlives the call. Both callers start
from a `&ZStr` and build the `Config` in the argument of `init`, and
`tcc_set_options` copies what it parses, so a borrow for the call says the same.

`Config` has a lifetime and holds the `&ZStr`. In `CompileC::compile` the string
can borrow `self.flags`, so the `*mut CompileC` for the error handler is taken
before that borrow starts instead of in the `Config` literal. The handler only
pushes to `deferred_errors`.

Same pointers and the same calls.
`make_napi_env_if_needed` cast the `*mut c_void` from `make_napi_env_for_ffi` to
`&'static NapiEnv`. Its one caller hands the result to `Function::compile`, which
turned it back into an address for `add_symbol`, where it is a symbol's value and
is not dereferenced. `'static` was true (`makeNapiEnvForFFI` returns
`&out.leakRef()`, so the allocation is never freed), but nothing used the
reference as a reference. The function returns `NonNull::new(..)` of the pointer
it got, an `Option<NonNull<c_void>>` with the same niche, and `compile` passes
`as_ptr()`. `CompileC::compile` already passes that pointer as it comes.

The comment at `add_symbol` called the env VM-owned; it names the leaked ref. The
doc of `make_napi_env_for_ffi` called it "owned by this global" and ended
"callers in `bun_runtime` cast to `*mut NapiEnv`", which neither caller does. It
says that C++ leaks a ref, and the clause is gone.

`to_buffer` built a `&mut [u8]` over the caller's foreign memory for
`create_buffer_with_ctx`, a private function with this one caller that split the
slice into pointer and length again for C++. Its call of
`JSBuffer__bufferFromPointerAndLengthAndDeinit` is written into `to_buffer`, with
the pointer and length that `get_ptr_slice` returned. Debug builds lose the
precondition check inside `from_raw_parts_mut`; release builds make the same call.

The helper declared that symbol itself, so the call was made from `bun_runtime`
before too; the declaration stands at the head of `to_buffer`.
`JSValue::create_buffer_with_ctx` in `bun_jsc` makes the same call but is neither
generic nor `#[inline]`, so it would stand between `to_buffer` and C++ wherever
the linker does not inline it.

`to_array_buffer` built the same kind of slice only for
`ArrayBuffer::from_bytes`, which stores the slice's pointer, its length in `len`
and `byte_len`, and the type, and takes the other fields from `Default`. A struct
literal sets the same four fields from the pointer and length of `get_ptr_slice`
and takes the rest from `Default` too. The `unsafe` block stays for
`to_js_with_context`, an `unsafe fn`. Debug builds lose the same check here.

`noop_bytes_deallocator` has an empty body and is not an `unsafe fn`. It still
coerces to the `unsafe extern "C" fn` pointer that the deallocator slot holds.
`json::decode_ipc_message` wraps an ASCII line in an external string and gave the
address of a local `bool` as the context of the string's free callback, which set
the flag through the `*mut bool` in an `unsafe` block.

`create_external` is generic over the context and documents it as a raw pointer
or `&T`. The flag is a `Cell<bool>`, the context is `&Cell<bool>` and the callback
calls `set(true)`. The callback has two callers: `create_external` itself when the
line is too long, and `ExternalStringImpl::freeExternalBuffer` when the string is
destroyed, with the `m_freeCtx` it stored at creation (`releaseBufferEarly` also
calls it; nothing calls that).

The `unsafe` on `create_external` stays, and its comment names the flag too: the
string carries the lifetime of neither borrow.

The same byte is stored and read back.
`SqlRuntimeHooks::ssl_config_from_js` was an `unsafe fn(&JSGlobalObject, JSValue)`
and its one call sat in an `unsafe` block whose comment named no obligation ("may
run JS getters / throw"). The one implementation calls `SSLConfig::from_js` and
boxes the result, all safe. The field is a `fn`, the implementation and the call
are safe.

`timer_heap` was an `unsafe fn(*mut VirtualMachine) -> *mut c_void` and states no
obligation either: the one implementation ignores its argument and calls the safe
`timer_all()`. The field and the implementation are a `fn`. The block at its one
call stays, for the dereference of the result.

An `unsafe fn` does not coerce to a `fn` field, so an implementation of either
hook has to be safe. The same indirect calls.
`ChromeProcess::spawn` and the macOS `HostProcess::spawn` are safe functions that
took `vm: *mut VirtualMachine` and dereferenced it twice. Each has one caller,
which holds a `&JSGlobalObject`: `Bun__Chrome__ensure` passed `bun_vm_ptr()`, and
`Bun__WebViewHost__ensure` cast the `&'static VirtualMachine` from `bun_vm()` to
`*mut`.

Both callers pass `global.bun_vm()`. `VirtualMachine::event_loop` and
`EventLoopHandle::init` are safe functions, so that block goes in each. The block
that dereferences the raw `transpiler.env` stays. Its comment in
ChromeProcess.rs called `map` a `&mut Map` slot; it is a field of the loader,
and the sentence goes.

In a release build `bun_vm()` and `bun_vm_ptr()` are the same read of the
thread-local, so the same code runs.
…unction

`uv_posix::unsupported` reached the crash handler, which is Rust, through its C
symbol. It declared `CrashHandler__unsupportedUVFunction` itself, returning `()`
where the definition returns `!`, called it in an `unsafe` block with the
pointer of its `&'static CStr`, and put `unreachable!` after the call.

The body of the symbol moves into
`pub fn unsupported_uv_function(name_bytes: &'static [u8]) -> !`. The symbol,
which `__bun_throw_not_implemented` in uv-posix-polyfills.c calls, keeps its
`cstr` and calls that function. `uv_posix` calls it too and has no declaration
of its own. Only the C symbol needed the NUL terminator, so `unsupported`,
`handle_type` and `ref_state` take the name as `&'static [u8]`, and the six
literals are `b"uv_close"` and so on.

The literals are one byte shorter. The rest differs only on a path that ends the
process: from `uv_posix` the length of the name comes with the slice instead of
from a `strlen`, which is one more instruction before the call in each of the
six. From C the counter is bumped after the `strlen` instead of before it, and
the reported stack starts one frame deeper unless the function is inlined into
the symbol.
`run_from_js_thread` and `release_unrun` were `unsafe fn(*mut T)`. Each turned
the pointer into a `ParentRef` behind an `expect` and released the ref that
`write()` took with `unsafe { T::deref(this_ptr) }`, once on each of their four
exits.

Both now take `RefPtr<T>`, read the stream through it and `drop` it where the
`deref` was, after `poll_ref.unref`. Their two callers, the dispatch arm and
`Taskable::release_unrun`, keep one `unsafe` block each: `RefPtr::from_raw` on
the queued pointer, which adds no ref. `RefPtr::drop` calls `rc_deref`, which
the derive forwards to the same `CellRefCounted::deref`, so every path still
releases exactly one ref. The two `expect` null tests are gone.
The slice was taken inside the `with_mut` closure and rebuilt with
`slice::from_raw_parts` to get it out of the closure's `&mut` borrow.

The closure now only fills the copy and says whether the allocation worked.
The slice is taken afterwards from `input_copy().get()`, a shared borrow that
lasts as long as `&self`, which is what the signature already promised. The
range check is the same one `get(range)`, moved after the closure. `write()`
does not touch `input_copy` between this call and `set_buffers`.
…ast_result` as a `c_int`

`do_work` copied `next_in` into a local, passed the local's address to brotli
and then advanced the field with `self.next_in.add(local - self.next_in)`, in
both modes. It now passes the address of the field, as it does for `next_out`.
brotli only advances `*next_in` and does not keep the address.

`last_result` was a union of the encoder's `c_int` and the decoder's
`BrotliDecoderResult`, read with `unsafe` in three places. It is a `c_int`:
the decode arm stores `result as c_int` and compares the enum value it still
holds, and `get_error_info` compares with `needs_more_input as c_int`. The slot
keeps its size and its initial value 0. The union had no other user.
`AresReply::parse` was an `unsafe fn` whose six impls each forwarded their
three arguments to the matching `ares_parse_*_reply` extern from inside an
`unsafe` block.

The trait now has `const PARSE: unsafe extern "C" fn(..)`, the shape
`HostentWithTtlsHandler::PARSE` has, and each impl names its extern. The six
externs have exactly that signature. `ares_reply_callback`, the only caller,
keeps its `unsafe` block and calls `(R::PARSE)(..)`, which is a direct call to
the C function once the callback is monomorphised.
`get_sockaddr` took `&mut sockaddr`, 16 bytes, and wrote a 28-byte
`sockaddr_in6` through it. Its one caller, `Resolver::global_lookup_service`, cast
its `sockaddr_storage` to that type in an `unsafe` block, twice. Both functions now
take the `sockaddr_storage` the caller has. `get_name_info` takes it shared:
`ares_getnameinfo` has `const struct sockaddr *` and copies the address. The
first cast block in dns.rs goes, the second shrinks to `&mut *request`.

Inside `get_sockaddr`, the two address stores went through
`(&raw mut field).cast().write(..)`. They assign `sin_addr.s_addr` and
`sin6_addr.s6_addr`, which libc and `bun_windows_sys::ws2_32` both name so:
the same 4-byte and 16-byte stores.

`get_host_by_addr` reported an unparsable address by calling its own C thunk
with `ARES_ENOTIMP`. The thunk only turns the pointer back into `&mut T` and the
status into `Some(Error::ENOTIMP)`, so it calls `ctx.on_hostent` with that.
Only `Error::ENOTIMP` names `ARES_ENOTIMP` now, so it is a plain `const` like its
neighbours that only the enum names.

The comment on the `winsock` import in cares_sys is cut to the one line that
still holds.
…arameter

`x509` was a `*mut X509` turned into a reference with `unsafe { as_mut() }`,
and `out_peer` a `*mut String` that `set_peer` tested for null and wrote with
`unsafe { out_peer.write(..) }`.

They are `Option<&mut X509>` and `Option<&mut MaybeUninit<String>>`: the same
two null tests, and `MaybeUninit::write` stores without dropping, as
`ptr::write` did. The function stays `unsafe` for `host_ptr`.

Its one caller is `X509View::checkHost` in ncrypto.cpp. It returns early when
`cert_` is null, and passes either null or the address of a local `BunString`
that it initialises to the empty string and does not touch during the call.
`X509` is an opaque zero-sized `!Unpin` type, so `&mut X509` claims nothing
about the certificate's memory.
…ee without fn-pointer transmutes

`X509_get_subject_name` (crypto/x509/x509_cmp.cc:69) returns the address of a
member and reads nothing. It is a `safe fn` over `&X509`. `common_names` is its
only Rust caller.

`GeneralNames::drop` transmuted `GENERAL_NAME_free` to `fn(*mut c_void)` and
passed it to `sk_pop_free_ex` as `free_func`, and its trampoline unwrapped that
argument and transmuted it back before calling it. `OPENSSL_sk_pop_free_ex`
(crypto/stack/stack.cc:112) only passes `free_func` on to the trampoline, so
`drop` passes `None` and the trampoline calls `GENERAL_NAME_free` by name. That
removes two transmutes and an `expect`, and the indirect call per element
becomes a direct one.
…DigestInit` is a `safe fn`

The 16 `EVP_*()` getters returned `*const EVP_MD`, so `EVP::init` was a safe fn
with a `# Safety` section about its `md` pointer. Each getter returns the
address of a static object (crypto/fipsmodule/digest/digests.cc.inc through
`DEFINE_DATA` in delocate.h:62, and crypto/digest/digest_extra.cc:276, 301,
326, 359, 393 and 416), so they return `&'static EVP_MD`.

`EVP_get_digestbyname` (digest_extra.cc:218) and `EVP_get_digestbynid` (:79)
walk one table (:43) and return what one of these getters or `EVP_md5_sha1`
returns, or null. `EVP_md5_sha1` (:455) returns the address of a constant at
file scope too. Both return `Option<&'static EVP_MD>`. `Algorithm::md()` and the
`EVP::md` field follow. `Option<&EVP_MD>` is one word where
`Option<*const EVP_MD>` was two.

A result that goes on to a `*const EVP_MD` parameter coerces, so those calls are
unchanged. Two callers tested a result for null. `EVP::by_name` matches on the
`Option`. In `X509::tls_server_end_point_hash` the test stood after a `match`
with an `EVP_sha256()` arm and an `EVP_get_digestbynid()` arm. The second arm
ends in `?` and the test goes, so that arm has one null test as before and the
first has none.

`EVP_DigestInit` (crypto/fipsmodule/digest/digest.cc.inc:162) starts with
`EVP_MD_CTX_init`, which overwrites the three pointers of the context before
anything reads them, then calls `EVP_DigestInit_ex`, which keeps `type` in
`ctx->digest` (:157). It becomes `safe fn(&mut EVP_MD_CTX, &'static EVP_MD)`.
`EVP::init` calls it instead of `EVP_MD_CTX_init` followed by
`EVP_DigestInit_ex`: the same work in one call. The `new_evp!` hashers in sha.rs
called `EVP_MD_CTX_init` and then `EVP_DigestInit`. They lose the first call and
the block around the second: one FFI call fewer per `init`, for the 8 types.
`EVP_MD_CTX_init` has no Rust caller left and its declaration goes.

bun-sema links no BoringSSL and has weak stand-ins in
src/sema/standalone/native_libraries.rs: its `EVP_sha256` (:167) returns the
address of a static, and its `EVP_DigestInit` (:199) aborts on any other `type_`
and overwrites the whole context, so both keep what the signatures promise. Its
`EVP_MD_CTX_init` (:187) stays, because a test in that file calls it.

`EVP::size` asked `EVP_MD_CTX_size(&self.ctx)`, which is
`EVP_MD_size(ctx->digest)` after a null test. `ctx.digest` is `self.md` from
`init` on, so it asks the new `safe fn EVP_MD_size(&EVP_MD)` (digest.cc.inc:36).
`ctx` was a `pub` field that nothing outside EVP.rs used. It is private, so only
EVP.rs can break that equality. `EVP_MD_CTX_size` has no caller left and its
declaration goes. `EVP_MD_do_all_sorted` has no caller either, and its
declaration goes too. The comment on `md` repeated its type and goes.
`EVP::reset` is unchanged.
…t>` it releases

The hook was `unsafe extern "C" fn(*mut c_void)` and ended in
`unsafe { BlockList::deref(ptr.cast()) }`, the only use of that inherent
`unsafe fn deref`.

It now takes `bun_ptr::RefPtr<BlockList>` by value, which is
`repr(transparent)` over `NonNull`, so C++ passes the same pointer. It removes
the table entry under the lock as before and then drops the `RefPtr`, which
calls the same `ThreadSafeRefCount::deref`: one ref down per call, matching the
one ref up in `BlockList__onStructuredCloneSerialize`. `BlockList::deref` goes.

SerializedScriptValue.cpp calls it in two places, the destructor (line 4300)
and `releaseSerializedRefs` in `create` (line 4957). Both walk a vector whose
only `append` (line 1440) stores the `blockList->wrapped()` that the line
before passed to the serialize hook as `&BlockList`, and each vector is
walked once.
`QuicEndpoint::on_new_conn` wraps lsquic's pointer in a `Conn` at its top. Two
later exits wrapped the same pointer again with `unsafe { Conn::from_raw }`
and a null test to call `abort_silent`, and two more called
`lsquic_conn_abort_error` on the raw pointer. All four use the first `Conn`.

`Conn::abort_error` takes a `&CStr`, so the two refusals pass `c""` where they
passed null. `lsquic_conn_abort_error` (lsquic_conn.c:208) replaces a null
reason with `""`, so the frame is the same. The argument is now the address of
a constant instead of zero.

No other crate names `lsquic_conn_abort_error`, so the declaration is private.
…rt` stays typed

`Bun__nodeQuic__drainEndpoint` and `Bun__nodeQuic__processEndpoint` were
`unsafe extern "C" fn(*mut c_void)` that cast the pointer through
`unsafe fn from_driver_owner` and called `run_driver_pass(&self)`. They take
`&QuicEndpoint`, and `from_driver_owner` goes.

Each has one call site, node_quic_shim.c:601 and :615, both under
`if (d->owner)`. `owner` is written only by `us_nq_loop_register` (:571), whose
one caller, `link_loop_driver`, passes `from_ref(self)`. `unlink_loop_driver`
takes the node off the list before the endpoint can be freed. A walk that is
under way has read its successor already, so it can still call an endpoint that
the pass before it closed and unlinked; `run_driver_pass` returns at once for
it, as before. The reference lives exactly as long as the `&self` of
`run_driver_pass` did.

`lookup_cert` already held `this: &QuicEndpoint` and still ended in
`unsafe { get_ssl_ctx(owner, local) }`, which turned the `void*` back into the
same reference behind a second null test. The body of `get_ssl_ctx` moves to
`fn server_ssl_ctx(&self)`, and both callbacks call it.

`lookup_cert` was the one use of the form `ctx: *mut c_void as r: &T` of
`lsquic_callback!`. The two arms for that form were otherwise only what the two
short arms forwarded to, so the short arms hold the expansion themselves and the
forwarding goes. No other use of the macro changes.
…eates the cell

The constructor built the struct with defaults, moved it into its JS cell, and
then stored each option it read through the raw payload pointer, in six
`unsafe { (*raw).field.set(..) }` blocks.

It now reads the options into locals first and puts them in the struct
literal, so seven fields are stored once instead of twice. The two blocks that
need the cell's address or the wrapper (the vtable, and `state`/`stats`) stay.

What changes in order: the six property reads (`address`, `blockList`,
`blockListPolicy`, `disableStatelessReset`, `statelessResetBurst`,
`statelessResetRate`) keep their order and their conversions, but run before
the cell and the boxed vtable are allocated instead of after. If one of them
throws, the cell and the vtable are never allocated, and the default bind host
and the `Strong` on the block list are freed on return instead of when the GC
finalizes the abandoned cell. Nothing could reach that cell, and its `Drop`
has nothing to release at that point. The one caller, `new Endpoint_(options)`
in quic.ts, passes a fresh null-prototype object of data properties.
…reference they make

Both constructors form `&*payload` to finish setting the object up and then
returned the raw pointer, so each caller went back through
`unsafe { (*session).x() }` with "was just created" as the reason.

They return `&'a Self`, and `'a` is the borrow of the owner.
`QuicSession::create` takes `endpoint: &'a QuicEndpoint`, `QuicStream::create`
takes `session: &'a QuicSession`, and `create_stream` borrows from `&self` and
passes the reference on. `global` has a lifetime of its own. Both constructors
took the owner as `*mut`, and all five call sites passed
`from_ref(self).cast_mut()`. They pass `self`, and the cast stands at the one
place in each constructor that stores the back-pointer. In every caller the
owner is the caller's own `&self`, so no body can choose a longer `'a`. That is
how `live_session` and `endpoint_ref` bound what they return.

`apply_server_session_options` takes `&QuicSession`. Seven blocks go: in
`on_new_conn`, `apply_server_session_options`, `connect` and
`connect_verneg_probe`, two in `on_remote_stream` and one in `open_stream`. The
large block in `connect` shrinks to the `lsquic_conn_set_ctx` call.

The payload belongs to the GC, and the compiler does not check that it lives for
`'a`. `create` roots the wrapper with `set_strong` before it returns, and only
`teardown` (for a stream, also `release_close_root`) undoes that. After that the
frame that has the reference holds the cell, which is what
`JsWrapper::payload_ref` asks of its caller. Of the callers only `connect` tears
the session down, and it returns right after. Nothing forms a `&mut` to either
payload before the sweeper drops it.

Where a pointer is stored (`sessions`, `pending_new_sessions`, `provisional`,
`pending_verneg`, `streams`, the conn and stream ctx, the two back-pointers) it
is `ptr::from_ref(x).cast_mut()`, the form these files use for `self`. Every use
of those pointers forms `&T`. The operations and their order are unchanged.
…Stream>`

`QuicSession::conn` and `QuicStream::raw` were `Cell<*mut _>`. Nine places
wrapped the field again with `unsafe { from_raw(..) }` before using it, among
them the safe accessors `conn()` and `ls()`. Ten safe functions took a raw
lsquic pointer that they or a later function trusted: `bind_conn`,
`cache_sockaddrs`, `conn_peer_addr`, `on_remote_stream`, `bind_raw`,
`QuicEndpoint::on_new_conn`, `defer_stream_abort`, `QuicSession::create`,
`create_stream` and `QuicStream::create`. `bind_conn`,
`on_remote_stream` and `bind_raw` wrapped their parameter, and `connect` wrapped
at its end the pointer it had tested for null further up.

The fields are `Cell<Option<Conn>>` and `Cell<Option<Stream>>`, and those ten
functions take the handle or an `Option` of it. 13 blocks go and none is added.
In `connect`, `from_raw` joins the block that calls `lsquic_engine_connect`. The
block in `QuicEndpoint::on_new_conn` moves into the `on_new_conn` callback with
its SAFETY comment. What is left to prove sits where lsquic hands a pointer over
(the `on_new_conn` callback, `connect`, `on_new_stream`, `on_stream_read`). The
fields are cleared where they were set to null. `DeferredAbort::ls` stays a raw
pointer with its own `from_raw`, because what keeps it alive is a different
argument. `defer_stream_abort` stores `ls.raw()` in it.

In `connect` the local `Conn` is used after `apply_options`, as the raw pointer
was. The SAFETY comment at `from_raw` says why no script runs in between when
quic.ts calls.

`Conn` and `Stream` hold a `NonNull` and are `repr(transparent)`, so the
`Option` is one pointer with null as `None`. Each `is_null()` becomes
`is_none()`, `is_some()` or `if let Some`, one for one. Six null tests go:

- `connect` tested the pointer from `lsquic_engine_connect`, and again in
  `from_raw` at its end.
- `bind_conn` matched on `from_raw` of its parameter.
- `on_remote_stream` did the same.
- `refresh_conn_stats` tested the field and then `from_raw` of it.
- `bind_raw` had `from_raw(..).expect(..)`. Its panic path goes too.
- The `on_new_conn` callback tested its pointer, and
  `QuicEndpoint::on_new_conn` tested it again in `from_raw`.

`report_close` matches on the field instead of testing it in a guard and reading
it again. 39 lines in lsquic_sys pass `self.0.as_ptr()`. `Conn` gains `raw()`,
which `Stream` had. The other operations and their order are unchanged.
…dentifier

`to_expr` took a `*mut c_void` context and a `ToExprWrapper`: a back reference to
the arena and a fn pointer to a trampoline that cast the context back to `&mut P`
or `&mut ExportHoist`. All four call sites hold that `&mut` and the arena, so they
pass both, and name the method (`Self::wrap_identifier_namespace`,
`Self::wrap_identifier_hoisting`, `ExportHoist::wrap_identifier`) instead of a
trampoline around it.

`to_expr` is generic over the context and the function. The function is a
zero-sized fn item, so nothing is passed for it and the call to it is direct.

The cost is code size: `to_expr` is compiled once per method (four copies in
bun_js_parser, one in bun_bundler) and not once in bun_ast. In a `--release` build
bun_js_parser grows by 526 instructions in 343,000 and bun_bundler by 78, and
bun_ast shrinks by 553.

`ToExprWrapper` goes away, and with it the `expect` on its arena for every array
and object pattern, the two fields of `P` that held it (32 bytes) and the code in
`prepare_for_visit_pass` that filled them. One behaviour differs: `to_expr` on a
`P` before `prepare_for_visit_pass` used to panic and now works.
…ds it

`P::init(out: &mut MaybeUninit<P>, ..)` returned `Result<(), Error>`. `init_p!`
called it, moved the slot into a drop guard and gave the guard to the caller.
Each of the four users of the macro then took its `&mut P` out with
`unsafe { __p.assume_init_mut() }`, and so did the fifth caller of `P::init`, the
copy of the macro that runtime/test_runner/snapshot.rs writes out by hand because
the macro is local to its crate.

`init` cannot fail: its body has no `?`, `return`, `Err`, `unwrap`, `expect` or
`panic!`. And `MaybeUninit::write` returns the reference the callers want. So the
signature is

    pub fn init<'o>(out: &'o mut MaybeUninit<Self>, ..) -> &'o mut Self

The guard holds that reference and the macro binds the caller's `p` to it. The
slot and the guard are locals of the expansion, which the caller cannot name. The
guard's closure runs `drop_in_place` on the reference, which is what
`assume_init_drop` does. snapshot.rs does the same by hand: its guard type
`InitializedParser` holds the reference, and its `Drop` runs `drop_in_place`.
Five `unsafe` blocks go, and with the `?` the macro's hidden early return.

Compiled code, in a `--release` build of bun_js_parser (default flags, no LTO),
the parent of the commit before this one against this commit, so with the 32
bytes that that commit takes off `P`:

- Moving the slot into the guard was a `memcpy` of the whole `P` (4344 bytes) in
  every caller. `_parse::<false>` and both `_scan_imports` copied it a second time
  to drop it. All eight copies are gone.
- Stack frames: `_parse::<false>` 13240 to 11016 bytes, `_parse::<true>` 12856 to
  10648, `_scan_imports` 9480 to 5176, `to_lazy_export_ast` 10008 to 5816.
- Instructions: `_parse::<false>` 5133 to 5075, `_parse::<true>` 5553 to 5477,
  `_scan_imports::<false>` 895 to 830, `to_lazy_export_ast` 582 to 569, and 4
  fewer in each of the four instances of `init`.
- `init` returns its first argument, and LLVM sees that. In the five callers
  that were read (both `_parse`, both `_scan_imports`, `to_lazy_export_ast`) the
  code after the call ignores the returned register and addresses `p`'s fields
  off the stack pointer, as before, without a spilled pointer to `p`.
- `P` is dropped as before: inline in `_scan_imports`, by a call to its drop glue
  in `_parse::<false>` and `to_lazy_export_ast`, and by a call to the guard's drop
  glue in `_parse::<true>`.

No cost was found in the assembly. Run time was not measured.
`WriterTrait::write_reserved` got a raw pointer to `n` spare bytes from
`reserve`, copied the bytes there and committed them with `advance`. On
`BufferWriter`, the only `WriterContext`, that is `Vec::reserve`, a copy and
`set_len`, which is what `print_slice` does through `Vec::extend_from_slice`. The
three callers use `print_slice`, two of them through `self.print` like the code
around them, and `write_reserved`, `advance` and `advance_by` go away.

`reserve` has two more callers, `print_ast` and `print_with_writer_and_platform`,
which pre-size the buffer and ignore the pointer, so `reserve` and `reserve_next`
stay and return nothing.

`print_slice` is `#[inline]` down to the `Vec`. `reserve_next` and `advance_by`
are not, and the printer is instantiated in other crates, so without LTO each
`write_reserved` made two calls into bun_js_printer.
…e `safe fn`

Four imported functions took raw pointers to locals of the caller, and every call
was an `unsafe` block. Typed as references, the signature says that the pointers
are valid:

- `JSC__JSValue__borrowBytesForOffThread` (bindings.cpp) writes `*out_ptr` and
  `*out_len`, or neither, and reads neither.
- `tj3GetScalingFactors` (turbojpeg.c) stores a constant in `*n` and returns its
  static table.
- `Bun__AOT__initializeOptions` (BunAOTCompile.cpp) calls `WTF::initialize()`,
  which is a `call_once`, and assigns `makeDefaultCompileOptions()`: zeroes, three
  integers, a double and two enumerators of enums generated from the schema that
  also generates the Rust type. One of the integers comes from
  `WTF::numberOfProcessorCores()`, which caches its answer in a plain
  `static int`; two first calls at once both store the same value, and safe Rust
  could already make them through `EngineOptions::new`.
- `mi_process_info` (mimalloc, src/util/stats.c) tests each of its eight pointers
  for null before it writes through it, so they are `Option<&mut usize>`.

`&mut T` and `Option<&mut T>` are passed like the pointers they replace.

Image.rs also had its own declaration of `JSC__JSValue__unpinArrayBuffer`. bun_jsc
declares that function `safe` and wraps it as `JSValue::unpin_array_buffer`, which
other files of bun_runtime call. The two calls in Image.rs use the wrapper. That
is the removal of a duplicate declaration, not a proof: the compiler checks
nothing new at those two sites, and the C++ function still wants a value that was
pinned. In Image.rs that holds by construction, because `Pin`'s field is private
and a `Pin` is built only for a result of 2.
…ump`

`call` turned the map entry and the arena into `*const Macro` and
`*const Arena` "so the closure does not extend `&mut self`", and the
closure made references of them again with two `unsafe` blocks.

Both are borrows of one field each. What else `call` touches while they
live is other fields (`self.javascript_object`, and before that `self.env`
and `self.transform_options`), so the borrow checker accepts `&Macro` and
`&Arena` as they are. `call` holds `&mut self` across the macro's JS as
before, and `Runner::run` got the same two shared references.

`disabled` and `vm` were copied out of the entry "so the borrow can be
released". They are read through `macro_`, `vm` after the `disabled` test
and not before it.
`JsonTape` is five `Vec<_, TapeAlloc>` and a fieldless enum. `TapeAlloc` and what
the element types hold (`StoreStr`, `StoreRef<ObjectJSON>`, `StoreRef<ArrayJSON>`)
have `Send` and `Sync` impls of their own, none behind a `cfg`, so `JsonTape` is
`Send + Sync` as an auto trait and its two impls said nothing more.

sql_jsc's handle to the runtime's `SSLConfig` was declared `Send`. No code asks
for that. The type is not named outside the crate, and none of its four holders
looks at the bound:

- `MySQLConnection` sits in `JSMySQLConnection` as a `JsCell<MySQLConnection>`,
  and `JsCell<T>` is `Send + Sync` for every `T`.
- `PostgresSQLConnection` and `ConnectParams` hold an `Rc<SSLConfig>`, which is
  never `Send`.
- `ConnectionCtorArgs` is `pub(crate)`, and nothing in the crate puts a bound on
  it.

`Send` occurs nowhere else in the code of sql_jsc or sql, and the crate has no
platform `cfg`. Without the impl the handle is `!Send`, like the `NonNull` in it.

The entry for src/sql_jsc/jsc.rs leaves vm-thread-door.inventory.json.
`IdentOrRef::as_ident` built a `&[u8]` with `from_raw_parts` only to turn it into
the `*const [u8]` that `Ident` stores. `slice_from_raw_parts` builds that pointer
and no reference.

`arena_slice_newtype!` gave its three types an `unsafe fn as_slice` that calls
the safe `v()`. It has no caller.
…onst` array

- `JSValkeyClient::on_valkey_connect` put `self.as_ctx_ptr()` in its drop guard
  and dereferenced it there. Both calls in the guard take `&self`, and `&Self` is
  `Copy`, so the guard holds `self`. The eight other drop guards in js_valkey.rs
  held `BackRef::new(self)` (or `this`) for the same kind of call. They hold the
  plain reference too, with no other change.
- `BlobReadChain::start` made a `&mut Blob` of what `as_::<Blob>()` returned and
  only called `read_bytes_to_handler(&self, ..)` on it, which can run the handler,
  and through it JS, before it returns. `as_class_ref` is `as_` plus the shared
  dereference, so the same tests run.
- `CachedStructure::build_from_columns` made its array of `MaybeUninit` with
  `MaybeUninit::uninit().assume_init()`. `[const { .. }; 70]` is the safe
  spelling. In an optimised build neither form writes anything; at opt-level 0
  (the debug build) the old form copied the whole array with a `memcpy` and the
  new one fills it with a `memset`, 70 × 32 = 2240 bytes either way.
- `Symbol::merge_contents_with` only reads `old`, so it takes `&Symbol`. No
  `unsafe` goes away with this one: its caller makes both references from raw
  pointers.
`OutputSlice::put_words` (`&[u32]`), the test helper `bytes_of` (`&[u64]`) and the
markdown parser's copy of `current_block_lines` into `block_bytes`
(`&[VerbatimLine]`) each built the `&[u8]` with `from_raw_parts`. `slice_as_bytes`
is that same expression, `#[inline(always)]`, behind the bound `T: NoUninit`.

`bytes_of` would only be another name for it, so it goes and its four call sites,
all in tests, call `slice_as_bytes`.

`VerbatimLine` (`#[repr(C)]`, three `u32`) gets the bound from `impl_no_uninit!`,
which compiles only if rustc can prove that the type has no padding.
`Rope::next` was a `*mut Rope`. `append` walked the chain through
`StoreRef`, and `next_ref` turned the pointer into a `&'a Rope` of any
lifetime with an `unsafe` block for `get_or_put_object`.

Every segment is allocated in the arena that the head is allocated in
(`ropealloc` in the INI parser; `bun init` has a single segment), so the
link is a `&'a mut` into that arena. `append` walks a `&mut Option<..>`
cursor, and `get_or_put_object` reads `rope.next.as_deref()`.

`append` returned `Result<*mut Rope, AllocError>`, always `Ok`, and its
three call sites dropped the pointer. It returns nothing.

The null niche keeps the layout. In a `--release` build of bun_ast,
`get_or_put_object` compiles to the same instructions and `append` loses
the two that built its return value.
`ResolveHandler::raw_callback` was an `unsafe extern "C" fn` in the trait. Its
one impl, for `ResolveInfoRequest<T>`, forwarded its five arguments to
`T::RAW_CALLBACK` from inside an `unsafe` block.

The trait now has `const RAW_CALLBACK: unsafe extern "C" fn(..)`, the shape
`CAresRecordType::RAW_CALLBACK` and `AresReply::PARSE` have, and the impl names
`T::RAW_CALLBACK`. `Channel::resolve`, the only user, keeps its two `unsafe`
blocks: it calls the constant for a bad name and hands it to `ares_query`
otherwise. c-ares then calls the record type's thunk itself, and not a thunk
that calls it.
@robobun

robobun commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 1:52 PM PT - Nov 4th, 2026

❌ @claude, your commit 37adb5b has 38 failures in Build #137632 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47944

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

bun-47944 --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.

I reviewed this PR and didn't find any bugs. Because it is a 35-commit, 58-file rewrite of pointer/ownership shapes across IPC, N-API, bun:ffi, BoringSSL/X509, c-ares and node:quic, a human look at the lifetime arguments would still be worthwhile.

What was reviewed:

  • IPC: CHANNEL now set only after the socket/pipe is up; the error path's data.detach() plus RefPtr drop matches the old deinit release sequence, and the pre-CHANNEL window was checked for readers.
  • node:quic: Option<Conn>/Option<Stream> and &'a returns from create were checked against option-application paths that can run JS (session.rs, endpoint.rs) and against the QuicStream Drop SAFETY text.
  • N-API: Option<&NapiEnv> ABI and the safe fn imports (Bun__napi_check_gc, NapiHandleScope__escape now taking JSValue) were checked for nullable-pointer layout and unchanged null tests.
  • zlib borrow_input_copy provenance through JsCell::get, and the BoringSSL GeneralNames drop/free_func path, were examined and not found to change behavior.
Extended reasoning...

The change is a +870/-1499 unsafe-reduction refactor across 56 Rust files plus a test comment and the vm-thread-door lint inventory, replacing raw pointers and type-erased trampolines with references, Option, RefPtr and safe fn imports. It touches security-sensitive surfaces: BoringSSL/X509 host checking and digest getters, N-API add-on entry-point ABI, bun:ffi buffer views, and the node:quic TLS/lsquic bindings. The automated hunt ran dry with no findings and eight candidate issues (IPC CHANNEL window, quic UAF during option application, zlib provenance, BoringSSL leak, SAFETY comment accuracy) were ruled out, but the size, the number of distinct subsystems, and the fact that correctness rests on lifetime reasoning the compiler only partially checks at FFI boundaries mean a human should also confirm the ownership arguments before merge.

dylan-conway commented Nov 4, 2026 •

Copy link
Copy Markdown
Member Author

BuildKite build 137632 of 37adb5b has finished, and it is red. Nothing fails only here.

  • All 188 jobs ran to the end. 38 test files fail.
  • main is red at e57d89b, the base of this PR (build 137628), as it was at b9f2ec3 (137481): 38 files, all under test/aot/ but for test/bundler/compile-aot-size.test.ts. The 38 files that fail here are those 38.
  • They fail on the same lanes. Outside ASAN, a file fails on a lane 77 times here, and those 77 pairs are the 77 of build 137628. main does not run ASAN. The 32 files that fail there are the 32 that fail there in build 137484 of the last commit of Remove 98 unsafe blocks from bun_threading, bun_alloc, the bundler and the transpiler #47901.
  • The same tests fail inside those files. Of the 76 tests that fail here, 73 fail in one of those two builds of main. The other 3 fail on ASAN only, and they fail there in the same way in the builds of the last commits of Remove 98 unsafe blocks from bun_threading, bun_alloc, the bundler and the transpiler #47901 (137484), Remove 131 unsafe blocks from Blob, Bun.write, FileSink, fetch, Response and the S3 client #47895 (137463), Remove 128 unsafe blocks from the event loop, spawn, bun_jsc, node:fs and fs.watch #47893 (137448), Remove 187 unsafe blocks from the VM, DNS, timers, and the socket and server code #47871 (137329) or Remove 126 unsafe blocks from the core crates, the resolver and the package manager #47414 (137212).
  • 34 files failed and then passed. 29 of them are under test/aot/, and all 29 passed when they ran alone.
    • 27 failed in the parallel batch of the ASAN lane, where most timed out or were interrupted, 2 in that of Debian x64 (one of them in both), and 1 in that of macOS aarch64.
    • 26 of the 29 did the same in one of the builds above, which have none of these commits. Of the other 3, two timed out on ASAN, and fields-created-by-name-cost-the-same-after-a-collection.test.ts measures a time on macOS: 21.5, where the bound is 20.
  • Four of the other 5 fail in the same way without these commits, or for a reason outside Bun:
    • test/js/bun/http/bun-serve-html.test.ts failed in the parallel batch on Ubuntu x64 and passed alone. Its test "rebuild that fails to parse an import, and a request for a failed route" got status 200 for a page where it expects 500. It got the same 200 on the same lane in build 137628 of main, and on Debian aarch64 in 137212.
    • test/js/bun/spawn/spawn_waiter_thread.test.ts failed in the parallel batch on Debian aarch64 and passed alone. The same test, "exited rejects when the waiter thread can't start, and the next spawn retries", did so on two lanes in build 137611 of main.
    • test/js/web/request/request-clone-leak.test.ts needed 1 retry on ASAN. Its test "new Request(test Fix ?? operator  #1)" measures a growth of 64 MB, and the bound is less than 64. It measured the same 64 MB in builds 137484 and 137212.
    • test/js/web/websocket/websocket.test.js failed in the parallel batch on Debian x64 and passed alone. A public echo server answered its handshake with 502.
  • The fifth I found in this build only. test/js/bun/test/vi-utils.test.ts failed in the parallel batch on macOS x64 and passed alone.
    • Its test "does not retain the imports that have settled" counts the Promise objects of the whole heap before and after 400 imports of node:path. It counted 149 more, where the bound is 100.
    • It is not in the 56 other builds that I looked at. The test is four days old (bun test: Vitest compatibility #44833).
    • What it tests is C++ (ViUtils.cpp, BunPlugin.cpp), and this PR changes no C++. All four shards of that lane are green.
  • I have no fix to port, and I cannot run the jobs again. AOT: hangs, wrong answers, failed builds; bun test --aot follows #44833's mocks #47605 ("AOT: hangs, wrong answers, failed builds; bun test --aot follows bun test: Vitest compatibility #44833's mocks") looks like the fix for most of the 38. It changes 7,093 files, and it conflicts with main today.

On GitHub every check passes on 37adb5b: Format, the lints, cargo clippy, cargo miri test, the lol-html tests and mordant.


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Explain Change →

No actionable comments were generated in the recent review.

Walkthrough

This pull request updates Rust APIs and call sites across parser, runtime, networking, and FFI code. It replaces several raw-pointer interfaces with references, option types, and typed handles. It also updates related initialization, callback, and cleanup paths.

Changes

Safe APIs and ownership

Layer / File(s) Summary
Byte views and process metrics
src/aot/*, src/md/*, src/mimalloc_sys/mimalloc.rs, src/crash_handler/lib.rs, src/css/values/ident.rs, src/sql_jsc/shared/CachedStructure.rs
Byte-slice conversions use bun_core::slice_as_bytes, and mimalloc output arguments use optional mutable references. The CSS identifier accessor and stack-buffer initialization also change.
AST, parser, and rope lifetimes
src/ast/*, src/bundler/linker_context/generateCodeForFileInChunkJS.rs, src/ini/lib.rs, src/js_parser/*, src/runtime/cli/init_command.rs, src/runtime/test_runner/snapshot.rs, src/js_parser_jsc/Macro.rs
Binding conversion and parser initialization use explicit references. Rope links use lifetime-bound mutable references. Call sites and snapshot parser cleanup use the updated APIs.
Certificate and digest APIs
src/boringssl/*, src/runtime/crypto/EVP.rs, src/sha_hmac/sha.rs
Certificate and digest interfaces use references and optional results. EVP initialization and digest-size lookup use the updated interfaces.
DNS callbacks and socket addresses
src/cares_sys/*, src/runtime/dns_jsc/dns.rs
c-ares callback traits use function-pointer constants. Socket-address helpers use sockaddr_storage in DNS lookup paths.
FFI buffers, writers, and borrowed configuration
src/js_printer/lib.rs, src/runtime/ffi/*, src/runtime/image/*, src/tcc_sys/tcc.rs, src/runtime/hw_exports.rs, src/sql_jsc/jsc.rs, src/runtime/webview/*, src/jsc/JSGlobalObject.rs
Writer APIs reserve capacity without exposing writable pointers. FFI buffer, image, TinyCC, SQL, and webview call sites use revised reference and pointer interfaces.
Node-API environments and unsupported UV calls
src/runtime/napi/*, src/crash_handler/lib.rs, test/internal/source-lints/vm-thread-door.inventory.json
Node-API entry points use optional environment references. Unsupported UV reporting calls the Rust crash-handler function. Related inventory entries were removed.
IPC, asynchronous references, and streams
src/runtime/ipc.rs, src/runtime/ipc_host.rs, src/runtime/node/net/BlockList.rs, src/runtime/node/node_zlib_binding.rs, src/runtime/node/zlib/NativeBrotli.rs, src/runtime/valkey_jsc/js_valkey.rs, src/runtime/dispatch.rs, test/js/bun/spawn/spawn.ipc.test.ts
IPC instances and asynchronous cleanup use borrowed instances or owning reference wrappers. Compression state and callback cleanup paths use the revised ownership patterns.
QUIC typed connection and stream handles
src/lsquic_sys/lib.rs, src/runtime/node/quic/*
QUIC endpoint, session, and stream paths use typed lsquic handles and borrowed references for setup, callbacks, and teardown.

Suggested reviewers: jarred-sumner, alii

Merge Risk: Minimal · up to 37adb

No actionable merge-blocking issue was identified. The ownership and API changes appear mergeable subject to normal build and test checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the primary change: removing 125 unsafe blocks across the affected subsystems. It is specific and related to the changeset.
Description check Passed The description includes both required sections, explains the 35 changes in detail, and documents verification results, limitations, test outcomes, and platform coverage.
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.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share
Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between e57d89b and 37adb5b.

Files selected for processing: 58
  • src/aot/compile.rs
  • src/aot/emit/output.rs
  • src/aot/engine.rs
  • src/aot/engine/testing.rs
  • src/aot/engine/tests.rs
  • src/aot/statistics.rs
  • src/ast/binding.rs
  • src/ast/e.rs
  • src/ast/symbol.rs
  • src/boringssl/lib.rs
  • src/boringssl_sys/boringssl.rs
  • src/bundler/linker_context/generateCodeForFileInChunkJS.rs
  • src/cares_sys/c_ares.rs
  • src/cares_sys/lib.rs
  • src/crash_handler/lib.rs
  • src/css/values/ident.rs
  • src/ini/lib.rs
  • src/js_parser/fold.rs
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_entry.rs
  • src/js_parser/visit/visit_stmt.rs
  • src/js_parser_jsc/Macro.rs
  • src/js_printer/lib.rs
  • src/jsc/JSGlobalObject.rs
  • src/lsquic_sys/lib.rs
  • src/md/blocks.rs
  • src/md/types.rs
  • src/mimalloc_sys/mimalloc.rs
  • src/runtime/cli/init_command.rs
  • src/runtime/crypto/EVP.rs
  • src/runtime/dispatch.rs
  • src/runtime/dns_jsc/dns.rs
  • src/runtime/ffi/FFIObject.rs
  • src/runtime/ffi/ffi_body.rs
  • src/runtime/hw_exports.rs
  • src/runtime/image/Image.rs
  • src/runtime/image/codec_jpeg.rs
  • src/runtime/ipc.rs
  • src/runtime/ipc_host.rs
  • src/runtime/napi/napi_body.rs
  • src/runtime/napi/uv_posix.rs
  • src/runtime/node/net/BlockList.rs
  • src/runtime/node/node_zlib_binding.rs
  • src/runtime/node/quic/endpoint.rs
  • src/runtime/node/quic/ffi.rs
  • src/runtime/node/quic/session.rs
  • src/runtime/node/quic/stream.rs
  • src/runtime/node/zlib/NativeBrotli.rs
  • src/runtime/test_runner/snapshot.rs
  • src/runtime/valkey_jsc/js_valkey.rs
  • src/runtime/webview/ChromeProcess.rs
  • src/runtime/webview/HostProcess.rs
  • src/sha_hmac/sha.rs
  • src/sql_jsc/jsc.rs
  • src/sql_jsc/shared/CachedStructure.rs
  • src/tcc_sys/tcc.rs
  • test/internal/source-lints/vm-thread-door.inventory.json
  • test/js/bun/spawn/spawn.ipc.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.

@dylan-conway
dylan-conway merged commit b1007f1 into main Nov 5, 2026
10 of 11 checks passed
@dylan-conway
dylan-conway deleted the claude/remove-unsafe-batch-12 branch November 5, 2026 00:03
robobun added a commit that referenced this pull request Nov 5, 2026
One conflict, in the imports of generateCodeForFileInChunkJS.rs. #47944 removed the ToExprWrapper import that stood next to the bun_ast import this branch extends. The merged line keeps the three added names and drops ToExprWrapper.
dylan-conway pushed a commit that referenced this pull request Nov 5, 2026
…rust-17fiip

One conflict, in src/runtime/image/codec_jpeg.rs: main made the declaration of tj3GetScalingFactors safe and took
the unsafe block off its call (#47944). This branch has neither any more, so the file stays as it is here.
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.

3 participants