Fix hang when a macro awaits crypto.subtle.digest
Problem
await crypto.subtle.digest(...)inside a macro makesBun.build()andbun runhang 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/instantiateandAtomics.waitAsync(JSC's DeferredWorkTimer,JSCScheduler.rs),MessageChannelandBroadcastChanneldeliveries and small (< 64 byte) digests (postTaskTofrom the same thread),Workermessages/online/exit (postTaskTofrom the worker thread), and the rest of SubtleCrypto (sign,encrypt, ECDHderiveBits, …). All of these were hardcoded toLoopKind::Regular(VmHandle::post_cpp_task,Bun__queueJSCDeferredWorkTaskConcurrently,Bun__VmHandle__refKeepAlive), while a macro'sawaitticks only the macro loop (wait_for_promise). - The opposite direction is #38304: work a macro starts but does not await takes a
Macroticket, 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.
LoopKindcrosses the FFI asBunLoopKind(BunLoopKind.h);Bun__VM__currentLoopKindexposescurrent_loop_kind()to the C++ initiators.Bun__VmHandle__postAndRelease/refKeepAliveandVmHandle::post_cpp_tasktake the kind instead of assumingRegular.- WebCrypto: the work-queue closures start on the JS thread with the context in hand, so they capture
context.currentLoopKind()next tocontext.identifier()and pass it topostTaskTofrom the pool thread; the sub-64-byte same-thread paths pass it directly.ConcurrentCppTaskis back to main'sevent_loop_shared().ref_keep_alive()+vm.ticket(). JSCTaskScheduler: the two pending-ticket sets become maps from JSC ticket to the loop kindonAddPendingWork(JS thread) captured, andonScheduleWorkSoonposts the completion to it. The keep-aliveonAddPendingWorktakes 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 joinsloopKind, which the cross-thread callers supply:WorkerMessagingProxycaptures the parent's loop atnew Worker()for everything it posts back (postTaskToWorkerObject); the worker heap-snapshot/statistics/CPU-usage request-replies capture it at request time;MessagePortPiperecords it next toctxIdwhen 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: trueFFI callbacks invoked from a foreign thread — staysRegular. Those are now the onlyRegularconstants 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_countfirst folds the macro loop'sconcurrent_refdelta and moves its concurrent batch intotasks(throughtake_concurrent_tasks, which now takes the batch), then drains its own queue as before;has_pending_tasks/has_pending_refs/is_event_loop_alivesee the same throughhas_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 carryRegularand 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 lateMacropost 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_loopandmacro_event_loop, andvm.event_looppoints at whichever is current.MacroModeGuardpoints 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
EventLoophas two things other threads write:concurrent_tasks(completions; each tick moves a batch intotasksand runs it) andconcurrent_ref(a pending keep-alive delta folded into the platform loop at the top of each tick). ATicketrecords which loop's pair it writes to when it is taken on the JS thread; a weak post (VmHandle::post, whatpostTaskTo(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 isrequire()d), execute in the main VM; that is where routing matters. Macros in files transpiled on the thread pool and inbun buildrun 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
Macroticket and is stranded once the macro returns unless something later drains the macro loop. Thea program completion that arrives during a macro waits for the macro to returntest 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.
postTaskTofrom the target's own thread keeps going through the concurrent queue rather thanpostTask's direct enqueue on purpose: same-threadMessageChannelping-pong relies on the per-tick concurrent refill bound to let timers and I/O run.Atomics.waitAsyncwith 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-completionrows (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.