[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
Wed Jun 24 07:50:48 PDT 2026
https://github.com/SLTozer updated https://github.com/llvm/llvm-project/pull/203847
>From 37781a7f626cd4dbd719c8f3875fd5b943e0df32 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 7441580b3fe35..a3bd98344a5f7 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 78cc1a5d5247a..95d5196b95a46 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, Tuple
+from typing import Callable, Dict, List, Optional, Set, Tuple
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
@@ -79,11 +109,13 @@ class WhereFrameMatchResult(IntEnum):
def _match_where_to_frame(
where: Where,
- frame: FrameIR,
+ frame_idx: int,
+ step: StepIR,
labels: FileLabels,
context: StateMatchContext,
) -> WhereFrameMatchResult:
"""A very simple matcher, returns True iff `where` matches `frame`."""
+ frame = step.frames[frame_idx]
if where.file is not None and not is_subpath(where.file, frame.loc.path):
return WhereFrameMatchResult.FALSE
if where.function is not None:
@@ -100,8 +132,10 @@ def _match_where_to_frame(
where_hit_count = context.where_hit_counts[where]
if where_hit_count > where.for_hit_count + pre_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:
@@ -113,19 +147,19 @@ def _match_where_to_frame(
def match_where_to_frame(
where: Where,
- frame: FrameIR,
+ frame_idx: int,
step: StepIR,
labels: Dict[str, int],
context: StateMatchContext,
) -> WhereFrameMatchResult:
"""Returns True if `where` matches `frame`. As part of this check, we perform the check once, and if necessary we
may increment `where`'s hit count and check again."""
- result = _match_where_to_frame(where, frame, labels, context)
+ result = _match_where_to_frame(where, frame_idx, step, labels, context)
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)
+ result = _match_where_to_frame(where, frame_idx, step, labels, context)
return result
@dataclass
@@ -161,6 +195,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.
@@ -191,7 +226,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,
@@ -206,7 +241,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
+ where, frame_idx, step_info, labels, match_context
)
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 ace44f7fdbe25..bc8cd7ca2d500 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 bb883e6cff8c207e56ecfd9338f5805ce10aff46 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 95d5196b95a46..654203d49562b 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