Skip to content

SamplingProfiler: stop the stack walk at a null entry frame instead of dereferencing vmEntryRecord(nullptr) - #395

Open
robobun wants to merge 2 commits into
mainfrom
fix/sampling-profiler-null-entry-frame
Open

SamplingProfiler: stop the stack walk at a null entry frame instead of dereferencing vmEntryRecord(nullptr)#395
robobun wants to merge 2 commits into
mainfrom
fix/sampling-profiler-null-entry-frame

Conversation

@robobun

@robobun robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Crash

Bun CI (Windows 2019 x64, bun --cpu-prof, test-cpu-prof-dir-worker.js sampling at 50us) intermittently segfaults in the jsc.sampling-profiler.thread at address 0xFFFFFFFFFFFFFFC8. Symbolized stack from the CI minidump (oven-sh/bun Buildkite build 90390):

JSC::VMEntryRecord::unsafePrevTopEntryFrame   VMEntryRecord.h:60
JSC::CallFrame::unsafeCallerFrame             interpreter/CallFrame.cpp:182
JSC::FrameWalker::advanceToParentFrame        runtime/SamplingProfiler.cpp:172
FrameWalker::walk                             runtime/SamplingProfiler.cpp:104
SamplingProfiler::takeSample                  runtime/SamplingProfiler.cpp:445
SamplingProfiler::timerLoop                   runtime/SamplingProfiler.cpp:359

The fault address is vmEntryRecord(nullptr)->m_prevTopEntryFrame: vmEntryRecord(entryFrame) is entryFrame - VMEntryTotalFrameSize, so with a null entry frame the load lands just below address zero (-0x38 with the Windows x64 frame layout).

Mechanism

SamplingProfiler::takeSample only requires vm.entryScope to be set, but there are windows where that holds while vm.topEntryFrame is null and the walk starts from memory that is not a live frame chain:

  • VMEntryScope is constructed and destroyed in C++ around vmEntryToJavaScript, while vm.topCallFrame/vm.topEntryFrame are stored and restored inside doVMEntry, and the store happens after the O(paddedArgCount) argument copy loops. On an outermost entry, everything from VMEntryScope setup to that store runs with entryScope set and topEntryFrame null; the same applies on exit between doVMEntry's restore and the VMEntryScope teardown.
  • vmEntryToJavaScript lies inside the LLInt PC range, so a sample landing in those loops takes the topFrameIsLLInt path and walks from the machine frame pointer, which is the half-built entry frame. A sample landing in the surrounding C++ takes the topCallFrame fallback ("We resort to topCallFrame"), which after the restore is stale.

Either way FrameWalker::m_entryFrame snapshots vm.topEntryFrame == nullptr, and the walker advances through dead or half-initialized stack memory (isValidFramePointer only checks stack bounds, and resetAtMachineFrame accepts any frame whose CodeBlock slot reads null). The first walked frame whose caller slot reads null compares equal to the null m_entryFrame in unsafeCallerFrame, which then dereferences vmEntryRecord(nullptr).

Fix

Bail out of the matched branch when currEntryFrame is null: with no entry frame there is no VMEntryRecord to consult, and returning null ends the walk exactly like reaching prevTopCallFrame == nullptr at the outermost record does. Valid walks are unaffected, since while JS is on the stack a caller slot matches m_entryFrame only at real, non-null entry frames.

unsafeCallerFrame's only caller is the sampling profiler's FrameWalker::advanceToParentFrame, so the change is confined to the sampler.

Upstream has the same code: https://github.com/WebKit/WebKit/blob/2e8a96a8c5857b071f073feea215c90803f22520/Source/JavaScriptCore/interpreter/CallFrame.cpp#L178

Testing

Tools/TestWebKitAPI/Tests/JavaScriptCore/UnsafeCallerFrame.cpp constructs the raced state deterministically: a zeroed frame (caller slot reads null) walked with a null EntryFrame cursor. On a JSCOnly debug+ASAN Linux build of the TestJavaScriptCore target:

  • without the guard: AddressSanitizer: SEGV ... VMEntryRecord.h:60:66 in JSC::VMEntryRecord::unsafePrevTopEntryFrame(), with the record pointer at 0xffffffffffffffb0 (vmEntryRecord(nullptr) under the SysV frame layout; the Windows layout puts the faulting load at the CI crash address 0xFFFFFFFFFFFFFFC8)
  • with the guard: passes, and the rest of the TestJavaScriptCore suite is unaffected (the two MarkedVector.GCLivenessInlineBuffer_* failures on that configuration are pre-existing and reproduce with this test filtered out)

Reproduction of the live race

The race needs a sample to land inside a per-entry window of microseconds, and the walk from there to hit a null caller slot before hitting something that fails isValidCodeBlock. It fired about once per fifteen builds on Bun's Windows 2019 CI runners. It did not reproduce locally: several hundred probe runs across Windows Server 2019 and Linux stayed clean, including 100 runs of the exact workload under the exact crashing binary and runs with the entry window artificially widened (callbacks declared with 30k parameters invoked from setImmediate, making the pad loop before the topEntryFrame store dominate the runtime, confirmed clean on both platforms at a 10us sampling interval). The stress form is deliberately not the regression guard; the constructed-state test above is.

…the sampling profiler's walk

SamplingProfiler::takeSample only gates on vm.entryScope, but vm.topEntryFrame
can be null while entryScope is set: VMEntryScope is created and destroyed in
C++ around vmEntryToJavaScript, while topCallFrame/topEntryFrame are stored and
restored inside doVMEntry (after the O(paddedArgCount) argument copy loops). A
sample landing in those windows walks a half-built entry frame or a stale
topCallFrame with FrameWalker::m_entryFrame == nullptr. The first walked frame
whose caller slot reads null then matches the null entry frame, and
unsafeCallerFrame dereferences vmEntryRecord(nullptr), which faults just below
address zero (0xFFFFFFFFFFFFFFC8 on Windows x64).

Bail out when currEntryFrame is null: there is no VMEntryRecord to consult, and
returning null ends the walk the same way reaching prevTopCallFrame == nullptr
at the outermost record does. unsafeCallerFrame's only caller is the sampling
profiler's FrameWalker::advanceToParentFrame.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 30 minutes

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

How can I continue?

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

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

How do review limits work?

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

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

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a55b908d-e72f-4279-9a2f-7bfed43badc7

📥 Commits

Reviewing files that changed from the base of the PR and between 78d45d3 and 62128f5.

📒 Files selected for processing (3)
  • Source/JavaScriptCore/interpreter/CallFrame.cpp
  • Tools/TestWebKitAPI/CMakeLists.txt
  • Tools/TestWebKitAPI/Tests/JavaScriptCore/UnsafeCallerFrame.cpp

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

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

LGTM — a two-line defensive null check that turns a near-null dereference into a clean end-of-walk.

What was reviewed:

  • Confirmed unsafeCallerFrame is called only from SamplingProfiler.cpp's FrameWalker::advanceToParentFrame, so the change is confined to the sampler.
  • Returning nullptr sets m_callFrame = nullptr, which isAtTop() / resetAtMachineFrame() already treat as end-of-walk — same path as reaching the outermost prevTopCallFrame == nullptr.
  • The non-unsafe callerFrame() and the fall-through static_cast<CallFrame*>(unsafeCallerFrameOrEntryFrame()) path are untouched.
Extended reasoning...

Overview

The PR adds a single if (!currEntryFrame) return nullptr; guard (plus an explanatory comment) inside CallFrame::unsafeCallerFrame in Source/JavaScriptCore/interpreter/CallFrame.cpp. When the sampling profiler's frame walker reaches a frame whose caller slot compares equal to a null currEntryFrame, the old code called vmEntryRecord(nullptr) and read m_prevTopEntryFrame at a negative offset from address zero, faulting at 0xFFFFFFFFFFFFFFC8 on Windows x64. The new code returns nullptr instead, which the walker's isAtTop() check treats as a clean end of the walk.

Security risks

None. This is crash-hardening in the sampling profiler's unsafe stack walker (already SUPPRESS_ASAN and designed to tolerate reading paused-thread state). It does not touch auth, crypto, permissions, or user-controlled data paths; it strictly replaces a segfault with an early return.

Level of scrutiny

Low-to-moderate. The change is two lines of logic in a function with exactly one call site (FrameWalker::advanceToParentFrame in SamplingProfiler.cpp:172, verified via grep). The behavior change is monotonic: every input that previously produced a valid result still does (the guard only fires when both unsafeCallerFrameOrEntryFrame() and currEntryFrame are null, a case that previously crashed). Even if the PR's race-window analysis were incomplete, the added null check is harmless — it cannot make a valid walk terminate early, since a real entry frame is never null while JS is on the stack.

Other factors

  • Verified the caller handles a nullptr return: advanceToParentFrame assigns it to m_callFrame, then resetAtMachineFrame() checks isAtTop() (!m_callFrame) and returns, and the walk loop exits — identical to the existing outermost-record termination path.
  • The safe variant CallFrame::callerFrame is unchanged; only the profiler-specific unsafe variant is touched.
  • The PR description provides a symbolized minidump stack that lines up exactly with the code path being guarded, and a plausible mechanism (vm.entryScope set while vm.topEntryFrame is still null during doVMEntry prologue/epilogue).
  • No prior human or bot reviews to reconcile; CodeRabbit was rate-limited and did not review.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
62128f58 autobuild-preview-pr-395-62128f58 2026-08-08 04:06:53 UTC
8ec8fb6a autobuild-preview-pr-395-8ec8fb6a 2026-08-08 02:32:18 UTC

…null entry frame

A zeroed frame whose caller slot reads null, walked with a null EntryFrame
cursor, models the state the sampling profiler hits when a sample lands inside
a VM entry/exit transition. Without the guard the test faults in
VMEntryRecord::unsafePrevTopEntryFrame reading just below address zero, the
same location as the CI crash; with it unsafeCallerFrame reports the end of
the stack.

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

LGTM — a strictly defensive null guard in the sampling profiler's unsafe stack walk. The inline nit about JS_EXPORT_PRIVATE is a portability concern for shared-library ports only and doesn't affect Bun's static-JSC builds; worth the one-word fix but not a blocker.

Checked that unsafeCallerFrame's only caller is FrameWalker::advanceToParentFrame (SamplingProfiler.cpp:172), and that returning nullptr there terminates the walk cleanly via isAtTop()!m_callFrame.
Verified the guard only fires when currEntryFrame == nullptr — valid walks (non-null entry frame) are untouched, so no behavior change on the happy path.
Confirmed resetAtMachineFrame() early-returns on a null m_callFrame, so no follow-on deref after the new bail-out.

Extended reasoning...

Overview

The PR adds a two-line null guard inside CallFrame::unsafeCallerFrame (CallFrame.cpp) so that when the sampling profiler's EntryFrame* cursor is null and a walked frame's caller slot also reads null, the walk ends instead of dereferencing vmEntryRecord(nullptr) (which faults just below address zero). It also adds a deterministic TestWebKitAPI unit test that constructs this exact state, and wires it into TestJavaScriptCore_SOURCES in CMake.

Security risks

None. This is a defensive null check inside a SUPPRESS_ASAN helper that exists specifically for the sampling profiler's racy reads of another thread's stack. It reads no attacker-controlled data, adds no new dereference, and can only cause the profiler to record a shorter stack trace in a race window where it previously segfaulted. There is no auth, crypto, or permission surface here.

Level of scrutiny

Medium — it touches interpreter/CallFrame.cpp, but the change is confined to unsafeCallerFrame, whose sole caller I confirmed is SamplingProfiler.cpp:172. The guard is inside the unsafeCallerFrameOrEntryFrame() == currEntryFrame branch and only triggers when both are null, so any walk with a real (non-null) entry frame is bit-for-bit unchanged. Returning nullptr sets m_callFrame = nullptr, isAtTop() returns true, and resetAtMachineFrame() early-returns on null — the termination path is the same one already used when the outermost VMEntryRecord yields a null prevTopCallFrame. The PR description is unusually thorough, matching the CI minidump fault address to vmEntryRecord(nullptr) under both Windows and SysV frame layouts, and the ASAN test reproduces the fault without the guard and passes with it.

Other factors

The one finding from the bug-hunting pass is a nit: unsafeCallerFrame in CallFrame.h:227 lacks JS_EXPORT_PRIVATE, so the new test won't link on ports that build JSC as a shared library with hidden visibility. Bun's CI builds with ENABLE_STATIC_JSC=ON and does not build the TestWebKitAPI target, so this repo's builds are unaffected — it's a one-word portability fix for upstreaming, not a correctness or merge blocker here. No prior human reviews or outstanding comments on the PR; CodeRabbit was rate-limited and did not review. Preview build for the fix commit succeeded.

JSC::CallFrame* callFrame = JSC::CallFrame::create(reinterpret_cast<JSC::Register*>(zeroedFrame));

JSC::EntryFrame* entryFrame = nullptr;
EXPECT_EQ(callFrame->unsafeCallerFrame(entryFrame), nullptr);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 unsafeCallerFrame is declared in CallFrame.h:227 without JS_EXPORT_PRIVATE (unlike the adjacent callerFrame on line 228), so on ports that build JavaScriptCore as a shared library with hidden visibility (Win, GTK, WPE, Cocoa, PlayStation, JSCOnly without ENABLE_STATIC_JSC) this test won't link. Bun's own CI uses ENABLE_STATIC_JSC=ON and doesn't build TestWebKitAPI so nothing breaks here, but adding JS_EXPORT_PRIVATE to the declaration is a one-word fix that keeps the test portable and eases upstreaming.

Extended reasoning...

What the bug is

The new test at Tools/TestWebKitAPI/Tests/JavaScriptCore/UnsafeCallerFrame.cpp:48 calls callFrame->unsafeCallerFrame(entryFrame). In Source/JavaScriptCore/interpreter/CallFrame.h that method is declared as:

CallFrame* unsafeCallerFrame(EntryFrame*&) const;                 // line 227 — no export
JS_EXPORT_PRIVATE CallFrame* callerFrame(EntryFrame*&) const;     // line 228 — exported

and defined out-of-line in CallFrame.cpp (not inline in the header). Without JS_EXPORT_PRIVATE, the symbol is not exported from a shared JavaScriptCore library.

The code path that triggers it

The PR adds Tests/JavaScriptCore/UnsafeCallerFrame.cpp unconditionally to TestJavaScriptCore_SOURCES under if (ENABLE_JAVASCRIPTCORE). Several ports build TestJavaScriptCore and set JavaScriptCore_LIBRARY_TYPE SHARED with hidden default visibility:

  • OptionsGTK.cmake:491 — SHARED, plus CXX_VISIBILITY_PRESET hidden
  • OptionsWin.cmake:193 — SHARED (DLL export table)
  • OptionsCocoa.cmake:409 — SHARED, -fvisibility=hidden
  • OptionsPlayStation.cmake — SHARED, hidden
  • OptionsJSCOnly.cmake:124 — SHARED when ENABLE_STATIC_JSC is OFF, with hidden visibility (:7)

On any of those, linking TestJavaScriptCore will fail with an undefined reference / unresolved external for JSC::CallFrame::unsafeCallerFrame(JSC::EntryFrame*&) const.

Why existing code doesn't prevent it

Until this PR, unsafeCallerFrame's only caller was SamplingProfiler.cpp, which is compiled into the JavaScriptCore library itself, so the symbol never needed to cross the library boundary. The new test is the first out-of-library caller. The PR was verified only on a JSCOnly Linux build with ENABLE_STATIC_JSC=ON, where all symbols are visible regardless of the export macro, so the missing export was not observed.

Step-by-step proof

  1. Configure GTK (or JSCOnly with -DENABLE_STATIC_JSC=OFF): CMake sets JavaScriptCore_LIBRARY_TYPE=SHARED and CXX_VISIBILITY_PRESET hidden.
  2. CallFrame::unsafeCallerFrame is compiled into libJavaScriptCore.so with default (hidden) visibility because its declaration lacks JS_EXPORT_PRIVATE (which expands to __attribute__((visibility("default"))) / __declspec(dllexport)).
  3. nm -D libJavaScriptCore.so | c++filt | grep unsafeCallerFrame → no output; the symbol is local.
  4. TestJavaScriptCore compiles UnsafeCallerFrame.cpp, which references _ZNK3JSC9CallFrame17unsafeCallerFrameERPNS_10EntryFrameE.
  5. ld fails: undefined reference to 'JSC::CallFrame::unsafeCallerFrame(JSC::EntryFrame*&) const'.

Impact

None on this fork's CI: .github/workflows/build.yml builds with ENABLE_STATIC_JSC=ON and does not build the TestWebKitAPI target. But it will break the TestJavaScriptCore build on every shared-library port and would block upstreaming this fix to WebKit as-is.

Fix

One word — add JS_EXPORT_PRIVATE to the declaration in CallFrame.h:227, matching the adjacent callerFrame:

JS_EXPORT_PRIVATE CallFrame* unsafeCallerFrame(EntryFrame*&) const;

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