https://github.com/SLTozer updated https://github.com/llvm/llvm-project/pull/203359
>From 2bfb9e191193371b56df5afe0eed321a5270c3bf Mon Sep 17 00:00:00 2001 From: Stephen Tozer <[email protected]> Date: Thu, 11 Jun 2026 18:44:38 +0100 Subject: [PATCH 1/2] [Dexter] Add for_hit_count for state nodes This patch adds the ability for state nodes to use a `for_hit_count: <int>` field to limit the number of times that a given state node will be active. --- .../ScriptDebuggerController.py | 46 +++++++--- .../dexter/dex/dextIR/StepIR.py | 3 +- .../dexter/dex/evaluation/ExpectWriter.py | 14 ++- .../dexter/dex/evaluation/RunMatch.py | 13 ++- .../dexter/dex/evaluation/StateMatch.py | 86 ++++++++++++++++--- .../dexter/dex/test_script/Nodes.py | 2 +- .../scripts/debugging/where_fn_hit_count.cpp | 26 ++++++ .../scripts/debugging/where_for_hit_count.cpp | 78 +++++++++++++++++ .../debugging/where_hit_count_early_exit.cpp | 33 +++++++ 9 files changed, 269 insertions(+), 32 deletions(-) create mode 100644 cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/where_fn_hit_count.cpp create mode 100644 cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/where_for_hit_count.cpp create mode 100644 cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/where_hit_count_early_exit.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 f1404c37116fa..fc052b5274089 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 get_active_where_matches +from dex.evaluation.StateMatch import StateMatchContext, get_active_where_matches from dex.test_script.Nodes import Where from dex.test_script.Script import DexterScript, Scope from dex.tools import Context @@ -87,6 +87,7 @@ def _run_debugger_custom(self, cmdline): self.step_collection.clear_steps() script: DexterScript = self.script + state_match_context = StateMatchContext() self._init_bps() self.debugger.launch(cmdline) @@ -119,8 +120,22 @@ def _run_debugger_custom(self, cmdline): ## Fetch frame information and breakpoint information from the debugger. step_info: StepIR = self.debugger.get_stack_frames(self._step_index) + # For some niche cases around function breakpoints, we may fail to + # correctly notice a !where being hit; therefore we explicitly track + # !where breakpoints that get hit in the StepIR. + hit_bps = self.debugger.get_triggered_breakpoint_ids() + step_info.hit_where_bps = sorted( + ( + where + for where, bp_ids in self._where_bps.items() + if any(bp_id in hit_bps for bp_id in bp_ids) + ), + key=lambda where: str(where), + ) - active_where_matches = get_active_where_matches(script, step_info) + active_where_matches = get_active_where_matches( + script, step_info, state_match_context + ) watches = [ watch @@ -160,6 +175,11 @@ def _run_debugger_custom(self, cmdline): next_action = DebuggerAction.STEP_OVER elif active_where_matches: next_action = DebuggerAction.STEP_OUT + elif all( + where in state_match_context.expired_wheres + for where in script.root_wheres + ): + next_action = DebuggerAction.EXIT else: next_action = DebuggerAction.CONTINUE @@ -170,19 +190,23 @@ def _run_debugger_custom(self, cmdline): for where_match in active_where_matches.values() for where in where_match.pending_wheres ) + + def where_should_have_breakpoint(where: Where): + if where in state_match_context.expired_wheres: + return False + if where in script.root_wheres: + return True + if should_step_out: + return False + return where in pending_wheres for where, bp_ids in self._where_bps.items(): - if ( - bp_ids - and where not in script.root_wheres - and (where not in pending_wheres or should_step_out) - ): + if bp_ids and not where_should_have_breakpoint(where): bp_to_delete.extend(bp_ids) bp_ids.clear() self.debugger.delete_breakpoints(bp_to_delete) - if not should_step_out: - for where in pending_wheres: - if not self._where_bps[where]: - self.add_where_entry_bp(where) + for where in pending_wheres: + if not self._where_bps[where] and where_should_have_breakpoint(where): + self.add_where_entry_bp(where) if step_info.current_frame: self._step_index += 1 diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/dextIR/StepIR.py b/cross-project-tests/debuginfo-tests/dexter/dex/dextIR/StepIR.py index 1b23015d02bcb..d2860a0ce8b95 100644 --- a/cross-project-tests/debuginfo-tests/dexter/dex/dextIR/StepIR.py +++ b/cross-project-tests/debuginfo-tests/dexter/dex/dextIR/StepIR.py @@ -14,6 +14,7 @@ from dex.dextIR.FrameIR import FrameIR from dex.dextIR.LocIR import LocIR from dex.dextIR.ProgramState import ProgramState +from dex.test_script.Nodes import Where class StopReason(Enum): @@ -66,7 +67,7 @@ def __init__( watches = {} self.watches = watches self.scope_watches = scope_watches or OrderedDict() - self.hit_fn_bps: List[str] = [] + self.hit_where_bps: List[Where] = [] def __str__(self): try: 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 d8660087b6ac0..9920cbd504379 100644 --- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/ExpectWriter.py +++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/ExpectWriter.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 get_active_where_matches +from dex.evaluation.StateMatch import StateMatchContext, get_active_where_matches from dex.test_script.Nodes import DexRange, Expect, Line, Then, Value, ValueAll, Where from dex.test_script.Script import DexterScript, Scope from dex.tools.Main import Context @@ -160,10 +160,12 @@ class StepExpectWriter: """Processes all active, unknown expects at a given debugger step and produces ExpectedValueWriter results for each.""" - def __init__(self, step: StepIR, script: DexterScript): + def __init__( + self, step: StepIR, script: DexterScript, state_match_context: StateMatchContext + ): self.step = step self.script = script - self.state_match = get_active_where_matches(script, step) + self.state_match = get_active_where_matches(script, step, state_match_context) active_expects = { expect for where_match in self.state_match.values() @@ -229,7 +231,11 @@ def collect_expects_to_write(expect: Expect, expected_value: Any, scope: Scope): if not self.unknown_expect_rewrites and not self.scope_expect_rewrites: return - self.step_writers = [StepExpectWriter(step, script) for step in dext_ir.steps] + state_match_context = StateMatchContext() + self.step_writers = [ + StepExpectWriter(step, script, state_match_context) + for step in dext_ir.steps + ] for step_writer in self.step_writers: step_idx = step_writer.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 73b12a918bd5f..e09cce69e6c28 100644 --- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/RunMatch.py +++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/RunMatch.py @@ -25,7 +25,7 @@ get_variable_metrics, serialize_metric_to_json, ) -from dex.evaluation.StateMatch import get_active_where_matches +from dex.evaluation.StateMatch import StateMatchContext, get_active_where_matches from dex.test_script import DexterScript, Scope from dex.test_script.Nodes import Expect, Value @@ -35,12 +35,16 @@ class DebuggerStepMatch: expected output.""" def __init__( - self, step: StepIR, script: DexterScript, match_context: ExpectMatchContext + self, + step: StepIR, + script: DexterScript, + match_context: ExpectMatchContext, + state_match_context: StateMatchContext, ): self.step = step self.script = script self.match_context = match_context - self.state_match = get_active_where_matches(script, step) + self.state_match = get_active_where_matches(script, step, state_match_context) expects_to_match = { expect for where_match in self.state_match.values() @@ -92,9 +96,10 @@ 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() for step in self.dext_ir.steps: self.step_matches.append( - DebuggerStepMatch(step, script, self.match_context) + DebuggerStepMatch(step, script, self.match_context, state_match_context) ) # Then, for each expect, produce the list of results for just that variable. 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 c515086f41293..6ba7b9d8fd1cb 100644 --- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py +++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py @@ -7,15 +7,55 @@ """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 dataclasses import dataclass, field import os -from typing import Dict, List, Tuple +from typing import Dict, List, Optional, Set, Tuple from dex.dextIR import FrameIR, StepIR from dex.test_script import DexterScript, Scope from dex.test_script.Nodes import Expect, Where, Then +class StateMatchContext: + """Class that holds any state needed for matching state nodes to debugger state across a run.""" + + def __init__(self): + self.where_hit_counts: Counter[Where] = Counter() + self.expired_wheres: Set[Where] = set() + self._last_match_result: Optional[StateMatchResult] = None + + 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 + this step (but does not check if `where` has actually been hit in the current step). + """ + # If this is the first step, all hits are new. + 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: + 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: + return True + return False + + 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?" + if self.where_hit_is_new(where, step): + self.where_hit_counts[where] += 1 + if self.where_hit_counts[where] >= where.for_hit_count: + self.expired_wheres.add(where) + return True + return False + + def update(self, new_match_result: "StateMatchResult"): + self._last_match_result = new_match_result + def is_subpath(subpath: str, superpath: str) -> bool: """Returns True if subpath is a trailing subpath of superpath, i.e. if `superpath` ends with `subpath` after normalizing both paths.""" @@ -24,11 +64,11 @@ def is_subpath(subpath: str, superpath: str) -> bool: return normalized_superpath.endswith(normalized_subpath) -# A very simple matcher, returns True iff `where` matches `frame`. -def match_where_to_frame( +def _match_where_to_frame( where: Where, frame: FrameIR, labels: Dict[str, int], + context: StateMatchContext, ) -> bool: """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): @@ -42,16 +82,30 @@ def match_where_to_frame( if where.lines is not None: if frame.loc.lineno not in where.get_lines(labels): return False - if ( - where.for_hit_count is not None - or where.after_hit_count is not None - or where.conditions is not None - ): + if where.for_hit_count is not None: + 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 +def match_where_to_frame( + where: Where, + frame: FrameIR, + step: StepIR, + labels: Dict[str, int], + context: StateMatchContext, +) -> bool: + """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 context.add_hit_if_where_hit_is_new(where, step): + result = _match_where_to_frame(where, frame, labels, context) + return result @dataclass class WhereMatchResult: @@ -63,10 +117,13 @@ class WhereMatchResult: active_expects: List[Expect] = field(default_factory=list) active_thens: List[Then] = field(default_factory=list) pending_wheres: List[Where] = field(default_factory=list) + expired_wheres: List[Where] = field(default_factory=list) + +StateMatchResult = Dict[Where, WhereMatchResult] def get_active_where_matches( - script: DexterScript, step_info: StepIR + script: DexterScript, step_info: StepIR, match_context: StateMatchContext ) -> Dict[Where, WhereMatchResult]: """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 @@ -93,14 +150,20 @@ 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(where, step_info.frames[target_frame_idx], labels): + if match_where_to_frame( + where, + step_info.frames[target_frame_idx], + step_info, + labels, + match_context, + ): active_where_expects[where] = WhereMatchResult(target_frame_idx) 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, labels): + if match_where_to_frame(where, frame, step_info, labels, match_context): matching_frame_idx = frame_idx break @@ -130,4 +193,5 @@ def get_active_thens(then: Then, scope: Scope): visit_then=get_active_thens, ) + match_context.update(active_where_expects) return active_where_expects 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 f3f81c6b2466f..eb24759f0c3ea 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 @@ -55,7 +55,7 @@ def __init__(self, attributes: dict, is_and: bool): self.lines: Union[Line, DexRange, None] = lines 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[dict] = 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/debugging/where_fn_hit_count.cpp b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/where_fn_hit_count.cpp new file mode 100644 index 0000000000000..0796968b47943 --- /dev/null +++ b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/where_fn_hit_count.cpp @@ -0,0 +1,26 @@ +// 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 that we record hit counts for !where{function} nodes, even when there +/// are no other steps between each breakpoint hit. + +/// We should only hit countdown 3 times, even though there is no gap between +/// the tail calls. +// CHECK-LABEL: Step 0 +// CHECK-COUNT-3: countdown +// CHECK-NOT: countdown + +/// All on one line for simplicity so that we only get one step per call. +// clang-format off +int countdown(int Num) { if (!Num) return 0; __attribute__((musttail)) return countdown(Num - 1); } +// clang-format on + +int main() { return countdown(2) + countdown(2); } + +/* +--- +!where {function: countdown, for_hit_count: 3}: + !value Num: [2, 1, 0] +... +*/ diff --git a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/where_for_hit_count.cpp b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/where_for_hit_count.cpp new file mode 100644 index 0000000000000..8a540b356b952 --- /dev/null +++ b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/where_for_hit_count.cpp @@ -0,0 +1,78 @@ +// 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 !where nodes work with for_hit_count. + +/// All on one line for simplicity so that we only get one step per call. +int collatz(int N) { return (N % 2) ? N * 3 + 1 : N / 2; } + +int main() { + int MaxAttempts = 50; + int Value = 472959593; + for (int I = 0; I < MaxAttempts; ++I) { + Value = collatz(Value); // !dex_label loop_start + if (Value == 1) + break; // !dex_label loop_end + } + return Value == 1 ? 0 : 1; +} + +// CHECK: Step 0 +// CHECK: Frame 0: +// CHECK-NEXT: main +// CHECK-NEXT: where_for_hit_count.cpp(14:21) +// CHECK: Step 1 +// CHECK: Frame 0: +// CHECK-NEXT: collatz(int) +// CHECK-NEXT: where_for_hit_count.cpp(8:30) +// CHECK: Step 2 +// CHECK: Frame 0: +// CHECK-NEXT: main +// CHECK-NEXT: where_for_hit_count.cpp(14:11) +// CHECK: Step 3 +// CHECK: Frame 0: +// CHECK-NEXT: main +// CHECK-NEXT: where_for_hit_count.cpp(15:15) +// CHECK: Step 4 +// CHECK: Frame 0: +// CHECK-NEXT: main +// CHECK-NEXT: where_for_hit_count.cpp(17:3) +// CHECK: Step 5 +// CHECK: Frame 0: +// CHECK-NEXT: main +// CHECK-NEXT: where_for_hit_count.cpp(14:21) +// CHECK: Step 6 +// CHECK: Frame 0: +// CHECK-NEXT: collatz(int) +// CHECK-NEXT: where_for_hit_count.cpp(8:30) +// CHECK: Step 7 +// CHECK: Frame 0: +// CHECK-NEXT: main +// CHECK-NEXT: where_for_hit_count.cpp(14:11) +// CHECK: Step 8 +// CHECK: Frame 0: +// CHECK-NEXT: main +// CHECK-NEXT: where_for_hit_count.cpp(15:15) +// CHECK: Step 9 +// CHECK: Frame 0: +// CHECK-NEXT: main +// CHECK-NEXT: where_for_hit_count.cpp(17:3) +// CHECK: Step 10 +// CHECK: Frame 0: +// CHECK-NEXT: collatz(int) +// CHECK-NEXT: where_for_hit_count.cpp(8:30) +// CHECK: Step 11 +// CHECK: Frame 0: +// CHECK-NEXT: main +// CHECK-NEXT: where_for_hit_count.cpp(14:11) + +/* +--- +!where {function: collatz, for_hit_count: 3}: + !value N: 0 +!where {lines: !range [!label loop_start, !label loop_end], for_hit_count: 2}: + !value Value: 0 + !value I: 0 +... +*/ diff --git a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/where_hit_count_early_exit.cpp b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/where_hit_count_early_exit.cpp new file mode 100644 index 0000000000000..5d799f4119c47 --- /dev/null +++ b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/where_hit_count_early_exit.cpp @@ -0,0 +1,33 @@ +// RUN: %dexter_regression_test_cxx_build %s -o %t +// RUN: %dexter_regression_test_run --use-script --skip-evaluate --binary %t \ +// RUN: --timeout-total 10 -- %s 2>&1 | FileCheck %s + +/// Test that when all root !where nodes have expired, we exit without waiting +/// for the debuggee to finish. + +// CHECK-NOT: timeout reached + +// CHECK-LABEL: Step 0 +// CHECK-COUNT-3: getRandomNumber +// CHECK-NOT: getRandomNumber + +/// All on one line for simplicity so that we only get one step per call. +int getRandomNumber(int Max) { return 4 % Max; } + +int main() { + // Bogo search + int List[] = {0, 0, 0, 0, 5, 0, 0, 0, 0, 0}; + int SearchTarget = 0; + while (true) { + int NextSearch = getRandomNumber(10); + if (List[NextSearch] == SearchTarget) + return NextSearch; + } +} + +/* +--- +!where {function: getRandomNumber, for_hit_count: 3}: + !value Max: 10 +... +*/ >From 19fbfa597ce8d345811d0d985e983bf6288f138b Mon Sep 17 00:00:00 2001 From: Stephen Tozer <[email protected]> Date: Mon, 15 Jun 2026 09:50:12 +0100 Subject: [PATCH 2/2] darker --- .../debuginfo-tests/dexter/dex/evaluation/StateMatch.py | 2 ++ 1 file changed, 2 insertions(+) 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 6ba7b9d8fd1cb..57f5a0fe1848b 100644 --- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py +++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py @@ -92,6 +92,7 @@ def _match_where_to_frame( ) return True + def match_where_to_frame( where: Where, frame: FrameIR, @@ -122,6 +123,7 @@ class WhereMatchResult: StateMatchResult = Dict[Where, WhereMatchResult] + def get_active_where_matches( script: DexterScript, step_info: StepIR, match_context: StateMatchContext ) -> Dict[Where, WhereMatchResult]: _______________________________________________ llvm-branch-commits mailing list [email protected] https://lists.llvm.org/cgi-bin/mailman/listinfo/llvm-branch-commits
