Skip to content

event loop: run completions left on the macro loop after the macro returned - #38304

Open
robobun wants to merge 2 commits into
mainfrom
farm/ab647f4f/macro-loop-stranded-completions
Open

event loop: run completions left on the macro loop after the macro returned#38304
robobun wants to merge 2 commits into
mainfrom
farm/ab647f4f/macro-loop-stranded-completions

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Since Worker / worker_threads: WebCore-shaped lifetimes, joined threads, one ordered VM teardown #37075, bun index.ts where index.ts calls a macro that starts async work without awaiting it (an un-awaited fs.promises.stat(), fetch() or crypto.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::schedule takes vm.ticket() (src/jsc/job.rs:331), which records current_loop_kind() (src/jsc/VmHandle.rs:519, src/jsc/VirtualMachine.rs:903). While a macro runs that is LoopKind::Macro, so the completion is posted to macro_event_loop.concurrent_tasks (loop_of + push, src/jsc/VmHandle.rs:159-179).
  • The macro loop only ticks inside wait_for_promise while 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) keeps is_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 -1 to macro_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 One door out of a VM's thread: tickets + a teardown that waits #38299 made ConcurrentCppTask carry a ticket instead of always releasing on the regular loop.
  • Before Worker / worker_threads: WebCore-shaped lifetimes, joined threads, one ordered VM teardown #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_count on the regular loop also services the macro loop: it folds that loop's concurrent_ref delta (apply_concurrent_ref_delta, the same function each loop runs on itself) and pops its concurrent queue. Gated by finished_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) and is_event_loop_alive_excluding_immediates() go through has_concurrent_tasks(), and has_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_loop points 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 in disable_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 Worker / worker_threads: WebCore-shaped lifetimes, joined threads, one ordered VM teardown #37075 removed.
  • For programs that never run a macro the drain is unchanged apart from one bool check (has_enabled_macro_mode is false). The batch transfer into tasks is factored into take_concurrent_batch (built on ConcurrentTask::into_task), which the teardown release path (release_queued_tasks) now shares instead of carrying its own copy.
  • Rebased onto main after One door out of a VM's thread: tickets + a teardown that waits #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 KiB crypto.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-level fs.stat case 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 in bundler_edgecase / bun-build-api, web-crypto.test.ts, spawn.test.ts and 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_loop and macro_event_loop, plus vm.event_loop, a pointer to whichever is current. MacroModeGuard points 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 what is_event_loop_alive() reads.
  • Each EventLoop has two things other threads write to: concurrent_tasks, the MPSC queue of completions (each tick moves a batch of it into the loop's own tasks FIFO and runs them), and concurrent_ref, a pending keep-alive delta that apply_concurrent_ref_delta folds into the platform loop's active count at the top of each tick. A Ticket (VmHandle.rs) is what off-thread work holds to reach either; its LoopKind, 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 in bun build run 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

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f55a2eea-4085-4247-8312-c94a4860b903

📥 Commits

Reviewing files that changed from the base of the PR and between 7ba276f and 73eb0fc.

📒 Files selected for processing (4)
  • src/jsc/VirtualMachine.rs
  • src/jsc/VmHandle.rs
  • src/jsc/event_loop.rs
  • test/bundler/transpiler/macro-test.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:49 AM PT - Aug 14th, 2026

@robobun, your commit 73eb0fcde6a901038401b1cd78a030a91ba3ea13 passed in Build #95863! 🎉


🧪   To try this PR locally:

bunx bun-pr 38304

That installs a local version of the PR into your bun-38304 executable, so you can run:

bun-38304 --bun

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed, waiting on CI.

Reproduced on canary 1.4.0-canary.1+da3851e57 (includes #37075) with the snippet from the report, a macro doing an un-awaited fs.promises.stat(): bun index.ts prints value 1 and never exits (killed by timeout after 10s). Same for the stat at the macro module's top level and for fetch(). 1.3.14 exits 0 on all three; bun build exits 0 on both versions.

The new cases in test/bundler/transpiler/macro-test.test.ts that start the work inside the macro (fs.promises, fetch(), and a crypto.subtle.digest() large enough to use the WebCrypto work queue, whose keep-alive release goes through the macro loop as well) time out without the fix (child never exits) and pass with it; the top-level case passes either way on current main because the stat finishes while the macro module is still loading (a slower top-level fetch still hangs without the fix, checked by hand). The rest of that file, transpiler.test.js, the macro cases in the bundler tests, spawn.test.ts and the worker teardown tests pass with the debug build.

Rebased onto main at 60f6e18: the darwin astro-post / vite-build aborts on the earlier CI run were the mimalloc fork-handler break fixed on main by #38291, which the previous base predated.

Rebased again onto main at 7ba276f (after #38299, the ticket model, and #38436) on request; the two commits apply unchanged. Re-verified there: the three in-macro cases hang with main's src/ and pass with this branch's, all 19 tests in the file pass.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it changes the per-tick concurrent drain in EventLoop::tick_concurrent_with_count and the exit-decision predicate (is_event_loop_alive_excluding_immediates), and introduces cross-loop draining, a human look would still be worthwhile.

What was reviewed:

  • take_concurrent_batch drops the deferred-free pattern from the old tick_concurrent_with_count in favor of the read-then-free pattern that take_concurrent_tasks already used; verified BatchIterator::next advances front past the node before returning it, so freeing immediately after the raw-pointer reads is sound.
  • finished_macro_loop gate: has_enabled_macro_mode && !macro_mode && ptr::eq(self, &regular_event_loop) — confirmed the spawnSync isolated loop and the macro loop itself return None, and pop_batch/is_empty take &self so the &EventLoop return is enough.
  • has_concurrent_tasks is wired into both has_pending_tasks and the is_event_loop_alive sum, so a stranded macro-loop completion both prevents a blocking poll and keeps the loop alive until it runs.
  • Tests: three cases (fs.promises in-macro, at module top level, fetch) spawn a child, use a local Bun.serve({port:0}), drain both pipes concurrently, filter debug [macro] lines, and sort to tolerate ordering — no timing sleeps.
Extended reasoning...

Overview

The PR fixes a hang regression from #37075: async work started inside a macro (un-awaited fs.promises, fetch()) captures LoopKind::Macro at creation and posts its completion to macro_event_loop.concurrent_tasks, but that queue is only drained while wait_for_promise is ticking the macro loop. Once the macro returns, the completion is stranded there while its keep-alive on the shared platform loop keeps is_event_loop_alive() true forever.

The fix has the regular loop's tick_concurrent_with_count also pop the macro loop's concurrent queue, gated by finished_macro_loop() (has_enabled_macro_mode && !macro_mode && self == &regular_event_loop). has_pending_tasks and is_event_loop_alive_excluding_immediates route through the same gate via a new has_concurrent_tasks(). The batch-transfer body is factored into take_concurrent_batch, which release_queued_tasks now shares (deleting the near-identical take_concurrent_tasks). VmHandle.rs gets only a doc-comment update. Three subprocess tests are added to macro-test.test.ts.

Security risks

None identified. No user-controlled input reaches new parsing/allocation; no auth/crypto/permissions surface. The change is internal event-loop scheduling.

Level of scrutiny

High. tick_concurrent_with_count runs on every tick of every JS VM, and is_event_loop_alive_excluding_immediates decides process exit. The refactoring changes the iteration/free ordering in the hot drain (dropping the one-iteration-deferred free in favor of immediate free after raw-pointer reads — the pattern the deleted take_concurrent_tasks already used, and BatchIterator::next does advance past the node before returning it, so the SAFETY comment is accurate). Cross-loop draining is a new behavior whose correctness depends on the invariant that only one of the two loops ticks at any moment (MacroModeGuard swaps vm.event_loop). The gate correctly excludes the macro loop itself and spawnSync's heap-allocated isolated loop (neither is &vm.regular_event_loop).

Other factors

  • Programs that never run a macro pay only one extra bool read (has_enabled_macro_mode is false), so the hot path is unchanged.
  • The &EventLoop returned by finished_macro_loop is derived through vm_ref() -> &'static VirtualMachine while &mut self (the regular loop, a sibling field) is held — this follows the existing vm_ref() pattern used throughout the file, and pop_batch/is_empty take &self.
  • The tests follow harness conventions (tempDir, bunEnv, concurrent pipe drain, local server, test.concurrent.each), assert both output lines and exitCode, and handle debug-build [macro] output and non-deterministic line order.
  • The PR description states the tests time out on an unfixed build and pass with the fix, and that the surrounding macro/spawn/worker suites still pass.

Given the change sits in core event-loop tick/exit machinery with unsafe iteration and cross-loop state, deferring to a human reviewer rather than auto-approving.

Comment thread src/jsc/VmHandle.rs Outdated
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/jsc/VmHandle.rs Outdated
Comment thread src/jsc/VmHandle.rs Outdated
Comment thread src/jsc/event_loop.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it changes how the core event loop drains cross-loop concurrent tasks (unsafe batch iteration, the exit-decision predicate, and a refactor that unifies two previously divergent drain implementations), a human look at the design would still be worthwhile.

What was reviewed:

  • finished_macro_loop() gating: has_enabled_macro_mode && !macro_mode plus the pointer-equality check correctly excludes the macro loop itself and spawnSync's isolated heap loop.
  • The deferred-free to_destroy pattern was dropped in favour of the immediate-free pattern the deleted take_concurrent_tasks already used; Batch::iterator().next() advances past the node before returning it, so freeing before the next next() is sound as the SAFETY comment states.
  • pop_batch(&self) on the macro loop's queue via a shared borrow while holding &mut self on the regular loop — disjoint VM fields, same vm_ref() pattern used throughout the file.
  • Tests follow harness conventions (port: 0, concurrent pipe drain, local server, test.concurrent.each, exit code asserted last).
Extended reasoning...

Overview

Fixes a hang regression from #37075: a macro that starts async work without awaiting it (un-awaited fs.promises.stat(), fetch()) now leaves its completion stranded on macro_event_loop.concurrent_tasks after the macro returns, holding a keep-alive on the shared platform loop so the process never exits. Touches src/jsc/event_loop.rs (adds finished_macro_loop/has_concurrent_tasks/take_concurrent_batch, rewires tick_concurrent_with_count/has_pending_tasks/release_queued_tasks), a one-line change in VirtualMachine.rs (is_event_loop_alive_excluding_immediates), a doc-only change in VmHandle.rs, and three new subprocess tests in macro-test.test.ts.

Security risks

None identified. No untrusted input parsing, no auth/crypto, no new FFI surface. The unsafe blocks are the same batch-node dereference/free the file already had, now in one place instead of two.

Level of scrutiny

High. This is the JS-thread event loop's per-tick drain and the process exit predicate — every bun run goes through it. The refactor unifies two implementations of the concurrent-batch transfer that previously disagreed on whether the ConcurrentTask carrier free needed to be deferred one iteration (tick_concurrent_with_count deferred via to_destroy; take_concurrent_tasks freed immediately). The PR picks the immediate-free variant, which the deleted function's SAFETY comment already justified ("the iterator advanced past it before returning"), and pop_batch takes &self so the cross-loop read is a shared borrow of a disjoint VM field. I did not find a problem with any of this, but it is exactly the class of change where a maintainer should confirm the design choice (regular loop adopting the macro loop's queue vs. re-binding the LoopKind at post time or transferring at disable_macro_mode).

Other factors

  • The core::ptr::eq(self, &raw const vm.regular_event_loop) guard means the spawnSync isolated loop (heap-allocated) and the macro loop itself never adopt the macro queue; has_enabled_macro_mode short-circuits the whole check to one bool for programs that never use macros.
  • Teardown already calls release_queued_tasks on both embedded loops (VirtualMachine.rs:1931-1932), so the new release_queued_tasks correctly draining only its own queue (not also the macro loop's) is fine.
  • All comment-cop bot flags were addressed in 09d148e and the threads are resolved.
  • Tests were verified by the author to hang under USE_SYSTEM_BUN=1 and pass with the debug build; they use a local Bun.serve({port:0}) for the fetch case and follow the harness conventions.

@robobun
robobun force-pushed the farm/ab647f4f/macro-loop-stranded-completions branch from 09d148e to 129de6f Compare August 14, 2026 08:22
Comment thread src/jsc/VmHandle.rs Outdated
Comment thread src/jsc/event_loop.rs
Comment thread src/jsc/VmHandle.rs
Comment thread src/jsc/event_loop.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The concurrent_ref adoption gap I flagged earlier is now covered — tick_concurrent_with_count folds macro_loop.concurrent_ref via apply_concurrent_ref_delta(), has_pending_refs() checks it, and the new crypto.subtle.digest() test case exercises that path. I re-reviewed and found nothing further; given this reworks how the regular loop reaches into the macro loop's concurrent state, a human look at the cross-loop borrow (finished_macro_loop handing out &vm.macro_event_loop while &mut self is the regular loop) and the > 0 check semantics for a pending -1 delta would still be worthwhile.

What was reviewed:

  • finished_macro_loop gating: only the regular loop adopts, only after has_enabled_macro_mode && !macro_mode, so the macro loop and spawnSync's isolated loop never self-adopt.
  • take_concurrent_batch refactor preserves the old drain semantics (into_task frees auto-delete carriers; ensure_unused_capacity before the loop); release_queued_tasks now shares it.
  • has_pending_refs checking > 0 on the macro loop's concurrent_ref: the off-thread write is -1 (unref), which correctly does not keep the loop alive; the stuck +1 on platform_loop.active is what keeps it alive until the next tick folds the -1.
Extended reasoning...

Overview

This PR fixes a hang regression from #37075: async work started inside a macro without being awaited posts its completion to macro_event_loop.concurrent_tasks (because the ticket captured LoopKind::Macro at creation), but that queue is only drained while a macro is being waited on. After the macro returns nothing pops it, and the work's keep-alive on the shared platform loop keeps the process alive forever. The fix has the regular loop's tick_concurrent_with_count also drain the macro loop's queue and fold its concurrent_ref deltas, gated by finished_macro_loop() (macro mode was ever entered, is not currently active, and self is the regular loop). has_concurrent_tasks() and has_pending_refs() extend the same check so the poll-must-not-block and exit-decision predicates see the adopted work. The batch-transfer loop is factored into take_concurrent_batch, which release_queued_tasks now reuses.

Follow-up on prior finding

My earlier inline comment noted that only concurrent_tasks was adopted, not concurrent_ref, so crypto.subtle.digest() (whose ConcurrentCppTask calls Ticket::unref_keep_alive off-thread → macro_event_loop.concurrent_ref -= 1) would still hang. Commit af84237 addresses this: tick_concurrent_with_count now calls macro_loop.apply_concurrent_ref_delta() before draining, has_pending_refs() also checks the macro loop, and a fourth test case (crypto.subtle.digest on a ≥64-byte input, forcing the WebCrypto work queue) was added. I traced the flow again: the JS-thread ref_keep_alive() folds its +1 into platform_loop.active immediately; the off-thread -1 sits in macro_event_loop.concurrent_ref until the regular loop's next tick folds it. has_pending_refs() checking > 0 is correct — a pending -1 should not itself keep the loop alive; the unfolded +1 in platform_loop.active does that until the fold happens on the next tick.

Security risks

None. No user-controlled input reaches new parsing/allocation; the change is which per-VM queue gets drained on the JS thread.

Level of scrutiny

High. event_loop.rs is the JS-thread run loop; tick_concurrent_with_count runs on every tick, and has_pending_tasks/has_pending_refs gate whether the process blocks or exits. The new finished_macro_loop() forms a shared borrow of vm.macro_event_loop (via vm_ref() -> &'static VirtualMachine) while &mut self aliases vm.regular_event_loop — disjoint fields, and apply_concurrent_ref_delta/pop_batch on the macro loop take &self, so no stacked-borrow overlap, but this is exactly the kind of cross-field access the file's other R-2 mitigations are careful about. The take_concurrent_batch refactor drops the deferred to_destroy pattern in favor of ConcurrentTask::into_task, which per the doc frees auto-delete carriers inline; that behavior change is worth a maintainer's confirmation.

Other factors

Four new test cases in macro-test.test.ts cover fs.promises (in-macro and top-level), fetch, and crypto.subtle.digest; the PR body confirms the in-macro cases hang without the fix on both debug-ASAN and are exercised on release. The two open comment-cop bot notes (VmHandle.rs:78, event_loop.rs:550) are about doc-comment length; the author already argued for keeping them on earlier iterations and the current comments are trimmed to the load-bearing invariant. Not approving because this is a substantive change to event-loop task adoption across two embedded loops — not mechanical.

…turned

Off-thread work captures the loop that is current when it is created, so
work a macro starts posts its completion to the macro loop. That loop only
ticks while the macro is being waited on. A completion arriving after the
macro returned (an un-awaited fs.promises call, a fetch()) stayed queued
there while still holding its keep-alive on the shared platform loop, so
`bun run` of a file using such a macro never exited.

The regular loop now also drains the macro loop's concurrent queue whenever
no macro is running, and its pending-work / liveness checks look at that
queue too. The batch transfer is shared with the teardown release path.
A Macro-kind Ticket releases its keep-alive into macro_event_loop's
concurrent_ref (WebCrypto's work queue does this for inputs hashed off
thread), and only a tick of that loop folded the delta into the platform
loop. So a crypto.subtle call started inside a macro still kept the
process alive after its completion had run. The regular loop now applies
the macro loop's delta when it services that loop, and the liveness check
sees a pending delta there as well.
@robobun
robobun force-pushed the farm/ab647f4f/macro-loop-stranded-completions branch from af84237 to 73eb0fc Compare August 14, 2026 10:27

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and found no remaining bugs — the concurrent_ref sibling I flagged earlier is fixed in af84237 and covered by the new 4 KiB crypto.subtle.digest case. Because it changes the core tick/drain path and the process-exit liveness checks in event_loop.rs, a human look is still worthwhile.

What was reviewed:

  • finished_macro_loop() gating: confirmed only the regular loop adopts (ptr-eq against regular_event_loop), so the macro loop and spawnSync's isolated loop never recurse into it.
  • take_concurrent_batch refactor vs the old deferred-to_destroy loop: ConcurrentTask::into_task reads task/auto_delete before freeing the carrier, and the batch iterator has advanced past the node, so the drop-then-next ordering is sound.
  • apply_concurrent_ref_delta on the macro loop folds into the shared platform_loop_opt(), so the delta lands in the same active count either loop uses.
  • Tests: hermetic (local Bun.serve({port:0}), tempDir, test.concurrent.each), drain all three pipes, and the sort handles the non-deterministic continuation ordering.
Extended reasoning...

Overview

The PR fixes a hang regression from #37075: async work started inside a macro captures LoopKind::Macro on its ticket, so its completion (and, since #38299, its keep-alive release) lands on macro_event_loop's concurrent channels. That loop only ticks inside wait_for_promise while a macro is pending, so once the macro returns nothing drains it and the shared platform loop's active count never reaches zero. The fix has the regular loop's tick_concurrent_with_count also service the macro loop's concurrent_tasks and fold its concurrent_ref delta, gated by finished_macro_loop() (has_enabled_macro_mode && !macro_mode && self is regular_event_loop). has_pending_tasks, has_concurrent_tasks, has_pending_refs and is_event_loop_alive_excluding_immediates are updated to see the same state so the poll/exit decisions match what the drain will pick up. The two batch-transfer loops (tick vs teardown's release_queued_tasks) are consolidated into take_concurrent_batch, built on the existing ConcurrentTask::into_task.

Security risks

None. This is internal event-loop scheduling; no user-controlled input reaches new code paths, no auth/crypto/permissions surface.

Level of scrutiny

High. tick_concurrent_with_count, has_pending_tasks, has_pending_refs and release_queued_tasks sit on every VM's tick and teardown path — a mistake here hangs or prematurely exits every Bun process, not just macro users. The finished_macro_loop() gate keeps the added work to one bool check when no macro ever ran, which bounds the blast radius, but the refactor of the concurrent-batch drain replaces a deliberate deferred-free pattern with into_task and runs on every tick regardless.

Other factors

  • My earlier inline finding (macro loop's concurrent_ref not folded) was confirmed by the author, fixed in af84237, and a test case (4 KiB crypto.subtle.digest, which routes through the WebCrypto work queue's Ticket::unref_keep_alive) was added that fails on the first commit and passes on the second.
  • The gate evidence in the PR description shows the new tests fail (timeout) on both debug-ASAN and release builds without the fix and pass with it; the rest of the macro/transpiler/spawn/worker suites pass.
  • The batch-drain refactor is behaviour-preserving: into_task reads the payload and auto_delete flag before freeing the heap carrier (ConcurrentTask.rs:320-328), and the iterator advances past a node before returning it, so freeing node N before fetching N+1 is safe — equivalent to the old to_destroy deferral.
  • The comment-cop bot flagged doc-comment length repeatedly; the author trimmed and then justified keeping the remaining two-line docs on LoopKind::Macro and finished_macro_loop. All those threads are resolved.

Given the surface area (core tick + exit-liveness), I'm not shadow-approving; a maintainer familiar with the macro-loop / MacroModeGuard interaction should sign off.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants