blob: 96610037ef82f403ec5ddc000f95e6c98165c657 [file]
# 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()