Worker / worker_threads: WebCore-shaped lifetimes, joined threads, one ordered VM teardown - #37075
Merged
Conversation
…e ordered VM teardown Makes a thread's own lifecycle — Web Worker and node:worker_threads — follow the upstream WebKit and Node models instead of ad-hoc guards, so terminating or finishing a worker (including nested workers, and including Windows) tears everything down in a defined order without touching freed state. Lifetime model (WebCore) - ActiveDOMObject and the ScriptExecutionContext registry are real again: Worker, MessagePort, BroadcastChannel and WebSocket get a stop phase (stop()/hasPendingActivity/suspendIfNeeded) that runs before the JSC VM is destroyed; ContextDestructionObserver is refcounted + weak-capable; JSEventListener is fenced by JSVMClientData::willDestroyVM. - ScriptExecutionContext outlives its Zig::GlobalObject and is destroyed after ~VM at a defined point; it addresses the thread's VM directly. - Worker is split into the script-visible object and a WorkerMessagingProxy that owns the parent<->thread relationship (message inboxes, cross-VM requests, close/exit notification), as upstream does. Threads - Worker threads are refcounted and joined by their parent (Node's model) instead of detached; a parent tracks its child workers, asks them to stop in its own stop phase and joins them before its VM goes away, so terminate() propagates through nested workers and resolves only once the thread is gone. The thread returns normally rather than pthread_exit. Teardown - VirtualMachine::teardown(kind) is the single sequence used by a finished worker thread and by main-thread exit under BUN_DESTRUCT_VM_ON_EXIT: A) stop with script allowed (WebCore stop phase; servers, listeners, fs/stat watchers stopped on every exit, not only under --isolate; socket groups; DNS), B) forbid script, cancel timers/immediates, join children, release queued work, C) JSC VM teardown, D) free the thread's uSockets loop and, on Windows, close its libuv loop after every handle has unlinked, E) destroy. - Windows: a worker thread now actually closes its loops. The uSockets loop over the thread's uv loop is freed; the keep-the-loop-polling timer is closed; keep-alive counts routed through the concurrent counter are folded before the loop close; the timer heap's embedded uv_timer/uv_idle are closed after ~VM (JSC's RunLoop timers ride that heap until then); open pipe/tty/process handles are closed through their owners before in-flight requests are drained; the loop close logs any handle an owner left open. - WebSocket keeps one keep-alive mechanism (PendingActivity) and stop() drops the connection without calling back into script; a Worker or WebSocket constructed after its context stopped never starts. Behaviour - worker.terminate() on a running node:worker_threads Worker resolves 1, as in Node; threadId stays valid until the thread has exited. - User close/exit handlers of servers, sockets and listeners run at worker terminate (they are stopped, not dropped). A worker whose child worker is blocked in an uninterruptible native call now waits for it (as Node does). Tests: nested terminate propagation, terminate() exit code, terminating a worker that owns a child process with a pending stdin write.
…ase both event loops' queued work Review follow-up. The list of open pipe/tty/process handles that a thread teardown closes was keyed by the reader/writer that started on a handle, but handles move between owners (spawn stdio -> Subprocess -> PipeReader -> FileReader via from()), can be adopted without start_with_current_pipe (lazy subprocess stdout/stderr, every node:child_process spawn), and some never pass through PipeReader/PipeWriter at all (the IPC channel pipe, named-pipe sockets before adoption). That left the common stdout paths unlisted, a moved-from reader dangling in the list, and IPC writes still able to hold the drain. The list now lives in the libuv wrapper and is keyed by the handle: Pipe::init, tty init (except the process-static stdin tty) and Process::spawn add the handle; UvHandle::close / close_and_destroy remove it; whoever drives it records itself in the entry's owner slot — readers and writers through Source::set_owner (start, start_with_pipe, lazy adopt and from() all pass through set_parent/set_data), IPC SendQueue at configure, WindowsNamedPipe until its writer adopts the pipe, Process at spawn. Teardown closes each open handle through its current owner, or directly if nothing adopted it. One close_without_reporting on the writer trait serves both Windows writers and WindowsNamedPipe. Also: queued tasks and pending immediates are released on both the regular and the macro event loop before the JSC VM and the loops go (a macro can leave work on the macro loop), EventLoop::deinit asserts they are gone, and the one owner that destroys a VM without a teardown (bake's build VM) releases them itself first. The new spawn+terminate test is concurrent like its siblings.
…ompiles out (release)
…keeps its args in release A Subprocess's extra stdio pipes (stdio_pipes: uv pipes with no reader or writer in front of them) are handles the Subprocess owns; it now records that on the open-handle list, so a thread teardown closes them through it and finalize_streams later finds the slots empty. finalize_streams also uses the same is_closing-aware close as WindowsSpawnResult's Drop instead of an unconditional uv_close (a second close of an already-closing pipe asserts inside libuv). The libuv crate's local log macro compiled its arguments out in release, so a binding used only for logging became an unused-variable error there; it is now a constant-false branch in release with the arguments still type-checked, like bun_core::scoped_log!.
Collaborator
|
Updated 5:22 AM PT - Aug 8th, 2026
@dylan-conway, your commit 3ff95aa is building: |
…/drain/join, no "shutdown"/"terminate" for it Names introduced by this branch used teardown/shutdown/terminate/exit/close interchangeably. Now: the event is a VM teardown; what its stop phase asks of an object is stop_for_vm_teardown (WebCore stop() semantics), registries are stop_all_for_vm_teardown / stop_active_handles_for_vm_teardown; "terminate" is reserved for the user's worker.terminate() request; "release" drops queued work unrun; "drain" runs to completion; "join" is for threads; the thread's loops are free_thread_loop (uSockets) / close_thread_loop (libuv). The WebSocket client operation used by WebSocket::stop() is what it does — drop_connection_without_callback — since it is not teardown-specific. C++ that mirrors WebKit keeps WebKit's names.
…g set_pipe (FileSink over a subprocess stdin)
…s it (no producers converted yet) Adds bun_jsc::VmHandle — an Arc-shared, Send+Sync handle that provides the only three things off-thread code legitimately needs from a VM: post a completion to one of its event loops and wake it, ref/unref its keep-alive, and hold a borrow while using memory the VM owns. Access goes through an atomic gate (raise `active`, then observe state); VirtualMachine::teardown marks it Stopping at the start of the stop phase, ScriptForbidden after forbidExecution, and close()s it after child workers are joined — close publishes Closed and waits until no thread is inside an access or borrow, so afterwards nothing off-thread can reach the VM's queues, waker or memory, and a late post is refused (the poster gets its task back to release itself). The same object answers script_allowed() for native code that would enter JS. Every VirtualMachine owns one (handle(), current_loop_kind()). This commit only adds the type and the teardown flip points; producers still use their existing pointers and are converted in the following commits.
…le::post; refused completions release off-thread
The three thread-pool carriers no longer capture BackRef<EventLoop> /
BackRef<VirtualMachine> ("the VM outlives every task"). They hold a VmHandle
and the LoopKind current at creation, deliver their completion with
handle.post(), and when the VM has been torn down meanwhile (Refused) release
the task on the pool thread: the context gets release_off_thread() for its
own portable resources, and the carrier's storage is reclaimed without
running the Drops of its JS-thread-only members (keep-alive on a loop that no
longer counts, promise handle of a heap that is gone). This covers every user
of the carriers — node:fs async ops, node:crypto jobs, zlib/brotli/zstd,
transpiler, glob, image, archive, password hashing, the libc dns.lookup
backend — without per-site changes.
…ref/unref are JS-thread operations
KeepAlive::{ref,unref}_concurrently{,_from_event_loop} resolved the VM
through the EventLoopCtx vtable's raw VirtualMachine pointer — the one
cross-thread pair in an otherwise JS-thread interface. Their only remaining
users were napi_ref/unref_threadsafe_function, which Node documents as
main-thread-only; those now use the ordinary ref/unref. The vtable entries,
their VirtualMachine implementation and the MiniEventLoop stubs are removed.
Cross-thread keep-alive goes through VmHandle::ref/unref_keep_alive.
…Machine* JSVMClientData owns a BunVmHandle* (a boxed clone of the VM's handle, created on the JS thread, released with the client data). Everything C++ does to a VM from another thread goes through it: ScriptExecutionContext's ref/unref of the event loop and postTaskConcurrently, the JSC deferred-work scheduler (DeferredWorkTimer runs on helper threads), EventLoopTaskNoContext for tasks run on the work pool, the debugger connection's keep-alive, MessagePort / BroadcastChannel keep-alive toggles, and the WebView backends. The FFI is Bun__VmHandle__refKeepAlive / Bun__VmHandle__queueTaskConcurrently / Bun__queueJSCDeferredWorkTaskConcurrently(handle, job); a post refused after teardown deletes the C++ task or job unrun on the calling thread. The VirtualMachine*-taking cross-thread entry points are gone.
# Conflicts: # src/jsc/any_task_job.rs # src/jsc/event_loop.rs
The tasklet no longer holds `&'static VirtualMachine`. The three HTTP-thread decisions that used to read the VM's non-atomic is_shutting_down flag — the last-ref drop (deinit must run on the JS thread), the request-body drain notification, and the progress callback — now post their task and act on the outcome: Queued behaves as before; Refused runs the branch that existed for "the JS side will never see this" (reclaim Rust-side boxes only, drop the resume ref, free the scheduled response body / release the parked socket / release both refs). JS-thread paths use the VM through the tasklet's global.
S3HttpSimpleTask and S3HttpDownloadStreamingTask held Option<BackRef<VM>> set at creation and posted from the HTTP thread through it. They now hold a VmHandle + LoopKind; a refused post (VM torn down) releases the finished task on the HTTP thread — the HTTP client's data and header copies (the portable part of their Drop, split out as release_portable) plus owned buffers and storage — without the keep-alive unref that only makes sense on the JS thread. Request setup reads verbose/TLS options from the current VM directly.
…opHandle loses its cross-thread post EventLoopHandle / JsEventLoop / AnyEventLoop no longer offer enqueue_task_concurrent: their interface is JS-thread-only, and js_poster() hands out the thread-safe object other threads use instead. bun_event_loop::JsPoster is an erased VmHandle clone (vtable filled by bun_jsc) for crates that cannot name VmHandle; bun_jsc::ConcurrentPoster is the typed form for producers that serve either a JS VM or a MiniEventLoop (mini loops are owned by their thread and keep direct posting). Converted: node:fs AsyncFSTask (direct thread-pool ops) and fs.cp, spawn's waiter thread → Process exit delivery (frees the result and drops the process ref if the VM is gone), the bundler's parse/server- component/plugin/defer hops back to an owning JS loop, JSBundler's plugin notify, and the shell tasks (interpreter/rm/Async/yes). ThreadSafe<T> can release its storage without unprotecting through a dead heap; ConcurrentTask::release_refused frees a refused heap task. VirtualMachine::destroy drops the VM's own handle clone (fixes a 48-byte-per-worker leak of the handle's shared state). BUN_DESTRUCT_VM_ON_EXIT defaults to on while this branch is in development so every process exit runs the shared teardown; a TODO marks flipping it back before merge.
… jobs (under borrow), install wake-ups, hot reload, Archive, readdir
- fs.watch (path-watcher thread) and fs.watchFile (pool/timer) post through a handle captured at
creation; refused batches free themselves and drop their activity ref. FSWatcher's
`vm() -> &'static mut VirtualMachine` accessor is gone.
- RuntimeTranspilerStore: a TranspilerJob lives inside the VM and reads VM state while it runs on
the pool, so the whole run happens under VmHandle::borrow() — the VM's teardown waits for it — and
the completion posts through the handle. First user of the borrow guard.
- The resolver's PackageManager wake handler gets a per-VM WakeContext (module queue + handle)
instead of recovering the whole VirtualMachine from a field pointer on install/HTTP threads.
- Hot reloader: the watcher thread posts reload tasks through a handle captured at init; the
HotReloaderEventLoop shim trait and event_loop()/event_loop_ref() on HotReloaderCtx are removed.
- Bun.Archive tasks and recursive readdir complete through the handle with off-thread release.
- VirtualMachine::enqueue_task_concurrent(&mut self) ("from another thread") is deleted.
…on-JS threads; free the wake context per VM - ConcurrentPoster::Js now carries the JS loop's own erased poster (EventLoopHandle::Js → JsEventLoop::js_poster) instead of looking up "the current VM" through TLS, which panicked when a shell task was constructed on a thread without a VM (ls/mv/shell tests). Independent of teardown. - RuntimeState owns the resolver's WakeContext, so it (and the handle it holds) is freed at VM teardown instead of leaking 24+48 bytes per worker. - Converted to post through a handle captured on the JS thread, with off-thread release on refusal: Bun.password jobs, the Windows memory-pressure thread (takes a VmHandle, not a VM address), zlib/brotli/zstd async writes, napi_async_work, napi threadsafe-function dispatch, napi finalizers fired off the JS thread (NapiEnv owns its own handle clone: NapiEnv__vmHandle), Windows Bun.write / copyFile mkdirp completions, Bun.build's completion task and plugin dispatch, and the dev server's file-watcher hot-reload event.
With every producer converted, EventLoop::enqueue_task_concurrent / ref_concurrently / unref_concurrently had no callers left and are deleted: another thread reaches a VM's queues, waker and keep-alive count only through VmHandle (or the erased JsPoster below bun_jsc), which the VM's teardown closes. The debug-only off-thread read of vm.has_terminated inside enqueue_task_concurrent goes with it. The Windows Bun.write / copyFile / readFile paths that adjusted the loop's keep-alive from the JS thread use the honestly named EventLoop::ref_keep_alive / unref_keep_alive.
…) (Node's can_call_into_js)
EventLoop::run_callback / run_callback_with_result / …_and_forcefully_drain_microtasks — the funnel
for native code calling user JS from outside the task queue — refuse the call once teardown has
forbidden script (in addition to the existing pending-exception check).
The ~60 ad-hoc `if vm.is_shutting_down() { return }` guards in front of user callbacks and promise
settlements (TCP/TLS sockets, listeners and SNI/ALPN callbacks, upgraded duplexes, ServerWebSocket
handlers, the server's all-connections-closed promise, MySQL/PostgreSQL connections and queries,
Valkey, HTTP/2 frame dispatch, Archive tasks, fetch progress, thread-pool job completions, stream
cancel signals) now ask script_allowed() instead. Those guards flipped the moment teardown began,
so in an exiting or terminated worker close/error handlers and pending query/promise settlements
were skipped; they now run during the stop phase and are suppressed only after execution is
forbidden. The remaining is_shutting_down() reads are literal "has teardown begun" ordering
decisions (who frees a server/sink/finalizer payload), not JS-entry checks.
VirtualMachine::on_exit can be entered with an exception pending — process.exit() from inside a throwing or catching callback (an ordinary exception), or after worker.terminate() / a termination request. It now settles that up front: an ordinary pending exception is cleared before 'exit' handlers run (as Node's EmitProcessExit does under a TryCatch); if a termination is pending, script is forbidden for the whole exit sequence, so 'beforeExit'/'exit' handlers and the stop phase's close events are skipped instead of entering JSC with a termination exception pending (the `!exception()` / continueDynamicImport assertion family). process 'exit'/'beforeExit' dispatch, which does not go through run_callback, consults the same gate. Adds VM::has_termination_request().
This was referenced Aug 13, 2026
Closed
dylan-conway
added a commit
that referenced
this pull request
Aug 14, 2026
### Problem - Regression from #37075: a `node:vm` `timeout` or `worker.terminate()` is lost when it lands while an addon is inside one of the ungated N-API functions. Release builds keep running the script forever (`vm.runInNewContext(...)` never returns, `await worker.terminate()` never resolves); debug builds abort on the next exception check with `ASSERTION FAILED: !!(scope).exception() == vm.traps().needHandling(JSC::VMTraps::NeedExceptionHandling)`, or in `napi_get_value_bigint_int64` with `ASSERTION FAILED: Unexpected exception observed` from `NAPI_RETURN_SUCCESS`. - Cause: #37075 rewrote `NAPI_PREAMBLE_NO_PENDING_CHECK` (`src/jsc/bindings/napi.cpp`) as an unconditional `JSC::SuspendExceptionScope`. That scope writes the `vm.exception` it saw on entry back when it exits. On entry nothing is pending; an exception check inside the body services the pending trap and raises the `TerminationException`; on exit the scope puts `null` back. The trap was consumed, so nothing raises it again. Of the 38 C++ users of the macro, the bodies with such a check are `napi_get_value_string_*`, `napi_get_value_bigint_int64/uint64`, `napi_create_bigint_int64/uint64` and `napi_create_symbol` with a description. - Two related gaps in the same set of functions, fixed along the way: before #37075 (and in the first version of this PR) the C++ macro checked for exceptions on entry, so a waiting termination made the ungated call itself fail with `napi_pending_exception`; and the ungated functions implemented in `src/runtime/napi/napi_body.rs` (`napi_create_array*`, `napi_create_string_*`, `napi_create_int32/uint32/int64`, `napi_get_undefined/null/boolean`, `napi_is_*`, `napi_get_*_info`, handle scopes) had no handling at all: `napi_create_array` delivered a termination itself and failed, and the ones using `JsResult` helpers failed (release) or asserted (debug) with any exception pending on the VM. Node's `CHECK_ENV`-only functions never fail for either reason, and node-addon-api aborts the process (`Error::ThrowAsJavaScriptException napi_throw`) when one of them does; a node-addon-api callback making only ungated calls hit that under a `vm` timeout on Bun and not on Node. ### Fix - `NapiUngatedScope` (napi.cpp) is what every ungated function now runs under: (a) it stashes the entry exception only when one is actually pending (`std::optional<JSC::SuspendExceptionScope>`), so a clean VM is left alone and whatever the body raises stays pending, and (b) it holds a `JSC::DeferTraps`, so no exception check inside the body (its own or in the JSC helpers it calls) services VM traps. A termination requested before or during the call stays a request and is delivered by the next check after the call returns (the caller's `RETURN_IF_EXCEPTION` in `NapiClass`, a JS loop check, or a gated N-API call). Nothing is lost, and the ungated calls never report a termination, as in Node. - `NAPI_PREAMBLE_NO_PENDING_CHECK` declares one; the 26 Rust functions construct the same C++ object in place through `NapiUngatedScope__construct/__destruct` from an `ungated!` macro that replaces `get_env!` (80 bytes of 8-aligned storage, checked by a `static_assert`; a guard destroys it on every return path). This also covers what #36091 and #36093 were addressing separately (`napi_create_string_*` and the `*_info` accessors with a VM exception pending). - Consequence of (b): no JS or addon code may run under the scope. The zero-length path of `node_api_create_external_string_{latin1,utf16}` ran the addon's finalizer there; the two functions now share one body (`createExternalString`) and run the finalizer after its scopes have closed. The other users only read, allocate, or register callbacks. - `napi_get_value_bigint_int64` checks for an exception after its conversion like the `uint64` variant already did. - Tests, in the `pending-exception gate` block of `test/napi/napi.test.ts` (addon side in `test/napi/napi-app/standalone_tests.cpp`, drivers in `module.js` and `ungated-calls-spin-worker.js`), all compared against Node's output; each round of ungated calls covers both the C++ and the Rust half: - a `node:vm` `timeout` on a script looping through the ungated functions: hangs without the fix (10/10 runs on a release build), asserts on a debug build - `worker.terminate()` of a worker doing the same: same failure modes - the ungated functions succeed with an exception pending on the VM and leave it pending - 200ms of ungated calls under a 20ms timeout with an exception pending: no call reports the timeout, the pending exception is still the original one afterwards, and the script still times out on return - Verified on a debug+ASAN build at 12d4d5c: the four tests pass, the rest of `test/napi/napi.test.ts` is unchanged, and the upstream `test_string`, `test_array`, `test_typedarray`, `test_dataview`, `test_handle_scope`, `test_promise`, `test_date`, `test_error`, `test_exception`, `test_number`, `test_conversions`, `2_function_arguments`, `test_buffer`, `test_worker_terminate` suites pass (a few GC-heavy files exceed the 5s harness timeout under a local debug build and pass when run directly; same without this change). A node-addon-api wrapped function looping under `vm` timeouts, which aborted on every run of 20 with the entry check, survives 60/60 as on Node. Supersedes #36091 and #36093 (per-function `SuspendExceptionScope` wrappers for `napi_create_string_*` and the `*_info` accessors); both are covered by `ungated!` here. ### Background - Ungated functions: Node implements pure value constructors/accessors (`napi_create_object`, `napi_get_cb_info`, `napi_get_value_*`, references, ...) with `CHECK_ENV` only, so an addon may call them while an exception is pending, and they never fail because execution is being terminated. node-addon-api relies on both. In Bun these are split between `napi.cpp` (`NAPI_PREAMBLE_NO_PENDING_CHECK`) and `napi_body.rs` (`ungated!`); `NAPI_PREAMBLE` / `preamble!` are the gated versions that refuse while an exception is pending. - VM traps: JSC delivers asynchronous requests (a watchdog timeout, which `node:vm`'s `timeout` uses, or a termination request, which `worker.terminate()` uses) by setting a trap bit. The next exception check that services traps (`RETURN_IF_EXCEPTION`, which most JSC entry points and Bun's `NAPI_RETURN_IF_VM_EXCEPTION` expand to) consumes the bit and throws the `TerminationException`, which is uncatchable from JS and is what unwinds the running script. Once thrown, it is the only record of the request. - `JSC::DeferTraps` makes trap servicing a no-op for its scope: checks inside it see no exception and the bits stay set, so the first check after the scope delivers the request. `JSC::SuspendExceptionScope` clears `vm.exception` on construction and writes the saved value back unconditionally on destruction. <details> <summary>Earlier shape of this PR (3b36256 to 63653e1)</summary> The first version kept the pre-#37075 entry check in the C++ macro (so a waiting termination was delivered by, and reported from, the ungated call) and wrapped the conditional suspend in a small scope object whose destructor re-raised a termination the body had delivered after putting the entry exception back. That fixed the hang but kept the Bun-only node-addon-api failure mode described under Problem, so it was replaced by the `DeferTraps` form (f599d79), which dbc32b6 then extended to the Rust-side functions. The Bun-only test that pinned the re-raise became the same-output "200ms of ungated calls under a 20ms timeout" test. </details> --------- Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
This was referenced Aug 14, 2026
Jarred-Sumner
pushed a commit
that referenced
this pull request
Aug 14, 2026
… and C++ bindings (#37332) Scheduled dead-code sweep. Areas this run: `src/libuv_sys`, `src/cares_sys`, `src/brotli_sys`, `src/simdutf_sys` (both sides of the FFI), `src/runtime/test_runner`, a few `bun_runtime` leftovers, and C++ bindings outside the files the other open dead-code PRs touch. Net: 43 files, +218 (the new lint) / -1723. ### Removed **`bun_libuv_sys` (Windows-only crate, -619)** - 169 `extern "C"` declarations that no Rust code references: the tcp/udp/tty/poll/prepare/check/fs_poll/threading/dl/os-info/metrics surface of `uv.h` (`uv_tcp_*`, `uv_udp_*`, `uv_tty_get_winsize`, `uv_prepare_*`, `uv_check_*`, `uv_fs_poll_*`, `uv_thread_*`, `uv_key_*`, `uv_once`, `uv_os_get_passwd`, `uv_os_environ`, `uv_queue_work`, `uv_getnameinfo`, `uv_random`, `uv_async_init`, `uv_dlopen`, ...). The C symbols themselves stay exported for napi addons via `symbols.def`/`symbols.dyn`/`linker*.lds` and the force-link list in `napi_body.rs`; only bun's own unused Rust declarations go. - Structs and callback aliases only those declarations used: `uv_getnameinfo_t`, `uv_random_t`, `uv_timespec64_t`, `uv_timeval64_t`, `uv_dir_t`, `uv_dirent_t`, `uv_env_item_t`, `uv_passwd_t`, `uv_group_t`, `uv_metrics_t`, `uv_key_t`, `uv_once_t`, `uv_thread_options_t`, `uv_lib_t`, `uv_getnameinfo_cb`, `uv_random_cb`, `uv_fs_poll_cb`, `uv_thread_cb`, plus the `uv_sem_t`/`uv_errno_t`/`uv_handle_s`/`uv_loop_s`/`uv_run_mode`/`uv_pipe_t`/`uv_fs_s`/`struct_uv_req_s`/`struct_uv_stream_s`/`uv_dirent_type_t`/`FILE`/`uv_thread_t`/`uv_loop_option`/`uv_membership`/`uv_tty_vtermstate_t`/`uv_clock_id` aliases. - Wrappers nobody called: the `UvReq` marker trait and its 10 impls, `Loop::dump_active_handles`, `Pipe::set_pending_instances_count`, `Pipe::as_stream_ptr`, `uv_async_t::init`, `uv_stat_t::birthtime`. - Alias tables nothing read: `StdioFlags` (the three `StdioFlags::INHERIT_FD` uses in `bun_spawn` now spell `UV_INHERIT_FD` like the rest of that function), `UV_FS_O_*` (duplicates of the `O` module), `UV_PRIORITY_*`, `UV_CLOCK_*`, `UV_LOOP_BLOCK_SIGNAL`/`UV_METRICS_IDLE_TIME`, the udp/tty-mode/membership/copyfile flag groups, `UV_MAXHOSTNAMESIZE`, `UV_IF_NAMESIZE`, `MAX_PIPENAME_LEN`. **`bun_cares_sys` (-118)**: `ares_library_init`, `ares_version`, `ares_init`, `ares_set_socket_functions`, `ares_send`, `ares_search`, `ares_gethostbyname`, `ares_getsock`, `ares_timeout`, `ares_create_query`, `ares_expand_name`, `ares_expand_string`, `ares_parse_uri_reply`, `ares_free_string`, the `ares_socket_functions` and `struct_ares_uri_reply` structs, the `ares_ssize_t`/`struct_timeval` aliases, two commented-out `ares_fds`/`ares_process` declarations, and the Windows `timeval`/`iovec` definitions in `lib.rs` that existed only for them. **`bun_brotli_sys`**: `BrotliDecoder::{is_finished, get_error_code, version}` and the `BrotliDecoderIsFinished`/`BrotliDecoderVersion` declarations (callers use `BrotliDecoderGetErrorCode` directly). **simdutf FFI (-230)**: 22 unused declarations in `simdutf.rs` and 30 wrappers in `bun-simdutf.cpp` (the 22 plus 8 that had already lost their Rust declaration), and their stubs in the parser bench shim. The lint asserts the two files now declare exactly the same set of `simdutf__*` names. **`bun_runtime`** - `test_runner::expect::JSValueTestExt`: 17 forwarder methods (`to_fmt`, `jest_deep_equals`, `values`, `keys`, `to_u32`, ...) that `bun_jsc::JSValue` inherent methods of the same name shadow, so no call site ever resolved to them; rustc reports them unused once the trait is scoped to the crate. The four methods that do add behavior stay. - Never-read fields: `CustomMatcherParamsFormatter::global_this` (and its now-unused lifetime), `SuccessfulReturnsFormatter::global_this`, `FetchOptions::global_this`. - `S3ErrorJsc::to_js` (every caller uses the `s3_error_to_js` free fn). **C++ bindings (roughly -590)** - `Events_functionGetEventListeners` / `ListenerCount` / `Once` / `On` in `JSEventEmitter.cpp` (declared in the header, never installed anywhere) and `jsEventEmitterCast` + `JSEventEmitterWrapper`, whose only callers they were. `jsEventEmitterCastFast` is untouched. - `jsFunctionDebugNoop`, `jsFunctionSyncBuiltinExports` (`NodeModuleModule.cpp`; the live export is `jsFunctionSyncBuiltinESMExports`). - `jsCookieStaticFunctionSerialize` (never attached to the constructor; only `parse`/`from` are) plus its helpers `toCookieWrapped` and `Cookie::serialize(VM&, span<Ref<Cookie>>)`. The prototype `serialize()` is untouched. - Nine `JSC_DECLARE_CUSTOM_GETTER(jsBakeResponsePrototypeGet*)` declarations with no definition. - `UTF8Encoding()`, `createMockResultStructure` (the `LazyProperty` initializer inlines the same code), `ActiveDOMObject::queueTaskToDispatchEvent` and the `queueTaskToDispatchEventInternal` / `isAllowedToRunScript` / `PendingActivity::object` helpers that became unreferenced with it, `StreamQueue::setTotalSize`, `WorkerMessagingProxy::{askedToTerminate, loaderContextIdentifier, workerThread}` getters, 19 `m_subspaceFor*` / `m_clientSubspaceFor*` slots no class allocates from, the `commonStringInitializer` typedef, the `BUN_FOREACH_CJS_NATIVE_MODULE` macro, a `DocumentLoadTiming` forward declaration. - The five cross-realm (transferable stream) stubs in `CrossRealmTransform.cpp` / `WebStreamsInternals.h`, which only threw "not implemented" and had no callers, plus `CrossRealmMessageType`. `WebStreamsInternals.h` described these signatures as frozen placeholders from the streams port; nothing reaches them, so they are deleted here, but this is the one group in the PR that is a judgment call rather than a leftover, so it is easy to drop if you would rather keep the placeholders. - `EventInterfaces.h` / `EventTargetInterfaces.h`: checked-in copies of WebCore's generated enums listing every event and event-target interface in WebKit. Trimmed to the 5 + 9 entries bun's `Event` / `EventTarget` subclasses report; survivors keep their numeric values (`Event::m_eventInterface` is a 7-bit field, and `EventFactory.cpp` / `EventTargetFactory.cpp` both have `default:` arms). ### Verification - Each symbol: `rg` across `src/`, `scripts/`, `test/`, `packages/` and the regenerated `build/debug/codegen/` output (plus substring searches for token-pasted names and `vendor/WebKit/Source` for anything `extern "C"`). - Rust candidates came from scoping same-crate-only `pub` items to `pub(crate)` and intersecting rustc's `dead_code` output across all 8 target families, so platform-specific items (e.g. `EmptyCopyFileState` on darwin, `Listener::NamedPipe` on Windows) were excluded automatically; `bun_libuv_sys` was evaluated on the Windows targets since the module is `cfg(windows)`. - `cargo check --workspace` on all 10 CI triples, `cargo check --workspace --tests`, `cargo fmt --check`, clippy on the touched crates, full `bun bd`, and `bun bd test` over events, cookies, node:module, TextEncoder/TextDecoder, expect.extend, toHaveReturnedWith, streams, fetch, zlib/brotli, mock, and `test/internal/source-lints/` (the dns file's 30 network-dependent tests fail in the sandbox with ENOTFOUND both before and after; its 92 offline tests pass). - `test/internal/source-lints/dead-symbols-ffi-sys-test-runner-cpp.test.ts` pins 67 of the removed symbols (every entry was checked to match on `main` and not on this branch). ### Notes for whoever merges - #32268 adds `RETURN_IF_EXCEPTION` lines inside the `Events_function*` bodies deleted here (its actual fix is in `jsEventEmitterCastFast`, which this PR does not touch); whichever lands second needs a trivial rebase that drops those hunks. - Found dead but deliberately left alone: `VM::has_termination_request`, `JSPromise::settle_task`, `job.rs` `on_js_thread` / `off_thread` (all added two days ago in #37075, presumably for follow-ups); `JSC__VM__hasTerminationRequest` goes with them. Single unconstructed variants / unread payloads inside otherwise-live ABI or ported enums (`SSRKind::Regular`, `DeclarationContext::Keyframes`, `StmtListWhich::AllStmts`, `OptionsData::Saved(usize)`, `AsyncState::Done(ExitCode)`) and the remaining partially-used libuv constant tables were also left as-is. rustc's "field is never read" hits on `multi_array_columns!` schema structs (`JSMeta`, `BuilderEntry`, `LineOffsetTable`, `ServerComponentBoundary`, the isolated-install `Entry`, `BundledAst::tla_check`) and on the type-punned `SerializedSourceMapLoaded` are live data, not dead code. <!-- robobun:evidence:begin --> --- **[review]** gate passed · iteration 0 · 43 files touched <details><summary>fails on main (without fix)</summary> ```console ASAN without fix: 6 FAILED $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/internal/source-lints/dead-symbols-ffi-sys-test-runner-cpp.test.ts bun test v1.4.0 (3f65303) test/internal/source-lints/dead-symbols-ffi-sys-test-runner-cpp.test.ts: 59 | ["src/libuv_sys/libuv.rs", /\bUV_FS_O_APPEND\b/], 60 | ["src/libuv_sys/libuv.rs", /\bUV_PRIORITY_LOW\b/], 61 | ["src/libuv_sys/libuv.rs", /\bUV_CLOCK_MONOTONIC\b/], 62 | ["src/spawn/process.rs", /\bStdioFlags\b/], 63 | ]), 64 | ).toEqual([]); ^ error: expect(received).toEqual(expected) - [] + [ + "src/libuv_sys/libuv.rs: \bfn uv_tcp_connect\b", + "src/libuv_sys/libuv.rs: \bfn uv_udp_recv_start\b", + "src/libuv_sys/libuv.rs: \bfn uv_tty_get_winsize\b", + "src/libuv_sys/libuv.rs: \bfn uv_prepare_init\b", + "src/libuv_sys/libuv.rs: \bfn uv_fs_poll_start\b", + "src/libuv_sys/libuv.rs: \bfn uv_thread_create\b", + "src/libuv_sys/libuv.rs: \bfn uv_os_get_passwd\b", + "src/libuv_sys/libuv.rs: \bfn uv_queue_work\b", + "src/libuv_sys/libuv.rs: \bfn uv_async_init\b", + "src/libuv_sys/libuv.rs: \bfn uv_dlopen\b", ... (truncated) release without fix: 6 FAILED bun test v1.4.0-canary.1 (8db13c3) test/internal/source-lints/dead-symbols-ffi-sys-test-runner-cpp.test.ts: 59 | ["src/libuv_sys/libuv.rs", /\bUV_FS_O_APPEND\b/], 60 | ["src/libuv_sys/libuv.rs", /\bUV_PRIORITY_LOW\b/], 61 | ["src/libuv_sys/libuv.rs", /\bUV_CLOCK_MONOTONIC\b/], 62 | ["src/spawn/process.rs", /\bStdioFlags\b/], 63 | ]), 64 | ).toEqual([]); ^ error: expect(received).toEqual(expected) - [] + [ + "src/libuv_sys/libuv.rs: \bfn uv_tcp_connect\b", + "src/libuv_sys/libuv.rs: \bfn uv_udp_recv_start\b", + "src/libuv_sys/libuv.rs: \bfn uv_tty_get_winsize\b", + "src/libuv_sys/libuv.rs: \bfn uv_prepare_init\b", + "src/libuv_sys/libuv.rs: \bfn uv_fs_poll_start\b", + "src/libuv_sys/libuv.rs: \bfn uv_thread_create\b", + "src/libuv_sys/libuv.rs: \bfn uv_os_get_passwd\b", + "src/libuv_sys/libuv.rs: \bfn uv_queue_work\b", + "src/libuv_sys/libuv.rs: \bfn uv_async_init\b", + "src/libuv_sys/libuv.rs: \bfn uv_dlopen\b", + "src/libuv_sys/libuv.rs: \bstruct uv_passwd_t\b", + "src/libuv_sys/libuv.rs: \bstruct uv_getnameinfo_t\b", + "src/libuv_sys/libuv.rs: \bstruct uv_thread_options_t\b", + "src/libuv_sys/l ... (truncated) ``` </details> <details><summary>passes on PR (with fix)</summary> ```console ASAN with fix: all passed $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/internal/source-lints/dead-symbols-ffi-sys-test-runner-cpp.test.ts bun test v1.4.0 (3f65303) test/internal/source-lints/dead-symbols-ffi-sys-test-runner-cpp.test.ts: (pass) dead libuv FFI declarations (windows-only crate) do not reappear [64.93ms] (pass) dead c-ares and brotli FFI declarations do not reappear [19.79ms] (pass) dead simdutf wrappers stay removed on both sides of the FFI [35.67ms] (pass) dead bun_runtime helpers do not reappear [14.26ms] (pass) dead C++ bindings do not reappear [28.17ms] (pass) WebCore event interface enums stay trimmed to the interfaces bun implements [9.48ms] 6 pass 0 fail 11 expect() calls Ran 6 tests across 1 file. [2.71s] __F:0:S:0 release with fix: all passed $ bun scripts/build.ts --profile=release [configured] bun-profile → bun (stripped) in 883ms (unchanged) ninja: Entering directory `/workspace/bun/build/release' [1/126] gen NodeModuleModule.lut.h Generating /workspace/bun/build/release/codegen/NodeModuleModule.lut.h from /workspace/bun/src/jsc/modules/NodeModuleModule.cpp [2/126] gen generated_host_exports.rs generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited [3/126] gen cpp.rs (cppbind) [3/126] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu) nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19) �[1m�[92m Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core) �[1m�[92m Compiling�[0m bun_simdutf_sys v0.0.0 (/workspace/bun/src/simdutf_sys) �[1m�[92m Compiling�[0m bun_libuv_sys v0.0.0 (/workspace/bun/src/libuv_sys) �[1m�[92m Compiling�[0m bun_brotli_sys v0.0.0 (/workspace/bun/src/brotli_sys) �[1m�[92m Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno) �[1m�[92m Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr) �[1m�[92m Compiling�[0m bun_boringssl_sys v0.0.0 (/works ... (truncated) ``` </details> <details><summary>diff hotspot</summary> ``` src/brotli_sys/brotli_c.rs | 14 - src/cares_sys/c_ares.rs | 110 +--- src/cares_sys/lib.rs | 16 +- src/jsc/bindings/BunHttp2CommonStrings.h | 2 - src/jsc/bindings/Cookie.cpp | 19 - src/jsc/bindings/Cookie.h | 2 - src/jsc/bindings/DOMFormData.h | 1 - src/jsc/bindings/JSBakeResponse.cpp | 10 - src/jsc/bindings/JSMockFunction.cpp | 23 - src/jsc/bindings/TextEncoding.cpp | 8 - src/jsc/bindings/TextEncoding.h | 2 - src/jsc/bindings/webcore/ActiveDOMObject.cpp | 21 - src/jsc/bindings/webcore/ActiveDOMObject.h | 13 - src/jsc/bindings/webcore/DOMClientIsoSubspaces.h | 10 - src/jsc/bindings/webcore/DOMIsoSubspaces.h | 11 - src/jsc/bindings/webcore/EventInterfaces.h | 127 ---- src/jsc/bindings/webcore/EventTargetInterfaces.h | 104 ---- src/jsc/bindings/webcore/JSCookie.cpp | 36 -- src/jsc/bindings/webcore/JSEventEmitter.cpp | 91 --- src/jsc/bindings/webcore/JSEventEmitter.h | 5 - src/jsc/bindings/webcore/JSEventEmitterCustom.cpp | 9 - src/jsc/bindings/webcore/JSEventEmitterCustom.h | 21 - src/jsc/bindings/webcore/Performance.h | 1 - src/jsc/bindings/webcore/WorkerMessagingProxy.h | 3 - .../webcore/streams/CrossRealmTransform.cpp | 48 +- .../streams/JSWritableStreamDefaultController.cpp | 2 +- src/jsc/bindings/webcore/streams/StreamQueue.h | 1 - src/jsc/bindings/webcore/streams/StreamsForward.h | 7 - .../bindings/webcore/streams/WebStreamsInternals.h | 15 - src/jsc/modules/NativeModuleList.h | 3 - src/jsc/modules/NodeModuleModule.cpp | 16 - src/libuv_sys/libuv.rs | 641 +-------------------- src/parsers/benches/support/s ... (truncated) ``` </details> **gate history** · 4 passed · 0 rejected · iteration 0 <details><summary>evidence per changed file</summary> ``` file reads edits tests src/brotli_sys/brotli_c.rs 1 2 0 src/cares_sys/c_ares.rs 5 10 0 src/cares_sys/lib.rs 1 2 0 src/jsc/bindings/BunHttp2CommonStrings.h 0 0 0 src/jsc/bindings/Cookie.cpp 0 0 0 src/jsc/bindings/Cookie.h 0 0 0 src/jsc/bindings/DOMFormData.h 0 0 0 src/jsc/bindings/JSBakeResponse.cpp 0 0 0 src/jsc/bindings/JSMockFunction.cpp 0 0 0 src/jsc/bindings/TextEncoding.cpp 0 0 0 src/jsc/bindings/TextEncoding.h 0 0 0 src/jsc/bindings/webcore/ActiveDOMObject.cpp 0 0 0 src/jsc/bindings/webcore/ActiveDOMObject.h 0 0 0 src/jsc/bindings/webcore/DOMClientIsoSubspaces.h 0 0 0 src/jsc/bindings/webcore/DOMIsoSubspaces.h 0 0 0 src/jsc/bindings/webcore/EventInterfaces.h 1 0 0 (+ 27 more files) ``` </details> <!-- robobun:evidence:end --> --------- Co-authored-by: Alistair Smith <hi@alistair.sh>
This was referenced Aug 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes
Worker/node:worker_threadsstable rather than experimental: every crash, use-after-free, assertion, leak and hang class around a VM's lifetime and teardown, on all platforms. Missingworker_threadsAPI surface (resourceLimits,trackUnmanagedFds,moveMessagePortToContext, …) is out of scope.Lifetime model
ActiveDOMObject/ScriptExecutionContextregistry is restored, soWorker,MessagePort,BroadcastChannelandWebSocketare stopped in a real stop phase before the JSC VM is destroyed instead of from inside~VM.Workeris split, as upstream, into the script object and aWorkerMessagingProxythat owns the parent↔thread relationship.terminate()propagates through nested workers and resolves only once the thread is gone. Nopthread_exit.VirtualMachine::teardown()is the one ordered sequence for a finished worker and for main-thread exit: exit handlers run, then script is forbidden and everything the VM owns is stopped natively (WebCore objects, servers, listeners, watchers, sockets, dns, sqlite, in-flightfetch/S3 requests,Bun.buildpasses waiting on this VM's plugins — as in Node, no'close'/'error'handler runs after'exit') → timers cancelled, children joined, in-flight off-thread work waited for or released, VM handle closed, queued work released → JSC VM destroyed → loops freed (uSockets; libuv on Windows) → destroy.dns.lookup, shell builtins,Bun.Archive, password hashing), the HTTP thread (fetch, S3), the bundle thread, child-process waiter, fs watcher threads, napi async work / threadsafe functions, JSC helper threads — reaches a VM only through a per-VMVmHandlethat teardown closes. Pool work is one typed carrier (bun_jsc::Job) whose JS-affine half only the owning thread can touch and teardown releases; work whose storage lives in JS objects or on another thread is counted and waited for; work that can block on an external party is registered so the stop phase aborts it. A late completion is refused and released by its producer instead of touching a dead VM.EventLoophas no cross-thread entry points any more.terminate(), its ownprocess.exit(), or an uncaught error — not when its thread gets around to tearing down (Node'scan_call_into_js/is_stopping). Every native→JS entry consults it (timer and immediate callbacks, event listeners, socket/server callbacks, pool-job completions, JSC deferred work, N-API), so nothing dispatches into a worker that is being stopped, whichever event source it came from. Promise settlement is the one native→promise boundary and never accepts an empty value: a JS conversion that a termination interrupted becomes "reject with the pending exception", which itself yields to the termination.Behaviour changes (Node parity)
parentPortis a realMessagePort:parentPort.close()ends the worker,.ref()/.unref()work,receiveMessageOnPortreturns falsy messages, and parent messages are delivered only after the worker's entry module has run (a preload's un-awaitedimport()does not count as the entry running).await worker.terminate()resolves the exit code (1for a running worker);threadIdstays valid until exit; everything a worker posted before it exited is delivered before'exit'/'close';postMessage()to a terminated worker is a no-op rather than an error; a rejection that is only a consequence ofterminate()(a lookup or request cancelled by the stop) is not reported as the worker's'error'.process.exit()/ worker exit no longer runs microtasks ornextTicks queued before it. A worker's ownprocess.exit()or uncaught error runs its'exit'handlers; a parentterminate()does not.process.exit()from inside (nested)node:vmcontexts in a worker unwinds like any exception, and anode:vmtimeoutinside a worker no longer leaves the worker unable to run script afterwards.process.send()of their own over the process's channel fd.Errorfor a call a termination interrupted instead of aborting the process.port.onmessagereleases the keep-alive a handler took.dns.Resolvers are closed by the exiting VM rather than left to GC finalizers; sqlite connections a VM opened are checkpointed and closed by that VM's exit; aBun.buildwhose VM goes away mid-build is cancelled (its plugin requests failed, the pass finished) rather than abandoned or waited on, and one still queued behind other builds is released without waiting for them.Bun.connect,net,WebSocketto a hostname) is process-wide and outlives the thread that happened to issue it: on macOS a worker exiting mid-lookup no longer answers every other thread's coalesced waiters with an error (and caches it for the TTL).napi_create_external_{arraybuffer,buffer}), as Node's environment teardown finalizes every remaining reference.port.close()insidesetImmediate) is noticed before the loop parks.Two of the crashes were in JavaScriptCore rather than Bun: a
TerminationExceptionraised while the module loader resolves an import continued intofinishLoadingImportedModule(fixed in oven-sh/WebKit#391, picked up by the WebKit version bump here). A worker parked inAtomics.waitwith no timeout still cannot be terminated (#32802); that needs a JSC change and is tracked separately.Testing
New tests accompany each behaviour fix (worker_threads, Web Worker lifecycle edges —
terminate()at every phase of dns/fs/build/vm/napi/http work, message ordering and flooding, process exit ordering, sqlite). Seven more upstreamtest-worker-*files are vendored (one of them, the message-port infinite-message-loop test, passes only with these changes) and previously todo/skipped worker-related napi and regression cases run again. LeakSanitizer validation is turned back on for the ~70 worker / MessagePort / BroadcastChannel test files that were exempt. A source lint rejects laundering aJsResult<JSValue>into an emptyJSValue. Main-threadprocess.exit()keeps its current fast path by default; the full main-thread teardown stays behindBUN_DESTRUCT_VM_ON_EXIT=1. Workers always tear down fully.Known / not in this PR
Atomics.waitwith no timeout still cannot be terminated (worker: make termination interrupt a worker blocked in Atomics.wait #32802; needs a JSC change).worker_threadsmessage throughput through the realMessagePortis ~0.8× the previous ad-hoc path in a flood microbenchmark (round-trip latency and WebWorkermessaging are unchanged); a follow-up, not a behaviour regression.localhostcan leave one connect stuck (pre-existing; reproduces on current releases; DNS-coalescing on the connect path).Workerstart semantics for a never-settling top-level await, file-stream fairness on a saturated loop, and a few diagnostics-only items found while fuzzing are tracked separately.Fixes #31281
Fixes #30421
Fixes #15964
Fixes #29173
Fixes #34690
Fixes #31880
Fixes #33936
Fixes #32073
Fixes #33313
Fixes #32828
Fixes #11760
Fixes #26501
Fixes #18661
Fixes #15408
Fixes #23102
Fixes #21101
Fixes #13570
Fixes #31224
Fixes #28643
Fixes #37163
Fixes #25860
Likely also addressed (mechanism matches, not verified end-to-end): #34095; #22376 and the other emscripten-pthread reports (#25454, #19453, #29211, #29635) whose glue installs both a
parentPortlistener andself.onmessage— the double delivery behind them (#25860) is fixed, the packages themselves were not run; and theparentPort.on('message').unref()hang half of #32609 (itsWorker.performancehalf is API surface, not addressed here).