Skip to content

[JSC] DFG: clear a cloned block's source pointer when that source block is removed - #620

Open
robobun wants to merge 1 commit into
mainfrom
robobun/bb49cc89/dfg-clone-source-dangling
Open

[JSC] DFG: clear a cloned block's source pointer when that source block is removed#620
robobun wants to merge 1 commit into
mainfrom
robobun/bb49cc89/dfg-clone-source-dangling

Conversation

@robobun

@robobun robobun commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • On an assert-enabled build, --validateAbstractInterpreterState=1 reports AddressSanitizer: heap-use-after-free ... READ of size 4 in BasicBlock::dump(), reached from Graph::dumpBlockHeader() (dfg/DFGGraph.cpp:490) inside FTL::LowerDFGToB3::validateAIState(). The same plan freed that block earlier, in CFGSimplificationPhase::mergeBlocks().
  • BasicBlock::cloneSource (dfg/DFGBasicBlock.h:279, ASSERT_ENABLED only) is a raw pointer to the block a clone came from. Loop unrolling sets it (dfg/DFGCloneHelper.h:125). Nothing cleared it when that block left the graph, so a clone could outlive its source and the next dump read freed memory.
  • Any other dump after unrolling reads the same pointer: --dumpGraphAtEachPhase, verbose compilation, a validation failure report, a DFG_ASSERT report.

Fix

  • Graph::killBlock() clears the pointer in every block that still names the dying block. It is the one call that takes a single block out of the graph, and m_blocks[blockIndex] = nullptr frees it there.
  • A dump then names the source block while it exists, and says nothing once it does not.
  • An index is not an option: BlockInsertionSet::execute() renumbers every block, so an index recorded at clone time names a different block later.
  • The cost is one scan of the block list per killed block, in assert-enabled builds only. A release build does not compile the field at all.
  • Verified: JSTests/stress/loop-unrolling-dump-after-source-block-removal.js (new). On a Debug + ASAN shell built from this tree it reports the use-after-free unpatched and passes patched. 37 reflect-* and loop-unrolling-* stress tests give identical results on both shells.

Background

  • validateAbstractInterpreterState checks the abstract interpreter's state against what the FTL lowering believes. It calls Graph::dump() once before it reports anything, which is how a validation option reaches the block dumper.
  • Loop unrolling copies the loop body with CloneHelper. Each copy keeps cloneSource so a dump can print Block #12<-#5 and a reader can tell which original block a clone came from.
  • CFG simplification runs after unrolling. It merges a block into its single predecessor and kills the merged block, which is how the source of a live clone goes away.
Notes

An internal differential audit of the JIT tiers found this. No user reported it and no issue is linked.

Reproduction, jsc shell from this tree (Debug + ASAN, so ASSERT_ENABLED):

jsc --validateAbstractInterpreterState=1 --useConcurrentJIT=0 \
    --thresholdForJITAfterWarmUp=10 --thresholdForOptimizeAfterWarmUp=20 \
    --thresholdForFTLOptimizeAfterWarmUp=50 \
    JSTests/stress/loop-unrolling-dump-after-source-block-removal.js

Unpatched: heap-use-after-free, 4 bytes inside a 304-byte region, exit 1. The read is m_index in BasicBlock::dump(). Patched: prints nothing and exits 0. The test takes 3.2 s in that configuration, almost all of it the validator's per-node graph dump.

  • Free stack: BasicBlock::operator delete <- Graph::killBlock <- CFGSimplificationPhase::mergeBlocks <- Plan::compileInThreadImpl.
  • Use stack: BasicBlock::dump <- printInternal(PrintStream&, BasicBlock*) <- Graph::dumpBlockHeader <- Graph::dump <- FTL::LowerDFGToB3::validateAIState <- lowerDFGToB3 <- Plan::compileInThreadImpl.
  • No product impact: a release build without asserts does not compile cloneSource. What the bug costs is the DFG dump on the builds that have it, which is the tool a developer reaches for when the JIT misbehaves.
  • The other two ways a block's storage goes away are safe. ByteCodeParser calls killBlockAndItsContents() before m_blocks.removeLast(), and during parsing no clone exists yet. BlockInsertionSet::execute() only shrinks away entries it has already moved out, and Graph::freeDFGIRAfterLowering() drops the whole graph.
  • JSTests/ is in the sparse-checkout exclude list of .github/workflows/build-reusable.yml, so this repo's CI builds the shell but does not run the new test. I ran it with Tools/Scripts/run-jsc-stress-tests against a local shell.
  • cloneSource and Graph::killBlock() are byte-identical in upstream WebKit main, so the patch applies there unchanged and this delta can be dropped on the sync that carries it. The field came from WebKit/WebKit#43647 (293265@main) and has had no follow-up.
  • The test runs testLoopCount iterations (150 with the thresholds in its header) and compares the result against the same arithmetic written without a loop, so it also fails on a miscompile of the unrolled loop.
  • Self-reviewed: 1 concern raised, not addressed in code. It says the change has no demand signal and that upstream is its natural home. Both are true and neither changes the patch.

@coderabbitai

coderabbitai Bot commented Sep 10, 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: f8c00711-e5c6-4d07-8140-60aa0314b177

📥 Commits

Reviewing files that changed from the base of the PR and between 4b9ff99 and 30ca760.

📒 Files selected for processing (4)
  • JSTests/stress/loop-unrolling-dump-after-source-block-removal.js
  • Source/JavaScriptCore/dfg/DFGBasicBlock.h
  • Source/JavaScriptCore/dfg/DFGGraph.cpp
  • Source/JavaScriptCore/dfg/DFGGraph.h

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


Walkthrough

The DFG graph now clears clone source references when assertion-enabled builds remove a basic block. A stress test exercises loop unrolling, late branch changes, and result equivalence.

Changes

DFG clone source cleanup

Layer / File(s) Summary
Clear clone sources during block removal
Source/JavaScriptCore/dfg/DFGGraph.h, Source/JavaScriptCore/dfg/DFGGraph.cpp, Source/JavaScriptCore/dfg/DFGBasicBlock.h
Graph::killBlock() clears references to the removed block in assertion-enabled builds. The helper scans graph blocks and resets matching cloneSource pointers.
Validate loop unrolling after block removal
JSTests/stress/loop-unrolling-dump-after-source-block-removal.js
The stress test compares loop-based and unrolled implementations after a late branch transition and asserts matching accumulated results.

Merge Risk: ⚪ Minimal · up to 30ca7

This change prevents stale clone-source pointers during graph cleanup and adds stress coverage for post-removal dumping. No merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description gives a detailed and relevant explanation of the problem, fix, reproduction, and verification. However, it omits the required Bugzilla link, review line, and file/function change list … Add the bug title and Bugzilla URL, include a "Reviewed by NOBODY (OOPS!)." line or the applicable reviewer, and list each changed file with its affected functions or classes. Link the pull request to the associated Bugzilla issue and apply…
✅ 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 and concisely describes the main fix: clearing a cloned block's source pointer when the source block is removed.
Full details: Description check

Explanation

The description gives a detailed and relevant explanation of the problem, fix, reproduction, and verification. However, it omits the required Bugzilla link, review line, and file/function change list from the repository template.

Resolution

Add the bug title and Bugzilla URL, include a "Reviewed by NOBODY (OOPS!)." line or the applicable reviewer, and list each changed file with its affected functions or classes. Link the pull request to the associated Bugzilla issue and apply the required labels.

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

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

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

Comment thread JSTests/stress/loop-unrolling-dump-after-source-block-removal.js Outdated
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
30ca7606 autobuild-preview-pr-620-30ca7606 2026-09-10 20:04:58 UTC
99ada55d autobuild-preview-pr-620-99ada55d 2026-09-10 12:24:06 UTC

…ck is removed

Loop unrolling records in each clone the block it was cloned from
(BasicBlock::cloneSource, ASSERT_ENABLED only). Graph::dumpBlockHeader() prints
it. Nothing cleared the pointer when the source block left the graph, so a clone
could outlive its source and the next graph dump read freed memory.

CFGSimplificationPhase::mergeBlocks() kills the source block of an unrolled loop,
and FTL::LowerDFGToB3::validateAIState() dumps the graph in the same plan. With
--validateAbstractInterpreterState=1 an assert-enabled build reports a
heap-use-after-free in BasicBlock::dump().

Clear the pointer in Graph::killBlock(), the one place a block leaves the graph.
The dump then names the source block while it exists and says nothing once it
does not.
@robobun
robobun force-pushed the robobun/bb49cc89/dfg-clone-source-dangling branch from 99ada55 to 30ca760 Compare September 10, 2026 19:36

@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 — thanks for reworking the test to use testLoopCount and compute the expected value from reference() instead of a pinned constant.

What was reviewed:

  • Graph::clearCloneSource and the killBlock(BlockIndex) call site: both correctly #if ASSERT_ENABLED-gated, so release builds compile no new code; the scan is O(blocks) per kill, debug-only.
  • Confirmed killBlock(BasicBlock*) delegates to killBlock(BlockIndex), so both overloads are covered by the one hook.
  • Verified reference(i, late) matches the unrolled arithmetic of hot(i, late) step-by-step, so the assertion is sound for any testLoopCount.
Extended reasoning...

Overview

This PR fixes a debug-only dangling pointer in DFG's BasicBlock::cloneSource field (set by loop unrolling, read by graph dumps). Graph::killBlock(BlockIndex) now calls a new clearCloneSource() that walks m_blocks and nulls any cloneSource pointing at the block being freed. A comment is added on the field, and a new stress test exercises the FTL path where --validateAbstractInterpreterState=1 dumps the graph after CFG simplification has merged away an unrolled loop's source block. Since my earlier review, the author rewrote the test to drive iterations with testLoopCount and to compute the expected result via a hand-unrolled reference() function rather than a hard-coded constant, addressing the JSTests convention I flagged.

Security risks

None. The entire C++ change is gated behind #if ASSERT_ENABLED, so release/product builds compile no additional code. In assert-enabled builds it strictly removes a heap-use-after-free read in a diagnostic dump path. The new test is a pure JS stress test with no I/O.

Level of scrutiny

Low. Although this lives in Source/JavaScriptCore/dfg/, the modification is a minimal, obviously-correct pointer cleanup in debug-only bookkeeping — no changes to code generation, optimization, or runtime semantics. killBlock(BasicBlock*) forwards to killBlock(BlockIndex), so hooking the index overload covers both entry points; the null-check on dyingBlock guards the already-null slot case. The repo's CODEOWNERS is upstream WebKit's auto-reviewer list (it explicitly states contributors do not "own" components and names a @ WebKit/... team from a different org), so I did not treat it as a blocking ownership gate for this fork.

Other factors

I hand-checked the reference() closed form against hot()'s loop for j∈{0,1,2,3} in both late branches — they match, so the shouldBe(result, expected) assertion is valid regardless of testLoopCount's configured value. The test follows JSTests/README.md rules (throws on failure, no logging, testLoopCount-driven). My previous inline comment was optional and has been fully addressed by the force-pushed revision.

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