Skip to content

Remove the December 2025 LTO de-inlining stopgap now that WTF::opaque() is volatile - #403

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/315abf4a/restore-always-inline
Aug 11, 2026
Merged

Remove the December 2025 LTO de-inlining stopgap now that WTF::opaque() is volatile#403
Jarred-Sumner merged 1 commit into
mainfrom
farm/315abf4a/restore-always-inline

Conversation

@robobun

@robobun robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

b2beff6 ("Temp: attempt to fix", December 2025) was a stopgap added while chasing a FinalizationRegistry miscompile that only reproduced on Alpine Linux LTO builds, and it has survived every upstream merge since. It:

  • demoted ALWAYS_INLINE to plain inline and ALWAYS_INLINE_LAMBDA to nothing, tree-wide
  • moved Heap::isMarked, MarkedBlock::isMarked, and both MarkedBlock::Handle::isLive overloads out of their inline headers into .cpp files as NEVER_INLINE
  • marked Dependency::fence, Dependency::loadAndFence, and JSFinalizationRegistry::finalizeUnconditionally as NEVER_INLINE
  • replaced the x86_64 compilerFence() fences with std::atomic_thread_fence

The actual root cause was upstream 302941@main ("Clean up opaque atomic variables"), which dropped volatile from the inline asm in WTF::opaque(). Without volatile, the optimizer may elide or relocate the asm statement, which breaks the Dependency-based fenceless reads in the GC liveness checks, and LTO made that visible. Upstream diagnosed and fixed it in 308243@main (https://bugs.webkit.org/show_bug.cgi?id=308660, "inline asm in WTF::opaque() needs to be volatile"), and this tree has carried that fix since February (9f672ca).

With the root cause fixed, this removes the stopgap and restores upstream parity at our merge base (3722912):

  • ALWAYS_INLINE / ALWAYS_INLINE_LAMBDA enforce __always_inline__ again in release (NDEBUG) builds
  • the GC liveness checks return to their inline headers; the upstream comment this restores measured about 2% on splay from isLive's inlining alone, and Heap::isMarked sits on the marking hot path
  • Dependency::fence / loadAndFence inline again, which matters most on ARM64 where every consume load otherwise became a real function call
  • the x86_64 fence definitions match upstream again

Verification:

  • restored text diffed against upstream at 3722912; the bodies match exactly
  • full JSCOnly Release build with clang 21 (-O3/NDEBUG, ENABLE_STATIC_JSC=ON, FTL, USE_BUN_JSC_ADDITIONS=ON) compiles cleanly and the jsc shell runs
  • FinalizationRegistry register/gc stress loop in the release shell: no crash

The original failure mode was specific to musl LTO builds, so the regression test that matters is Bun's CI on linux x64 musl against this PR's preview artifacts. I will run Bun's suite with a draft PR pinned to the preview tag and report the result here before this merges.

…() is volatile

b2beff6 ("Temp: attempt to fix", Dec 2025) was a stopgap while chasing a
FinalizationRegistry miscompile on Alpine Linux LTO builds. It demoted
ALWAYS_INLINE to plain inline and ALWAYS_INLINE_LAMBDA to nothing across the
whole tree, moved Heap::isMarked, MarkedBlock::isMarked, and
MarkedBlock::Handle::isLive out of line as NEVER_INLINE, marked
Dependency::fence/loadAndFence and JSFinalizationRegistry::finalizeUnconditionally
NEVER_INLINE, and swapped the x86_64 compiler fences for std::atomic_thread_fence.

The actual root cause was found upstream: 302941@main made the inline asm in
WTF::opaque() non-volatile, so the optimizer could elide or relocate it,
breaking the Dependency-based fenceless reads in the GC's liveness checks.
Upstream fixed it in 308243@main (webkit.org/b/308660, "inline asm in
WTF::opaque() needs to be volatile"), which this tree has carried since
February.

This restores upstream parity at our merge base (3722912): ALWAYS_INLINE
and ALWAYS_INLINE_LAMBDA are enforced again in release builds, the GC liveness
checks are back in their inline headers (upstream measured ~2% on splay from
isLive's inlining alone), and the fence definitions match upstream.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 038bae38-bb80-4219-802d-bc3b3df95f04

📥 Commits

Reviewing files that changed from the base of the PR and between 447082a and a9acfec.

📒 Files selected for processing (8)
  • Source/JavaScriptCore/heap/Heap.cpp
  • Source/JavaScriptCore/heap/HeapInlines.h
  • Source/JavaScriptCore/heap/MarkedBlock.cpp
  • Source/JavaScriptCore/heap/MarkedBlock.h
  • Source/JavaScriptCore/heap/MarkedBlockInlines.h
  • Source/JavaScriptCore/runtime/JSFinalizationRegistry.cpp
  • Source/WTF/wtf/Atomics.h
  • Source/WTF/wtf/Compiler.h
💤 Files with no reviewable changes (2)
  • Source/JavaScriptCore/heap/Heap.cpp
  • Source/JavaScriptCore/heap/MarkedBlock.cpp

Walkthrough

Changes

The pull request moves JavaScriptCore heap mark and liveness checks into inline headers. It updates compiler inlining and x86 fence macros, and removes NEVER_INLINE from selected functions.

Heap marking and liveness

Layer / File(s) Summary
Compiler and fence configuration
Source/WTF/wtf/Compiler.h, Source/WTF/wtf/Atomics.h, Source/JavaScriptCore/runtime/JSFinalizationRegistry.cpp
Optimized builds use always-inline attributes except for GCC AddressSanitizer. Selected x86 fences use compilerFence(). Selected NEVER_INLINE attributes are removed.
Heap mark-state checks
Source/JavaScriptCore/heap/Heap.cpp, Source/JavaScriptCore/heap/HeapInlines.h, Source/JavaScriptCore/heap/MarkedBlock.h
Heap::isMarked and the version-aware MarkedBlock::isMarked implementations move into headers. Precise-allocation handling, version validation, and concurrent mark-bit reads remain.
Handle liveness checks
Source/JavaScriptCore/heap/MarkedBlock.cpp, Source/JavaScriptCore/heap/MarkedBlockInlines.h
Both MarkedBlock::Handle::isLive overloads move into the inline header. The implementation retains optimistic bit reads, validation, and locked fallback behavior.

Possibly related PRs

  • oven-sh/WebKit#397: Both changes modify JavaScriptCore heap mark-state code, including Heap and MarkedBlock implementations.

Suggested reviewers: dcrousso

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the change and verification, but it omits the required bug title, explicit Bugzilla link, reviewer line, and changed-file list. Add the required template sections: bug title and link, “Reviewed by NOBODY (OOPS!).”, and a paths/functions list.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies removal of the December 2025 LTO de-inlining stopgap and the reason for the change.
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.

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.

I reviewed this PR and didn't find any bugs. Given that it touches GC liveness hot paths, memory-ordering primitives, and re-enables __always_inline__ tree-wide — and the author notes the decisive musl LTO regression run is still pending — a human look and the CI result are worth waiting on before merging.

What was reviewed:

  • Confirmed WTF::opaque() and compilerFence() both carry asm volatile with the needed constraints, so the stated root-cause fix is in place.
  • Restored bodies of Heap::isMarked, MarkedBlock::isMarked, and both Handle::isLive overloads match the versions removed from the .cpp files (plus the upstream comment block).
  • x86_64 loadLoadFence/loadStoreFence/storeStoreFence reverting to compilerFence() is sound under TSO and matches the upstream pattern.
Extended reasoning...

Overview

This PR reverts a December 2025 stopgap that worked around a FinalizationRegistry miscompile on Alpine musl LTO builds. It restores upstream parity across eight files: ALWAYS_INLINE/ALWAYS_INLINE_LAMBDA regain __attribute__((__always_inline__)) in NDEBUG builds (Compiler.h), the GC liveness checks (Heap::isMarked, MarkedBlock::isMarked, MarkedBlock::Handle::isLive) move back from .cpp NEVER_INLINE definitions to their inline headers, Dependency::fence/loadAndFence and JSFinalizationRegistry::finalizeUnconditionally drop NEVER_INLINE, and the x86_64 load-load/load-store/store-store fences revert from std::atomic_thread_fence to compilerFence().

Security risks

The direct security exposure is memory safety in the concurrent GC. If the root-cause analysis were wrong and the optimizer can still reorder or elide the fenceless-read machinery under LTO, isLive/isMarked could return stale results, leading to premature collection and use-after-free. I verified that WTF::opaque() now uses asm volatile("" : "+r"(value) ::) and compilerFence() uses asm volatile("" ::: "memory"), which are the correct barriers. On x86_64 TSO, compiler fences are sufficient for load-load/load-store/store-store ordering, so that reversion is semantically fine. There is no injection, auth, or data-exposure surface here.

Level of scrutiny

High. Three factors push this well outside auto-approval territory: (1) the code sits on the GC marking/liveness hot path where a regression means heap corruption rather than a visible crash; (2) the ALWAYS_INLINE macro change is tree-wide and affects codegen for essentially all of JSC/WTF in release builds; (3) the original bug only reproduced on a specific toolchain/config (musl + LTO), and the author explicitly states that the decisive regression test — Bun's suite against the musl LTO preview artifacts — has not yet been run and will be reported before merge.

Other factors

The restored function bodies are byte-identical (modulo the reinstated explanatory comment) to what was removed from the .cpp files, and the PR description says they were diffed against upstream at the merge base. The bug-hunting pass found nothing. Local verification (clang 21 release build + FinalizationRegistry stress loop) passed, but that is not the configuration that originally failed. Given the pending CI gate the author called out and the criticality of the code, deferring to a human reviewer is the right call.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
a9acfec7 autobuild-preview-pr-403-a9acfec7 2026-08-10 07:36:12 UTC

@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Preview build succeeded across the full matrix (including linux musl lto). Bun-side CI validation is running in oven-sh/bun#37306, pinned to autobuild-preview-pr-403-a9acfec7. I will post the results here.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 10, 2026
objectPrototypeToString is defined ALWAYS_INLINE in ObjectPrototypeInlines.h.
With only ObjectPrototype.h included, the call compiled to an extern reference
that happened to resolve against an out-of-line copy JSC emitted while the
fork demoted ALWAYS_INLINE to plain inline. oven-sh/WebKit#403 restores the
attribute, no out-of-line copy exists, and every release link fails with an
undefined symbol. Include the inlines header so this TU has the definition.
@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Bun CI results against the preview artifacts are in: oven-sh/bun#37306, build 91420 (196 jobs).

  • Every build-bun lane compiles and links on every platform, after one bun-side companion fix: NodeUtilTypesModule.cpp called objectPrototypeToString, which is ALWAYS_INLINE in ObjectPrototypeInlines.h, while only including ObjectPrototype.h. It linked before purely because the demotion made JSC emit a weak out-of-line copy. The include fix is oven-sh/bun@1c25b30c19 and needs to ride along with the WEBKIT_VERSION bump.
  • All test lanes pass. The only red test job is darwin 14 x64, where both failing files (require-cache.test.ts, node-http2.test.js timeouts) are flagged pre-existing on bun main by the CI tooling.
  • The linux x64 and arm64 musl release lanes (ThinLTO, the configuration the December stopgap was chasing) are fully green, including the FinalizationRegistry tests. No recurrence.
  • Cost: binary size grows 2.3 to 3.8 MB per target vs current main (linux-x64 +3.44 MB, windows-x64 +3.81 MB, darwin-aarch64 +2.94 MB). That is the real price of __always_inline__ coming back; main is currently smaller only because the demotion also shrank codegen. Bun's binary-size guard (0.50 MB threshold) flags it, so accepting this PR means accepting that growth, and the bump PR's binary-size job will be red until the new baseline lands in canary.

Summary: correctness validated across the matrix; the open question is the size-for-speed tradeoff, which is a maintainer call.

@Jarred-Sumner
Jarred-Sumner merged commit 4485572 into main Aug 11, 2026
43 checks passed
robobun added a commit to oven-sh/bun that referenced this pull request Aug 11, 2026
The WebKit bump to 3997b59485da restores ALWAYS_INLINE to always_inline
(oven-sh/WebKit#403), so release archives no longer carry an out-of-line
copy of objectPrototypeToString and every link failed with an undefined
symbol. Pull in the inline definition where it is called.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 11, 2026
Restoring ALWAYS_INLINE (oven-sh/WebKit#403, in the 3997b59485da pin)
re-inlines JSC hot paths that the December stopgap had left out of line,
adding roughly 3 MB per target relative to main's older pin.
Jarred-Sumner added a commit to oven-sh/bun that referenced this pull request Aug 11, 2026
…after oven-sh/WebKit#403)

No-Verification-Needed: include-only change required to link against the bumped WebKit; exercised by this PR's CI build.
Jarred-Sumner added a commit to oven-sh/bun that referenced this pull request Aug 11, 2026
`447082ab6897` → `3997b59485da` is three commits on the fork:

- oven-sh/WebKit#405: JSC's `DebuggerParseData`, `CachedTypes` and
`Completion` switches get a `BunTranspiledModule` arm next to `Module`.
Runtime ESM that Bun hands to JSC with a prebuilt module record (`bun
test --isolate` / `--parallel`, `bun build --compile` output) uses that
source type, and `gatherDebuggerParseDataForSource` returned false for
it, so `Debugger.setBreakpoint` replied "Could not resolve breakpoint"
and `Debugger.setBreakpointByUrl` returned no locations for those files.
- oven-sh/WebKit#406: exception checks on the inspector's pause /
`evaluateOnCallFrame` / `Runtime.evaluate` paths
(`JSJavaScriptCallFrame`, `JSInjectedScriptHost`, `jsToInspectorValue`).
Under `validateExceptionChecks` (the ASAN lane) a paused inspectee used
to abort at `JSJavaScriptCallFrame::scopeChain`, which is why
`inspector.test.ts` stripped the flag for its children and
`inspect.test.ts` sat in `no-validate-exceptions.txt`. Both workarounds
are removed here; all of `test/cli/inspect/*` and
`test/js/node/inspector/inspector.test.ts` pass locally with the flag
forced on for every child.
- oven-sh/WebKit#403 also landed in between: `ALWAYS_INLINE` is
`__always_inline__` again in release builds, so
`NodeUtilTypesModule.cpp` now includes `ObjectPrototypeInlines.h` for
`objectPrototypeToString` (the only out-of-line use that relied on the
old stopgap; release links locally against the new tarball).

`test/cli/inspect/debugger-buntranspiledmodule.test.ts` (from #35754,
updated for the `scriptType` param the Aug 2 upgrade introduced) drives
`bun test --isolate` under `--inspect-wait` and asserts both breakpoint
calls resolve; it fails on `447082ab` and passes here. #35605 makes
every `bun run` module take the `BunTranspiledModule` path; splitting
the bump out so it can land first. Supersedes #35754.

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Since this reached Bun (oven-sh/bun#37352), Bun's linux x64 musl lane has crashed three times in the GC marking threads on already-collected cells (oven-sh/bun CI builds 96675, 97509, 100350), the configuration the December arrangement was added for. On x86_64 the liveness paths do not go through WTF::opaque(), so the fix cited here did not reach them. #466 restores what this removed, with the evidence.

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my comment above: #466 is closed. The December failure the stopgap was added for was fixed on the Bun side (oven-sh/bun@22b3be442c, a FileSink wrapper collected while a write was pending), so the stopgap was never shown to do anything and this PR's removal of it is not established as the cause of the current alpine x64 crashes. Details in #466.

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.

3 participants