Skip to content

[JSC] The entry scopes of a microtask drain keep their flag in a whole word - #694

Open
robobun wants to merge 1 commit into
mainfrom
robobun/a66075ce/drain-entry-scope-flag-word
Open

robobun wants to merge 1 commit into
mainfrom
robobun/a66075ce/drain-entry-scope-flag-word

Conversation

@robobun

@robobun robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • An object that nothing references survives every full collection made from a microtask job. In a Bun release build, 100 Bun.gc(true) calls from an async function leave one module graph of 50 alive. This happens in 8 of 80 runs.
  • VM::drainMicrotasks() (VM.cpp:1702) and MicrotaskQueue::performMicrotaskCheckpoint() (MicrotaskQueueInlines.h:140) each keep a std::optional<VMEntryScope> on the stack while the jobs run. Its flag is one byte. Nothing writes the other seven bytes of that word: the release build stores to -0x48(%rbp) only with movb.
  • Over a stale cell pointer the word reads (old address & ~0xff) | 1. The collector marks that cell at every collection under the drain.

Fix

  • OptionalVMEntryScope replaces both optionals with the same interface. Its flag is a uintptr_t. A static_assert rejects padding.
  • Correct because every store to the flag word is now 8 bytes wide. The destructor reads the flag, so the compiler cannot drop the store.
  • Verified on a Bun release build (LTO) against the preview build of this PR: the 7 movb stores to the two flags are gone, and movq writes them. The cause is verified in gdb on an unmodified Bun binary (Notes).

Background

Notes

The intervention (gdb, unmodified bun-profile of oven-sh/bun 0d3492e353, BUN_GC_TIMER_DISABLE=1). The test calls a host function once, at round 5, only when a graph is still alive. A breakpoint there walks the frame-pointer chain to the frame that returns into Bun::jsFunctionDrainMicrotaskQueue, which is the frame of VM::drainMicrotasks(), and reads rbp-0x58, rbp-0x50 and rbp-0x48. Arm A writes the flag byte back as a whole word, which zeroes the seven stale bytes. Arm B makes the same stop and writes nothing. The arms alternate by run. 154 runs gave 7 triggers.

run arm [rbp-0x48] result
1/56 B 0x2422f362801 graph 15 still alive at round 100
1/69 A 0x4cba749e701 graph 7 collected at round 6
1/73 A 0x48632402701 graph 0 collected at round 6
2/2 B 0x3473b172701 graph 14 still alive at round 100
2/9 A 0x525f385de01 graph 35 collected at round 6
2/16 B 0x3c608d92701 graph 7 still alive at round 100
2/21 A 0x257da7ee701 graph 40 collected at round 6

Arm A: 4 of 4 collected at the next collection after the write. Arm B: 3 of 3 still alive at round 100. The sample is small. In every triggered run [rbp-0x58] is the VM, [rbp-0x50] is the global object, and the inner optional's flag word at rbp-0x30 is a clean 0x1.

The heap snapshot. generateHeapSnapshotForDebugging() in three failing runs: the retained module (environment, record, namespace object, its two functions, its array) has no incoming edge from outside itself and no roots entry. The function has the lowest node id of the six, so it was visited first.

The roots list. In 7 of 7 failing runs, the ConservativeRoots inline buffer that the previous collection left in dead stack memory lists that function. Its position in the list is between the two stack words that surround rbp-0x48 of the drain frame.

Run counts cannot verify this change. I first tried to prove the cause with a Bun build that zeroes the stack before the drain, switched by an environment variable. The control arm stopped reproducing: 0 of 120. The instrumentation had added push %rbx; push %rax to the prologue of jsFunctionDrainMicrotaskQueue, in both arms, so the drain frame sat 16 bytes lower over a different stale word. The unmodified binary reproduced in 8 of 80 runs at the same time. So a rebuild alone moves the word, and "0 failures after the change" would not tell a fixed word from a moved one. That is why the evidence here is the disassembly and the single-word write.

The disassembly, local build. UnifiedSource-runtime-49.cpp.o (it includes runtime/VM.cpp), -DPORT=JSCOnly -DCMAKE_BUILD_TYPE=Release, clang 23, no LTO, no frame pointer. Every byte-wide store in VM::drainMicrotasks():

  • Before: movb $0x0,0x10(%rsp) x4, movb $0x1,0x10(%rsp) x2, movb $0x0,0x28(%rsp) x4, movb $0x1,0x28(%rsp) x1, and three stores to fields of the VM and of the queue.
  • After: only the three stores to fields of the VM and of the queue. The flags are written with movq $0x0,(%rsp), movq $0x1,(%rsp), movq $0x0,0x18(%rsp) and movq $0x1,0x18(%rsp).

The disassembly, shipping-style build. Bun 0d3492e353, release profile (LTO, frame pointers), linked against autobuild-preview-pr-694-2a67b829, compared with the same Bun linked against 000c489972. Every byte-wide store to memory in VM::drainMicrotasks():

  • Before only: movb $0x0,-0x48(%rbp) x2, movb $0x1,-0x48(%rbp), movb $0x0,-0x30(%rbp) x3, movb $0x1,-0x30(%rbp). These are the two flags.
  • Before and after: movb $0x1,-0x40(%rbp), movb $0x2,-0x40(%rbp), sete -0x30(%rbp), sete -0x2f(%rbp), and three stores to fields of the VM and of the queue. The four frame stores are the inlined getCallData() for the promise rejection callback: the type byte and two bools of a CallData. That is the struct of [JSC] CallData has no padding after its type #660. This PR does not change it.
  • After: the flags are at -0x58(%rbp) and -0x40(%rbp), written with movq $0x0 and movq $0x1. The caller, jsFunctionDrainMicrotaskQueue, has the same frame in both binaries.

A read that did not discriminate. I read the outer flag word in the live drain frame at round 5 of every run, 10 runs per binary. Both binaries read 0x1 in 10 of 10. I expected stale bytes in most runs of the old build, and that was wrong. On the old build the word is stale only in a minority of runs: 0 of 10 here, against 7 of 7 in the runs where a graph was still alive at round 5. This read does not show the fix at run time. The disassembly does: no code stores less than the whole word.

The Bun side. The test is unchanged and is the check for this fix: "50 graphs alive at once, each with its own state, then all exit and are collected" in test/js/bun/module-graph/module-graph.test.ts. I opened oven-sh/bun#43217, which moved the collections of that test out from under the drain, and closed it again: that edit routed the test around this defect.

Not covered.

No JSTest. A test has to put a chosen pointer under the flag word. Spraying the stack does that, but the same spray also lands under the padding that #660 and #678 remove, and in slots that the frames never write. Such a test fails with and without this change.

Self-review. A second read of the diff changed three things: reset() now destroys before it clears the flag, as std::optional does. The headers include <cstddef>, <memory> and <new> directly. The comment gives the general form of the bad pointer and not only the case of an address that ends in 00.

…e word

VM::drainMicrotasks() and MicrotaskQueue::performMicrotaskCheckpoint() each keep a
std::optional<VMEntryScope> in their frame, and those frames stay on the stack for as long
as the jobs they drain run. A std::optional keeps its flag in one byte, and nothing writes
the seven bytes after it, so the flag's word keeps the rest of what an earlier frame left
in that stack slot. Over a pointer to a cell that leaves a pointer into whichever cell lies
at (the old address & ~0xff) + the flag. The conservative scan reads whole words and accepts
a pointer into the middle of a cell, so it marks that cell at every collection made under
the drain. The drain runs at the same stack depth in every event loop turn and only ever
stores one byte into that word, so the cell stays marked until something else writes the slot.

In the x86_64 release build VM::drainMicrotasks() writes the word at rbp-0x48 only with
"movb $0x0" and "movb $0x1". Seen in Bun as one module graph of 50 that 100 full collections
made from an async function did not collect: the word read <address of a function of that
graph, which ends in 00> | 1.

OptionalVMEntryScope is the same thing with a flag that is a whole word. This is the defect
that MicrotaskCallCache already describes for the type byte of a MicrotaskCall.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

@github-actions

Copy link
Copy Markdown

Preview build of 2a67b82: autobuild-preview-pr-694-2a67b829

@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 138d6e7e-4dd7-4b13-8693-d5a41aa599d8

📥 Commits

Reviewing files that changed from the base of the PR and between 000c489 and 2a67b82.

📒 Files selected for processing (4)
  • Source/JavaScriptCore/runtime/MicrotaskQueueInlines.h
  • Source/JavaScriptCore/runtime/VM.cpp
  • Source/JavaScriptCore/runtime/VMEntryScope.h
  • Source/JavaScriptCore/runtime/VMEntryScopeInlines.h

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


Walkthrough

The patch adds OptionalVMEntryScope with in-place lifetime management and replaces std::optional<VMEntryScope> in microtask checkpoint and draining paths.

Changes

Microtask entry scope

Layer / File(s) Summary
Optional scope storage and lifecycle
Source/JavaScriptCore/runtime/VMEntryScope.h, Source/JavaScriptCore/runtime/VMEntryScopeInlines.h
Adds OptionalVMEntryScope with in-place storage, engagement tracking, access, emplacement, reset, destruction, and allocation restrictions.
Microtask processing integration
Source/JavaScriptCore/runtime/MicrotaskQueueInlines.h, Source/JavaScriptCore/runtime/VM.cpp
Updates microtask checkpoint and draining code to use OptionalVMEntryScope. The checkpoint calls reset() when no global object is active.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 2a67b

No actionable behavior regression was identified in the reviewed change, so it is ready to merge subject to normal build validation.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description provides a detailed and relevant explanation of the bug, fix, evidence, and limitations, but it does not include the required Bugzilla title and link, review line, or structured change… Add the bug title and Bugzilla URL, include a "Reviewed by NOBODY (OOPS!)." line or the applicable reviewer, and list each changed path with the affected functions or classes using the required template format.
✅ Passed checks (3 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 main change: changing microtask drain entry-scope flags to use whole-word storage.
Full details: Description check

Explanation

The description provides a detailed and relevant explanation of the bug, fix, evidence, and limitations, but it does not include the required Bugzilla title and link, review line, or structured changed-file and function list from the repository template.

  • Fix all pre-merge checks with AI

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant