Fix hang when a macro awaits crypto.subtle.digest

Merged
RO
3mo ago
+553-123

Problem

  • await crypto.subtle.digest(...) inside a macro makes Bun.build() and bun run hang forever. Regression from 1.3 to 1.4 (#39900).
  • It is not specific to digest. Anything a macro awaits whose completion reaches the VM through a post that does not know which loop the macro is waiting on hangs the same way on 1.4.0 and passes on 1.3.14: WebAssembly.compile/instantiate and Atomics.waitAsync (JSC's DeferredWorkTimer, JSCScheduler.rs), MessageChannel and BroadcastChannel deliveries and small (< 64 byte) digests (postTaskTo from the same thread), Worker messages/online/exit (postTaskTo from the worker thread), and the rest of SubtleCrypto (sign, encrypt, ECDH deriveBits, …). All of these were hardcoded to LoopKind::Regular (VmHandle::post_cpp_task, Bun__queueJSCDeferredWorkTaskConcurrently, Bun__VmHandle__refKeepAlive), while a macro's await ticks only the macro loop (wait_for_promise).
  • The opposite direction is #38304: work a macro starts but does not await takes a Macro ticket, finishes after the macro returned, and sits on a loop nothing ticks, its keep-alive holding the process open. 1.3 posted to whichever loop was current at completion time, so both directions worked there (through a cross-thread read of the JS thread's loop pointer, which #37075 removed).

Fix

Every post carries the loop that was current on the JS thread when its work was initiated — the rule tickets already follow — and the regular loop adopts whatever is left on the macro loop once no macro is running. The macro's wait services only what the macro started; nothing can be stranded; no thread reads another thread's loop state.

  • LoopKind crosses the FFI as BunLoopKind (BunLoopKind.h); Bun__VM__currentLoopKind exposes current_loop_kind() to the C++ initiators. Bun__VmHandle__postAndRelease / refKeepAlive and VmHandle::post_cpp_task take the kind instead of assuming Regular.
  • WebCrypto: the work-queue closures start on the JS thread with the context in hand, so they capture context.currentLoopKind() next to context.identifier() and pass it to postTaskTo from the pool thread; the sub-64-byte same-thread paths pass it directly. ConcurrentCppTask is back to main's event_loop_shared().ref_keep_alive() + vm.ticket().
  • JSCTaskScheduler: the two pending-ticket sets become maps from JSC ticket to the loop kind onAddPendingWork (JS thread) captured, and onScheduleWorkSoon posts the completion to it. The keep-alive onAddPendingWork takes is applied directly (Bun__eventLoop__refKeepAlive, it is on the JS thread) so its release does not depend on which loop ticks next.
  • ScriptExecutionContext::postTaskTo(id, loopKind, task) — no default, every caller states it: from the target's own thread the task joins the loop that thread is running (still through the concurrent queue, so per-tick refill bounding is unchanged); from another thread it joins loopKind, which the cross-thread callers supply: WorkerMessagingProxy captures the parent's loop at new Worker() for everything it posts back (postTaskToWorkerObject); the worker heap-snapshot/statistics/CPU-usage request-replies capture it at request time; MessagePortPipe records it next to ctxId when a side attaches to a context and posts drains and the peer-close notification to it; the BroadcastChannel registry records it per subscriber. ScriptExecutionContext::currentLoopKind() is the single C++ accessor.
  • Work no script initiated — the debugger, signal delivery, hot reload, napi finalizers from GC threads, memory-pressure, webview socket refs, threadsafe: true FFI callbacks invoked from a foreign thread — stays Regular. Those are now the only Regular constants left.
  • EventLoop::macro_loop_if_not_running (from #38304): when this is the regular loop, the VM has run a macro, and none is running now, tick_concurrent_with_count first folds the macro loop's concurrent_ref delta and moves its concurrent batch into tasks (through take_concurrent_tasks, which now takes the batch), then drains its own queue as before; has_pending_tasks / has_pending_refs / is_event_loop_alive see the same through has_concurrent_tasks. The macro loop never adopts the regular loop's queue.
  • Correct because at any moment exactly one of the two loops ticks. While a macro runs, only completions of work the macro (transitively) started carry Macro, and the wait drains exactly those; the program's completions carry Regular and wait for the macro to return, as its same-thread tasks always have. After the macro returns the regular loop owns both queues, and a late Macro post or unref wakes the shared platform loop, so it is picked up on the next tick. No poster reads the JS thread's macro state: same-thread initiators read their own thread's state, cross-thread ones use a value captured earlier on the JS thread.
  • Supersedes #38304 (its drain and its four test cases are included here, on top of the routing change).

Verified on a Windows debug build: test/regression/issue/39900.test.ts (12 cases, including a port transferred to a Worker and a BroadcastChannel message sent from a Worker; all time out on 1.4.0, pass on 1.3.14 except Atomics.waitAsync, which hung there too), the new event loop routing around macros block in test/bundler/transpiler/macro-test.test.ts (7 cases), plus web-crypto, web-crypto-sha3, atomics, message-channel, message-port-pipe, worker-postmessage-transfer, message-port-closed-leak, broadcast-channel, broadcast-channel-worker-gc, worker_threads, worker-late-completion and the source lints.

Background

  • A VM embeds two EventLoops, regular_event_loop and macro_event_loop, and vm.event_loop points at whichever is current. MacroModeGuard points it at the macro loop while a macro runs (and while the macro module loads) so that the program's queued work does not run underneath the transpiler; a macro that returns a promise is settled by ticking that loop. Both sit on one uSockets/libuv platform loop, whose active count keep-alives adjust.
  • Each EventLoop has two things other threads write: concurrent_tasks (completions; each tick moves a batch into tasks and runs it) and concurrent_ref (a pending keep-alive delta folded into the platform loop at the top of each tick). A Ticket records which loop's pair it writes to when it is taken on the JS thread; a weak post (VmHandle::post, what postTaskTo(identifier) becomes) is told which by its caller.
  • In bun run, the entry file's macros, and those of any module transpiled on the main thread (e.g. one that is require()d), execute in the main VM; that is where routing matters. Macros in files transpiled on the thread pool and in bun build run in per-thread VMs.
Notes
  • Why not drain the regular queue from the macro wait (this PR's first revision): it fixes the hang, but it runs the program's pending completions mid-transpile, and any work those callbacks start takes a Macro ticket and is stranded once the macro returns unless something later drains the macro loop. The a program completion that arrives during a macro waits for the macro to return test pins this: with that revision the macro observes ["program"]; with this one it observes [].
  • Why not pick the loop at post time (what 1.3 did): the posting thread would read the JS thread's macro state again, and a program completion that merely arrives during a macro would still run inside it. Every producer here has a JS-thread initiation point where the loop is known exactly, so it is captured there instead.
  • postTaskTo from the target's own thread keeps going through the concurrent queue rather than postTask's direct enqueue on purpose: same-thread MessageChannel ping-pong relies on the per-tick concurrent refill bound to let timers and I/O run.
  • Atomics.waitAsync with a timeout inside a macro hangs on 1.3.14 as well; it goes through the same DeferredWorkTimer path and is fixed by the same change, so it is in the producer list even though it is not strictly a 1.4 regression.
  • A sync macro that calls setImmediate() still hangs (immediates are per-loop, as on 1.3.14); untouched here.
  • On the Windows debug build, four worker-late-completion rows (Bun.file().text(), Bun.Image().metadata(), fs.read, thread-pool dns) do not observe a late completion because those paths run on the libuv loop thread there; unrelated to this change (that describe block is debug/ASAN-only and CI runs it on Linux). worker.test.ts's message-flood case needs the first worker message within ~30 ms, which a Windows debug build's worker startup does not meet.