Skip to content

process.nextTick: run the tick pass only when a tick is scheduled - #43826

Merged
alii merged 3 commits into
mainfrom
robobun/f8bb34dd/fs-nexttick-read-at-call-site
Sep 24, 2026
Merged

alii merged 3 commits into
mainfrom
robobun/f8bb34dd/fs-nexttick-read-at-call-site

Conversation

@robobun

@robobun robobun commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A read of process.nextTick creates the tick queue, and node:fs (since node:fs: call callbacks from the native completion, not a promise reaction #43728) and node:stream read it at load. Every checkpoint (Zig::GlobalObject::drainMicrotasks) then calls JSNextTickQueue::drain: +124 instructions per setImmediate callback.
  • After the first tick, the scheduled flag (field 0 of the queue cell) is never cleared. Every later checkpoint calls processTicksAndRejections in JS for an empty queue: +2796 instructions per callback (+598 with the JIT).
  • With no tick queue, Process__dispatchOnBeforeExit drains nothing, so microtasks queued by a beforeExit listener never run. Node runs them.

Fix

  • The checkpoint runs the tick pass only when the flag is set, drains the microtasks once, then reads the flag again. processTicksAndRejections clears the flag when the queue is empty. Costs now: +5 and +8.
  • beforeExit ends with that same checkpoint.
  • node:fs keeps its capture. Without it (the first commit), a replaced process.nextTick held seven callbacks.
  • Verified: process-nexttick.test.js, process.test.js (two new tests fail on main), fs.test.ts, 108 node tests.

Background

  • Zig::GlobalObject::drainMicrotasks is Bun's checkpoint, not JSC's microtask drain. After each event loop callback it runs the scheduled process.nextTick callbacks, then vm.drainMicrotasks().
  • Considered: create the queue on the first process.nextTick() call. node:stream users would then get the first-tick order below.

Downsides

Notes

All numbers compare release builds, Linux x64: main dc55830bb and this PR (based on 6d504dd98, four unrelated commits later).

Calls, exact

Breakpoint hit counts from gdb on the unstripped release binaries, over 10,000 setImmediate callbacks, each scheduled from the one before it. GlobalObject::drainMicrotasks runs 20,004 times in every row.

the script first does JSNextTickQueue::drain on main this PR
nothing 0 0
import "node:fs" 20,004 1
import "node:stream" 20,004 1
one read of process.nextTick 20,003 1
one process.nextTick(() => {}) call 20,003 1

A node:http server with a keep-alive client in the same process, 300 requests one after the other: 1,509 checkpoints on both builds. JSNextTickQueue::drain runs in 1,509 of them on main and in 603 with this PR. Those 603 have a tick scheduled.

Instructions per callback

perf_event_open returns EPERM on this machine, so the counts come from qemu-x86_64 -one-insn-per-tb -d exec,nochain, which logs each guest instruction with its thread. I count the main thread only. BUN_JSC_useGC=0 keeps collections out of the count. Each value is the slope between two N, so startup cancels, and it is the median of 3 runs. The table gives the difference against a script that never touches process.nextTick, on the same build.

JIT off (BUN_JSC_useJIT=0), N = 4,000 and 14,000:

shape the script first does main this PR
14,000 setImmediate up front (one checkpoint per callback, 1809 instructions with no tick queue) import "node:fs" +123.8 +5.2
import "node:stream" +122.5 +9.5
one process.nextTick() call +2796.4 +8.3
14,000 setTimeout(fn, 0) up front (2401) import "node:fs" +134.3 +5.6
one process.nextTick() call +2804.7 +8.4
chained setImmediate (two checkpoints per callback, 3214, runs spread by about 100) import "node:fs" +308.3 -6.9
one process.nextTick() call +5908.2 +24.3

Default JIT, N = 20,000 and 60,000, setImmediate up front (1433 instructions with no tick queue):

the script first does main this PR
import "node:fs" +122.6 +10.4
one process.nextTick() call +598.4 +1.6

Every callback schedules a tick (setImmediate(() => process.nextTick(noop))), JIT off. With 14,000 of them up front, every checkpoint has a tick pending: 9756.9 instructions per callback on main, 9840.5 with this PR (+83.6, +0.9%). With the default JIT and N = 20,000 and 60,000: 2432.1 and 2436.2 (+4.1, +0.2%). With chained setImmediate the second checkpoint of each callback is idle, and the same callback costs 15,063 on main and 11,983 with this PR.

The binary size does not change: the stripped bun is 80,823,840 bytes on both builds.

What a script can see

After one tick, main runs every promise reaction inside the JS tick pass, so processTicksAndRejections is on its stack:

process.nextTick(() => setImmediate(() => Promise.resolve().then(() => console.log(new Error().stack))));
  • main: at <anonymous> (file:1:86), then at processTicksAndRejections (native:7:39)
  • this PR and node v26.3.0: the first frame only

beforeExit

process.on("beforeExit", async () => {
  await null;
  console.log("microtask");
  process.nextTick(() => console.log("tick"));
});
process.on("exit", () => console.log("exit"));

Node v26.3.0 and this PR print microtask, tick, exit. Main and bun 1.4.3-canary.1+367d939d9 print exit. With one read of process.nextTick at the top, main prints all three, because the tick queue then exists.

The first commit and the review

The first commit removed the capture from fs.ts and read process.nextTick at the call sites. The review found three things, and I confirmed each on node, the last release, main and that commit:

  1. A process.nextTick that user code replaces held the callbacks of cp, rm, recursive rmdir, opendir, Dir.read, Dir.close and glob. Node holds cp and a buffered Dir.read. The capture is back, and a new test in fs.test.ts covers the eight callbacks.
  2. The beforeExit bug above. node:fs: call callbacks from the native completion, not a promise reaction #43728 hid it for programs that load node:fs, because its capture created the tick queue.
  3. The first-tick order in Downsides. Deleting the startup hook (onEachMicrotaskTick) fixes it, but the hook is also what runs the ticks of a CommonJS entry point before its promise jobs: without it a .cjs file that does process.nextTick(t); Promise.resolve().then(m) prints microtask tick, and the existing test "a tick that throws goes to uncaughtException..." fails. Run process.nextTick callbacks before promise jobs in a CommonJS entry point after a preload or in a node worker #43366 arms that drain from the evaluation of the entry point. After it, the hook can go.

Verification

  • process-nexttick.test.js: a promise reaction does not run inside processTicksAndRejections fails on main (reaction: true) and when the flag reset is deleted. The idle-path case with a tick that a microtask schedules fails when the second read of the flag is deleted (microtask microtask 2 next immediate). 11 pass with this PR.
  • process.test.js: the new beforeExit test fails on main (prints exit only). Whole file with the ASAN debug build: 173 pass, 1 fail. The failure is process, which needs process.env.USER, and this container has none.
  • fs.test.ts, the process.nextTick tests: 46 pass.
  • Node's test/parallel files for next-tick, microtask, promise, async-hooks, AsyncLocalStorage, timers, beforeExit, exit, stream destroy, vm microtask and worker exit: 108 files pass.
  • test/js/node/{async_hooks,timers,vm,events} and test/js/web/timers, ASAN debug build: 680 pass, 7 fail. All 7 are memory or time limits of the debug build: five RSS leak checks whose threshold is not widened for a debug binary that is not named bun-asan, and two 5 s timeouts. The three setTimeout leak fixtures give 0 to 3 MB on both release builds (limit 10 MB).
  • BUN_JSC_validateExceptionChecks=1 on the beforeExit and idle-path scripts: clean.

no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/process/process.test.js, test/js/node/fs/fs.test.ts

The first read of `process.nextTick` creates the tick queue. When the tick
queue exists, every event loop task drains it. `node:fs` read the property
at load, so a program that imports `node:fs` and never calls
`process.nextTick` paid for the drain in every `setImmediate` and
`setTimeout` callback.

The callback APIs that still need `process.nextTick` (`cp`, `rm`, recursive
`rmdir`, `Dir.read`, `Dir.close`, `glob`, `opendir`, the aborted-signal path
of `stat`, and the Windows `realpath` loop) read it when they call it.
@robobun

robobun commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the rework is pushed (head a0fe7ab). The PR now changes the checkpoint in GlobalObject::drainMicrotasks and the beforeExit dispatch, and src/js/node/fs.ts is the same as on main again. The three review findings have replies.

CI on a0fe7ab (build 120123): 180 of 181 jobs passed, on every platform. The diff is green. The one failed job does not come from this PR:

  • It is the macOS arm64 test shard, on test/js/bun/console/console-write.test.ts (an awaited console.write rejects with EPIPE when the reader has hung up (several arguments)). The child reports a second write error, ENOTCONN, after the EPIPE that the test expects.
  • This PR does not touch console.write or FileSink. The same test fails the same way in the annotations of about 20 other recent builds from other pull requests, for example 120107 and 120097.
  • The build before it (120109, on 897d5ab) also passed 180 of 181. Its one failure was a different unrelated test (spawn.test.ts, a LeakSanitizer warning on the debian ASAN lane), and that test passed in 120123.

How I reproduced the problems, on a release build of main (dc55830bb), Linux x64:

  1. The cost of a tick queue that exists. A gdb breakpoint on Bun::JSNextTickQueue::drain in the unstripped release binary counts the hits over 10,000 setImmediate callbacks, each scheduled from the one before it:

    the script first does main this PR
    nothing 0 0
    import "node:fs" 20,004 1
    import "node:stream" 20,004 1
    one process.nextTick(() => {}) call 20,003 1
  2. beforeExit:

    process.on("beforeExit", async () => {
      await null;
      console.log("microtask");
      process.nextTick(() => console.log("tick"));
    });
    process.on("exit", () => console.log("exit"));
    • node v26.3.0 and this PR: microtask, tick, exit
    • main and bun 1.4.3-canary.1+367d939d9: exit
  3. What a script sees of the idle tick pass. After one tick, a promise reaction on main has processTicksAndRejections on its stack. With this PR and on node it does not:

    process.nextTick(() => setImmediate(() => Promise.resolve().then(() => console.log(new Error().stack))));

bun bd test test/js/node/process/process.test.js -t "runs the microtasks and the ticks" and bun bd test test/js/node/process/process-nexttick.test.js -t "does not run inside" fail on main and pass with this PR.

@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: a9f01593-a8c3-4124-9cb7-b4863f2e827d

📥 Commits

Reviewing files that changed from the base of the PR and between 897d5ab and a0fe7ab.

📒 Files selected for processing (2)
  • test/js/node/fs/fs.test.ts
  • test/js/node/process/process-nexttick.test.js
💤 Files with no reviewable changes (1)
  • test/js/node/fs/fs.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The changes update next-tick queue draining and beforeExit microtask handling. New subprocess tests cover tick and microtask ordering, filesystem callbacks when process.nextTick is replaced, and callback order before process exit.

Changes

Process tick ordering

Layer / File(s) Summary
Tick and microtask checkpoints
src/js/builtins/ProcessObjectInternals.ts, src/jsc/bindings/JSNextTickQueue.h, src/jsc/bindings/JSNextTickQueue.cpp, src/jsc/bindings/ZigGlobalObject.cpp, test/js/node/process/process-nexttick.test.js, test/js/node/fs/fs.test.ts
The tick queue pending flag is reset after a drain. drainMicrotasks() drains ticks before microtasks and checks for ticks queued by microtasks. Tests cover idle-queue ordering and filesystem callbacks when process.nextTick is replaced.
beforeExit microtask handling
src/jsc/bindings/BunProcess.cpp, test/js/node/process/process.test.js
The beforeExit callback drains microtasks after listeners fire. A subprocess test checks that the awaited microtask and queued tick run before the exit listener.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to a0fe7

No actionable issue is identified; the change is ready for normal merge checks.

🚥 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 identifies the primary change: run the process.nextTick tick pass only when a tick is scheduled.
Description check ✅ Passed The description provides a detailed explanation of the problem, fix, scope, tradeoffs, and verification results. It does not use the exact template headings, but it covers the required information for…

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

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

Beyond the inline findings, I also checked the fs.access aborted-signal early return and the three Windows realpath LOOP re-entries — they share the replaced-nextTick exposure of the first finding rather than adding a new one. The beforeExit and first-tick ordering findings trace to the lazy tick-queue creation and the m_nextTickQueue guard in Process__dispatchOnBeforeExit (src/jsc/bindings/BunProcess.cpp:865), not to fs.ts logic, so the layer to fix them at is worth a human call.

Extended reasoning...

The change removes the module-load capture of process.nextTick in src/js/node/fs.ts and reads it at 11 call sites, plus two subprocess tests in test/js/node/fs/fs.test.ts; no security-sensitive surface is touched. Three confirmed findings are posted inline, and the remaining call sites were checked and found to be the same class as the first finding. The two ordering/beforeExit findings are pre-existing runtime behaviors that this PR newly exposes for fs-only programs, which is a layering decision a maintainer should weigh.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/js/node/fs.ts Resolved
Comment thread src/js/node/fs.ts Resolved
Comment thread src/js/node/fs.ts Resolved
Every checkpoint called `JSNextTickQueue::drain` once a tick queue existed,
and a tick queue exists from the first read of `process.nextTick`. With an
empty queue that call drained the microtasks, and the caller drained them a
second time. After the first tick of a process the scheduled flag stayed
set, so every later checkpoint also called `processTicksAndRejections` in
JS to find an empty queue.

`GlobalObject::drainMicrotasks` now runs the tick pass only when the flag is
set, drains the microtasks once, and reads the flag again for a tick that a
microtask scheduled. `processTicksAndRejections` clears the flag when the
queue is empty. `JSNextTickQueue::isEmpty` is inline.

`node:fs` keeps its load-time capture of `process.nextTick`: this reverts
the call-site reads of the previous commit. The capture costs nothing now,
and a `process.nextTick` that user code replaces does not hold the callbacks
of `fs.cp`, `fs.rm`, `fs.opendir`, `Dir.read`, `Dir.close` and `fs.glob`.

`Process__dispatchOnBeforeExit` ends with that same checkpoint. It drained
only when a tick queue existed, so without one the microtasks that a
'beforeExit' listener queued never ran.
@robobun

robobun commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:11 PM PT - Sep 23rd, 2026

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


🧪   To try this PR locally:

bunx bun-pr 43826

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

bun-43826 --bun

@robobun robobun changed the title node:fs: read process.nextTick at the call sites, not at module load process.nextTick: run the tick pass only when a tick is scheduled Sep 24, 2026

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked whether the scheduled flag can read 0 while the queue holds a tick: it is set on every push (the native Process::queueNextTick goes through the same JS nextTick), cleared only after processTicksAndRejections sees the queue empty, and a throwing tick leaves it set so JSNextTickQueue::drain re-enters — nested checkpoints from inside a tick stay consistent. The re-check after vm.drainMicrotasks() reproduces the base branch's ordering for ticks queued by microtasks, and the beforeExit change reports exceptions inside drainMicrotasks() rather than propagating them, so the trailing RETURN_IF_EXCEPTION is dead but harmless.

Extended reasoning...

The net diff no longer touches src/js/node/fs.ts; it reworks GlobalObject::drainMicrotasks in src/jsc/bindings/ZigGlobalObject.cpp to skip the JS drain call unless the tick queue's scheduled flag is set and re-check after draining VM microtasks, clears that flag at the end of processTicksAndRejections in src/js/builtins/ProcessObjectInternals.ts, and makes Process__dispatchOnBeforeExit drain microtasks unconditionally. No security-sensitive surface is touched. The checkpoint is core event-loop code and the PR title, description and perf numbers describe the reverted fs.ts change rather than this diff, so a maintainer should weigh the actual change.

Comment thread test/js/node/fs/fs.test.ts Resolved
…ange

A promise reaction at an idle checkpoint must not have
`processTicksAndRejections` on its stack. It fails on main, and it fails
when the reset of the scheduled flag is deleted.

The idle-path cases use two immediates of one batch, so that one checkpoint
decides the order. The case with a tick that a microtask schedules fails
when the second read of the flag is deleted.

The fs test for the first tick of a process is gone. With the capture back
in `node:fs` it only repeated the `rm` case of the table above it.

@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 re-reviewed after a0fe7ab and found no bugs; the new process-nexttick tests now pin each clause of the checkpoint change, which resolves my earlier test nit. Because this rewrites the microtask/nextTick checkpoint that every event loop callback runs through, and changes what 'beforeExit' drains, a human look is still worthwhile.

What was reviewed:

  • Traced old vs new GlobalObject::drainMicrotasks control flow: the gated first pass plus the post-microtask re-read reproduces the old empty-queue path (drain → vm.drainMicrotasks → tick loop), and the queue pointer is re-read from the WriteBarrier after JS ran, not held across it.
  • isEmpty() now compares against jsNumber(0) instead of asNumber(); every writer ($putInternalField(..., 0, 0/1), discard) stores an int32, and the flag reset in processTicksAndRejections sits after the do-while exits with the queue empty, so no tick can be stranded with flag 0.
  • Process__dispatchOnBeforeExit calling the full checkpoint: a termination from a listener is taken via takeTerminationOutsideScript and the Rust loop stops on script_allowed(), matching the old pending-exception path.
  • Note the PR description still describes removing the process.nextTick capture from src/js/node/fs.ts; the net diff leaves fs.ts untouched (the capture at fs.ts:28 remains), so the description should be updated to match the checkpoint change the title names.
Extended reasoning...

The net diff is about 40 lines of C++ across ZigGlobalObject.cpp, BunProcess.cpp and JSNextTickQueue.{h,cpp}, a two-line flag reset in the ProcessObjectInternals.ts tick loop, and three new subprocess tests; src/js/node/fs.ts is not in the diff despite the description. It touches no security-sensitive surface (no auth, parsing of untrusted input, or crypto) but does touch the event loop microtask checkpoint and beforeExit dispatch, which every callback in the runtime runs through. The change traced as behavior-preserving on the idle, tick-first, microtask-scheduled-tick, exception and termination paths, and the a0fe7ab tests cover each clause, which is why this is not a block; the hot-path criticality and the stale PR description are why a human should still look rather than an auto-approve. No CODEOWNERS entry covers the changed files, and no third-party CHANGES_REQUESTED review is outstanding.

@alii
alii merged commit b5e2208 into main Sep 24, 2026
5 of 6 checks passed
@alii
alii deleted the robobun/f8bb34dd/fs-nexttick-read-at-call-site branch September 24, 2026 01:24
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