[llvm-branch-commits] [llvm] [Dexter] Add condition check to state nodes (PR #203847)

Stephen Tozer via llvm-branch-commits llvm-branch-commits at lists.llvm.org
Tue Jun 30 12:06:32 PDT 2026


https://github.com/SLTozer updated https://github.com/llvm/llvm-project/pull/203847

>From 3245a5b7974d274fbcee2f2b7cdc4b7131a6dc67 Mon Sep 17 00:00:00 2001
From: Stephen Tozer <stephen.tozer at sony.com>
Date: Fri, 12 Jun 2026 20:29:14 +0100
Subject: [PATCH 1/2] [Dexter] Add condition check to state nodes

This patch enables the ability for state nodes to check conditions, meaning
they will be active only if the condition is met.

Condition evaluation is somewhat language specific; we directly check
whether the value of the evaluated expression is "true" (case-insensitive),
which works for the languages we actually use Dexter with, but may require
generalizing in future.

We also cache conditions as they are evaluated; each time we step, we clear
all cached conditions for the current frame and any expired frames, but we
keep the cached conditions for any frames rootwards from the current frame;
this prevents us from unexpectedly exiting out of a callee frame because of
debug info not surviving a stack unwind; if the early exit is desired, an
!and{at_frame_idx, condition} under the lower frame may suffice.
---
 .../ScriptDebuggerController.py               | 10 +++-
 .../dexter/dex/evaluation/ExpectRewriter.py   | 10 +++-
 .../dexter/dex/evaluation/RunMatch.py         |  7 ++-
 .../dexter/dex/evaluation/StateMatch.py       | 55 +++++++++++++++----
 .../dexter/dex/test_script/Nodes.py           |  2 +-
 .../feature_tests/scripts/conditions.cpp      | 53 ++++++++++++++++++
 6 files changed, 121 insertions(+), 16 deletions(-)
 create mode 100644 cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/conditions.cpp

diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerControllers/ScriptDebuggerController.py b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerControllers/ScriptDebuggerController.py
index 089831864c05d..3de9a2eff5a98 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerControllers/ScriptDebuggerController.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerControllers/ScriptDebuggerController.py
@@ -86,8 +86,16 @@ def _run_debugger_custom(self, cmdline):
 
         self.step_collection.clear_steps()
 
+        def check_condition(step: StepIR, frame_idx: int, condition: str):
+            """Evaluates the given condition at the given frame index. Requires the debugger session to be alive and the
+            debuggee must be stopped."""
+            cond_value = self.debugger.evaluate_expression(condition, frame_idx)
+            step.frames[frame_idx].watches[condition] = cond_value
+            # FIXME: This is a language-specific test (albeit it covers all languages Dexter is currently used with).
+            return cond_value.could_evaluate and cond_value.value.lower() == "true"
+
         script: DexterScript = self.script
-        state_match_context = StateMatchContext()
+        state_match_context = StateMatchContext(check_condition=check_condition)
         self._init_bps()
 
         self.debugger.launch(cmdline)
diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/ExpectRewriter.py b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/ExpectRewriter.py
index 9ab347dbaebbf..d1c31eb51df11 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/ExpectRewriter.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/ExpectRewriter.py
@@ -276,14 +276,18 @@ def collect_expects_to_rewrite(
         ):
             return
 
-        state_match_context = StateMatchContext()
+        def check_condition(step: StepIR, frame_idx: int, condition: str):
+            cond_value = step.frames[frame_idx].watches[condition]
+            result = cond_value.could_evaluate and cond_value.value.lower() == "true"
+            return result
 
-        # Populate the `unknown_expect_rewrites` dict, mapping each expect with an unknown value to its list of observed
-        # during this run, along with the corresponding step indices.
+        state_match_context = StateMatchContext(check_condition=check_condition)
         self.step_rewriters = [
             StepExpectRewriter(step, script, state_match_context)
             for step in dext_ir.steps
         ]
+        # Populate the expect_rewrites dicts, mapping each expect with an unknown value to its list of observed values
+        # during this run, along with the corresponding step indices.
         for step_rewriter in self.step_rewriters:
             step_idx = step_rewriter.step.step_index
             for (
diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/RunMatch.py b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/RunMatch.py
index 1af68fbdf7c61..0233d287dc2c0 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/RunMatch.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/RunMatch.py
@@ -109,7 +109,12 @@ def add_expected_values(expect: Expect, expected_value: Any, scope: Scope):
         script.visit_script(visit_expect=add_expected_values)
 
         # Then produce all of our step matches.
-        state_match_context = StateMatchContext()
+        def check_condition(step: StepIR, frame_idx: int, condition: str):
+            cond_value = step.frames[frame_idx].watches[condition]
+            result = cond_value.could_evaluate and cond_value.value.lower() == "true"
+            return result
+
+        state_match_context = StateMatchContext(check_condition=check_condition)
         for step in self.dext_ir.steps:
             self.step_matches.append(
                 DebuggerStepMatch(step, script, self.match_context, state_match_context)
diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
index ae7e4e332fdc8..9f2b975b25389 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
@@ -7,11 +7,11 @@
 """Utilities for matching debugger state, such as the call stack, conditions, or historical state (e.g. breakpoint
 hitcounts) to descriptions of expected state in a DexterScript."""
 
-from collections import Counter
+from collections import Counter, defaultdict
 from dataclasses import dataclass, field
 from enum import Enum, IntEnum
 import os
-from typing import Dict, List, Optional, Set
+from typing import Callable, Dict, List, Optional, Set
 
 from dex.dextIR import FrameIR, StepIR
 from dex.test_script import DexterScript, Scope
@@ -21,10 +21,40 @@
 class StateMatchContext:
     """Class that holds any state needed for matching state nodes to debugger state across a run."""
 
-    def __init__(self):
+    def __init__(self, check_condition: Callable[[StepIR, int, str], bool]):
         self.where_hit_counts: Counter[Where] = Counter()
         self.expired_wheres: Set[Where] = set()
         self._last_match_result: Optional[StateMatchResult] = None
+        # To avoid constantly re-evaluating conditions above the current function, and potentially causing them to
+        # be unfulfillable if we have imperfect stack unwinding, we track conditions that have been found True for state
+        # nodes above the current function and consider those conditions true until we return to/pass that frame.
+        # Key is a frame index counting from the root upwards, to keep stable as we grow and shrink the stack.
+        self._cached_frame_conditions: Dict[int, Dict[str, bool]] = defaultdict(dict)
+        self._check_condition = check_condition
+
+    def check_condition(self, step: StepIR, frame_idx: int, condition: str) -> bool:
+        reverse_frame_idx = step.num_frames - frame_idx - 1
+        cached_conditions = self._cached_frame_conditions[reverse_frame_idx]
+        if condition in cached_conditions:
+            return cached_conditions[condition]
+        # In an ideal world we would always cache conditions in a callee frame before moving to a called frame, but
+        # some optimized code makes this infeasible, so we settle for computing it after reaching the called frame
+        # instead.
+        result = self._check_condition(step, frame_idx, condition)
+        # We cache this now, but we won't actually use it *unless* we end up stepping into a lower frame next step.
+        self._cached_frame_conditions[reverse_frame_idx][condition] = result
+        return result
+
+    def refresh_condition_cache(self, step: StepIR):
+        """Call once we start matching a new step, to clear out any stale/invalid cached conditions."""
+        to_delete = []
+        for reverse_frame_idx in self._cached_frame_conditions:
+            # Any cached condition for the current frame, or a lower frame no longer on the stack at all, must be
+            # cleared.
+            if reverse_frame_idx + 1 >= step.num_frames:
+                to_delete.append(reverse_frame_idx)
+        for idx in to_delete:
+            del self._cached_frame_conditions[idx]
 
     def where_hit_is_new(self, where: Where, step: StepIR) -> bool:
         """Returns True if the current step can be counted as a new "hit" for `where`, assuming that `where` was hit in
@@ -80,12 +110,14 @@ class WhereFrameMatchResult(IntEnum):
 
 def _match_where_to_frame(
     where: Where,
-    frame: FrameIR,
+    frame_idx: int,
+    step: StepIR,
     labels: FileLabels,
     context: StateMatchContext,
     default_path: Optional[str] = None,
 ) -> WhereFrameMatchResult:
     """A very simple matcher, returns True iff `where` matches `frame`."""
+    frame = step.frames[frame_idx]
     file = where.file
     if not file and where.lines and not where.function:
         file = default_path
@@ -105,8 +137,10 @@ def _match_where_to_frame(
         where_hit_count = context.where_hit_counts[where]
         if where_hit_count > where.for_hit_count + (after_hit_count):
             return WhereFrameMatchResult.FALSE
+    # We place the condition check as far down as possible to avoid unnecessary debugger calls.
     if where.conditions is not None:
-        raise NotImplementedError("!where conditions currently unsupported.")
+        if not context.check_condition(step, frame_idx, where.conditions):
+            return WhereFrameMatchResult.FALSE
     # The check for after_hit_count must go last, as before we return EARLY, we need to know that the only condition
     # preventing the match is after_hit_count.
     if where.after_hit_count is not None:
@@ -118,7 +152,7 @@ def _match_where_to_frame(
 
 def match_where_to_frame(
     where: Where,
-    frame: FrameIR,
+    frame_idx: int,
     step: StepIR,
     labels: FileLabels,
     context: StateMatchContext,
@@ -128,12 +162,12 @@ def match_where_to_frame(
     we get a result that could change due to an increased hit count (i.e. if we get a match with a `for_hit_count`
     where, or if we get an "early" result), we increment `where`'s hit count and run the check again to check whether
     the result changes."""
-    result = _match_where_to_frame(where, frame, labels, context, default_path)
+    result = _match_where_to_frame(where, frame_idx, step, labels, context, default_path)
     if result == WhereFrameMatchResult.EARLY or (
         result == WhereFrameMatchResult.TRUE and where.for_hit_count is not None
     ):
         if context.add_hit_if_where_hit_is_new(where, step):
-            result = _match_where_to_frame(where, frame, labels, context, default_path)
+            result = _match_where_to_frame(where, frame_idx, step, labels, context, default_path)
     return result
 
 
@@ -170,6 +204,7 @@ def get_state_match(
     """
     active_where_expects: Dict[Where, WhereMatchResult] = {}
     early_wheres: Set[Where] = set()
+    match_context.refresh_condition_cache(step_info)
 
     def get_active_wheres(where: Where, scope: Scope):
         # For nested !wheres, we must match a specific frame relative to the parent !where.
@@ -200,7 +235,7 @@ def get_active_wheres(where: Where, scope: Scope):
             )
             match_result = match_where_to_frame(
                 where,
-                step_info.frames[target_frame_idx],
+                target_frame_idx,
                 step_info,
                 labels,
                 match_context,
@@ -214,7 +249,7 @@ def get_active_wheres(where: Where, scope: Scope):
         for frame_idx, frame in reversed(list(enumerate(step_info.frames))):
             labels = script.get_labels(expected_file or frame.loc.path)
             match_result = match_where_to_frame(
-                where, frame, step_info, labels, match_context, script.root_scope.file
+                where, frame_idx, step_info, labels, match_context, script.root_scope.file
             )
             if match_result == WhereFrameMatchResult.TRUE:
                 active_where_expects[where] = WhereMatchResult(frame_idx)
diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/test_script/Nodes.py b/cross-project-tests/debuginfo-tests/dexter/dex/test_script/Nodes.py
index b754f6bebfe5a..c5e4e7c4ae510 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/test_script/Nodes.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/test_script/Nodes.py
@@ -64,7 +64,7 @@ def __init__(self, attributes: dict, is_and: bool):
         self.at_frame_idx: Optional[int] = attributes.pop("at_frame_idx", None)
         self.after_hit_count: Optional[int] = attributes.pop("after_hit_count", None)
         self.for_hit_count: Optional[int] = attributes.pop("for_hit_count", None)
-        self.conditions: dict = attributes.pop("conditions", None)
+        self.conditions: Optional[str] = attributes.pop("conditions", None)
         self.is_and = is_and
         if attributes:
             raise DexterNodeError(
diff --git a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/conditions.cpp b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/conditions.cpp
new file mode 100644
index 0000000000000..3e83a66030c28
--- /dev/null
+++ b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/conditions.cpp
@@ -0,0 +1,53 @@
+// RUN: %dexter_regression_test_cxx_build %s -o %t
+// RUN: %dexter_regression_test_run --use-script --binary %t -- %s \
+// RUN:   | FileCheck %s
+
+/// Test that we correctly interpret nested !where+!and nodes during debugging
+/// and evaluation.
+/// With the conditional check, we should observe half the values of I ([0-3]
+/// and [8-11]), and step into `walk` exactly 8 times.
+
+void walk() {} // !dex_label walk
+
+const char *Red = "Red";
+const char *Green = "Green";
+
+int main() {
+  const char *Light = Red;
+  for (int I = 0; I < 16; ++I) {
+    if (I % 8 == 0)
+      Light = Green;
+    else if (I % 4 == 0)
+      Light = Red;
+    walk(); // !dex_label call
+  }
+  return 0;
+}
+
+// CHECK: total_watched_steps: 8
+// CHECK: correct_steps: 8
+// CHECK: incorrect_steps: 0
+// CHECK: partial_step_correctness: 8.0
+// CHECK: missing_var_steps: 0
+// CHECK: unexpected_value_steps: 0
+// CHECK: correct_step_coverage: 100.0% (8/8)
+// CHECK: seen_values: 8
+// CHECK: missing_values: 0
+// CHECK: total_line_steps: 8
+// CHECK: correct_line_steps: 8
+// CHECK: correct_line_score: 100.0% (8/8)
+// CHECK: misordered_line_steps: 0
+// CHECK: missing_lines: 0
+// CHECK: incorrect_line_steps: 0
+// CHECK: unexpected_lines: 0
+
+/*
+---
+!where {function: main}:
+  !and {lines: !label call, conditions: 'Light == Green'}:
+    !value I: [0, 1, 2, 3, 8, 9, 10, 11]
+    !where {function: walk}:
+      !step exactly: [!label walk, !label walk, !label walk, !label walk,
+        !label walk, !label walk, !label walk, !label walk]
+...
+*/

>From a84508e4ba0ededc008c2128071d22c20c41a0a6 Mon Sep 17 00:00:00 2001
From: Stephen Tozer <stephen.tozer at sony.com>
Date: Wed, 24 Jun 2026 15:50:10 +0100
Subject: [PATCH 2/2] Address review comments, add check for 'true' assumption

---
 .../DebuggerControllers/ScriptDebuggerController.py   | 11 ++++++++++-
 .../dexter/dex/evaluation/StateMatch.py               |  5 +++--
 .../dexter/feature_tests/scripts/conditions.cpp       |  7 ++++---
 3 files changed, 17 insertions(+), 6 deletions(-)

diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerControllers/ScriptDebuggerController.py b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerControllers/ScriptDebuggerController.py
index 3de9a2eff5a98..db4c4a339f2db 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerControllers/ScriptDebuggerController.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerControllers/ScriptDebuggerController.py
@@ -91,7 +91,16 @@ def check_condition(step: StepIR, frame_idx: int, condition: str):
             debuggee must be stopped."""
             cond_value = self.debugger.evaluate_expression(condition, frame_idx)
             step.frames[frame_idx].watches[condition] = cond_value
-            # FIXME: This is a language-specific test (albeit it covers all languages Dexter is currently used with).
+            if (
+                cond_value.could_evaluate
+                and cond_value.value.lower() != "true"
+                and cond_value.value.lower() == "false"
+            ):
+                self.context.logger.warning(
+                    f"Condition '{condition}' evaluated to non-bool value '{cond_value.value}'"
+                )
+            # FIXME: This is a language-specific test (albeit it covers all languages Dexter is currently used with). If
+            #        this assumption is broken, the warning above should be triggered.
             return cond_value.could_evaluate and cond_value.value.lower() == "true"
 
         script: DexterScript = self.script
diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
index 9f2b975b25389..b7d7b62e367ab 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
@@ -37,11 +37,12 @@ def check_condition(self, step: StepIR, frame_idx: int, condition: str) -> bool:
         cached_conditions = self._cached_frame_conditions[reverse_frame_idx]
         if condition in cached_conditions:
             return cached_conditions[condition]
-        # In an ideal world we would always cache conditions in a callee frame before moving to a called frame, but
+        # In an ideal world we would always cache conditions in a caller frame before moving to a called frame, but
         # some optimized code makes this infeasible, so we settle for computing it after reaching the called frame
         # instead.
         result = self._check_condition(step, frame_idx, condition)
-        # We cache this now, but we won't actually use it *unless* we end up stepping into a lower frame next step.
+        # We cache this now, but we won't actually use it *unless* the next step adds a new frame (i.e. we step into a
+        # call).
         self._cached_frame_conditions[reverse_frame_idx][condition] = result
         return result
 
diff --git a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/conditions.cpp b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/conditions.cpp
index 3e83a66030c28..e5bb78c35c3d2 100644
--- a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/conditions.cpp
+++ b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/conditions.cpp
@@ -9,16 +9,17 @@
 
 void walk() {} // !dex_label walk
 
-const char *Red = "Red";
-const char *Green = "Green";
+bool Red = false;
+bool Green = true;
 
 int main() {
-  const char *Light = Red;
+  bool Light = Red;
   for (int I = 0; I < 16; ++I) {
     if (I % 8 == 0)
       Light = Green;
     else if (I % 4 == 0)
       Light = Red;
+    // Light == Green from [0-3], [8-11]
     walk(); // !dex_label call
   }
   return 0;



More information about the llvm-branch-commits mailing list