[llvm-branch-commits] [llvm] [Dexter] Enable after_hit_count for state nodes (PR #203846)
Stephen Tozer via llvm-branch-commits
llvm-branch-commits at lists.llvm.org
Wed Jun 24 07:30:19 PDT 2026
https://github.com/SLTozer updated https://github.com/llvm/llvm-project/pull/203846
>From d036570405e7557f368648f1c660803887305086 Mon Sep 17 00:00:00 2001
From: Stephen Tozer <stephen.tozer at sony.com>
Date: Fri, 12 Jun 2026 18:47:40 +0100
Subject: [PATCH] [Dexter] Enable after_hit_count for state nodes
The after_hit_count attribute for a state node causes it to become active
only after it would have become active N times. This uses the existing logic
for incrementing hit counts, i.e. after the node becomes "active", we will
not add another hit count until it stops being active for at least one step.
Since state nodes with after_hit_count do not become active before reaching
the required hit count, this requires us to keep track of an "early" set of
state nodes, meaning nodes that would be active if not for their
after_hit_count.
---
.../ScriptDebuggerController.py | 18 ++--
.../dexter/dex/evaluation/ExpectRewriter.py | 6 +-
.../dexter/dex/evaluation/RunMatch.py | 11 ++-
.../dexter/dex/evaluation/StateMatch.py | 95 +++++++++++++------
.../dexter/dex/test_script/Nodes.py | 8 --
.../debugging/then_after_hit_count.cpp | 31 ++++++
.../feature_tests/scripts/where_hit_count.cpp | 38 ++++++++
7 files changed, 153 insertions(+), 54 deletions(-)
create mode 100644 cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/then_after_hit_count.cpp
create mode 100644 cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/where_hit_count.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 e80dd5405a3a0..089831864c05d 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
@@ -19,7 +19,7 @@
)
from dex.debugger.DebuggerBase import DebuggerBase
from dex.debugger.DAP import DAP
-from dex.evaluation.StateMatch import StateMatchContext, get_active_where_matches
+from dex.evaluation.StateMatch import StateMatchContext, get_state_match
from dex.test_script.Nodes import Where
from dex.test_script.Script import DexterScript, Scope
from dex.tools import Context
@@ -133,13 +133,11 @@ def _run_debugger_custom(self, cmdline):
key=lambda where: str(where),
)
- active_where_matches = get_active_where_matches(
- script, step_info, state_match_context
- )
+ state_match = get_state_match(script, step_info, state_match_context)
watches = defaultdict(list)
scope_watches = defaultdict(list)
- for where, where_match in active_where_matches.items():
+ for where, where_match in state_match.where_match_results.items():
watches[where_match.frame_idx].extend(
watch
for expect in where_match.active_expects
@@ -158,7 +156,7 @@ def _run_debugger_custom(self, cmdline):
active_thens = [
then
- for where_match in active_where_matches.values()
+ for where_match in state_match.where_match_results.values()
for then in where_match.active_thens
]
should_step_out = any(then.command == "step_out" for then in active_thens)
@@ -175,10 +173,10 @@ def _run_debugger_custom(self, cmdline):
next_action = DebuggerAction.STEP_OUT
elif any(
where_match.frame_idx == 0
- for where_match in active_where_matches.values()
+ for where_match in state_match.where_match_results.values()
):
next_action = DebuggerAction.STEP_OVER
- elif active_where_matches:
+ elif state_match.where_match_results:
next_action = DebuggerAction.STEP_OUT
elif all(
where in state_match_context.expired_wheres
@@ -192,7 +190,7 @@ def _run_debugger_custom(self, cmdline):
bp_to_delete = []
pending_wheres = set(
where
- for where_match in active_where_matches.values()
+ for where_match in state_match.where_match_results.values()
for where in where_match.pending_wheres
)
@@ -222,7 +220,7 @@ def where_should_have_breakpoint(where: Where):
# If we have --trace enabled, report a short overview of this step.
self.context.logger.note(
- f"Stopped at {step_info.current_function} {step_info.current_location.short_str()}, {len(active_where_matches)} !wheres on the stack, next_action={next_action}"
+ f"Stopped at {step_info.current_function} {step_info.current_location.short_str()}, {len(state_match.where_match_results)} !wheres on the stack, next_action={next_action}"
)
if next_action == DebuggerAction.EXIT:
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 265cc7b1bb7ec..9ab347dbaebbf 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/ExpectRewriter.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/ExpectRewriter.py
@@ -10,7 +10,7 @@
from typing import Any, Dict, List, Optional, Set, Tuple, Union
from dex.dextIR import DextIR, StepIR, ValueIR
-from dex.evaluation.StateMatch import StateMatchContext, get_active_where_matches
+from dex.evaluation.StateMatch import StateMatchContext, get_state_match
from dex.test_script.Nodes import (
DexRange,
Expect,
@@ -189,10 +189,10 @@ def __init__(
):
self.step = step
self.script = script
- self.state_match = get_active_where_matches(script, step, state_match_context)
+ self.state_match = get_state_match(script, step, state_match_context)
active_expects = {
expect: where_match.frame_idx
- for where_match in self.state_match.values()
+ for where_match in self.state_match.where_match_results.values()
for expect in where_match.active_expects
}
self.expect_value_matches: Dict[Expect, ExpectedValueRewriter] = {}
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 e4cf117110e37..7441580b3fe35 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/RunMatch.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/RunMatch.py
@@ -26,7 +26,7 @@
get_variable_metrics,
serialize_metric_to_json,
)
-from dex.evaluation.StateMatch import StateMatchContext, get_active_where_matches
+from dex.evaluation.StateMatch import StateMatchContext, get_state_match
from dex.test_script import DexterScript, Scope
from dex.test_script.Nodes import Expect, Line, Step, Value
@@ -45,10 +45,10 @@ def __init__(
self.step = step
self.script = script
self.match_context = match_context
- self.state_match = get_active_where_matches(script, step, state_match_context)
+ self.state_match = get_state_match(script, step, state_match_context)
expects_to_match = {
expect: where_match.frame_idx
- for where_match in self.state_match.values()
+ for where_match in self.state_match.where_match_results.values()
for expect in where_match.active_expects
}
self.var_expect_matches: Dict[Expect, DebuggerExpectMatch] = {}
@@ -177,7 +177,10 @@ def dump_step_results(self) -> str:
result += f"Step {step_match.step.step_index}:\n"
result += f" {step_match.step.current_location}\n"
frame_active_wheres = defaultdict(list)
- for where, where_match in step_match.state_match.items():
+ for (
+ where,
+ where_match,
+ ) in step_match.state_match.where_match_results.items():
frame_active_wheres[where_match.frame_idx].append(str(where))
if not frame_active_wheres:
result += f" No active !where nodes.\n"
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 c7b2803ea0a86..78cc1a5d5247a 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
@@ -9,6 +9,7 @@
from collections import Counter
from dataclasses import dataclass, field
+from enum import Enum, IntEnum
import os
from typing import Dict, List, Optional, Set, Tuple
@@ -33,7 +34,10 @@ def where_hit_is_new(self, where: Where, step: StepIR) -> bool:
if self._last_match_result is None:
return True
# If this !where did not appear in any frame in the previous step, this is a fresh hit.
- if where not in self._last_match_result:
+ if (
+ where not in self._last_match_result.where_match_results
+ and where not in self._last_match_result.early_wheres
+ ):
return True
# If !where uses a function breakpoint and that breakpoint was hit this step, this is a fresh hit.
if where.function and not where.lines and where in step.hit_where_bps:
@@ -44,11 +48,14 @@ def add_hit_if_where_hit_is_new(self, where: Where, step: StepIR) -> bool:
"""Checks whether the current step can be counted as a new "hit" for `where`. Increments `where`'s hit count if
it has a new hit, and returns True iff so."""
assert (
- where.for_hit_count is not None
- ), "Tried to add hit count for !where without for_hit_count?"
+ where.for_hit_count is not None or where.after_hit_count is not None
+ ), "Tried to add hit count for !where without for/after_hit_count?"
if self.where_hit_is_new(where, step):
self.where_hit_counts[where] += 1
- if self.where_hit_counts[where] >= where.for_hit_count:
+ print(f"Added hit count for {where}")
+ if where.for_hit_count is not None and self.where_hit_counts[
+ where
+ ] >= where.for_hit_count + (where.after_hit_count or 0):
self.expired_wheres.add(where)
return True
return False
@@ -64,33 +71,44 @@ def is_subpath(subpath: str, superpath: str) -> bool:
return normalized_superpath.endswith(normalized_subpath)
+class WhereFrameMatchResult(IntEnum):
+ FALSE = 0
+ TRUE = 1
+ EARLY = 2
+
+
def _match_where_to_frame(
where: Where,
frame: FrameIR,
labels: FileLabels,
context: StateMatchContext,
-) -> bool:
+) -> WhereFrameMatchResult:
"""A very simple matcher, returns True iff `where` matches `frame`."""
if where.file is not None and not is_subpath(where.file, frame.loc.path):
- return False
+ return WhereFrameMatchResult.FALSE
if where.function is not None:
fn = frame.function
if "(" in fn:
fn = fn.split("(")[0]
if where.function != fn:
- return False
+ return WhereFrameMatchResult.FALSE
if where.lines is not None:
if frame.loc.lineno not in where.get_lines(labels):
- return False
+ return WhereFrameMatchResult.FALSE
if where.for_hit_count is not None:
+ pre_hit_count = where.after_hit_count or 0
where_hit_count = context.where_hit_counts[where]
- if where_hit_count > where.for_hit_count:
- return False
- if where.after_hit_count is not None or where.conditions is not None:
- raise NotImplementedError(
- "!where hit counts and conditions currently unsupported."
- )
- return True
+ if where_hit_count > where.for_hit_count + pre_hit_count:
+ return WhereFrameMatchResult.FALSE
+ if where.conditions is not None:
+ raise NotImplementedError("!where conditions currently unsupported.")
+ # 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:
+ where_hit_count = context.where_hit_counts[where]
+ if where_hit_count <= where.after_hit_count:
+ return WhereFrameMatchResult.EARLY
+ return WhereFrameMatchResult.TRUE
def match_where_to_frame(
@@ -99,11 +117,13 @@ def match_where_to_frame(
step: StepIR,
labels: Dict[str, int],
context: StateMatchContext,
-) -> bool:
+) -> 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)
- if result == True and where.for_hit_count is not None:
+ 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)
return result
@@ -119,19 +139,28 @@ class WhereMatchResult:
active_thens: List[Then] = field(default_factory=list)
pending_wheres: List[Where] = field(default_factory=list)
expired_wheres: List[Where] = field(default_factory=list)
+ early_wheres: List[Where] = field(default_factory=list)
-StateMatchResult = Dict[Where, WhereMatchResult]
+class StateMatchResult:
+ def __init__(
+ self,
+ where_match_results: Dict[Where, WhereMatchResult],
+ early_wheres: Set[Where],
+ ):
+ self.where_match_results = where_match_results
+ self.early_wheres = early_wheres
-def get_active_where_matches(
+def get_state_match(
script: DexterScript, step_info: StepIR, match_context: StateMatchContext
-) -> Dict[Where, WhereMatchResult]:
+) -> StateMatchResult:
"""Match the script against the step_info, producing a dict that maps each !where that matches a stack frame to the
index of the (rootmost) stack frame that it matches, and if the frame that it matches is the current stack frame
(i.e. the frame index is 0), also includes a list of every direct child !expect node for that !where.
"""
active_where_expects: Dict[Where, WhereMatchResult] = {}
+ early_wheres: Set[Where] = set()
def get_active_wheres(where: Where, scope: Scope):
# For nested !wheres, we must match a specific frame relative to the parent !where.
@@ -160,25 +189,32 @@ def get_active_wheres(where: Where, scope: Scope):
labels = script.get_labels(
expected_file or step_info.frames[target_frame_idx].loc.path
)
- if match_where_to_frame(
+ match_result = match_where_to_frame(
where,
step_info.frames[target_frame_idx],
step_info,
labels,
match_context,
- ):
+ )
+ if match_result == WhereFrameMatchResult.TRUE:
active_where_expects[where] = WhereMatchResult(target_frame_idx)
+ elif match_result == WhereFrameMatchResult.EARLY:
+ early_wheres.add(where)
return
# For this !where, search for the rootmost stack frame that matches it.
matching_frame_idx = None
for frame_idx, frame in reversed(list(enumerate(step_info.frames))):
labels = script.get_labels(expected_file or frame.loc.path)
- if match_where_to_frame(where, frame, step_info, labels, match_context):
- matching_frame_idx = frame_idx
- break
+ match_result = match_where_to_frame(
+ where, frame, step_info, labels, match_context
+ )
+ if match_result == WhereFrameMatchResult.TRUE:
+ active_where_expects[where] = WhereMatchResult(frame_idx)
+ return
+ if match_result == WhereFrameMatchResult.EARLY:
+ early_wheres.add(where)
+ return
- if matching_frame_idx is not None:
- active_where_expects[where] = WhereMatchResult(matching_frame_idx)
# As we visit the script nodes in pre-order traversal, we can always assume that an expect's parent !where
# has already been visited, and thus should have an entry in active_where_expects if it is active.
@@ -202,5 +238,6 @@ def get_active_thens(then: Then, scope: Scope):
visit_then=get_active_thens,
)
- match_context.update(active_where_expects)
- return active_where_expects
+ result = StateMatchResult(active_where_expects, early_wheres)
+ match_context.update(result)
+ return result
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 24370be5df7f0..ace44f7fdbe25 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
@@ -70,14 +70,6 @@ def __init__(self, attributes: dict, is_and: bool):
raise DexterNodeError(
self, f"unexpected attributes {', '.join(attributes)}"
)
- if (
- not self.function
- and not self.lines
- and (self.for_hit_count or self.after_hit_count)
- ):
- raise DexterNodeError(
- self, "can't check hit counts without an explicit lines or function arg"
- )
if self.at_frame_idx is not None and not self.is_and:
raise DexterNodeError(self, "at_frame_idx can only be used with !and nodes")
diff --git a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/then_after_hit_count.cpp b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/then_after_hit_count.cpp
new file mode 100644
index 0000000000000..c6366f7376a2b
--- /dev/null
+++ b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/then_after_hit_count.cpp
@@ -0,0 +1,31 @@
+// RUN: %dexter_regression_test_cxx_build %s -o %t
+// RUN: %dexter_regression_test_run --use-script --skip-evaluate --binary %t \
+// RUN: -- %s | FileCheck %s
+
+/// Test !then finish with !and{after_hit_count}.
+/// The infinite loop will be exited when we hit the `!then finish` command,
+/// which we will see after 101 hits of main: 50 hits on the loop line before
+/// after_hit_count is reached, 50 hits from stepping off of the loop line, and
+/// 1 hit from the step where we trigger the !then node.
+
+// CHECK-LABEL: Step 0
+// CHECK-COUNT-101: main
+// CHECK-NOT: Step
+
+bool checkCows() { return false; }
+
+int main() {
+ bool AreCowsHomeYet = false;
+ while (!AreCowsHomeYet) {
+ AreCowsHomeYet = checkCows(); // !dex_label loop
+ }
+ return 0;
+}
+
+/*
+---
+!where {lines: !label loop}:
+ !value AreCowsHomeYet: false
+ !and {after_hit_count: 50}: !then finish
+...
+*/
diff --git a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/where_hit_count.cpp b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/where_hit_count.cpp
new file mode 100644
index 0000000000000..18db2df8a46b2
--- /dev/null
+++ b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/where_hit_count.cpp
@@ -0,0 +1,38 @@
+// RUN: %dexter_regression_test_cxx_build %s -o %t
+// RUN: %dexter_regression_test_run --use-script --binary %t -- %s \
+// RUN: | FileCheck %s
+
+/// Test that Dexter respects for and after hit_counts.
+
+void receive(int N) {}
+
+int main() {
+ int Current = 0;
+ int Increment = 1;
+ for (int I = 0; I < 10; ++I) {
+ receive(Current); // !dex_label loop
+ Current += Increment++;
+ }
+ return 0;
+}
+
+// CHECK: total_watched_steps: 10
+// CHECK: correct_steps: 10
+// CHECK: incorrect_steps: 0
+// CHECK: partial_step_correctness: 10.0
+// CHECK: missing_var_steps: 0
+// CHECK: unexpected_value_steps: 0
+// CHECK: correct_step_coverage: 100.0% (10/10)
+// CHECK: seen_values: 10
+// CHECK: missing_values: 0
+
+/*
+---
+!where {function: receive, after_hit_count: 3, for_hit_count: 4}:
+ !value N: [6, 10, 15, 21]
+!where {lines: !label loop, for_hit_count: 3}:
+ !value Current: [0, 1, 3]
+!where {lines: !label loop, after_hit_count: 7}:
+ !value Current: [28, 36, 45]
+...
+*/
More information about the llvm-branch-commits
mailing list