event loop: run completions left on the macro loop after the macro returned
Closed
RO
3mo ago+130+130+012345678901234567890123456789-61-61-01234567890123456789
Problem
- Since #37075,
bun index.tswhereindex.tscalls a macro that starts async work without awaiting it (an un-awaitedfs.promises.stat(),fetch()orcrypto.subtle.digest()inside the macro; the same at the macro module's top level when the work outlasts loading that module) prints its output and then never exits. 1.3.14 exits 0. - Off-thread work now captures its destination loop when it is created:
Job::scheduletakesvm.ticket()(src/jsc/job.rs:331), which recordscurrent_loop_kind()(src/jsc/VmHandle.rs:519,src/jsc/VirtualMachine.rs:903). While a macro runs that isLoopKind::Macro, so the completion is posted tomacro_event_loop.concurrent_tasks(loop_of+ push,src/jsc/VmHandle.rs:159-179). - The macro loop only ticks inside
wait_for_promisewhile a macro is being waited on. Once the macro has returned nothing pops that queue, and the job's keep-alive (taken on the uSockets loop both embedded loops share,job.rs:316, released only when the completion runs) keepsis_event_loop_alive()true forever. - The macro loop has a second per-loop channel with the same problem: a Macro-kind
Ticket::unref_keep_alive()(VmHandle.rs:245, used by WebCrypto's work queue,src/jsc/CppTask.rs:75, for inputs hashed off thread) adds its-1tomacro_event_loop.concurrent_ref, which only a tick of that loop folds into the platform loop. The WebCrypto result itself is posted to the regular loop and runs; the keep-alive is what stays stuck. This channel exists since #38299 madeConcurrentCppTaskcarry a ticket instead of always releasing on the regular loop. - Before #37075 the producers looked the loop up at completion time and landed on the regular loop, which is why this used to work.
Fix
EventLoop::tick_concurrent_with_counton the regular loop also services the macro loop: it folds that loop'sconcurrent_refdelta (apply_concurrent_ref_delta, the same function each loop runs on itself) and pops its concurrent queue. Gated byfinished_macro_loop()(src/jsc/event_loop.rs:551): the VM has run a macro (has_enabled_macro_mode), no macro is running right now (!macro_mode), and the loop doing the drain is the regular one (so the macro loop itself and spawnSync's isolated loop never adopt it).- The exit decision sees the same state:
has_pending_tasks()(poll must not block) andis_event_loop_alive_excluding_immediates()go throughhas_concurrent_tasks(), andhas_pending_refs()also looks at the macro loop, so work sitting on the macro loop counts the same as work on the regular loop. - Correct because at any moment only one of the two loops ticks: while a macro runs,
vm.event_looppoints at the macro loop and it services itself as before (awaited macros are unchanged, and the regular loop's queue still waits until the macro returns); once it returns, the regular loop owns whatever is left or arrives later. A post or unref already wakes the shared platform loop, so a late one is picked up on the next tick. Draining only indisable_macro_mode()would not be enough: in the repro the completion is posted after that runs. Picking the loop at post time instead would put a read of the JS thread's macro state back on the posting thread, which is the race #37075 removed. - For programs that never run a macro the drain is unchanged apart from one
boolcheck (has_enabled_macro_modeis false). The batch transfer intotasksis factored intotake_concurrent_batch(built onConcurrentTask::into_task), which the teardown release path (release_queued_tasks) now shares instead of carrying its own copy. - Rebased onto main after #38299 (tickets + the waiting teardown) merged; the diff applies unchanged there, since it only touches the drain on the consuming side. Re-verified on main at 7ba276f: the three in-macro cases below still hang without the fix and pass with it.
- Verified with
test/bundler/transpiler/macro-test.test.ts, four new cases that assert the child prints both the macro's value and the un-awaited continuation's output and exits 0. The three cases that start the work inside the macro call (fs.promises,fetch(), a 4 KiBcrypto.subtle.digest()) time out without the fix (the child never exits) and pass with it; the digest case also times out with only the first commit of this PR. The top-levelfs.statcase passes either way on current main, because the stat usually completes while the macro loop is still being ticked to load the macro module; it covers the continuation running from the macro loop. Top-level work that outlasts the module load (a fetch against a server that answers after 300 ms, checked by hand) hangs without the fix and exits with it. The rest of that file,transpiler.test.js, the macro cases inbundler_edgecase/bun-build-api,web-crypto.test.ts,spawn.test.tsand the worker teardown tests pass with the debug build; the file also passes with a release build.
Background
- A VM embeds two
EventLoops,regular_event_loopandmacro_event_loop, plusvm.event_loop, a pointer to whichever is current.MacroModeGuardpoints it at the macro loop for the duration of a macro call (and of loading the macro module) so that the user program's queued work does not run in the middle of transpiling; a macro that returns a promise is settled by ticking that loop. Both loops sit on the same uSockets/libuv platform loop, whose active count is what keep-alives adjust and whatis_event_loop_alive()reads. - Each
EventLoophas two things other threads write to:concurrent_tasks, the MPSC queue of completions (each tick moves a batch of it into the loop's owntasksFIFO and runs them), andconcurrent_ref, a pending keep-alive delta thatapply_concurrent_ref_deltafolds into the platform loop's active count at the top of each tick. ATicket(VmHandle.rs) is what off-thread work holds to reach either; itsLoopKind, fixed when the work is created, says which loop's pair it writes to. - In
bun run, macros in the entry file (and in every file when transpiling happens on the main thread, e.g. with plugins) execute in the main VM, which is why this hangs the process. Macros in files transpiled on the thread pool and inbun buildrun in per-thread VMs whose regular loop never ticks, so there the same stranded work is only a leak, which is unchanged here. - A sync macro calling
setImmediate()also hangs, on 1.3.14 as well (immediates are per-loop too); that is pre-existing and not touched by this change.
[review] gate passed · iteration 2 · 4 files touched
fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/macro-test.test.ts
bun test v1.4.0 (af84237cd)
test/bundler/transpiler/macro-test.test.ts:
[macro] call escapeHTML
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call escape
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] c
... (truncated)
release without fix: 3 FAILED
bun test v1.4.0-canary.1 (b7a043103)
test/bundler/transpiler/macro-test.test.ts:
(pass) bun builtins can be used in macros [0.04ms]
(pass) latin1 string [0.01ms]
(pass) ascii string
(pass) type coercion [0.09ms]
(pass) escaping [0.15ms]
(pass) utf16 string
(pass) import aliases [0.02ms]
(pass) default import [0.01ms]
(pass) namespace import [0.02ms]
(pass) ireturnapromise [0.07ms]
(pass) object argument with a sparse numeric key [9.95ms]
(pass) object destructuring of a macro result keeps every bound property regardless of key order or repeated keys [8.28ms]
(pass) work a macro starts without awaiting it > a crypto.subtle.digest() inside the macro still lets the process exit [8.20ms]
(fail) work a macro starts without awaiting it > an fs.promises call inside the macro still lets the process exit [5000.09ms]
^ this test timed out after 5000ms.
(fail) work a macro starts without awaiting it > an fs.promises call at the macro module's top level still lets the process exit [5000.07ms]
^ this test timed out after 5000ms.
(fail) work a macro starts without awaiting it > a fetch() inside the macro still lets the process exit [5000.06ms]
^ this test timed out after 5
... (truncated)
passes on PR (with fix)
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/bundler/transpiler/macro-test.test.ts
bun test v1.4.0 (af84237cd)
test/bundler/transpiler/macro-test.test.ts:
[macro] call escapeHTML
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call escape
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] c
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 667ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/663] cc obj/vendor/tinycc/x86_64-gen.c.o
[2/663] cc obj/vendor/tinycc/tccelf.c.o
[3/663] cc obj/vendor/tinycc/x86_64-link.c.o
[4/663] cc obj/vendor/tinycc/tccrun.c.o
[5/663] cc obj/vendor/tinycc/tccasm.c.o
[6/663] cc obj/vendor/tinycc/i386-asm.c.o
[7/663] fetch boringssl
[boringssl] up to date
[8/241] fetch hdrhistogram
[hdrhistogram] up to date
[9/238] fetch mimalloc
[mimalloc] up to date
[10/238] gen ZigGeneratedClasses.lut.h
Generating /workspace/bun/build/release/codegen/ZigGeneratedClasses.lut.h from /workspace/bun/build/release/codegen/ZigGeneratedClasses.lut.txt
[11/238] fetch highway
[highway] up to date
[12/229] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[13/229] cc obj/codegen/InternalModuleRegistryConstants.S.o
[14/229] fetch lolhtml
[lolhtml] up to date
[15/229] fetch WebKit (prebuilt)
[WebKit] up to date
[16/229] cc obj/vendor/mimalloc/src/static.c.o
[17/229] fetch lsquic
[lsquic] up to date
[18/160] cc obj/packages/bun-usockets/src/udp.c.o
[19/160] c
... (truncated)
diff hotspot
src/jsc/VirtualMachine.rs | 2 +-
src/jsc/VmHandle.rs | 2 +
src/jsc/event_loop.rs | 108 +++++++++++++----------------
test/bundler/transpiler/macro-test.test.ts | 79 +++++++++++++++++++++
4 files changed, 130 insertions(+), 61 deletions(-)
gate history · 2 passed · 1 rejected · iteration 2
evidence per changed file
file reads edits tests
src/jsc/VirtualMachine.rs 5 1 0
src/jsc/VmHandle.rs 6 7 0
src/jsc/event_loop.rs 17 15 0
test/bundler/transpiler/macro-test.test.ts 3 5 0