Skip to content

JIT: fgHeadTailMerge can remove the block its Blocks() cursor is standing on #133131

Description

@AndyAyersMS

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:5559
    iterateTailMerge(block);               // fgopt.cpp:5561

The block removed is that cursor.

Repro

Requires DOTNET_TieredCompilation=0. No stress modes, no PGO, plain FullOpts, x64 Windows.

using System;

public static class Program
{
    static int s_v;

    static void F(int x)
    {
        if (x == 1) goto P;
        if (x == 2) { s_v = 77; goto L; }   // 3rd pred, tail does NOT match
        return;
    P:
        s_v = 1; goto L;                    // matched pred, falls through into L
    L:
        s_v = 1; goto L;                    // self-loop: L is its own predecessor
    }

    public static int Main()
    {
        F(-1);                              // the loop is never entered
        Console.WriteLine(s_v);
        return 0;
    }
}
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:

  1. 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.
  2. 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
static void F(int x)
{
    if (x >= 0) goto P;
    return;
L:
    s_v = 1; goto L;
P:
    s_v = 1; goto L;
}

Regression range

commit date result
b41e0e4210a 2026-05-28 clean — predates tryRemoveAndFixFlow
6fb715b7b1c 2026-07-15 assert
9a72df7dab4 2026-08-11 assert
f259a30044d 2026-08-25 assert
d2ea726dd3d 2026-08-28 assert
baee3fc4586 2026-08-31 assert
a5ea966a138 2026-08-31 assert

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:

bool canRemove = (emptyBlock != newTarget) && !emptyBlock->HasFlag(BBF_DONT_REMOVE) && ...

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.

Activity

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

Metadata

Metadata

Assignees

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions