[Lldb-commits] [lldb] [lldb-dap] Add a new lldb-dap test infrastructure (PR #203978)
Ebuka Ezike via lldb-commits
lldb-commits at lists.llvm.org
Wed Jul 1 07:48:57 PDT 2026
https://github.com/da-viper updated https://github.com/llvm/llvm-project/pull/203978
>From fb41361ead7bec4d2794e8380b7c413f461c6f10 Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <e_ezike at apple.com>
Date: Mon, 15 Jun 2026 18:55:43 +0100
Subject: [PATCH 01/12] [lldb-dap] Add a new lldb-dap test infrastructure
Introduce a new test infrastructure under lldbsuite/test/tools/lldb_dap
that replaces the dict-based dap_server. Initially the new and legacy version
will live side by side.
Once all tests are ported, the legacy version will be removed.
The new infrastructure has four modules:
- dap_types.py: DAP protocol messages as frozen dataclasses,
plus serializer/deserializer functions to and from JSON.
- utils.py: Helper classes for launching and managing lldb-dap process and its connection.
- session.py: DAP session layer. Owns the per-session state.
- session_helpers.py: Higher-level helpers for writing tests.
Using dataclasses for protocol types lets us catch unexpected
or missing fields at deserialization time instead of at use-site.
Adds a new environment flag, LLDBDAP_RUN_AS_SERVER, that runs the
test suite against lldb-dap in server mode for test cases with
USE_DEFAULT_DEBUG_ADAPTER=True. In the future this could be extended
to run the entire suite through a single lldb-dap server.
Migrates TestDAP_launch_basic and TestDAP_invalidatedEvent to show the
new style and Add unittests for the new primitives:
DAPConnection, EventHistory, and the DAP type (de)serializer.
Some class and function names may not be ideal. I'm open to suggestions.
More information in [discourse page](https://discourse.llvm.org/t/rfc-making-the-lldb-dap-test-infrastructure-more-reliable/90694)
---
.../lldbsuite/test/tools/lldb_dap/__init__.py | 5 +
.../test/tools/lldb_dap/dap_types.py | 2019 +++++++++++++++++
.../test/tools/lldb_dap/lldb_dap_testcase.py | 221 ++
.../lldbsuite/test/tools/lldb_dap/session.py | 529 +++++
.../test/tools/lldb_dap/session_helpers.py | 1699 ++++++++++++++
.../lldbsuite/test/tools/lldb_dap/utils.py | 809 +++++++
.../TestDAP_invalidatedEvent.py | 67 +-
.../lldb-dap/launch/TestDAP_launch_basic.py | 16 +-
.../utils/TestDAPUtils_DAPConnection.py | 82 +
.../utils/TestDAPUtils_EventHistory.py | 252 ++
.../lldb-dap/utils/TestDAPUtils_Types.py | 452 ++++
.../tools/lldb-dap/utils/sample_message.json | 38 +
12 files changed, 6154 insertions(+), 35 deletions(-)
create mode 100644 lldb/packages/Python/lldbsuite/test/tools/lldb_dap/__init__.py
create mode 100644 lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
create mode 100644 lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py
create mode 100644 lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
create mode 100644 lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
create mode 100644 lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
create mode 100644 lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py
create mode 100644 lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py
create mode 100644 lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_Types.py
create mode 100644 lldb/test/API/tools/lldb-dap/utils/sample_message.json
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/__init__.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/__init__.py
new file mode 100644
index 0000000000000..5e378bfd8b97f
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/__init__.py
@@ -0,0 +1,5 @@
+from .lldb_dap_testcase import DAPTestCaseBase
+from .session_helpers import DAPTestSession
+from .utils import DebugAdapter, DebugAdapterOptions
+
+__all__ = ["DAPTestCaseBase", "DAPTestSession", "DebugAdapter", "DebugAdapterOptions"]
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
new file mode 100644
index 0000000000000..5a3460f7fa226
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
@@ -0,0 +1,2019 @@
+# NOTE: this module must not include `from __future__ import annotations`
+# as the `annotations` import changes some of the types hints to strings.
+# especially when you have a forward declared type reference.
+# see https://peps.python.org/pep-0649/#motivation-for-this-pep
+# https://peps.python.org/pep-0749/#rejected-alternatives
+#
+# This module may not depend on any other module.
+
+from contextlib import suppress
+import copy
+import dataclasses
+import enum
+import json
+import os
+import sys
+import typing
+from dataclasses import asdict, dataclass, field, is_dataclass
+from enum import Enum
+from functools import lru_cache
+from typing import (
+ Any,
+ ClassVar,
+ Dict,
+ List,
+ Literal,
+ Optional,
+ Protocol,
+ Set,
+ Tuple,
+ Type,
+ TypeVar,
+ Union,
+ runtime_checkable,
+)
+
+if sys.version_info >= (3, 11):
+ from enum import StrEnum
+else:
+
+ class StrEnum(str, Enum):
+ """Backport of StrEnum for Python < 3.11."""
+
+ def __str__(self) -> str:
+ return self.value
+
+ def __repr__(self) -> str:
+ return self.value
+
+ @staticmethod
+ def _generate_next_value_(name: str, start, count, last_values) -> str:
+ return name.lower()
+
+
+T = TypeVar("T")
+
+
+class DAPError(AssertionError):
+ """The base error for all DAP related errors
+
+ Inherits from assertion error because of unittests treats assertions outside of a test as a failure
+ instead of a test error. see https://docs.python.org/3/library/unittest.html#unittest.TestCase.setUp
+ """
+
+ @classmethod
+ def history_closed(cls, reason=None, last_event: Optional["Event"] = None):
+ suffix = f" (Reason: {reason})" if reason else ""
+ return cls(
+ f"EventHistory is closed{suffix}. Last recorded event: {last_event}."
+ )
+
+
+RawMessage = Dict[str, Any]
+"""Representation of a json protocol message """
+
+
+class MessageType(StrEnum):
+ REQUEST = "request"
+ RESPONSE = "response"
+ EVENT = "event"
+
+
+class EventName(StrEnum):
+ BREAKPOINT = "breakpoint"
+ CAPABILITIES = "capabilities"
+ CONTINUED = "continued"
+ EXITED = "exited"
+ INITIALIZED = "initialized"
+ INVALIDATED = "invalidated"
+ MEMORY = "memory"
+ MODULE = "module"
+ OUTPUT = "output"
+ PROCESS = "process"
+ PROGRESS_END = "progressEnd"
+ PROGRESS_START = "progressStart"
+ PROGRESS_UPDATE = "progressUpdate"
+ STOPPED = "stopped"
+ TERMINATED = "terminated"
+ THREAD = "thread"
+
+
+ at dataclass(frozen=True)
+class ProtocolMessage:
+ type: MessageType
+ seq: int
+
+ def to_dict(self):
+ return message_to_dict(self)
+
+ @classmethod
+ def from_json(cls: Type[T], json: RawMessage) -> T:
+ if not dataclasses.is_dataclass(cls):
+ raise ValueError(f"{cls.__name__} must be a dataclass")
+
+ return dict_to_message(cls, json)
+
+
+ at dataclass(frozen=True)
+class Request(ProtocolMessage):
+ command: str
+ arguments: Any
+
+ def __post_init__(self):
+ assert (
+ self.type == MessageType.REQUEST
+ ), f"expected request type to be 'request' got '{self.type}' in : {self}"
+
+
+ at dataclass(frozen=True)
+class Response(ProtocolMessage):
+ command: str
+ request_seq: int
+ success: bool
+
+ def __post_init__(self):
+ assert (
+ self.type == MessageType.RESPONSE
+ ), f"expected '{type(self).__name__}' to be of type response: {self}"
+
+ def __init_subclass__(cls, **kwargs):
+ super().__init_subclass__(**kwargs)
+ if not dataclasses.is_dataclass(cls):
+ raise TypeError(f"{cls.__name__} must be a dataclass")
+
+
+AnyResponse = TypeVar("AnyResponse", bound=Response)
+
+
+class RequestError(DAPError):
+ """Raised if a DAP request fails."""
+
+ def __init__(
+ self,
+ request: Union[Request, dict],
+ response: Optional[Union[Response, dict]] = None,
+ ):
+ super().__init__()
+ self.request = request
+ self.response = response
+
+ def __str__(self) -> str:
+ desc = f"request failed request={self.request!r}"
+ if self.response:
+ desc += f" response={self.response!r}"
+ return desc
+
+
+ at dataclass(frozen=True)
+class Event(ProtocolMessage):
+ event: Union[EventName, str]
+ type: MessageType
+ __registry: ClassVar[Dict[str, Type]] = {}
+
+ def __init_subclass__(cls, *, event: str, **kwargs):
+ super().__init_subclass__(**kwargs)
+
+ assert event is not None
+ # Attach metadata (not a field of an event).
+ cls.__message_type__ = event
+
+ # Prevent duplicate types.
+ existing_event_class = Event.__registry.get(event)
+ if existing_event_class is not None:
+ raise Exception(
+ f"cannot register '{event}' event to class '{cls}' because it is already registered to '{existing_event_class}'"
+ )
+
+ Event.__registry[event] = cls
+
+ def __post_init__(self):
+ if self.type != MessageType.EVENT:
+ raise ValueError(f"event must have type of 'event': {self}")
+
+ @classmethod
+ def from_json(cls: Type[T], json: dict, check_class: bool = True) -> T:
+ event_type = json["event"]
+
+ if event_type not in Event.__registry:
+ raise ValueError(f"event type '{event_type}' is not registered")
+
+ event_class = Event.__registry[event_type]
+
+ if check_class and type(cls) != type(event_class):
+ raise ValueError(
+ f"class: {type(cls)} is not of the expected type {type(event_class)}"
+ )
+
+ if not dataclasses.is_dataclass(cls):
+ raise ValueError(f"{cls.__name__} must be a dataclass")
+
+ return dict_to_message(event_class, json)
+
+
+AnyEvent = TypeVar("AnyEvent", bound=Event)
+
+
+class EmptyBodyResponse(Response):
+ body: None = field(init=False, default=None)
+
+
+def args_protocol(cls):
+ """Decorator to check a class conforms to the ArgsProtocol"""
+
+ required_fields = filter(lambda x: not x.startswith("_"), dir(ArgsProtocol))
+ for r_field in required_fields:
+ if not hasattr(cls, r_field):
+ raise AttributeError(
+ f"{cls.__name__} must define '{r_field}' to implement ArgProtocol"
+ )
+
+ command_name = getattr(cls, "command_")
+ if not issubclass(type(command_name), str):
+ raise TypeError(
+ f"the command_ type '{type(command_name)}' for class '{cls.__name__}' must be string like"
+ )
+
+ return cls
+
+
+def _message_to_dict_impl(obj: typing.Any, skip_none: bool = True) -> typing.Any:
+ if dataclasses.is_dataclass(obj):
+ fields = _get_dataclass_fields(type(obj))
+ visited: Dict[str, Any] = {}
+ for f in fields:
+ dict_name = f.metadata.get("alias", f.name)
+ if dict_name in visited or not hasattr(obj, f.name):
+ continue
+ name_attr = getattr(obj, f.name)
+ if skip_none and name_attr is None:
+ continue
+ visited[dict_name] = _message_to_dict_impl(name_attr, skip_none)
+ return visited
+
+ if isinstance(obj, (list, tuple)):
+ return type(obj)(_message_to_dict_impl(item, skip_none) for item in obj)
+
+ if isinstance(obj, dict):
+ result: Dict[str, Any] = {}
+ for key, value in obj.items():
+ result[str(key)] = _message_to_dict_impl(value, skip_none)
+ return result
+
+ # Test enum first as it can also be a subclass of other primitives.
+ if isinstance(obj, Enum):
+ return obj.value
+
+ if isinstance(obj, (bool, int, float, str, bytes, type, type(None))):
+ return obj
+
+ return copy.deepcopy(obj)
+
+
+def message_to_dict(args: Any) -> RawMessage:
+ """
+ Converts DAP types to dictionaries.
+ We always skip optional types in dataclasses during conversion.
+ """
+ if not is_dataclass(args):
+ raise TypeError(
+ f"expected a dataclass instance, got {type(args).__name__}: {args!r}"
+ )
+ result = _message_to_dict_impl(args)
+ assert isinstance(result, dict)
+ return result
+
+
+ at lru_cache
+def _get_dataclass_fields(cls: Type) -> Tuple[dataclasses.Field, ...]:
+ data_class_hints = typing.get_type_hints(cls)
+ result: List[dataclasses.Field] = []
+ for f in dataclasses.fields(cls): # noqa
+ # Ignores 'command_' and 'response_class_'.
+ if f.name.endswith("_"):
+ continue
+ f_copy = copy.copy(f)
+ f_copy.type = data_class_hints[f_copy.name]
+ result.append(f_copy)
+ return tuple(result)
+
+
+ at lru_cache
+def _prepare_dataclass_fields(cls: Type) -> Dict[str, dataclasses.Field]:
+ # Excludes init=False fields since they cannot be passed to __init__.
+ return {f.name: f for f in _get_dataclass_fields(cls) if f.init}
+
+
+def _get_compatible_union_types(data: Any, possible_types: List[Type]) -> List[Type]:
+ """Filters a list of candidate types to find those compatible with the given raw data.
+
+ This helps fail early on type mismatches during parsing. For example, it
+ ensures that a raw payload like `[10, 20]` is correctly matched to `List[int]`
+ rather than a primitive `int`.
+ """
+
+ def _is_type_compatible(candidate_type: Type, data: Any) -> bool:
+ """Helper function to check if a single type is compatible with the data."""
+ origin = typing.get_origin(candidate_type)
+
+ if dataclasses.is_dataclass(candidate_type) or origin is dict:
+ return isinstance(data, dict)
+
+ if origin in (list, tuple):
+ return isinstance(data, (list, tuple))
+
+ if candidate_type is bool:
+ return isinstance(data, bool)
+
+ if candidate_type in (int, float):
+ # Prevent implicit bool conversion as bool is a subclass of int.
+ return isinstance(data, (int, float)) and not isinstance(data, bool)
+
+ if candidate_type is str:
+ return isinstance(data, str)
+
+ if isinstance(candidate_type, type) and issubclass(candidate_type, enum.Enum):
+ if issubclass(candidate_type, enum.IntEnum):
+ return isinstance(data, int) and not isinstance(data, bool)
+ return isinstance(data, str)
+
+ # Fallback for unknown or other generic types
+ return True
+
+ result = [a_type for a_type in possible_types if _is_type_compatible(a_type, data)]
+ return result
+
+
+def _generic_to_message(cls: Optional[Type], data: Any, scope: List[str]) -> Any:
+ origin = typing.get_origin(cls)
+ args = typing.get_args(cls)
+ full_path = ".".join(scope)
+
+ if origin is Literal:
+ unique_literals: Set = set()
+
+ def flatten_literal(args: Any):
+ """'Literal' types can be nested e.g.
+ Literal[Literal["book"], Literal["pen"]] is the same as Literal["book", "pen"]
+ """
+ for val in args:
+ if typing.get_origin(val) is None:
+ unique_literals.add(val)
+ else:
+ flatten_literal(typing.get_args(val))
+
+ flatten_literal(args)
+
+ if data not in unique_literals:
+ raise ValueError(
+ f"expected one of {unique_literals!r} at {full_path}, got {data!r}"
+ )
+ return data
+
+ elif origin is Union:
+ none_type = type(None)
+ is_optional = none_type in args
+ candidate_args = [a for a in args if a is not none_type]
+
+ # Handle Optional.
+ # Optional is represented as Union[T, None].
+ if is_optional and len(candidate_args) == 1:
+ return _dict_to_message_impl(candidate_args[0], data, scope)
+
+ matches = _get_compatible_union_types(data, candidate_args)
+ if not matches:
+ raise TypeError(
+ f"no variant of {cls} is compatible with "
+ f"{type(data).__name__} at {full_path}: {data!r}"
+ )
+
+ # Only one match, try conversion.
+ if len(matches) == 1:
+ return _dict_to_message_impl(matches[0], data, scope)
+
+ for arg in matches:
+ with suppress(TypeError, ValueError, AttributeError, AssertionError):
+ return _dict_to_message_impl(arg, data, scope)
+
+ raise TypeError(
+ f"no variant of {cls} matched {type(data).__name__} at {full_path}: {data!r}"
+ )
+
+ elif origin is list:
+ if not isinstance(data, list):
+ raise TypeError(
+ f"expected list at {full_path}, got {type(data).__name__}: {data!r}"
+ )
+ list_type = args[0]
+ return [
+ _dict_to_message_impl(list_type, val, scope + [str(idx)])
+ for idx, val in enumerate(data)
+ ]
+
+ elif origin is tuple:
+ if not isinstance(data, list):
+ raise TypeError(
+ f"expected list for tuple at {full_path}, got {type(data).__name__}: {data!r}"
+ )
+ item_type = args[0]
+ return tuple(
+ _dict_to_message_impl(item_type, val, scope + [str(idx)])
+ for idx, val in enumerate(data)
+ )
+
+ elif origin is dict:
+ if not isinstance(data, dict):
+ raise TypeError(
+ f"expected dict at {full_path}, got {type(data).__name__}: {data!r}"
+ )
+ if len(args) == 0:
+ args = (str, Any)
+ key_type = args[0]
+ value_type = args[1]
+ return {
+ _dict_to_message_impl(key_type, key, scope): _dict_to_message_impl(
+ value_type, value, scope + [str(key)]
+ )
+ for key, value in data.items()
+ }
+
+ raise TypeError(f"unhandled generic type {cls} at {full_path}: {data!r}")
+
+
+def _dict_to_message_impl(
+ cls: Optional[Type], data: typing.Any, scope: List[str]
+) -> typing.Any:
+ """
+ Recursively deserializes a dictionary into a specified Python type or dataclass.
+
+ Args:
+ cls: The target Python type or dataclass to deserialize the data into.
+ data: The raw data (usually from a dictionary or JSON payload) to be converted.
+ scope: A list of keys representing the current depth in the nested data structure.
+ Used to provide error messages when validation fails.
+
+ Returns:
+ The deserialized data cast to the requested type or instantiated dataclass.
+
+ Raises:
+ TypeError: If the data type does not match the expected type and cannot be coerced.
+ ValueError: If a dataclass field has a required value that does not match the data.
+ """
+ if not cls:
+ return data
+
+ full_path = ".".join(scope)
+ if data is None:
+ # Only pass None through if the declared type permits it.
+ if cls is type(None):
+ return None
+ is_union = typing.get_origin(cls) is Union
+ if is_union and type(None) in typing.get_args(cls): # Is Optional
+ return None
+
+ raise TypeError(f"got None for non-optional type '{cls}' at {full_path}")
+
+ if cls in (bytes, bytearray, Any):
+ return data
+
+ if dataclasses.is_dataclass(cls):
+ if not isinstance(data, dict):
+ raise TypeError(
+ f"expected dict for '{cls.__name__}' at {full_path}, "
+ f"got {type(data).__name__}: {data!r}"
+ )
+ fields = _prepare_dataclass_fields(cls)
+ deserialized = {}
+ for key, f in fields.items():
+ data_key = f.metadata.get("alias", key)
+ if data_key not in data:
+ continue
+
+ required_value = f.metadata.get("required", dataclasses.MISSING)
+ value = _dict_to_message_impl(f.type, data[data_key], scope + [key])
+
+ if required_value is not dataclasses.MISSING and value != required_value:
+ raise ValueError(
+ f"field '{key}' at {full_path} must be {required_value!r}, "
+ f"got {value!r}"
+ )
+ deserialized[key] = value
+ try:
+ return cls(**deserialized)
+ except TypeError as err:
+ msg = f"\n\tfailed to initialize '{cls.__name__}' at '{full_path}' "
+ msg += f'\n\twith dict: "{data}"'
+ err.args = (err.args[0] + msg, *err.args[1:])
+ raise
+
+ if cls in (str, int, float):
+ if isinstance(data, cls):
+ return data
+ # Allow int to float coercion but not bool as bool is a subclass of int:
+ if cls is float and isinstance(data, int) and not isinstance(data, bool):
+ return float(data)
+ raise TypeError(
+ f"expected {cls.__name__} at {full_path}, "
+ f"got {type(data).__name__}: {data!r}"
+ )
+
+ if cls is bool:
+ if not isinstance(data, bool):
+ raise TypeError(
+ f"expected bool at {full_path}, " f"got {type(data).__name__}: {data!r}"
+ )
+ return data
+
+ if isinstance(cls, type) and issubclass(cls, enum.Enum):
+ if issubclass(cls, enum.IntEnum):
+ return cls(int(data))
+ return cls(data)
+
+ # Handle generic types i.e List[T], Tuple[T] e.t.c.
+ if typing.get_origin(cls) is not None:
+ return _generic_to_message(cls, data, scope)
+
+ raise TypeError(
+ f"unexpected type {cls} at {full_path}: " f"got {type(data).__name__}: {data!r}"
+ )
+
+
+def dict_to_message(cls: Type[T], raw_dict: RawMessage) -> T:
+ return _dict_to_message_impl(cls, raw_dict, [cls.__name__])
+
+
+ at runtime_checkable
+class ArgsProtocol(Protocol[T]): # type: ignore[misc]
+ __dataclass_fields__: ClassVar[Dict]
+
+ @property
+ def response_class_(self) -> Type[T]: ...
+
+ @property
+ def command_(self) -> str: ...
+
+
+class Console(StrEnum):
+ INTERNAL = "internalConsole"
+ INTEGRATED_TERMINAL = "integratedTerminal"
+ EXTERNAL_TERMINAL = "externalTerminal"
+
+
+class OutputCategory(StrEnum):
+ CONSOLE = "console"
+ IMPORTANT = "important"
+ STDOUT = "stdout"
+ STDERR = "stderr"
+ TELEMETRY = "telemetry"
+
+
+class StoppedReason(StrEnum):
+ STEP = "step"
+ BREAKPOINT = "breakpoint"
+ EXCEPTION = "exception"
+ PAUSE = "pause"
+ ENTRY = "entry"
+ GOTO = "goto"
+ FUNCTION_BREAKPOINT = "function breakpoint"
+ DATA_BREAKPOINT = "data breakpoint"
+ INSTRUCTION_BREAKPOINT = "instruction breakpoint"
+
+
+class ModuleReason(StrEnum):
+ NEW = "new"
+ CHANGED = "changed"
+ REMOVED = "removed"
+
+
+class BreakpointReason(StrEnum):
+ NEW = "new"
+ CHANGED = "changed"
+ REMOVED = "removed"
+
+
+class InvalidatedAreas(StrEnum):
+ ALL = "all"
+ STACKS = "stacks"
+ THREADS = "threads"
+ VARIABLES = "variables"
+
+
+class StartDebuggingRequestType(StrEnum):
+ LAUNCH = "launch"
+ ATTACH = "attach"
+
+
+ExceptionBreakMode = Literal["never", "always", "unhandled", "userUnhandled"]
+ChecksumAlgorithm = Literal["MD5", "SHA1", "SHA256", "timestamp"]
+
+ScopePresentationHint = Literal["arguments", "locals", "registers"]
+SourcePresentationHint = Literal["normal", "emphasize", "deemphasize"]
+StepInTargetPresentationHint = Literal["normal", "label", "subtle"]
+GotoTargetPresentationHint = StepInTargetPresentationHint
+StackFramePresentationHint = StepInTargetPresentationHint
+
+VariablePresentationHintKind = Literal[
+ "property",
+ "method",
+ "class",
+ "data",
+ "event",
+ "baseClass",
+ "innerClass",
+ "interface",
+ "mostDerivedClass",
+ "virtual",
+ "dataBreakpoint",
+]
+
+VariablePresentationHintVisibility = Literal[
+ "public", "private", "protected", "internal", "final"
+]
+
+
+ at dataclass(frozen=True)
+class Checksum:
+ algorithm: ChecksumAlgorithm
+ checksum: str
+
+
+ at dataclass(frozen=True)
+class Source:
+ name: Optional[str] = None
+ path: Optional[str] = None
+ sourceReference: Optional[int] = None
+ presentationHint: Optional[SourcePresentationHint] = None
+ origin: Optional[str] = None
+ sources: Optional[List["Source"]] = None
+ adapterData: Optional[Any] = None
+ checksums: Optional[List[Checksum]] = None
+
+ def __post_init__(self):
+ if not self.name and not self.path and not self.sourceReference:
+ raise ValueError(
+ f"Source requires either name, path, or source_reference. {self}"
+ )
+
+ @classmethod
+ def create(cls, name: Optional[str] = None, path: Optional[str] = None, **kwargs):
+ if path and not name:
+ name = os.path.basename(path)
+
+ return cls(name=name, path=path, **kwargs)
+
+
+ at dataclass(frozen=True)
+class Breakpoint:
+ verified: bool
+ id: Optional[int] = None
+ message: Optional[str] = None
+ source: Optional[Source] = None
+ line: Optional[int] = None
+ column: Optional[int] = None
+ endLine: Optional[int] = None
+ endColumn: Optional[int] = None
+ instructionReference: Optional[str] = None
+ offset: Optional[int] = None
+ reason: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class SourceBreakpoint:
+ line: int
+ column: Optional[int] = None
+ condition: Optional[str] = None
+ hitCondition: Optional[str] = None
+ logMessage: Optional[str] = None
+ mode: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class FunctionBreakpoint:
+ name: str
+ condition: Optional[str] = None
+ hitCondition: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class DataBreakpoint:
+ dataId: str
+ accessType: Optional[str] = None
+ condition: Optional[str] = None
+ hitCondition: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class InstructionBreakpoint:
+ instructionReference: str
+ offset: Optional[int] = None
+ condition: Optional[str] = None
+ hitCondition: Optional[str] = None
+ mode: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class BreakpointLocation:
+ line: int
+ column: Optional[int] = None
+ endLine: Optional[int] = None
+ endColumn: Optional[int] = None
+
+
+ColumnDescriptorType = Literal["string", "number", "boolean", "unixTimestampUTC"]
+
+
+ at dataclass(frozen=True)
+class ColumnDescriptor:
+ attributeName: str
+ label: str
+ format: Optional[str] = None
+ type: Optional[ColumnDescriptorType] = None
+ width: Optional[int] = None
+
+
+ at dataclass(frozen=True)
+class ExceptionFilterOptions:
+ filterId: str
+ condition: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class ExceptionPathSegment:
+ names: List[str]
+ negate: Optional[bool] = None
+
+
+ at dataclass(frozen=True)
+class ExceptionOptions:
+ breakMode: ExceptionBreakMode
+ path: Optional[List[ExceptionPathSegment]] = None
+
+
+ at dataclass(frozen=True)
+class ExceptionDetails:
+ message: Optional[str] = None
+ typeName: Optional[str] = None
+ fullTypeName: Optional[str] = None
+ evaluateName: Optional[str] = None
+ stackTrace: Optional[str] = None
+ innerException: Optional[List["ExceptionDetails"]] = None
+
+
+ at dataclass(frozen=True)
+class ExceptionBreakpointsFilter:
+ filter: str
+ label: str
+ description: Optional[str] = None
+ default: Optional[bool] = None
+ supportsCondition: Optional[bool] = None
+ conditionDescription: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class Thread:
+ id: int
+ name: str
+
+
+ at dataclass(frozen=True)
+class StackFrame:
+ id: int
+ name: str
+ line: int
+ column: int
+ source: Optional[Source] = None
+ endLine: Optional[int] = None
+ endColumn: Optional[int] = None
+ canRestart: Optional[bool] = None
+ instructionPointerReference: Optional[str] = None
+ moduleId: Optional[Union[int, str]] = None
+ presentationHint: Optional[StackFramePresentationHint] = None
+
+
+ at dataclass(frozen=True)
+class Scope:
+ name: str
+ variablesReference: int
+ presentationHint: Optional[ScopePresentationHint] = None
+ namedVariables: Optional[int] = None
+ indexedVariables: Optional[int] = None
+ expensive: bool = False
+ source: Optional[Source] = None
+ line: Optional[int] = None
+ column: Optional[int] = None
+ endLine: Optional[int] = None
+ endColumn: Optional[int] = None
+
+
+ at dataclass(frozen=True)
+class VariablePresentationHint:
+ kind: Optional[VariablePresentationHintKind] = None
+ attributes: Optional[List[str]] = None
+ visibility: Optional[VariablePresentationHintVisibility] = None
+ lazy: Optional[bool] = None
+
+
+ at dataclass(frozen=True)
+class Variable:
+ name: str
+ value: str
+ variablesReference: int
+ type: Optional[str] = None
+ presentationHint: Optional[VariablePresentationHint] = None
+ evaluateName: Optional[str] = None
+ namedVariables: Optional[int] = None
+ indexedVariables: Optional[int] = None
+ memoryReference: Optional[str] = None
+ declarationLocationReference: Optional[int] = None
+ valueLocationReference: Optional[int] = None
+
+ @property
+ def value_as_int(self):
+ value = self.value
+ # 'value' may have the variable value and summary.
+ # Extract the variable value since summary can have nonnumeric characters.
+ value = value.split(" ")[0]
+ if value.startswith("0x"):
+ return int(value, 16)
+ elif value.startswith("0"):
+ return int(value, 8)
+ else:
+ return int(value)
+
+
+ at dataclass(frozen=True)
+class Message:
+ id: int
+ format: str
+ variables: Optional[Dict[str, str]] = None
+ sendTelemetry: Optional[bool] = None
+ showUser: Optional[bool] = None
+ url: Optional[str] = None
+ urlLabel: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class Module:
+ id: str
+ name: str
+ path: Optional[str] = None
+ isOptimized: Optional[bool] = None
+ isUserCode: Optional[bool] = None
+ version: Optional[str] = None
+ symbolStatus: Optional[str] = None
+ symbolFilePath: Optional[str] = None
+ dateTimeStamp: Optional[str] = None
+ addressRange: Optional[str] = None
+ # custom
+ debugInfoSize: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class CompletionItem:
+ label: str
+ text: Optional[str] = None
+ detail: Optional[str] = None
+ start: Optional[int] = None
+ length: int = 0
+
+ def __repr__(self):
+ # Use json as it is easier to see the diff on failure.
+ return json.dumps(asdict(self), indent=4)
+
+ def clone(self, **kwargs) -> "CompletionItem":
+ """Creates a copy of this CompletionItem with specified fields modified."""
+ return dataclasses.replace(self, **kwargs)
+
+
+ at dataclass(frozen=True)
+class ValueFormat:
+ hex: Optional[bool] = None
+
+
+ at dataclass(frozen=True)
+class StackFrameFormat:
+ hex: Optional[bool] = None
+ parameters: Optional[bool] = None
+ parameterTypes: Optional[bool] = None
+ parameterNames: Optional[bool] = None
+ parameterValues: Optional[bool] = None
+ line: Optional[bool] = None
+ module: Optional[bool] = None
+ includeAll: Optional[bool] = None
+
+
+ at dataclass(frozen=True)
+class GotoTarget:
+ id: int
+ label: str
+ line: int
+ column: Optional[int] = None
+ endLine: Optional[int] = None
+ endColumn: Optional[int] = None
+ instructionPointerReference: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class StepInTarget:
+ id: int
+ label: str
+ line: Optional[int] = None
+ column: Optional[int] = None
+ endLine: Optional[int] = None
+ endColumn: Optional[int] = None
+
+
+ at dataclass(frozen=True)
+class DisassembledInstruction:
+ address: str
+ instruction: str
+ instructionBytes: Optional[str] = None
+ symbol: Optional[str] = None
+ location: Optional[Source] = None
+ line: Optional[int] = None
+ column: Optional[int] = None
+ endLine: Optional[int] = None
+ endColumn: Optional[int] = None
+ presentationHint: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class Capabilities:
+ supportsConfigurationDoneRequest: Optional[bool] = None
+ supportsFunctionBreakpoints: Optional[bool] = None
+ supportsConditionalBreakpoints: Optional[bool] = None
+ supportsHitConditionalBreakpoints: Optional[bool] = None
+ supportsEvaluateForHovers: Optional[bool] = None
+ exceptionBreakpointFilters: Optional[List[ExceptionBreakpointsFilter]] = None
+ supportsStepBack: Optional[bool] = None
+ supportsSetVariable: Optional[bool] = None
+ supportsRestartFrame: Optional[bool] = None
+ supportsGotoTargetsRequest: Optional[bool] = None
+ supportsStepInTargetsRequest: Optional[bool] = None
+ supportsCompletionsRequest: Optional[bool] = None
+ completionTriggerCharacters: Optional[List[str]] = None
+ supportsModulesRequest: Optional[bool] = None
+ additionalModuleColumns: Optional[List[ColumnDescriptor]] = None
+ supportedChecksumAlgorithms: Optional[List[ChecksumAlgorithm]] = None
+ supportsRestartRequest: Optional[bool] = None
+ supportsExceptionOptions: Optional[bool] = None
+ supportsValueFormattingOptions: Optional[bool] = None
+ supportsExceptionInfoRequest: Optional[bool] = None
+ supportTerminateDebuggee: Optional[bool] = None
+ supportSuspendDebuggee: Optional[bool] = None
+ supportsDelayedStackTraceLoading: Optional[bool] = None
+ supportsLoadedSourcesRequest: Optional[bool] = None
+ supportsLogPoints: Optional[bool] = None
+ supportsTerminateThreadsRequest: Optional[bool] = None
+ supportsSetExpression: Optional[bool] = None
+ supportsTerminateRequest: Optional[bool] = None
+ supportsDataBreakpoints: Optional[bool] = None
+ supportsReadMemoryRequest: Optional[bool] = None
+ supportsWriteMemoryRequest: Optional[bool] = None
+ supportsDisassembleRequest: Optional[bool] = None
+ supportsCancelRequest: Optional[bool] = None
+ supportsBreakpointLocationsRequest: Optional[bool] = None
+ supportsClipboardContext: Optional[bool] = None
+ supportsSteppingGranularity: Optional[bool] = None
+ supportsInstructionBreakpoints: Optional[bool] = None
+ supportsExceptionFilterOptions: Optional[bool] = None
+ supportsSingleThreadExecutionRequests: Optional[bool] = None
+ supportsDataBreakpointBytes: Optional[bool] = None
+ breakpointModes: Optional[List[Any]] = None
+ lldb_version: Optional[str] = field(
+ metadata={"alias": "$__lldb_version"}, default=None
+ )
+
+ # lldb-dap custom capability.
+ supportsModuleSymbolsRequest: Optional[bool] = None
+
+
+ at dataclass(frozen=True)
+class ErrorResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ error: Optional[Message] = None
+
+ message: Optional[str] = None
+ body: Optional[Body] = None
+
+ def __post_init__(self):
+ assert not self.success, f"success field must be 'False' {self}"
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class CancelArgs:
+ requestId: Optional[int] = None
+ progressId: Optional[str] = None
+
+ command_ = "cancel"
+ response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+class RunInTerminalResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ processId: Optional[int] = None
+ shellProcessId: Optional[int] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+class RunInTerminalArgs:
+ # TODO: Fix this cwd is not optional
+ cwd: Optional[str] = None
+ args: List[str] = field(default_factory=list)
+ kind: Optional[Literal["integrated", "external"]] = None
+ title: Optional[str] = None
+ env: Optional[Dict[str, Union[str, None]]] = None
+ argsCanBeInterpretedByShell: Optional[bool] = None
+
+
+ at dataclass(frozen=True)
+class RunInTerminalRequest(Request):
+ arguments: RunInTerminalArgs
+
+
+ReverseResponse = Union[RunInTerminalResponse, EmptyBodyResponse, ErrorResponse]
+"""Possible Responses from a reverse Request"""
+
+
+ at dataclass(frozen=True)
+class StartDebuggingRequestArgs:
+ configuration: Dict[str, Any] = field(default_factory=dict)
+ request: StartDebuggingRequestType = StartDebuggingRequestType.LAUNCH
+
+ command_ = "startDebugging"
+ response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+class InitializeResponse(Response):
+ body: Capabilities
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class InitializeArgs:
+ adapterID: str
+ clientID: Optional[str] = None
+ clientName: Optional[str] = None
+ locale: Optional[str] = None
+ linesStartAt1: Optional[bool] = None
+ columnsStartAt1: Optional[bool] = None
+ pathFormat: Optional[Literal["path", "uri"]] = None
+ supportsVariableType: Optional[bool] = None
+ supportsVariablePaging: Optional[bool] = None
+ supportsRunInTerminalRequest: Optional[bool] = True
+ supportsMemoryReferences: Optional[bool] = None
+ supportsProgressReporting: Optional[bool] = None
+ supportsInvalidatedEvent: Optional[bool] = None
+ supportsMemoryEvent: Optional[bool] = None
+ supportsArgsCanBeInterpretedByShell: Optional[bool] = None
+ supportsStartDebuggingRequest: Optional[bool] = None
+ supportsANSIStyling: Optional[bool] = None
+ sourceInitFile: bool = field(
+ metadata={"alias": "$__lldbSourceInitFile"}, default=False
+ )
+
+ command_ = "initialize"
+ response_class_ = InitializeResponse
+
+
+ at dataclass(frozen=True)
+class InitializedEvent(Event, event=EventName.INITIALIZED):
+ event: Union[EventName, str] = field(metadata={"required": EventName.INITIALIZED})
+
+ @dataclass(frozen=True)
+ class Body:
+ lldb_statistics: Dict[str, Any] = field(
+ default_factory=dict, metadata={"alias": "$__lldb_statistics"}
+ )
+
+ body: Optional[Body] = None
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ConfigurationDoneArgs:
+ command_ = "configurationDone"
+ response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class LaunchArgs:
+ program: str
+ noDebug: bool = False
+ launchCommands: Optional[List[str]] = None
+ cwd: Optional[str] = None
+ args: Optional[List[str]] = None
+ env: Optional[Union[Dict[str, str], List[str]]] = None
+ detachOnError: Optional[bool] = None
+ disableASLR: bool = False
+ disableSTDIO: bool = False
+ shellExpandArguments: bool = False
+ console: Console = Console.INTERNAL
+ stdio: Optional[List[Optional[str]]] = None
+
+ # Configurations.
+ debuggerRoot: Optional[str] = None
+ enableAutoVariableSummaries: bool = False
+ enableSyntheticChildDebugging: bool = False
+ displayExtendedBacktrace: bool = False
+ stopOnEntry: bool = False
+ timeout: Optional[float] = None
+ commandEscapePrefix: Optional[str] = None
+ customFrameFormat: Optional[str] = None
+ customThreadFormat: Optional[str] = None
+ sourcePath: Optional[str] = None
+ sourceMap: Optional[Union[List[Tuple[str, str]], Dict[str, str]]] = None
+ preInitCommands: Optional[List[str]] = None
+ initCommands: Optional[List[str]] = None
+ preRunCommands: Optional[List[str]] = None
+ postRunCommands: Optional[List[str]] = None
+ stopCommands: Optional[List[str]] = None
+ exitCommands: Optional[List[str]] = None
+ terminateCommands: Optional[List[str]] = None
+
+ command_ = "launch"
+ response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class AttachArgs:
+ restart: Optional[Any] = field(metadata={"alias": "__restart"}, default=None)
+
+ attachCommands: Optional[List[str]] = None
+ pid: Optional[int] = None
+ waitFor: Optional[bool] = None
+ gdbRemotePort: Optional[int] = None
+ gdbRemoteHostname: Optional[str] = None
+ coreFile: Optional[str] = None
+ program: Optional[str] = None
+
+ # Configurations.
+ debuggerRoot: Optional[str] = None
+ enableAutoVariableSummaries: Optional[bool] = None
+ enableSyntheticChildDebugging: Optional[bool] = None
+ displayExtendedBacktrace: Optional[bool] = None
+ stopOnEntry: Optional[bool] = None
+ timeout: Optional[float] = None
+ commandEscapePrefix: Optional[str] = None
+ customFrameFormat: Optional[str] = None
+ customThreadFormat: Optional[str] = None
+ sourcePath: Optional[str] = None
+ sourceMap: Optional[Union[List[Tuple[str, str]], Dict[str, str]]] = None
+ preInitCommands: Optional[List[str]] = None
+ initCommands: Optional[List[str]] = None
+ preRunCommands: Optional[List[str]] = None
+ postRunCommands: Optional[List[str]] = None
+ stopCommands: Optional[List[str]] = None
+ exitCommands: Optional[List[str]] = None
+ terminateCommands: Optional[List[str]] = None
+
+ command_ = "attach"
+ response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class RestartArgs:
+ arguments: Optional[Union[LaunchArgs, AttachArgs]] = None
+
+ command_ = "restart"
+ response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class DisconnectArgs:
+ restart: Optional[bool] = None
+ terminateDebuggee: Optional[bool] = None
+ suspendDebuggee: Optional[bool] = None
+
+ command_ = "disconnect"
+ response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class TerminateArgs:
+ restart: Optional[bool] = None
+
+ command_ = "terminate"
+ response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+class BreakpointLocationsResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ breakpoints: List[BreakpointLocation]
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class BreakpointLocationsArgs:
+ source: Source
+ line: int
+ column: Optional[int] = None
+ endLine: Optional[int] = None
+ endColumn: Optional[int] = None
+
+ command_ = "breakpointLocations"
+ response_class_ = BreakpointLocationsResponse
+
+
+ at dataclass(frozen=True)
+class AnyBreakpointsResponse(Response):
+ """The response for 'setBreakpoints', 'setFunctionBreakpoints', 'setDataBreakpoints'
+ and 'setInstructionBreakpoints'"""
+
+ @dataclass(frozen=True)
+ class Body:
+ breakpoints: List[Breakpoint]
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class SetBreakpointsArgs:
+ source: Source
+ breakpoints: Optional[List[SourceBreakpoint]] = None
+ lines: Optional[List[int]] = None
+ sourceModified: Optional[bool] = None
+
+ command_ = "setBreakpoints"
+ response_class_ = AnyBreakpointsResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class SetFunctionBreakpointsArgs:
+ breakpoints: List[FunctionBreakpoint]
+
+ command_ = "setFunctionBreakpoints"
+ response_class_ = AnyBreakpointsResponse
+
+
+ at dataclass(frozen=True)
+class SetExceptionBreakpointsResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ breakpoints: Optional[List[Breakpoint]] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class SetExceptionBreakpointsArgs:
+ filters: List[str]
+ filterOptions: Optional[List[ExceptionFilterOptions]] = None
+ exceptionOptions: Optional[List[ExceptionOptions]] = None
+
+ command_ = "setExceptionBreakpoints"
+ response_class_ = SetExceptionBreakpointsResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class DAPTestGetTargetBreakpointsArgs:
+ command_ = "_testGetTargetBreakpoints"
+ response_class_ = AnyBreakpointsResponse
+
+
+ at dataclass(frozen=True)
+class DataBreakpointInfoResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ description: str
+ dataId: Optional[str] = None
+ accessTypes: Optional[List[str]] = None
+ canPersist: Optional[bool] = None
+ canBreakOnBytes: Optional[bool] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class DataBreakpointInfoArgs:
+ name: str
+ variablesReference: Optional[int] = None
+ frameId: Optional[int] = None
+ bytes: Optional[int] = None
+ asAddress: Optional[bool] = None
+ mode: Optional[str] = None
+
+ command_ = "dataBreakpointInfo"
+ response_class_ = DataBreakpointInfoResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class SetDataBreakpointsArgs:
+ breakpoints: List[DataBreakpoint]
+
+ command_ = "setDataBreakpoints"
+ response_class_ = AnyBreakpointsResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class SetInstructionBreakpointsArgs:
+ breakpoints: List[InstructionBreakpoint]
+
+ command_ = "setInstructionBreakpoints"
+ response_class_ = AnyBreakpointsResponse
+
+
+ at dataclass(frozen=True)
+class CompileUnit:
+ compileUnitPath: str
+
+
+ at dataclass(frozen=True)
+class CompileUnitsResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ compileUnits: List[CompileUnit]
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class CompileUnitsArgs:
+ moduleId: str
+
+ command_ = "compileUnits"
+ response_class_ = CompileUnitsResponse
+
+
+ at dataclass(frozen=True)
+class ContinueResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ allThreadsContinued: Optional[bool] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ContinueArgs:
+ threadId: int = 0
+ singleThread: Optional[bool] = None
+
+ command_ = "continue"
+ response_class_ = ContinueResponse
+
+
+SteppingGranularity = Literal["statement", "line", "instruction"]
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class NextArgs:
+ threadId: int = 0
+ singleThread: Optional[bool] = None
+ granularity: Optional[SteppingGranularity] = None
+
+ command_ = "next"
+ response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class StepInArgs:
+ threadId: int = 0
+ singleThread: Optional[bool] = None
+ targetId: Optional[int] = None
+ granularity: Optional[SteppingGranularity] = None
+
+ command_ = "stepIn"
+ response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class StepOutArgs:
+ threadId: int
+ singleThread: Optional[bool] = None
+ granularity: Optional[SteppingGranularity] = None
+
+ command_ = "stepOut"
+ response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class GotoArgs:
+ threadId: int
+ targetId: int
+
+ command_ = "goto"
+ response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class PauseArgs:
+ threadId: int
+
+ command_ = "pause"
+ response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+class StackTraceResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ stackFrames: List[StackFrame]
+ totalFrames: Optional[int] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class StackTraceArgs:
+ threadId: int
+ startFrame: Optional[int] = None
+ levels: Optional[int] = None
+ format: Optional[StackFrameFormat] = None
+
+ command_ = "stackTrace"
+ response_class_ = StackTraceResponse
+
+
+ at dataclass(frozen=True)
+class ScopesResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ scopes: List[Scope]
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ScopesArgs:
+ frameId: int
+
+ command_ = "scopes"
+ response_class_ = ScopesResponse
+
+
+ at dataclass(frozen=True)
+class VariablesResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ variables: List[Variable]
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class VariablesArgs:
+ variablesReference: int
+ filter: Optional[Literal["indexed", "named"]] = None
+ start: Optional[int] = None
+ count: Optional[int] = None
+ format: Optional[ValueFormat] = None
+
+ command_ = "variables"
+ response_class_ = VariablesResponse
+
+
+ at dataclass(frozen=True)
+class SetVariableResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ value: str
+ type: Optional[str] = None
+ variablesReference: Optional[int] = None
+ namedVariables: Optional[int] = None
+ indexedVariables: Optional[int] = None
+ memoryReference: Optional[str] = None
+ valueLocationReference: Optional[int] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class SetVariableArgs:
+ variablesReference: int
+ name: str
+ value: str
+ format: Optional[ValueFormat] = None
+
+ command_ = "setVariable"
+ response_class_ = SetVariableResponse
+
+
+ at dataclass(frozen=True)
+class SourceResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ content: str
+ mimeType: Optional[str] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class SourceArgs:
+ sourceReference: int
+ source: Optional[Source] = None
+
+ command_ = "source"
+ response_class_ = SourceResponse
+
+
+ at dataclass(frozen=True)
+class ThreadsResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ threads: List[Thread]
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ThreadsArgs:
+ command_ = "threads"
+ response_class_ = ThreadsResponse
+
+
+ at dataclass(frozen=True)
+class ModulesResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ modules: List[Module]
+ totalModules: Optional[int] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ModulesArgs:
+ startModule: Optional[int] = None
+ moduleCount: Optional[int] = None
+
+ command_ = "modules"
+ response_class_ = ModulesResponse
+
+
+ at dataclass(frozen=True)
+class ModuleSymbol:
+ """Mirrors the `Symbol` struct produced by lldb-dap's `moduleSymbols`
+ request."""
+
+ id: int
+ isDebug: bool
+ isSynthetic: bool
+ isExternal: bool
+ type: str
+ fileAddress: int
+ size: int
+ name: str
+ loadAddress: Optional[int] = None
+
+
+ at dataclass(frozen=True)
+class ModuleSymbolsResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ symbols: List[ModuleSymbol]
+ totalSymbols: Optional[int] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ModuleSymbolsArgs:
+ moduleName: str
+ moduleId: str = ""
+ startIndex: Optional[int] = None
+ count: Optional[int] = None
+
+ command_ = "__lldb_moduleSymbols"
+ response_class_ = ModuleSymbolsResponse
+
+
+ at dataclass(frozen=True)
+class EvaluateResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ result: str
+ variablesReference: int
+ type: Optional[str] = None
+ presentationHint: Optional[VariablePresentationHint] = None
+ namedVariables: Optional[int] = None
+ indexedVariables: Optional[int] = None
+ memoryReference: Optional[str] = None
+ valueLocationReference: Optional[int] = None
+
+ body: Body
+
+
+EvaluateContext = Literal["watch", "repl", "hover", "clipboard", "variables"]
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class EvaluateArgs:
+ expression: str
+ frameId: Optional[int] = None
+ context: Optional[EvaluateContext] = None
+ format: Optional[ValueFormat] = None
+
+ command_ = "evaluate"
+ response_class_ = EvaluateResponse
+
+ at dataclass(frozen=True)
+class StepInTargetsResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ targets: List[StepInTarget]
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class StepInTargetsArgs:
+ frameId: int
+
+ command_ = "stepInTargets"
+ response_class_ = StepInTargetsResponse
+
+
+ at dataclass(frozen=True)
+class GotoTargetsResponse(Response):
+ class Body:
+ targets: List[GotoTarget]
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class GotoTargetsArgs:
+ source: Source
+ line: int
+ column: Optional[int] = None
+
+ command_ = "gotoTargets"
+ response_class_ = GotoTargetsResponse
+
+
+ at dataclass(frozen=True)
+class CompletionsResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ targets: List[CompletionItem]
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class CompletionsArgs:
+ text: str
+ column: int
+ frameId: Optional[int] = None
+ line: Optional[int] = None
+
+ command_ = "completions"
+ response_class_ = CompletionsResponse
+
+
+ at dataclass(frozen=True)
+class ExceptionInfoResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ exceptionId: str
+ breakMode: ExceptionBreakMode
+ description: Optional[str] = None
+ details: Optional[ExceptionDetails] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ExceptionInfoArgs:
+ threadId: int
+
+ command_ = "exceptionInfo"
+ response_class_ = ExceptionInfoResponse
+
+
+ at dataclass(frozen=True)
+class ReadMemoryResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ address: str
+ unreadableBytes: Optional[int] = None
+ data: Optional[str] = None # base64-encoded bytes
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ReadMemoryArgs:
+ memoryReference: str
+ count: int
+ offset: Optional[int] = None
+
+ command_ = "readMemory"
+ response_class_ = ReadMemoryResponse
+
+
+ at dataclass(frozen=True)
+class WriteMemoryResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ offset: Optional[int] = None
+ bytesWritten: Optional[int] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class WriteMemoryArgs:
+ memoryReference: str
+ data: str # base64-encoded bytes
+ offset: Optional[int] = None
+ allowPartial: Optional[bool] = None
+
+ command_ = "writeMemory"
+ response_class_ = WriteMemoryResponse
+
+
+ at dataclass(frozen=True)
+class DisassembleResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ instructions: List[DisassembledInstruction]
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class DisassembleArgs:
+ memoryReference: str
+ instructionCount: int
+ offset: Optional[int] = None
+ instructionOffset: Optional[int] = None
+ resolveSymbols: Optional[bool] = None
+
+ command_ = "disassemble"
+ response_class_ = DisassembleResponse
+
+
+ at dataclass(frozen=True)
+class LocationsResponse(Response):
+ @dataclass(frozen=True)
+ class Body:
+ source: Source
+ line: int
+ column: Optional[int] = None
+ endLine: Optional[int] = None
+ endColumn: Optional[int] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class LocationsArgs:
+ locationReference: int
+
+ command_ = "locations"
+ response_class_ = LocationsResponse
+
+
+ at dataclass(frozen=True)
+class StoppedEvent(Event, event=EventName.STOPPED):
+ event: Union[EventName, str] = field(metadata={"required": EventName.STOPPED})
+
+ @dataclass(frozen=True)
+ class Body:
+ reason: StoppedReason
+ description: Optional[str] = None
+ threadId: Optional[int] = None
+ preserveFocusHint: Optional[bool] = None
+ text: Optional[str] = None
+ allThreadsStopped: Optional[bool] = None
+ hitBreakpointIds: Optional[List[int]] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+class ContinuedEvent(Event, event=EventName.CONTINUED):
+ event: Union[EventName, str] = field(metadata={"required": EventName.CONTINUED})
+
+ @dataclass(frozen=True)
+ class Body:
+ threadId: int
+ allThreadsContinued: Optional[bool] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+class ExitedEvent(Event, event=EventName.EXITED):
+ event: Union[EventName, str] = field(metadata={"required": EventName.EXITED})
+
+ @dataclass(frozen=True)
+ class Body:
+ exitCode: int
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+class TerminatedEvent(Event, event=EventName.TERMINATED):
+ event: Union[EventName, str] = field(metadata={"required": EventName.TERMINATED})
+
+ @dataclass(frozen=True)
+ class Body:
+ restart: Optional[Any] = None
+ lldb_statistics: Dict[str, Any] = field(
+ default_factory=dict, metadata={"alias": "$__lldb_statistics"}
+ )
+
+ body: Optional[Body] = None
+
+
+ at dataclass(frozen=True)
+class ThreadEvent(Event, event=EventName.THREAD):
+ event: Union[EventName, str] = field(metadata={"required": EventName.THREAD})
+
+ @dataclass(frozen=True)
+ class Body:
+ threadId: int
+ reason: Literal["started", "exited"]
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+class OutputEvent(Event, event=EventName.OUTPUT):
+ event: Union[EventName, str] = field(metadata={"required": EventName.OUTPUT})
+
+ @dataclass(frozen=True)
+ class Body:
+ output: str
+ category: OutputCategory = OutputCategory.CONSOLE # defaults to console
+ group: Optional[Literal["start", "startCollapsed", "end"]] = None
+ variablesReference: Optional[int] = None
+ source: Optional[Source] = None
+ line: Optional[int] = None
+ column: Optional[int] = None
+ data: Optional[Any] = None
+ locationReference: Optional[int] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+class BreakpointEvent(Event, event=EventName.BREAKPOINT):
+ event: Union[EventName, str] = field(metadata={"required": EventName.BREAKPOINT})
+
+ @dataclass(frozen=True)
+ class Body:
+ reason: BreakpointReason
+ breakpoint: Breakpoint
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+class ModuleEvent(Event, event=EventName.MODULE):
+ event: Union[EventName, str] = field(metadata={"required": EventName.MODULE})
+
+ @dataclass(frozen=True)
+ class Body:
+ reason: ModuleReason
+ module: Module
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+class ProcessEvent(Event, event=EventName.PROCESS):
+ event: Union[EventName, str] = field(metadata={"required": EventName.PROCESS})
+
+ @dataclass(frozen=True)
+ class Body:
+ name: str
+ systemProcessId: Optional[int] = None
+ isLocalProcess: Optional[bool] = None
+ startMethod: Optional[Literal["launch", "attach"]] = None
+ pointerSize: Optional[int] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+class CapabilitiesEvent(Event, event=EventName.CAPABILITIES):
+ event: Union[EventName, str] = field(metadata={"required": EventName.CAPABILITIES})
+
+ @dataclass(frozen=True)
+ class Body:
+ capabilities: Capabilities
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+class ProgressStartEvent(Event, event=EventName.PROGRESS_START):
+ event: Union[EventName, str] = field(
+ metadata={"required": EventName.PROGRESS_START}
+ )
+
+ @dataclass(frozen=True)
+ class Body:
+ progressId: str
+ title: str
+ requestId: Optional[int] = None
+ cancellable: Optional[bool] = None
+ message: Optional[str] = None
+ percentage: Optional[float] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+class ProgressUpdateEvent(Event, event=EventName.PROGRESS_UPDATE):
+ event: Union[EventName, str] = field(
+ metadata={"required": EventName.PROGRESS_UPDATE}
+ )
+
+ @dataclass(frozen=True)
+ class Body:
+ progressId: str
+ message: Optional[str] = None
+ percentage: Optional[float] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+class ProgressEndEvent(Event, event=EventName.PROGRESS_END):
+ event: Union[EventName, str] = field(metadata={"required": EventName.PROGRESS_END})
+
+ @dataclass(frozen=True)
+ class Body:
+ progressId: str
+ message: Optional[str] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+class InvalidatedEvent(Event, event=EventName.INVALIDATED):
+ event: Union[EventName, str] = field(metadata={"required": EventName.INVALIDATED})
+
+ @dataclass(frozen=True)
+ class Body:
+ areas: Optional[List[InvalidatedAreas]] = None
+ threadId: Optional[int] = None
+ stackFrameId: Optional[int] = None
+
+ body: Body
+
+
+ at dataclass(frozen=True)
+class MemoryEvent(Event, event=EventName.MEMORY):
+ event: Union[EventName, str] = field(metadata={"required": EventName.MEMORY})
+
+ @dataclass(frozen=True)
+ class Body:
+ memoryReference: str
+ offset: int
+ count: int
+
+ body: Body
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py
new file mode 100644
index 0000000000000..b2f56b81c2cd2
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py
@@ -0,0 +1,221 @@
+import gc
+import logging
+import os
+from pathlib import Path
+from typing import Any, Final, Optional, TypeVar, Union, cast
+
+from lldbsuite.test.lldbtest import Base, is_exe
+
+from .dap_types import AnyResponse, ErrorResponse, Response
+from .session_helpers import DAPTestSession
+from .utils import DebugAdapter, DebugAdapterOptions
+
+
+def strtobool(val: str) -> bool:
+ """Convert a string representation of truth to a bool following LLVM's CLI argument parsing."""
+
+ val = val.lower()
+ if val in {"false", "0", "no", "off"}:
+ return False
+ return True
+
+
+T = TypeVar("T")
+
+
+class DAPTestCaseBase(Base):
+ """Base test case for DAP tests"""
+
+ NO_DEBUG_INFO_TESTCASE = True
+ DEFAULT_TIMEOUT: Final[float] = 500.0 if "ASAN_OPTIONS" in os.environ else 50.0
+
+ USE_DEFAULT_DEBUG_ADAPTER: bool = True
+ """Subclasses can set this to true to avoid creating a debug adapter is will not be used."""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.run_as_server: bool = strtobool(os.getenv("LLDBDAP_RUN_AS_SERVER", "false"))
+
+ def setUp(self):
+ super().setUp()
+ self.setUpBaseLogging()
+
+ self._debug_adapter_count: int = 0
+ if self.USE_DEFAULT_DEBUG_ADAPTER:
+ self.__create_default_debug_adapter()
+
+ def setUpBaseLogging(self):
+ self.logger = logging.getLogger(f"lldb_dap.{__name__}")
+ self.logger.propagate = False
+ self.logger.setLevel(logging.DEBUG)
+
+ log_path = f"{self.getLogBasenameForCurrentTest()}-test_dap.log"
+ handler = logging.FileHandler(log_path, mode="w")
+
+ # The Log name gets quite long and becomes noise. use the last log scope.
+ class _ShortNameFormatter(logging.Formatter):
+ def format(self, record: logging.LogRecord) -> str:
+ record.short_name = record.name.rsplit(".", 1)[-1]
+ return super().format(record)
+
+ handler.setFormatter(
+ _ShortNameFormatter(
+ "%(asctime)s.%(msecs)03d %(levelname)-5s (%(short_name)s) %(message)s",
+ datefmt="%H:%M:%S",
+ )
+ )
+ self.logger.addHandler(handler)
+
+ def close_log():
+ self.logger.removeHandler(handler)
+ handler.close()
+
+ self.addTearDownHook(close_log)
+
+ def tearDown(self):
+ gc.collect()
+ super().tearDown()
+
+ def __create_default_debug_adapter(self):
+ self.assertFalse(hasattr(self, "adapter"), "A default adapter already exists.")
+
+ if self.run_as_server:
+ self.adapter = self.create_server_debug_adapter(
+ DebugAdapterOptions(cwd=self.getBuildDir()),
+ connection="listen://localhost:0",
+ connection_timeout=10,
+ )
+ else:
+ self.adapter = self.create_stdio_debug_adapter(
+ DebugAdapterOptions(cwd=self.getBuildDir())
+ )
+
+ def create_session(
+ self,
+ adapter: Optional[DebugAdapter] = None,
+ disconnect_automatically: Optional[bool] = None,
+ ) -> DAPTestSession:
+ if adapter is None:
+ self.assertIsNotNone(self.adapter, "expected we already have an adapter.")
+ adapter = self.adapter
+ self.assertTrue(adapter.is_alive, "expected adapter process is alive.")
+
+ if adapter.is_server and disconnect_automatically is not None:
+ self.assertFalse(
+ disconnect_automatically,
+ "disconnect_automatically is not supported for lldb-dap running as a server",
+ )
+
+ build_dir = Path(self.getBuildDir())
+ session = DAPTestSession(
+ self,
+ build_dir,
+ adapter,
+ message_timeout=self.DEFAULT_TIMEOUT,
+ process_spawner=self.spawnSubprocess, # type: ignore
+ logger=self.logger,
+ )
+
+ def cleanup_session():
+ # In server mode the adapter automatically shuts down after the last
+ # client disconnects.
+ if not adapter.is_server and disconnect_automatically is not False:
+ session.disconnect(terminateDebuggee=True)
+ session.stop()
+
+ self.addTearDownHook(cleanup_session)
+ session.start()
+ return session
+
+ def build_and_create_session(
+ self,
+ adapter: Optional[DebugAdapter] = None,
+ disconnect_automatically: Optional[bool] = None,
+ ) -> DAPTestSession:
+ self.build()
+ return self.create_session(adapter, disconnect_automatically)
+
+ def create_debug_adapter(
+ self, adapter_options: DebugAdapterOptions
+ ) -> DebugAdapter:
+ self.assertTrue(
+ is_exe(self.lldbDAPExec),
+ f"lldb-dap must exist and be executable. path: {self.lldbDAPExec}",
+ )
+
+ if adapter_options.log_file:
+ log_file = adapter_options.log_file
+ else:
+ count = self._debug_adapter_count
+ suffix = f"-{count}" if count else ""
+ log_file = f"{self.getLogBasenameForCurrentTest()}-dap{suffix}.log"
+
+ self._debug_adapter_count += 1
+ cwd = adapter_options.cwd or self.getBuildDir()
+ pre_init_commands = self.setUpCommands()
+
+ adapter_options = adapter_options.clone(
+ log_file=log_file, cwd=cwd, pre_init_commands=pre_init_commands
+ )
+ lldb_dap_exec = self.expect_not_none(self.lldbDAPExec)
+ adapter = DebugAdapter(executable=lldb_dap_exec, opts=adapter_options)
+ self.assertTrue(adapter.is_alive, "adapter should be running after creation.")
+
+ def cleanup_adapter():
+ if adapter.is_alive:
+ adapter.kill()
+
+ self.addTearDownHook(cleanup_adapter)
+ return adapter
+
+ def create_stdio_debug_adapter(
+ self, adapter_options: Optional[DebugAdapterOptions] = None
+ ) -> DebugAdapter:
+ """Forces the adapter to stdio mode. the DebugAdapter class handles the validation"""
+ adapter_options = adapter_options or DebugAdapterOptions()
+ self.assertIsNone(
+ adapter_options.connection, "'connection' cannot be used with stdio mode."
+ )
+
+ adapter = self.create_debug_adapter(adapter_options)
+ self.assertFalse(adapter.is_server, "adapter should be using stdio.")
+ return adapter
+
+ def create_server_debug_adapter(
+ self,
+ adapter_options: Optional[DebugAdapterOptions] = None,
+ *,
+ connection: str,
+ connection_timeout: int,
+ ) -> DebugAdapter:
+ """Forces the adapter to server mode. the DebugAdapter class handles the validation."""
+ adapter_options = adapter_options or DebugAdapterOptions()
+ adapter_options = adapter_options.clone(
+ connection=connection,
+ connection_timeout=connection_timeout,
+ )
+ adapter = self.create_debug_adapter(adapter_options)
+ self.assertTrue(adapter.is_server, "adapter should run as a server.")
+ return adapter
+
+ def expect_not_none(self, value: Optional[T], msg: Any = None) -> T:
+ """Convenience function to narrow fields that are optional, as most DAP types are."""
+ self.assertIsNotNone(value, msg=msg)
+ return cast(T, value)
+
+ def expect_error(
+ self, value: Union[Response, ErrorResponse], msg: Any = None
+ ) -> ErrorResponse:
+ """Convenience function for narrowing a response Union to `ErrorResponse`."""
+ self.assertIsInstance(value, ErrorResponse, msg=msg)
+ self.assertFalse(value.success)
+ return cast(ErrorResponse, value)
+
+ def expect_success(
+ self, value: Union[AnyResponse, ErrorResponse], msg: Any = None
+ ) -> AnyResponse:
+ """Convenience function for narrowing a response Union to the success type."""
+ self.assertNotIsInstance(value, ErrorResponse, msg=msg)
+ self.assertTrue(value.success)
+ return cast(AnyResponse, value)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
new file mode 100644
index 0000000000000..22063815b111a
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
@@ -0,0 +1,529 @@
+# FIXME: remove when LLDB_MINIMUM_PYTHON_VERSION > 3.8
+from __future__ import annotations
+
+import contextlib
+import dataclasses
+import functools
+import io
+import itertools
+import json
+import logging
+import os
+import subprocess
+import threading
+from concurrent import futures
+from concurrent.futures import Future
+from dataclasses import fields
+from pathlib import Path
+from typing import Any, Callable, Generic, Optional, Type, TypeVar
+
+from .dap_types import (
+ AnyResponse,
+ ArgsProtocol,
+ Capabilities,
+ CapabilitiesEvent,
+ ContinueArgs,
+ DAPError,
+ DisconnectArgs,
+ ErrorResponse,
+ Event,
+ ExitedEvent,
+ GotoArgs,
+ InitializeArgs,
+ InitializedEvent,
+ Message,
+ MessageType,
+ NextArgs,
+ OutputCategory,
+ OutputEvent,
+ RawMessage,
+ Request,
+ Response,
+ RestartArgs,
+ ReverseResponse,
+ RunInTerminalRequest,
+ RunInTerminalResponse,
+ StepInArgs,
+ StepOutArgs,
+ StoppedEvent,
+ TerminateArgs,
+ dict_to_message,
+)
+from .utils import (
+ DebugAdapter,
+ EventHistory,
+ MessageHandler,
+ SubProcessSpawner,
+ redirect_stream,
+)
+
+R = TypeVar("R")
+
+# Any Request that resumes execution (or terminates the session),
+# invalidates any frameId or variablesReference captured during the current stop.
+# Sending one of these Requests advances the session's stop_generation.
+# To prevent using a frameId or variablesReference that is no longer valid once
+# the session continues.
+_RESUMING_COMMANDS = (
+ ContinueArgs,
+ NextArgs,
+ StepInArgs,
+ StepOutArgs,
+ GotoArgs,
+ RestartArgs,
+ TerminateArgs,
+)
+
+
+class PendingResponse(Generic[AnyResponse]):
+ """A Holds the future to the expected request for the sequence id."""
+
+ def __init__(
+ self,
+ seq: int,
+ response_class: Type[AnyResponse],
+ raw_future: Future[RawMessage],
+ timeout: float,
+ command: str,
+ on_resolve: Callable[[Response], None] = lambda _: None,
+ ):
+ assert issubclass(
+ response_class, Response
+ ), f"'{response_class.__name__}' must be a subclass of Response."
+ self.seq = seq
+ self.response_class: Type[AnyResponse] = response_class
+ self._future = raw_future
+ self._timeout = timeout
+ self._command = command
+ self._on_resolve = on_resolve
+
+ def result(self, msg: Optional[str] = None) -> AnyResponse:
+ response = self.result_or_error()
+
+ if isinstance(response, self.response_class):
+ return response
+ detail = f"expected '{self.response_class.__name__}' got {response}."
+ raise DAPError(f"{msg}:\n\t{detail}" if msg else detail)
+
+ def error(self, msg: Optional[str] = None) -> ErrorResponse:
+ response = self.result_or_error()
+
+ if isinstance(response, ErrorResponse):
+ return response
+ detail = f"expected '{self.response_class.__name__}' got {response}."
+ raise DAPError(f"{msg}\n\t{detail}" if msg else detail)
+
+ def result_or_error(self) -> AnyResponse | ErrorResponse:
+ try:
+ raw = self._future.result(timeout=self._timeout)
+ except (TimeoutError, futures.TimeoutError) as e:
+ msg = f"\n\tRequest '{self._command}' (seq={self.seq}) timed out after {self._timeout}s"
+ e.args = (f"{e.args[0]}{msg}", *e.args)
+ raise
+ except ConnectionError as e:
+ raise DAPError(
+ f"Session ended before getting response for "
+ f"'{self._command}' (seq={self.seq})"
+ ) from e
+
+ cls = self.response_class if raw["success"] else ErrorResponse
+ response = cls.from_json(raw)
+
+ self._on_resolve(response)
+ return response
+
+
+def _synchronized(method: Callable[..., R]) -> Callable[..., R]:
+ """Class method decorator to acquire and release the lock automatically."""
+
+ @functools.wraps(method)
+ def wrapper(self, *args: Any, **kwargs: Any) -> R:
+ with self._lock:
+ return method(self, *args, **kwargs)
+
+ return wrapper
+
+
+class _DAPSessionState:
+ def __init__(self):
+ self._lock = threading.RLock()
+ self._initialized = False
+ self._capabilities = Capabilities()
+ self.output_streams = {
+ OutputCategory.STDOUT: io.StringIO(),
+ OutputCategory.STDERR: io.StringIO(),
+ OutputCategory.CONSOLE: io.StringIO(),
+ OutputCategory.IMPORTANT: io.StringIO(),
+ OutputCategory.TELEMETRY: io.StringIO(),
+ }
+ self._stopped_thread_id: Optional[int] = None
+ self._last_response: Optional[Response] = None
+ self._stop_generation: int = 0
+
+ @property
+ @_synchronized
+ def is_initialized(self):
+ return self._initialized
+
+ @_synchronized
+ def set_initialized(self, val: bool):
+ self._initialized = val
+
+ @property
+ @_synchronized
+ def stopped_thread_id(self):
+ return self._stopped_thread_id
+
+ @_synchronized
+ def set_stopped_thread_id(self, id: int):
+ assert isinstance(id, int)
+ self._stopped_thread_id = id
+
+ @property
+ @_synchronized
+ def stop_generation(self) -> int:
+ """The current stop's generation number.
+
+ Monotonic counter identifying the current session stop.
+ Incremented every time we send a request that resumes or terminates execution
+ (see `_RESUMING_COMMANDS`).
+ """
+ return self._stop_generation
+
+ @_synchronized
+ def advance_stop_generation(self) -> int:
+ self._stop_generation += 1
+ return self._stop_generation
+
+ @_synchronized
+ def capabilities(self):
+ return dataclasses.replace(self._capabilities)
+
+ @_synchronized
+ def update_capabilities(self, new_capabilities: Capabilities):
+ kwargs = {
+ field: value
+ for field, value in vars(new_capabilities).items()
+ if value is not None
+ }
+ self._capabilities = dataclasses.replace(self._capabilities, **kwargs)
+
+ @property
+ @_synchronized
+ def last_response(self) -> Optional[Response]:
+ return self._last_response
+
+ @_synchronized
+ def set_last_response(self, response: Response):
+ self._last_response = response
+
+
+class Session:
+ """
+ Protocol-level DAP session managing communication and state with a debug adapter.
+
+ Wraps a `DAPConnection` to handle message routing (requests, responses, and events).
+ It maintains the core session state, including negotiated capabilities.
+
+ It only exists to separate the test helpers from the implementation.
+ see `DAPTestSession`.
+ """
+
+ def __init__(
+ self,
+ test_dir: Path,
+ adapter: DebugAdapter,
+ message_timeout: float,
+ process_spawner: SubProcessSpawner,
+ logger: logging.Logger,
+ ):
+ self._test_dir = test_dir
+
+ self._message_timeout = message_timeout
+ self._process_spawner = process_spawner
+ self._next_sequence = functools.partial(next, itertools.count(start=1))
+ self._state = _DAPSessionState()
+
+ self._event_history = EventHistory(self._message_timeout)
+ self._adapter = adapter
+ self._connection = adapter.create_connection()
+ self._logger = logger.getChild(self._connection.id)
+
+ def on_connection_closed(err: Optional[Exception]):
+ # We want fail early if there is already a request for wait_for_X_event
+ # in the main thread.
+ self._event_history.close(err or Exception("Session Ended."))
+
+ msg_handler = MessageHandler(
+ on_response=self._on_protocol_response,
+ on_event=self._on_protocol_event,
+ on_reverse_request=self._on_protocol_reverse_request,
+ on_close=on_connection_closed,
+ )
+ self._read_thread = threading.Thread(
+ target=self._connection.start, args=[msg_handler], name="Read Thread"
+ )
+
+ # Function Mappings.
+ self.wait_for_earliest_event = self._event_history.wait_for_earliest_event
+ self.wait_for_any_event = self._event_history.wait_for_any_event
+ self.wait_for_event = self._event_history.wait_for_event
+ self.capabilities = self._state.capabilities
+
+ # Reverse Requests.
+ self._reverse_requests: list[Request] = []
+ self._reverse_process: Optional[subprocess.Popen[bytes]] = None
+ # The list of threads that redirects stdio when the debuggee
+ # is created using `RunInTerminal`.
+ self._reverse_process_io_threads: list[threading.Thread] = []
+
+ def last_response(self):
+ """Returns a copy of the response most recently consumed by a test.
+
+ This is the response a test last checked via (PendingResponse) not
+ the last response from the adapter. only useful when a helper function
+ does not expose the response such as `resolve_source_breakpoint`."""
+ response = self._state.last_response
+ assert response is not None, "expected at least previous response."
+ return dataclasses.replace(response)
+
+ @property
+ def stopped_thread_id(self):
+ thread_id = self._state.stopped_thread_id
+ assert thread_id is not None, "stopped thread id is never set."
+ return thread_id
+
+ def _current_stop_generation(self) -> int:
+ return self._state.stop_generation
+
+ def _check_stop_generation(self, ctx_generation: int, context: Any) -> None:
+ """Assert a context generation is still valid for the current stop."""
+ current = self._state.stop_generation
+ if ctx_generation != current:
+ raise AssertionError(
+ f"{type(context).__name__} from stop generation {ctx_generation} used at "
+ f"generation {current}: the session resumed, so "
+ f"this context's frameId/variablesReference is no longer valid."
+ )
+
+ def start(self) -> None:
+ self._read_thread.start()
+ # Synchronize with the connection.
+ self._connection.wait_until_alive(self._message_timeout)
+
+ def _on_protocol_response(self, message: RawMessage) -> None:
+ self._logger.debug("<-- %s", json.dumps(message))
+ command = message.get("command")
+ if command == InitializeArgs.command_:
+ if raw_capabilities := message.get("body"):
+ init_capabilities = dict_to_message(Capabilities, raw_capabilities)
+ self._state.update_capabilities(init_capabilities)
+ if command == DisconnectArgs.command_:
+ self._connection.stop()
+
+ def _on_protocol_event(self, message: RawMessage) -> None:
+ self._logger.debug("<-- %s", json.dumps(message))
+
+ event = Event.from_json(message)
+ event_name = event.event
+ assert event_name is not None
+
+ if isinstance(event, InitializedEvent):
+ self._state.set_initialized(True)
+ elif isinstance(event, CapabilitiesEvent):
+ self._state.update_capabilities(event.body.capabilities)
+ elif isinstance(event, StoppedEvent):
+ if (thread_id := event.body.threadId) and not event.body.preserveFocusHint:
+ self._state.set_stopped_thread_id(thread_id)
+ elif isinstance(event, OutputEvent):
+ category = self._state.output_streams[event.body.category]
+ category.write(event.body.output)
+ elif isinstance(event, ExitedEvent):
+ # If we have a 'runInTerminal' process it must have exited.
+ if self._reverse_process:
+ self.verify_reverse_process_exited()
+
+ # Join the redirect threads here. so any tail bytes are flushed
+ # into the StringIOs before the test reads them.
+ for thread in self._reverse_process_io_threads:
+ thread.join(self._message_timeout)
+
+ # Store in general event queue.
+ self._event_history.record(event)
+
+ def _on_protocol_reverse_request(self, request: RawMessage):
+ self._logger.debug("<-- %s", json.dumps(request))
+ request_type = request.get("command", "unknown")
+ if request_type == "runInTerminal":
+ terminal_request = dict_to_message(RunInTerminalRequest, request)
+ self._reverse_requests.append(terminal_request)
+ self._handle_run_in_terminal(terminal_request)
+ else:
+ raise NotImplementedError(
+ f"no reverse request handler for '{request_type}'"
+ )
+
+ def _handle_run_in_terminal(self, request: RunInTerminalRequest):
+ request_args = request.arguments
+ [process_exe, *process_args] = request_args.args
+ # Per DAP spec, "env" contains additions/overrides to the
+ # default environment, not a full replacement. Merge with
+ # os.environ so the spawned process inherits PATH etc.
+ env_dict = os.environ.copy()
+ if request_args.env:
+ for key, value in request_args.env.items():
+ env_dict[key] = "" if value is None else value
+
+ process_env = [f"{k}={v}" for k, v in env_dict.items()]
+ self._logger.info("runInTerminal process with args: %s", process_args)
+
+ process = self._process_spawner(
+ process_exe,
+ process_args,
+ process_env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ return_code = process.poll()
+ if return_code is not None:
+ stdout, stderr = process.communicate()
+ response = ErrorResponse(
+ type=MessageType.RESPONSE,
+ seq=self._next_sequence(),
+ command=request.command,
+ request_seq=request.seq,
+ success=False,
+ body=ErrorResponse.Body(
+ error=Message(
+ id=3,
+ format=f"failed to launch process {process_args[0]}, stdout={stdout}"
+ f"\nstderr={stderr} return code ={return_code}",
+ )
+ ),
+ )
+ else:
+ self._reverse_process = process
+ response = RunInTerminalResponse(
+ type=MessageType.RESPONSE,
+ seq=self._next_sequence(),
+ command="runInTerminal",
+ request_seq=request.seq,
+ success=True,
+ body=RunInTerminalResponse.Body(processId=process.pid),
+ )
+ if proc_stdout := process.stdout:
+ out_stream = self._state.output_streams[OutputCategory.STDOUT]
+ out_thread = redirect_stream(proc_stdout, out_stream, "stdout")
+ self._reverse_process_io_threads.append(out_thread)
+ if proc_stderr := process.stderr:
+ err_stream = self._state.output_streams[OutputCategory.STDERR]
+ err_thread = redirect_stream(proc_stderr, err_stream, "stderr")
+ self._reverse_process_io_threads.append(err_thread)
+
+ self._send_response(response)
+
+ def ensure_initialized(self):
+ if self._state.is_initialized:
+ return
+ self.wait_for_earliest_event(InitializedEvent)
+ # Sanity check.
+ assert self._state.is_initialized
+
+ def get_stdout(self):
+ return self._state.output_streams[OutputCategory.STDOUT].getvalue()
+
+ def get_console(self) -> str:
+ return self._state.output_streams[OutputCategory.CONSOLE].getvalue()
+
+ def get_stderr(self) -> str:
+ return self._state.output_streams[OutputCategory.STDERR].getvalue()
+
+ def get_important(self) -> str:
+ return self._state.output_streams[OutputCategory.IMPORTANT].getvalue()
+
+ def send_request(
+ self, request_args: ArgsProtocol[AnyResponse]
+ ) -> PendingResponse[AnyResponse]:
+ """Send a request and return a `PendingResponse` to wait on."""
+ assert isinstance(request_args, ArgsProtocol)
+ seq = self._next_sequence()
+
+ # Any frameId or variablesReference during this stop becomes stale once the request is sent.
+ if type(request_args) in _RESUMING_COMMANDS:
+ self._state.advance_stop_generation()
+
+ request = Request(
+ seq=seq,
+ type=MessageType.REQUEST,
+ command=request_args.command_,
+ arguments=request_args if len(fields(request_args)) > 0 else None,
+ )
+
+ self._logger.debug("--> %s", json.dumps(request.to_dict()))
+ raw_future = self._connection.send_request(request)
+ return PendingResponse(
+ seq=seq,
+ response_class=request_args.response_class_,
+ raw_future=raw_future,
+ timeout=self._message_timeout,
+ command=request_args.command_,
+ on_resolve=self._state.set_last_response,
+ )
+
+ def _send_response(self, response: ReverseResponse):
+ assert isinstance(response, Response)
+ response_dict = response.to_dict()
+ self._logger.debug("--> %s", response_dict)
+ self._connection.send_message(response_dict)
+
+ def last_reverse_request(self) -> Request:
+ assert len(self._reverse_requests) > 0, "No Reverse Request made"
+ return self._reverse_requests[-1]
+
+ def is_running(self):
+ return self._connection.is_alive()
+
+ def verify_reverse_process_exited(self, exit_code: Optional[int] = None):
+ if process := self._reverse_process:
+ proc_exit_code = process.poll()
+ if proc_exit_code is None:
+ raise DAPError(
+ f"process is still running, "
+ f"for process pid: '{process.pid}', args: {process.args}"
+ )
+
+ if exit_code is not None:
+ assert proc_exit_code == exit_code, (
+ f"{proc_exit_code=} != expected_exit_code={exit_code} "
+ f"for process pid: '{process.pid}'"
+ )
+
+ def stop(self) -> None:
+ logger = self._logger
+ self._connection.stop()
+
+ if self._read_thread.is_alive():
+ self._logger.info("Joining the read thread.")
+ self._read_thread.join(self._message_timeout)
+
+ # If the runInTerminal subprocess is still alive the redirect threads are
+ # blocked in read. Kill the process to stop the redirect threads.
+ reverse_process = self._reverse_process
+ if reverse_process and reverse_process.poll() is None:
+ logger.info("Terminating the reverse process: %s.", reverse_process.args)
+ reverse_process.terminate()
+ try:
+ reverse_process.wait(timeout=2.0)
+ except subprocess.TimeoutExpired:
+ logger.info("Force kill the reverse process: %s.", reverse_process.args)
+ reverse_process.kill()
+
+ for thread in self._reverse_process_io_threads:
+ logger.info("Joining the reverse process io thread: %s.", thread.name)
+ thread.join(timeout=self._message_timeout)
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.stop()
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
new file mode 100644
index 0000000000000..5c36d5dbed2aa
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
@@ -0,0 +1,1699 @@
+# FIXME: remove when LLDB_MINIMUM_PYTHON_VERSION > 3.8
+from __future__ import annotations
+
+import base64
+import dataclasses
+import logging
+import os
+import unittest
+from dataclasses import dataclass
+from pathlib import Path
+from typing import (
+ Callable,
+ Iterable,
+ Iterator,
+ Literal,
+ Optional,
+ Sequence,
+ TypeVar,
+ cast,
+)
+
+from .dap_types import (
+ AttachArgs,
+ Breakpoint,
+ BreakpointEvent,
+ BreakpointLocationsArgs,
+ CompletionsArgs,
+ ConfigurationDoneArgs,
+ ContinueArgs,
+ DataBreakpoint,
+ DataBreakpointInfoArgs,
+ DisassembleArgs,
+ DisconnectArgs,
+ EmptyBodyResponse,
+ ErrorResponse,
+ EvaluateArgs,
+ EvaluateContext,
+ EvaluateResponse,
+ Event,
+ EventName,
+ ExceptionFilterOptions,
+ ExceptionInfoArgs,
+ ExceptionOptions,
+ ExitedEvent,
+ FunctionBreakpoint,
+ InitializeArgs,
+ InitializedEvent,
+ InstructionBreakpoint,
+ InvalidatedEvent,
+ LaunchArgs,
+ LocationsArgs,
+ MemoryEvent,
+ ModuleEvent,
+ ModuleReason,
+ ModulesArgs,
+ NextArgs,
+ OutputCategory,
+ OutputEvent,
+ ProcessEvent,
+ ReadMemoryArgs,
+ ReadMemoryResponse,
+ Response,
+ RestartArgs,
+ Scope,
+ ScopesArgs,
+ SetBreakpointsArgs,
+ SetDataBreakpointsArgs,
+ SetExceptionBreakpointsArgs,
+ SetFunctionBreakpointsArgs,
+ SetInstructionBreakpointsArgs,
+ SetVariableArgs,
+ SetVariableResponse,
+ Source,
+ SourceBreakpoint,
+ StackFrame,
+ StackFrameFormat,
+ StackTraceArgs,
+ StepInArgs,
+ StepOutArgs,
+ SteppingGranularity,
+ StoppedEvent,
+ StoppedReason,
+ TerminatedEvent,
+ ThreadsArgs,
+ ValueFormat,
+ Variable,
+ VariablePresentationHint,
+ VariablesArgs,
+ WriteMemoryArgs,
+)
+from .session import PendingResponse, Session
+from .utils import DebugAdapter, SubProcessSpawner
+
+T = TypeVar("T")
+
+
+class ThreadContext:
+ """Lazy view of a debug adapter thread.
+
+ Thread ids do not have a limited lifetime, so this context is long-lived.
+ It can be reused after continue and stepXXX requests.
+ """
+
+ def __init__(self, thread_id: int, session: DAPTestSession):
+ self._thread_id: int = thread_id
+ self._session: DAPTestSession = session
+
+ @property
+ def thread_id(self) -> int:
+ return self._thread_id
+
+ def step_in(
+ self,
+ *,
+ targetId: Optional[int] = None,
+ granularity: SteppingGranularity = "statement",
+ ):
+ return self._session.step_in(
+ threadId=self.thread_id, targetId=targetId, granularity=granularity
+ )
+
+ def step_over(self, *, granularity: SteppingGranularity = "statement"):
+ return self._session.step_over(threadId=self.thread_id, granularity=granularity)
+
+ def step_out(self, *, granularity: SteppingGranularity = "statement"):
+ return self._session.step_out(threadId=self.thread_id, granularity=granularity)
+
+ def top_frame(
+ self,
+ *,
+ format: Optional[StackFrameFormat] = None,
+ ) -> FrameContext:
+ return self.frames(levels=1, format=format)[0]
+
+ def frames(
+ self,
+ *,
+ startFrame: Optional[int] = None,
+ levels: Optional[int] = None,
+ format: Optional[StackFrameFormat] = None,
+ ) -> list[FrameContext]:
+ args = StackTraceArgs(
+ self._thread_id, startFrame=startFrame, levels=levels, format=format
+ )
+ response = self._session.send_request(args).result()
+ generation = self._session._current_stop_generation()
+ return [
+ FrameContext(frame, self._session, generation)
+ for frame in response.body.stackFrames
+ ]
+
+
+class FrameContext:
+ """Lazy view of a stack frame. Valid only within its stop generation."""
+
+ def __init__(self, frame: StackFrame, session: DAPTestSession, generation: int):
+ self._frame = frame
+ self._session = session
+ self._generation = generation
+ self._scopes: Optional[list[ScopeContext]] = None
+
+ @property
+ def frame(self) -> StackFrame:
+ self._session._check_stop_generation(self._generation, self)
+ return self._frame
+
+ @property
+ def id(self) -> int:
+ return self.frame.id
+
+ @property
+ def name(self) -> str:
+ return self.frame.name
+
+ def __dir__(self):
+ # Hide the property fields that may call 'ScopesRequest' from the debugger.
+ # The python debugger will hang because it is waiting for a response
+ # when viewing the FrameContext.
+ hidden = {"locals", "globals", "registers", "scopes"}
+ return (attr for attr in super().__dir__() if attr not in hidden)
+
+ def source_and_line(self) -> tuple[str, int]:
+ frame = self.frame
+ assert frame.source is not None
+ assert frame.source.path is not None
+ assert frame.line is not None
+ return frame.source.path, frame.line
+
+ def scopes(self) -> list[ScopeContext]:
+ self._session._check_stop_generation(self._generation, self)
+ if self._scopes is None:
+ scope_args = ScopesArgs(frameId=self._frame.id)
+ response = self._session.send_request(scope_args).result()
+ self._scopes = [
+ ScopeContext(scope, self._session, self._generation)
+ for scope in response.body.scopes
+ ]
+ return self._scopes
+
+ def scope(self, name: str) -> ScopeContext:
+ scopes = self.scopes()
+ for scope in scopes:
+ if scope.scope.name == name:
+ return scope
+ scope_names = [scope.scope.name for scope in scopes]
+ self._session.test_case.fail(
+ f"scope '{name}' not in frame scopes: {scope_names}"
+ )
+
+ @property
+ def locals(self) -> ScopeContext:
+ return self.scope("Locals")
+
+ @property
+ def globals(self) -> ScopeContext:
+ return self.scope("Globals")
+
+ @property
+ def registers(self) -> ScopeContext:
+ return self.scope("Registers")
+
+ def evaluate(
+ self,
+ expression: str,
+ *,
+ context: Optional[EvaluateContext] = None,
+ format: Optional[ValueFormat] = None,
+ ):
+ """Evaluates `expression` in this frame's context."""
+ self._session._check_stop_generation(self._generation, self)
+ return self._session.evaluate(
+ expression, frameId=self._frame.id, context=context, format=format
+ )
+
+ def disassemble(self):
+ self._session._check_stop_generation(self._generation, self)
+
+ mem_ref = self._frame.instructionPointerReference
+ if mem_ref is None:
+ self._session.test_case.fail(
+ f"expects 'instructionPointerReference' for frame {self.frame}"
+ )
+ return self._session.disassemble(
+ mem_ref, instructionOffset=0, instructionCount=100
+ )
+
+
+class _VariableContainer:
+ """Shared dict-like behaviour for contexts that hold a variablesReference.
+
+ The optional `_value_format` is passed into every child-fetching
+ `variables` request in the container.
+ A child `VariableContext` inherits its parent's format,
+ so walking `locals.with_format(hex)["pt"]["x"]` keeps hex formatting all
+ the way down without the caller repeating it at each step.
+ """
+
+ _session: DAPTestSession
+ _generation: int
+ _value_format: Optional[ValueFormat] = None
+
+ def _fetch_variables(
+ self,
+ variables_reference: int,
+ *,
+ filter: Optional[Literal["indexed", "named"]] = None,
+ start: Optional[int] = None,
+ count: Optional[int] = None,
+ ) -> list[VariableContext]:
+ self._session._check_stop_generation(self._generation, self)
+ variables = self._session.get_variables(
+ variables_reference,
+ filter=filter,
+ start=start,
+ count=count,
+ format=self._value_format,
+ )
+ return [
+ VariableContext(var, self._session, self._generation, self._value_format)
+ for var in variables
+ ]
+
+ def page(
+ self,
+ *,
+ filter: Optional[Literal["indexed", "named"]] = None,
+ start: Optional[int] = None,
+ count: Optional[int] = None,
+ ) -> list[VariableContext]:
+ """Fetch a subset of children with paging/filter arguments.
+
+ Inherits the container's value format.
+ """
+ return self._fetch_variables(
+ self._container_reference(),
+ filter=filter,
+ start=start,
+ count=count,
+ )
+
+ def set(
+ self, name: str, value, *, is_hex: bool = False
+ ) -> SetVariableResponse | ErrorResponse:
+ """Sends a `setVariable` request for a named child."""
+ self._session._check_stop_generation(self._generation, self)
+ return self._session.set_variable(
+ name, value, variablesReference=self._container_reference(), is_hex=is_hex
+ )
+
+ def _container_reference(self) -> int:
+ raise NotImplementedError
+
+ def _by_name(self) -> dict[str, VariableContext]:
+ return {child.name: child for child in self._children()}
+
+ def _children(self) -> list[VariableContext]:
+ raise NotImplementedError
+
+ def __getitem__(self, name: str) -> VariableContext:
+ by_name = self._by_name()
+ try:
+ return by_name[name]
+ except KeyError:
+ self._session.test_case.fail(
+ f"'{name}' not found in {self}, has: {list(by_name)}"
+ )
+
+ def __contains__(self, name: object) -> bool:
+ return name in self._by_name()
+
+ def __iter__(self) -> Iterator[VariableContext]:
+ return iter(self._children())
+
+ def __len__(self) -> int:
+ return len(self._children())
+
+ def __str__(self) -> str:
+ return type(self).__name__
+
+
+class ScopeContext(_VariableContainer):
+ """Lazy view of a scope's variables. Valid only within its stop generation."""
+
+ def __init__(
+ self,
+ scope: Scope,
+ session: DAPTestSession,
+ generation: int,
+ value_format: Optional[ValueFormat] = None,
+ ):
+ self._scope = scope
+ self._session = session
+ self._generation = generation
+ self._value_format = value_format
+
+ @property
+ def scope(self) -> Scope:
+ self._session._check_stop_generation(self._generation, self)
+ return self._scope
+
+ @property
+ def name(self) -> str:
+ return self.scope.name
+
+ @property
+ def variablesReference(self) -> int:
+ return self.scope.variablesReference
+
+ def variables(self) -> list[VariableContext]:
+ return self._fetch_variables(self._scope.variablesReference)
+
+ def with_format(self, *, is_hex: bool = False) -> ScopeContext:
+ """Return a new ScopeContext that applies the ValueFormat."""
+ value_format = ValueFormat(hex=True) if is_hex else None
+ return ScopeContext(self._scope, self._session, self._generation, value_format)
+
+ def _container_reference(self) -> int:
+ return self._scope.variablesReference
+
+ def _children(self) -> list[VariableContext]:
+ return self.variables()
+
+ def __str__(self) -> str:
+ return f"scope '{self._scope.name}'"
+
+
+class VariableContext(_VariableContainer):
+ """Lazy view of a variable and (optionally) its children.
+
+ Valid only within its' stop generation.
+ """
+
+ def __init__(
+ self,
+ variable: Variable,
+ session: DAPTestSession,
+ generation: int,
+ value_format: Optional[ValueFormat] = None,
+ ):
+ self._variable = variable
+ self._session = session
+ self._generation = generation
+ self._value_format = value_format
+
+ @property
+ def variable(self) -> Variable:
+ self._session._check_stop_generation(self._generation, self)
+ return self._variable
+
+ @property
+ def name(self) -> str:
+ return self._variable.name
+
+ @property
+ def value(self) -> str:
+ return self._variable.value
+
+ @property
+ def value_as_int(self) -> int:
+ return self._variable.value_as_int
+
+ @property
+ def type(self) -> Optional[str]:
+ return self._variable.type
+
+ @property
+ def variablesReference(self) -> int:
+ return self._variable.variablesReference
+
+ @property
+ def memoryReference(self) -> Optional[str]:
+ return self._variable.memoryReference
+
+ @property
+ def indexedVariables(self) -> Optional[int]:
+ return self._variable.indexedVariables
+
+ @property
+ def namedVariables(self) -> Optional[int]:
+ return self._variable.namedVariables
+
+ @property
+ def has_children(self) -> bool:
+ return self._variable.variablesReference > 0
+
+ def children(self) -> list[VariableContext]:
+ if not self.has_children:
+ self._session.test_case.fail(
+ f"variable '{self._variable.name}' has no children"
+ )
+ return self._fetch_variables(self._variable.variablesReference)
+
+ def with_format(self, *, is_hex: bool = False) -> VariableContext:
+ """Return a new VariableContext that applies the ValueFormat"""
+ value_format = ValueFormat(hex=True) if is_hex else None
+ return VariableContext(
+ self._variable, self._session, self._generation, value_format
+ )
+
+ def _container_reference(self) -> int:
+ return self._variable.variablesReference
+
+ def _children(self) -> list[VariableContext]:
+ return self.children()
+
+ def __str__(self) -> str:
+ return f"variable '{self._variable.name}'"
+
+
+ at dataclass(frozen=True)
+class CapturedOutput:
+ seen_texts: str
+ """The accumulated text until the terminator (included if it was an OutputEvent)."""
+ event: Event
+ """The event that terminated the collection"""
+
+
+class _ConfigureContext:
+ """Handles the initial launch sequence handshake.
+
+ Orchestrates the full DAP initialization sequence:
+ On enter:
+ - Request and respond to the `Initialize` command.
+ - Send launch/attach request.
+ - Wait for InitializedEvent.
+
+ In between:
+ The test can set breakpoints or perform any check it needs do.
+
+ On exit:
+ 4. Set and verify the pending source and function breakpoints.
+ 5. Request and response to configurationDone.
+ 6. Wait for ProcessEvent and launch/attach response.
+
+ Example:
+
+ >>> session.configure(LaunchArgs(program="a.out")) as ctx:
+ ... session.resolve_function_breakpoints(["do_foo"])
+ >>> session.wait_for_breakpoint(after=ctx.process_event)
+ """
+
+ def __init__(
+ self,
+ session: "DAPTestSession",
+ config: LaunchArgs | AttachArgs,
+ ):
+ self._session = session
+ self._config = config
+ self._pending_request: Optional[PendingResponse[EmptyBodyResponse]] = None
+
+ def __enter__(self) -> "_ConfigureContext":
+ session = self._session
+ session.test_case.assertFalse(
+ session._state.is_initialized, "session already started."
+ )
+ self.init_response = session.initialize_sequence(session.initialize_args)
+ self._pending_request = session.send_request(self._config)
+ session.wait_for_event(InitializedEvent, after=self.init_response)
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ if exc_type is not None:
+ return False
+
+ session = self._session
+ assert self.init_response is not None
+ assert self._pending_request is not None
+
+ session.verify_configuration_done()
+ self.process_event = session.wait_for_event(
+ ProcessEvent, after=self.init_response
+ )
+
+ self.launch_or_attach_response = self._pending_request.result()
+ return False
+
+
+ at dataclass
+class _ExpectCommon:
+ """Shared fields used by both `ExpectVar` and `ExpectEval`.
+ Any attribute set to `None` will be skipped when checking.
+ """
+
+ type: Optional[str] = None
+ variables_reference: Optional[int] = None
+ named_variables: Optional[int] = None
+ indexed_variables: Optional[int] = None
+ read_only: bool = False
+
+ has_var_ref: Optional[bool] = None
+ has_mem_ref: Optional[bool] = None
+ has_loc_ref: Optional[bool] = None
+ has_indexed_variables: Optional[bool] = None
+
+ # Checks on the Expression's result or Variable's value.
+ startswith: Optional[str] = None
+ matches: Optional[str] = None # regex applied to .value/.result
+ # When set, fetch children via `variablesReference` and verify recursively.
+ children: Optional[dict[str, "ExpectVar"]] = None
+
+
+ at dataclass
+class ExpectVar(_ExpectCommon):
+ """Typed expectation for a `Variable`.
+ Any attribute set to `None` will be skipped when checking.
+ """
+
+ value: Optional[str] = None
+ evaluate_name: Optional[str] = None
+ has_evaluate_name: Optional[bool] = None
+
+
+ at dataclass
+class ExpectEval(_ExpectCommon):
+ """Typed expectation for an `EvaluateResponse.body`
+ Any attribute set to `None` will be skipped when checking.
+ """
+
+ result: Optional[str] = None
+
+
+class DAPTestSession(Session):
+ """A `Session` bound to a `unittest.TestCase`.
+
+ Adds repeating patterns for sending, receiving and verifying protocol messages.
+ such as breakpoints, threads and evaluate.
+ """
+
+ def __init__(
+ self,
+ test_case: unittest.TestCase,
+ test_dir: Path,
+ adapter: DebugAdapter,
+ message_timeout: float,
+ process_spawner: SubProcessSpawner,
+ logger: logging.Logger,
+ ):
+ super().__init__(test_dir, adapter, message_timeout, process_spawner, logger)
+ self.test_case = test_case
+
+ # The default features that lldb supports.
+ # When a test does not explicitly set initialize args this is used.
+ self._init_args = InitializeArgs(
+ adapterID="lldb-native",
+ clientID="vscode",
+ columnsStartAt1=True,
+ linesStartAt1=True,
+ locale="en-us",
+ pathFormat="path",
+ supportsRunInTerminalRequest=True,
+ supportsVariablePaging=True,
+ supportsVariableType=True,
+ supportsStartDebuggingRequest=True,
+ supportsProgressReporting=True,
+ supportsInvalidatedEvent=True,
+ supportsMemoryEvent=True,
+ )
+
+ def update_initialize_args(self, **kwargs):
+ self.test_case.assertFalse(
+ self._state.is_initialized,
+ "session already initialized cannot update initialize args.",
+ )
+
+ self._init_args = dataclasses.replace(self._init_args, **kwargs)
+
+ @property
+ def initialize_args(self):
+ return dataclasses.replace(self._init_args)
+
+ def launch(self, config: LaunchArgs) -> ProcessEvent:
+ """Drives the full launch handshake
+
+ (initialize -> launch -> configurationDone -> ProcessEvent).
+ """
+ with self.configure(config) as ctx:
+ pass
+ return ctx.process_event
+
+ def attach(self, config: AttachArgs) -> ProcessEvent:
+ """Drives the full attach handshake
+
+ (initialize -> attach -> configurationDone -> ProcessEvent).
+ """
+ with self.configure(config) as ctx:
+ pass
+ return ctx.process_event
+
+ def configure(self, config: LaunchArgs | AttachArgs) -> _ConfigureContext:
+ """Return a context that scopes the launch sequence.
+
+ process_event and launch_or_attach are only a valid after
+ leaving the context block.
+
+ Example:
+ >>> with session.configure(LaunchArgs(program)) as ctx:
+ ... session.set_source_breakpoints("main.cpp", [10, 25])
+ >>> process_event = ctx.process_event
+ >>> response = ctx.launch_or_attach_response
+ """
+ return _ConfigureContext(self, config)
+
+ def initialize_sequence(self, initialize_args: InitializeArgs):
+ init_response = self.send_request(initialize_args).result()
+ return init_response
+
+ def initialize_and_launch(self, args: LaunchArgs | AttachArgs):
+ self.initialize_sequence(self.initialize_args)
+ return self.send_request(args)
+
+ def configuration_done(self) -> PendingResponse[EmptyBodyResponse]:
+ # Wait for initialized event.
+ self.ensure_initialized()
+ # And then send configuration done.
+ return self.send_request(ConfigurationDoneArgs())
+
+ def verify_configuration_done(self, expected_success: bool = True):
+ response = self.configuration_done().result_or_error()
+ if expected_success:
+ self.test_case.assertEqual(
+ response.success, True, f"got error response: {response}."
+ )
+ self.test_case.assertIsInstance(response, EmptyBodyResponse)
+
+ # In VSCode, immediately following 'configurationDone', a
+ # 'threads' request is made to get the initial set of threads,
+ # specifically the main threads id and name.
+ # We issue the threads request to mimic this pattern and prevent
+ # tests that use threads to have the wrong result.
+ self.send_request(ThreadsArgs()).result()
+ else:
+ self.test_case.assertEqual(response.success, False)
+ self.test_case.assertIsInstance(response, ErrorResponse)
+ return response
+
+ def set_source_breakpoints(
+ self, source_path: str, breakpoints: list[int] | list[SourceBreakpoint]
+ ):
+ self.ensure_initialized()
+ # Convert the deprecated lines field to SourceBreakpoints.
+ s_breakpoints: list[SourceBreakpoint] = []
+ for bp in breakpoints:
+ if isinstance(bp, int):
+ s_breakpoints.append(SourceBreakpoint(bp))
+ elif isinstance(bp, SourceBreakpoint):
+ s_breakpoints.append(bp)
+ else:
+ self.test_case.fail(
+ "breakpoints must only contain ints or SourceBreakpoints."
+ f" got '{bp}' of type '{type(bp)}'."
+ )
+
+ source = Source.create(path=source_path)
+ bp_args = SetBreakpointsArgs(source, breakpoints=s_breakpoints)
+ return self.send_request(bp_args).result()
+
+ def set_assembly_breakpoints(
+ self,
+ source: Source | int,
+ breakpoints: list[int] | list[SourceBreakpoint],
+ ):
+ """Set breakpoints in an assembly source.
+
+ `source` can be either a `sourceReference` int (for a source produced
+ in the current session) or a full `Source` object (for replaying a
+ persisted assembly source across sessions).
+ """
+ self.ensure_initialized()
+ if isinstance(source, int):
+ source = Source(sourceReference=source)
+
+ s_breakpoints: list[SourceBreakpoint] = []
+ for bp in breakpoints:
+ if isinstance(bp, int):
+ s_breakpoints.append(SourceBreakpoint(bp))
+ elif isinstance(bp, SourceBreakpoint):
+ s_breakpoints.append(bp)
+ else:
+ self.test_case.fail(
+ "breakpoints must only contain ints or SourceBreakpoints."
+ f" got '{bp}' of type '{type(bp)}'."
+ )
+
+ bp_args = SetBreakpointsArgs(source=source, breakpoints=s_breakpoints)
+ return self.send_request(bp_args).result()
+
+ def set_function_breakpoints(
+ self,
+ function_names: list[str],
+ condition: Optional[str] = None,
+ hitCondition: Optional[str] = None,
+ ):
+ f_breakpoints = [
+ FunctionBreakpoint(name, condition=condition, hitCondition=hitCondition)
+ for name in function_names
+ ]
+ response = self.send_request(SetFunctionBreakpointsArgs(f_breakpoints)).result()
+ return response
+
+ def set_exception_breakpoints(
+ self,
+ *,
+ filters: list[str],
+ filterOptions: Optional[list[ExceptionFilterOptions]] = None,
+ exceptionOptions: Optional[list[ExceptionOptions]] = None,
+ ):
+ args = SetExceptionBreakpointsArgs(
+ filters=filters,
+ filterOptions=filterOptions,
+ exceptionOptions=exceptionOptions,
+ )
+ response = self.send_request(args).result()
+ return response
+
+ def set_variable(
+ self, name: str, value, *, variablesReference: int, is_hex: bool = False
+ ) -> SetVariableResponse | ErrorResponse:
+ last_response = self.last_response()
+ handle = self.send_request(
+ SetVariableArgs(
+ variablesReference=variablesReference,
+ name=name,
+ value=str(value),
+ format=ValueFormat(hex=True) if is_hex else None,
+ )
+ )
+ response = handle.result_or_error()
+ if isinstance(response, SetVariableResponse):
+ invalidated_event = self.wait_for_invalidated(after=last_response)
+ invalidated_areas = invalidated_event.body.areas
+ self.test_case.assertEqual(["variables"], invalidated_areas)
+
+ memory_event = self.wait_for_memory(after=last_response)
+ self.test_case.assertEqual(
+ memory_event.body.memoryReference, response.body.memoryReference
+ )
+ return response
+
+ @staticmethod
+ def breakpoints_to_ids(breakpoints: list[Breakpoint]):
+ ids: list[int] = []
+ for bp in breakpoints:
+ assert bp.id is not None, f"id is None for breakpoint: {bp}"
+ ids.append(bp.id)
+ return ids
+
+ def resolve_source_breakpoints(
+ self, source_path: str, breakpoints: list[int] | list[SourceBreakpoint]
+ ) -> list[int]:
+ last_response = self.last_response()
+ bp_response = self.set_source_breakpoints(source_path, breakpoints)
+ r_breakpoints = bp_response.body.breakpoints
+
+ all_verified = all(bp.verified for bp in r_breakpoints)
+ if not all_verified:
+ self.wait_until_all_breakpoints_verified(r_breakpoints, after=last_response)
+
+ self.test_case.assertEqual(
+ len(breakpoints),
+ len(r_breakpoints),
+ "expect correct number of breakpoints.",
+ )
+ return self.breakpoints_to_ids(r_breakpoints)
+
+ def resolve_function_breakpoints(
+ self,
+ function_names: list[str],
+ condition: Optional[str] = None,
+ hitCondition: Optional[str] = None,
+ ) -> list[int]:
+ """Sets breakpoints by function name given an array of function names
+ and returns an array of strings containing the breakpoint IDs
+ ("1", "2") for each breakpoint that was set.
+ """
+ last_response = self.last_response()
+ response = self.set_function_breakpoints(
+ function_names, condition, hitCondition
+ )
+ breakpoints = response.body.breakpoints
+
+ all_verified = all(bp.verified for bp in breakpoints)
+ if not all_verified:
+ self.wait_until_all_breakpoints_verified(breakpoints, after=last_response)
+
+ return self.breakpoints_to_ids(breakpoints)
+
+ def set_data_breakpoints(self, breakpoints: list[DataBreakpoint]):
+ args = SetDataBreakpointsArgs(breakpoints=breakpoints)
+ return self.send_request(args).result()
+
+ def set_instruction_breakpoints(self, memory_references: list[str]):
+ breakpoints = [InstructionBreakpoint(ref) for ref in memory_references]
+ return self.send_request(SetInstructionBreakpointsArgs(breakpoints)).result()
+
+ def set_breakpoint_locations(
+ self,
+ file_path: str,
+ line: int,
+ column: Optional[int] = None,
+ endLine: Optional[int] = None,
+ endColumn: Optional[int] = None,
+ ):
+ _, name = os.path.split(file_path)
+ bp_loc_args = BreakpointLocationsArgs(
+ Source.create(name=name, path=file_path),
+ line=line,
+ column=column,
+ endLine=endLine,
+ endColumn=endColumn,
+ )
+ return self.send_request(bp_loc_args).result()
+
+ def data_breakpoint_info(self, name: str, variablesReference: int, frameId: int):
+ info_args = DataBreakpointInfoArgs(
+ name=name, variablesReference=variablesReference, frameId=frameId
+ )
+ return self.send_request(info_args).result()
+
+ def data_breakpoint_info_as_address(self, address: str, size: int):
+ info_args = DataBreakpointInfoArgs(name=address, bytes=size, asAddress=True)
+ return self.send_request(info_args).result()
+
+ def step_in(
+ self,
+ threadId: Optional[int] = None,
+ *,
+ targetId: Optional[int] = None,
+ granularity: SteppingGranularity = "statement",
+ ):
+ thread_id = threadId or self.stopped_thread_id
+ self.test_case.assertIsNotNone(thread_id)
+ stepin_args = StepInArgs(
+ threadId=thread_id, targetId=targetId, granularity=granularity
+ )
+ response = self.send_request(stepin_args).result()
+ stop_event = self.verify_stopped(StoppedReason.STEP, after=response)
+ return stop_event
+
+ def step_over(
+ self,
+ threadId: Optional[int] = None,
+ *,
+ granularity: SteppingGranularity = "statement",
+ ):
+ thread_id = threadId or self.stopped_thread_id
+ self.test_case.assertIsNotNone(thread_id)
+ next_args = NextArgs(threadId=thread_id, granularity=granularity)
+ response = self.send_request(next_args).result()
+ stop_event = self.verify_stopped(StoppedReason.STEP, after=response)
+ return stop_event
+
+ def step_out(
+ self,
+ threadId: Optional[int] = None,
+ *,
+ granularity: SteppingGranularity = "statement",
+ ):
+ thread_id = threadId or self.stopped_thread_id
+ self.test_case.assertIsNotNone(thread_id)
+ step_out_args = StepOutArgs(threadId=thread_id, granularity=granularity)
+ response = self.send_request(step_out_args).result()
+
+ stop_event = self.verify_stopped(StoppedReason.STEP, after=response)
+ return stop_event
+
+ def wait_until_any_breakpoint_hit(
+ self, breakpoint_ids: list[int], *, after: Event | Response
+ ) -> StoppedEvent:
+ """Wait for the process to send `StoppedEvents` and verify we stopped for
+ any breakpoint in breakpoint_ids the event or response.
+ """
+
+ self.test_case.assertGreater(len(breakpoint_ids), 0, "empty breakpoint ids.")
+ is_ids_int = all(isinstance(id, int) for id in breakpoint_ids)
+ self.test_case.assertTrue(is_ids_int, "all breakpoint_ids must be integers.")
+
+ breakpoint_stop_reasons = [
+ StoppedReason.BREAKPOINT,
+ StoppedReason.INSTRUCTION_BREAKPOINT,
+ StoppedReason.FUNCTION_BREAKPOINT,
+ StoppedReason.DATA_BREAKPOINT,
+ ]
+
+ def event_hit_id_in_breakpoint_ids(event: StoppedEvent):
+ hit_ids = event.body.hitBreakpointIds or []
+ for hit_id in hit_ids:
+ if hit_id in breakpoint_ids:
+ return True
+
+ return False
+
+ event = self.wait_for_stopped(
+ breakpoint_stop_reasons,
+ after=after,
+ until=event_hit_id_in_breakpoint_ids,
+ timeout_msg=f"waiting for any breakpoint id in {breakpoint_ids} after seq {after.seq}.",
+ )
+
+ return event
+
+ def wait_until_all_breakpoints_verified(
+ self, breakpoints: list[int] | list[Breakpoint], *, after: Event | Response
+ ):
+ """Wait for the process to send breakpoint events and verify we hit
+ all 'ids' in 'breakpoints' after the event or response.
+ """
+ self.test_case.assertTrue(len(breakpoints) > 0, "empty list of breakpoints.")
+
+ id_to_verify: dict[int, bool] = {}
+ for bp in breakpoints:
+ if isinstance(bp, int):
+ id_to_verify[bp] = False
+ elif isinstance(bp, Breakpoint):
+ assert bp.id is not None
+ id_to_verify[bp.id] = bp.verified
+ else:
+ self.test_case.fail(
+ f"expected list of type 'Breakpoint' or 'int' got '{breakpoints}'"
+ )
+
+ bp_ids = list(id_to_verify.keys())
+
+ def all_breakpoints_verified(evt: BreakpointEvent):
+ event_bp = evt.body.breakpoint
+ if event_bp.id is None:
+ return False
+
+ if event_bp.id not in bp_ids:
+ return False
+
+ id_to_verify[event_bp.id] = event_bp.verified
+ all_verified = all(verified for verified in id_to_verify.values())
+ return all_verified
+
+ timeout_msg = f"waiting for all breakpoint ids {bp_ids} to be verified"
+ last_breakpoint_event = self.wait_for_event(
+ BreakpointEvent,
+ after=after,
+ until=all_breakpoints_verified,
+ timeout_msg=timeout_msg,
+ )
+ return last_breakpoint_event
+
+ def wait_for_stopped_or_exited(
+ self,
+ *,
+ after: Event | Response,
+ until: Optional[Callable[[StoppedEvent | ExitedEvent], bool]] = None,
+ timeout_msg: Optional[str] = None,
+ ) -> StoppedEvent | ExitedEvent:
+ event = self.wait_for_any_event(
+ (StoppedEvent, ExitedEvent),
+ after=after,
+ until=until,
+ timeout_msg=timeout_msg,
+ )
+ return event
+
+ def wait_for_stopped(
+ self,
+ matching_any: Optional[Sequence[StoppedReason]] = None,
+ *,
+ after: Event | Response,
+ until: Optional[Callable[[StoppedEvent], bool]] = None,
+ timeout_msg: Optional[str] = None,
+ ) -> StoppedEvent:
+ """
+ Wait for a process to stop, optionally filtered by stop reason and custom condition.
+
+ Blocks until a StoppedEvent is received after the specified event. If matching_any
+ is provided, only stops with those reasons are accepted. The until callback allows
+ additional condition checking. If an ExitedEvent is encountered, wait_for terminates.
+
+ Args:
+ matching_any: Filter by specific stop reasons.
+ after: Event or Response to start waiting after.
+ until: Optional callback for additional filtering.
+ timeout_msg: Custom timeout error message.
+ """
+ if matching_any:
+ self.test_case.assertGreater(
+ len(matching_any), 0, "expected at least one stop reason."
+ )
+
+ def matches_any_reason_until(event: StoppedEvent | ExitedEvent):
+ # Break early for exited event.
+ # We cannot hit a stopped event after the process exited.
+ if isinstance(event, ExitedEvent):
+ return True
+
+ # Match any of the stopped reasons.
+ if matching_any and event.body.reason not in matching_any:
+ return False
+
+ if until:
+ return until(event)
+
+ return True
+
+ event = self.wait_for_stopped_or_exited(
+ after=after, until=matches_any_reason_until, timeout_msg=timeout_msg
+ )
+
+ self.test_case.assertIsInstance(event, StoppedEvent, f"after seq: {after.seq}")
+ self.test_case.assertEqual(event.event, EventName.STOPPED)
+ return cast(StoppedEvent, event)
+
+ def wait_for_exited(self, *, after: Event | Response) -> ExitedEvent:
+ """
+ Wait for a process to exit.
+
+ Blocks until an ExitedEvent is received following the given event or response.
+ Raises an error if a StoppedEvent is encountered, as a stopped process
+ cannot subsequently exit.
+ """
+ event = self.wait_for_stopped_or_exited(after=after)
+ self.test_case.assertIsInstance(event, ExitedEvent)
+ self.test_case.assertEqual(event.event, "exited", "expected ExitedEvent'")
+ return cast(ExitedEvent, event)
+
+ def verify_next_module_event(
+ self,
+ reason: Optional[ModuleReason] = None,
+ *,
+ after: Event | Response,
+ ):
+ event = self.wait_for_module_event(after=after)
+ event_body = event.body
+ if reason is not None:
+ msg = f"module event reason does not match, got {event_body}."
+ self.test_case.assertEqual(event_body.reason, reason, msg)
+ return event
+
+ def wait_for_module_event(
+ self,
+ *,
+ after: Event | Response,
+ until: Optional[Callable[[ModuleEvent], bool]] = None,
+ ):
+ return self.wait_for_event(ModuleEvent, after=after, until=until)
+
+ def wait_for_terminated(self, *, after: Event | Response):
+ return self.wait_for_event(TerminatedEvent, after=after)
+
+ def wait_for_invalidated(self, *, after: Event | Response):
+ return self.wait_for_event(InvalidatedEvent, after=after)
+
+ def wait_for_memory(self, *, after: Event | Response):
+ return self.wait_for_event(MemoryEvent, after=after)
+
+ def do_continue(self):
+ self.ensure_initialized()
+ return self.send_request(ContinueArgs()).result()
+
+ def continue_to_exit(self, exitCode: int = 0) -> ExitedEvent:
+ continue_response = self.do_continue()
+ return self.verify_process_exited(after=continue_response, exitCode=exitCode)
+
+ def continue_to_breakpoint(self, breakpoint_id: int):
+ return self.continue_to_any_breakpoint([breakpoint_id])
+
+ def continue_to_any_breakpoint(self, breakpoint_ids: list[int]):
+ response = self.do_continue()
+ event = self.wait_until_any_breakpoint_hit(breakpoint_ids, after=response)
+ return event
+
+ def continue_to_exception_breakpoint(
+ self, *, expected_description: str, expected_text: Optional[str] = None
+ ):
+ continue_response = self.do_continue()
+ return self.verify_stopped_on_exception(
+ expected_description=expected_description,
+ expected_text=expected_text,
+ after=continue_response,
+ )
+
+ def continue_to_next_stop(self, *, exp_reason: Optional[StoppedReason] = None):
+ """Continue execution and wait for stopped event"""
+ response = self.do_continue()
+ if exp_reason is None:
+ return self.wait_for_stopped(after=response)
+
+ return self.verify_stopped(exp_reason, after=response)
+
+ def evaluate(
+ self,
+ expression: str,
+ *,
+ frameId: Optional[int] = None,
+ context: Optional[EvaluateContext] = None,
+ format: Optional[ValueFormat] = None,
+ ):
+ """Send an `evaluate` request and expects a successful response and result."""
+ pending = self.do_evaluate(
+ expression, frameId=frameId, context=context, format=format
+ )
+ response = pending.result(
+ f"failed to evaluate `{expression}` with {context=}, {frameId=}."
+ )
+
+ result = response.body.result
+ self.test_case.assertFalse(result.startswith("error:"), f'"{result}"')
+ return response.body
+
+ def do_evaluate(
+ self,
+ expression: str,
+ *,
+ frameId: Optional[int] = None,
+ context: Optional[EvaluateContext] = None,
+ format: Optional[ValueFormat] = None,
+ ) -> PendingResponse[EvaluateResponse]:
+ """Send an `evaluate` request without failing on error."""
+ eval_args = EvaluateArgs(
+ expression=expression, frameId=frameId, context=context, format=format
+ )
+ return self.send_request(eval_args)
+
+ def collect_output(
+ self,
+ category: OutputCategory,
+ *,
+ until: str | Event,
+ after: Event | Response,
+ timeout_msg: Optional[str] = None,
+ ) -> CapturedOutput:
+ """Collect OutputEvents in `category` until a terminator is reached.
+
+ Args:
+ until:
+ If a `str`, return once this substring is seen in an
+ OutputEvent's text in `category`.
+ If an `Event` instance, return when that exact event (matched by seq) is reached —
+ useful after a prior `wait_for_event` call gave you the
+ synchronization point. The terminator's own text is not
+ accumulated.
+ category: The output category to collect.
+ Returns:
+ CapturedOutput. `event` is the terminator — the matching
+ OutputEvent (string form) or the supplied event (instance form).
+ """
+ if not isinstance(until, (str, Event)):
+ self.test_case.fail(f"expected str or Event for until got {type(until)}")
+
+ seen_outputs: list[str] = []
+
+ if isinstance(until, str):
+ self.test_case.assertTrue(until, "expected a non-empty pattern")
+ pattern = until
+
+ def matches_pattern(event: OutputEvent):
+ if event.body.category != category:
+ return False
+ seen_outputs.append(event.body.output)
+ return pattern in event.body.output
+
+ timeout_msg = f"{timeout_msg}\n\t" if timeout_msg else ""
+ timeout_msg += f"collecting output category '{category}' until found pattern: '{pattern}'."
+
+ event = self.wait_for_event(
+ OutputEvent,
+ after=after,
+ until=matches_pattern,
+ timeout_msg=timeout_msg,
+ )
+ # Sanity check.
+ self.test_case.assertIsInstance(event, OutputEvent)
+ return CapturedOutput(seen_texts="".join(seen_outputs), event=event)
+
+ until_event = until
+ self.test_case.assertGreater(
+ until_event.seq,
+ after.seq,
+ f"'{until_event}' event must be later than '{after}'.",
+ )
+
+ def matches_until_event(event: Event):
+ if isinstance(event, OutputEvent) and event.body.category == category:
+ seen_outputs.append(event.body.output)
+ return event.seq >= until_event.seq
+
+ timeout_msg = f"{timeout_msg}\n\t" if timeout_msg else ""
+ timeout_msg += f"collecting output category '{category}' up to event seq "
+ timeout_msg += f"{until_event.seq} ({type(until_event).__name__})."
+
+ # The until_event is already in history, so this never blocks beyond
+ # reading the events that are already recorded.
+ self.wait_for_event(
+ Event,
+ after=after,
+ until=matches_until_event,
+ timeout_msg=timeout_msg,
+ )
+ return CapturedOutput(seen_texts="".join(seen_outputs), event=until_event)
+
+ def collect_console(self, *, until: str | Event, after: Event | Response):
+ return self.collect_output(OutputCategory.CONSOLE, until=until, after=after)
+
+ def collect_stdout(self, *, until: str | Event, after: Event | Response):
+ return self.collect_output(OutputCategory.STDOUT, until=until, after=after)
+
+ def collect_important(self, *, until: str | Event, after: Event | Response):
+ return self.collect_output(OutputCategory.IMPORTANT, until=until, after=after)
+
+ def verify_stopped(
+ self,
+ reasons: StoppedReason | Iterable[StoppedReason],
+ *,
+ after: Event | Response,
+ expected_ids: Iterable[int] | int | None = None,
+ expected_description: Optional[str] = None,
+ expected_text: Optional[str] = None,
+ ) -> StoppedEvent:
+ """Wait for a `StoppedEvent` and assert the body matches every supplied
+ expectation."""
+ if isinstance(reasons, StoppedReason):
+ reasons = [reasons]
+
+ timeout_msg = f"waiting for 'StoppedEvent' matching reasons: {reasons}"
+ stopped_event = self.wait_for_stopped(after=after, timeout_msg=timeout_msg)
+
+ body = stopped_event.body
+ test_case = self.test_case
+ msg = f"for stopped event {body}."
+ test_case.assertIn(body.reason, reasons, msg)
+
+ if expected_ids is not None:
+ if isinstance(expected_ids, int):
+ expected_ids = [expected_ids]
+
+ hit_bp_ids = body.hitBreakpointIds or []
+ for expected_id in expected_ids:
+ msg = f"expected breakpoint_id '{expected_id}' not in {hit_bp_ids=}."
+ test_case.assertIn(expected_id, hit_bp_ids, msg)
+
+ if expected_description is not None:
+ test_case.assertIsNotNone(
+ body.description, f"stopped event missing description {body}."
+ )
+ description = cast(str, body.description)
+ test_case.assertRegex(description, expected_description, msg)
+
+ if expected_text is not None:
+ test_case.assertIsNotNone(body.text, f"stopped event missing text {body}.")
+ text = cast(str, body.text)
+ test_case.assertRegex(text, expected_text, msg)
+
+ return stopped_event
+
+ def verify_stopped_on_breakpoint(
+ self,
+ expected_ids: list[int] | int | None = None,
+ *,
+ after: Event | Response,
+ ) -> StoppedEvent:
+ reasons = [
+ StoppedReason.BREAKPOINT,
+ StoppedReason.DATA_BREAKPOINT,
+ StoppedReason.FUNCTION_BREAKPOINT,
+ StoppedReason.INSTRUCTION_BREAKPOINT,
+ ]
+ return self.verify_stopped(reasons, after=after, expected_ids=expected_ids)
+
+ def verify_stopped_on_entry(self, *, after: Event | Response) -> StoppedEvent:
+ return self.verify_stopped(StoppedReason.ENTRY, after=after)
+
+ def verify_stopped_on_exception(
+ self,
+ *,
+ expected_description: Optional[str] = None,
+ expected_text: Optional[str] = None,
+ after: Event | Response,
+ ) -> StoppedEvent:
+ """Wait for the debuggee to stop with reason `exception` and verify
+ the description matches `expected_description` (regex) and, if given,
+ the text matches `expected_text` (regex).
+ """
+ return self.verify_stopped(
+ StoppedReason.EXCEPTION,
+ after=after,
+ expected_description=expected_description,
+ expected_text=expected_text,
+ )
+
+ def verify_multiple_breakpoints_hit(
+ self, breakpoint_ids: list[int], *, after: Event | Response
+ ) -> StoppedEvent:
+ """Wait for the session receive a 'StoppedEvent' and verify we stopped for
+ any breakpoint in breakpoint_ids the event or response.
+ """
+
+ self.test_case.assertGreater(len(breakpoint_ids), 0, "empty breakpoint ids.")
+ is_ids_int = all(isinstance(id, int) for id in breakpoint_ids)
+ self.test_case.assertTrue(is_ids_int, "all breakpoint_ids must be integers.")
+
+ event = self.verify_stopped_on_breakpoint(after=after)
+ hit_ids = event.body.hitBreakpointIds or []
+ if set(breakpoint_ids).issubset(hit_ids):
+ return event
+
+ self.test_case.fail(f"{breakpoint_ids=} missed in {event=} {after=}.")
+
+ def verify_process_exited(
+ self, *, after: Event | Response | None = None, exitCode: int = 0
+ ):
+ if after:
+ event = self.wait_for_exited(after=after)
+ else:
+ event = self.wait_for_earliest_event(ExitedEvent)
+
+ fail_msg = f"expect '{exitCode=}' for '{event.body}'"
+ self.test_case.assertEqual(event.body.exitCode, exitCode, fail_msg)
+
+ self.verify_reverse_process_exited(exitCode)
+ return event
+
+ def verify_commands(self, flavor: str, output: str, commands: list[str]):
+ self.test_case.assertTrue(output and len(output) > 0, "expect console output")
+ lines = output.splitlines()
+ prefix = "(lldb) "
+
+ for cmd in commands:
+ cmd_stripped = cmd.lstrip("!?")
+ for line in lines:
+ if line.startswith(prefix) and cmd_stripped in line:
+ break
+ else:
+ self.test_case.fail(
+ f"Command '{flavor}' - '{cmd}' not found in output: {output}."
+ )
+
+ def verify_location(self, locationReference: int, filename: str, line: int):
+ response = self.send_request(LocationsArgs(locationReference)).result()
+ path = response.body.source.path
+ self.test_case.assertIsNotNone(response.body.source.path)
+
+ msg = f"expect path '{path}' to end with '{filename}'."
+ self.test_case.assertTrue(str(path).endswith(filename), msg)
+ self.test_case.assertEqual(response.body.line, line)
+
+ def __verify_common(
+ self, actual: Variable | EvaluateResponse.Body, expected: _ExpectCommon
+ ):
+ """Verify the shared fields of `Variable` and `EvaluateResponse.Body`."""
+ if expected.type is not None:
+ msg = f"type mismatch for {actual}: {actual.type!r} != {expected.type!r}."
+ self.test_case.assertEqual(actual.type, expected.type, msg)
+
+ if expected.variables_reference is not None:
+ self.test_case.assertEqual(
+ actual.variablesReference,
+ expected.variables_reference,
+ f"variablesReference mismatch for {actual}.",
+ )
+
+ if expected.named_variables is not None:
+ self.test_case.assertEqual(
+ actual.namedVariables,
+ expected.named_variables,
+ f"namedVariables mismatch for {actual}.",
+ )
+
+ if expected.indexed_variables is not None:
+ self.test_case.assertEqual(
+ actual.indexedVariables,
+ expected.indexed_variables,
+ f"indexedVariables mismatch for {actual}.",
+ )
+
+ if expected.has_var_ref is not None:
+ has_var_ref = bool(actual.variablesReference)
+ fail_msg = f"has_var_ref mismatch for {actual=}, {expected=}."
+ self.test_case.assertEqual(has_var_ref, expected.has_var_ref, fail_msg)
+
+ if expected.has_mem_ref is not None:
+ has_mem_ref = actual.memoryReference is not None
+ fail_msg = f"has_mem_ref mismatch for {actual=}, {expected=}."
+ self.test_case.assertEqual(has_mem_ref, expected.has_mem_ref, fail_msg)
+
+ if expected.has_loc_ref is not None:
+ has_loc_ref = actual.valueLocationReference is not None
+ fail_msg = f"has_loc_ref mismatch for {actual=}, {expected=}."
+ self.test_case.assertEqual(has_loc_ref, expected.has_loc_ref, fail_msg)
+
+ if expected.has_indexed_variables is not None:
+ has_idx_vars = actual.indexedVariables is not None
+ fail_msg = f"has_index_variables mismatch for {actual=}, {expected=}."
+ self.test_case.assertEqual(
+ has_idx_vars, expected.has_indexed_variables, fail_msg
+ )
+
+ hint = actual.presentationHint or VariablePresentationHint()
+ attributes = hint.attributes or []
+ fail_msg = f"readOnly attribute mismatch for {actual=}."
+ if expected.read_only:
+ self.test_case.assertIn("readOnly", attributes, fail_msg)
+ else:
+ self.test_case.assertNotIn("readOnly", attributes, fail_msg)
+
+ if expected.children is not None:
+ var_ref = actual.variablesReference
+ self.test_case.assertTrue(
+ var_ref, f"children expected but no variablesReference for {actual=}."
+ )
+ children = self.get_variables(var_ref)
+ self.verify_variables(children, expected.children)
+
+ def verify_evaluate(
+ self,
+ eval_body: EvaluateResponse.Body,
+ expected: Optional[ExpectEval] = None,
+ /,
+ **expected_kwargs,
+ ):
+ """Verify an `EvaluateResponse.body`."""
+ if expected is not None and expected_kwargs:
+ self.test_case.fail("pass an ExpectEval OR its keyword fields, not both.")
+ if expected is None and not expected_kwargs:
+ self.test_case.fail("pass an ExpectEval or at least one keyword field.")
+
+ expected = expected or ExpectEval(**expected_kwargs)
+ eval_result = eval_body.result
+ if expected.result is not None:
+ self.test_case.assertEqual(
+ eval_result, expected.result, f"result mismatch for {eval_body}."
+ )
+
+ if expected.matches is not None:
+ self.test_case.assertRegex(eval_result, expected.matches)
+
+ if expected_prefix := expected.startswith:
+ self.test_case.assertTrue(
+ eval_result.startswith(expected_prefix),
+ f"'{eval_result!r}' does not start with '{expected_prefix!r}'.",
+ )
+
+ self.__verify_common(eval_body, expected)
+
+ def verify_variable(
+ self,
+ variable: Variable,
+ expected: Optional[ExpectVar] = None,
+ /,
+ **expected_kwargs,
+ ):
+ """Verify a Variable matches the expected `ExpectVar` ."""
+ if expected is not None and expected_kwargs:
+ self.test_case.fail("pass an ExpectVar or its keyword fields, not both.")
+ if expected is None and not expected_kwargs:
+ self.test_case.fail("pass an ExpectVar or at least one keyword field.")
+
+ expected = expected or ExpectVar(**expected_kwargs)
+ value = variable.value
+
+ if expected.value is not None:
+ fail_msg = f"value mismatch for {variable=}."
+ self.test_case.assertEqual(value, expected.value, fail_msg)
+
+ if expected.matches is not None:
+ fail_msg = f"value doesn't match pattern for '{variable}'."
+ self.test_case.assertRegex(variable.value, expected.matches, fail_msg)
+
+ if expected_prefix := expected.startswith:
+ fail_msg = f"{value!r} does not start with {expected_prefix!r}."
+ self.test_case.assertTrue(value.startswith(expected_prefix), fail_msg)
+
+ if evaluate_name := expected.evaluate_name:
+ fail_msg = f"evaluateName mismatch for {variable}."
+ self.test_case.assertEqual(variable.evaluateName, evaluate_name, fail_msg)
+
+ if expected.has_evaluate_name is not None:
+ has_evaluate_name = variable.evaluateName is not None
+ fail_msg = f"has_evaluate_name mismatch for {variable=}, {expected=}."
+ self.test_case.assertEqual(
+ has_evaluate_name, expected.has_evaluate_name, fail_msg
+ )
+
+ self.__verify_common(variable, expected)
+
+ def verify_variables(
+ self, variables: list[Variable], expected: dict[str, ExpectVar]
+ ):
+ """Verify each `Variable` in `variables` against its entry in `expected`."""
+ self.test_case.assertTrue(len(variables) >= 1, f"no variables to verify.")
+
+ for variable in variables:
+ if variable.name.startswith("std::"):
+ continue
+ self.test_case.assertIn(variable.name, expected)
+ self.verify_variable(variable, expected[variable.name])
+
+ def get_modules(
+ self, startModule: Optional[int] = None, moduleCount: Optional[int] = None
+ ):
+ args = ModulesArgs(startModule=startModule, moduleCount=moduleCount)
+ response = self.send_request(args).result()
+ modules_dict = {module.name: module for module in response.body.modules}
+ return modules_dict
+
+ def get_threads(self) -> list[ThreadContext]:
+ response = self.send_request(ThreadsArgs()).result()
+ threads = response.body.threads
+ t_threads = [ThreadContext(thread.id, self) for thread in threads]
+ return t_threads
+
+ def get_variables(
+ self,
+ variablesReference: int,
+ *,
+ filter: Optional[Literal["indexed", "named"]] = None,
+ start: Optional[int] = None,
+ count: Optional[int] = None,
+ format: Optional[ValueFormat] = None,
+ ) -> list[Variable]:
+ args = VariablesArgs(
+ variablesReference=variablesReference,
+ filter=filter,
+ start=start,
+ count=count,
+ format=format,
+ )
+ response = self.send_request(args).result()
+ return response.body.variables
+
+ def thread_context_from(self, thread_ref: int | StoppedEvent) -> ThreadContext:
+ if isinstance(thread_ref, StoppedEvent):
+ self.test_case.assertIsNotNone(thread_ref.body.threadId)
+ thread_id = cast(int, thread_ref.body.threadId)
+ elif isinstance(thread_ref, int):
+ thread_id = thread_ref
+ else:
+ self.test_case.fail(f"cannot get thread context from '{type(thread_ref)}'.")
+ return ThreadContext(thread_id, self)
+
+ def top_frame_from(self, thread_ref: int | StoppedEvent) -> FrameContext:
+ """Top FrameContext of the currently stopped thread."""
+ return self.thread_context_from(thread_ref).top_frame()
+
+ def get_completions(self, text: str, frameId: Optional[int]):
+ def code_units(input: str) -> int:
+ utf16_bytes = input.encode("utf-16-le")
+ # one UTF16 codeunit = 2 bytes.
+ return len(utf16_bytes) // 2
+
+ com_args = CompletionsArgs(
+ text=text, column=code_units(text) + 1, frameId=frameId
+ )
+ response = self.send_request(com_args).result()
+ return response.body.targets
+
+ def get_exception_info(self, threadId: int):
+ info_args = ExceptionInfoArgs(threadId=threadId)
+ response = self.send_request(info_args).result()
+ return response.body
+
+ def do_restart(self, arguments: LaunchArgs | AttachArgs | None = None):
+ restart_args = RestartArgs(arguments)
+ return self.send_request(restart_args).result()
+
+ def disassemble(
+ self,
+ memoryReference: str,
+ instructionOffset: int = -50,
+ instructionCount: int = 200,
+ resolveSymbols: bool = True,
+ ):
+ dis_args = DisassembleArgs(
+ memoryReference=memoryReference,
+ instructionOffset=instructionOffset,
+ instructionCount=instructionCount,
+ resolveSymbols=resolveSymbols,
+ )
+ return self.send_request(dis_args).result().body.instructions
+
+ def stack_trace(
+ self,
+ threadId: int,
+ *,
+ startFrame: Optional[int] = None,
+ levels: Optional[int] = None,
+ format: Optional[StackFrameFormat] = None,
+ ):
+ """Send a `stackTrace` request and wait for a response"""
+ args = StackTraceArgs(
+ threadId=threadId, startFrame=startFrame, levels=levels, format=format
+ )
+ return self.send_request(args).result()
+
+ def read_memory(
+ self, memoryReference: str, count: int, offset: Optional[int] = None
+ ) -> PendingResponse[ReadMemoryResponse]:
+ args = ReadMemoryArgs(
+ memoryReference=memoryReference, offset=offset, count=count
+ )
+ return self.send_request(args)
+
+ def write_memory(
+ self,
+ memoryReference: str,
+ value: int | str | bytes,
+ *,
+ offset: Optional[int] = None,
+ allowPartial: bool = False,
+ ):
+ """Send a `writeMemory` request.
+
+ Integer value is serialized as little-endian bytes,
+ `value` is Base64-encoded because the DAP protocol requires it.
+ """
+ if isinstance(value, int):
+ # (bit_length + 7 (rounding up to nearest byte)) // 8 = converts to bytes.
+ byte_length = (value.bit_length() + 7) // 8
+ val_bytes = value.to_bytes(byte_length, "little")
+ data = base64.b64encode(val_bytes).decode()
+ else:
+ data = base64.b64encode(str(value).encode()).decode()
+
+ before_request = self.last_response()
+ write_args = WriteMemoryArgs(
+ memoryReference=memoryReference,
+ data=data,
+ offset=offset,
+ allowPartial=allowPartial,
+ )
+ handle = self.send_request(write_args)
+ response = handle.result_or_error()
+
+ # Check we sent invalidated event.
+ if response.success and self.initialize_args.supportsInvalidatedEvent:
+ invalidated = self.wait_for_invalidated(after=before_request)
+ self.test_case.assertEqual(invalidated.body.areas, ["all"])
+ return response
+
+ def disconnect(
+ self, restart: Optional[bool] = None, terminateDebuggee: Optional[bool] = None
+ ):
+ args = DisconnectArgs(restart=restart, terminateDebuggee=terminateDebuggee)
+ response = self.send_request(args).result()
+ return response
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
new file mode 100644
index 0000000000000..9ef04b0c5ca90
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
@@ -0,0 +1,809 @@
+# FIXME: remove when LLDB_MINIMUM_PYTHON_VERSION > 3.8
+from __future__ import annotations
+
+import bisect
+import contextlib
+import itertools
+import json
+import os
+import socket
+import subprocess
+import threading
+import time
+from concurrent.futures import Future
+from dataclasses import asdict, dataclass, field, replace
+from pprint import pformat
+from typing import IO, Callable, Optional, Protocol, Tuple, Type, runtime_checkable
+
+from .dap_types import (
+ AnyEvent,
+ DAPError,
+ Event,
+ MessageType,
+ RawMessage,
+ Request,
+ Response,
+)
+
+
+# See lldbtest.Base.spawnSubprocess, which should help ensure any processes
+# created by the DAP client are terminated correctly when the test ends.
+class SubProcessSpawner(Protocol):
+ def __call__(
+ self,
+ executable: str,
+ args: list[str] | None = None,
+ extra_env: list[str] | None = None,
+ install_remote: bool = True,
+ **kwargs,
+ ) -> subprocess.Popen[bytes]: ...
+
+
+ at dataclass(frozen=True)
+class DebugAdapterOptions:
+ """The options passed when spawning the debug adapter."""
+
+ args: list[str] = field(default_factory=list)
+ env: dict[str, str] = field(default_factory=dict)
+ cwd: Optional[str] = None
+ pre_init_commands: Optional[list[str]] = None
+ log_file: Optional[str] = None
+ # sever_mode related options.
+ connection: Optional[str] = None
+ connection_timeout: Optional[int] = None
+
+ @property
+ def run_as_server(self):
+ return self.connection is not None
+
+ def clone(self, **kwargs) -> DebugAdapterOptions:
+ """Returns a copy with the given fields overridden."""
+ return replace(self, **kwargs)
+
+ def __repr__(self):
+ return f"{type(self).__name__}: {pformat(asdict(self), indent=2, compact=True)}"
+
+ def __post_init__(self):
+ # Check connection options is not in args.
+ if "--connection" in self.args or "--connection-timeout" in self.args:
+ raise DAPError(
+ f"--connection in adapter options, use the connection field instead {self}"
+ )
+
+ if not self.run_as_server and self.connection_timeout is not None:
+ raise DAPError(
+ f"'--connection-timeout' option can only be used when a connection is specified: {self}"
+ )
+
+
+class DebugAdapter:
+ """Spawns and owns the lifetime of lldb-dap binary"""
+
+ _listening_uri: Optional[str]
+
+ def __init__(self, executable: str, opts: DebugAdapterOptions):
+ self.executable = executable
+ self._connection_count = 0
+ self._is_server = opts.run_as_server
+
+ # Setup the process args.
+ process_args = [self.executable]
+ process_args.extend(opts.args)
+
+ if pre_init_commands := opts.pre_init_commands:
+ for command in pre_init_commands:
+ process_args.extend(["--pre-init-command", command])
+
+ # Verify we are using the correct args in stdio or server mode.
+ if opts.run_as_server:
+ process_args.extend(["--connection", opts.connection]) # type: ignore
+ if opts.connection_timeout:
+ connection_timeout = str(opts.connection_timeout)
+ process_args.extend(["--connection-timeout", connection_timeout])
+
+ # Setup process environment.
+ process_env = os.environ.copy()
+ process_env.update(opts.env)
+ if log_file := opts.log_file:
+ process_env["LLDBDAP_LOG"] = log_file
+
+ self._process = subprocess.Popen(
+ process_args,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=process_env,
+ cwd=opts.cwd,
+ )
+ assert self.is_alive, "expected running process"
+
+ if self.is_server:
+ self._listening_uri = self._read_listening_uri()
+ else:
+ self._listening_uri = None
+
+ def create_connection(self) -> DAPConnection:
+ if self.is_server:
+ assert self._listening_uri is not None
+ transport = _SocketTransport(uri=self._listening_uri)
+ else:
+ if self._connection_count > 0:
+ raise DAPError("Cannot create multiple connections in stdio mode")
+ transport = _StdioTransport(self._process)
+
+ count = self._connection_count
+ connection_id = f"conn{count}" if self.is_server else "stdio"
+ self._connection_count += 1
+ return DAPConnection(connection_id, transport)
+
+ @property
+ def is_server(self):
+ return self._is_server
+
+ @property
+ def is_alive(self):
+ return self._process.poll() is None
+
+ @property
+ def process(self):
+ return self._process
+
+ def kill(self):
+ self._process.terminate()
+ try:
+ self._process.wait(timeout=2.0)
+ except subprocess.TimeoutExpired:
+ self._process.kill()
+
+ def _read_listening_uri(self) -> str:
+ # lldb-dap will print the listening address once the listener is
+ # made to stdout. The listener is formatted like
+ # `connection://host:port` or `unix-connection:///path`.
+ expected_prefix = "Listening for: "
+ process_stdout = self._process.stdout
+ if process_stdout is None:
+ raise AttributeError("expected the process stdout to be a PIPE")
+
+ out = process_stdout.readline().decode()
+ if not out:
+ # Check if there is a message in stderr.
+ err = ""
+ with contextlib.suppress(Exception):
+ if process_stderr := self.process.stderr:
+ err = process_stderr.read().decode()
+ raise EOFError(
+ f"Unexpected End of file for process {self.process.args},\n"
+ f"process stderr: {err}"
+ )
+
+ if not out.startswith(expected_prefix):
+ raise ValueError(
+ "lldb-dap failed to print listening address, "
+ f"expected '{expected_prefix}', got '{out}'"
+ )
+
+ # FIXME: use `str.removeprefix` when LLDB_MINIMUM_PYTHON_VERSION > 3.8
+ out = out[len(expected_prefix) :]
+
+ # If the listener expanded into multiple addresses, use the first.
+ uri = out.rstrip("\r\n").split(",", 1)[0]
+ return uri
+
+
+class EventHistory:
+ """Thread-safe event log that tests block against to observe the adapter.
+
+ Every event the debug adapter sends is recorded here by the read
+ thread, in the order it arrived. Tests don't read the log directly,
+ they call one of the `wait_for_*` methods, which block until a matching
+ event has been recorded.
+
+ Args:
+ timeout: Default timeout in seconds for `wait_for_*` functions.
+
+ Example:
+ Wait for a stop after stepping, without racing the adapter.
+
+ >>> step_resp = session.step_in(thread_id=1)
+ >>> # History will only check for events after the step_response sequence.
+ >>> stopped = history.wait_for_event(StoppedEvent, after=step_resp)
+
+ Wait for any of several events (either is an acceptable outcome).
+ >>> end = history.wait_for_any_event((StoppedEvent, TerminatedEvent), after=continue_resp)
+
+ Narrow with a predicate.
+
+ >>> hit = history.wait_for_event(
+ ... StoppedEvent,
+ ... after=launch_resp,
+ ... until=lambda e: e.body.reason == "breakpoint",
+ ... )
+
+ Find the first Initialized event from the start of the history.
+ >>> init_event = history.wait_for_earliest_event(InitializedEvent)
+ """
+
+ def __init__(self, timeout: float):
+ self._sequences: list[int] = []
+ self._events: list[Event] = []
+ self._new_event_condition = threading.Condition()
+ self._timeout: float = timeout
+
+ self._is_closed: bool = False
+ self._closed_reason: Optional[Exception] = None
+
+ @property
+ def is_closed(self):
+ with self._new_event_condition:
+ return self._is_closed
+
+ def close(self, reason: Optional[Exception] = None):
+ """Close the history and wake all pending waiters.
+
+ After closing, `record` raises `DAPError` and any in-flight
+ `wait_for_*` call raises `DAPError` instead of timing out. This
+ is called when the adapter disconnects or the session ends so
+ tests do not block for the full default timeout.
+
+ Args:
+ reason: Optional exception describing why the history was
+ closed. When set, it is included in the error raised by
+ waiters so they can see the underlying cause.
+ """
+ with self._new_event_condition:
+ if self._is_closed:
+ raise DAPError(
+ f"history already closed with exception {self._closed_reason}"
+ f"trying to close again with {reason}."
+ )
+ self._is_closed = True
+ self._closed_reason = reason
+ self._new_event_condition.notify_all()
+
+ def record(self, new_event: Event):
+ """Record an event in the history.
+
+ Enforces recording event in sequential order.
+ Raises:
+ DAPError: If the history has been closed or If the new event
+ seq is not greater than the last recorded event's `seq`.
+ """
+ new_seq: int = new_event.seq
+ with self._new_event_condition:
+ if self._is_closed:
+ raise DAPError(
+ "Cannot record in EventHistory: session is closed."
+ ) from self._closed_reason
+
+ if len(self._sequences) > 0:
+ # History must be sequential.
+ last_seen_seq = self._sequences[-1]
+ if new_seq <= last_seen_seq:
+ raise DAPError(
+ f"event: '{new_event.event}' seq '{new_seq}' is older than last event: "
+ f"'{self._events[-1].event}' seq: '{last_seen_seq}'"
+ )
+
+ self._sequences.append(new_seq)
+ self._events.append(new_event)
+
+ # Sanity check.
+ assert len(self._sequences) == len(self._events)
+ self._new_event_condition.notify_all()
+
+ def wait_for_earliest_event(
+ self,
+ event_type: Type[AnyEvent],
+ *,
+ until: Optional[Callable[[AnyEvent], bool]] = None,
+ timeout: Optional[float] = None,
+ timeout_msg: Optional[str] = None,
+ ) -> AnyEvent:
+ """Wait for the earliest event of `event_type` in the history.
+
+ Searches from the beginning of the log (`seq` 0), so already-received
+ events count. Use this when a test wants the first event of a given
+ kind regardless of when it arrived.
+
+ Raises the same exceptions as `wait_for_event`.
+ """
+ assert issubclass(event_type, Event)
+
+ event_types = tuple((event_type,))
+ return self.__wait_for_any_event(
+ event_types,
+ after_seq=0,
+ until=until,
+ timeout=timeout,
+ timeout_msg=timeout_msg,
+ )
+
+ def wait_for_event(
+ self,
+ event_type: Type[AnyEvent],
+ *,
+ until: Optional[Callable[[AnyEvent], bool]] = None,
+ after: Event | Response,
+ timeout: Optional[float] = None,
+ timeout_msg: Optional[str] = None,
+ ) -> AnyEvent:
+ """Wait for the next event of `event_type` after a given message.
+
+ Search from "after some prior message" avoids races where
+ the event has already been observed: a test can capture a response
+ or event, run some action, and then wait for the *next* event of a
+ given kind without matching against anything already in the log.
+
+ Args:
+ event_type: Event subclass to match.
+ until: Optional predicate applied to each candidate. Only
+ events for which `until(event)` is true are accepted.
+ after: The prior event or response. Only events whose `seq`
+ is strictly greater are considered.
+ timeout: Override the history's default timeout, in seconds.
+ timeout_msg: Extra context appended to the `TimeoutError`
+ message if the wait times out.
+
+ Returns:
+ The first matching event after `after`.
+
+ Raises:
+ TimeoutError: If no matching event arrives within `timeout`.
+ DAPError: If the history is closed before a match is found.
+ """
+ assert issubclass(event_type, Event)
+
+ event_types = tuple((event_type,))
+ return self.wait_for_any_event(
+ event_types,
+ after=after,
+ until=until,
+ timeout=timeout,
+ timeout_msg=timeout_msg,
+ )
+
+ def wait_for_any_event(
+ self,
+ event_types: Tuple[Type[AnyEvent], ...],
+ *,
+ until: Optional[Callable[[AnyEvent], bool]] = None,
+ after: Event | Response,
+ timeout: Optional[float] = None,
+ timeout_msg: Optional[str] = None,
+ ):
+ """Wait for the next event matching any of several types.
+
+ Same semantics as `wait_for_event`, but the returned event may be
+ an instance of any of the given `event_types`. Useful when a test
+ is expecting the first of two different events.
+ """
+ assert after.type in (
+ MessageType.EVENT,
+ MessageType.RESPONSE,
+ ), f"expects instance of 'Event' or 'Response' got {after}."
+ return self.__wait_for_any_event(
+ event_types,
+ after_seq=after.seq,
+ until=until,
+ timeout=timeout,
+ timeout_msg=timeout_msg,
+ )
+
+ def __wait_for_any_event(
+ self,
+ event_types: Tuple[Type[AnyEvent], ...],
+ *,
+ after_seq: int,
+ until: Optional[Callable[[AnyEvent], bool]] = None,
+ timeout: Optional[float] = None,
+ timeout_msg: Optional[str] = None,
+ ):
+ assert after_seq >= 0, "response or event sequence must be greater than 0."
+ assert isinstance(event_types, tuple), "expected a tuple of events."
+ assert len(event_types) > 0, "expected at least one event to wait for."
+
+ def make_error_msg(is_timeout: bool = True):
+ event_names = [x.__name__ for x in event_types]
+ prefix = f"Timed out after {timeout}s" if is_timeout else "Error while"
+ err_msg = f"{prefix} waiting for any event that matches: {event_names}"
+ err_msg += f" after sequence: {after_seq}."
+
+ if timeout_msg:
+ err_msg += f"\n\t{timeout_msg}."
+
+ with self._new_event_condition:
+ last_event = self._events[-1] if self._events else None
+ err_msg += f"\n\tlast seen event: {last_event}."
+ return err_msg
+
+ def is_event_and_matches_condition(evt: Event):
+ if not isinstance(evt, event_types):
+ return False
+
+ if until is None:
+ return True
+
+ matches = until(evt)
+ return matches
+
+ timeout = timeout or self._timeout
+ try:
+ event = self.__wait_until(
+ is_event_and_matches_condition, after_seq=after_seq, timeout=timeout
+ )
+ except DAPError as err:
+ # Add extra context to the error.
+ err.args = (f"{err.args[0]}\n\t{make_error_msg(False)}", *err.args[1:])
+ raise
+
+ if event is None:
+ raise TimeoutError(make_error_msg())
+
+ # Sanity check.
+ assert isinstance(event, event_types)
+ return event
+
+ def __wait_until(
+ self,
+ matches_condition: Callable[[Event], bool],
+ *,
+ after_seq: int,
+ timeout: float,
+ ):
+ """Waits until the `matches_condition` returns true for an exiting
+ event or an incoming event. If the history is closed during the wait,
+ raise a DAPError."""
+
+ end_time = time.monotonic() + timeout
+ start_idx = 0
+
+ with self._new_event_condition:
+ while True:
+ seq_len = len(self._sequences)
+ idx = bisect.bisect_right(self._sequences, after_seq, lo=start_idx)
+
+ # Scan forward until we find a matching type.
+ for event in itertools.islice(self._events, idx, seq_len):
+ if matches_condition(event):
+ return event
+ start_idx = seq_len
+
+ if self._is_closed: # Can no longer receive new messages.
+ reason = self._closed_reason
+ last_evt = self._events[-1] if self._events else None
+ raise DAPError.history_closed(reason, last_evt) from reason
+
+ remaining_time = end_time - time.monotonic()
+ if remaining_time <= 0:
+ return None
+ self._new_event_condition.wait(remaining_time)
+
+
+def redirect_stream(
+ in_stream: IO[bytes], out_stream: IO[str], thread_name: str
+) -> threading.Thread:
+ """
+ Creates a new thread that redirects stream from `in_stream` to
+ `out_stream`. We use this for the 'runInTerminal' process to send stdio
+ to the session's output.
+
+ Returns a thread that redirects the stream.
+ """
+
+ def read_loop(in_stream: IO[bytes], out_stream: IO[str]):
+ with contextlib.suppress(OSError, ValueError): # Nothing to report.
+ while True:
+ chunk = in_stream.read(4096)
+ if not chunk:
+ break
+
+ out_stream.write(chunk.decode(errors="replace"))
+ out_stream.flush()
+
+ thread_name = f"redirect_{thread_name}"
+ redirect_thread = threading.Thread(
+ target=read_loop,
+ name=thread_name,
+ args=[in_stream, out_stream],
+ daemon=True,
+ )
+ redirect_thread.start()
+
+ return redirect_thread
+
+
+ at runtime_checkable
+class Transport(Protocol):
+ """Interface representing a bidirectional transport.
+
+ Implementations:
+ `_StdioTransport`: speaks to the adapter using a subprocess's stdin/stdout.
+ Used when the adapter is spawned as a child process.
+ `_SocketTransport`: speaks to the adapter using socket. Used when the
+ adapter is already running and exposes connection URI.
+ """
+
+ def write(self, data: bytes): ...
+
+ def read(self, n: int) -> bytes: ...
+
+ def readline(self) -> bytes: ...
+
+ def close(self):
+ """Close the transport.
+
+ Buffered data will be flushed and transport closed.
+ """
+ ...
+
+ @property
+ def is_alive(self) -> bool:
+ """Whether send or receive bytes through the transport."""
+ ...
+
+
+ at dataclass(frozen=True)
+class MessageHandler:
+ on_response: Callable[[RawMessage], None]
+ on_event: Callable[[RawMessage], None]
+ on_reverse_request: Callable[[RawMessage], None]
+ on_close: Optional[Callable[[Optional[Exception]], None]] = lambda _: None
+
+
+class DAPConnection:
+ """Manages the connection between a debug session and a debug adapter.
+
+ Encodes and decodes messages using the DAP protocol, mapping them to
+ dictionaries representing DAP types. Handles bidirectional communication
+ between the session and the adapter, including error handling for
+ failures from the debug adapter.
+ """
+
+ def __init__(self, connection_id: str, transport: Transport):
+ assert isinstance(transport, Transport)
+ self.id: str = connection_id
+ self._transport = transport
+
+ # A request that's been sent and is awaiting its response.
+ self._in_flight_requests: dict[int, tuple[RawMessage, Future[RawMessage]]] = {}
+ self._in_flight_lock = threading.Lock()
+ self._received_messages: list[RawMessage] = []
+
+ # Event to sync when the Connection start listening for messages.
+ self._is_ready = threading.Event()
+ self._is_ready.clear()
+
+ def start(self, handler: MessageHandler):
+ self._is_ready.set()
+ self._read_loop(handler)
+
+ def stop(self):
+ if self._transport.is_alive:
+ self._transport.close()
+
+ @staticmethod
+ def encode_message(message: dict):
+ content = json.dumps(message, separators=(",", ":"))
+ header = f"Content-Length: {len(content)}\r\n\r\n"
+ data = f"{header}{content}".encode("utf-8")
+ return data
+
+ def send_request(self, request: Request) -> Future[RawMessage]:
+ seq = request.seq
+ response_future: Future[RawMessage] = Future()
+ request_dict = request.to_dict()
+ with self._in_flight_lock:
+ self._in_flight_requests[seq] = (request_dict, response_future)
+ self.send_message(request_dict)
+ return response_future
+
+ def send_message(self, message: dict):
+ assert self.is_alive(), f"'{type(self).__name__}' is not running"
+ data = DAPConnection.encode_message(message)
+ self._transport.write(data)
+
+ def is_alive(self):
+ return self._transport.is_alive
+
+ def wait_until_alive(self, timeout: float):
+ return self._is_ready.wait(timeout)
+
+ def _read_loop(self, handler: MessageHandler):
+ error = None
+ try:
+ while self.is_alive():
+ message = self._read_message()
+ if not message:
+ break
+
+ self._received_messages.append(message)
+ self._on_message(message, handler)
+
+ except Exception as e:
+ error = e
+ finally:
+ # Reject any unresolved requests so the test thread don't wait the
+ # full timeout when the adapter exits or gets killed.
+ with self._in_flight_lock:
+ pending_futures = [f for _, f in self._in_flight_requests.values()]
+
+ resp_error = error or DAPError("DAP connection closed before response.")
+ for future in pending_futures:
+ if not future.done():
+ future.set_exception(resp_error)
+
+ with contextlib.suppress(Exception):
+ self.stop()
+ if on_close := handler.on_close:
+ on_close(error)
+
+ def _on_message(self, message: RawMessage, handler: MessageHandler):
+ msg_type = message.get("type")
+ if msg_type == "response":
+ request_seq = message["request_seq"]
+ with self._in_flight_lock:
+ in_flight = self._in_flight_requests.pop(request_seq, None)
+ if in_flight is not None:
+ request, response_future = in_flight
+ if request["command"] == message["command"]:
+ response_future.set_result(message)
+ else:
+ response_future.set_exception(
+ ValueError(
+ f"command mismatch in response"
+ f"{request['command']} != {message['command']}"
+ )
+ )
+ handler.on_response(message)
+
+ elif msg_type == "event":
+ handler.on_event(message)
+
+ elif msg_type == "request":
+ handler.on_reverse_request(message)
+
+ else:
+ raise DAPError(f"Unknown message type: {msg_type}")
+
+ def _read_message(self):
+ HEADER_TERMINATOR = b"\r\n\r\n"
+ CONTENT_LEN_PREFIX = b"Content-Length: "
+ buffer = bytearray()
+
+ while True:
+ chunk = self._transport.readline()
+ if not chunk:
+ if buffer:
+ raise EOFError(f"unexpected EOF when parsing header: {buffer}")
+ else:
+ return None
+ buffer += chunk
+
+ header_end = buffer.find(HEADER_TERMINATOR)
+ if header_end == -1:
+ continue
+ header = buffer[:header_end]
+
+ # Look for the Content-Length header.
+ content_length = 0
+ for line in header.split(b"\r\n"):
+ if line.startswith(CONTENT_LEN_PREFIX):
+ content_length = int(line[len(CONTENT_LEN_PREFIX) :])
+ break
+ else:
+ raise DAPError(f"Invalid header: {header}")
+
+ # Parse Content-Part.
+ message_start = header_end + len(HEADER_TERMINATOR)
+ buffer = buffer[message_start:]
+ while len(buffer) < content_length:
+ chunk = self._transport.read(content_length - len(buffer))
+ if not chunk:
+ raise EOFError(f"unexpected EOF when parsing message: {buffer}")
+ buffer += chunk
+
+ message = json.loads(buffer.decode("utf-8"))
+ return message
+
+
+class _StdioTransport:
+ def __init__(self, process: subprocess.Popen[bytes]):
+ self._process = process
+
+ stdin = self._process.stdin
+ stdout = self._process.stdout
+ assert stdin is not None
+ assert stdout is not None
+ self._stdin = stdin
+ self._stdout = stdout
+
+ self._is_closed = False
+ assert self.is_alive
+
+ def write(self, data: bytes):
+ self._stdin.write(data)
+ self._stdin.flush()
+
+ def read(self, n: int) -> bytes:
+ return self._stdout.read(n)
+
+ def readline(self):
+ return self._stdout.readline()
+
+ def close(self):
+ if self._is_closed:
+ return
+ self._is_closed = True
+
+ # Close stdin only. In Python3.8, closing stdout from main thread while the
+ # reader thread is inside BufferedReader.read() will crash the interpreter.
+ # The stdout cleanup happens via DebugAdapter.kill() when we kill the process
+ with contextlib.suppress(OSError, ValueError):
+ self._stdin.flush()
+ self._stdin.close()
+
+ @property
+ def is_alive(self):
+ if self._is_closed:
+ return False
+
+ # On Unix-like systems, attaching a debugger via ptrace temporarily reparents
+ # the target process. This breaks Python's internal waitpid() tracking, causing
+ # Popen.poll() to falsely return 0. Python assumes the missing child process
+ # has already terminated cleanly, which leads to inaccurate status checks.
+ return not self._stdout.closed and not self._stdin.closed
+
+
+class _SocketTransport:
+ def __init__(self, uri: str):
+ self.uri = uri
+ scheme, address = self.uri.split("://")
+ if scheme == "unix-connect": # unix-connect:///path
+ self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ self._socket.connect(address)
+ elif scheme == "connection": # connection://[host]:port
+ host, port = address.rsplit(":", 1)
+ # create_connection with try both ipv4 and ipv6.
+ self._socket = socket.create_connection((host.strip("[]"), int(port)))
+ else:
+ raise ValueError(f"invalid URI '{self.uri}' for socket")
+
+ self._reader = self._socket.makefile("rb", buffering=-1)
+ self._writer = self._socket.makefile("wb", buffering=-1)
+
+ self._is_closed = False
+ assert self.is_alive
+
+ def write(self, data: bytes):
+ self._writer.write(data)
+ self._writer.flush()
+
+ def read(self, n: int) -> bytes:
+ return self._reader.read(n)
+
+ def readline(self):
+ return self._reader.readline()
+
+ def close(self):
+ if self._is_closed:
+ return
+ self._is_closed = True
+
+ with contextlib.suppress(OSError, ValueError):
+ self._writer.flush()
+
+ if self._socket.fileno() != -1:
+ self._socket.shutdown(socket.SHUT_RDWR)
+
+ self._writer.close()
+ self._socket.close()
+
+ @property
+ def is_alive(self):
+ if self._is_closed:
+ return False
+ try:
+ _ = self._socket.getpeername()
+ except socket.error:
+ return False
+ return True
diff --git a/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py b/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py
index 8ba56b0bb27ca..b13b1d44b6d43 100644
--- a/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py
+++ b/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py
@@ -4,17 +4,23 @@
know about it.
"""
-import lldbdap_testcase
from lldbsuite.test.lldbtest import line_number
-from dap_server import Event
+from lldbsuite.test.tools.lldb_dap.dap_types import LaunchArgs, StackTraceArgs
+from lldbsuite.test.tools.lldb_dap.lldb_dap_testcase import DAPTestCaseBase
+from lldbsuite.test.tools.lldb_dap.session_helpers import DAPTestSession
-class TestDAP_invalidatedEvent(lldbdap_testcase.DAPTestCaseBase):
- def verify_top_frame_name(self, frame_name: str):
- all_frames = self.get_stackFrames()
+class TestDAP_invalidatedEvent(DAPTestCaseBase):
+ def verify_top_frame_name(
+ self, session: DAPTestSession, frame_name: str, thread_id: int
+ ):
+ response = session.stack_trace(thread_id)
+ all_frames = response.body.stackFrames
+
self.assertGreaterEqual(len(all_frames), 1, "Expected at least one frame.")
- top_frame_name = all_frames[0]["name"]
+ top_frame_name = all_frames[0].name
self.assertRegex(top_frame_name, f"{frame_name}.*")
+ return response
def test_invalidated_stack_area_event(self):
"""
@@ -22,34 +28,39 @@ def test_invalidated_stack_area_event(self):
The event is sent when the command `thread return <expr>` is sent by the user.
"""
other_source = "other.h"
- return_bp_line = line_number(other_source, "// thread return breakpoint")
-
program = self.getBuildArtifact("a.out")
- self.build_and_launch(program)
- self.set_source_breakpoints(other_source, [return_bp_line])
- self.continue_to_next_stop()
+ session = self.build_and_create_session()
+ with session.configure(LaunchArgs(program)) as ctx:
+ return_bp_line = line_number(other_source, "// thread return breakpoint")
+ session.resolve_source_breakpoints(other_source, [return_bp_line])
+
+ stopped_event = session.verify_stopped_on_breakpoint(after=ctx.process_event)
- self.verify_top_frame_name("add")
- thread_id = self.dap_server.get_thread_id()
- self.assertIsNotNone(thread_id, "Exepected a thread id.")
+ thread_id = self.expect_not_none(
+ stopped_event.body.threadId, "expected a thread id."
+ )
+ stack_response = self.verify_top_frame_name(session, "add", thread_id)
- # run thread return
+ # Run thread return.
thread_command = "thread return 20"
- eval_resp = self.dap_server.request_evaluate(thread_command, context="repl")
- self.assertTrue(eval_resp["success"], f"Failed to evaluate `{thread_command}`.")
-
- # wait for the invalidated stack event.
- stack_event = self.dap_server.wait_for_event(["invalidated"])
- self.assertIsNotNone(stack_event, "Expected an invalidated event.")
- event_body: Event = stack_event["body"]
- self.assertIn("stacks", event_body["areas"])
- self.assertIn("threadId", event_body.keys())
+ session.evaluate(thread_command, context="repl")
+
+ # Wait for the invalidated stack event.
+ invalid_event = session.wait_for_invalidated(after=stack_response)
+ self.assertIsNotNone(invalid_event, "Expected an invalidated event.")
+ event_body = invalid_event.body
+ self.assertIsNotNone(event_body.areas)
+ self.assertIn("stacks", event_body.areas or [])
+ self.assertIsNotNone(event_body.threadId)
self.assertEqual(
thread_id,
- event_body["threadId"],
+ event_body.threadId,
f"Expected the event from thread {thread_id}.",
)
- # confirm we are back at the main frame.
- self.verify_top_frame_name("main")
- self.continue_to_exit()
+ # Confirm we are back at the main frame.
+ thread_id = self.expect_not_none(
+ invalid_event.body.threadId, "expected a thread id."
+ )
+ self.verify_top_frame_name(session, "main", thread_id)
+ session.continue_to_exit()
diff --git a/lldb/test/API/tools/lldb-dap/launch/TestDAP_launch_basic.py b/lldb/test/API/tools/lldb-dap/launch/TestDAP_launch_basic.py
index 93ae5d05e9d6c..8cbad660651b6 100644
--- a/lldb/test/API/tools/lldb-dap/launch/TestDAP_launch_basic.py
+++ b/lldb/test/API/tools/lldb-dap/launch/TestDAP_launch_basic.py
@@ -2,11 +2,11 @@
Test lldb-dap launch request.
"""
-from lldbsuite.test.decorators import expectedFailureWindows
-import lldbdap_testcase
+from lldbsuite.test.tools.lldb_dap import lldb_dap_testcase
+from lldbsuite.test.tools.lldb_dap.dap_types import LaunchArgs
-class TestDAP_launch_basic(lldbdap_testcase.DAPTestCaseBase):
+class TestDAP_launch_basic(lldb_dap_testcase.DAPTestCaseBase):
"""
Tests the default launch of a simple program. No arguments,
environment, or anything else is specified.
@@ -14,10 +14,12 @@ class TestDAP_launch_basic(lldbdap_testcase.DAPTestCaseBase):
def test(self):
program = self.getBuildArtifact("a.out")
- self.build_and_launch(program)
- self.continue_to_exit()
- # Now get the STDOUT and verify our program argument is correct
- output = self.get_stdout()
+ session = self.build_and_create_session()
+ session.launch(LaunchArgs(program=program))
+ session.verify_process_exited()
+
+ # Now get the STDOUT and verify our program argument is correct.
+ output = session.get_stdout()
self.assertTrue(output and len(output) > 0, "expect program output")
lines = output.splitlines()
self.assertIn(program, lines[0], "make sure program path is in first argument")
diff --git a/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py
new file mode 100644
index 0000000000000..9af9e553c0446
--- /dev/null
+++ b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py
@@ -0,0 +1,82 @@
+import io
+import json
+from typing import List
+
+from lldbsuite.test.tools.lldb_dap.dap_types import RawMessage
+from lldbsuite.test.tools.lldb_dap.lldb_dap_testcase import DAPTestCaseBase
+from lldbsuite.test.tools.lldb_dap.utils import DAPConnection, MessageHandler, Transport
+
+
+class EchoClient:
+ def __init__(self):
+ self.seen_messages: List[RawMessage] = []
+
+ def on_message(self, msg: RawMessage):
+ self.seen_messages.append(msg)
+
+
+class TestDAPUtils_DAPConnection(DAPTestCaseBase):
+ def test_round_trip(self):
+ received_messages = self.get_sample_message()
+ transport = self.create_transport(received_messages)
+ connection = DAPConnection("conn0", transport)
+
+ client = EchoClient()
+ handler = MessageHandler(
+ on_event=client.on_message,
+ on_response=client.on_message,
+ on_reverse_request=client.on_message,
+ )
+ connection.start(handler)
+ expected_messages = client.seen_messages
+ self.assertEqual(len(received_messages), len(expected_messages))
+ for actual, expected in zip(received_messages, expected_messages):
+ self.assertEqual(actual, expected)
+
+ def test_encode_message_framing(self):
+ payload = {"type": "request", "seq": 1, "command": "initialize"}
+ data = DAPConnection.encode_message(payload)
+ header, _, body = data.partition(b"\r\n\r\n")
+ self.assertTrue(header.startswith(b"Content-Length:"))
+ content_length = int(header.split(b":")[1].strip())
+ self.assertEqual(content_length, len(body))
+ self.assertEqual(json.loads(body), payload)
+
+ def test_encode_message_round_trips(self):
+ payload = {"nested": {"a": 1, "b": [1, 2, 3]}}
+ _, _, body = DAPConnection.encode_message(payload).partition(b"\r\n\r\n")
+ self.assertEqual(json.loads(body), payload)
+
+ @staticmethod
+ def create_transport(data: List[RawMessage]) -> Transport:
+ class BinaryTransport:
+ def __init__(self, data: List[RawMessage]):
+ encoded_data = [
+ DAPConnection.encode_message(message) for message in data
+ ]
+ self._in = io.BytesIO(b"".join(encoded_data))
+ self._out: List[str] = []
+
+ def write(self, data: bytes):
+ self._out.append(data.decode("utf-8"))
+
+ def readline(self):
+ return self._in.readline()
+
+ def read(self, n: int) -> bytes:
+ return self._in.read(n)
+
+ def close(self):
+ self._in.close()
+
+ @property
+ def is_alive(self) -> bool:
+ return not self._in.closed
+
+ return BinaryTransport(data)
+
+ def get_sample_message(self) -> List[dict]:
+ message_log = self.getSourcePath("sample_message.json")
+ with open(message_log, "r") as file:
+ raw_events = json.loads(file.read())
+ return raw_events
diff --git a/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py
new file mode 100644
index 0000000000000..a5e69cf15dc83
--- /dev/null
+++ b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py
@@ -0,0 +1,252 @@
+import json
+import threading
+from typing import List, Union
+
+from lldbsuite.test.tools.lldb_dap.dap_types import (
+ CapabilitiesEvent,
+ DAPError,
+ Event,
+ ExitedEvent,
+ InitializedEvent,
+ ModuleEvent,
+ OutputEvent,
+ ProcessEvent,
+ ProgressEndEvent,
+ TerminatedEvent,
+)
+from lldbsuite.test.tools.lldb_dap.lldb_dap_testcase import DAPTestCaseBase
+from lldbsuite.test.tools.lldb_dap.utils import EventHistory
+
+
+class TestDAPUtils_EventHistory(DAPTestCaseBase):
+ """
+ This test suite verifies the behavior of EventHistory, including event ordering validation,
+ querying specific events, handling closed histories, applying custom filtering conditions,
+ and managing timeouts during asynchronous event additions.
+ """
+
+ def test_history_order(self):
+ """Test that events added to EventHistory are in sequential order"""
+
+ history = EventHistory(timeout=10)
+ events = [
+ {
+ "body": {"category": "console", "output": "Running preInitCommands:\n"},
+ "event": "output",
+ "seq": 2,
+ "type": "event",
+ },
+ {
+ "body": {
+ "category": "console",
+ "output": "(lldb) log enable lldb process",
+ },
+ "event": "output",
+ "seq": 1,
+ "type": "event",
+ },
+ ]
+ events = [Event.from_json(event) for event in events]
+
+ with self.assertRaises(AssertionError):
+ for event in events:
+ history.record(event)
+
+ def test_wait_for_X_event_on_history_closed(self):
+ """Tests `wait_for_first_event`, `wait_for_event` and `wait_for_any_event` when the history is closed.
+
+ It should return a DAPError (HistoryClosed) instead of waiting for new events if the
+ history is closed and the requested event does not already exist in the history.
+ """
+ history = EventHistory(timeout=10)
+ self.populate_history(history)
+
+ # We only intend on working on existing events.
+ # so we don't get a Timeout error.
+ history.close()
+
+ init_event = history.wait_for_earliest_event(InitializedEvent)
+ self.assertEqual(init_event.seq, 9)
+ self.assertIsInstance(init_event, InitializedEvent)
+ self.assertEqual(init_event.type, "event")
+
+ cap_event = history.wait_for_event(CapabilitiesEvent, after=init_event)
+ self.assertGreater(cap_event.seq, init_event.seq)
+ self.assertIsInstance(cap_event, CapabilitiesEvent)
+
+ # There is only one capabilitiesEvent in the history.
+ with self.assertRaises(DAPError):
+ history.wait_for_event(CapabilitiesEvent, after=cap_event)
+
+ # There is no progressEndEvent in history.
+ with self.assertRaises(DAPError):
+ history.wait_for_earliest_event(ProgressEndEvent)
+
+ # Must wait for at least one event.
+ with self.assertRaises(AssertionError):
+ history.wait_for_any_event((), after=cap_event)
+
+ # Test wait_for_any_event.
+ # Wait for_any_event should return the first seen event that in in the tuple
+ any_event = history.wait_for_any_event(
+ (ProcessEvent, OutputEvent, ModuleEvent, TerminatedEvent), after=cap_event
+ )
+ # We should see the output event first.
+ self.assertIsInstance(any_event, OutputEvent)
+
+ any_event = history.wait_for_any_event(
+ (ModuleEvent, TerminatedEvent), after=cap_event
+ )
+
+ # We should see the ModuleEvent event first.
+ self.assertIsInstance(any_event, ModuleEvent)
+
+ def test_wait_for_X_event_until_Y_condition(self):
+ """
+ Tests the `until` parameter in wait_for_XXXX methods.
+
+ Verifies that `wait_for_event` and `wait_for_any_event` continue scanning
+ through matching event types until the provided condition function returns True.
+ or throws an error.
+ """
+ history = EventHistory(timeout=10)
+ self.populate_history(history)
+ history.close()
+
+ start_event = history.wait_for_earliest_event(OutputEvent)
+ self.assertEqual(start_event.seq, 1)
+ self.assertIsInstance(start_event, OutputEvent)
+ self.assertEqual(start_event.body.output, "Running preInitCommands:\n")
+
+ # Test wait_for_event_until success.
+ def has_got_token(event: OutputEvent):
+ return "GOT TOKEN" in event.body.output
+
+ output_event = history.wait_for_event(
+ OutputEvent, after=start_event, until=has_got_token
+ )
+ self.assertEqual(output_event.seq, 44)
+ self.assertIsInstance(output_event, OutputEvent)
+ self.assertIn("GOT TOKEN", output_event.body.output)
+
+ def sdl3_module_loaded(event: ModuleEvent):
+ module_is_sdl = event.body.module.name == "sdl3"
+ is_new_module = event.body.reason == "new"
+
+ return module_is_sdl and is_new_module
+
+ # Test wait_for_event_until failure.
+ # There is no sdl3 module in the event history.
+ with self.assertRaises(DAPError):
+ history.wait_for_event(
+ ModuleEvent, after=start_event, until=sdl3_module_loaded
+ )
+
+ # Test wait_for_any_event.
+ def sdl3_module_loaded_or_process_exited(
+ event: Union[ModuleEvent, ExitedEvent],
+ ):
+ if isinstance(event, ModuleEvent):
+ return sdl3_module_loaded(event)
+
+ return True
+
+ seen_event = history.wait_for_any_event(
+ (ModuleEvent, ExitedEvent),
+ after=start_event,
+ until=sdl3_module_loaded_or_process_exited,
+ )
+
+ self.assertEqual(seen_event.seq, 47)
+ self.assertIsInstance(seen_event, ExitedEvent)
+ self.assertEqual(seen_event.body.exitCode, 0) # type: ignore
+
+ def test_wait_for_X_event_with_timeout(self):
+ """Tests the timeout functionality when waiting for events.
+
+ Verifies that a TimeoutError is raised if the expected event does not
+ arrive within the specified timeout. Also ensures that the wait methods
+ successfully block and retrieve events added asynchronously by another thread
+ before the timeout expires.
+ """
+ history = EventHistory(timeout=10)
+ self.populate_history(history)
+
+ delayed_append = threading.Event()
+ delayed_append.clear()
+
+ new_cap_events = [
+ Event.from_json(
+ {
+ "body": {"capabilities": {"supportsModulesRequest": True}},
+ "event": "capabilities",
+ "seq": 50,
+ "type": "event",
+ }
+ ),
+ Event.from_json(
+ {
+ "body": {"capabilities": {"supportsRestartRequest": True}},
+ "event": "capabilities",
+ "seq": 52,
+ "type": "event",
+ }
+ ),
+ ]
+
+ def add_delayed_events(events: List[Event]):
+ delayed_append.wait()
+ for event in events:
+ history.record(event)
+
+ event_thread = threading.Thread(
+ target=add_delayed_events, args=[new_cap_events]
+ )
+ event_thread.start()
+
+ try:
+ exited_event = history.wait_for_earliest_event(ExitedEvent)
+ self.assertEqual(exited_event.seq, 47)
+ self.assertEqual(exited_event.body.exitCode, 0)
+
+ def supports_restart_request(event: CapabilitiesEvent):
+ return event.body.capabilities.supportsRestartRequest == True
+
+ # No new CapabilitiesEvents have been added after exited event, so this should time out.
+ with self.assertRaises(TimeoutError):
+ history.wait_for_event(
+ CapabilitiesEvent,
+ after=exited_event,
+ until=supports_restart_request,
+ timeout=0.05,
+ )
+
+ # Unblock and Sync with the event thread.
+ delayed_append.set()
+ seen_event = history.wait_for_event(
+ CapabilitiesEvent,
+ after=exited_event,
+ until=supports_restart_request,
+ timeout=10,
+ )
+
+ # The supportsModuleRequest CapabilitiesEvent should be ignored.
+ self.assertEqual(seen_event.seq, 52)
+ self.assertIsInstance(seen_event, CapabilitiesEvent)
+ self.assertTrue(seen_event.body.capabilities.supportsRestartRequest)
+ finally:
+ # Clean up
+ delayed_append.set()
+ event_thread.join()
+
+ def generate_events(self) -> List[Event]:
+ """Generates the default events that is used in tests."""
+ message_log = self.getSourcePath("sample_message.json")
+ with open(message_log, "r") as file:
+ raw_events = json.loads(file.read())
+ events = [Event.from_json(raw_event) for raw_event in raw_events]
+ return events
+
+ def populate_history(self, history: EventHistory):
+ for event in self.generate_events():
+ history.record(event)
diff --git a/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_Types.py b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_Types.py
new file mode 100644
index 0000000000000..2205e17d41c40
--- /dev/null
+++ b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_Types.py
@@ -0,0 +1,452 @@
+import copy
+import unittest
+from dataclasses import dataclass, field
+from typing import Dict, List, Literal, Optional, Tuple, Type, TypeVar, Union, cast
+
+from lldbsuite.test.tools.lldb_dap.dap_types import (
+ EmptyBodyResponse,
+ Event,
+ EventName,
+ OutputEvent,
+ Request,
+ SetBreakpointsArgs,
+ Source,
+ StackTraceResponse,
+ StoppedEvent,
+ StoppedReason,
+ dict_to_message,
+ message_to_dict,
+)
+
+T = TypeVar("T")
+
+_Color = Literal["RED", "BLUE", "YELLOW"]
+_Number = Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
+_ColorOrNumber = Literal[_Color, _Number]
+_ColorNumberOrString = Literal[Literal["ONE", "TWO", Literal["THREE"]], _ColorOrNumber]
+
+
+class TestDAPUtils_Types(unittest.TestCase):
+ """Test serialization and deserialization of different dap types."""
+
+ def verify_round_trip(self, value_type: Type, value: dict):
+ message = dict_to_message(value_type, value)
+ gotten_value = message_to_dict(message)
+ self.assertDictEqual(value, gotten_value)
+
+ # Check the dict values get reconverted to the appropriate types.
+ for expected_key, expected_value in gotten_value.items():
+ self.assertEqual(type(expected_key), str, "expects type of key to be str.")
+ original_value = value[expected_key]
+ self.assertEqual(type(expected_value), type(original_value))
+
+ def test_encode_and_decode(self):
+ # Self referencing and optional type.
+ source_dict = {
+ "name": "main.cpp",
+ "path": "/some/random/path/to/a/file",
+ "sources": [{"name": "main.c", "path": "/another/random/path/to/file"}],
+ }
+ self.verify_round_trip(Source, source_dict)
+
+ # Argument and nested type.
+ source_breakpoint_dict = {
+ "source": {
+ "name": "CreateASTUnitFromArgs.cpp",
+ "path": "/llvm-project/clang/lib/Driver/CreateASTUnitFromArgs.cpp",
+ },
+ "lines": [86],
+ "breakpoints": [{"line": 86}, {"line": 30}, {"line": 100}],
+ "sourceModified": False,
+ }
+ self.verify_round_trip(SetBreakpointsArgs, source_breakpoint_dict)
+
+ # Response type.
+ configuration_done_response_dict = {
+ "command": "configurationDone",
+ "request_seq": 16,
+ "seq": 28,
+ "success": True,
+ "type": "response",
+ }
+ self.verify_round_trip(EmptyBodyResponse, configuration_done_response_dict)
+
+ stack_trace_dict = {
+ "body": {
+ "stackFrames": [
+ {
+ "column": 11,
+ "id": 524288,
+ "instructionPointerReference": "0x555555555266",
+ "line": 23,
+ "moduleId": "2833EAD0-0FDC-66C8-88B1-8C6E1D82736C-AE103AE6",
+ "name": "main",
+ "source": {
+ "name": "convert.cpp",
+ "path": "/path/to/where/convert.cpp",
+ },
+ }
+ ],
+ "totalFrames": 21,
+ },
+ "command": "stackTrace",
+ "request_seq": 21,
+ "seq": 40,
+ "success": True,
+ "type": "response",
+ }
+ self.verify_round_trip(StackTraceResponse, stack_trace_dict)
+
+ # Event type.
+ output_event_dict = {
+ "body": {
+ "category": "stdout",
+ "output": "print malformed utf8 𐌶𐌰L𐌾𐍈 C𐍈𐌼𐌴𐍃 lone trailing �� bytes\r\n",
+ },
+ "event": "output",
+ "seq": 37,
+ "type": "event",
+ }
+ self.verify_round_trip(OutputEvent, output_event_dict)
+
+ stopped_event_dict = {
+ "body": {
+ "allThreadsStopped": True,
+ "description": "breakpoint 3.1",
+ "hitBreakpointIds": [3],
+ "reason": "breakpoint",
+ "text": "breakpoint 3.1",
+ "threadId": 4039875,
+ },
+ "event": "stopped",
+ "seq": 38,
+ "type": "event",
+ }
+ self.verify_round_trip(StoppedEvent, stopped_event_dict)
+
+ def test_failing_encode_and_decode(self):
+ """Test encoding will fail if the type or value is wrong."""
+ # Verify missing field type.
+ with self.assertRaises(TypeError):
+ configuration_request_dict = {
+ "command": "configurationDone",
+ "seq": 16,
+ }
+ self.verify_round_trip(Request, configuration_request_dict)
+
+ # Event with the wrong value for the type key.
+ with self.assertRaises(ValueError, msg="expects events with the correct type"):
+ process_event_dict = {
+ "body": {
+ "isLocalProcess": True,
+ "name": "/path/to/test/build/test",
+ "pointerSize": 64,
+ "startMethod": "launch",
+ "systemProcessId": 4039875,
+ },
+ "event": "process",
+ "seq": 27,
+ "type": "response", # wrong type
+ }
+ Event.from_json(process_event_dict)
+
+ def test_primitives(self):
+ """Test encoding and decoding of python primitives."""
+
+ @dataclass
+ class Primitives:
+ pbool: bool
+ pint: int
+ pfloat: float
+ pstr: str
+ pbytes: bytes
+
+ prim_dict = {
+ "pbool": True,
+ "pint": 10,
+ "pfloat": 3.14,
+ "pstr": "HeLL0",
+ "pbytes": b"HeLL0 Bytes",
+ }
+ prim_obj = dict_to_message(Primitives, prim_dict)
+ self.assertEqual(prim_obj.pbool, True)
+ self.assertEqual(type(prim_obj.pbool), bool)
+
+ self.assertEqual(prim_obj.pint, 10)
+ self.assertEqual(type(prim_obj.pint), int)
+
+ self.assertEqual(prim_obj.pfloat, 3.14)
+ self.assertEqual(type(prim_obj.pfloat), float)
+
+ self.assertEqual(prim_obj.pstr, "HeLL0")
+ self.assertEqual(type(prim_obj.pstr), str)
+
+ self.assertEqual(prim_obj.pbytes, b"HeLL0 Bytes")
+ self.assertEqual(type(prim_obj.pbytes), bytes)
+
+ self.verify_round_trip(Primitives, prim_dict)
+
+ def test_literals(self):
+ """Test that encoding or decoding dataclass with any form of literal
+ is typechecked correctly.
+ """
+
+ @dataclass
+ class WithLiteral:
+ color: _Color
+ id: Optional[_Number] = None
+ any: Optional[_ColorOrNumber] = None
+ color_opt: Optional[_Color] = None
+ reason: Optional[StoppedReason] = None
+
+ @dataclass
+ class NestedLiterals:
+ value: _ColorNumberOrString
+
+ # Literals
+ color_dict = {"color": "RED", "id": 2, "color_opt": "BLUE"}
+ self.verify_round_trip(WithLiteral, color_dict)
+
+ color_dict_no_opt = {"color": "RED", "id": 1}
+ self.verify_round_trip(WithLiteral, color_dict_no_opt)
+
+ literal_dict = {"color": "BLUE", "id": 0, "any": "YELLOW", "reason": "goto"}
+ self.verify_round_trip(WithLiteral, literal_dict)
+ message = dict_to_message(WithLiteral, literal_dict)
+ self.assertEqual(message.color, literal_dict["color"])
+ self.assertEqual(message.id, literal_dict["id"])
+ self.assertIsNotNone(message.id)
+ self.assertEqual(message.any, literal_dict["any"])
+ self.assertIsNone(message.color_opt)
+ self.assertEqual(message.reason, literal_dict["reason"])
+
+ with self.assertRaises(ValueError):
+ # '1' is string instead of int
+ mixed_lit_dict = {"color": "BLUE", "id": 7, "any": "1"}
+ self.verify_round_trip(WithLiteral, mixed_lit_dict)
+
+ lit_dicts = [
+ {"value": 1},
+ {"value": "ONE"},
+ {"value": "BLUE"},
+ {"value": "THREE"},
+ ]
+ for a_dict in lit_dicts:
+ self.verify_round_trip(NestedLiterals, a_dict)
+
+ with self.assertRaises(ValueError):
+ a_dict = {"value": 100} # Not part of the specified literals.
+ self.verify_round_trip(NestedLiterals, a_dict)
+
+ def test_unions(self):
+ """Test that Union types encodes and decodes correctly"""
+
+ @dataclass
+ class WithPrimitiveUnion:
+ module_id: Union[int, str]
+
+ obj = dict_to_message(WithPrimitiveUnion, {"module_id": 42})
+ self.assertIsInstance(obj.module_id, int)
+ self.assertEqual(obj.module_id, 42)
+
+ obj = dict_to_message(WithPrimitiveUnion, {"module_id": "abc-def"})
+ self.assertIsInstance(obj.module_id, str)
+ self.assertEqual(obj.module_id, "abc-def")
+
+ # Round-trip preserves the correct type.
+ self.verify_round_trip(WithPrimitiveUnion, {"module_id": 7})
+ self.verify_round_trip(WithPrimitiveUnion, {"module_id": "x"})
+
+ # Incompatible data raises immediately with a clear error.
+ with self.assertRaises(TypeError) as ctx:
+ dict_to_message(WithPrimitiveUnion, {"module_id": [1, 2, 3]})
+ self.assertIn("is compatible with", str(ctx.exception))
+
+ # Union of list and dict.
+ @dataclass
+ class WithListOrDict:
+ source_map: Union[List[Tuple[str, str]], Dict[str, str]]
+
+ obj_list = dict_to_message(
+ WithListOrDict, {"source_map": [["a", "b"], ["c", "d"]]}
+ )
+ self.assertIsInstance(obj_list.source_map, list)
+ self.assertEqual(
+ obj_list.source_map, [("a", "b"), ("c", "d")], "expect list of tuples"
+ )
+
+ obj_dict = dict_to_message(WithListOrDict, {"source_map": {"a": "b", "c": "d"}})
+ self.assertIsInstance(obj_dict.source_map, dict)
+ self.assertEqual(obj_dict.source_map, {"a": "b", "c": "d"})
+
+ # Union of two dataclasses.
+ @dataclass
+ class TypeA:
+ x: int
+
+ @dataclass
+ class TypeB:
+ y: str
+
+ @dataclass
+ class WithDataclassUnion:
+ value: Union[TypeA, TypeB]
+
+ obj_a = dict_to_message(WithDataclassUnion, {"value": {"x": 1}})
+ self.assertIsInstance(obj_a.value, TypeA)
+ self.assertEqual(obj_a.value.x, 1) # type: ignore
+
+ obj_b = dict_to_message(WithDataclassUnion, {"value": {"y": "hello"}})
+ self.assertIsInstance(obj_b.value, TypeB)
+ self.assertEqual(obj_b.value.y, "hello") # type: ignore
+
+ # When only one Union type matches, the error should contain that type.
+ # In this case only 'RequiresX' will match 999.
+ @dataclass
+ class RequiresX:
+ x: int
+
+ @dataclass
+ class WithSingleCompatible:
+ value: Union[List[int], str, RequiresX]
+
+ with self.assertRaises(TypeError) as ctx:
+ dict_to_message(WithSingleCompatible, {"value": {"z": 999}})
+ self.assertIn("RequiresX", str(ctx.exception))
+
+ # Nested Optional value.
+ @dataclass
+ class WithOptionalUnion:
+ value: Optional[Union[int, str]] = None
+
+ obj_none = dict_to_message(WithOptionalUnion, {})
+ self.assertIsNone(obj_none.value)
+
+ obj_none = dict_to_message(WithOptionalUnion, {"value": None})
+ self.assertIsNone(obj_none.value)
+
+ obj_int = dict_to_message(WithOptionalUnion, {"value": 5})
+ self.assertEqual(obj_int.value, 5)
+
+ obj_str = dict_to_message(WithOptionalUnion, {"value": "hi"})
+ self.assertEqual(obj_str.value, "hi")
+
+ with self.assertRaises(TypeError):
+ # bool not accepted as int, even though it is a base class.
+ dict_to_message(WithOptionalUnion, {"value": True})
+
+ def test_field_alias(self):
+ """Test that renaming in a DAP field serializes and deserializes correctly"""
+
+ @dataclass
+ class AClass:
+ version: str = field(metadata={"alias": "$__lldb_version"})
+ color: Optional[_Color] = field(metadata={"alias": "colour"})
+
+ a_dict = {"$__lldb_version": "20.20.20", "colour": "RED"}
+ dict_obj = dict_to_message(AClass, a_dict)
+ self.assertEqual(dict_obj.version, a_dict["$__lldb_version"])
+ self.assertEqual(dict_obj.color, a_dict["colour"])
+
+ self.assertEqual(a_dict, message_to_dict(dict_obj))
+
+ def test_required_field(self):
+ """Test that fields with required metadata enforce their value on decode."""
+
+ @dataclass
+ class WithRequired:
+ color: str = field(metadata={"required": "RED"})
+ name: str = ""
+
+ # Test present and correct value.
+ obj = dict_to_message(WithRequired, {"color": "RED", "name": "test"})
+ self.assertEqual(obj.color, "RED")
+
+ # Test missing key.
+ with self.assertRaises(TypeError):
+ dict_to_message(WithRequired, {"name": "test"})
+
+ # Test wrong value raises ValueError.
+ with self.assertRaises(ValueError):
+ dict_to_message(WithRequired, {"color": "BLUE", "name": "test"})
+
+ result = message_to_dict(obj)
+ self.assertEqual(result["color"], "RED")
+
+ # Test non-string required values.
+ @dataclass
+ class WithRequiredInt:
+ version: int = field(metadata={"required": 2})
+
+ obj_int = dict_to_message(WithRequiredInt, {"version": 2})
+ self.assertEqual(obj_int.version, 2)
+
+ with self.assertRaises(TypeError):
+ dict_to_message(WithRequiredInt, {})
+
+ with self.assertRaises(ValueError):
+ dict_to_message(WithRequiredInt, {"version": 3})
+
+ # Test 'required' and 'alias' metadata can be combined.
+ @dataclass
+ class WithRequiredAlias:
+ kind: str = field(metadata={"required": "PURPLE", "alias": "type"})
+
+ obj_alias = dict_to_message(WithRequiredAlias, {"type": "PURPLE"})
+ self.assertEqual(obj_alias.kind, "PURPLE")
+
+ with self.assertRaises(ValueError):
+ dict_to_message(WithRequiredAlias, {"type": "response"})
+
+ def test_event_field_constant(self):
+ """Test that event subclasses always have the correct event name set."""
+ stopped_dict = {
+ "body": {
+ "reason": "breakpoint",
+ "threadId": 1,
+ "allThreadsStopped": True,
+ },
+ "event": "stopped",
+ "seq": 10,
+ "type": "event",
+ }
+ event = dict_to_message(StoppedEvent, stopped_dict)
+ self.assertEqual(event.event, EventName.STOPPED)
+
+ # Serialization round-trips back to the correct event name.
+ result = message_to_dict(event)
+ self.assertEqual(result["event"], "stopped")
+
+ # Missing event key.
+ with self.assertRaises(TypeError):
+ without_event = {k: v for k, v in stopped_dict.items() if k != "event"}
+ dict_to_message(StoppedEvent, without_event)
+
+ # Test wrong event type.
+ with self.assertRaises(ValueError):
+ dict_to_message(StoppedEvent, dict(stopped_dict, event="output"))
+
+ def test_dictionary(self):
+ """Tests DAP types with dictionary decodes and encodes correctly"""
+
+ @dataclass
+ class MessageDict:
+ data: Dict[str, Union[str, None]]
+ data_opt: Optional[Dict[str, Optional[str]]] = None
+
+ a_dict = {
+ "data": {"FOO": None, "NO_COLOR": "TRUE", "OTHER": ""},
+ "data_opt": None,
+ }
+ dict_obj = dict_to_message(MessageDict, a_dict)
+ self.assertIsNotNone(dict_obj.data)
+ dict_obj_data = cast(dict, dict_obj.data)
+ self.assertEqual(dict_obj_data["FOO"], a_dict["data"]["FOO"])
+ self.assertEqual(dict_obj_data["NO_COLOR"], a_dict["data"]["NO_COLOR"])
+ self.assertEqual(dict_obj_data["OTHER"], a_dict["data"]["OTHER"])
+ self.assertEqual(dict_obj.data_opt, a_dict["data_opt"])
+
+ # We skip fields that is None. when converting dataclass to dict.
+ without_data_opt = copy.deepcopy(a_dict)
+ without_data_opt.pop("data_opt")
+ self.assertEqual(without_data_opt, message_to_dict(dict_obj))
diff --git a/lldb/test/API/tools/lldb-dap/utils/sample_message.json b/lldb/test/API/tools/lldb-dap/utils/sample_message.json
new file mode 100644
index 0000000000000..2c2a5e3eed534
--- /dev/null
+++ b/lldb/test/API/tools/lldb-dap/utils/sample_message.json
@@ -0,0 +1,38 @@
+[
+ {"body": {"category": "console", "output": "Running preInitCommands:\n"}, "event": "output", "seq": 1, "type": "event"},
+ {"body": {"category": "console", "output": "(lldb) log enable lldb event -T -f dap_event.log\n"}, "event": "output", "seq": 3, "type": "event"},
+ {"body": {"category": "console", "output": "To get started with the debug console try \"<variable>\", \"<lldb-cmd>\" or \"help [<lldb-cmd>]\"\r\n"}, "event": "output", "seq": 4, "type": "event"},
+ {"body": {"category": "console", "output": "For more information visit https://lldb.llvm.org/use/lldbdap.html#debug-console.\r\n"}, "event": "output", "seq": 5, "type": "event"},
+ {"body": {"module": {"addressRange": "0x7e4ec4e6e000", "debugInfoSize": "563.1KB", "id": "A55BBBD8-5660-8820-2DBB-60DA08A95C6B-7697E8EE", "name": "ld-linux-x86-64.so.2", "path": "/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", "symbolFilePath": "/usr/lib/debug/.build-id/a5/5bbbd8566088202dbb60da08a95c6b7697e8ee.debug", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 6, "type": "event"},
+ {"body": {"module": {"addressRange": "0x7e4ec4e6c000", "id": "E500AEFD-F104-2A72-8C7D-8390BFD2A311-02F8A183", "name": "[vdso]", "path": "[vdso]", "symbolStatus": "Symbols not found."}, "reason": "new"}, "event": "module", "seq": 7, "type": "event"},
+ {"body": {"module": {"addressRange": "0x5fd98276c000", "debugInfoSize": "1.1KB", "id": "2C18BEA2-9D64-2123-5FBA-0344D06183D9-E7DCA074", "name": "a.out", "path": "/home/buildbot/workspace/a.out", "symbolFilePath": "/home/buildbot/workspace/a.out", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 8, "type": "event"},
+ {"body": {}, "event": "initialized", "seq": 9, "type": "event"},
+ {"body": {"breakpoint": {"column": 3, "id": 1, "instructionReference": "0x5FD98276D191", "line": 13, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 11, "type": "event"},
+ {"body": {"breakpoint": {"column": 3, "id": 2, "instructionReference": "0x5FD98276D1CD", "line": 18, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 12, "type": "event"},
+ {"body": {"breakpoint": {"column": 3, "id": 3, "instructionReference": "0x5FD98276D1E4", "line": 20, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 13, "type": "event"},
+ {"body": {"capabilities": {"supportsModuleSymbolsRequest": false, "supportsRestartRequest": false, "supportsStepInTargetsRequest": true}}, "event": "capabilities", "seq": 14, "type": "event"},
+ {"body": {"category": "console", "output": "Executable binary set to '/home/buildbot/workspace/a.out' (x86_64-unknown-linux-gnu).\r\n"}, "event": "output", "seq": 15, "type": "event"},
+ {"body": {"category": "console", "output": "Attached to process 2961472.\r\n"}, "event": "output", "seq": 16, "type": "event"},
+ {"body": {"isLocalProcess": true, "name": "/home/buildbot/workspace/a.out", "pointerSize": 64, "startMethod": "launch", "systemProcessId": 2961472}, "event": "process", "seq": 17, "type": "event"},
+ {"body": {"module": {"addressRange": "0x7e4ec4d3e000", "debugInfoSize": "936.7KB", "id": "0558EEC1-E2C0-3F68-8CF3-83E73579DEF7-863A61D0", "name": "libm.so.6", "path": "/lib/x86_64-linux-gnu/libm.so.6", "symbolFilePath": "/usr/lib/debug/.build-id/05/58eec1e2c03f688cf383e73579def7863a61d0.debug", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 21, "type": "event"},
+ {"body": {"module": {"addressRange": "0x7e4ec4600000", "debugInfoSize": "3.6MB", "id": "A5B27DB7-EF30-36C1-DACF-2E4DDC2E0527-67129439", "name": "libc.so.6", "path": "/lib/x86_64-linux-gnu/libc.so.6", "symbolFilePath": "/usr/lib/debug/.build-id/a5/b27db7ef3036c1dacf2e4ddc2e052767129439.debug", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 22, "type": "event"},
+ {"body": {"module": {"addressRange": "0x7e4ec4a00000", "id": "280F658F-43AA-E7A7-F92B-BA6EC4C5D96B-C2B53A7A", "name": "libstdc++.so.6", "path": "/lib/x86_64-linux-gnu/libstdc++.so.6", "symbolStatus": "Symbols not found."}, "reason": "new"}, "event": "module", "seq": 23, "type": "event"},
+ {"body": {"module": {"addressRange": "0x7e4ec4d11000", "id": "183187C1-CD58-CFF0-9C2A-25539EEB6AE6-14C67F19", "name": "libgcc_s.so.1", "path": "/lib/x86_64-linux-gnu/libgcc_s.so.1", "symbolStatus": "Symbols not found."}, "reason": "new"}, "event": "module", "seq": 24, "type": "event"},
+ {"body": {"allThreadsStopped": true, "description": "breakpoint 1.1", "hitBreakpointIds": [1], "reason": "breakpoint", "text": "breakpoint 1.1", "threadId": 2961472}, "event": "stopped", "seq": 25, "type": "event"},
+ {"body": {"allThreadsContinued": true, "threadId": 2961472}, "event": "continued", "seq": 27, "type": "event"},
+ {"body": {"category": "stdout", "output": "before dlopen\r\n"}, "event": "output", "seq": 28, "type": "event"},
+ {"body": {"module": {"addressRange": "0x7e4ec4e5f000", "debugInfoSize": "782B", "id": "F71ED270-47EF-5421-FD13-E6B6FAADED6B-3BC4D52D", "name": "libother.so", "path": "/home/buildbot/workspace/libother.so", "symbolFilePath": "/home/buildbot/workspace/libother.so", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 29, "type": "event"},
+ {"body": {"allThreadsStopped": true, "description": "breakpoint 2.1", "hitBreakpointIds": [2], "reason": "breakpoint", "text": "breakpoint 2.1", "threadId": 2961472}, "event": "stopped", "seq": 30, "type": "event"},
+ {"body": {"allThreadsContinued": true, "threadId": 2961472}, "event": "continued", "seq": 32, "type": "event"},
+ {"body": {"category": "stdout", "output": "before dlclose\r\n"}, "event": "output", "seq": 33, "type": "event"},
+ {"body": {"module": {"id": "F71ED270-47EF-5421-FD13-E6B6FAADED6B-3BC4D52D", "name": ""}, "reason": "removed"}, "event": "module", "seq": 34, "type": "event"},
+ {"body": {"breakpoint": {"column": 3, "id": 1, "instructionReference": "0x5FD98276D191", "line": 13, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 35, "type": "event"},
+ {"body": {"breakpoint": {"column": 3, "id": 2, "instructionReference": "0x5FD98276D1CD", "line": 18, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 36, "type": "event"},
+ {"body": {"breakpoint": {"column": 3, "id": 3, "instructionReference": "0x5FD98276D1E4", "line": 20, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 37, "type": "event"},
+ {"body": {"allThreadsStopped": true, "description": "breakpoint 3.1", "hitBreakpointIds": [3], "reason": "breakpoint", "text": "breakpoint 3.1", "threadId": 2961472}, "event": "stopped", "seq": 41, "type": "event"},
+ {"body": {"allThreadsContinued": true, "threadId": 2961472}, "event": "continued", "seq": 43, "type": "event"},
+ {"body": {"category": "stdout", "output": "GOT TOKEN 3200"}, "event": "output", "seq": 44, "type": "event"},
+ {"body": {"category": "console", "output": "Process 2961472 exited with status = 0 (0x00000000) \n"}, "event": "output", "seq": 45, "type": "event"},
+ {"body": {}, "event": "terminated", "seq": 46, "type": "event"},
+ {"body": {"exitCode": 0}, "event": "exited", "seq": 47, "type": "event"}
+]
\ No newline at end of file
>From 41e76232efe8e8253db0553bbe46c459514bb5a0 Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Mon, 15 Jun 2026 20:42:56 +0100
Subject: [PATCH 02/12] Add format changes
---
.../lldbsuite/test/tools/lldb_dap/dap_types.py | 7 +++++--
.../Python/lldbsuite/test/tools/lldb_dap/utils.py | 12 ++++++++----
2 files changed, 13 insertions(+), 6 deletions(-)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
index 5a3460f7fa226..a1c0da50c25fd 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
@@ -546,10 +546,12 @@ class ArgsProtocol(Protocol[T]): # type: ignore[misc]
__dataclass_fields__: ClassVar[Dict]
@property
- def response_class_(self) -> Type[T]: ...
+ def response_class_(self) -> Type[T]:
+ ...
@property
- def command_(self) -> str: ...
+ def command_(self) -> str:
+ ...
class Console(StrEnum):
@@ -1646,6 +1648,7 @@ class EvaluateArgs:
command_ = "evaluate"
response_class_ = EvaluateResponse
+
@dataclass(frozen=True)
class StepInTargetsResponse(Response):
@dataclass(frozen=True)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
index 9ef04b0c5ca90..ca0cc8e13f441 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
@@ -36,7 +36,8 @@ def __call__(
extra_env: list[str] | None = None,
install_remote: bool = True,
**kwargs,
- ) -> subprocess.Popen[bytes]: ...
+ ) -> subprocess.Popen[bytes]:
+ ...
@dataclass(frozen=True)
@@ -523,11 +524,14 @@ class Transport(Protocol):
adapter is already running and exposes connection URI.
"""
- def write(self, data: bytes): ...
+ def write(self, data: bytes):
+ ...
- def read(self, n: int) -> bytes: ...
+ def read(self, n: int) -> bytes:
+ ...
- def readline(self) -> bytes: ...
+ def readline(self) -> bytes:
+ ...
def close(self):
"""Close the transport.
>From e2dd75b077a913133d22ba166a1af92436f32b70 Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Tue, 16 Jun 2026 13:46:24 +0100
Subject: [PATCH 03/12] Leave only the launch and DAP_types test.
---
.../TestDAP_invalidatedEvent.py | 67 ++---
.../utils/TestDAPUtils_DAPConnection.py | 82 ------
.../utils/TestDAPUtils_EventHistory.py | 252 ------------------
.../tools/lldb-dap/utils/sample_message.json | 38 ---
4 files changed, 28 insertions(+), 411 deletions(-)
delete mode 100644 lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py
delete mode 100644 lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py
delete mode 100644 lldb/test/API/tools/lldb-dap/utils/sample_message.json
diff --git a/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py b/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py
index b13b1d44b6d43..8ba56b0bb27ca 100644
--- a/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py
+++ b/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py
@@ -4,23 +4,17 @@
know about it.
"""
+import lldbdap_testcase
from lldbsuite.test.lldbtest import line_number
-from lldbsuite.test.tools.lldb_dap.dap_types import LaunchArgs, StackTraceArgs
-from lldbsuite.test.tools.lldb_dap.lldb_dap_testcase import DAPTestCaseBase
-from lldbsuite.test.tools.lldb_dap.session_helpers import DAPTestSession
+from dap_server import Event
-class TestDAP_invalidatedEvent(DAPTestCaseBase):
- def verify_top_frame_name(
- self, session: DAPTestSession, frame_name: str, thread_id: int
- ):
- response = session.stack_trace(thread_id)
- all_frames = response.body.stackFrames
-
+class TestDAP_invalidatedEvent(lldbdap_testcase.DAPTestCaseBase):
+ def verify_top_frame_name(self, frame_name: str):
+ all_frames = self.get_stackFrames()
self.assertGreaterEqual(len(all_frames), 1, "Expected at least one frame.")
- top_frame_name = all_frames[0].name
+ top_frame_name = all_frames[0]["name"]
self.assertRegex(top_frame_name, f"{frame_name}.*")
- return response
def test_invalidated_stack_area_event(self):
"""
@@ -28,39 +22,34 @@ def test_invalidated_stack_area_event(self):
The event is sent when the command `thread return <expr>` is sent by the user.
"""
other_source = "other.h"
- program = self.getBuildArtifact("a.out")
- session = self.build_and_create_session()
- with session.configure(LaunchArgs(program)) as ctx:
- return_bp_line = line_number(other_source, "// thread return breakpoint")
- session.resolve_source_breakpoints(other_source, [return_bp_line])
+ return_bp_line = line_number(other_source, "// thread return breakpoint")
- stopped_event = session.verify_stopped_on_breakpoint(after=ctx.process_event)
+ program = self.getBuildArtifact("a.out")
+ self.build_and_launch(program)
+ self.set_source_breakpoints(other_source, [return_bp_line])
+ self.continue_to_next_stop()
- thread_id = self.expect_not_none(
- stopped_event.body.threadId, "expected a thread id."
- )
- stack_response = self.verify_top_frame_name(session, "add", thread_id)
+ self.verify_top_frame_name("add")
+ thread_id = self.dap_server.get_thread_id()
+ self.assertIsNotNone(thread_id, "Exepected a thread id.")
- # Run thread return.
+ # run thread return
thread_command = "thread return 20"
- session.evaluate(thread_command, context="repl")
-
- # Wait for the invalidated stack event.
- invalid_event = session.wait_for_invalidated(after=stack_response)
- self.assertIsNotNone(invalid_event, "Expected an invalidated event.")
- event_body = invalid_event.body
- self.assertIsNotNone(event_body.areas)
- self.assertIn("stacks", event_body.areas or [])
- self.assertIsNotNone(event_body.threadId)
+ eval_resp = self.dap_server.request_evaluate(thread_command, context="repl")
+ self.assertTrue(eval_resp["success"], f"Failed to evaluate `{thread_command}`.")
+
+ # wait for the invalidated stack event.
+ stack_event = self.dap_server.wait_for_event(["invalidated"])
+ self.assertIsNotNone(stack_event, "Expected an invalidated event.")
+ event_body: Event = stack_event["body"]
+ self.assertIn("stacks", event_body["areas"])
+ self.assertIn("threadId", event_body.keys())
self.assertEqual(
thread_id,
- event_body.threadId,
+ event_body["threadId"],
f"Expected the event from thread {thread_id}.",
)
- # Confirm we are back at the main frame.
- thread_id = self.expect_not_none(
- invalid_event.body.threadId, "expected a thread id."
- )
- self.verify_top_frame_name(session, "main", thread_id)
- session.continue_to_exit()
+ # confirm we are back at the main frame.
+ self.verify_top_frame_name("main")
+ self.continue_to_exit()
diff --git a/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py
deleted file mode 100644
index 9af9e553c0446..0000000000000
--- a/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py
+++ /dev/null
@@ -1,82 +0,0 @@
-import io
-import json
-from typing import List
-
-from lldbsuite.test.tools.lldb_dap.dap_types import RawMessage
-from lldbsuite.test.tools.lldb_dap.lldb_dap_testcase import DAPTestCaseBase
-from lldbsuite.test.tools.lldb_dap.utils import DAPConnection, MessageHandler, Transport
-
-
-class EchoClient:
- def __init__(self):
- self.seen_messages: List[RawMessage] = []
-
- def on_message(self, msg: RawMessage):
- self.seen_messages.append(msg)
-
-
-class TestDAPUtils_DAPConnection(DAPTestCaseBase):
- def test_round_trip(self):
- received_messages = self.get_sample_message()
- transport = self.create_transport(received_messages)
- connection = DAPConnection("conn0", transport)
-
- client = EchoClient()
- handler = MessageHandler(
- on_event=client.on_message,
- on_response=client.on_message,
- on_reverse_request=client.on_message,
- )
- connection.start(handler)
- expected_messages = client.seen_messages
- self.assertEqual(len(received_messages), len(expected_messages))
- for actual, expected in zip(received_messages, expected_messages):
- self.assertEqual(actual, expected)
-
- def test_encode_message_framing(self):
- payload = {"type": "request", "seq": 1, "command": "initialize"}
- data = DAPConnection.encode_message(payload)
- header, _, body = data.partition(b"\r\n\r\n")
- self.assertTrue(header.startswith(b"Content-Length:"))
- content_length = int(header.split(b":")[1].strip())
- self.assertEqual(content_length, len(body))
- self.assertEqual(json.loads(body), payload)
-
- def test_encode_message_round_trips(self):
- payload = {"nested": {"a": 1, "b": [1, 2, 3]}}
- _, _, body = DAPConnection.encode_message(payload).partition(b"\r\n\r\n")
- self.assertEqual(json.loads(body), payload)
-
- @staticmethod
- def create_transport(data: List[RawMessage]) -> Transport:
- class BinaryTransport:
- def __init__(self, data: List[RawMessage]):
- encoded_data = [
- DAPConnection.encode_message(message) for message in data
- ]
- self._in = io.BytesIO(b"".join(encoded_data))
- self._out: List[str] = []
-
- def write(self, data: bytes):
- self._out.append(data.decode("utf-8"))
-
- def readline(self):
- return self._in.readline()
-
- def read(self, n: int) -> bytes:
- return self._in.read(n)
-
- def close(self):
- self._in.close()
-
- @property
- def is_alive(self) -> bool:
- return not self._in.closed
-
- return BinaryTransport(data)
-
- def get_sample_message(self) -> List[dict]:
- message_log = self.getSourcePath("sample_message.json")
- with open(message_log, "r") as file:
- raw_events = json.loads(file.read())
- return raw_events
diff --git a/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py
deleted file mode 100644
index a5e69cf15dc83..0000000000000
--- a/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py
+++ /dev/null
@@ -1,252 +0,0 @@
-import json
-import threading
-from typing import List, Union
-
-from lldbsuite.test.tools.lldb_dap.dap_types import (
- CapabilitiesEvent,
- DAPError,
- Event,
- ExitedEvent,
- InitializedEvent,
- ModuleEvent,
- OutputEvent,
- ProcessEvent,
- ProgressEndEvent,
- TerminatedEvent,
-)
-from lldbsuite.test.tools.lldb_dap.lldb_dap_testcase import DAPTestCaseBase
-from lldbsuite.test.tools.lldb_dap.utils import EventHistory
-
-
-class TestDAPUtils_EventHistory(DAPTestCaseBase):
- """
- This test suite verifies the behavior of EventHistory, including event ordering validation,
- querying specific events, handling closed histories, applying custom filtering conditions,
- and managing timeouts during asynchronous event additions.
- """
-
- def test_history_order(self):
- """Test that events added to EventHistory are in sequential order"""
-
- history = EventHistory(timeout=10)
- events = [
- {
- "body": {"category": "console", "output": "Running preInitCommands:\n"},
- "event": "output",
- "seq": 2,
- "type": "event",
- },
- {
- "body": {
- "category": "console",
- "output": "(lldb) log enable lldb process",
- },
- "event": "output",
- "seq": 1,
- "type": "event",
- },
- ]
- events = [Event.from_json(event) for event in events]
-
- with self.assertRaises(AssertionError):
- for event in events:
- history.record(event)
-
- def test_wait_for_X_event_on_history_closed(self):
- """Tests `wait_for_first_event`, `wait_for_event` and `wait_for_any_event` when the history is closed.
-
- It should return a DAPError (HistoryClosed) instead of waiting for new events if the
- history is closed and the requested event does not already exist in the history.
- """
- history = EventHistory(timeout=10)
- self.populate_history(history)
-
- # We only intend on working on existing events.
- # so we don't get a Timeout error.
- history.close()
-
- init_event = history.wait_for_earliest_event(InitializedEvent)
- self.assertEqual(init_event.seq, 9)
- self.assertIsInstance(init_event, InitializedEvent)
- self.assertEqual(init_event.type, "event")
-
- cap_event = history.wait_for_event(CapabilitiesEvent, after=init_event)
- self.assertGreater(cap_event.seq, init_event.seq)
- self.assertIsInstance(cap_event, CapabilitiesEvent)
-
- # There is only one capabilitiesEvent in the history.
- with self.assertRaises(DAPError):
- history.wait_for_event(CapabilitiesEvent, after=cap_event)
-
- # There is no progressEndEvent in history.
- with self.assertRaises(DAPError):
- history.wait_for_earliest_event(ProgressEndEvent)
-
- # Must wait for at least one event.
- with self.assertRaises(AssertionError):
- history.wait_for_any_event((), after=cap_event)
-
- # Test wait_for_any_event.
- # Wait for_any_event should return the first seen event that in in the tuple
- any_event = history.wait_for_any_event(
- (ProcessEvent, OutputEvent, ModuleEvent, TerminatedEvent), after=cap_event
- )
- # We should see the output event first.
- self.assertIsInstance(any_event, OutputEvent)
-
- any_event = history.wait_for_any_event(
- (ModuleEvent, TerminatedEvent), after=cap_event
- )
-
- # We should see the ModuleEvent event first.
- self.assertIsInstance(any_event, ModuleEvent)
-
- def test_wait_for_X_event_until_Y_condition(self):
- """
- Tests the `until` parameter in wait_for_XXXX methods.
-
- Verifies that `wait_for_event` and `wait_for_any_event` continue scanning
- through matching event types until the provided condition function returns True.
- or throws an error.
- """
- history = EventHistory(timeout=10)
- self.populate_history(history)
- history.close()
-
- start_event = history.wait_for_earliest_event(OutputEvent)
- self.assertEqual(start_event.seq, 1)
- self.assertIsInstance(start_event, OutputEvent)
- self.assertEqual(start_event.body.output, "Running preInitCommands:\n")
-
- # Test wait_for_event_until success.
- def has_got_token(event: OutputEvent):
- return "GOT TOKEN" in event.body.output
-
- output_event = history.wait_for_event(
- OutputEvent, after=start_event, until=has_got_token
- )
- self.assertEqual(output_event.seq, 44)
- self.assertIsInstance(output_event, OutputEvent)
- self.assertIn("GOT TOKEN", output_event.body.output)
-
- def sdl3_module_loaded(event: ModuleEvent):
- module_is_sdl = event.body.module.name == "sdl3"
- is_new_module = event.body.reason == "new"
-
- return module_is_sdl and is_new_module
-
- # Test wait_for_event_until failure.
- # There is no sdl3 module in the event history.
- with self.assertRaises(DAPError):
- history.wait_for_event(
- ModuleEvent, after=start_event, until=sdl3_module_loaded
- )
-
- # Test wait_for_any_event.
- def sdl3_module_loaded_or_process_exited(
- event: Union[ModuleEvent, ExitedEvent],
- ):
- if isinstance(event, ModuleEvent):
- return sdl3_module_loaded(event)
-
- return True
-
- seen_event = history.wait_for_any_event(
- (ModuleEvent, ExitedEvent),
- after=start_event,
- until=sdl3_module_loaded_or_process_exited,
- )
-
- self.assertEqual(seen_event.seq, 47)
- self.assertIsInstance(seen_event, ExitedEvent)
- self.assertEqual(seen_event.body.exitCode, 0) # type: ignore
-
- def test_wait_for_X_event_with_timeout(self):
- """Tests the timeout functionality when waiting for events.
-
- Verifies that a TimeoutError is raised if the expected event does not
- arrive within the specified timeout. Also ensures that the wait methods
- successfully block and retrieve events added asynchronously by another thread
- before the timeout expires.
- """
- history = EventHistory(timeout=10)
- self.populate_history(history)
-
- delayed_append = threading.Event()
- delayed_append.clear()
-
- new_cap_events = [
- Event.from_json(
- {
- "body": {"capabilities": {"supportsModulesRequest": True}},
- "event": "capabilities",
- "seq": 50,
- "type": "event",
- }
- ),
- Event.from_json(
- {
- "body": {"capabilities": {"supportsRestartRequest": True}},
- "event": "capabilities",
- "seq": 52,
- "type": "event",
- }
- ),
- ]
-
- def add_delayed_events(events: List[Event]):
- delayed_append.wait()
- for event in events:
- history.record(event)
-
- event_thread = threading.Thread(
- target=add_delayed_events, args=[new_cap_events]
- )
- event_thread.start()
-
- try:
- exited_event = history.wait_for_earliest_event(ExitedEvent)
- self.assertEqual(exited_event.seq, 47)
- self.assertEqual(exited_event.body.exitCode, 0)
-
- def supports_restart_request(event: CapabilitiesEvent):
- return event.body.capabilities.supportsRestartRequest == True
-
- # No new CapabilitiesEvents have been added after exited event, so this should time out.
- with self.assertRaises(TimeoutError):
- history.wait_for_event(
- CapabilitiesEvent,
- after=exited_event,
- until=supports_restart_request,
- timeout=0.05,
- )
-
- # Unblock and Sync with the event thread.
- delayed_append.set()
- seen_event = history.wait_for_event(
- CapabilitiesEvent,
- after=exited_event,
- until=supports_restart_request,
- timeout=10,
- )
-
- # The supportsModuleRequest CapabilitiesEvent should be ignored.
- self.assertEqual(seen_event.seq, 52)
- self.assertIsInstance(seen_event, CapabilitiesEvent)
- self.assertTrue(seen_event.body.capabilities.supportsRestartRequest)
- finally:
- # Clean up
- delayed_append.set()
- event_thread.join()
-
- def generate_events(self) -> List[Event]:
- """Generates the default events that is used in tests."""
- message_log = self.getSourcePath("sample_message.json")
- with open(message_log, "r") as file:
- raw_events = json.loads(file.read())
- events = [Event.from_json(raw_event) for raw_event in raw_events]
- return events
-
- def populate_history(self, history: EventHistory):
- for event in self.generate_events():
- history.record(event)
diff --git a/lldb/test/API/tools/lldb-dap/utils/sample_message.json b/lldb/test/API/tools/lldb-dap/utils/sample_message.json
deleted file mode 100644
index 2c2a5e3eed534..0000000000000
--- a/lldb/test/API/tools/lldb-dap/utils/sample_message.json
+++ /dev/null
@@ -1,38 +0,0 @@
-[
- {"body": {"category": "console", "output": "Running preInitCommands:\n"}, "event": "output", "seq": 1, "type": "event"},
- {"body": {"category": "console", "output": "(lldb) log enable lldb event -T -f dap_event.log\n"}, "event": "output", "seq": 3, "type": "event"},
- {"body": {"category": "console", "output": "To get started with the debug console try \"<variable>\", \"<lldb-cmd>\" or \"help [<lldb-cmd>]\"\r\n"}, "event": "output", "seq": 4, "type": "event"},
- {"body": {"category": "console", "output": "For more information visit https://lldb.llvm.org/use/lldbdap.html#debug-console.\r\n"}, "event": "output", "seq": 5, "type": "event"},
- {"body": {"module": {"addressRange": "0x7e4ec4e6e000", "debugInfoSize": "563.1KB", "id": "A55BBBD8-5660-8820-2DBB-60DA08A95C6B-7697E8EE", "name": "ld-linux-x86-64.so.2", "path": "/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", "symbolFilePath": "/usr/lib/debug/.build-id/a5/5bbbd8566088202dbb60da08a95c6b7697e8ee.debug", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 6, "type": "event"},
- {"body": {"module": {"addressRange": "0x7e4ec4e6c000", "id": "E500AEFD-F104-2A72-8C7D-8390BFD2A311-02F8A183", "name": "[vdso]", "path": "[vdso]", "symbolStatus": "Symbols not found."}, "reason": "new"}, "event": "module", "seq": 7, "type": "event"},
- {"body": {"module": {"addressRange": "0x5fd98276c000", "debugInfoSize": "1.1KB", "id": "2C18BEA2-9D64-2123-5FBA-0344D06183D9-E7DCA074", "name": "a.out", "path": "/home/buildbot/workspace/a.out", "symbolFilePath": "/home/buildbot/workspace/a.out", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 8, "type": "event"},
- {"body": {}, "event": "initialized", "seq": 9, "type": "event"},
- {"body": {"breakpoint": {"column": 3, "id": 1, "instructionReference": "0x5FD98276D191", "line": 13, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 11, "type": "event"},
- {"body": {"breakpoint": {"column": 3, "id": 2, "instructionReference": "0x5FD98276D1CD", "line": 18, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 12, "type": "event"},
- {"body": {"breakpoint": {"column": 3, "id": 3, "instructionReference": "0x5FD98276D1E4", "line": 20, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 13, "type": "event"},
- {"body": {"capabilities": {"supportsModuleSymbolsRequest": false, "supportsRestartRequest": false, "supportsStepInTargetsRequest": true}}, "event": "capabilities", "seq": 14, "type": "event"},
- {"body": {"category": "console", "output": "Executable binary set to '/home/buildbot/workspace/a.out' (x86_64-unknown-linux-gnu).\r\n"}, "event": "output", "seq": 15, "type": "event"},
- {"body": {"category": "console", "output": "Attached to process 2961472.\r\n"}, "event": "output", "seq": 16, "type": "event"},
- {"body": {"isLocalProcess": true, "name": "/home/buildbot/workspace/a.out", "pointerSize": 64, "startMethod": "launch", "systemProcessId": 2961472}, "event": "process", "seq": 17, "type": "event"},
- {"body": {"module": {"addressRange": "0x7e4ec4d3e000", "debugInfoSize": "936.7KB", "id": "0558EEC1-E2C0-3F68-8CF3-83E73579DEF7-863A61D0", "name": "libm.so.6", "path": "/lib/x86_64-linux-gnu/libm.so.6", "symbolFilePath": "/usr/lib/debug/.build-id/05/58eec1e2c03f688cf383e73579def7863a61d0.debug", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 21, "type": "event"},
- {"body": {"module": {"addressRange": "0x7e4ec4600000", "debugInfoSize": "3.6MB", "id": "A5B27DB7-EF30-36C1-DACF-2E4DDC2E0527-67129439", "name": "libc.so.6", "path": "/lib/x86_64-linux-gnu/libc.so.6", "symbolFilePath": "/usr/lib/debug/.build-id/a5/b27db7ef3036c1dacf2e4ddc2e052767129439.debug", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 22, "type": "event"},
- {"body": {"module": {"addressRange": "0x7e4ec4a00000", "id": "280F658F-43AA-E7A7-F92B-BA6EC4C5D96B-C2B53A7A", "name": "libstdc++.so.6", "path": "/lib/x86_64-linux-gnu/libstdc++.so.6", "symbolStatus": "Symbols not found."}, "reason": "new"}, "event": "module", "seq": 23, "type": "event"},
- {"body": {"module": {"addressRange": "0x7e4ec4d11000", "id": "183187C1-CD58-CFF0-9C2A-25539EEB6AE6-14C67F19", "name": "libgcc_s.so.1", "path": "/lib/x86_64-linux-gnu/libgcc_s.so.1", "symbolStatus": "Symbols not found."}, "reason": "new"}, "event": "module", "seq": 24, "type": "event"},
- {"body": {"allThreadsStopped": true, "description": "breakpoint 1.1", "hitBreakpointIds": [1], "reason": "breakpoint", "text": "breakpoint 1.1", "threadId": 2961472}, "event": "stopped", "seq": 25, "type": "event"},
- {"body": {"allThreadsContinued": true, "threadId": 2961472}, "event": "continued", "seq": 27, "type": "event"},
- {"body": {"category": "stdout", "output": "before dlopen\r\n"}, "event": "output", "seq": 28, "type": "event"},
- {"body": {"module": {"addressRange": "0x7e4ec4e5f000", "debugInfoSize": "782B", "id": "F71ED270-47EF-5421-FD13-E6B6FAADED6B-3BC4D52D", "name": "libother.so", "path": "/home/buildbot/workspace/libother.so", "symbolFilePath": "/home/buildbot/workspace/libother.so", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 29, "type": "event"},
- {"body": {"allThreadsStopped": true, "description": "breakpoint 2.1", "hitBreakpointIds": [2], "reason": "breakpoint", "text": "breakpoint 2.1", "threadId": 2961472}, "event": "stopped", "seq": 30, "type": "event"},
- {"body": {"allThreadsContinued": true, "threadId": 2961472}, "event": "continued", "seq": 32, "type": "event"},
- {"body": {"category": "stdout", "output": "before dlclose\r\n"}, "event": "output", "seq": 33, "type": "event"},
- {"body": {"module": {"id": "F71ED270-47EF-5421-FD13-E6B6FAADED6B-3BC4D52D", "name": ""}, "reason": "removed"}, "event": "module", "seq": 34, "type": "event"},
- {"body": {"breakpoint": {"column": 3, "id": 1, "instructionReference": "0x5FD98276D191", "line": 13, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 35, "type": "event"},
- {"body": {"breakpoint": {"column": 3, "id": 2, "instructionReference": "0x5FD98276D1CD", "line": 18, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 36, "type": "event"},
- {"body": {"breakpoint": {"column": 3, "id": 3, "instructionReference": "0x5FD98276D1E4", "line": 20, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 37, "type": "event"},
- {"body": {"allThreadsStopped": true, "description": "breakpoint 3.1", "hitBreakpointIds": [3], "reason": "breakpoint", "text": "breakpoint 3.1", "threadId": 2961472}, "event": "stopped", "seq": 41, "type": "event"},
- {"body": {"allThreadsContinued": true, "threadId": 2961472}, "event": "continued", "seq": 43, "type": "event"},
- {"body": {"category": "stdout", "output": "GOT TOKEN 3200"}, "event": "output", "seq": 44, "type": "event"},
- {"body": {"category": "console", "output": "Process 2961472 exited with status = 0 (0x00000000) \n"}, "event": "output", "seq": 45, "type": "event"},
- {"body": {}, "event": "terminated", "seq": 46, "type": "event"},
- {"body": {"exitCode": 0}, "event": "exited", "seq": 47, "type": "event"}
-]
\ No newline at end of file
>From be162909f54ca327ad6e811a5ea11e8ff4291618 Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Tue, 16 Jun 2026 13:59:55 +0100
Subject: [PATCH 04/12] update the failed request message detail.
---
lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
index 22063815b111a..d8505b4a17c6b 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
@@ -110,7 +110,7 @@ def error(self, msg: Optional[str] = None) -> ErrorResponse:
if isinstance(response, ErrorResponse):
return response
- detail = f"expected '{self.response_class.__name__}' got {response}."
+ detail = f"expected 'ErrorResponse' got {response}."
raise DAPError(f"{msg}\n\t{detail}" if msg else detail)
def result_or_error(self) -> AnyResponse | ErrorResponse:
>From 24a31dd9e22029cbc8dac8b578aa7438166dc82a Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Wed, 17 Jun 2026 14:05:08 +0100
Subject: [PATCH 05/12] support `disconnect_automatically` in server_mode.
---
.../test/tools/lldb_dap/lldb_dap_testcase.py | 26 +++++--------------
.../lldbsuite/test/tools/lldb_dap/session.py | 2 +-
2 files changed, 8 insertions(+), 20 deletions(-)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py
index b2f56b81c2cd2..2b2412bcea072 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py
@@ -1,10 +1,9 @@
-import gc
import logging
import os
from pathlib import Path
from typing import Any, Final, Optional, TypeVar, Union, cast
-from lldbsuite.test.lldbtest import Base, is_exe
+from lldbsuite.test.lldbtest import Base, LLDBTestCaseFactory, is_exe
from .dap_types import AnyResponse, ErrorResponse, Response
from .session_helpers import DAPTestSession
@@ -23,7 +22,7 @@ def strtobool(val: str) -> bool:
T = TypeVar("T")
-class DAPTestCaseBase(Base):
+class DAPTestCaseBase(Base, metaclass=LLDBTestCaseFactory):
"""Base test case for DAP tests"""
NO_DEBUG_INFO_TESTCASE = True
@@ -73,10 +72,6 @@ def close_log():
self.addTearDownHook(close_log)
- def tearDown(self):
- gc.collect()
- super().tearDown()
-
def __create_default_debug_adapter(self):
self.assertFalse(hasattr(self, "adapter"), "A default adapter already exists.")
@@ -94,19 +89,13 @@ def __create_default_debug_adapter(self):
def create_session(
self,
adapter: Optional[DebugAdapter] = None,
- disconnect_automatically: Optional[bool] = None,
+ disconnect_automatically: bool = True,
) -> DAPTestSession:
if adapter is None:
self.assertIsNotNone(self.adapter, "expected we already have an adapter.")
adapter = self.adapter
self.assertTrue(adapter.is_alive, "expected adapter process is alive.")
- if adapter.is_server and disconnect_automatically is not None:
- self.assertFalse(
- disconnect_automatically,
- "disconnect_automatically is not supported for lldb-dap running as a server",
- )
-
build_dir = Path(self.getBuildDir())
session = DAPTestSession(
self,
@@ -118,20 +107,19 @@ def create_session(
)
def cleanup_session():
- # In server mode the adapter automatically shuts down after the last
- # client disconnects.
- if not adapter.is_server and disconnect_automatically is not False:
+ if disconnect_automatically:
+ self.logger.debug("Automatically disconnecting.")
session.disconnect(terminateDebuggee=True)
session.stop()
- self.addTearDownHook(cleanup_session)
session.start()
+ self.addTearDownHook(cleanup_session)
return session
def build_and_create_session(
self,
adapter: Optional[DebugAdapter] = None,
- disconnect_automatically: Optional[bool] = None,
+ disconnect_automatically: bool = True,
) -> DAPTestSession:
self.build()
return self.create_session(adapter, disconnect_automatically)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
index d8505b4a17c6b..f1de7faadfd55 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
@@ -147,7 +147,7 @@ def wrapper(self, *args: Any, **kwargs: Any) -> R:
class _DAPSessionState:
def __init__(self):
self._lock = threading.RLock()
- self._initialized = False
+ self._initialized: bool = False
self._capabilities = Capabilities()
self.output_streams = {
OutputCategory.STDOUT: io.StringIO(),
>From 54011ca3afb0b7c2c97e2a1cacfad1c6634bc20d Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Thu, 18 Jun 2026 12:43:41 +0100
Subject: [PATCH 06/12] add review changes
---
.../test/tools/lldb_dap/dap_types.py | 19 +++++++++++--------
.../lldbsuite/test/tools/lldb_dap/session.py | 2 +-
.../test/tools/lldb_dap/session_helpers.py | 13 ++++++++-----
3 files changed, 20 insertions(+), 14 deletions(-)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
index a1c0da50c25fd..5c0a369d445ce 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
@@ -30,6 +30,7 @@
Type,
TypeVar,
Union,
+ cast,
runtime_checkable,
)
@@ -188,31 +189,32 @@ def __init_subclass__(cls, *, event: str, **kwargs):
def __post_init__(self):
if self.type != MessageType.EVENT:
- raise ValueError(f"event must have type of 'event': {self}")
+ raise ValueError(f"event must have type of 'event': {self}.")
@classmethod
- def from_json(cls: Type[T], json: dict, check_class: bool = True) -> T:
+ def from_json(cls: Type[T], json: dict) -> T:
event_type = json["event"]
if event_type not in Event.__registry:
- raise ValueError(f"event type '{event_type}' is not registered")
+ raise ValueError(f"event type '{event_type}' is not registered.")
event_class = Event.__registry[event_type]
- if check_class and type(cls) != type(event_class):
+ if cls not in (Event, event_class):
raise ValueError(
- f"class: {type(cls)} is not of the expected type {type(event_class)}"
+ f"class: {cls.__name__!r} is not of the expected type {event_class.__name__!r}."
)
- if not dataclasses.is_dataclass(cls):
- raise ValueError(f"{cls.__name__} must be a dataclass")
+ if not dataclasses.is_dataclass(event_class):
+ raise ValueError(f"{cls.__name__!r} must be a dataclass.")
- return dict_to_message(event_class, json)
+ return cast(T, dict_to_message(event_class, json))
AnyEvent = TypeVar("AnyEvent", bound=Event)
+ at dataclass(frozen=True)
class EmptyBodyResponse(Response):
body: None = field(init=False, default=None)
@@ -1669,6 +1671,7 @@ class StepInTargetsArgs:
@dataclass(frozen=True)
class GotoTargetsResponse(Response):
+ @dataclass(frozen=True)
class Body:
targets: List[GotoTarget]
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
index f1de7faadfd55..a51d47e8b5c30 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
@@ -118,7 +118,7 @@ def result_or_error(self) -> AnyResponse | ErrorResponse:
raw = self._future.result(timeout=self._timeout)
except (TimeoutError, futures.TimeoutError) as e:
msg = f"\n\tRequest '{self._command}' (seq={self.seq}) timed out after {self._timeout}s"
- e.args = (f"{e.args[0]}{msg}", *e.args)
+ e.args = (f"{e.args[0]}{msg}", *e.args) if e.args else (msg,)
raise
except ConnectionError as e:
raise DAPError(
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
index 5c36d5dbed2aa..da1e6c9c814db 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
@@ -1668,12 +1668,15 @@ def write_memory(
`value` is Base64-encoded because the DAP protocol requires it.
"""
if isinstance(value, int):
- # (bit_length + 7 (rounding up to nearest byte)) // 8 = converts to bytes.
- byte_length = (value.bit_length() + 7) // 8
- val_bytes = value.to_bytes(byte_length, "little")
- data = base64.b64encode(val_bytes).decode()
+ # The minimum bytes needed to represent 'value'.
+ byte_length = max(1, (value.bit_length() + 7) // 8)
+ is_negative = value < 0
+ val_bytes = value.to_bytes(byte_length, "little", signed=is_negative)
+ elif isinstance(value, str):
+ val_bytes = value.encode()
else:
- data = base64.b64encode(str(value).encode()).decode()
+ val_bytes = value
+ data = base64.b64encode(val_bytes).decode()
before_request = self.last_response()
write_args = WriteMemoryArgs(
>From 085e527ad25167bfcd4cb5c5d344828966ae1585 Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Fri, 19 Jun 2026 16:07:50 +0100
Subject: [PATCH 07/12] update wait_for_XXX_event functions. use consistent
numbering
---
.../test/tools/lldb_dap/session_helpers.py | 74 +++++++++++--------
1 file changed, 42 insertions(+), 32 deletions(-)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
index da1e6c9c814db..5478bb409ed80 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
@@ -480,9 +480,9 @@ class _ConfigureContext:
Orchestrates the full DAP initialization sequence:
On enter:
- - Request and respond to the `Initialize` command.
- - Send launch/attach request.
- - Wait for InitializedEvent.
+ 1. Request and respond to the `Initialize` command.
+ 2. Send launch/attach request.
+ 3. Wait for InitializedEvent.
In between:
The test can set breakpoints or perform any check it needs do.
@@ -746,14 +746,24 @@ def set_assembly_breakpoints(
def set_function_breakpoints(
self,
- function_names: list[str],
+ breakpoints: list[str] | list[FunctionBreakpoint],
condition: Optional[str] = None,
hitCondition: Optional[str] = None,
):
- f_breakpoints = [
- FunctionBreakpoint(name, condition=condition, hitCondition=hitCondition)
- for name in function_names
- ]
+ f_breakpoints: list[FunctionBreakpoint] = []
+ for bp in breakpoints:
+ if isinstance(bp, str):
+ func_bp = FunctionBreakpoint(
+ bp, condition=condition, hitCondition=hitCondition
+ )
+ f_breakpoints.append(func_bp)
+ elif isinstance(bp, FunctionBreakpoint):
+ f_breakpoints.append(bp)
+ else:
+ self.test_case.fail(
+ "breakpoints must only contain 'str' or 'FunctionBreakpoints'."
+ f" got '{bp}' of type '{type(bp)}'."
+ )
response = self.send_request(SetFunctionBreakpointsArgs(f_breakpoints)).result()
return response
@@ -786,11 +796,11 @@ def set_variable(
)
response = handle.result_or_error()
if isinstance(response, SetVariableResponse):
- invalidated_event = self.wait_for_invalidated(after=last_response)
+ invalidated_event = self.wait_for_invalidated_event(after=last_response)
invalidated_areas = invalidated_event.body.areas
self.test_case.assertEqual(["variables"], invalidated_areas)
- memory_event = self.wait_for_memory(after=last_response)
+ memory_event = self.wait_for_memory_event(after=last_response)
self.test_case.assertEqual(
memory_event.body.memoryReference, response.body.memoryReference
)
@@ -824,7 +834,7 @@ def resolve_source_breakpoints(
def resolve_function_breakpoints(
self,
- function_names: list[str],
+ breakpoints: list[str] | list[FunctionBreakpoint],
condition: Optional[str] = None,
hitCondition: Optional[str] = None,
) -> list[int]:
@@ -833,16 +843,14 @@ def resolve_function_breakpoints(
("1", "2") for each breakpoint that was set.
"""
last_response = self.last_response()
- response = self.set_function_breakpoints(
- function_names, condition, hitCondition
- )
- breakpoints = response.body.breakpoints
+ response = self.set_function_breakpoints(breakpoints, condition, hitCondition)
+ resp_bps = response.body.breakpoints
- all_verified = all(bp.verified for bp in breakpoints)
+ all_verified = all(bp.verified for bp in resp_bps)
if not all_verified:
- self.wait_until_all_breakpoints_verified(breakpoints, after=last_response)
+ self.wait_until_all_breakpoints_verified(resp_bps, after=last_response)
- return self.breakpoints_to_ids(breakpoints)
+ return self.breakpoints_to_ids(resp_bps)
def set_data_breakpoints(self, breakpoints: list[DataBreakpoint]):
args = SetDataBreakpointsArgs(breakpoints=breakpoints)
@@ -852,7 +860,7 @@ def set_instruction_breakpoints(self, memory_references: list[str]):
breakpoints = [InstructionBreakpoint(ref) for ref in memory_references]
return self.send_request(SetInstructionBreakpointsArgs(breakpoints)).result()
- def set_breakpoint_locations(
+ def get_breakpoint_locations(
self,
file_path: str,
line: int,
@@ -949,7 +957,7 @@ def event_hit_id_in_breakpoint_ids(event: StoppedEvent):
return False
- event = self.wait_for_stopped(
+ event = self.wait_for_stopped_event(
breakpoint_stop_reasons,
after=after,
until=event_hit_id_in_breakpoint_ids,
@@ -1001,7 +1009,7 @@ def all_breakpoints_verified(evt: BreakpointEvent):
)
return last_breakpoint_event
- def wait_for_stopped_or_exited(
+ def wait_for_stopped_or_exited_event(
self,
*,
after: Event | Response,
@@ -1016,7 +1024,7 @@ def wait_for_stopped_or_exited(
)
return event
- def wait_for_stopped(
+ def wait_for_stopped_event(
self,
matching_any: Optional[Sequence[StoppedReason]] = None,
*,
@@ -1057,7 +1065,7 @@ def matches_any_reason_until(event: StoppedEvent | ExitedEvent):
return True
- event = self.wait_for_stopped_or_exited(
+ event = self.wait_for_stopped_or_exited_event(
after=after, until=matches_any_reason_until, timeout_msg=timeout_msg
)
@@ -1065,7 +1073,7 @@ def matches_any_reason_until(event: StoppedEvent | ExitedEvent):
self.test_case.assertEqual(event.event, EventName.STOPPED)
return cast(StoppedEvent, event)
- def wait_for_exited(self, *, after: Event | Response) -> ExitedEvent:
+ def wait_for_exited_event(self, *, after: Event | Response) -> ExitedEvent:
"""
Wait for a process to exit.
@@ -1073,7 +1081,7 @@ def wait_for_exited(self, *, after: Event | Response) -> ExitedEvent:
Raises an error if a StoppedEvent is encountered, as a stopped process
cannot subsequently exit.
"""
- event = self.wait_for_stopped_or_exited(after=after)
+ event = self.wait_for_stopped_or_exited_event(after=after)
self.test_case.assertIsInstance(event, ExitedEvent)
self.test_case.assertEqual(event.event, "exited", "expected ExitedEvent'")
return cast(ExitedEvent, event)
@@ -1099,13 +1107,13 @@ def wait_for_module_event(
):
return self.wait_for_event(ModuleEvent, after=after, until=until)
- def wait_for_terminated(self, *, after: Event | Response):
+ def wait_for_terminated_event(self, *, after: Event | Response):
return self.wait_for_event(TerminatedEvent, after=after)
- def wait_for_invalidated(self, *, after: Event | Response):
+ def wait_for_invalidated_event(self, *, after: Event | Response):
return self.wait_for_event(InvalidatedEvent, after=after)
- def wait_for_memory(self, *, after: Event | Response):
+ def wait_for_memory_event(self, *, after: Event | Response):
return self.wait_for_event(MemoryEvent, after=after)
def do_continue(self):
@@ -1138,7 +1146,7 @@ def continue_to_next_stop(self, *, exp_reason: Optional[StoppedReason] = None):
"""Continue execution and wait for stopped event"""
response = self.do_continue()
if exp_reason is None:
- return self.wait_for_stopped(after=response)
+ return self.wait_for_stopped_event(after=response)
return self.verify_stopped(exp_reason, after=response)
@@ -1277,7 +1285,9 @@ def verify_stopped(
reasons = [reasons]
timeout_msg = f"waiting for 'StoppedEvent' matching reasons: {reasons}"
- stopped_event = self.wait_for_stopped(after=after, timeout_msg=timeout_msg)
+ stopped_event = self.wait_for_stopped_event(
+ after=after, timeout_msg=timeout_msg
+ )
body = stopped_event.body
test_case = self.test_case
@@ -1364,7 +1374,7 @@ def verify_process_exited(
self, *, after: Event | Response | None = None, exitCode: int = 0
):
if after:
- event = self.wait_for_exited(after=after)
+ event = self.wait_for_exited_event(after=after)
else:
event = self.wait_for_earliest_event(ExitedEvent)
@@ -1690,7 +1700,7 @@ def write_memory(
# Check we sent invalidated event.
if response.success and self.initialize_args.supportsInvalidatedEvent:
- invalidated = self.wait_for_invalidated(after=before_request)
+ invalidated = self.wait_for_invalidated_event(after=before_request)
self.test_case.assertEqual(invalidated.body.areas, ["all"])
return response
>From 64e9103f8d7432efb0a84998ff48b9e3693922cb Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Fri, 26 Jun 2026 16:52:47 +0100
Subject: [PATCH 08/12] explicitly check if a default or default factory is
provided remove all in flight request when stopping the readloop
---
.../Python/lldbsuite/test/tools/lldb_dap/dap_types.py | 7 +++++++
.../packages/Python/lldbsuite/test/tools/lldb_dap/utils.py | 1 +
2 files changed, 8 insertions(+)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
index 5c0a369d445ce..f5af6482d01f7 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
@@ -488,6 +488,13 @@ def _dict_to_message_impl(
for key, f in fields.items():
data_key = f.metadata.get("alias", key)
if data_key not in data:
+ if (
+ f.default is dataclasses.MISSING
+ and f.default_factory is dataclasses.MISSING
+ ):
+ raise TypeError(
+ f"expected field {data_key!r} in {data!r} at {full_path!r}."
+ )
continue
required_value = f.metadata.get("required", dataclasses.MISSING)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
index ca0cc8e13f441..6bd4b3f85bacd 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
@@ -630,6 +630,7 @@ def _read_loop(self, handler: MessageHandler):
# full timeout when the adapter exits or gets killed.
with self._in_flight_lock:
pending_futures = [f for _, f in self._in_flight_requests.values()]
+ self._in_flight_requests = {}
resp_error = error or DAPError("DAP connection closed before response.")
for future in pending_futures:
>From a20d5ebb0316023609edcf8f83b63bdfe99fd853 Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Fri, 26 Jun 2026 17:04:55 +0100
Subject: [PATCH 09/12] update the error message since it may try different
types
---
lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
index f5af6482d01f7..209cbabae4e80 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
@@ -493,7 +493,7 @@ def _dict_to_message_impl(
and f.default_factory is dataclasses.MISSING
):
raise TypeError(
- f"expected field {data_key!r} in {data!r} at {full_path!r}."
+ f"expected field {data_key!r} in {data!r} at {full_path!r} for {cls.__name__!r}."
)
continue
>From eaabd7c7ff7a71ae581edb1879e55bbf382a19fc Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Fri, 26 Jun 2026 17:42:32 +0100
Subject: [PATCH 10/12] Replace last_response with last_event
---
.../lldbsuite/test/tools/lldb_dap/session.py | 25 ++++---------------
.../test/tools/lldb_dap/session_helpers.py | 16 ++++++------
.../lldbsuite/test/tools/lldb_dap/utils.py | 12 ++++++++-
3 files changed, 24 insertions(+), 29 deletions(-)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
index a51d47e8b5c30..5189cfafe0c96 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
@@ -157,7 +157,6 @@ def __init__(self):
OutputCategory.TELEMETRY: io.StringIO(),
}
self._stopped_thread_id: Optional[int] = None
- self._last_response: Optional[Response] = None
self._stop_generation: int = 0
@property
@@ -208,15 +207,6 @@ def update_capabilities(self, new_capabilities: Capabilities):
}
self._capabilities = dataclasses.replace(self._capabilities, **kwargs)
- @property
- @_synchronized
- def last_response(self) -> Optional[Response]:
- return self._last_response
-
- @_synchronized
- def set_last_response(self, response: Response):
- self._last_response = response
-
class Session:
"""
@@ -277,15 +267,11 @@ def on_connection_closed(err: Optional[Exception]):
# is created using `RunInTerminal`.
self._reverse_process_io_threads: list[threading.Thread] = []
- def last_response(self):
- """Returns a copy of the response most recently consumed by a test.
-
- This is the response a test last checked via (PendingResponse) not
- the last response from the adapter. only useful when a helper function
- does not expose the response such as `resolve_source_breakpoint`."""
- response = self._state.last_response
- assert response is not None, "expected at least previous response."
- return dataclasses.replace(response)
+ def last_event(self):
+ """Returns a copy of the last received event or an anchor event with seq=0
+ if no events have been received yet."""
+ event = self._event_history.last_event()
+ return dataclasses.replace(event)
@property
def stopped_thread_id(self):
@@ -467,7 +453,6 @@ def send_request(
raw_future=raw_future,
timeout=self._message_timeout,
command=request_args.command_,
- on_resolve=self._state.set_last_response,
)
def _send_response(self, response: ReverseResponse):
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
index 5478bb409ed80..58c30bb8de04e 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
@@ -785,7 +785,7 @@ def set_exception_breakpoints(
def set_variable(
self, name: str, value, *, variablesReference: int, is_hex: bool = False
) -> SetVariableResponse | ErrorResponse:
- last_response = self.last_response()
+ last_event = self.last_event()
handle = self.send_request(
SetVariableArgs(
variablesReference=variablesReference,
@@ -796,11 +796,11 @@ def set_variable(
)
response = handle.result_or_error()
if isinstance(response, SetVariableResponse):
- invalidated_event = self.wait_for_invalidated_event(after=last_response)
+ invalidated_event = self.wait_for_invalidated_event(after=last_event)
invalidated_areas = invalidated_event.body.areas
self.test_case.assertEqual(["variables"], invalidated_areas)
- memory_event = self.wait_for_memory_event(after=last_response)
+ memory_event = self.wait_for_memory_event(after=last_event)
self.test_case.assertEqual(
memory_event.body.memoryReference, response.body.memoryReference
)
@@ -817,13 +817,13 @@ def breakpoints_to_ids(breakpoints: list[Breakpoint]):
def resolve_source_breakpoints(
self, source_path: str, breakpoints: list[int] | list[SourceBreakpoint]
) -> list[int]:
- last_response = self.last_response()
+ last_event = self.last_event()
bp_response = self.set_source_breakpoints(source_path, breakpoints)
r_breakpoints = bp_response.body.breakpoints
all_verified = all(bp.verified for bp in r_breakpoints)
if not all_verified:
- self.wait_until_all_breakpoints_verified(r_breakpoints, after=last_response)
+ self.wait_until_all_breakpoints_verified(r_breakpoints, after=last_event)
self.test_case.assertEqual(
len(breakpoints),
@@ -842,13 +842,13 @@ def resolve_function_breakpoints(
and returns an array of strings containing the breakpoint IDs
("1", "2") for each breakpoint that was set.
"""
- last_response = self.last_response()
+ last_event = self.last_event()
response = self.set_function_breakpoints(breakpoints, condition, hitCondition)
resp_bps = response.body.breakpoints
all_verified = all(bp.verified for bp in resp_bps)
if not all_verified:
- self.wait_until_all_breakpoints_verified(resp_bps, after=last_response)
+ self.wait_until_all_breakpoints_verified(resp_bps, after=last_event)
return self.breakpoints_to_ids(resp_bps)
@@ -1688,7 +1688,7 @@ def write_memory(
val_bytes = value
data = base64.b64encode(val_bytes).decode()
- before_request = self.last_response()
+ before_request = self.last_event()
write_args = WriteMemoryArgs(
memoryReference=memoryReference,
data=data,
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
index 6bd4b3f85bacd..5488fb04c7dc5 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
@@ -444,6 +444,16 @@ def is_event_and_matches_condition(evt: Event):
assert isinstance(event, event_types)
return event
+ def last_event(self) -> Event:
+ with self._new_event_condition:
+ if self._events:
+ return self._events[-1]
+
+ # There is no event with the sequence 0.
+ # This exist in order provide the last event since the event list is empty.
+ anchor_event = Event(seq=0, type=MessageType.EVENT, event="anchor_first_event")
+ return anchor_event
+
def __wait_until(
self,
matches_condition: Callable[[Event], bool],
@@ -578,7 +588,6 @@ def __init__(self, connection_id: str, transport: Transport):
self._is_ready.clear()
def start(self, handler: MessageHandler):
- self._is_ready.set()
self._read_loop(handler)
def stop(self):
@@ -613,6 +622,7 @@ def wait_until_alive(self, timeout: float):
return self._is_ready.wait(timeout)
def _read_loop(self, handler: MessageHandler):
+ self._is_ready.set()
error = None
try:
while self.is_alive():
>From d413c69660ee0c614a23ec21e7af581e00c7fd49 Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Mon, 29 Jun 2026 16:35:40 +0100
Subject: [PATCH 11/12] add a lock for all output category buffers
---
.../lldbsuite/test/tools/lldb_dap/session.py | 37 ++++++++++---------
.../lldbsuite/test/tools/lldb_dap/utils.py | 31 +++++++++++++---
2 files changed, 45 insertions(+), 23 deletions(-)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
index 5189cfafe0c96..4a3db82dabc0a 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
@@ -53,6 +53,7 @@
DebugAdapter,
EventHistory,
MessageHandler,
+ OutputBuffer,
SubProcessSpawner,
redirect_stream,
)
@@ -149,12 +150,12 @@ def __init__(self):
self._lock = threading.RLock()
self._initialized: bool = False
self._capabilities = Capabilities()
- self.output_streams = {
- OutputCategory.STDOUT: io.StringIO(),
- OutputCategory.STDERR: io.StringIO(),
- OutputCategory.CONSOLE: io.StringIO(),
- OutputCategory.IMPORTANT: io.StringIO(),
- OutputCategory.TELEMETRY: io.StringIO(),
+ self.output_buffers = {
+ OutputCategory.STDOUT: OutputBuffer(),
+ OutputCategory.STDERR: OutputBuffer(),
+ OutputCategory.CONSOLE: OutputBuffer(),
+ OutputCategory.IMPORTANT: OutputBuffer(),
+ OutputCategory.TELEMETRY: OutputBuffer(),
}
self._stopped_thread_id: Optional[int] = None
self._stop_generation: int = 0
@@ -322,15 +323,15 @@ def _on_protocol_event(self, message: RawMessage) -> None:
if (thread_id := event.body.threadId) and not event.body.preserveFocusHint:
self._state.set_stopped_thread_id(thread_id)
elif isinstance(event, OutputEvent):
- category = self._state.output_streams[event.body.category]
- category.write(event.body.output)
+ category_buffer = self._state.output_buffers[event.body.category]
+ category_buffer.write(event.body.output)
elif isinstance(event, ExitedEvent):
# If we have a 'runInTerminal' process it must have exited.
if self._reverse_process:
self.verify_reverse_process_exited()
# Join the redirect threads here. so any tail bytes are flushed
- # into the StringIOs before the test reads them.
+ # into the output buffer before the test reads them.
for thread in self._reverse_process_io_threads:
thread.join(self._message_timeout)
@@ -398,12 +399,12 @@ def _handle_run_in_terminal(self, request: RunInTerminalRequest):
body=RunInTerminalResponse.Body(processId=process.pid),
)
if proc_stdout := process.stdout:
- out_stream = self._state.output_streams[OutputCategory.STDOUT]
- out_thread = redirect_stream(proc_stdout, out_stream, "stdout")
+ out_buffer = self._state.output_buffers[OutputCategory.STDOUT]
+ out_thread = redirect_stream(proc_stdout, out_buffer, "stdout")
self._reverse_process_io_threads.append(out_thread)
if proc_stderr := process.stderr:
- err_stream = self._state.output_streams[OutputCategory.STDERR]
- err_thread = redirect_stream(proc_stderr, err_stream, "stderr")
+ err_buffer = self._state.output_buffers[OutputCategory.STDERR]
+ err_thread = redirect_stream(proc_stderr, err_buffer, "stderr")
self._reverse_process_io_threads.append(err_thread)
self._send_response(response)
@@ -415,17 +416,17 @@ def ensure_initialized(self):
# Sanity check.
assert self._state.is_initialized
- def get_stdout(self):
- return self._state.output_streams[OutputCategory.STDOUT].getvalue()
+ def get_stdout(self) -> str:
+ return self._state.output_buffers[OutputCategory.STDOUT].getvalue()
def get_console(self) -> str:
- return self._state.output_streams[OutputCategory.CONSOLE].getvalue()
+ return self._state.output_buffers[OutputCategory.CONSOLE].getvalue()
def get_stderr(self) -> str:
- return self._state.output_streams[OutputCategory.STDERR].getvalue()
+ return self._state.output_buffers[OutputCategory.STDERR].getvalue()
def get_important(self) -> str:
- return self._state.output_streams[OutputCategory.IMPORTANT].getvalue()
+ return self._state.output_buffers[OutputCategory.IMPORTANT].getvalue()
def send_request(
self, request_args: ArgsProtocol[AnyResponse]
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
index 5488fb04c7dc5..cc1046855d097 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
@@ -3,6 +3,7 @@
import bisect
import contextlib
+import io
import itertools
import json
import os
@@ -490,8 +491,28 @@ def __wait_until(
self._new_event_condition.wait(remaining_time)
+class OutputBuffer:
+ """A Thread safe io.StringIO."""
+
+ def __init__(self):
+ self._buf = io.StringIO()
+ self._lock = threading.Lock()
+
+ def write(self, text: str) -> int:
+ with self._lock:
+ return self._buf.write(text)
+
+ def flush(self):
+ with self._lock:
+ self._buf.flush()
+
+ def getvalue(self) -> str:
+ with self._lock:
+ return self._buf.getvalue()
+
+
def redirect_stream(
- in_stream: IO[bytes], out_stream: IO[str], thread_name: str
+ in_stream: IO[bytes], out_buffer: OutputBuffer, thread_name: str
) -> threading.Thread:
"""
Creates a new thread that redirects stream from `in_stream` to
@@ -501,21 +522,21 @@ def redirect_stream(
Returns a thread that redirects the stream.
"""
- def read_loop(in_stream: IO[bytes], out_stream: IO[str]):
+ def read_loop(in_stream: IO[bytes], out_buffer: OutputBuffer):
with contextlib.suppress(OSError, ValueError): # Nothing to report.
while True:
chunk = in_stream.read(4096)
if not chunk:
break
- out_stream.write(chunk.decode(errors="replace"))
- out_stream.flush()
+ out_buffer.write(chunk.decode(errors="replace"))
+ out_buffer.flush()
thread_name = f"redirect_{thread_name}"
redirect_thread = threading.Thread(
target=read_loop,
name=thread_name,
- args=[in_stream, out_stream],
+ args=[in_stream, out_buffer],
daemon=True,
)
redirect_thread.start()
>From c5d968dbb13fe4ef2e5c19148ca11f6f02514bc8 Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Wed, 1 Jul 2026 15:45:32 +0100
Subject: [PATCH 12/12] drop condition and hitCondition parameters from
function_breakpoints
remove stopped thread id and default thread Id from stepping requests.
---
.../test/tools/lldb_dap/dap_types.py | 16 +++------
.../lldbsuite/test/tools/lldb_dap/session.py | 23 -------------
.../test/tools/lldb_dap/session_helpers.py | 34 ++++++-------------
.../lldbsuite/test/tools/lldb_dap/utils.py | 16 +++++----
4 files changed, 24 insertions(+), 65 deletions(-)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
index 209cbabae4e80..cfd14f480e2a6 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
@@ -135,12 +135,12 @@ class Response(ProtocolMessage):
def __post_init__(self):
assert (
self.type == MessageType.RESPONSE
- ), f"expected '{type(self).__name__}' to be of type response: {self}"
+ ), f"expected '{type(self).__name__}' to be of type response: {self}."
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not dataclasses.is_dataclass(cls):
- raise TypeError(f"{cls.__name__} must be a dataclass")
+ raise TypeError(f"{cls.__name__!r} must be a dataclass.")
AnyResponse = TypeVar("AnyResponse", bound=Response)
@@ -601,18 +601,12 @@ class BreakpointReason(StrEnum):
REMOVED = "removed"
-class InvalidatedAreas(StrEnum):
- ALL = "all"
- STACKS = "stacks"
- THREADS = "threads"
- VARIABLES = "variables"
-
-
class StartDebuggingRequestType(StrEnum):
LAUNCH = "launch"
ATTACH = "attach"
+InvalidatedAreas = Literal["all", "stacks", "threads", "variables"]
ExceptionBreakMode = Literal["never", "always", "unhandled", "userUnhandled"]
ChecksumAlgorithm = Literal["MD5", "SHA1", "SHA256", "timestamp"]
@@ -1394,7 +1388,7 @@ class ContinueArgs:
@dataclass(frozen=True)
@args_protocol
class NextArgs:
- threadId: int = 0
+ threadId: int
singleThread: Optional[bool] = None
granularity: Optional[SteppingGranularity] = None
@@ -1405,7 +1399,7 @@ class NextArgs:
@dataclass(frozen=True)
@args_protocol
class StepInArgs:
- threadId: int = 0
+ threadId: int
singleThread: Optional[bool] = None
targetId: Optional[int] = None
granularity: Optional[SteppingGranularity] = None
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
index 4a3db82dabc0a..156c3e2a41d0d 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
@@ -1,10 +1,8 @@
# FIXME: remove when LLDB_MINIMUM_PYTHON_VERSION > 3.8
from __future__ import annotations
-import contextlib
import dataclasses
import functools
-import io
import itertools
import json
import logging
@@ -45,7 +43,6 @@
RunInTerminalResponse,
StepInArgs,
StepOutArgs,
- StoppedEvent,
TerminateArgs,
dict_to_message,
)
@@ -157,7 +154,6 @@ def __init__(self):
OutputCategory.IMPORTANT: OutputBuffer(),
OutputCategory.TELEMETRY: OutputBuffer(),
}
- self._stopped_thread_id: Optional[int] = None
self._stop_generation: int = 0
@property
@@ -169,16 +165,6 @@ def is_initialized(self):
def set_initialized(self, val: bool):
self._initialized = val
- @property
- @_synchronized
- def stopped_thread_id(self):
- return self._stopped_thread_id
-
- @_synchronized
- def set_stopped_thread_id(self, id: int):
- assert isinstance(id, int)
- self._stopped_thread_id = id
-
@property
@_synchronized
def stop_generation(self) -> int:
@@ -274,12 +260,6 @@ def last_event(self):
event = self._event_history.last_event()
return dataclasses.replace(event)
- @property
- def stopped_thread_id(self):
- thread_id = self._state.stopped_thread_id
- assert thread_id is not None, "stopped thread id is never set."
- return thread_id
-
def _current_stop_generation(self) -> int:
return self._state.stop_generation
@@ -319,9 +299,6 @@ def _on_protocol_event(self, message: RawMessage) -> None:
self._state.set_initialized(True)
elif isinstance(event, CapabilitiesEvent):
self._state.update_capabilities(event.body.capabilities)
- elif isinstance(event, StoppedEvent):
- if (thread_id := event.body.threadId) and not event.body.preserveFocusHint:
- self._state.set_stopped_thread_id(thread_id)
elif isinstance(event, OutputEvent):
category_buffer = self._state.output_buffers[event.body.category]
category_buffer.write(event.body.output)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
index 58c30bb8de04e..1e992883d03eb 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
@@ -745,17 +745,12 @@ def set_assembly_breakpoints(
return self.send_request(bp_args).result()
def set_function_breakpoints(
- self,
- breakpoints: list[str] | list[FunctionBreakpoint],
- condition: Optional[str] = None,
- hitCondition: Optional[str] = None,
+ self, breakpoints: list[str] | list[FunctionBreakpoint]
):
f_breakpoints: list[FunctionBreakpoint] = []
for bp in breakpoints:
if isinstance(bp, str):
- func_bp = FunctionBreakpoint(
- bp, condition=condition, hitCondition=hitCondition
- )
+ func_bp = FunctionBreakpoint(name=bp)
f_breakpoints.append(func_bp)
elif isinstance(bp, FunctionBreakpoint):
f_breakpoints.append(bp)
@@ -833,17 +828,14 @@ def resolve_source_breakpoints(
return self.breakpoints_to_ids(r_breakpoints)
def resolve_function_breakpoints(
- self,
- breakpoints: list[str] | list[FunctionBreakpoint],
- condition: Optional[str] = None,
- hitCondition: Optional[str] = None,
+ self, breakpoints: list[str] | list[FunctionBreakpoint]
) -> list[int]:
"""Sets breakpoints by function name given an array of function names
and returns an array of strings containing the breakpoint IDs
("1", "2") for each breakpoint that was set.
"""
last_event = self.last_event()
- response = self.set_function_breakpoints(breakpoints, condition, hitCondition)
+ response = self.set_function_breakpoints(breakpoints)
resp_bps = response.body.breakpoints
all_verified = all(bp.verified for bp in resp_bps)
@@ -890,15 +882,13 @@ def data_breakpoint_info_as_address(self, address: str, size: int):
def step_in(
self,
- threadId: Optional[int] = None,
+ threadId: int,
*,
targetId: Optional[int] = None,
granularity: SteppingGranularity = "statement",
):
- thread_id = threadId or self.stopped_thread_id
- self.test_case.assertIsNotNone(thread_id)
stepin_args = StepInArgs(
- threadId=thread_id, targetId=targetId, granularity=granularity
+ threadId=threadId, targetId=targetId, granularity=granularity
)
response = self.send_request(stepin_args).result()
stop_event = self.verify_stopped(StoppedReason.STEP, after=response)
@@ -906,26 +896,22 @@ def step_in(
def step_over(
self,
- threadId: Optional[int] = None,
+ threadId: int,
*,
granularity: SteppingGranularity = "statement",
):
- thread_id = threadId or self.stopped_thread_id
- self.test_case.assertIsNotNone(thread_id)
- next_args = NextArgs(threadId=thread_id, granularity=granularity)
+ next_args = NextArgs(threadId=threadId, granularity=granularity)
response = self.send_request(next_args).result()
stop_event = self.verify_stopped(StoppedReason.STEP, after=response)
return stop_event
def step_out(
self,
- threadId: Optional[int] = None,
+ threadId: int,
*,
granularity: SteppingGranularity = "statement",
):
- thread_id = threadId or self.stopped_thread_id
- self.test_case.assertIsNotNone(thread_id)
- step_out_args = StepOutArgs(threadId=thread_id, granularity=granularity)
+ step_out_args = StepOutArgs(threadId=threadId, granularity=granularity)
response = self.send_request(step_out_args).result()
stop_event = self.verify_stopped(StoppedReason.STEP, after=response)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
index cc1046855d097..826ffa88cbabb 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
@@ -522,15 +522,15 @@ def redirect_stream(
Returns a thread that redirects the stream.
"""
- def read_loop(in_stream: IO[bytes], out_buffer: OutputBuffer):
+ def read_loop(in_stream: IO[bytes], out_stream: OutputBuffer):
with contextlib.suppress(OSError, ValueError): # Nothing to report.
while True:
chunk = in_stream.read(4096)
if not chunk:
break
- out_buffer.write(chunk.decode(errors="replace"))
- out_buffer.flush()
+ out_stream.write(chunk.decode(errors="replace"))
+ out_stream.flush()
thread_name = f"redirect_{thread_name}"
redirect_thread = threading.Thread(
@@ -602,6 +602,7 @@ def __init__(self, connection_id: str, transport: Transport):
# A request that's been sent and is awaiting its response.
self._in_flight_requests: dict[int, tuple[RawMessage, Future[RawMessage]]] = {}
self._in_flight_lock = threading.Lock()
+ # received_messages is not accessed anywhere. It only exists for debugging purposes.
self._received_messages: list[RawMessage] = []
# Event to sync when the Connection start listening for messages.
@@ -647,7 +648,7 @@ def _read_loop(self, handler: MessageHandler):
error = None
try:
while self.is_alive():
- message = self._read_message()
+ message = DAPConnection.read_message(self._transport)
if not message:
break
@@ -701,13 +702,14 @@ def _on_message(self, message: RawMessage, handler: MessageHandler):
else:
raise DAPError(f"Unknown message type: {msg_type}")
- def _read_message(self):
+ @staticmethod
+ def read_message(transport: Transport):
HEADER_TERMINATOR = b"\r\n\r\n"
CONTENT_LEN_PREFIX = b"Content-Length: "
buffer = bytearray()
while True:
- chunk = self._transport.readline()
+ chunk = transport.readline()
if not chunk:
if buffer:
raise EOFError(f"unexpected EOF when parsing header: {buffer}")
@@ -733,7 +735,7 @@ def _read_message(self):
message_start = header_end + len(HEADER_TERMINATOR)
buffer = buffer[message_start:]
while len(buffer) < content_length:
- chunk = self._transport.read(content_length - len(buffer))
+ chunk = transport.read(content_length - len(buffer))
if not chunk:
raise EOFError(f"unexpected EOF when parsing message: {buffer}")
buffer += chunk
More information about the lldb-commits
mailing list