[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 17 07:59:38 PDT 2026
https://github.com/SLTozer updated https://github.com/llvm/llvm-project/pull/203847
>From eeb486c001288620cd6c7e23a88590de427da639 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] [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/ExpectWriter.py | 7 ++-
.../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, 120 insertions(+), 14 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/ExpectWriter.py b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/ExpectWriter.py
index a27ba7b688d21..721fbd397b58b 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/ExpectWriter.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/ExpectWriter.py
@@ -269,7 +269,12 @@ def collect_expects_to_write(expect: Expect, expected_value: Any, scope: Scope):
):
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
+
+ state_match_context = StateMatchContext(check_condition=check_condition)
self.step_writers = [
StepExpectWriter(step, script, state_match_context)
for step in dext_ir.steps
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 01b0c5742c351..49a17a7b6e334 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.
@@ -190,7 +225,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,
@@ -205,7 +240,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 50c8028bf0d1c..2bd84fd8a4dd1 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]
+...
+*/
More information about the llvm-branch-commits
mailing list