[llvm-branch-commits] [llvm] [Dexter] Allow fetching "scopes" from the debugger (PR #202802)

Stephen Tozer via llvm-branch-commits llvm-branch-commits at lists.llvm.org
Mon Jun 15 05:48:10 PDT 2026


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

>From c2758045e65267f93d60ee261fd2d6e33290bd27 Mon Sep 17 00:00:00 2001
From: Stephen Tozer <stephen.tozer at sony.com>
Date: Tue, 9 Jun 2026 23:13:22 +0100
Subject: [PATCH 1/2] [Dexter] Allow fetching "scopes" from the debugger

To further improve Dexter's script writing ability, this patch starts
implementing the ability for Dexter to fetch all variables with in a given
"scope", as defined by the DAP "scopes" request. This allows the test to
collect all available variables without needing to specify them explicitly
in the script, aiding in fast script generation/re-generation.

This patch does not add any script-writing functionality, but adds the
!value/all Node, which fetches all variable values from the given scope, and
enables fetching these values from DAP-based debuggers.
---
 .../dexter/dex/debugger/DAP.py                | 66 ++++++++++++++++---
 .../dexter/dex/debugger/DebuggerBase.py       |  4 +-
 .../ScriptDebuggerController.py               |  8 ++-
 .../dexter/dex/debugger/dbgeng/dbgeng.py      |  4 +-
 .../dexter/dex/debugger/lldb/LLDB.py          |  8 ++-
 .../dex/debugger/visualstudio/VisualStudio.py |  4 +-
 .../dexter/dex/dextIR/StepIR.py               | 11 +++-
 .../dexter/dex/evaluation/StateMatch.py       |  7 +-
 .../dexter/dex/test_script/Nodes.py           | 47 +++++++++----
 .../dexter/dex/test_script/Script.py          | 12 +++-
 .../scripts/debugging/watch_scope.cpp         | 47 +++++++++++++
 .../scripts/parser/expect-all-with-value.test | 12 ++++
 12 files changed, 197 insertions(+), 33 deletions(-)
 create mode 100644 cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/watch_scope.cpp
 create mode 100644 cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/parser/expect-all-with-value.test

diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DAP.py b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DAP.py
index 0dfded0ff881a..b1e2538ea8891 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DAP.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DAP.py
@@ -555,15 +555,22 @@ def _await_response(self, seq: int, timeout: float = 0.0) -> dict:
     # response when it arrives. An optional timeout for the response may be passed.
     # If allow_failure is passed, then the result may instead be a str containing the fail reason if the request failed.
     def _communicate_request(
-        self, command: str, arguments=None, timeout: float = 60.0, allow_failure=False
+        self, command: str, arguments=None, timeout: float = 60.0
+    ) -> Dict:
+        req_id = self.send_message(self.make_request(command, arguments))
+        response = self._await_response(req_id, timeout)
+        if not response["success"]:
+            raise DebuggerException(
+                f"received failure response for command {command}: {response['message']}"
+            )
+        return response["body"]
+
+    def _communicate_fallible_request(
+        self, command: str, arguments=None, timeout: float = 60.0
     ) -> Union[Dict, str]:
         req_id = self.send_message(self.make_request(command, arguments))
         response = self._await_response(req_id, timeout)
         if not response["success"]:
-            if not allow_failure:
-                raise DebuggerException(
-                    f"received failure response for command {command}"
-                )
             return response["message"]
         return response["body"]
 
@@ -1008,12 +1015,53 @@ def get_stack_frames(self, step_index: int) -> StepIR:
             stop_reason=reason,
         )
 
-    def collect_watches(self, step: StepIR, watches: List[str]):
+    def collect_watches(
+        self, step: StepIR, watches: List[str], scope_watches: List[str]
+    ):
         """Evaluates the provided watches and stores their evaluation results (ValueIR) in the provided step."""
         frame_idx = 0
-        if not watches:
+        if not watches and not scope_watches:
             return
         active_exprs = set(watches)
+        active_scopes = set(scope_watches)
+        frame_loc = step.frames[frame_idx].loc
+        frame_id = self._debugger_state.frame_map[frame_idx]
+        frame_scopes = self._communicate_request("scopes", {"frameId": frame_id})
+        for scope in frame_scopes["scopes"]:
+            scope_name = scope["name"]
+            if scope_name not in active_scopes:
+                continue
+            scope_vars_ref = scope["variablesReference"]
+            scope_vars = self._communicate_request(
+                "variables", {"variablesReference": scope_vars_ref}
+            )
+            assert isinstance(scope_vars, dict)
+            scope_var_values = {}
+            # Evaluate all scope variables.
+            for var in scope_vars["variables"]:
+                result = var["value"]
+                # Check to see whether this variable is in-scope yet.
+                # FIXME: This is just the best solution I can see right now, but we may want better in
+                # future (especially for languages with non-C-like declaration/scoping semantics).
+                if "declarationLocationReference" in var:
+                    declaration_loc = self._communicate_request(
+                        "locations",
+                        {"locationReference": var["declarationLocationReference"]},
+                    )
+                    if declaration_loc["line"] >= frame_loc.lineno:
+                        continue
+                value = self._evaluate_result_value(
+                    var["evaluateName"], result, var.get("type")
+                )
+                self._evaluate_subvariables(value, var["variablesReference"])
+                scope_var_values[value.expression] = value
+            step.scope_watches[scope_name] = list(scope_var_values.keys())
+            for var_name in sorted(step.scope_watches[scope_name]):
+                step.watches[var_name] = scope_var_values[var_name]
+            for expr in list(active_exprs):
+                if expr in scope_var_values:
+                    active_exprs.remove(expr)
+
         for expr in active_exprs:
             step.watches[expr] = self.evaluate_expression(expr, frame_idx)
 
@@ -1031,7 +1079,9 @@ def frames_below_main(self):
 
     @staticmethod
     @abc.abstractmethod
-    def _evaluate_result_value(expression: str, result_string: str) -> ValueIR:
+    def _evaluate_result_value(
+        expression: str, result_string: str, type_string: Optional[str]
+    ) -> ValueIR:
         """For the result of an "evaluate" message, return a ValueIR. Implementation must be debugger-specific."""
 
     # For the given `value` and associated `variables_reference`, recursively requests "variables" information for all
diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerBase.py b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerBase.py
index 2f268a414381f..af8e69e75c212 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerBase.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/DebuggerBase.py
@@ -223,7 +223,9 @@ def get_stack_frames(self, step_index: int) -> StepIR:
         pass
 
     @abc.abstractmethod
-    def collect_watches(self, step: StepIR, watches: List[str]):
+    def collect_watches(
+        self, step: StepIR, watches: List[str], scope_watches: List[str]
+    ):
         pass
 
     @abc.abstractproperty
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 1b42a679e3b4f..f1404c37116fa 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
@@ -128,7 +128,13 @@ def _run_debugger_custom(self, cmdline):
                 for expect in where_match.active_expects
                 if (watch := expect.get_watched_expr())
             ]
-            self.debugger.collect_watches(step_info, watches)
+            scope_watches = [
+                scope_watch
+                for where_match in active_where_matches.values()
+                for expect in where_match.active_expects
+                if (scope_watch := expect.get_watched_scope())
+            ]
+            self.debugger.collect_watches(step_info, watches, scope_watches)
 
             active_thens = [
                 then
diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/dbgeng/dbgeng.py b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/dbgeng/dbgeng.py
index 921cfc70655f1..86d5f78f4c556 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/dbgeng/dbgeng.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/dbgeng/dbgeng.py
@@ -171,7 +171,9 @@ def _get_step_info(self, watches, step_index):
     def get_stack_frames(self, step_index: int) -> StepIR:
         raise NotImplementedError("--use-script debugging not supported in dbgeng yet.")
 
-    def collect_watches(self, step: StepIR, watches: List[str]):
+    def collect_watches(
+        self, step: StepIR, watches: List[str], scope_watches: List[str]
+    ):
         raise NotImplementedError("--use-script debugging not supported in dbgeng yet.")
 
     @property
diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/lldb/LLDB.py b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/lldb/LLDB.py
index 4b92da9e0f38c..d328498553938 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/lldb/LLDB.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/lldb/LLDB.py
@@ -11,7 +11,7 @@
 import shlex
 from subprocess import CalledProcessError, check_output, STDOUT
 import sys
-from typing import List
+from typing import List, Optional
 
 from dex.debugger.DebuggerBase import DebuggerBase, watch_is_active
 from dex.debugger.DAP import DAP
@@ -322,7 +322,9 @@ def _get_step_info(self, watches, step_index):
     def get_stack_frames(self, step_index: int) -> StepIR:
         raise NotImplementedError("--use-script debugging not supported in lldb yet.")
 
-    def collect_watches(self, step: StepIR, watches: List[str]):
+    def collect_watches(
+        self, step: StepIR, watches: List[str], scope_watches: List[str]
+    ):
         raise NotImplementedError("--use-script debugging not supported in lldb yet.")
 
     @property
@@ -480,7 +482,7 @@ def _get_launch_params(self, cmdline):
 
     @staticmethod
     def _evaluate_result_value(
-        expression: str, result_string: str, type_string
+        expression: str, result_string: str, type_string: Optional[str]
     ) -> ValueIR:
         could_evaluate = not any(
             s in result_string
diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/visualstudio/VisualStudio.py b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/visualstudio/VisualStudio.py
index 406296a1605e9..606e08502fae3 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/debugger/visualstudio/VisualStudio.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/debugger/visualstudio/VisualStudio.py
@@ -398,7 +398,9 @@ def get_stack_frames(self, step_index: int) -> StepIR:
             "--use-script debugging not supported in visual studio yet."
         )
 
-    def collect_watches(self, step: StepIR, watches: List[str]):
+    def collect_watches(
+        self, step: StepIR, watches: List[str], scope_watches: List[str]
+    ):
         raise NotImplementedError(
             "--use-script debugging not supported in visual studio yet."
         )
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 5024ccedd1433..7c4c98e52c06a 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/dextIR/StepIR.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/dextIR/StepIR.py
@@ -9,7 +9,7 @@
 import json
 
 from collections import OrderedDict
-from typing import List
+from typing import Dict, List, Optional
 from enum import Enum
 from dex.dextIR.FrameIR import FrameIR
 from dex.dextIR.LocIR import LocIR
@@ -50,6 +50,7 @@ def __init__(
         frames: List[FrameIR],
         step_kind: StepKind = None,
         watches: OrderedDict = None,
+        scope_watches: Optional[Dict[str, List[str]]] = None,
         program_state: ProgramState = None,
     ):
         self.step_index = step_index
@@ -64,6 +65,7 @@ def __init__(
         if watches is None:
             watches = {}
         self.watches = watches
+        self.scope_watches = scope_watches or OrderedDict()
         self.hit_fn_bps: List[str] = []
 
     def __str__(self):
@@ -125,6 +127,13 @@ def detailed_print(self) -> List[str]:
             lines.append(f"    {frame.loc}")
             lines.append(f"    $pc = {frame.instruction_addr}")
 
+        if self.scope_watches:
+            lines.append(f"Variable Scopes:")
+            for scope_name in sorted(self.scope_watches):
+                lines.append(
+                    f"  {scope_name}: [{', '.join(self.scope_watches[scope_name])}]"
+                )
+
         if self.watches:
             lines.append(f"Variables:")
             for value in sorted(self.watches.values(), key=lambda v: v.expression):
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 2be87590b978c..03a9d4c0d0b89 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/evaluation/StateMatch.py
@@ -13,7 +13,7 @@
 
 from dex.dextIR import FrameIR, StepIR
 from dex.test_script import DexterScript, Scope
-from dex.test_script.Nodes import Expect, FileLabels, Value, Where, Then
+from dex.test_script.Nodes import Expect, FileLabels, Where, Then
 
 
 def is_subpath(subpath: str, superpath: str) -> bool:
@@ -60,7 +60,7 @@ class WhereMatchResult:
     """
 
     frame_idx: int
-    active_expects: List[Value] = field(default_factory=list)
+    active_expects: List[Expect] = field(default_factory=list)
     active_thens: List[Then] = field(default_factory=list)
     pending_wheres: List[Where] = field(default_factory=list)
 
@@ -110,9 +110,6 @@ def get_active_wheres(where: Where, scope: Scope):
     # 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.
     def get_active_expects(expect: Expect, expected_value, scope: Scope):
-        assert isinstance(
-            expect, Value
-        ), "Values should be the only type of expect possible!"
         if (
             scope.where in active_where_expects
             and active_where_expects[scope.where].frame_idx == 0
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 6d9f4f8ef5080..d441657f5761d 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
@@ -18,14 +18,7 @@
 
 
 def setup_yaml_parser(loader):
-    reg_classes = [
-        Where,
-        Value,
-        DexRange,
-        Label,
-        Then,
-        Address,
-    ]
+    reg_classes = [Where, Value, DexRange, Label, Then, Address, ValueAll]
     for c in reg_classes:
         c.register_yaml(loader)
 
@@ -156,9 +149,13 @@ def get_variable_result(value: ValueIR) -> Optional[str]:
         excluding any subvalues (i.e. struct members), or None if there is no valid result for this ValueIR.
         """
 
-    @abc.abstractmethod
-    def get_watched_expr(self) -> str:
-        """Returns the list of expressions that this Expect wants to evaluate."""
+    def get_watched_expr(self) -> Optional[str]:
+        """Returns the expression that this Expect wants to evaluate."""
+        return None
+
+    def get_watched_scope(self) -> Optional[str]:
+        """Returns the scope that this Expect wants to evaluate."""
+        return None
 
 
 class Value(Expect):
@@ -194,6 +191,34 @@ def register_yaml(loader):
         yaml.add_representer(Value, Value.representer)
 
 
+class ValueAll(Expect):
+    def __init__(self, scope_name: str):
+        self.scope_name = scope_name
+
+    def __repr__(self):
+        return f"ValueAll({self.scope_name})"
+
+    @staticmethod
+    def get_variable_result(value: ValueIR) -> Optional[str]:
+        return Value.get_variable_result(value)
+
+    def get_watched_scope(self) -> Optional[str]:
+        return self.scope_name
+
+    @staticmethod
+    def constructor(loader, node):
+        return ValueAll(loader.construct_scalar(node))
+
+    @staticmethod
+    def representer(dumper, data):
+        return dumper.represent_scalar("!value/all", data.scope_name)
+
+    @staticmethod
+    def register_yaml(loader):
+        yaml.add_constructor("!value/all", ValueAll.constructor, loader)
+        yaml.add_representer(ValueAll, ValueAll.representer)
+
+
 ##############
 ## Execution Nodes: Can appear as leaf nodes directly under a state node to perform debugger actions when they become
 ## active, to advance the debugger state.
diff --git a/cross-project-tests/debuginfo-tests/dexter/dex/test_script/Script.py b/cross-project-tests/debuginfo-tests/dexter/dex/test_script/Script.py
index 50a5cb48a200e..25ba27c70dc17 100644
--- a/cross-project-tests/debuginfo-tests/dexter/dex/test_script/Script.py
+++ b/cross-project-tests/debuginfo-tests/dexter/dex/test_script/Script.py
@@ -21,6 +21,7 @@
     FileLabels,
     Where,
     Then,
+    ValueAll,
     setup_yaml_parser,
 )
 
@@ -169,9 +170,18 @@ def __init__(
             if source_root_dir is not None
             else os.path.dirname(scope.file)
         )
+        self._validate()
+
+    def _validate(self):
+        def validate_expect(expect: Expect, expected_value, scope: Scope):
+            if isinstance(expect, ValueAll) and expected_value is not None:
+                raise DexterScriptError(
+                    f"!expect/all node {expect} should not have an expected value."
+                )
+
         # `visit_script` will validate the structure of the script, as it traverses the full script and raises an
         # exception if it sees anything unexpected.
-        self.visit_script()
+        self.visit_script(visit_expect=validate_expect)
 
     # If a truthy value is returned, abort further visiting and return that value.
     def _visit_script(
diff --git a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/watch_scope.cpp b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/watch_scope.cpp
new file mode 100644
index 0000000000000..daf5eea4ecd2c
--- /dev/null
+++ b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/watch_scope.cpp
@@ -0,0 +1,47 @@
+// RUN: %dexter_regression_test_cxx_build %s -o %t
+// RUN: %dexter_regression_test_run --use-script --skip-evaluate --binary %t -- %s | FileCheck %s
+
+char There[] = "Here";
+
+int main() {
+  int One = 2;
+  char Red[] = "Blue";
+  return 0; // !dex_label ret
+}
+
+/// Test that we can use functions in !where nodes, and that Dexter steps
+/// through the entirety of those functions. We expect both calls to `assign` to
+/// be stepped through, but only the non-recursive call of `replace` should be
+/// stepped through, as the !where matches to the rootmost applicable frame.
+
+// CHECK:      Step 0
+// CHECK:          main
+// CHECK:      Variable Scopes:
+// CHECK-NEXT:   Globals: [::There]
+// CHECK-NEXT:   Locals: [One, Red]
+// CHECK-NEXT: Variables:
+// CHECK-NEXT:   "::There": (char[5]) "Here"
+// CHECK-NEXT:     "[0]": (char) 'H'
+// CHECK-NEXT:     "[1]": (char) 'e'
+// CHECK-NEXT:     "[2]": (char) 'r'
+// CHECK-NEXT:     "[3]": (char) 'e'
+// CHECK-NEXT:     "[4]": (char) '\0'
+// CHECK-NEXT:   "One": (int) 2
+// CHECK-NEXT:   "Red": (char[5]) "Blue"
+// CHECK-NEXT:     "[0]": (char) 'B'
+// CHECK-NEXT:     "[1]": (char) 'l'
+// CHECK-NEXT:     "[2]": (char) 'u'
+// CHECK-NEXT:     "[3]": (char) 'e'
+// CHECK-NEXT:     "[4]": (char) '\0'
+
+// CHECK-NOT: Step 1
+
+/*
+---
+!where {lines: !label ret}:
+    ? !value/all Locals
+    ? !value/all Globals
+    # Invalid scopes won't appear in the output.
+    ? !value/all NotARealScope
+...
+*/
diff --git a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/parser/expect-all-with-value.test b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/parser/expect-all-with-value.test
new file mode 100644
index 0000000000000..c5cb3c084e2bd
--- /dev/null
+++ b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/parser/expect-all-with-value.test
@@ -0,0 +1,12 @@
+RUN: not %dexter_regression_test_run --binary %s --use-script --skip-run -- %s 2>&1 | FileCheck %s
+
+Tests that we reject !expect/all nodes with expected values.
+
+CHECK: No valid Dexter script found in file
+
+CHECK: Script starting line [[# @LINE + 2]]:
+CHECK: !expect/all node ValueAll(Locals) should not have an expected value.
+---
+!where {function: foo}:
+    !value/all Locals: "a real value"
+...

>From 294b36235388128ff4e4b0640bbad4b7efd73833 Mon Sep 17 00:00:00 2001
From: Stephen Tozer <stephen.tozer at sony.com>
Date: Wed, 10 Jun 2026 13:28:18 +0100
Subject: [PATCH 2/2] format

---
 .../dexter/feature_tests/scripts/debugging/watch_scope.cpp     | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/watch_scope.cpp b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/watch_scope.cpp
index daf5eea4ecd2c..11710d6591eb7 100644
--- a/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/watch_scope.cpp
+++ b/cross-project-tests/debuginfo-tests/dexter/feature_tests/scripts/debugging/watch_scope.cpp
@@ -1,5 +1,6 @@
 // RUN: %dexter_regression_test_cxx_build %s -o %t
-// RUN: %dexter_regression_test_run --use-script --skip-evaluate --binary %t -- %s | FileCheck %s
+// RUN: %dexter_regression_test_run --use-script --skip-evaluate --binary %t \
+// RUN:   -- %s | FileCheck %s
 
 char There[] = "Here";
 



More information about the llvm-branch-commits mailing list