[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