Skip to content

process: drain the microtask queue before nextTick callbacks queued from inside it - #33088

Open
robobun wants to merge 11 commits into
mainfrom
farm/1d537037/nexttick-microtask-order
Open

robobun wants to merge 11 commits into
mainfrom
farm/1d537037/nexttick-microtask-order

Conversation

@robobun

@robobun robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A process.nextTick callback queued from inside a microtask runs immediately after that microtask, jumping ahead of the microtasks still pending in the queue. Node drains the entire microtask queue first, then the tick queue.

const L = [];
Promise.resolve().then(() => { L.push("p1"); process.nextTick(() => L.push("tick")); });
Promise.resolve().then(() => L.push("p2"));
queueMicrotask(() => L.push("q3"));
setTimeout(() => console.log(JSON.stringify(L)), 5);
node: ["p1","p2","q3","tick"]
bun:  ["p1","tick","p2","q3"]

It only reproduces on the process's first process.nextTick call, but that is the shape the common "collect work across the microtask queue, then flush once in a nextTick" batching pattern takes, and the batch flushes early.

Cause

Bun installs a JSC onEachMicrotaskTick handler, checkIfNextTickWasCalledDuringMicrotask, to catch m_nextTickQueue being created for the first time in the middle of a vm.drainMicrotasks() call. The checkpoint in GlobalObject::drainMicrotasks has already passed its m_nextTickQueue null check by then, so without the hook those ticks would not run until the next event loop turn.

The handler fires after every microtask. On the first one that sees a non-null queue it uninstalls itself and calls JSNextTickQueue::drain, which runs processTicksAndRejections: ticks first, then microtasks. When that microtask was an ordinary promise reaction, the tick preempts the reaction's siblings.

The hook firing at that exact spot is also what gives the entry module's top-level code its nextTick checkpoint. Bun evaluates module graphs from inside the microtask queue, so "the entry script's top-level code just ran" and "an ordinary microtask just ran" look identical to the hook, but Node orders them differently:

process.nextTick(() => console.log("tick0"));
Promise.resolve().then(() => console.log("p1"));
// node (CommonJS entry) and bun both print: tick0, p1

So the hook cannot simply wait for the microtask queue to drain; that would flip the case above.

Fix

Separate the two cases.

Mark the microtask in which a script's synchronous top-level code ran by setting nextTickQueueCheckpointAtEndOfMicrotask on the global right after it, at all three places that run one:

  • GlobalObject::moduleLoaderEvaluate, for ES modules
  • EvalGlobalObject::moduleLoaderEvaluate, for bun -e
  • evaluateCommonJSModuleOnce, for CommonJS bodies, which evaluate during module linking and never reach moduleLoaderEvaluate (any script that calls require is classified as CommonJS, so this is the common case for Node-style scripts)

checkIfNextTickWasCalledDuringMicrotask consumes that flag. At a script top-level boundary it hands off immediately, exactly as before. At an ordinary microtask boundary it returns until vm.defaultMicrotaskQueue() is empty, matching Node.

Because the handoff can now stay pending across several microtasks, resetOnEachMicrotaskTick can no longer use a non-null m_nextTickQueue as its signal that the handoff already happened. A dedicated nextTickQueueHandoffDone bit replaces it: the hook sets the bit when it hands off and is a no-op afterwards, and cleanupAsyncHooksData (which AsyncLocalStorage.enterWith installs into the same hook slot) just restores whatever that state machine says and runs the hook once. That keeps an enterWith in a sibling microtask from orphaning a pending handoff, and keeps a later steady-state enterWith from reading a checkpoint flag left over from an unrelated lazy require(). Zig__GlobalObject__createForTestIsolation (bun test --isolate) now re-arms the hook for each fresh global, matching Zig__GlobalObject__create, so the invariant also holds across the per-file global swap.

The hook's behavior at script top-level boundaries is unchanged; only ordinary microtask boundaries change. Also replaces the lambda installed at VM creation with the identical checkIfNextTickWasCalledDuringMicrotask.

Known residual, unchanged from current Bun: a microtask that lazily require()s a not-yet-evaluated CommonJS module (or is the reaction that evaluates a dynamic import() root) also counts as a module-evaluation boundary, so a process.nextTick queued there still runs before that microtask's siblings. Stock Bun orders those the same way today. Narrowing the boundary to the entry module alone needs an entry-specifier check that would also change behavior for --preload modules, bun test files, and dynamic import() roots, so that is left out of scope here.

Verification

New tests in test/js/node/process/process-nexttick.test.js spawn fresh subprocesses (the hook is one-shot) across a (bun -e vs bun <file>) x (ES module vs CommonJS) matrix:

  • queued from inside a microtask waits for the microtask queue: fails on the current build with ["p1","tick1","p2","q3","p2-nested","tick2","p4"] in all four variants and passes with the fix, producing Node's ["p1","p2","q3","p2-nested","tick1","tick2","p4"].
  • waits for microtasks queued by the same microtask after the nextTick: the variant where the microtask queue is empty going in and the microtask itself supplies the pending siblings after the process.nextTick call. Fails on the current build with ["nt1","nt2","qm","pt","nt-from-qm"] in all four variants, passes with the fix producing Node's ["qm","pt","nt1","nt2","nt-from-qm"]. Covers the ordering-conformance repro Promise.resolve().then(() => { process.nextTick(nt); queueMicrotask(qm); Promise.resolve().then(pt); }).
  • queued from the script's top-level code runs before its promise jobs: passes before and after. It pins the checkpoint the moduleLoaderEvaluate / evaluateCommonJSModuleOnce half of the fix preserves, and fails if that half is removed.
  • is preserved across AsyncLocalStorage.enterWith in a sibling microtask: fails on the current build with the preempting order, passes with the fix.
  • steady-state AsyncLocalStorage.enterWith after a lazy require does not preempt: passes on released Bun and with the fix; it pins an ordering an intermediate revision of this branch regressed.
  • bun test --isolate gives each file its own nextTick bootstrap: same, for the --isolate global-swap variant of that regression.

Also ran test/js/node/test/parallel/test-util-promisify.js (the CI failure this revision fixes), the vendored Node suites nearest the change (test-next-tick*.js, test-microtask*.js, test-queue-microtask.js, test-domain*.js, test-promises-unhandled-rejections.js, test-events-once.js, test-event-emitter-error-monitor.js), test/js/node/async_hooks/, and test/js/web/timers/microtask.test.js; all pass.


[review] gate passed · iteration 8 · 4 files touched

fails on main (without fix)
ASAN without fix: 9 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/process/process-nexttick.test.js
bun test v1.4.0 (b2f856141)

test/js/node/process/process-nexttick.test.js:
(pass) process.nextTick [54.06ms]
(pass) process.nextTick 2 args [6.50ms]
(pass) process.nextTick 5 args [8.86ms]
(pass) process.nextTick runs after queueMicrotask [89.04ms]
(pass) process.nextTick can be called 100,000 times [542.82ms]
(pass) process.nextTick works more than once [101.83ms]
(pass) process.nextTick and AsyncLocalStorage.enterWith don't conflict [469.73ms]
1078 |          process.nextTick(() => L.push("tick2"));
1079 |          Promise.resolve().then(() => L.push("p4"));
1080 |          setImmediate(() => console.log(JSON.stringify(L)));
1081 |        });`,
1082 |     );
1083 |     expect(result).toEqual({
                          ^
error: expect(received).toEqual(expected)

  {
    "exitCode": 0,
    "order": [
      "p1",
+     "tick1",
      "p2",
      "q3",
      "p2-nested",
-     "tick1",
      "tick2",
      "p4",
    ],
    "stderr": "",
  }

- Expected  - 1
+ Received  + 1

      at <anony
... (truncated)

release without fix: 9 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/process/process-nexttick.test.js:
(pass) process.nextTick [0.89ms]
(pass) process.nextTick 2 args [0.09ms]
(pass) process.nextTick 5 args [0.14ms]
(pass) process.nextTick runs after queueMicrotask [2.40ms]
(pass) process.nextTick can be called 100,000 times [11.24ms]
(pass) process.nextTick works more than once [1.99ms]
(pass) process.nextTick and AsyncLocalStorage.enterWith don't conflict [2.18ms]
1078 |          process.nextTick(() => L.push("tick2"));
1079 |          Promise.resolve().then(() => L.push("p4"));
1080 |          setImmediate(() => console.log(JSON.stringify(L)));
1081 |        });`,
1082 |     );
1083 |     expect(result).toEqual({
                          ^
error: expect(received).toEqual(expected)

  {
    "exitCode": 0,
    "order": [
      "p1",
+     "tick1",
      "p2",
      "q3",
      "p2-nested",
-     "tick1",
      "tick2",
      "p4",
    ],
    "stderr": "",
  }

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/node/process/process-nexttick.test.js:1083:20)
1078 |          process.nextTick(() => L.push("tick2"));
1079 |          Promise.resolve().then(() =
... (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/js/node/process/process-nexttick.test.js
bun test v1.4.0 (b2f856141)

test/js/node/process/process-nexttick.test.js:
(pass) process.nextTick [52.75ms]
(pass) process.nextTick 2 args [6.65ms]
(pass) process.nextTick 5 args [9.15ms]
(pass) process.nextTick runs after queueMicrotask [116.07ms]
(pass) process.nextTick can be called 100,000 times [451.28ms]
(pass) process.nextTick works more than once [83.48ms]
(pass) process.nextTick and AsyncLocalStorage.enterWith don't conflict [466.55ms]
(pass) process.nextTick interleaving with the microtask queue > queued from inside a microtask waits for the microtask queue (-e-cjs) [529.42ms]
(pass) process.nextTick interleaving with the microtask queue > queued from inside a microtask waits for the microtask queue (-e) [603.16ms]
(pass) process.nextTick interleaving with the microtask queue > waits for microtasks queued by the same microtask after the nextTick (-e) [529.85ms]
(pass) process.nextTick interleaving with the microtask queue > queued from inside a microtask waits for the microtask 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     b2f856141a
  features     baseline

22 deps, 108 codegen, 1171 objects in 928ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [15.00ms]
[2/1234] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [1.00ms]
[3/1234] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [21.00ms]
[4/1234] fetch zlib
[zlib] up to date
[5/1234] gen bindgenv2
[6/1234] fetch picohttpparser
[picohttpparser] up to date
[7/1234] gen ErrorCode+*.h
[8/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[9/1234] fetch tinycc
[tinycc] up to date
[10/1234] gen .bind.ts → GeneratedBindings.cpp
[11/1234] subst deps/zlib/zlib.h
[12/1234] subst deps/zlib/zconf.h
[13/1234] gen JSBuffer.lut.h
Generating /workspace/bun/build
... (truncated)
diff hotspot
src/jsc/bindings/JSCommonJSModule.cpp         |   4 +
 src/jsc/bindings/ZigGlobalObject.cpp          |  39 ++++---
 src/jsc/bindings/ZigGlobalObject.h            |   4 +
 test/js/node/process/process-nexttick.test.js | 152 +++++++++++++++++++++++++-
 4 files changed, 181 insertions(+), 18 deletions(-)

gate history · 2 passed · 0 rejected · iteration 8

evidence per changed file
file                                           reads  edits  tests
src/jsc/bindings/JSCommonJSModule.cpp              3      4      0
src/jsc/bindings/ZigGlobalObject.cpp              12     21      0
src/jsc/bindings/ZigGlobalObject.h                 4      6      0
test/js/node/process/process-nexttick.test.js      5      8      0

…rotask

Bun uses JSC's onEachMicrotaskTick hook to notice when the nextTick queue is
created for the first time during a microtask drain. The hook handed off to
processTicksAndRejections immediately, so a process.nextTick queued from inside
a microtask ran before the microtasks still pending in the queue. Node drains
the whole microtask queue first.

The same hook is also what gives the script's top-level code its nextTick
checkpoint (Bun evaluates modules from inside the microtask queue, so the entry
body's boundary is a microtask boundary). Keep that by having moduleLoaderEvaluate
mark its enclosing microtask, and only let the hook preempt at that boundary;
otherwise wait until the microtask queue is exhausted.
@coderabbitai

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds a nextTickQueueCheckpointAtEndOfMicrotask flag for module evaluation boundaries, updates microtask nextTick draining to consume it, and expands subprocess tests for nextTick and AsyncLocalStorage ordering. Two docs files are reformatted without content changes.

Changes

nextTick/microtask module boundary fix

Layer / File(s) Summary
GlobalObject checkpoint flag declaration
src/jsc/bindings/ZigGlobalObject.h
Adds bool nextTickQueueCheckpointAtEndOfMicrotask with comments describing its write/read contract.
Drain logic and hook wiring
src/jsc/bindings/ZigGlobalObject.cpp
Consumes the checkpoint flag in checkIfNextTickWasCalledDuringMicrotask; updates cleanup, handoff-state handling, and direct on-tick registration.
moduleLoaderEvaluate sets checkpoint
src/jsc/bindings/JSCommonJSModule.cpp, src/jsc/bindings/ZigGlobalObject.cpp
Sets nextTickQueueCheckpointAtEndOfMicrotask = true after module evaluation in both CommonJS and global-object module paths.
Interleaving tests
test/js/node/process/process-nexttick.test.js
Adds runFresh plus subprocess tests covering nextTick/microtask ordering and AsyncLocalStorage interactions.

Doc reformats

Layer / File(s) Summary
base64 and web-apis reformats
docs/guides/util/base64.mdx, docs/runtime/web-apis.mdx
Wraps the btoa/atob snippet in an explicit ts fence and reflows the Web APIs support table spacing.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main change: adjusting nextTick ordering around microtasks.
Description check ✅ Passed The description covers the problem, cause, fix, and verification, which satisfies the template’s intent despite different headings.

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

@robobun

robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:03 PM PT - Jul 28th, 2026

❌ @robobun, your commit b2f8561 has 1 failures in Build #84474 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33088

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

bun-33088 --bun

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/ZigGlobalObject.cpp`:
- Around line 3747-3752: Move the pending-exception check ahead of the nextTick
checkpoint write in both GlobalObject::moduleLoaderEvaluate overrides. After
moduleLoader->evaluateNonVirtual, call RETURN_IF_EXCEPTION before setting
defaultGlobalObject()->nextTickQueueCheckpointAtEndOfMicrotask so a failed
module evaluation does not arm a process.nextTick checkpoint. Apply the same
ordering in both moduleLoaderEvaluate implementations in ZigGlobalObject.cpp.

In `@test/js/node/process/process-nexttick.test.js`:
- Around line 1066-1070: The test in process-nexttick.test.js is using 1ms
setTimeout calls just to advance to the next task turn, which ties the ordering
assertion to timer latency. Replace those task-checkpoint timers in the affected
nextTick ordering cases with setImmediate (or equivalent immediate task
scheduling) so the observable ordering in the test is driven by the event loop
turn rather than elapsed time, keeping the checks in line with the
nextTick-related test helpers and assertions.
- Around line 1052-1053: The spawned Bun subprocess helper currently reads only
proc.stdout and leaves proc.stderr undrained, which can block on noisy failures
and hides useful diagnostics. Update the relevant subprocess test helper(s)
around the Promise.all call to drain proc.stderr concurrently with
stdout/exited, return stderr alongside order and exitCode, and include stderr in
the assertions so clean runs expect an empty string and failures surface the
captured error output.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8b339366-06c0-45a3-90c6-19fd7c538208

📥 Commits

Reviewing files that changed from the base of the PR and between fb24aac and 048481b.

📒 Files selected for processing (5)
  • docs/guides/util/base64.mdx
  • docs/runtime/web-apis.mdx
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • test/js/node/process/process-nexttick.test.js

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
- pipe and assert stderr from the spawned subprocesses
- schedule the next task turn with setImmediate instead of a 1ms timer
- run the four independent subprocess cases concurrently
@mintlify

mintlify Bot commented Jun 29, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 29, 2026, 4:35 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
…e handoff across enterWith

nextTickQueueCheckpointAtEndOfMicrotask was only set by moduleLoaderEvaluate, but a
CommonJS-classified entry (any script that calls require) evaluates its body through
evaluateCommonJSModuleOnce during module linking and never reaches moduleLoaderEvaluate.
The flag was therefore unset at that boundary, so nextTicks queued by the script's
top-level code lost their checkpoint and ran after the promise jobs the script queued.
This is what broke test/js/node/test/parallel/test-util-promisify.js on CI. Set the
flag there too.

Also make cleanupAsyncHooksData hand the onEachMicrotaskTick slot back to the bootstrap
hook unconditionally: the nextTick queue can now exist with the handoff still deferred
to the end of the microtask queue, so a non-null m_nextTickQueue no longer implies the
handoff already happened, and nulling the slot could orphan it.

Expand the test matrix to cover (-e vs file) x (ES module vs CommonJS) and the
AsyncLocalStorage.enterWith composition.
Comment thread src/jsc/bindings/JSCommonJSModule.cpp Resolved
@robobun

robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status at the current head (b2f8561, comments-only tightening on top of 111da2a): build 84474 is green on every lane except test/cli/run/no-orphans.test.ts timing out on :darwin: 26 aarch64.

That is the same test case (bun run --no-orphans (perl): fast-exit intermediate ... daemon still reaped) that timed out on :darwin: 14 x64 in build 72565 (the previous run at 1df255e). It passed on all other darwin lanes in both builds. The test was modified on main in c464531 ("make some tests faster") and reached this branch via the main merge; this PR does not touch bun run or process spawning, and b2f8561 is a comments-only change.

Everything this PR touches (the 22-case process-nexttick.test.js matrix, test/js/node/test/parallel/test-util-promisify.js) is green on every lane. All review feedback has been addressed and resolved. Ready for review.

Earlier builds at b41c382 and prior

Across 66856, 66881, 66894, and 66913, the only test failures were test/js/node/test/parallel/test-net-connect-memleak.js on the two linux-x64-musl lanes (the pre-existing #33044 flake), plus a one-off test/regression/issue/20965.test.ts timeout on :darwin: 14 aarch64 in build 66913 only.

Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
…a m_nextTickQueue

resetOnEachMicrotaskTick used a non-null m_nextTickQueue as its signal that the
onEachMicrotaskTick bootstrap hook had already handed off to
processTicksAndRejections. That stopped being true once the handoff became
deferrable: the queue can exist with the handoff still pending.

dc51e05 made cleanupAsyncHooksData reinstall and invoke the hook
unconditionally to cover that window, but the hook reads
nextTickQueueCheckpointAtEndOfMicrotask, which any post-handoff module
evaluation (a lazy require, a dynamic import root, bun:main's own loader tail)
sets with nothing left to consume it. A later steady-state
AsyncLocalStorage.enterWith then read the stale flag and drained nextTick ahead
of its sibling microtasks, an ordering that both Node and released Bun get
right.

Replace the proxy with an explicit nextTickQueueHandoffDone bit: the hook sets
it when it hands off, resetOnEachMicrotaskTick keys off it, and the hook
early-returns once it is set (still consuming the flag so it cannot go stale).
cleanupAsyncHooksData now just restores whatever that state machine says and
runs the hook once, which also covers the deferred window.
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Zig__GlobalObject__createForTestIsolation (bun test --isolate) creates a fresh
default global on the same VM without touching the per-VM onEachMicrotaskTick
slot, which the previous global's nextTick bootstrap nulls once it hands off.
The fresh global starts with nextTickQueueHandoffDone=false, breaking the
invariant that the bootstrap hook is installed while the handoff is pending.
Its module evaluation then sets nextTickQueueCheckpointAtEndOfMicrotask with
nothing to consume it, and a later AsyncLocalStorage.enterWith inside a
microtask reads the stale flag through cleanupAsyncHooksData and drains
nextTick ahead of the sibling microtasks still in the queue.

Re-arm the hook in createForTestIsolation the same way Zig__GlobalObject__create
does for the first global, so each isolated file's global gets its own
bootstrap.

@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.

All my earlier findings are addressed and this run found nothing new, but this reworks the core nextTick/microtask handoff state machine (with three real regressions caught and fixed along the way), so it's worth a human sign-off before merge.

Extended reasoning...

Overview

The PR changes how Bun orders process.nextTick callbacks relative to the microtask queue when the first nextTick is queued from inside a microtask. It touches ZigGlobalObject.cpp/.h (the onEachMicrotaskTick hook, checkIfNextTickWasCalledDuringMicrotask, cleanupAsyncHooksData, resetOnEachMicrotaskTick, both moduleLoaderEvaluate overrides, and Zig__GlobalObject__createForTestIsolation), JSCommonJSModule.cpp (evaluateCommonJSModuleOnce), and adds two new state bits on GlobalObject (nextTickQueueCheckpointAtEndOfMicrotask, nextTickQueueHandoffDone). The two doc-file changes are whitespace-only table reflows. New subprocess tests cover a 4-way (-e × file × ESM × CJS) matrix plus AsyncLocalStorage.enterWith composition and bun test --isolate.

Security risks

None identified. This is event-loop ordering, not auth/crypto/permissions/input handling.

Level of scrutiny

High. This is the runtime's microtask/nextTick handoff state machine — it composes with module evaluation (ESM and CJS), AsyncLocalStorage.enterWith's shared onEachMicrotaskTick slot, and the per-file global swap in bun test --isolate. Subtle mistakes here change observable ordering for every program. The review history bears this out: three of my earlier inline findings were confirmed real regressions introduced by intermediate revisions of this PR (the deferred-handoff/enterWith race fixed in dc51e05, the steady-state stale-flag preemption fixed in 822c4e9, and the --isolate invariant break fixed in b41c382), each now pinned by a dedicated test.

Other factors

All prior review comments (mine and CodeRabbit's) are resolved, the bug-hunting system found nothing on the current head, and the test matrix is thorough. The PR description documents a known residual (lazy require() inside a microtask still counts as a checkpoint boundary, unchanged from released Bun) that is explicitly out of scope. The only CI failure on the latest build is a pre-existing musl flake (#33044) unrelated to this change. Given the criticality of the code path and the iteration it took to converge, a maintainer should give the final state machine a once-over rather than relying solely on automated review.

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

While investigating the onEachMicrotaskTick hook I built a 7-case interleaving characterization in the same file (process-nexttick.test.js), on branch farm/10bc3441/nexttick-interleaving-tests. I'm not opening it as a PR: four of its cases pin the exact pre-change orderings this PR intentionally flips, so as written it would only conflict with this change.

Offering them in case any are worth folding in with Node's expectations instead:

  • Cases A, E, F, and G cover a tick scheduled from an ordinary microtask through a throwing tick, an unhandled rejection raised inside a tick, and a microtask that throws right after creating the queue. Tracing them through this PR's handoff logic by hand, all four should flip to Node's ordering. I derived that from the diff and did not build this branch, so treat it as a prediction.
  • Case B covers the handoff taken when the tick queue is empty at the boundary (the vm.drainMicrotasks() at JSNextTickQueue.cpp:83, rather than the JSC::call(processTicksAndRejections) path).
  • Case C is a 6-level alternating tick/microtask chain that needs three consecutive re-entrant drainMicrotasks() calls to complete. It earned its place: a "skip the drain if a checkpoint is already running" guard on that re-entry looks harmless and silently drops 4 of case C's 7 callbacks.

All seven pass on main as pinned. Feel free to take or ignore any of them.

@robobun

robobun commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

This also fixes the top-level-await continuation variant of the same ordering: after an await at module top level resumes (via AsyncModuleExecutionResume, which bypasses moduleLoaderEvaluate), a process.nextTick queued in the continuation no longer cuts ahead of microtasks already in the queue.

// entry .mjs
await new Promise(r => setTimeout(r, 10));
const O = [];
process.nextTick(() => O.push("nt"));
queueMicrotask(() => O.push("qm"));
await new Promise(r => setTimeout(r, 5));
console.log(O.join(","));
// node: qm,nt   bun main: nt,qm   this branch: qm,nt

Verified on this branch: the resume falls into the ordinary-microtask-boundary path (no nextTickQueueCheckpointAtEndOfMicrotask set) and the hook waits for the microtask queue to empty, which is exactly what Node does here.

@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.

No new issues found this pass, but this changes core microtask/nextTick interleaving and the per-VM onEachMicrotaskTick state machine, so it's worth a maintainer's sign-off.

Reviewed the nextTickQueueHandoffDone / nextTickQueueCheckpointAtEndOfMicrotask state machine across checkIfNextTickWasCalledDuringMicrotask, cleanupAsyncHooksData, resetOnEachMicrotaskTick, and both global-create paths — the invariant "!handoffDone ⇒ bootstrap hook installed" now holds at every writer.
Checked the four flag-write sites (moduleLoaderEvaluate ×2, evaluateCommonJSModuleOnce ×2) for exception-safety and stale-flag reads — the std::exchange + early-return-on-handoffDone consumes writes even post-handoff.
The three composition regressions flagged in earlier passes (enterWith during deferred window, steady-state enterWith after lazy require, --isolate global swap) are each fixed and pinned by a dedicated subprocess test.

Extended reasoning...

Overview

This PR changes how Bun's one-shot process.nextTick bootstrap hook (onEachMicrotaskTick) decides when to hand off to processTicksAndRejections. It introduces two per-global flags (nextTickQueueCheckpointAtEndOfMicrotask, nextTickQueueHandoffDone) written from four module-evaluation sites and read from the hook, rewires cleanupAsyncHooksData and resetOnEachMicrotaskTick to key off the new bit instead of m_nextTickQueue, replaces the VM-creation lambda with the named hook, and re-arms the hook in Zig__GlobalObject__createForTestIsolation. Files: ZigGlobalObject.{cpp,h}, JSCommonJSModule.cpp, plus a 130-line subprocess test matrix.

Security risks

None identified. The change is purely event-loop ordering; no user input parsing, no auth/crypto, no filesystem/network surface. The flag writes are plain bool stores on the thread-local default global.

Level of scrutiny

High. This is core runtime behavior that runs on every process startup and affects the observable ordering of process.nextTick vs promise reactions — a Node-compat contract that user code depends on. The state machine spans a per-VM hook slot and per-global flags, which is exactly the kind of coupling that produced the three real regressions I flagged in earlier passes (all now fixed and each pinned by a test). The PR description explicitly documents a known residual Node-compat gap (lazy require() inside a microtask still counts as a checkpoint boundary) that the author scoped out; that's a design call a maintainer should ratify.

Other factors

  • Four prior review rounds from me each found a real issue; all were fixed with regression tests added. This run's bug hunt found nothing new.
  • Test coverage is thorough: a 4-way (-e/file × ESM/CJS) matrix for both the fixed ordering and the preserved top-level checkpoint, plus targeted tests for each of the three composition regressions and the --isolate global-swap path.
  • CI at the current head is green on the touched tests; the reported failures are the pre-existing musl test-net-connect-memleak flake and an unrelated one-off darwin timeout.
  • The only change since my last inline finding is a merge from main; no substantive edits to the reviewed code.

robobun added 2 commits July 28, 2026 23:19
Adds the matrix variant where the microtask queue is empty going into the
microtask that queues the nextTick, and that same microtask then queues
further microtasks after the nextTick call. Node drains every microtask
(including those) before the tick queue; the hook waits for
defaultMicrotaskQueue().isEmpty() so this already holds.
Comment thread src/jsc/bindings/JSCommonJSModule.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.h Resolved
Comment thread src/jsc/bindings/ZigGlobalObject.h Resolved
The state-machine documentation lives in the field docs and the function names;
the per-site comments only need the cross-reference.

@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.

No bugs found this run, and the three issues raised on earlier revisions (enterWith composing with the deferred handoff, the steady-state stale-checkpoint-flag regression, and the --isolate global-swap re-arm) are all fixed with pinning tests. That said, this rewires the core onEachMicrotaskTick / nextTick bootstrap state machine and carries an explicit scoping decision (lazy require() inside a microtask still counts as a checkpoint boundary), so it's worth a human sign-off.

What was reviewed:

  • The nextTickQueueHandoffDone / nextTickQueueCheckpointAtEndOfMicrotask state machine and its interaction with cleanupAsyncHooksData / resetOnEachMicrotaskTick.
  • The four flag writers (both moduleLoaderEvaluate overrides and both evaluateCommonJSModuleOnce paths) and the createForTestIsolation re-arm.
  • The new subprocess test matrix — each case maps to a specific transition in the hook's state and pins Node's ordering.
Extended reasoning...

Overview

This PR changes when Bun hands off from JSC's microtask queue to the process.nextTick queue during the one-shot bootstrap window. Previously the onEachMicrotaskTick hook drained the tick queue at the first microtask boundary that saw a non-null m_nextTickQueue, which let a nextTick queued from inside a microtask preempt its sibling microtasks. The fix introduces two per-global bits — nextTickQueueCheckpointAtEndOfMicrotask (set by the four module-evaluation entry points) and nextTickQueueHandoffDone (a one-shot latch replacing the "m_nextTickQueue is non-null" heuristic) — so the hook hands off immediately at a module top-level boundary and otherwise waits until the microtask queue is empty. cleanupAsyncHooksData and resetOnEachMicrotaskTick are rewritten to key off the new latch, Zig__GlobalObject__create's inline lambda is deduplicated into the named hook, and Zig__GlobalObject__createForTestIsolation now re-arms the hook per fresh global.

Security risks

None. This is purely event-loop ordering; no user input parsing, auth, crypto, or resource handling is touched. The new fields are plain bools on the global object.

Level of scrutiny

High. Microtask/nextTick interleaving is one of the most correctness-sensitive areas of a JS runtime — subtle changes here can break real-world packages that rely on Node's ordering (the batching pattern named in the PR description). The state machine spans a per-VM hook slot (onEachMicrotaskTick) and per-global bits, and composes with AsyncLocalStorage.enterWith's use of the same slot and with bun test --isolate's per-file global swap. The review history bears this out: three separate real regressions were introduced and fixed across intermediate revisions of this PR before reaching the current state. That is not a reason to block, but it is a strong signal that a maintainer familiar with GlobalObject::drainMicrotasks / JSNextTickQueue::drain should read the final state machine.

Other factors

  • All three prior findings are now covered by dedicated subprocess tests, plus a fourth pinning the top-level-checkpoint invariant that would flip if the module-evaluation half of the fix were removed. The gate evidence shows all five new failing-before cases fail on main and pass on the branch (debug+ASAN and release).
  • There is an explicitly documented residual (evaluateCommonJSModuleOnce marks every CJS body, so a lazy require() inside a microtask still preempts — unchanged from stock Bun). The author declined to narrow it further to avoid changing --preload / bun test / dynamic-import semantics in the same PR. That scoping call is reasonable but is exactly the kind of design decision a human should ratify.
  • The comment-cop feedback about paragraph-long comments was addressed in b2f8561 and the current diff's comments are one-liners.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

One more shape of the same ordering came in from another session: the first process.nextTick call happens in a later event loop turn, from a promise reaction inside a timer callback, long after the entry module's top-level code finished.

const order = [];
function chain(n) { return n === 0 ? Promise.resolve() : Promise.resolve().then(() => chain(n - 1)); }
setTimeout(() => {
  Promise.resolve().then(() => {
    chain(5).then(() => order.push("chain done"));
    process.nextTick(() => order.push("nextTick"));
  });
  setTimeout(() => console.log(order.join(" -> ")), 10);
}, 0);
// node v26.3.0:  chain done -> nextTick
// bun 1.4.0:     nextTick -> chain done

As with the cases already in the PR it only happens on the process's first process.nextTick call. With a process.nextTick(() => {}) earlier in the script, released Bun already prints Node's order through the empty-queue path in JSNextTickQueue::drain (full vm.drainMicrotasks(), then processTicksAndRejections), and the same holds for a tick queued from a microtask that runs inside the tick drain's own runMicrotasks().

Checked this branch merged onto current main (8c5296a): the snippet prints Node's order as a CommonJS file, as an .mjs file with a 30-deep chain, and through bun -e, and the 22 cases in process-nexttick.test.js still pass there. It would make a cheap extra case for the matrix, since it pins that the checkpoint flag consumed at the end of the entry module's evaluation does not carry over into a later checkpoint, but the fix as written covers it.

Two rebase notes:

  • The branch currently conflicts with main in GlobalObject::moduleLoaderEvaluate: main added a noteModuleEvaluation(...) call ahead of evaluateNonVirtual. Keeping that line plus this PR's result capture resolves it; it is the only conflict.
  • process: keep the onEachMicrotaskTick nextTick hook armed across preloads #34121 (open, mergeable) rewrites the same onEachMicrotaskTick slot (keeps the hook armed permanently and removes asyncHooksNeedsCleanup), and its description defers the microtask FIFO ordering to this PR. Whichever of the two lands first, the other needs reworking over the same state machine.

@robobun

robobun commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

#40665 carries the hand-off half of this change on its own: the one-shot's state moves to JSNextTickQueue::handedOff() instead of a Zig::GlobalObject field, cleanupAsyncHooksData restores the displaced hook and runs the one-shot once, the VM-create lambda becomes the named function, and Zig__GlobalObject__createForTestIsolation re-arms the slot for the fresh global. If it lands first, this PR shrinks to the module-boundary checkpoint and its ordering tests, and can read queue->handedOff() in place of nextTickQueueHandoffDone.

Jarred-Sumner pushed a commit that referenced this pull request Sep 17, 2026
…#42815)

Follow-up to #42796, which is merged. This branch is rebased onto
`main`.

### Problem

- Since #42762, a `Bun.serve` response that completes inside `onData`
stays corked until `onData` returns, so JavaScript that runs after it
delays it. One POST, not pipelined: the handler does `await req.json()`,
starts work that it does not await, and returns a 202. The client waits
504 ms on `main` for 500 ms of trailing work. Bun 1.4.2: 2 ms.
- `process.exit()` or `server.stop(true)` in that trailing work loses
the response.
- Cause: `HttpResponse::uncorkCompletedResponse()`
(`packages/bun-uws/src/HttpResponse.h:127`) leaves the cork of the
socket that `onData` parses to `onData`.

### Fix

- New response state bit `HTTP_SEND_WHEN_COMPLETE`.
`prepare_js_request_context` and the WebSocket upgrade fallback set it
next to the `send_corked()` call of #42796. `uncorkCompletedResponse()`
uncorks a marked response. `onData` still runs the close gate.
- A response that JavaScript produced leaves when it completes, as on
1.4.2. Static route responses still batch, and #42796 sends them before
the next handler runs JavaScript.
- Cost against `main` with #42796: none. Same `send()` count, req/s
within noise (133k and 140k at depth 16).
- Verified: `test/js/bun/http/serve.test.ts`. Two new tests and the
mixed sequence of the segment test fail on `main` and pass here. The new
tests pass on 1.4.2.

### Background

- Cork: uWS collects the writes of a socket in a 16 KB buffer and sends
them with one `send()` at uncork. `onData` corks for the whole read.
- The request body callback runs inside `onData`. A handler that awaited
the body resumes there, and the microtask queue drains before `onData`
returns.
- `internalEnd()` calls `uncorkCompletedResponse()` when a response
completes on a corked socket. `node:http` responses are already sent
there.

<details><summary>Notes</summary>

**Origin.** The review of #42796 found this case and measured a
prototype of this shape. #42796 lists it under "Not covered". #42762 and
#42796 are in no release tag.

**When the trailing work runs.** Median time to the first response byte
over 5 requests, one POST each on a fresh connection, handler awaits
`req.json()`, 300 ms of trailing work. Release builds.

| trailing work | 1.4.2 | main 7d792a5 | #42796 | this PR |
|---|---|---|---|---|
| async function, 3 awaits in | 2.6 ms | 301.8 ms | 300.6 ms | 0.3 ms |
| `.then().then().then(busy)` | 0.7 ms | 300.7 ms | 301.5 ms | 0.7 ms |
| `queueMicrotask` 3 deep | 0.3 ms | 300.8 ms | 300.6 ms | 0.4 ms |
| `process.nextTick(busy)` | 0.4 ms | 301.2 ms | 300.7 ms | 0.4 ms |
| `process.nextTick` 2 deep | 0.4 ms | 300.5 ms | 300.6 ms | 0.3 ms |
| `Promise.resolve().then(busy)` (control) | 300.5 ms | 301.9 ms | 302.5
ms | 300.7 ms |
| `setTimeout(busy, 0)` (control) | 0.3 ms | 0.5 ms | 0.9 ms | 0.3 ms |

The first control runs before the response completes, so it waits on
every build. The second control runs after `onData` returns, so it never
waits. The rows between them run after the response completes and before
`onData` returns. That is the regression, and this PR restores all of
them.

The first `process.nextTick` call of a process is a separate case. Its
callback runs before the microtasks that are still queued, so the first
request of the `process.nextTick` rows waits 304 ms on every build,
1.4.2 included. #33088 covers that ordering bug. The tests here use the
async function form for that reason.

**Response shapes.** The same measurement, trailing work 3 awaits in, by
the kind of response.

| response | 1.4.2 | main 7d792a5 | this PR |
|---|---|---|---|
| `new Response("accepted", { status: 202 })` | 2.6 ms | 301.8 ms | 0.3
ms |
| `Response.json(...)` | 0.7 ms | 300.7 ms | 0.3 ms |
| `Response.redirect(...)` | 0.7 ms | 300.6 ms | 0.7 ms |
| `new Response(null, { status: 204 })` | 1 ms | 502 ms | 1 ms |
| `new Response("")` | 1 ms | 502 ms | 1 ms |
| `new Response(new ReadableStream(...))` | 2 ms | 508 ms | 2 ms |
| `new Response(Bun.file(...))`, 1 KB and 8 KB | 0.7 ms | 300.6 ms | 0.7
ms |
| `new Response(Bun.file(...))`, 64 KB | 0.8 ms | 0.8 ms | 1.1 ms |
| `new Response(null, { status: 304 })` | 502 ms | 502 ms | 502 ms |

The 204, empty, stream and 304 rows used 500 ms of trailing work. A file
body larger than the 16 KB cork buffer releases the cork on its own, so
the 64 KB row never waited. The 304 row does not change. That response
ends through `uws_res_end_without_body`, which does not call
`internalEnd()` and never uncorked. It behaves the same on 1.4.2, so it
is not part of this regression.

**Segment test.** The mixed sequence `[static static static js static
static js static]` goes from 3 segments to 5: `[static static static]
[js] [static static] [js] [static]`. A JavaScript response no longer
shares its `send()` with the static responses behind it. `fetch` 8 of 8,
route function 8 of 8, static 8 in 1, and the `100 Continue` plus 413
case in 1 do not change.

**Why a mark, and who sets it.** The two places that hand a request to
user JavaScript are the same two places that call `send_corked()` in
#42796. They set the mark after `resetResponseState()` has run for the
request, and the next request clears it. Everything that is not marked
keeps the batching of #42762. The static responses directly before a
handler that blocks or exits are what the `send_corked()` call of #42796
is still needed for, and its tests pin that case.

**Throughput.** Release builds, loopback, 8 connections, batches of 16,
2,304,000 requests, interleaved runs. #42796: 134k, 139k, 117k, 131k,
134k, 141k req/s. This PR: 151k, 111k, 144k, 156k, 139k, 139k req/s.
`main`: 320k to 368k. Static routes: 0.9M to 1.2M req/s on all three.
Depth 1: the same on all three.

**Tests.** "sends a response before the JavaScript that runs after it":
the work that the handler leaves behind blocks the server thread in
`Atomics.wait` until the client has the response (the server runs in a
worker). Without the fix the wait times out and reports `timed-out`.
"delivers a response when the JavaScript that runs after it ends the
process": the trailing work calls `process.exit(0)`. Without the fix the
client receives nothing. It is skipped on Windows for the same reason as
the `process.exit()` test of #42796.

**Suites run with the debug build.** `serve.test.ts`,
`bun-server.test.ts`, `request-smuggling.test.ts`,
`http-server-chunking.test.ts`, `bun-serve-static.test.ts`,
`bun-serve-routes.test.ts`, `bun-serve-file.test.ts`,
`bun-serve-headers.test.ts`, `bun-serve-body-json-async.test.ts`,
`serve-close-delimited-framing.test.ts`, `serve-if-none-match.test.ts`,
`serve-body-leak.test.ts`, `serve-error-handler-stream.test.ts`,
`serve-direct-readable-stream.test.ts`, `hspec.test.ts`, and three
`websocket-server-*.test.ts` files. `websocket-server.test.ts` passes
142 of 142 with the release build. Three tests fail in this container
for reasons of the container (a root user can bind port 1, no IPv6, an
egress proxy answers for the external address). They fail the same way
with a release build of `main`.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 2 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/bun/http/serve.test.ts

<!-- robobun:evidence:end -->
@robobun

robobun commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status, from work on a sibling of this bug (#48849).

This branch was successfully deployed

1 active (outdated) deployment
staging - docs — 111da2a6 Deployed Jul 28, 2026 by mintlify[bot]
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