| # 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, Protocol |
| |
| 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, |
| StepOutArguments, |
| ThreadsResponse, |
| VariablesArguments, |
| VariablesResponse, |
| ) |
| |
| |
| class StreamWriterProtocol(Protocol): |
| """Protocol for writer objects handling raw byte writes.""" |
| |
| def write(self, data: bytes) -> None: |
| ... |
| |
| async def drain(self) -> None: |
| ... |
| |
| |
| READER_STOPPED_EVENT: str = "reader_stopped" |
| """Synthetic event type emitted to `event_queue` when the reader task terminates. |
| |
| This event is pushed to `event_queue` when the background reader task stops (due to EOF on stream, |
| cancellation, or an unrecoverable stream error). |
| |
| * **DapClient State**: The `DapClient` instance is in the process of shutting down or already closed. |
| `close()` is called immediately after emitting this event, cleaning up background reader/writer tasks |
| and clearing pending requests. `is_running` will return `False`. |
| * **Subsequent Events**: This is the final event placed on `event_queue`. Callers must not expect any further |
| events or responses from the client after receiving this. |
| * **Caller Expectations & Cleanup**: Callers consuming `event_queue` should catch this event and stop reading |
| from the queue. The event payload is `{"type": READER_STOPPED_EVENT, "exception": exc}`, where `exception` is |
| `None` if the reader exited cleanly or via cancellation, or the exception instance if caused by a failure. |
| Callers should perform any necessary caller-side cleanup (e.g. tearing down test fixtures or session state). |
| Once the client has finished closing (`is_running` is `False`), `run()` may be called again with a new reader, writer, and event_queue. |
| """ |
| logger = logging.getLogger(__name__) |
| |
| |
| class DapError(Exception): |
| """Base exception for DAP client errors.""" |
| |
| |
| class DapClient: |
| """A client for the Debug Adapter Protocol.""" |
| |
| DEFAULT_REQUEST_TIMEOUT: float = 5.0 |
| |
| def __init__(self) -> None: |
| """Initializes the DAP client.""" |
| self._pending_requests: dict[int, asyncio.Future[Any]] = {} |
| self._seq_counter = 1 |
| self._write_queue: asyncio.Queue[ |
| tuple[ |
| dict[str, Any], |
| asyncio.Future[None], |
| asyncio.Future[Any], |
| ] |
| ] = asyncio.Queue() |
| self._reader_task: asyncio.Task[None] | None = None |
| self._writer_task: asyncio.Task[None] | None = None |
| |
| @property |
| def is_running(self) -> bool: |
| """Returns True if both the reader loop and writer tasks are currently running.""" |
| return ( |
| self._reader_task is not None |
| and not self._reader_task.done() |
| and self._writer_task is not None |
| and not self._writer_task.done() |
| ) |
| |
| async def close(self) -> None: |
| """Closes the client and cancels active writer and reader tasks if running.""" |
| curr_task = asyncio.current_task() |
| tasks_to_cancel: list[asyncio.Task[None]] = [] |
| if ( |
| self._writer_task is not None |
| and not self._writer_task.done() |
| and self._writer_task != curr_task |
| ): |
| tasks_to_cancel.append(self._writer_task) |
| self._writer_task = None |
| |
| if ( |
| self._reader_task is not None |
| and not self._reader_task.done() |
| and self._reader_task != curr_task |
| ): |
| tasks_to_cancel.append(self._reader_task) |
| self._reader_task = None |
| |
| for task in tasks_to_cancel: |
| task.cancel() |
| try: |
| await task |
| except (asyncio.CancelledError, Exception): |
| pass |
| |
| while True: |
| try: |
| _, sent_fut, data_fut = self._write_queue.get_nowait() |
| except asyncio.QueueEmpty: |
| break |
| try: |
| if not sent_fut.done(): |
| sent_fut.cancel() |
| if not data_fut.done(): |
| data_fut.cancel() |
| except Exception: |
| pass |
| finally: |
| try: |
| self._write_queue.task_done() |
| except ValueError: |
| pass |
| |
| for fut in self._pending_requests.values(): |
| if not fut.done(): |
| fut.cancel() |
| self._pending_requests.clear() |
| |
| # The caller shouldn't directly manage this task. Use `run` and `close` instead. |
| async def _run_writer_task(self, writer: StreamWriterProtocol) -> None: |
| """Processes queued write requests sequentially in FIFO order. |
| |
| Args: |
| writer: Output stream writer to transmit messages to debug adapter. |
| """ |
| try: |
| while True: |
| # Cancellation is raised via client.close(). |
| request, sent_fut, data_fut = await self._write_queue.get() |
| try: |
| # If the caller gave up or cancelled before dequeuing, skip sending. |
| if not data_fut.done(): |
| await self._write_message(writer, request) |
| if not sent_fut.done(): |
| sent_fut.set_result(None) |
| elif not sent_fut.done(): |
| sent_fut.cancel() |
| except Exception as e: |
| seq = request.get("seq") |
| if seq is not None: |
| self._pending_requests.pop(seq, None) |
| if not sent_fut.done(): |
| sent_fut.set_exception(e) |
| if not data_fut.done(): |
| data_fut.set_exception(e) |
| logger.error("DAP write message failed: %s", e) |
| finally: |
| self._write_queue.task_done() |
| except asyncio.CancelledError: |
| pass |
| |
| async def _run_reader_task( |
| self, reader: asyncio.StreamReader, event_queue: asyncio.Queue[Any] |
| ) -> None: |
| """Runs the client's read loop, processing messages from the reader. |
| |
| Args: |
| reader: Input stream reader receiving messages from debug adapter. |
| event_queue: Destination queue for incoming DAP events. |
| """ |
| exc: Exception | None = None |
| try: |
| while True: |
| 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 asyncio.CancelledError: |
| pass |
| |
| # We cannot recover when _read_message encounters errors such as an invalid Content-Length, as there is no reliable way to parse corrupt stream data. |
| except Exception as e: |
| logger.exception("Error in DAP client reader task") |
| exc = e |
| # Set exception on all in-flight pending request futures so callers fail fast with root cause. |
| for fut in self._pending_requests.values(): |
| if not fut.done(): |
| fut.set_exception(e) |
| self._pending_requests.clear() |
| finally: |
| # Emit synthetic termination event so queue consumers wake up immediately and fail active event expectations without polling. |
| try: |
| await event_queue.put( |
| {"type": READER_STOPPED_EVENT, "exception": exc} |
| ) |
| except Exception as put_err: |
| logger.warning( |
| "Failed to emit READER_STOPPED_EVENT: %s", put_err |
| ) |
| finally: |
| await self.close() |
| |
| def run( |
| self, |
| reader: asyncio.StreamReader, |
| writer: StreamWriterProtocol, |
| event_queue: asyncio.Queue[Any], |
| ) -> None: |
| """Starts the client's read and write background tasks. |
| |
| Args: |
| reader: Stream reader to receive messages from the debug adapter. |
| writer: Stream writer to send messages to the debug adapter. |
| event_queue: Queue to put received DAP events into. |
| |
| Raises: |
| DapError: If the client is already running. |
| """ |
| if self.is_running: |
| raise DapError("DapClient is already running") |
| |
| self._reader_task = asyncio.create_task( |
| self._run_reader_task(reader, event_queue) |
| ) |
| self._writer_task = asyncio.create_task(self._run_writer_task(writer)) |
| |
| # To make requests execute `_write_message` by FIFO order, we rely on the run_writer_task. |
| def _send_request_future( |
| self, |
| command: str, |
| arguments: DapBaseModel | None = None, |
| ) -> tuple[int, asyncio.Future[None], asyncio.Future[dict[str, Any]]]: |
| """Sends a request to the debug adapter synchronously by queueing it for the write worker. |
| |
| Args: |
| command: The DAP command name. |
| arguments: Optional arguments for the command. |
| |
| Returns: |
| A tuple of (sequence number, sent future, response future). |
| |
| Raises: |
| DapError: If the client is not running. |
| TypeError: If arguments is not a DapBaseModel instance. |
| """ |
| if not self.is_running: |
| raise DapError( |
| "DapClient is not running. Call 'run()' before sending requests." |
| ) |
| |
| seq = self._seq_counter |
| self._seq_counter += 1 |
| |
| loop = asyncio.get_running_loop() |
| sent_fut: asyncio.Future[None] = loop.create_future() |
| data_fut: asyncio.Future[dict[str, Any]] = loop.create_future() |
| |
| 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() |
| |
| self._pending_requests[seq] = data_fut |
| self._write_queue.put_nowait((request, sent_fut, data_fut)) |
| |
| return seq, sent_fut, data_fut |
| |
| async def _await_request_response( |
| self, |
| command: str, |
| seq: int, |
| sent_fut: asyncio.Future[None], |
| data_fut: asyncio.Future[dict[str, Any]], |
| timeout: float, |
| ) -> dict[str, Any]: |
| """Awaits wire transmission and response for a request with centralized cleanup.""" |
| try: |
| async with asyncio.timeout(timeout): |
| await sent_fut |
| return await data_fut |
| except TimeoutError: |
| if not sent_fut.done(): |
| raise DapError( |
| f"Request {command} (seq={seq}) failed to write to socket within {timeout}s" |
| ) |
| raise DapError( |
| f"Request {command} (seq={seq}) sent over wire, but adapter timed out waiting for response after {timeout}s" |
| ) |
| finally: |
| self._pending_requests.pop(seq, None) |
| if not sent_fut.done(): |
| sent_fut.cancel() |
| if not data_fut.done(): |
| data_fut.cancel() |
| |
| async def _send_request( |
| self, |
| command: str, |
| arguments: DapBaseModel | None = None, |
| timeout: float = DEFAULT_REQUEST_TIMEOUT, |
| ) -> dict[str, Any]: |
| """Sends a request to the debug adapter and waits for the response. |
| |
| Args: |
| 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. |
| ValueError: If timeout is not positive. |
| """ |
| if timeout <= 0: |
| raise ValueError(f"timeout must be positive, got {timeout}") |
| |
| # Instead of directly calling _write_message here, |
| # we leverage _send_request_future, so this function inherently sends requests in FIFO order. |
| # FIFO may not be necessary for this function and its client, but we keep it for consistency. |
| seq, sent_fut, data_fut = self._send_request_future(command, arguments) |
| resp = await self._await_request_response( |
| command, seq, sent_fut, data_fut, timeout |
| ) |
| if not resp.get("success", True): |
| msg = resp.get("message", "Unknown DAP error") |
| logger.error( |
| "DAP request %s (seq=%s) failed: %s", command, seq, msg |
| ) |
| raise DapError(f"DAP request {command} failed: {msg}") |
| return resp |
| |
| async def initialize(self, args: InitializeArguments) -> Response: |
| """Sends an initialize request. |
| |
| Args: |
| args: Arguments for the initialize request. |
| |
| Returns: |
| The response model. |
| """ |
| resp = await self._send_request("initialize", args) |
| return Response.model_validate(resp) |
| |
| async def disconnect(self, args: DisconnectArguments) -> Response: |
| """Sends a disconnect request. |
| |
| Args: |
| args: Arguments for the disconnect request. |
| |
| Returns: |
| The response model. |
| """ |
| resp = await self._send_request("disconnect", args) |
| return Response.model_validate(resp) |
| |
| async def stack_trace( |
| self, args: StackTraceArguments |
| ) -> StackTraceResponse: |
| """Sends a stackTrace request. |
| |
| Args: |
| args: Arguments for the stackTrace request. |
| |
| Returns: |
| The stackTrace response model. |
| """ |
| resp = await self._send_request("stackTrace", args) |
| return StackTraceResponse.model_validate(resp) |
| |
| async def continue_thread( |
| self, args: ContinueArguments |
| ) -> ContinueResponse: |
| """Sends a continue request. |
| |
| Args: |
| args: Arguments for the continue request. |
| |
| Returns: |
| The continue response model. |
| """ |
| resp = await self._send_request("continue", args) |
| return ContinueResponse.model_validate(resp) |
| |
| async def pause_thread(self, args: PauseArguments) -> Response: |
| """Sends a pause request. |
| |
| Args: |
| args: Arguments for the pause request. |
| |
| Returns: |
| The response model. |
| """ |
| resp = await self._send_request("pause", args) |
| return Response.model_validate(resp) |
| |
| async def step_out(self, args: StepOutArguments) -> Response: |
| """Sends a stepOut request. |
| |
| Args: |
| args: Arguments for the stepOut request. |
| |
| Returns: |
| The response model. |
| """ |
| resp = await self._send_request("stepOut", args) |
| return Response.model_validate(resp) |
| |
| async def threads(self) -> ThreadsResponse: |
| """Sends a threads request. |
| |
| Returns: |
| The threads response model. |
| """ |
| resp = await self._send_request("threads") |
| return ThreadsResponse.model_validate(resp) |
| |
| async def attach(self, args: AttachRequestArguments) -> Response: |
| """Sends an attach request. |
| |
| Args: |
| args: Arguments for the attach request. |
| |
| Returns: |
| The response model. |
| """ |
| resp = await self._send_request("attach", args) |
| return Response.model_validate(resp) |
| |
| async def launch(self, args: LaunchArguments) -> Response: |
| """Sends a launch request. |
| |
| Args: |
| args: Arguments for the launch request. |
| |
| Returns: |
| The response model. |
| """ |
| resp = await self._send_request("launch", args) |
| return Response.model_validate(resp) |
| |
| async def evaluate(self, args: EvaluateArguments) -> EvaluateResponse: |
| """Sends an evaluate request. |
| |
| Args: |
| args: Arguments for the evaluate request. |
| |
| Returns: |
| The response model. |
| """ |
| resp = await self._send_request("evaluate", args) |
| return EvaluateResponse.model_validate(resp) |
| |
| async def scopes(self, args: ScopesArguments) -> ScopesResponse: |
| """Sends a scopes request. |
| |
| Args: |
| args: Arguments for the scopes request. |
| |
| Returns: |
| The scopes response model. |
| """ |
| resp = await self._send_request("scopes", args) |
| return ScopesResponse.model_validate(resp) |
| |
| async def variables(self, args: VariablesArguments) -> VariablesResponse: |
| """Sends a variables request. |
| |
| Args: |
| args: Arguments for the variables request. |
| |
| Returns: |
| The variables response model. |
| """ |
| resp = await self._send_request("variables", args) |
| return VariablesResponse.model_validate(resp) |
| |
| async def set_breakpoints( |
| self, args: SetBreakpointsArguments |
| ) -> SetBreakpointsResponse: |
| """Sends a setBreakpoints request. |
| |
| Args: |
| args: Arguments for the setBreakpoints request. |
| |
| Returns: |
| The setBreakpoints response model. |
| """ |
| resp = await self._send_request("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: StreamWriterProtocol, 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() |