Skip to content

dns: destroy the per-VM c-ares channel before JSC/RareData teardown on worker terminate - #34455

Merged
Jarred-Sumner merged 5 commits into
mainfrom
claude/farm/088fabf7/worker-dns-terminate-uaf
Jul 18, 2026
Merged

dns: destroy the per-VM c-ares channel before JSC/RareData teardown on worker terminate#34455
Jarred-Sumner merged 5 commits into
mainfrom
claude/farm/088fabf7/worker-dns-terminate-uaf

Conversation

@robobun

@robobun robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

What

Fixes a heap-use-after-free when a Worker with an in-flight dns.lookup() / dns.resolve*() is terminated.

Surfaced by Node's upstream test/parallel/test-worker-dns-terminate.js (being vendored in #34441), on the debian 13 x64-asan lane:

==11356==ERROR: AddressSanitizer: heap-use-after-free on address 0x12ce0a3af168
READ of size 4 at 0x12ce0a3af168 thread T6 (Worker)
    #0 FilePoll::unregister            src/io/posix_event_loop.rs:951
    #1 FilePoll::deinit_possibly_defer src/io/posix_event_loop.rs:428
    #2 FilePoll::deinit_with_vm        src/io/posix_event_loop.rs:448
    #3 Resolver::on_dns_socket_state   src/runtime/dns_jsc/dns.rs:4894
    #6 ares_conn_sock_state_cb_update  vendor/cares/src/lib/ares_conn.c:36

freed by thread T6 (Worker):
    drop_in_place<Box<posix_event_loop::Store>>   (RareData field drop)
    VirtualMachine::destroy                       src/jsc/VirtualMachine.rs:4453
    WebWorker::shutdown                           src/jsc/web_worker.rs:1299

Repro

const { Worker } = require('worker_threads');
const w = new Worker(`
  const dns = require('dns');
  dns.lookup('nonexistent.org', () => {});
  require('worker_threads').parentPort.postMessage('0');
`, { eval: true });
w.on('message', () => w.terminate());

Cause

WebWorker::shutdown() runs, in order: WebWorker__teardownJSCVM (frees the JSGlobalObject), then VirtualMachine::destroy() which drops rare_data (frees the FilePoll hive Store) and finally calls deinit_runtime_state which drops RuntimeState. That last drop runs GlobalData::drop which calls ares_destroy() on the per-VM c-ares channel.

ares_destroy() synchronously fires every pending query callback with ARES_EDESTRUCTION and then the socket-state callback for each fd it closes. Those callback chains re-enter:

  • Resolver::on_dns_socket_state -> FilePoll::deinit_with_vm on the already-freed hive slot (the ASAN trace above)
  • GetAddrInfoRequest::on_cares_complete -> DNSLookup::process_get_addr_info -> reject_later(global_this) on the freed JSGlobalObject (bmalloc-backed so ASAN misses it)
  • ResolveInfoRequest::on_cares_complete -> request_completed() -> remove_timer() -> (*runtime_state()).timer with the TLS already nulled (null deref)

Fix

Add a RuntimeHooks::close_dns_for_terminate slot that runs Resolver::close_channel_for_terminate() from WebWorker::shutdown() (and the BUN_DESTRUCT_VM_ON_EXIT main-thread path) right after close_all_socket_groups, while JSC, RareData.file_polls, the event loop, and runtime_state are all still live. The method also removes the resolver's c-ares timeout timer, which GetAddrInfoRequest's EDESTRUCTION path never unwinds. GlobalData::drop still handles the channel if the early hook never ran (it sees channel == None when it did).

This matches Node's model: Worker::Exit -> CleanupHandles() closes every handle wrap (including ChannelWrap) before disposing the Isolate.

Verification

New ASAN-gated test in test/js/web/workers/worker-terminate-lifetime.test.ts spawns four workers that each start a dns.lookup() + dns.resolve4() and terminates them mid-flight.

  • fail-before (git stash -- src/ && bun bd test ...): null-deref panic / ASAN heap-use-after-free
  • pass-after: clean exit 0 across 10 consecutive runs

Also verified test/js/node/dns/ and test/js/bun/dns/ pass/fail counts are unchanged vs. main, and bun run rust:check-all is clean on all targets.


no test proof · iteration 0 · 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

…n worker terminate

ares_destroy() synchronously invokes every pending query callback with
ARES_EDESTRUCTION and then the socket-state callback for each fd it
closes. On worker terminate the channel was destroyed in GlobalData::drop
during deinit_runtime_state, which runs after WebWorker__teardownJSCVM
(JSGlobalObject freed) and after drop(rare_data) (the FilePoll hive
freed). The socket-state callback then called FilePoll::deinit_with_vm on
a freed hive slot (ASAN heap-use-after-free), and the query callbacks
dereferenced the freed global and a nulled runtime_state().

Add a RuntimeHooks::close_dns_for_terminate slot that tears the channel
down in WebWorker::shutdown (and the BUN_DESTRUCT_VM_ON_EXIT main-thread
path) right after close_all_socket_groups, while everything the callbacks
touch is still live. Resolver::close_channel_for_terminate also removes
the resolver's c-ares timeout timer, which GetAddrInfoRequest's
EDESTRUCTION path never unwinds.

Surfaced by Node's upstream test/parallel/test-worker-dns-terminate.js.
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:28 AM PT - Jul 17th, 2026

@robobun, your commit 2e68559 has 2 failures in Build #74482 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34455

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

bun-34455 --bun

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 5 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 46f25e73-c2c6-475f-b56b-cb1fb32135b5

📥 Commits

Reviewing files that changed from the base of the PR and between f2a92a8 and 853c924.

📒 Files selected for processing (2)
  • src/jsc/web_worker.rs
  • test/js/web/workers/worker-terminate-lifetime.test.ts

Walkthrough

The change adds a runtime hook that closes each VM’s c-ares channel during VM and worker shutdown, removes its timer, prevents deferred DNS rejection enqueueing during shutdown, and adds ASAN-only worker termination coverage.

Changes

DNS termination teardown

Layer / File(s) Summary
Resolver teardown hook
src/jsc/VirtualMachine.rs, src/runtime/dns_jsc/dns.rs, src/runtime/jsc_hooks.rs
Adds the close_dns_for_terminate runtime hook and a resolver method that destroys the c-ares channel and removes its timer.
VM and worker shutdown ordering
src/jsc/VirtualMachine.rs, src/jsc/web_worker.rs
Invokes DNS teardown after socket-group closure and before subsequent runtime shutdown steps.
Shutdown callback handling and regression coverage
src/runtime/dns_jsc/cares_jsc.rs, test/js/web/workers/worker-terminate-lifetime.test.ts
Skips deferred DNS rejection enqueueing during shutdown and adds ASAN-only worker termination coverage with active DNS lookups.

Possibly related PRs

  • oven-sh/bun#34154: Both modify worker and VM shutdown sequencing to address termination-time lifetime issues.
  • oven-sh/bun#34278: Both modify worker shutdown ordering around termination state.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: early c-ares DNS teardown during worker terminate.
Description check ✅ Passed The description covers the PR purpose, cause, fix, repro, and verification, satisfying the template requirements.
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.

Beyond the inline findings, I also checked whether the new test's dns.lookup("nonexistent.org") / dns.resolve4(...) violate the hermetic-tests rule — the queries are terminated mid-flight and the test asserts nothing about their result, so it doesn't depend on external resolution (matches upstream Node's test-worker-dns-terminate.js shape).

Extended reasoning...

The inline findings already cover what needs attention here — in particular the reject_laterManagedTask::new(cleanup: None) leak on the EDESTRUCTION path, which is newly reachable now that ares_destroy() runs while the VM is live. This is a worker-shutdown-ordering / memory-safety change and warrants a human look regardless.

Comment thread src/runtime/dns_jsc/dns.rs
Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts Outdated
Comment thread src/runtime/jsc_hooks.rs
robobun added 2 commits July 17, 2026 05:59
reject_later() enqueued a ManagedTask with cleanup=None; on the worker
terminate path the task queue is drained-without-run so the Context box
(and its JSPromiseStrong) leaked per in-flight query. Check
vm.is_shutting_down() and drop the ErrorDeferred inline instead, while
JSC is still live so the Strong handle releases cleanly.

Also switch the new test's assertion to the combined-object shape.
…t_groups; opt the test into LSan

The hook's placement after close_all_socket_groups is load-bearing:
on_close JS can call dns.resolve*() and Resolver::get_channel() lazily
re-inits when channel is None, so running the hook earlier lets a
re-created channel survive to GlobalData::drop (the original UAF).

Also opt the new test into detect_leaks=1 with the repo suppression
file so it catches the EDESTRUCTION-path reject_later leak class
regardless of the runner's ambient ASAN_OPTIONS.
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Status: UAF fix verified locally (fail-before against main's src/: null-deref / heap-use-after-free; pass-after: clean across repeated LSan runs).

Follow-up commits address review feedback:

  • f2a92a8: drop the EDESTRUCTION-path reject_later task during shutdown (LSan leak)
  • c19f06d: document the must-follow-close_all_socket_groups ordering; opt the test into LSan explicitly
  • 853c924: make the test hermetic (local never-replying UDP socket via dns.setServers())
  • 2e68559: update remove_timer's SAFETY proof for the new caller; gate dns.lookup to Linux in the hermetic test

CI (build 74482, complete: 284/286 passed): the diff is green; the new test passed on every ASAN lane. The two red lanes are unrelated to this change:

  • test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js on debian 13 x64-asan: pre-existing JSC assertNoException assertion in JSObject::getOwnPropertyDescriptor (no DNS/FilePoll in that path), also fails on main.
  • test/cli/run/no-orphans.test.ts on darwin 14 aarch64: 30s timeout in the perl fast-exit-intermediate reaping case; --no-orphans uses kqueue EVFILT_PROC on macOS and involves no workers, no DNS, and does not reach the BUN_DESTRUCT_VM_ON_EXIT branch this PR touches.

Both are tracked separately. Ready for maintainer review.

Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts Outdated
Point the global resolver's c-ares channel at a local UDP socket that
never replies, so both dns.lookup() and dns.resolve4() are guaranteed
in-flight when terminate() lands regardless of the runner's DNS cache
speed, and no external DNS traffic is sent.
Comment thread src/runtime/dns_jsc/dns.rs
Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts
…okup to Linux in the hermetic test

remove_timer()'s SAFETY comment said request_completed was the only path;
close_channel_for_terminate is now a second caller. The invariant still
holds via the global resolver's permanent +1 pin from global_resolver();
state that in the proof.

On macOS dns.lookup() uses libinfo (ignores setServers()), so gate it to
Linux where the c-ares backend is the default. dns.resolve4() stays
hermetic everywhere and covers the same callback chains.

@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.

No new findings — all prior feedback (the reject_later leak, hermetic test, SAFETY proof, macOS dns.lookup gate) is addressed and resolved. This reorders worker-VM teardown around ares_destroy() re-entry and adds an is_shutting_down gate on a JS-visible rejection path, so it's worth a maintainer's final look at the shutdown-step ordering invariants.

What was reviewed:

  • Placement of close_dns_for_terminate after close_all_socket_groups and before JSC teardown in both WebWorker::shutdown and global_exit — the re-init hazard from on_close JS is documented and the ordering matches.
  • reject_later's new is_shutting_down early return — drops ErrorDeferred/JSPromiseStrong while JSC is live; checked that other callers reaching this during shutdown (e.g. on_exit() user handlers) are also safe to short-circuit.
  • remove_timer()'s updated SAFETY proof for the new direct caller — the global resolver's +1 pin keeps the deref from reaching 0.
  • The hermetic ASAN/LSan test: local UDP never-replies socket, Linux-gated dns.lookup, combined-object assertion, leaksan.supp path.
Extended reasoning...

Overview

The PR fixes a heap-use-after-free on worker terminate when a dns.lookup()/dns.resolve*() is in flight: ares_destroy() was firing its EDESTRUCTION and socket-state callbacks from GlobalData::drop in deinit_runtime_state, after JSC and RareData.file_polls were already freed. The fix adds a RuntimeHooks::close_dns_for_terminate slot invoked from WebWorker::shutdown (and the BUN_DESTRUCT_VM_ON_EXIT main-thread path) right after close_all_socket_groups, while all dependent state is still live. Follow-up commits addressed a task-leak on the now-reachable EDESTRUCTION rejection path (reject_later drops inline when is_shutting_down), made the regression test hermetic (local never-replying UDP socket via dns.setServers), gated dns.lookup to Linux, and updated remove_timer()'s SAFETY proof for the new caller.

Security risks

None identified. This is a lifetime/teardown-ordering fix in the worker shutdown path; no user-facing API surface, parsing, or trust-boundary changes.

Level of scrutiny

High. Worker VM teardown ordering is one of the most invariant-dense parts of the runtime — the shutdown() doc block enumerates five ordered barriers, and this PR inserts a new step whose callbacks re-enter three of them (JSGlobalObject, the FilePoll hive, runtime_state().timer). The placement (after close_all_socket_groups, before markTerminating/JSC teardown) is documented in-line with the specific hazard (on_close JS lazily re-initing the channel), and the GlobalData::drop path remains as a fallback. The is_shutting_down gate in reject_later also affects any DNS error deferred during on_exit() user handlers on both worker and main-thread shutdown; dropping the Strong handle inline there is strictly safer than the prior enqueue-and-orphan, but it's a behavior change on a shared helper.

Other factors

Three prior review rounds; every inline finding is resolved. The ASAN-gated test opts into LSan with the repo's leaksan.supp, uses the combined-object assertion, and was verified fail-before/pass-after. rust:check-all reported clean across targets. The one acknowledged gap (per-instance new dns.Resolver() channels leak on terminate) is pre-existing, a different failure mode, and needs a per-VM registry to fix — reasonably deferred. Given the subtlety of the teardown-ordering contract and the new RuntimeHooks surface, deferring to a maintainer for the final sign-off rather than auto-approving.

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.

2 participants