Skip to content

napi: implement uv_async_t on POSIX by bridging to Bun's event loop - #35475

Closed
robobun wants to merge 5 commits into
mainfrom
farm/4a09a751/uv-async-posix
Closed

napi: implement uv_async_t on POSIX by bridging to Bun's event loop#35475
robobun wants to merge 5 commits into
mainfrom
farm/4a09a751/uv-async-posix

Conversation

@robobun

@robobun robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

What

On Linux/macOS Bun does not run a libuv loop; every uv_* symbol except a handful of pthread/time wrappers is a panic stub. N-API addons that use uv_async_t to call back onto the JS thread die with:

panic(main thread): unsupported uv function: uv_async_init

This is the wrtc, zeromq, @serialport/bindings-cpp pattern: work happens on an addon thread and uv_async_send bounces the completion back to JS. It is also the one loop-coupled libuv primitive that maps cleanly onto machinery Bun already has.

How

The uv_loop_t* that napi_get_uv_event_loop hands out on POSIX is already Bun's *mut EventLoop (see napi_get_uv_event_loop in src/runtime/napi/napi_body.rs), and uv_async_send's contract ("run this callback on the loop thread, coalesced, thread-safe") is exactly EventLoop::enqueue_task_concurrent, the same path napi_threadsafe_function uses.

Three extern "C" shims in napi_body.rs expose what C cannot reach: enqueue a ManagedTask onto the loop, bump the loop's concurrent keep-alive refcount, and return the main-thread EventLoop* for uv_default_loop(). Everything the addon can observe on the uv_handle_t itself (flags, type, pending, close_cb, the u.fd busy counter) lives in uv-posix-polyfills.c so the struct bits match what libuv would have written.

Handle lifecycle matches libuv's (semantics taken from src/unix/async.c / src/uv-common.h at the header commit in src/jsc/bindings/libuv/README.md):

  • uv_async_init sets REF|ACTIVE and refs the loop; data is left untouched. Returns UV_EINVAL when loop is NULL.
  • uv_async_send coalesces via atomic exchange on pending and carries libuv's u.fd busy counter so a concurrent uv_close can spin out in-flight sends.
  • Dispatch runs on the loop thread, checks CLOSING first (so a racing send cannot schedule behind close_cb), then resets pending before calling async_cb so a send inside the callback schedules again.
  • uv_close asserts !uv__is_closing before any field write, is deferred, and keeps the loop alive until close_cb returns; if the handle was unref'd it takes a ref back for the duration so the process cannot exit with a close callback outstanding.
  • uv_ref/uv_unref/uv_close keep panicking for handle types Bun has not initialised, so the failure mode for everything else is unchanged.

Also implemented: uv_default_loop, uv_has_ref, uv_is_active, uv_is_closing, uv_handle_get_data/set_data/get_loop/get_type (pure field accessors addons reach for alongside async handles).

Worker teardown

A uv_async_t initialised inside a Worker stores that Worker's *mut EventLoop. Unlike ThreadSafeFunction there is no env-teardown hook yet to neutralise it when the Worker terminates (#18546 tracking the broader handle surface), so an addon thread that keeps calling uv_async_send past Worker::terminate() without the usual uv_close + join in a cleanup hook can reach a freed loop. Before this PR such code panicked at uv_async_init instead, so this is not a regression for any working addon; handles on the main thread's loop are unaffected.

Tests

test/napi/uv-stub-stuff/uv_impl.c gains testUvAsync / testUvAsyncClosePending exports that drive uv_async_init / uv_async_send / uv_ref / uv_unref / uv_close / uv_is_closing / the handle accessors, with sends both from the loop thread and from a pthread, with the loop obtained from both napi_get_uv_event_loop and uv_default_loop(), and with a close issued while a send is still queued so the "queued task takes the close path" branch runs. A separate keep-alive test initialises a ref'd handle and waits on an unref'd timer so the handle's loop ref is the only thing keeping the process alive.

Every case produces byte-identical output under Node.js.

  • USE_SYSTEM_BUN=1 bun test test/napi/uv.test.ts: 6 pass, 6 fail (panic: unsupported uv function: uv_async_init / uv_default_loop)
  • bun bd test test/napi/uv.test.ts: 12 pass

Fixes #5019. Towards #18546.

Related open PRs #31696 / #34155 / #29261 implement the loop-free libuv subset and explicitly leave uv_async_* for a loop-integrated change; this is that change for the async handle family and does not overlap those diffs beyond the shared generated-stubs files.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/napi/uv.test.ts

On Linux/macOS Bun does not run a libuv loop; every uv_* symbol except a
handful of pthread/time wrappers is a panic stub. N-API addons that use
uv_async_t to call back onto the JS thread (wrtc, zeromq, serialport, ...)
die with 'unsupported uv function: uv_async_init'.

The uv_loop_t* that napi_get_uv_event_loop returns on POSIX is already
Bun's *mut EventLoop, and uv_async_send's contract ('run this callback on
the loop thread, coalesced') is exactly EventLoop::enqueue_task_concurrent.
Three small extern "C" shims in napi_body.rs expose that plus the
concurrent keep-alive refcount and the main-thread EventLoop pointer for
uv_default_loop(); uv-posix-polyfills.c owns the uv_handle_t bookkeeping
(flags, pending, close_cb) so addons see the same bits libuv sets.

Handle lifecycle matches libuv: init sets REF|ACTIVE and refs the loop;
send CAS's pending 0->1 with libuv's busy-counter guard so a concurrent
uv_close can spin it out; close is deferred and holds the loop open until
close_cb runs; dispatch runs on the loop thread via ManagedTask. uv_close /
uv_ref / uv_unref keep panicking for handle types Bun has not initialised.

Fixes #5019.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 16 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: b35dd1d0-675f-4e49-8f88-773177e9d1ad

📥 Commits

Reviewing files that changed from the base of the PR and between 028f7a3 and eb2d513.

📒 Files selected for processing (7)
  • src/jsc/bindings/libuv/generate_uv_posix_stubs_constants.ts
  • src/jsc/bindings/uv-posix-polyfills.c
  • src/jsc/bindings/uv-posix-stubs.c
  • src/runtime/napi/napi_body.rs
  • test/napi/uv-stub-stuff/plugin.c
  • test/napi/uv-stub-stuff/uv_impl.c
  • test/napi/uv.test.ts

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

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:10 AM PT - Jul 25th, 2026

@robobun, your commit eb2d513 has 1 failures in Build #79860 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35475

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

bun-35475 --bun

Clearing pending to 0 on the close-path dispatch reopened the window for a
racing uv_async_send to schedule a second task behind close_cb, which may
free the handle. Check the CLOSING flag first and leave pending non-zero on
that path so at most one task ever references the handle.
@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. node-pty: PTY spawns but onData callback never fires (no data received) #25822 - node-pty's onData callback never fires on macOS, consistent with uv_async_t not being implemented on POSIX; this PR bridges uv_async_init/uv_async_send to Bun's event loop

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #25822

🤖 Generated with Claude Code

Comment thread src/jsc/bindings/uv-posix-polyfills.c Outdated
Comment thread test/napi/uv.test.ts Outdated
The setImmediate chain itself kept the loop alive, so a no-op
Bun__uv_handle_ref would still pass. Use an unref'd timer so the
uv_async_t handle is the only thing holding the process open.
Comment thread test/napi/uv.test.ts Outdated
robobun added 2 commits July 25, 2026 00:01
- uv_close: check CLOSING before writing close_cb, matching libuv's
  assert-first ordering so a second close does not silently clobber the
  first caller's callback
- uv_async_init: return UV_EINVAL when loop is NULL instead of silently
  succeeding with a dead handle
- uv_default_loop: fall back to this thread's VM when there is no
  main-thread VM (the bun build macro VM is created with
  is_main_thread: false)
- test: cover uv_close with a send already queued so the prev!=0 branch
  runs; byte-identical output under Node
- document the Worker-teardown caveat pending env-tracked handle cleanup

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

The three earlier findings are all addressed in eb2d513 and predecessors (CLOSING check now precedes the pending reset in Bun__uv_handle_dispatch; keepalive test uses an unref'd timer; subprocess tests are test.concurrent), and I found nothing new this pass. Deferring to a human because this is hand-rolled concurrent native code — atomics + spin-wait coordinating uv_async_send (any thread) with uv_close/dispatch, plus event-loop keep-alive refcount balancing across an FFI boundary — and the acknowledged Worker-teardown gap is a design tradeoff worth a maintainer's sign-off.

What was reviewed:

  • Re-traced the send/close/dispatch interleaving after the CLOSING-first fix — pending stays non-zero through close_cb, so the racing-send UAF window is closed.
  • Ref/unref bookkeeping: init (+1), unref (−1), ref (+1), close-of-unref'd (+1), dispatch-close (−1) balance on every path I could enumerate.
  • uv_stub.test.ts still consistent: the now-implemented symbols were removed from both symbols[] and plugin.c, and the dead symbols_to_test in uv.test.ts was unused.
Extended reasoning...

Overview

Implements uv_async_t on POSIX by bridging to Bun's EventLoop::enqueue_task_concurrent, replacing panic stubs for uv_async_init/uv_async_send/uv_close/uv_ref/uv_unref/uv_default_loop and the handle accessors. ~200 lines of new C in uv-posix-polyfills.c (handle flags, atomic pending/u.fd busy-counter, spin-wait in uv_close), three extern "C" Rust shims in napi_body.rs (Bun__uv_handle_schedule/_ref/_default_loop), plus a native test addon and 6 subprocess tests. Generated stub files updated to drop the now-implemented symbols.

Security risks

None user-facing. The concern here is memory safety: raw uv_handle_t* stored in a concurrent task queue, cross-thread atomics coordinating with a spin-wait, and an FFI boundary where the C side treats a *mut EventLoop as an opaque uv_loop_t*. My earlier review found a UAF race (dispatch clearing pending before checking CLOSING); that's fixed. The PR description explicitly documents a remaining Worker-teardown gap (#18546) where uv_async_send after Worker::terminate() can reach a freed loop — not a regression since these calls previously panicked at init, but it's a known hole a maintainer should acknowledge.

Level of scrutiny

High. This is exactly the category REVIEW.md flags hardest: hand-written atomics with weakly-justified orderings (relaxed load in uv_async_send's fast path, seq_cst elsewhere), a busy-spin (bun__uv_async_spin) copied from libuv semantics, refcount balancing across four entry points, and native callbacks (async_cb/close_cb) that re-enter JS from a ManagedTask. Previously this whole surface was an immediate panic, so any bug here is net new. The test coverage is good (both loop sources, both send origins, close-while-pending, keepalive) but can't exercise the tight races the spin-wait guards.

Other factors

All three of my prior findings were fixed in follow-up commits and verified against the current diff. The symbols_to_test deletion from uv.test.ts was dead code (only uv_stub.test.ts consumes it). CI build #79860 was still running when I reviewed. Given the concurrency subtlety and the design decision to ship with the Worker-teardown limitation, this warrants a maintainer's eyes rather than a bot approval.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI build #79860: test/napi/uv.test.ts and test/napi/uv_stub.test.ts passed on every lane that built. The reported failures are all classified pre-existing or flaky by ci:errors:

  • step failed outside runner on :darwin: x64 / :linux: aarch64-musl build-bun (pre-existing runner failure, also on main)
  • bun-install-registry.test.ts peer-hoisting on :windows: aarch64 (flaky, passed on retry)
  • run-crash-handler.test.ts reporter timeout on :darwin: aarch64 (flaky, passed on retry)
  • 20144.test.ts SIGKILL/SIGINT on :darwin: 14 aarch64 (flaky, passed on retry)

None touch the napi/uv surface this diff changes.

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

#39652 implements uv_async_t together with uv_queue_work, uv_default_loop and the header-only helpers, with a per-VM loop object in place of the EventLoop pointer. It covers what this PR does, so this one can be closed once that lands.

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #39652, which now carries this work.

#39652 implements the same uv_async_t functions (all 13 symbols this PR un-stubs are in its list), and also uv_queue_work, uv_cancel and the header-only helpers. It uses a per-VM loop object in place of the EventLoop pointer. Its tests cover every case in this PR: both loops, a send from another thread, close with a send pending, the ref and unref keep-alive behavior, and the handle accessors. This branch is also in conflict with main. Nothing here needs to move over.

@robobun robobun closed this Aug 19, 2026
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.

bun: symbol lookup error: undefined symbol: uv_async_init

1 participant