dns: do not resolve with an empty value when worker.terminate() races a lookup completion - #35161
dns: do not resolve with an empty value when worker.terminate() races a lookup completion#35161robobun wants to merge 3 commits into
Conversation
… a lookup completion When a parent calls worker.terminate() while the worker thread is draining getaddrinfo completions, the JSC termination flag can fire in the middle of the result-to-JSArray conversion. The conversion then throws, the caller falls back to JSValue::ZERO, and the DNS on_complete path passed that empty value straight into JSC__JSPromise__resolve, tripping ASSERT(!target.isEmpty()) in debug and a near-null (0x5) SIGSEGV in release. Guard the four DNS on_complete sinks (DNSLookup::on_complete_with_array, CAresLookup::on_complete, CAresReverse::on_complete, CAresNameInfo::on_complete) so an empty result drops the promise Strong and cleans up instead of calling resolve. Also fix the one drain_pending_host re-conversion site that would have panicked on an Err from result_any_to_js, and add a GetAddrInfoRequestTask release arm so queued-but-never-dispatched completions free their JSPromiseStrong handles while the JSC VM is still alive instead of being stranded past worker VM dealloc.
|
Updated 1:57 PM PT - Jul 22nd, 2026
❌ @robobun, your commit ac2b7bc has 2 failures in
🧪 To try this PR locally: bunx bun-pr 35161That installs a local version of the PR into your bun-35161 --bun |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
Neither issue is in scope here: #33911 is the cross-thread fetch/HTTP-thread race (this PR only touches the same-thread DNS completion path; #34154 is the tracker for the cross-thread class), and #34690's |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Overlap with #34154 is real and intentional: that PR carries the full |
WalkthroughChangesThe DNS runtime skips empty promise resolutions, reclaims queued native DNS shutdown lifecycle
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/dns_jsc/dns.rs`:
- Around line 1569-1599: Update GetAddrInfoRequest::release_for_shutdown to call
resolver.request_completed() after removing any pending cache entry and before
freeing the request chain. Preserve the existing conditional cache cleanup, and
do not alter the separate global-resolver close_channel_for_terminate() path.
In `@test/js/web/workers/worker-terminate-lifetime.test.ts`:
- Line 217: Update the grace-period sleep in the worker termination test to
scale with the existing slow condition (isDebug || isASAN), matching the scaling
approach used for rounds, perRound, and timeout. Preserve the normal-build delay
while increasing it for slow/ASAN builds so getaddrinfo completions can
accumulate before terminate().
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 96fe4d98-d2fb-47dd-a3f1-55d531f74007
📒 Files selected for processing (3)
src/runtime/dispatch.rssrc/runtime/dns_jsc/dns.rstest/js/web/workers/worker-terminate-lifetime.test.ts
|
CI on build #78011 is green for this change: the new |
There was a problem hiding this comment.
No bugs found, but this touches unsafe worker-VM shutdown ordering and JSC handle lifetime — worth a human pass, especially given the overlap with #34154's broader ShutdownGate model.
Checked that release_for_shutdown mirrors the normal then() reclaim (pending-native-cache slot pop, head.next chain destroy, box drop) and only sees Backend::Libc on this task path, so no file_poll to release.
Checked the four is_empty() guards leave promise to drop its Strong on scope exit, and the drain_pending_host_native .unwrap_or(ZERO) feeds directly into the new guard.
Checked DNSLookup::Drop (poll_ref unref, resolver deref) is safe at this shutdown stage — same precondition as the existing AsyncFSTask arm.
Extended reasoning...
Overview
This PR fixes a crash (ASSERT(!target.isEmpty()) / segfault at 0x5) when worker.terminate() races an in-flight dns.lookup() completion. Three files: four identical is_empty() guards in DNS on_complete sinks in src/runtime/dns_jsc/dns.rs, a new GetAddrInfoRequest::release_for_shutdown reclaim helper, an .unwrap() → .unwrap_or(JSValue::ZERO) in drain_pending_host_native, a GetAddrInfoRequestTask arm in __bun_release_task_at_shutdown (src/runtime/dispatch.rs), and a regression test in worker-terminate-lifetime.test.ts.
Security risks
None. This is a crash/UAF fix on the shutdown path; no user-controlled input reaches new parsing or allocation logic.
Level of scrutiny
High. This is native unsafe Rust in the memory-safety category REVIEW.md flags as most-blocked: JSC Strong handle lifetime across worker VM teardown, raw-pointer linked-list walking in release_for_shutdown, and shutdown ordering invariants (must run after close_dns_for_terminate but before JSC destructOnExit). The reasoning about why request_completed() is deliberately not called (would re-arm the timer mid-shutdown) is subtle enough that CodeRabbit flagged it and the author had to justify it in-thread.
Other factors
The change is small, well-commented, and follows the exact pattern of the existing AsyncFSTask/FetchTasklet/JSCDeferredWorkTask arms in __bun_release_task_at_shutdown. The regression test is hermetic (IP-literal lookups, Atomics.wait handshake), fails 8/8 before and passes 8/8 after per the PR description, and CI is green on the new test. Both CodeRabbit findings were resolved with sound reasoning. However: this is carved out of #34154 (47-file ShutdownGate PR) and someone familiar with that larger design should confirm the shared hunks land cleanly and that release_for_shutdown's reclaim-only shape (no request_completed(), no backend file_poll handling) is complete for every state a queued GetAddrInfoRequestTask can be in at shutdown. That is not something I can auto-approve.
Reproduction
Release build:
panic: Segmentation fault at address 0x5(3/3).Debug build:
ASSERTION FAILED: !target.isEmpty()inJSC__JSPromise__resolve(bindings.cpp:3564).Cause
worker.terminate()from the parent sets the JSC termination flag on the worker's VM cross-thread. If the worker thread is mid-tick()dispatching aGetAddrInfoRequestTaskwhen that lands, the native result-to-JSArray conversion (result_any_to_js/addr_info_to_js_array/to_js_response) throws, and every caller falls back toJSValue::ZERO(the existing// TODO: properly propagate exception upwardssites). The DNSon_completesinks then passed that empty value straight intopromise.resolve_task(global, result), which hitsASSERT(!target.isEmpty())inJSC__JSPromise__resolve(near-null deref in release).The
dns.resolve*/ c-ares path is already safe here because worker shutdown destroys the c-ares channel before the drain; the getaddrinfo work-pool path had no equivalent.Fix
on_completesinks (DNSLookup::on_complete_with_array,CAresLookup::<T>::on_complete,CAresReverse::on_complete,CAresNameInfo::on_complete) so an emptyresultdrops the promiseStrongand cleans up instead of callingresolve_task..unwrap()indrain_pending_host_native's re-conversion loop with the same.unwrap_or(JSValue::ZERO)pattern so a mid-drain throw there feeds the guard above instead of panicking.GetAddrInfoRequestTaskarm to__bun_release_task_at_shutdown(and aGetAddrInfoRequest::release_for_shutdownhelper) so queued-but-never-dispatched completions free theirJSPromiseStronghandles and request boxes while the JSC VM is still live, instead of being re-queued past worker VM dealloc.The separate cross-thread race where the work-pool thread posts into a freed
EventLoopafter the worker box isdealloc'd is the general class #34154 addresses and is not in scope here.Test
test/js/web/workers/worker-terminate-lifetime.test.tsgains a regression test that queues 128 distinct IP-literal lookups withbackend: "getaddrinfo"(so each is its ownGetAddrInfoRequestTaskon the work pool), parks the worker inAtomics.waituntil they pile up, then wakes and terminates it mid-drain. Fails 8/8 before (ASSERTION FAILED: !target.isEmpty()/Segmentation fault at address 0x5), passes 8/8 after.no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/workers/worker-terminate-lifetime.test.ts