Skip to content

dns: carry c-ares reply ownership in OwnedReply<T> - #37602

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/c83f5856/dns-owned-reply-2
Aug 11, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
farm/c83f5856/dns-owned-reply-2

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

What

For the 12 record types resolved through ResolveInfoRequest<T: CAresRecordType> (srv, soa, txt, naptr, mx, caa, any, ns, ptr, cname, a, aaaa), the parsed reply travelled through ResolveInfoRequest::on_cares_complete, CAresLookup::process_resolve and Resolver::drain_pending_cares as Option<*mut T>, and the two consumers re-established ownership by hand: process_resolve armed a scopeguard calling T::destroy, with a comment explaining that the cached path frees the reply itself and therefore "always passes null", and drain_pending_cares armed a second guard (_free_addr) after the first to_js_response call. src/runtime/dns_jsc/dns.rs now has

/// The parsed reply of one query, freed by `T::destroy` when dropped.
#[repr(transparent)]
pub(crate) struct OwnedReply<T: CAresRecordType>(NonNull<T>);   // unsafe fn adopt(NonNull<T>); Deref/DerefMut to T; Drop calls T::destroy

and the three signatures take result: Option<OwnedReply<T>>. The 4 producers adopt the reply where the c-ares layer hands it over: the impl_cares_record_type! and hostent_newtype! handlers replace their is_null() checks with NonNull::new(..).map(adopt), and the any and hostent_ttls_newtype! handlers, which receive a Box from the parser, release it with heap::into_raw_nn into the same wrapper instead of heap::into_raw. Both consumers lose their guards: process_resolve binds let Some(mut node) = result and node drops at the end of the function, drain_pending_cares binds let Some(mut addr) = result, calls addr.to_js_response(..) instead of (*addr).to_js_response(..) at its 2 sites, and addr drops after the waiter loop; the None it forwards to process_resolve on the no-reply path now needs no comment. CAresRecordType::destroy is unchanged per type and has exactly one caller, the Drop impl. The two ad hoc T::destroy sites are gone; the wrapper adds three one-line unsafe blocks (deref, deref_mut, drop) and each of the 4 producers gains a one-line unsafe { OwnedReply::adopt(..) } whose SAFETY comment states why that reply is ours to free. One file, +71/-55 lines.

Why

Who frees a reply, and on which path, is now answered by the parameter type: Option<OwnedReply<T>> is freed by whoever holds it, while the reverse-lookup hostent that c-ares only lends to GetHostByAddrInfoRequest keeps its Option<*mut struct_hostent>, so the owned and lent cases differ in type instead of by convention. A consumer can no longer forget to free a reply on one of its exit paths, and the free itself is written in one place instead of two. It is zero-cost: Option<OwnedReply<T>> is one nullable pointer (the NonNull niche) where Option<*mut T> was two words, the producers' NonNull::new is the same null check they already performed, Box::into_raw and heap::into_raw_nn compile to the same thing, and the drop glue is the same T::destroy call the guards made at the same scope exits (the workspace builds with panic = "abort", so no unwind paths or drop flags are introduced).

Part of a series of small type-system hardening changes; each PR stands alone.

Related

#36123 also removes these two scopeguard sites, by wrapping the Option<*mut T> in a CAresReply<T> at the two consumer sites and leaving the signatures unchanged. This PR instead moves the ownership into the signatures and adopts the reply at the producers, so the two conflict textually in process_resolve and drain_pending_cares; whichever lands second needs a rebase of that hunk.

Verification

cargo check and cargo clippy are clean for the touched crates. Debug build succeeds. test/js/node/dns/dns-resolver-concurrent-timeout.test.ts, dns-tcp-bidirectional-poll.test.ts, node-dns.test.js: 97 pass, 29 fail; the same 29 node-dns.test.js tests (resolveSrv/Txt/Soa/Naptr/Caa/Mx/Ns/Ptr/Cname against bun.sh/socketify.dev, lookup example.com, reverse/lookupService on 1.1.1.1/8.8.8.8) fail identically on main with ESERVFAIL/ETIMEOUT/ENOTFOUND because they need public internet DNS, so they are pre-existing and unrelated to this change.

The parsed reply of a generic record query (ResolveInfoRequest<T: CAresRecordType>, 12 record types) travelled through on_cares_complete, CAresLookup::process_resolve and Resolver::drain_pending_cares as Option<*mut T>, and the two consumers re-established ownership by hand with scopeguard closures calling T::destroy, plus a comment explaining which caller passes null. It now travels as Option<OwnedReply<T>>, a repr(transparent) NonNull<T> wrapper whose Drop is the only caller of T::destroy; the four producers adopt the reply where the c-ares layer hands it over, and the consumers simply let it drop at the same scope exit where the guards fired. The reverse-lookup hostent, which c-ares only lends, keeps its raw pointer, so the owned and lent cases now differ in type rather than by convention. Option<OwnedReply<T>> is a single nullable pointer instead of a two-word Option<*mut T>, the null checks in the producers are the same NonNull::new checks as before, and the drop glue is the same T::destroy call the guards made.
@robobun
robobun requested a review from alii August 11, 2026 20:47
@robobun

robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:04 PM PT - Aug 11th, 2026

✅ @robobun, your commit 72b9aaa30b3f4c972f1b6f006b00d87bb7745dea passed in Build #92434! 🎉


🧪   To try this PR locally:

bunx bun-pr 37602

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

bun-37602 --bun

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4dade516-508a-4052-8fdd-2dcc1b78c2a1

📥 Commits

Reviewing files that changed from the base of the PR and between 97e21e5 and 72b9aaa.

📒 Files selected for processing (1)
  • src/runtime/dns_jsc/dns.rs

Walkthrough

Changes

The DNS c-ares integration now uses OwnedReply<T> for reply ownership. Record callbacks adopt parser allocations, and completion paths convert owned replies while RAII handles destruction. Pending-cache draining reuses outcomes across coalesced waiters.

DNS reply ownership

Layer / File(s) Summary
Owned reply contract
src/runtime/dns_jsc/dns.rs
OwnedReply<T> owns c-ares replies and calls the record-specific destruction hook on drop. Lookup processing now consumes owned replies.
Record callback adoption
src/runtime/dns_jsc/dns.rs
Generic, ANY, hostent, and TTL callbacks adopt parser allocations into OwnedReply.
Completion and cache draining
src/runtime/dns_jsc/dns.rs
Completion paths process owned replies. Pending-cache draining reuses converted outcomes across waiters and removes manual destruction guards.

Suggested reviewers: alii, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: carrying c-ares reply ownership in OwnedReply.
Description check ✅ Passed The description explains the change, rationale, affected paths, related work, and verification results, including known pre-existing test failures.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — clean RAII wrapper that moves c-ares reply ownership from scopeguard-by-convention into the type system.

Checked that all exit paths in process_resolve and drain_pending_cares still free the reply: the early-return in the Some(err) branch drops the unmoved result parameter, and node/addr drop at the same scope end the old guards did. Verified T::destroy now has exactly one caller (the Drop impl), the four producers adopt at the c-ares handover point with correct SAFETY comments, and the borrowed GetHostByAddrInfoRequest hostent path is intentionally left as Option<*mut struct_hostent>. Confirmed heap::into_raw_nn exists and to_js_response(&mut self) resolves through DerefMut.

Extended reasoning...

Overview

Single-file refactor in src/runtime/dns_jsc/dns.rs (+71/-55). Introduces OwnedReply<T: CAresRecordType>, a #[repr(transparent)] NonNull<T> wrapper whose Drop calls T::destroy, and threads it through ResolveInfoRequest::on_cares_complete, CAresLookup::process_resolve, and Resolver::drain_pending_cares in place of Option<*mut T>. The four producer sites (impl_cares_record_type!, hostent_newtype!, hostent_ttls_newtype!, and the any handler) now adopt the reply where c-ares hands it over; the two consumer-side scopeguard calls are deleted.

Security risks

None. This is internal memory-ownership plumbing for DNS reply structs; no user-facing input handling, parsing, or trust boundary is touched.

Level of scrutiny

Moderate — it's memory-lifetime code, so I traced every exit path in both consumers. In process_resolve, the early return when err_.is_some() no longer has an explicit guard, but result: Option<OwnedReply<T>> is a by-value parameter and drops on return, so a (theoretical) Some(err) + Some(reply) case still frees. In drain_pending_cares, addr drops after the waiter loop, matching the old _free_addr guard's scope end. The None branch forwards None to process_resolve, so nothing to free there. T::destroy is now called from exactly one place (dns.rs:427), and the borrowed hostent path (GetHostByAddrInfoRequest, line 656) correctly retains its raw-pointer signature since c-ares owns that reply.

Other factors

The change is exactly what REVIEW.md's memory-safety guidance asks for: replace ad-hoc release-at-scope-exit with a Drop/RAII guard armed at the acquisition site. Option<NonNull<T>> niche-optimizes to a single nullable pointer, and panic = "abort" means no new unwind edges. Verified heap::into_raw_nn exists in bun_core::heap and that to_js_response takes &mut self so DerefMut dispatch works. PR description accurately notes the textual conflict with #36123. No behavior change, no test changes needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants