dns: keep the c-ares uv_poll_t alive while its poll callback is on the stack (Windows) - #38024
dns: keep the c-ares uv_poll_t alive while its poll callback is on the stack (Windows)#38024robobun wants to merge 1 commit into
Conversation
…e stack (Windows) On Windows each c-ares socket is driven by a uv_poll_t embedded in a heap UvDnsPoll. c-ares closes a UDP socket as soon as its answer is processed, which happens inside on_dns_poll_uv, so uv_close() is issued from within the handle's own poll callback. The callback then drains microtasks; a promise reaction that spins the event loop (Bun.build() with a plugin whose setup() returns a pending promise, bun:test's .resolves, ...) runs a nested uv_run, whose endgame phase invoked on_close_uv and freed the UvDnsPoll. libuv's uv__fast_poll_process_poll_req frame for that handle was still suspended underneath and reads the handle again once the callback returns; with the stale bytes it re-queued the endgame, close_cb ran a second time and the struct was freed twice. A script that re-enters the loop from resolve4() reactions segfaults after about ten queries on the release build. Count the on_dns_poll_uv frames active for a handle. on_close_uv only frees the struct when none is active; otherwise it marks the handle and the outermost frame, once its microtask drain is over, hands the struct to the event loop's task queue, which is drained only after the libuv callback has returned. The common path (close callback delivered after the poll callback returned) is unchanged. The libuv side of the same scenario is the win-poll-no-reendgame-after- close patch from #33018 (identical copy): with the struct kept alive, libuv would otherwise re-queue the endgame of the already CLOSED handle from the post-callback check and invoke close_cb twice (uv__poll_endgame asserts on this in debug builds).
|
Warning Review limit reached
Next review available in: 6 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
|
Updated 11:05 PM PT - Aug 12th, 2026
❌ @robobun, your commit 6d3c2c3 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 38024That installs a local version of the PR into your bun-38024 --bun |
|
Status: fix and test verified on Windows x64 locally and on both Windows CI lanes; review in progress.
|
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not a duplicate of #33018, but deliberately overlapping with it. #33018 repairs the uSockets call site ( The libuv patch file and its |
There was a problem hiding this comment.
I reviewed this PR and the automated bug-hunt found no issues. Because it's a native memory-safety fix that reasons about nested uv_run re-entry and adds a libuv vendor patch affecting every Windows uv_poll_t, a human look would still be worthwhile.
What was reviewed:
poll_cb_depth/closedbookkeeping inon_dns_poll_uv/on_close_uv— the depth counter brackets the entire scope including the_exitmicrotask drain, and only the outermost frame enqueues the deferred destroy.- The libuv patch's
UV_HANDLE_CLOSEDguard — matches the assertionuv__poll_endgamealready makes; applied to both fast and slow poll paths. ManagedTask::new_owneddeferral — confirmed the task queue drains outside the libuv callback and frees on VM teardown.- The new test — hermetic (local UDP responder,
port: 0), asserts the re-entry actually happened synchronously, uses distinct names to avoid the c-ares cache.
Extended reasoning...
Overview
This PR fixes a Windows-only use-after-free / double-free in the c-ares DNS resolver. Each c-ares socket is backed by a heap-allocated UvDnsPoll containing a uv_poll_t. When c-ares closes a socket from inside its own poll callback and JS then re-enters the event loop (nested uv_run) from the microtask drain, libuv's endgame phase runs on_close_uv and frees the struct while uv__fast_poll_process_poll_req's frame for that handle is still suspended underneath — libuv then reads the freed handle after the callback returns and re-queues its endgame, double-freeing it.
The fix has two parts: (1) UvDnsPoll gains a poll_cb_depth counter and a closed flag so on_close_uv defers the free to a ManagedTask when a poll-callback frame is still active; (2) a new libuv patch adds a !(flags & UV_HANDLE_CLOSED) guard to the post-poll_cb endgame check in both the fast and slow Windows poll paths so that once the struct stays alive, libuv doesn't re-queue an endgame that already ran. A Windows-only regression test spawns a child that answers 20 resolve4() queries from a local UDP server and re-enters the loop from each promise reaction via Bun.build() with a pending setup().
Security risks
None identified. This is a crash/UAF fix; no new attack surface, no untrusted-input parsing changes. The libuv patch only narrows a condition (adds a guard that uv__poll_endgame already asserts).
Level of scrutiny
High. This is squarely in REVIEW.md's most-blocked category — native memory safety. It reasons about pointer lifetime across nested uv_run re-entry (which libuv's contract does not support), adds a vendor patch that touches every Windows uv_poll_t (not just DNS), and the failure mode is release-only (debug mimalloc's 0xDF fill masks it). The reasoning in the PR description is exceptionally detailed and includes instrumented traces for all four scenarios (unfixed, fixed-with-reentry, fixed-without-reentry, fixed-without-libuv-patch), which builds substantial confidence — but that same depth is what makes it inappropriate for auto-approval.
Other factors
- I traced the new control flow:
poll_cb_depthis incremented before any work and decremented after the inner scope (which drops_exit→ drains microtasks → may nestuv_run), so the counter is non-zero throughout any nested endgame. Only the outermost frame (depth == 0 && closed) enqueues the deferred destroy, so re-entrant depth-2 dispatch cannot double-enqueue.on_close_uvsetsclosedat most once (debug_assert!(!closed), and the libuv patch prevents the second invocation). vmis captured before the inner scope, so it remains valid fordestroy_after_poll_cb_returnseven after_derefmay free the resolver —vmis the VirtualMachine, not the resolver.- The libuv patch is described as byte-identical to #33018's; the
deps/libuv.tscomment correctly notes it is not upstreamable (nesteduv_runis outside libuv's contract). - The test is well-constructed: hermetic local UDP responder, distinct hostnames per round to force network hits, and it self-checks that
Bun.build()actually spun the loop synchronously (throws ifreentriesdidn't increment beforeBun.build()returned), so the test cannot silently degrade into a no-op if that behavior changes. - No prior reviews on this PR; CodeRabbit was rate-limited. Given the vendor patch and the subtlety of the lifetime argument, a maintainer familiar with the Windows event loop should sign off.
Problem
dns.promises.Resolver(ordns.Resolver) query whose promise reaction synchronously spins the event loop crashes the release build after about ten queries:panic(main thread): Segmentation fault at address 0x0(also seen asaddress 0x600000030). Levers that spin the loop from a reaction includeBun.build()with a plugin whosesetup()returns a pending promise, andexpect().resolvesinbun:test.uv_poll_tembedded in a heapUvDnsPoll(src/runtime/dns_jsc/dns.rs). c-ares closes a UDP socket as soon as its answer has been processed, which happens insideChannel::processinon_dns_poll_uv(dns.rs:4726 on main), soon_dns_socket_stateissues theuv_close(dns.rs:4830) from inside the handle's own poll callback.on_dns_poll_uvthen drains microtasks (EventLoopEnterGuarddrop). A reaction that re-enters the loop runs a nesteduv_run; its endgame phase invokedon_close_uv, which freed theUvDnsPollimmediately (dns.rs:4771).uv__fast_poll_process_poll_reqframe for that handle is still suspended underneathon_dns_poll_uvand readshandle->events,submitted_events_*andflagsonce the callback returns (vendorsrc/win/poll.c, the block afterpoll_cb). On the stale bytes it re-queues the endgame of the already closed handle,on_close_uvruns a second time, and the struct is freed twice; mimalloc then hands the block out twice and a later round crashes.FilePoll, whose dispatch does not touch the poll after the callback, and is not affected.Fix
UvDnsPollcounts theon_dns_poll_uvframes currently active for it (poll_cb_depth).on_close_uvfrees the struct only when that is zero; otherwise it just marks the handleclosed.uv_run) and seesclosed, it hands the struct to the event loop task queue (ManagedTask::new_owned, the same primitivecares_jsc.rsalready uses; it also frees the struct if the VM tears down first). Bun drains that queue from its own tick, never from inside the libuv callback that enqueued the task, so by the time the task runs the frame that enqueued it has returned and libuv's tail behind it has finished reading the handle.on_dns_socket_stateremoves the map entry, the only things still referring to aUvDnsPollare the suspendedon_dns_poll_uvframes for it and libuv's tail under each of them, which is exactly what the counter tracks. libuv does not invokepoll_cbfor a closing handle, so the count can only go down onceuv_closewas issued, and the close callback reaching a handle with a zero count means no frame holds it, which is today's behavior unchanged. Re-entrant dispatch of the same handle (depth 2) works the same way: inner frames only decrement, the outermost one defers.patches/libuv/win-poll-no-reendgame-after-close.patch(and its entry indeps/libuv.ts) is a byte-identical copy of the patch in Fix Windows heap corruption when a socket's data callback closes it and re-enters the event loop #33018: once the struct stays alive, unpatched libuv reads real flags in the tail and re-queues the endgame of the CLOSED handle, invokingclose_cbtwice (double free in release; in debuguv__poll_endgameasserts, trace below). The patch guards the re-queue on!(flags & UV_HANDLE_CLOSED), whichuv__poll_endgamealready asserts. Whichever of the two PRs lands second drops the duplicate on rebase.on_close_uvfrees immediately as before (trace below shows 20/20 queries taking that branch).test/js/node/dns/node-dns.test.js, "dns.Resolver: a query's promise reaction may re-enter the event loop". A child answers 20resolve4()queries from a local UDP responder and re-enters the loop from each reaction viaBun.build(); it also checks the re-entry really was synchronous (HTMLRewriter.transform(), which Fix Windows heap corruption when a socket's data callback closes it and re-enters the event loop #33018 uses, no longer spins the loop on main). Distinct names per query keep c-ares from answering later rounds from its own cache. Windows only (skipIf(!isWindows)).USE_SYSTEM_BUN=1 bun test ... -t "promise reaction may re-enter"(canary 1.4.0, unfixed): fail, child exit 3 with the segfault above; the repro script crashed by round 9 or 10 in 10/10 runs.bun bd test test/js/node/dns/node-dns.test.js -t "promise reaction may re-enter"on this branch: pass. Whole file: 133 pass, 0 fail (canary: 132 pass, only the new test failing).test/js/node/dns/+test/js/bun/dns/: 222 pass, 0 fail on rerun (onedns.getServersfailure in the first run; it compares againstnode -e dns.getServers()and passed alone and in the full-file reruns on the same build).test-dns-*/test-worker-dns-terminate-during-querytests that use local servers: all exit 0.uv_poll_t:socket.test.ts76 pass,tcp-server,udp_socket,socket-retention,worker-terminate-lifetime228 pass, 0 fail.0xDF, which makes libuv's stale read take its "both request slots busy" no-op branch, so the unfixed debug build reads freed memory silently. The instrumented traces below show the free inside the frame on the unfixed build regardless.Background
uv_close(handle, close_cb); libuv invokesclose_cblater, from the endgame phase of auv_runiteration, and the embedding struct may only be freed fromclose_cbor later. For auv_poll_tthe endgame is reached once its outstanding AFD poll requests have completed; bun's existing rearm patch means one is always outstanding duringpoll_cb, souv_closefrom insidepoll_cbcancels it and the endgame comes from whicheveruv_runiteration dequeues that cancellation.uv__fast_poll_process_poll_reqis the libuv function that callspoll_cb; after the callback it checks whether to resubmit a request or, for a closing handle, queue the endgame. libuv assumesuv_runis not re-entered from a callback, so it never expects the endgame to have run in between.EventLoop::wait_for_promise(behindBun.build()option parsing,bun:test's.resolves/.rejects, and others) callsauto_tick, which on Windows isus_loop_run/us_loop_pump, i.e. a nesteduv_run.EventLoopEnterGuard(enter_event_loop_scope) is how native callbacks drain microtasks when they return; that drain is where the promise reactions run, still inside the libuv callback.ManagedTaskis the event loop's one-off heap task; a task enqueued during a libuv callback runs when bun drains its task queue afteruv_runreturns, or is released (here: freed) if the VM tears down before that.Instrumented traces (Windows x64 debug builds, one query shown; eprintln added locally, not in this diff)
Unfixed (main).
close_cbfrees the struct between the reaction's start and end, i.e. whilepoll_cbfor the same handle is still on the stack; the same sequence repeated for all 20 queries:This branch, with re-entry (20/20 deferred, 0 freed early, 20 deferred frees ran):
This branch, without re-entry (20/20 freed directly in
close_cb, no task enqueued):This branch's dns.rs change with the libuv patch removed from
deps/libuv.ts: the now-live handle makes libuv's post-callback check re-queue the endgame, and the first query aborts in libuv: