[Lldb-commits] [lldb] 0c69421 - [lldb-dap] Add a new lldb-dap test infrastructure (#203978)
via lldb-commits
lldb-commits at lists.llvm.org
Wed Jul 1 08:15:16 PDT 2026
Author: Ebuka Ezike
Date: 2026-07-01T16:15:11+01:00
New Revision: 0c69421ce1a6a756c3581f9c0a031762a5bc678f
URL: https://github.com/llvm/llvm-project/commit/0c69421ce1a6a756c3581f9c0a031762a5bc678f
DIFF: https://github.com/llvm/llvm-project/commit/0c69421ce1a6a756c3581f9c0a031762a5bc678f.diff
LOG: [lldb-dap] Add a new lldb-dap test infrastructure (#203978)
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 files:
- 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).
Added:
lldb/packages/Python/lldbsuite/test/tools/lldb_dap/__init__.py
lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py
lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_Types.py
Modified:
lldb/test/API/tools/lldb-dap/launch/TestDAP_launch_basic.py
Removed:
################################################################################
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..cfd14f480e2a6
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
@@ -0,0 +1,2026 @@
+# 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,
+ cast,
+ 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__!r} 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) -> 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 cls not in (Event, event_class):
+ raise ValueError(
+ f"class: {cls.__name__!r} is not of the expected type {event_class.__name__!r}."
+ )
+
+ if not dataclasses.is_dataclass(event_class):
+ raise ValueError(f"{cls.__name__!r} must be a dataclass.")
+
+ 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)
+
+
+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:
+ 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} for {cls.__name__!r}."
+ )
+ 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 StartDebuggingRequestType(StrEnum):
+ LAUNCH = "launch"
+ ATTACH = "attach"
+
+
+InvalidatedAreas = Literal["all", "stacks", "threads", "variables"]
+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
+ singleThread: Optional[bool] = None
+ granularity: Optional[SteppingGranularity] = None
+
+ command_ = "next"
+ response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class StepInArgs:
+ threadId: int
+ 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):
+ @dataclass(frozen=True)
+ 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..2b2412bcea072
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py
@@ -0,0 +1,209 @@
+import logging
+import os
+from pathlib import Path
+from typing import Any, Final, Optional, TypeVar, Union, cast
+
+from lldbsuite.test.lldbtest import Base, LLDBTestCaseFactory, 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, metaclass=LLDBTestCaseFactory):
+ """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 __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: 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.")
+
+ 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():
+ if disconnect_automatically:
+ self.logger.debug("Automatically disconnecting.")
+ session.disconnect(terminateDebuggee=True)
+ session.stop()
+
+ session.start()
+ self.addTearDownHook(cleanup_session)
+ return session
+
+ def build_and_create_session(
+ self,
+ adapter: Optional[DebugAdapter] = None,
+ disconnect_automatically: bool = True,
+ ) -> 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..156c3e2a41d0d
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
@@ -0,0 +1,492 @@
+# FIXME: remove when LLDB_MINIMUM_PYTHON_VERSION > 3.8
+from __future__ import annotations
+
+import dataclasses
+import functools
+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,
+ TerminateArgs,
+ dict_to_message,
+)
+from .utils import (
+ DebugAdapter,
+ EventHistory,
+ MessageHandler,
+ OutputBuffer,
+ 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 'ErrorResponse' 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) if e.args else (msg,)
+ 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: bool = False
+ self._capabilities = Capabilities()
+ self.output_buffers = {
+ OutputCategory.STDOUT: OutputBuffer(),
+ OutputCategory.STDERR: OutputBuffer(),
+ OutputCategory.CONSOLE: OutputBuffer(),
+ OutputCategory.IMPORTANT: OutputBuffer(),
+ OutputCategory.TELEMETRY: OutputBuffer(),
+ }
+ 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 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)
+
+
+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_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)
+
+ 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, OutputEvent):
+ 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 output buffer 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_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_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)
+
+ 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) -> str:
+ return self._state.output_buffers[OutputCategory.STDOUT].getvalue()
+
+ def get_console(self) -> str:
+ return self._state.output_buffers[OutputCategory.CONSOLE].getvalue()
+
+ def get_stderr(self) -> str:
+ return self._state.output_buffers[OutputCategory.STDERR].getvalue()
+
+ def get_important(self) -> str:
+ return self._state.output_buffers[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_,
+ )
+
+ 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..1e992883d03eb
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
@@ -0,0 +1,1698 @@
+# 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:
+ 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.
+
+ 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, breakpoints: list[str] | list[FunctionBreakpoint]
+ ):
+ f_breakpoints: list[FunctionBreakpoint] = []
+ for bp in breakpoints:
+ if isinstance(bp, str):
+ func_bp = FunctionBreakpoint(name=bp)
+ 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
+
+ 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_event = self.last_event()
+ 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_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_event)
+ 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_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_event)
+
+ 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, 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)
+ 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_event)
+
+ return self.breakpoints_to_ids(resp_bps)
+
+ 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 get_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: int,
+ *,
+ targetId: Optional[int] = None,
+ granularity: SteppingGranularity = "statement",
+ ):
+ stepin_args = StepInArgs(
+ threadId=threadId, 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: int,
+ *,
+ granularity: SteppingGranularity = "statement",
+ ):
+ 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: int,
+ *,
+ granularity: SteppingGranularity = "statement",
+ ):
+ 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)
+ 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_event(
+ 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_event(
+ 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_event(
+ 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_event(
+ 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_event(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_event(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_event(self, *, after: Event | Response):
+ return self.wait_for_event(TerminatedEvent, after=after)
+
+ def wait_for_invalidated_event(self, *, after: Event | Response):
+ return self.wait_for_event(InvalidatedEvent, after=after)
+
+ def wait_for_memory_event(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_event(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_event(
+ 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_event(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):
+ # 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:
+ val_bytes = value
+ data = base64.b64encode(val_bytes).decode()
+
+ before_request = self.last_event()
+ 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_event(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..db89d9f77ceba
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
@@ -0,0 +1,847 @@
+# FIXME: remove when LLDB_MINIMUM_PYTHON_VERSION > 3.8
+from __future__ import annotations
+
+import bisect
+import contextlib
+import io
+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
diff erent 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 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],
+ *,
+ 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)
+
+
+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_buffer: OutputBuffer, 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_buffer: 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()
+
+ thread_name = f"redirect_{thread_name}"
+ redirect_thread = threading.Thread(
+ target=read_loop,
+ name=thread_name,
+ args=[in_stream, out_buffer],
+ 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()
+ # 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.
+ self._is_ready = threading.Event()
+ self._is_ready.clear()
+
+ def start(self, handler: MessageHandler):
+ 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):
+ self._is_ready.set()
+ error = None
+ try:
+ while self.is_alive():
+ message = DAPConnection.read_message(self._transport)
+ 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()]
+ self._in_flight_requests = {}
+
+ 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}")
+
+ @staticmethod
+ def read_message(transport: Transport):
+ HEADER_TERMINATOR = b"\r\n\r\n"
+ CONTENT_LEN_PREFIX = b"Content-Length: "
+ buffer = bytearray()
+
+ while True:
+ chunk = 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 = 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/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_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
diff erent 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))
More information about the lldb-commits
mailing list