You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fgHeadTailMerge walks the block list with a cursor and, on the same iteration, can remove the very block that cursor is holding. The next increment trips an assert in the block iterator.
The trigger is a BBJ_ALWAYS block that targets itself. Such a block is its own predecessor and
satisfies predBlock->GetUniqueSucc() == block, so it passes the non-critical-edge filter in tailMerge:
if (predBlock->GetUniqueSucc() != block) // fgopt.cpp:5488
continue;
and is collected into predInfo as a predecessor of itself. If its tail statement then matches
another predecessor's and is unlinked, the block becomes empty and is removed:
if (!(predBlock->isEmpty() && tryRemoveAndFixFlow(predBlock, crossJumpTarget))) // fgopt.cpp:5439
tryRemoveAndFixFlow uses PredBlocksEditing() and so correctly guards mutation of the pred list.
Nothing guards the outer block-list cursor:
for (BasicBlock* const block : Blocks()) // fgopt.cpp:5559iterateTailMerge(block); // fgopt.cpp:5561
The block removed is that cursor.
Repro
Requires DOTNET_TieredCompilation=0. No stress modes, no PGO, plain FullOpts, x64 Windows.
usingSystem;publicstaticclassProgram{staticints_v;staticvoidF(intx){if(x==1)gotoP;if(x==2){s_v=77;gotoL;}// 3rd pred, tail does NOT matchreturn;P:s_v=1;gotoL;// matched pred, falls through into LL:s_v=1;gotoL;// self-loop: L is its own predecessor}publicstaticintMain(){F(-1);// the loop is never enteredConsole.WriteLine(s_v);return0;}}
Assertion failed 'm_block->IsFirst() || m_block->Prev()->NextIs(m_block)'
in 'Program:F(int)' during 'Head and tail merge' (IL size 32; hash 0xd186bebf; FullOpts)
src/coreclr/jit/block.h:1958
Two conditions in that program are load-bearing, and both are why the shape is uncommon:
The third predecessor with a non-matching tail. Without it canMergeIntoSucc is true, and the
merge-into-successor path at fgopt.cpp:5303 runs instead, passing commSucc — the loop block
itself — as newTarget. That produces a different failure, described below.
P must physically precede L and fall through into it. Victim ranking at fgopt.cpp:5366
prefers isNoSplit && isFallThrough, breaking ties on lowest bbID. A self-loop is never a
fall-through to itself, so it ranks worse — but it typically has the lowest bbID and therefore
wins the tie, becomes crossJumpVictim, and is skipped rather than removed. A matched pred laid
out immediately before it takes that slot and exposes the block to removal.
Condition 2 is a block-layout requirement rather than a dataflow one, which is likely why this is
rarely hit.
A second assertion, same root cause
Dropping condition 1 takes the merge-all-preds path, where newTarget is commSucc, which is the
block being removed. fgReplaceJumpTarget(pred, L, L) is then a no-op, the self-edge survives, and
removal fails a different assert:
Assertion failed '(block->bbRefs == 0) && (block->bbPreds == nullptr)'
during 'Head and tail merge' src/coreclr/jit/fgbasic.cpp:5123
Introduced by #129643 (commit 94605765899, 2026-06-25), which added tryRemoveAndFixFlow. The one
clean build is clean because it is older, not because it is fixed — it does not contain that lambda.
fgHeadTailMerge is unchanged in main as of 69f1b6a5f52 (2026-09-02): fgopt.cpp lines
5091-5641 are byte-identical to the 2026-08-28 build above (SHA-256 of the region matches), and block.h:1958, fgbasic.cpp:5123, fgopt.cpp:5488 and fgopt.cpp:5561 are all at the same lines.
I do not have a checked build of main itself, so that is a source-identity argument rather than an
execution result.
Note on the phase name
The repro above trips the early fgHeadTailMerge(early = true) invocation. The failure that
originally prompted this investigation was reported during the post-morph invocation
(early = false), with the same assert text and line. Same function, same lambda, same cursor — but
they are two distinct call sites, and I was not able to construct a program that survives to the
second one: attempts to keep the tails textually distinct until morph (a + 0, a * 1, a / 1, a | 0 against a plain a) are all folded by Roslyn, so the tails are already identical in IL.
I believe the difference is incidental, but I have not proven it.
Severity
Assert-only as far as I can establish. Release net10.0 runs the first repro to completion, prints 0, does not hang, and produces the correct result. A release JIT takes the same path without the
assert, leaving the cursor pointing at an unlinked block; that produced no observable misbehaviour
here, but it is not a safe state.
Suggested fix
For the bbRefs variant, canRemove should reject a self-target:
That does not address the cursor variant, where newTarget is legitimately a different block. For
that, either capture block->Next() before iterateTailMerge(block), or exclude block from its
own predInfo in tailMerge — merging a block's tail into itself is not a useful transform.
Configuration
Windows x64, checked JIT, DOTNET_TieredCompilation=0, FullOpts.
Found by a randomized C# generation campaign; the repro above was constructed by hand from the
root cause, not minimized from the original program.
Note
This issue body was drafted with GitHub Copilot. The repro, the regression range, and the
source-identity check against main were verified by running them.
fgHeadTailMergewalks the block list with a cursor and, on the same iteration, can remove the very block that cursor is holding. The next increment trips an assert in the block iterator.The trigger is a
BBJ_ALWAYSblock that targets itself. Such a block is its own predecessor andsatisfies
predBlock->GetUniqueSucc() == block, so it passes the non-critical-edge filter intailMerge:and is collected into
predInfoas a predecessor of itself. If its tail statement then matchesanother predecessor's and is unlinked, the block becomes empty and is removed:
tryRemoveAndFixFlowusesPredBlocksEditing()and so correctly guards mutation of the pred list.Nothing guards the outer block-list cursor:
The block removed is that cursor.
Repro
Requires
DOTNET_TieredCompilation=0. No stress modes, no PGO, plain FullOpts, x64 Windows.Two conditions in that program are load-bearing, and both are why the shape is uncommon:
canMergeIntoSuccis true, and themerge-into-successor path at
fgopt.cpp:5303runs instead, passingcommSucc— the loop blockitself — as
newTarget. That produces a different failure, described below.Pmust physically precedeLand fall through into it. Victim ranking atfgopt.cpp:5366prefers
isNoSplit && isFallThrough, breaking ties on lowestbbID. A self-loop is never afall-through to itself, so it ranks worse — but it typically has the lowest
bbIDand thereforewins the tie, becomes
crossJumpVictim, and is skipped rather than removed. A matched pred laidout immediately before it takes that slot and exposes the block to removal.
Condition 2 is a block-layout requirement rather than a dataflow one, which is likely why this is
rarely hit.
A second assertion, same root cause
Dropping condition 1 takes the merge-all-preds path, where
newTargetiscommSucc, which is theblock being removed.
fgReplaceJumpTarget(pred, L, L)is then a no-op, the self-edge survives, andremoval fails a different assert:
Regression range
b41e0e4210atryRemoveAndFixFlow6fb715b7b1c9a72df7dab4f259a30044dd2ea726dd3dbaee3fc4586a5ea966a138Introduced by #129643 (commit
94605765899, 2026-06-25), which addedtryRemoveAndFixFlow. The oneclean build is clean because it is older, not because it is fixed — it does not contain that lambda.
fgHeadTailMergeis unchanged inmainas of69f1b6a5f52(2026-09-02):fgopt.cpplines5091-5641 are byte-identical to the 2026-08-28 build above (SHA-256 of the region matches), and
block.h:1958,fgbasic.cpp:5123,fgopt.cpp:5488andfgopt.cpp:5561are all at the same lines.I do not have a checked build of
mainitself, so that is a source-identity argument rather than anexecution result.
Note on the phase name
The repro above trips the early
fgHeadTailMerge(early = true)invocation. The failure thatoriginally prompted this investigation was reported during the post-morph invocation
(
early = false), with the same assert text and line. Same function, same lambda, same cursor — butthey are two distinct call sites, and I was not able to construct a program that survives to the
second one: attempts to keep the tails textually distinct until morph (
a + 0,a * 1,a / 1,a | 0against a plaina) are all folded by Roslyn, so the tails are already identical in IL.I believe the difference is incidental, but I have not proven it.
Severity
Assert-only as far as I can establish. Release
net10.0runs the first repro to completion, prints0, does not hang, and produces the correct result. A release JIT takes the same path without theassert, leaving the cursor pointing at an unlinked block; that produced no observable misbehaviour
here, but it is not a safe state.
Suggested fix
For the
bbRefsvariant,canRemoveshould reject a self-target:That does not address the cursor variant, where
newTargetis legitimately a different block. Forthat, either capture
block->Next()beforeiterateTailMerge(block), or excludeblockfrom itsown
predInfointailMerge— merging a block's tail into itself is not a useful transform.Configuration
DOTNET_TieredCompilation=0, FullOpts.root cause, not minimized from the original program.
Note
This issue body was drafted with GitHub Copilot. The repro, the regression range, and the
source-identity check against
mainwere verified by running them.