Repository navigation
Conversation
…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.
WalkthroughAdds a ChangesnextTick/microtask module boundary fix
Doc reformats
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 9:03 PM PT - Jul 28th, 2026
❌ @robobun, your commit b2f8561 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 33088That installs a local version of the PR into your bun-33088 --bun |
There was a problem hiding this comment.
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
📒 Files selected for processing (5)
docs/guides/util/base64.mdxdocs/runtime/web-apis.mdxsrc/jsc/bindings/ZigGlobalObject.cppsrc/jsc/bindings/ZigGlobalObject.htest/js/node/process/process-nexttick.test.js
- 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
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
…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.
|
CI status at the current head (b2f8561, comments-only tightening on top of 111da2a): build 84474 is green on every lane except That is the same test case ( Everything this PR touches (the 22-case Earlier builds at b41c382 and priorAcross 66856, 66881, 66894, and 66913, the only test failures were |
…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.
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.
There was a problem hiding this comment.
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.
|
While investigating the Offering them in case any are worth folding in with Node's expectations instead:
All seven pass on main as pinned. Feel free to take or ignore any of them. |
|
This also fixes the top-level-await continuation variant of the same ordering: after an // 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,ntVerified on this branch: the resume falls into the ordinary-microtask-boundary path (no |
There was a problem hiding this comment.
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--isolateglobal-swap path. - CI at the current head is green on the touched tests; the reported failures are the pre-existing musl
test-net-connect-memleakflake 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.
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.
The state-machine documentation lives in the field docs and the function names; the per-site comments only need the cross-reference.
There was a problem hiding this comment.
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/nextTickQueueCheckpointAtEndOfMicrotaskstate machine and its interaction withcleanupAsyncHooksData/resetOnEachMicrotaskTick. - The four flag writers (both
moduleLoaderEvaluateoverrides and bothevaluateCommonJSModuleOncepaths) and thecreateForTestIsolationre-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 (
evaluateCommonJSModuleOncemarks every CJS body, so a lazyrequire()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.
|
One more shape of the same ordering came in from another session: the first 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 doneAs with the cases already in the PR it only happens on the process's first Checked this branch merged onto current main (8c5296a): the snippet prints Node's order as a CommonJS file, as an Two rebase notes:
|
|
#40665 carries the hand-off half of this change on its own: the one-shot's state moves to |
…#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 -->
|
Status, from work on a sibling of this bug (#48849).
|
Problem
A
process.nextTickcallback 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.It only reproduces on the process's first
process.nextTickcall, 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
onEachMicrotaskTickhandler,checkIfNextTickWasCalledDuringMicrotask, to catchm_nextTickQueuebeing created for the first time in the middle of avm.drainMicrotasks()call. The checkpoint inGlobalObject::drainMicrotaskshas already passed itsm_nextTickQueuenull 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 runsprocessTicksAndRejections: 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:
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
nextTickQueueCheckpointAtEndOfMicrotaskon the global right after it, at all three places that run one:GlobalObject::moduleLoaderEvaluate, for ES modulesEvalGlobalObject::moduleLoaderEvaluate, forbun -eevaluateCommonJSModuleOnce, for CommonJS bodies, which evaluate during module linking and never reachmoduleLoaderEvaluate(any script that callsrequireis classified as CommonJS, so this is the common case for Node-style scripts)checkIfNextTickWasCalledDuringMicrotaskconsumes that flag. At a script top-level boundary it hands off immediately, exactly as before. At an ordinary microtask boundary it returns untilvm.defaultMicrotaskQueue()is empty, matching Node.Because the handoff can now stay pending across several microtasks,
resetOnEachMicrotaskTickcan no longer use a non-nullm_nextTickQueueas its signal that the handoff already happened. A dedicatednextTickQueueHandoffDonebit replaces it: the hook sets the bit when it hands off and is a no-op afterwards, andcleanupAsyncHooksData(whichAsyncLocalStorage.enterWithinstalls into the same hook slot) just restores whatever that state machine says and runs the hook once. That keeps anenterWithin a sibling microtask from orphaning a pending handoff, and keeps a later steady-stateenterWithfrom reading a checkpoint flag left over from an unrelated lazyrequire().Zig__GlobalObject__createForTestIsolation(bun test --isolate) now re-arms the hook for each fresh global, matchingZig__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 dynamicimport()root) also counts as a module-evaluation boundary, so aprocess.nextTickqueued 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--preloadmodules,bun testfiles, and dynamicimport()roots, so that is left out of scope here.Verification
New tests in
test/js/node/process/process-nexttick.test.jsspawn fresh subprocesses (the hook is one-shot) across a (bun -evsbun <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 theprocess.nextTickcall. 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 reproPromise.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 themoduleLoaderEvaluate/evaluateCommonJSModuleOncehalf 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--isolateglobal-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/, andtest/js/web/timers/microtask.test.js; all pass.[review] gate passed · iteration 8 · 4 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 2 passed · 0 rejected · iteration 8
evidence per changed file