Fix Bun.spawnSync + GC unbalancing the event loop's keep-alive count

Merged
DY
2mo ago
+170-114

What

Bun.spawnSync points vm.event_loop_handle at a private uws/libuv loop for the duration of the call (SpawnSyncEventLoop::prepare/cleanup). If a GC finishes while spawnSync is on the stack and a FinalizationRegistry has dead targets, JSFinalizationRegistry::reconcileWeakReferencesAtGCEnd → DeferredWorkTimer::addPendingWork → JSCTaskScheduler::onAddPendingWork takes a keep-alive with Bun__eventLoop__refKeepAlive, which folded into vm.event_loop_handle — the private loop. The matching -1 (when the cleanup task runs) is queued on the regular EventLoop and folded at its next tick onto the main loop.

Net per occurrence: main loop num_polls -= 1 / active -= 1. Once num_polls reads 0 while polls are still registered, us_loop_run_bun_tick returns before epoll_wait/kevent and the process stops observing I/O and child exits (timers keep firing; Bun.serve never accepts, await proc.exited never resolves, …). On Windows the same sequence shows up as active_handles drift and a process that never exits. Regressed by #39905 (which switched this +1 from a queued VmHandle ref to an immediate fold); 1.4.0 is unaffected.

Fix

EventLoop now records the uws loop it runs on (uws_loop, previously a Windows-only field): the thread's loop for the VM's regular/macro loops (set in ensure_waker), the private loop for a spawnSync loop (set at creation). apply_concurrent_ref_delta, wakeup, usockets_loop and native_loop/uv_loop resolve through that instead of through vm.event_loop_handle, so a keep-alive taken on the regular loop lands on the regular loop regardless of what spawnSync has installed. This covers every Bun__eventLoop__refKeepAlive caller (deferred work, MessagePort, BroadcastChannel, ScriptExecutionContext), not just the FinalizationRegistry path, and also removes an off-thread read of vm.event_loop_handle (VmHandle → wakeup()) that raced spawnSync's swap.

Also: EventLoop::uv_loop() is now Windows-only (every caller already was) with native_loop() as the cross-platform accessor, matching EventLoopHandle; and spawnSync's cleanup no longer writes vm.event_loop back to the value it just read.

Why this is behaviour-preserving outside spawnSync

Every writer of vm.event_loop_handle (ensure_waker, two sites in server/mod.rs) stores the thread's loop (bun_io::Loop::get()), except spawnSync's prepare/cleanup. For the regular and macro EventLoops, uws_loop is uws::Loop::get(), and uws_to_native(uws::Loop::get()) == bun_io::Loop::get() on every platform (Windows: uws_get_loop_with_native(uv::Loop::get()); now debug_asserted in ensure_waker). So old and new resolve to the same pointer everywhere except between prepare and cleanup — which is exactly the broken window.

This was checked mechanically: a temporary build computed both the old (vm.event_loop_handle) and new (self.uws_loop) pointer in each touched method and aborted if they differed outside the spawnSync window. ~14k tests across 39 directories (js/bun/spawn, js/node/child_process, js/web/workers, js/bun/shell, cli/run, js/bun/http/serve, js/web/fetch, js/node/fs, js/node/http, …) on a Linux release build: 0 divergences outside the window. Inside the window the only divergent callers were, by backtrace, reconcileWeakReferencesAtGCEnd → onAddPendingWork → ref_keep_alive (the bug) and → scheduleWorkSoon → VmHandle::post → wakeup (previously woke the private loop, now the main one), both on the JS thread.

Tests

  • spawnSync-keepalive-gc-fixture.js: 5000 FinalizationRegistry targets, spawnSync, tick, assert numPolls never drops below its starting value; run under BUN_JSC_collectContinuously=1 so a collection reliably ends inside spawnSync.
  • spawnSync-keepalive-stress-fixture.js: the same under a message-flooding Worker and a listening Bun.serve; after each round the server must still answer a fetch, numPolls must not move, and the process must exit on its own at the end.
Linux x64macOS arm64Windows x64
gc fixture, canary 11fb73032DRIFT 3/3DRIFT 3/3hangs at exit 3/3
stress fixture, canaryDRIFT 3/3DRIFT 3/3DRIFT 3/3
both fixtures, this PROKOKOK
issue repro (serve never accepts), canary → this PRHANG after 19 → OKHANG after 5 → OK—
spawnsync-isolated-event-loop, spawnSync, spawn, child_process, 39900, macro-test, worker, message-channel, broadcastchannel, worker_threads, servepasspasspass¹

¹ stdin/stdout … not affected by spawnSync fails on the Windows box used here on 1.4.0/canary/this PR alike (it spawns echo); passes in CI.