| # Copyright 2026 The Fuchsia Authors. All rights reserved. | |
| # Use of this source code is governed by a BSD-style license that can be | |
| # found in the LICENSE file. | |
| import asyncio | |
| import json | |
| import logging | |
| from typing import Any | |
| from .dap_types import DapBaseModel | |
| from .models import ( | |
| AttachRequestArguments, | |
| ContinueArguments, | |
| ContinueResponse, | |
| DisconnectArguments, | |
| EvaluateArguments, | |
| EvaluateResponse, | |
| InitializeArguments, | |
| LaunchArguments, | |
| MessageType, | |
| PauseArguments, | |
| Response, | |
| ScopesArguments, | |
| ScopesResponse, | |
| SetBreakpointsArguments, | |
| SetBreakpointsResponse, | |
| StackTraceArguments, | |
| StackTraceResponse, | |
| ThreadsResponse, | |
| VariablesArguments, | |
| VariablesResponse, | |
| ) | |
| logger = logging.getLogger(__name__) | |
| class DapError(Exception): | |
| """Base exception for DAP client errors.""" | |
| class DapClient: | |
| """A client for the Debug Adapter Protocol.""" | |
| def __init__(self) -> None: | |
| """Initializes the DAP client.""" | |
| self._pending_requests: dict[int, asyncio.Future[Any]] = {} | |
| self._seq_counter = 1 | |
| async def run( | |
| self, reader: asyncio.StreamReader, event_queue: asyncio.Queue[Any] | |
| ) -> None: | |
| """Runs the client's read loop, processing messages from the reader. | |
| Args: | |
| reader: Stream reader to receive messages from the debug adapter. | |
| event_queue: Queue to put received DAP events into. | |
| """ | |
| while True: | |
| try: | |
| msg = await self._read_message(reader) | |
| if msg is None: | |
| break # EOF | |
| msg_type = msg.get("type") | |
| if msg_type == MessageType.EVENT.value: | |
| await event_queue.put(msg) | |
| elif msg_type == MessageType.RESPONSE.value: | |
| req_seq = msg.get("request_seq") | |
| if req_seq in self._pending_requests: | |
| fut = self._pending_requests.pop(req_seq) | |
| if not fut.done(): | |
| fut.set_result(msg) | |
| except Exception: | |
| logger.exception("Error in DAP client run loop") | |
| break | |
| async def _send_request( | |
| self, | |
| writer: asyncio.StreamWriter, | |
| command: str, | |
| arguments: DapBaseModel | None = None, | |
| timeout: float = 5.0, | |
| ) -> dict[str, Any]: | |
| """Sends a request to the debug adapter and waits for the response. | |
| Args: | |
| writer: Stream writer to send the request to. | |
| command: The DAP command name. | |
| arguments: Optional arguments for the command. | |
| timeout: Maximum time to wait for response in seconds. | |
| Returns: | |
| The response message dictionary from the adapter. | |
| Raises: | |
| DapError: If the request times out or framing fails. | |
| """ | |
| seq = self._seq_counter | |
| self._seq_counter += 1 | |
| loop = asyncio.get_running_loop() | |
| fut = loop.create_future() | |
| self._pending_requests[seq] = fut | |
| request: dict[str, Any] = { | |
| "seq": seq, | |
| "type": MessageType.REQUEST.value, | |
| "command": command, | |
| } | |
| if arguments is not None: | |
| if not isinstance(arguments, DapBaseModel): | |
| raise TypeError( | |
| f"arguments must be a DapBaseModel, got {type(arguments)}" | |
| ) | |
| request["arguments"] = arguments.dump_dap() | |
| await self._write_message(writer, request) | |
| try: | |
| resp = await asyncio.wait_for(fut, timeout=timeout) | |
| if not resp.get("success", True): | |
| msg = resp.get("message", "Unknown DAP error") | |
| logger.error(f"DAP request {command} (seq={seq}) failed: {msg}") | |
| raise DapError(f"DAP request {command} failed: {msg}") | |
| return resp | |
| except asyncio.TimeoutError: | |
| self._pending_requests.pop(seq, None) | |
| raise DapError( | |
| f"Request {command} (seq={seq}) timed out after {timeout}s" | |
| ) | |
| async def initialize( | |
| self, writer: asyncio.StreamWriter, args: InitializeArguments | |
| ) -> Response: | |
| """Sends an initialize request. | |
| Args: | |
| writer: Stream writer to send the request to. | |
| args: Arguments for the initialize request. | |
| Returns: | |
| The response model. | |
| """ | |
| resp = await self._send_request(writer, "initialize", args) | |
| return Response.model_validate(resp) | |
| async def disconnect( | |
| self, writer: asyncio.StreamWriter, args: DisconnectArguments | |
| ) -> Response: | |
| """Sends a disconnect request. | |
| Args: | |
| writer: Stream writer to send the request to. | |
| args: Arguments for the disconnect request. | |
| Returns: | |
| The response model. | |
| """ | |
| resp = await self._send_request(writer, "disconnect", args) | |
| return Response.model_validate(resp) | |
| async def stack_trace( | |
| self, writer: asyncio.StreamWriter, args: StackTraceArguments | |
| ) -> StackTraceResponse: | |
| """Sends a stackTrace request. | |
| Args: | |
| writer: Stream writer to send the request to. | |
| args: Arguments for the stackTrace request. | |
| Returns: | |
| The stackTrace response model. | |
| """ | |
| resp = await self._send_request(writer, "stackTrace", args) | |
| return StackTraceResponse.model_validate(resp) | |
| async def continue_thread( | |
| self, writer: asyncio.StreamWriter, args: ContinueArguments | |
| ) -> ContinueResponse: | |
| """Sends a continue request. | |
| Args: | |
| writer: Stream writer to send the request to. | |
| args: Arguments for the continue request. | |
| Returns: | |
| The continue response model. | |
| """ | |
| resp = await self._send_request(writer, "continue", args) | |
| return ContinueResponse.model_validate(resp) | |
| async def pause_thread( | |
| self, writer: asyncio.StreamWriter, args: PauseArguments | |
| ) -> Response: | |
| """Sends a pause request. | |
| Args: | |
| writer: Stream writer to send the request to. | |
| args: Arguments for the pause request. | |
| Returns: | |
| The response model. | |
| """ | |
| resp = await self._send_request(writer, "pause", args) | |
| return Response.model_validate(resp) | |
| async def threads(self, writer: asyncio.StreamWriter) -> ThreadsResponse: | |
| """Sends a threads request. | |
| Args: | |
| writer: Stream writer to send the request to. | |
| Returns: | |
| The threads response model. | |
| """ | |
| resp = await self._send_request(writer, "threads") | |
| return ThreadsResponse.model_validate(resp) | |
| async def attach( | |
| self, writer: asyncio.StreamWriter, args: AttachRequestArguments | |
| ) -> Response: | |
| """Sends an attach request. | |
| Args: | |
| writer: Stream writer to send the request to. | |
| args: Arguments for the attach request. | |
| Returns: | |
| The response model. | |
| """ | |
| resp = await self._send_request(writer, "attach", args) | |
| return Response.model_validate(resp) | |
| async def launch( | |
| self, writer: asyncio.StreamWriter, args: LaunchArguments | |
| ) -> Response: | |
| """Sends a launch request. | |
| Args: | |
| writer: Stream writer to send the request to. | |
| args: Arguments for the launch request. | |
| Returns: | |
| The response model. | |
| """ | |
| resp = await self._send_request(writer, "launch", args) | |
| return Response.model_validate(resp) | |
| async def evaluate( | |
| self, writer: asyncio.StreamWriter, args: EvaluateArguments | |
| ) -> EvaluateResponse: | |
| """Sends an evaluate request. | |
| Args: | |
| writer: Stream writer to send the request to. | |
| args: Arguments for the evaluate request. | |
| Returns: | |
| The response model. | |
| """ | |
| resp = await self._send_request(writer, "evaluate", args) | |
| return EvaluateResponse.model_validate(resp) | |
| async def scopes( | |
| self, writer: asyncio.StreamWriter, args: ScopesArguments | |
| ) -> ScopesResponse: | |
| """Sends a scopes request. | |
| Args: | |
| writer: Stream writer to send the request to. | |
| args: Arguments for the scopes request. | |
| Returns: | |
| The scopes response model. | |
| """ | |
| resp = await self._send_request(writer, "scopes", args) | |
| return ScopesResponse.model_validate(resp) | |
| async def variables( | |
| self, writer: asyncio.StreamWriter, args: VariablesArguments | |
| ) -> VariablesResponse: | |
| """Sends a variables request. | |
| Args: | |
| writer: Stream writer to send the request to. | |
| args: Arguments for the variables request. | |
| Returns: | |
| The variables response model. | |
| """ | |
| resp = await self._send_request(writer, "variables", args) | |
| return VariablesResponse.model_validate(resp) | |
| async def set_breakpoints( | |
| self, writer: asyncio.StreamWriter, args: SetBreakpointsArguments | |
| ) -> SetBreakpointsResponse: | |
| """Sends a setBreakpoints request. | |
| Args: | |
| writer: Stream writer to send the request to. | |
| args: Arguments for the setBreakpoints request. | |
| Returns: | |
| The setBreakpoints response model. | |
| """ | |
| resp = await self._send_request(writer, "setBreakpoints", args) | |
| return SetBreakpointsResponse.model_validate(resp) | |
| async def _read_message( | |
| self, reader: asyncio.StreamReader | |
| ) -> dict[str, Any] | None: | |
| """Reads a single message from the reader, handling protocol framing. | |
| Args: | |
| reader: Stream reader to read from. | |
| Returns: | |
| The parsed message dictionary, or None on EOF. | |
| Raises: | |
| DapError: If framing headers are invalid or missing. | |
| """ | |
| content_length = None | |
| while True: | |
| line = await reader.readline() | |
| if not line: | |
| return None # EOF | |
| trimmed = line.decode("utf-8").strip() | |
| if not trimmed: | |
| break # End of headers | |
| if trimmed.startswith("Content-Length:"): | |
| parts = trimmed.split(":") | |
| if len(parts) >= 2: | |
| try: | |
| content_length = int(parts[1].strip()) | |
| except ValueError: | |
| raise DapError(f"Invalid Content-Length: {trimmed}") | |
| if content_length is None: | |
| raise DapError("Missing Content-Length header") | |
| body = await reader.readexactly(content_length) | |
| return json.loads(body.decode("utf-8")) | |
| async def _write_message( | |
| self, writer: asyncio.StreamWriter, value: dict[str, Any] | |
| ) -> None: | |
| """Writes a message to the writer, handling protocol framing. | |
| Args: | |
| writer: Stream writer to write to. | |
| value: The message dictionary to serialize and send. | |
| """ | |
| content = json.dumps(value, separators=(",", ":")).encode("utf-8") | |
| header = f"Content-Length: {len(content)}\r\n\r\n".encode("utf-8") | |
| writer.write(header) | |
| writer.write(content) | |
| await writer.drain() |