use std::collections::HashMap;
use std::io;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use serde::Serialize;
use serde_json::Value;
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite};
use tokio::sync::{Mutex, oneshot};
use tokio::task::JoinHandle;
use tokio::time::timeout;
use crate::dap::types::Capabilities;
use crate::jsonrpc_framing::{decode_frame, encode_frame};
const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, thiserror::Error)]
pub enum RpcError {
#[error("adapter error: {0}")]
Server(String),
#[error("connection closed before response arrived")]
ConnectionClosed,
#[error("request timed out after {0:?}")]
Timeout(Duration),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("serialization error: {0}")]
Serialize(#[from] serde_json::Error),
}
type Pending = HashMap<u64, oneshot::Sender<Result<Value, RpcError>>>;
pub type NotificationHandler = Box<dyn Fn(Value) + Send + Sync>;
struct Inner {
next_seq: AtomicU64,
pending: Mutex<Pending>,
handlers: Mutex<HashMap<String, Arc<dyn Fn(Value) + Send + Sync>>>,
writer: Mutex<Box<dyn AsyncWrite + Send + Unpin>>,
closed: std::sync::atomic::AtomicBool,
}
#[derive(Clone)]
pub struct DapRpc {
inner: Arc<Inner>,
}
impl DapRpc {
pub fn new<R, W>(reader: R, writer: W) -> (Self, JoinHandle<io::Result<()>>)
where
R: AsyncBufRead + Send + Unpin + 'static,
W: AsyncWrite + Send + Unpin + 'static,
{
let inner = Arc::new(Inner {
next_seq: AtomicU64::new(1),
pending: Mutex::new(HashMap::new()),
handlers: Mutex::new(HashMap::new()),
writer: Mutex::new(Box::new(writer)),
closed: std::sync::atomic::AtomicBool::new(false),
});
let rpc = DapRpc {
inner: inner.clone(),
};
let task = tokio::spawn(read_loop(inner, reader));
(rpc, task)
}
pub async fn request<P, R>(
&self,
command: &str,
arguments: P,
request_timeout: Duration,
) -> Result<R, RpcError>
where
P: Serialize,
R: serde::de::DeserializeOwned,
{
if self.inner.closed.load(Ordering::SeqCst) {
return Err(RpcError::ConnectionClosed);
}
let seq = self.inner.next_seq.fetch_add(1, Ordering::SeqCst);
let body = serde_json::json!({
"type": "request",
"seq": seq,
"command": command,
"arguments": serde_json::to_value(arguments)?,
});
let bytes = serde_json::to_vec(&body)?;
let (tx, rx) = oneshot::channel();
self.inner.pending.lock().await.insert(seq, tx);
let send_result = timeout(WRITE_TIMEOUT, async {
let mut writer = self.inner.writer.lock().await;
encode_frame(&mut *writer, &bytes).await
})
.await;
match send_result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
self.inner.pending.lock().await.remove(&seq);
return Err(RpcError::Io(e));
}
Err(_) => {
self.inner.pending.lock().await.remove(&seq);
return Err(RpcError::Timeout(WRITE_TIMEOUT));
}
}
let value = match timeout(request_timeout, rx).await {
Ok(Ok(result)) => result?,
Ok(Err(_)) => {
self.inner.pending.lock().await.remove(&seq);
return Err(RpcError::ConnectionClosed);
}
Err(_) => {
self.inner.pending.lock().await.remove(&seq);
return Err(RpcError::Timeout(request_timeout));
}
};
Ok(serde_json::from_value(value)?)
}
pub async fn notify<P>(&self, command: &str, arguments: P) -> Result<(), RpcError>
where
P: Serialize,
{
if self.inner.closed.load(Ordering::SeqCst) {
return Err(RpcError::ConnectionClosed);
}
let seq = self.inner.next_seq.fetch_add(1, Ordering::SeqCst);
let body = serde_json::json!({
"type": "request",
"seq": seq,
"command": command,
"arguments": serde_json::to_value(arguments)?,
});
let bytes = serde_json::to_vec(&body)?;
match timeout(WRITE_TIMEOUT, async {
let mut writer = self.inner.writer.lock().await;
encode_frame(&mut *writer, &bytes).await
})
.await
{
Ok(r) => r?,
Err(_) => return Err(RpcError::Timeout(WRITE_TIMEOUT)),
}
Ok(())
}
pub async fn on_event(&self, event_type: &str, handler: NotificationHandler) {
self.inner
.handlers
.lock()
.await
.insert(event_type.to_string(), Arc::from(handler));
}
}
async fn read_loop<R>(inner: Arc<Inner>, mut reader: R) -> io::Result<()>
where
R: AsyncBufRead + Send + Unpin,
{
loop {
let frame = match decode_frame(&mut reader).await {
Ok(b) => b,
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
Err(e) => return Err(e),
};
let msg: Value = match serde_json::from_slice(&frame) {
Ok(v) => v,
Err(e) => {
tracing::warn!("dap: skipping non-JSON frame: {e}");
continue;
}
};
dispatch(&inner, msg).await;
}
inner.closed.store(true, Ordering::SeqCst);
let mut pending = inner.pending.lock().await;
for (_, sender) in pending.drain() {
let _ = sender.send(Err(RpcError::ConnectionClosed));
}
Ok(())
}
async fn dispatch(inner: &Arc<Inner>, msg: Value) {
let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("");
match msg_type {
"response" => {
let request_seq = msg.get("request_seq").and_then(|v| v.as_u64());
if let Some(seq) = request_seq {
let sender = inner.pending.lock().await.remove(&seq);
if let Some(sender) = sender {
let result = if msg.get("success").and_then(|v| v.as_bool()) == Some(true) {
Ok(msg.get("body").cloned().unwrap_or(Value::Null))
} else {
let message = msg
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("(no message)")
.to_string();
Err(RpcError::Server(message))
};
let _ = sender.send(result);
}
}
}
"event" => {
let event_type = msg.get("event").and_then(|v| v.as_str()).unwrap_or("");
let handler = inner.handlers.lock().await.get(event_type).cloned();
if let Some(handler) = handler {
let body = msg.get("body").cloned().unwrap_or(Value::Null);
handler(body);
}
}
_ => {
tracing::warn!("dap: unexpected message type {msg_type:?}, ignoring");
}
}
}
#[cfg(unix)]
struct DapProcessGuard {
pgid: u32,
}
#[cfg(unix)]
impl Drop for DapProcessGuard {
fn drop(&mut self) {
if self.pgid <= 1 {
return;
}
unsafe {
let _ = libc::kill(-(self.pgid as libc::pid_t), libc::SIGKILL);
}
}
}
pub struct DapClient {
#[cfg(unix)]
_pg_guard: Option<DapProcessGuard>,
_child: Option<tokio::process::Child>,
pub(crate) rpc: DapRpc,
_stderr_task: JoinHandle<()>,
pub capabilities: Mutex<Option<Capabilities>>,
pub adapter_name: String,
}
impl DapClient {
pub async fn spawn_stdio(
adapter_name: &str,
program: &Path,
args: &[String],
cwd: &Path,
) -> io::Result<Self> {
let mut cmd = tokio::process::Command::new(program);
cmd.args(args)
.current_dir(cwd)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
#[cfg(unix)]
unsafe {
use std::os::unix::process::CommandExt;
cmd.as_std_mut().pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let mut child = cmd.spawn()?;
#[cfg(unix)]
let pg_guard = child.id().map(|pid| DapProcessGuard { pgid: pid });
let stdin = child
.stdin
.take()
.ok_or_else(|| io::Error::other("adapter stdin pipe unavailable"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| io::Error::other("adapter stdout pipe unavailable"))?;
let stderr_task = if let Some(stderr) = child.stderr.take() {
let name = adapter_name.to_string();
tokio::spawn(async move {
let mut reader = tokio::io::BufReader::new(stderr);
let mut buf = String::new();
loop {
buf.clear();
match reader.read_line(&mut buf).await {
Ok(0) => break,
Ok(_) => tracing::debug!(adapter = %name, "{}", buf.trim_end()),
Err(_) => break,
}
}
})
} else {
tokio::spawn(std::future::ready(()))
};
let reader = tokio::io::BufReader::new(stdout);
let (rpc, _read_task) = DapRpc::new(reader, stdin);
Ok(Self {
_child: Some(child),
#[cfg(unix)]
_pg_guard: pg_guard,
rpc,
_stderr_task: stderr_task,
capabilities: Mutex::new(None),
adapter_name: adapter_name.to_string(),
})
}
pub async fn request<P, R>(
&self,
command: &str,
arguments: P,
timeout_dur: Duration,
) -> Result<R, RpcError>
where
P: Serialize,
R: serde::de::DeserializeOwned,
{
self.rpc.request(command, arguments, timeout_dur).await
}
pub async fn notify<P>(&self, command: &str, arguments: P) -> Result<(), RpcError>
where
P: Serialize,
{
self.rpc.notify(command, arguments).await
}
pub async fn on_event(&self, event_type: &str, handler: NotificationHandler) {
self.rpc.on_event(event_type, handler).await
}
#[cfg(test)]
pub(crate) fn from_rpc(rpc: DapRpc, adapter_name: &str) -> Self {
Self {
_child: None,
#[cfg(unix)]
_pg_guard: None,
rpc,
_stderr_task: tokio::spawn(std::future::ready(())),
capabilities: Mutex::new(None),
adapter_name: adapter_name.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn fake_adapter(
client_reader: &mut (impl AsyncBufRead + Unpin),
client_writer: &mut (impl AsyncWrite + Unpin),
) {
let frame = decode_frame(client_reader).await.unwrap();
let msg: Value = serde_json::from_slice(&frame).unwrap();
assert_eq!(msg["type"], "request");
let seq = msg["seq"].as_u64().unwrap();
let resp = serde_json::json!({
"type": "response",
"seq": 1,
"request_seq": seq,
"success": true,
"command": "initialize",
"body": {
"supportsConfigurationDoneRequest": true,
"supportsFunctionBreakpoints": false,
}
});
encode_frame(client_writer, &serde_json::to_vec(&resp).unwrap())
.await
.unwrap();
let evt = serde_json::json!({
"type": "event",
"seq": 2,
"event": "stopped",
"body": {
"reason": "entry",
"threadId": 1,
}
});
encode_frame(client_writer, &serde_json::to_vec(&evt).unwrap())
.await
.unwrap();
}
#[tokio::test]
async fn request_response_roundtrip() {
let (client_side, server_side) = tokio::io::duplex(4096);
let (client_read, client_write) = tokio::io::split(client_side);
let (server_read, mut server_write) = tokio::io::split(server_side);
let client_reader = tokio::io::BufReader::new(client_read);
let (rpc, _task) = DapRpc::new(client_reader, client_write);
let adapter = tokio::spawn(async move {
fake_adapter(
&mut tokio::io::BufReader::new(server_read),
&mut server_write,
)
.await;
});
let response: Value = rpc
.request(
"initialize",
serde_json::json!({ "adapterID": "test" }),
Duration::from_secs(5),
)
.await
.unwrap();
assert_eq!(response["supportsConfigurationDoneRequest"], true);
assert_eq!(response["supportsFunctionBreakpoints"], false);
adapter.await.unwrap();
}
#[tokio::test]
async fn event_handler_receives_stopped() {
let (client_side, server_side) = tokio::io::duplex(4096);
let (client_read, client_write) = tokio::io::split(client_side);
let (server_read, mut server_write) = tokio::io::split(server_side);
let client_reader = tokio::io::BufReader::new(client_read);
let (rpc, _task) = DapRpc::new(client_reader, client_write);
let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel();
rpc.on_event(
"stopped",
Box::new(move |body| {
let _ = evt_tx.send(body);
}),
)
.await;
let adapter = tokio::spawn(async move {
fake_adapter(
&mut tokio::io::BufReader::new(server_read),
&mut server_write,
)
.await;
});
let _: Value = rpc
.request("initialize", serde_json::json!({}), Duration::from_secs(5))
.await
.unwrap();
let event_body = evt_rx.recv().await.unwrap();
assert_eq!(event_body["reason"], "entry");
assert_eq!(event_body["threadId"], 1);
adapter.await.unwrap();
}
#[tokio::test]
async fn request_timeout() {
let (client_side, _server_side) = tokio::io::duplex(4096);
let (client_read, client_write) = tokio::io::split(client_side);
let client_reader = tokio::io::BufReader::new(client_read);
let (rpc, _task) = DapRpc::new(client_reader, client_write);
let err = rpc
.request::<_, Value>("launch", serde_json::json!({}), Duration::from_millis(100))
.await
.unwrap_err();
assert!(matches!(err, RpcError::Timeout(_)));
}
#[tokio::test]
async fn connection_closed_on_eof() {
let (client_side, server_side) = tokio::io::duplex(4096);
let (client_read, client_write) = tokio::io::split(client_side);
let client_reader = tokio::io::BufReader::new(client_read);
let (rpc, task) = DapRpc::new(client_reader, client_write);
drop(server_side);
let _ = task.await;
let err = rpc
.request::<_, Value>("launch", serde_json::json!({}), Duration::from_secs(5))
.await
.unwrap_err();
assert!(matches!(err, RpcError::ConnectionClosed));
}
fn mock_adapter_path() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("mock_dap_adapter.py")
}
#[tokio::test]
async fn full_lifecycle_against_mock_adapter() {
use crate::dap::types::{
Capabilities, ContinueResponse, EvaluateResponse, ScopesResponse,
SetBreakpointsResponse, StackTraceResponse, ThreadsResponse, VariablesResponse,
};
let program = mock_adapter_path();
assert!(
program.exists(),
"mock adapter must exist at {}",
program.display()
);
let client = super::DapClient::spawn_stdio(
"mock",
std::path::Path::new("python3"),
&[program.to_string_lossy().to_string()],
std::path::Path::new("."),
)
.await
.expect("mock adapter should spawn");
let caps: Capabilities = client
.request(
"initialize",
serde_json::json!({
"adapterID": "mock",
"clientID": "dirge-test",
"linesStartAt1": true,
"columnsStartAt1": true,
"pathFormat": "path"
}),
std::time::Duration::from_secs(5),
)
.await
.expect("initialize should succeed");
assert!(
caps.supports_configuration_done_request.unwrap_or(false),
"mock adapter should support configurationDoneRequest"
);
client
.request::<_, Value>(
"launch",
serde_json::json!({"program": "/tmp/test.py", "stopOnEntry": true}),
std::time::Duration::from_secs(5),
)
.await
.expect("launch should succeed");
let bp_response: SetBreakpointsResponse = client
.request(
"setBreakpoints",
serde_json::json!({
"source": {"path": "/tmp/test.py"},
"breakpoints": [{"line": 10}]
}),
std::time::Duration::from_secs(5),
)
.await
.expect("setBreakpoints should succeed");
assert_eq!(bp_response.breakpoints.len(), 1);
assert!(bp_response.breakpoints[0].verified);
client
.request::<_, Value>(
"configurationDone",
serde_json::json!({}),
std::time::Duration::from_secs(5),
)
.await
.expect("configurationDone should succeed");
let threads_response: ThreadsResponse = client
.request(
"threads",
serde_json::json!({}),
std::time::Duration::from_secs(5),
)
.await
.expect("threads should succeed");
assert!(!threads_response.threads.is_empty());
let thread_id = threads_response.threads[0].id;
let st_response: StackTraceResponse = client
.request(
"stackTrace",
serde_json::json!({"threadId": thread_id, "levels": 10}),
std::time::Duration::from_secs(5),
)
.await
.expect("stackTrace should succeed");
assert!(!st_response.stack_frames.is_empty());
let frame_id = st_response.stack_frames[0].id;
let scopes_response: ScopesResponse = client
.request(
"scopes",
serde_json::json!({"frameId": frame_id}),
std::time::Duration::from_secs(5),
)
.await
.expect("scopes should succeed");
assert!(!scopes_response.scopes.is_empty());
let variables_ref = scopes_response.scopes[0].variables_reference;
let vars_response: VariablesResponse = client
.request(
"variables",
serde_json::json!({"variablesReference": variables_ref}),
std::time::Duration::from_secs(5),
)
.await
.expect("variables should succeed");
assert!(!vars_response.variables.is_empty());
let eval_response: EvaluateResponse = client
.request(
"evaluate",
serde_json::json!({"expression": "1 + 1", "frameId": frame_id, "context": "repl"}),
std::time::Duration::from_secs(5),
)
.await
.expect("evaluate should succeed");
assert_eq!(eval_response.result, "2");
let continue_response: ContinueResponse = client
.request(
"continue",
serde_json::json!({"threadId": thread_id}),
std::time::Duration::from_secs(5),
)
.await
.expect("continue should succeed");
assert!(continue_response.all_threads_continued.unwrap_or(true));
client
.request::<_, Value>(
"terminate",
serde_json::json!({}),
std::time::Duration::from_secs(5),
)
.await
.expect("terminate should succeed");
client
.request::<_, Value>(
"disconnect",
serde_json::json!({"terminateDebuggee": true}),
std::time::Duration::from_secs(5),
)
.await
.expect("disconnect should succeed");
}
const SMOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
macro_rules! skip_if_missing {
($which:expr, $adapter:expr) => {
if which::which($which).is_err() {
eprintln!(
"SKIP: {} smoke test — {} not found on PATH",
$adapter, $which
);
return;
}
};
($which:expr, $adapter:expr, $detail:expr) => {
if which::which($which).is_err() {
eprintln!(
"SKIP: {} smoke test — {} not found on PATH ({})",
$adapter, $which, $detail
);
return;
}
};
}
#[tokio::test]
async fn smoke_debugpy_python() {
skip_if_missing!("python3", "debugpy");
let check = std::process::Command::new("python3")
.args(["-c", "import debugpy"])
.output();
if check.map_or(true, |o| !o.status.success()) {
eprintln!(
"SKIP: debugpy smoke test — debugpy module not installed (pip install debugpy)"
);
return;
}
let client = super::DapClient::spawn_stdio(
"debugpy",
std::path::Path::new("python3"),
&["-m".to_string(), "debugpy.adapter".to_string()],
std::path::Path::new("."),
)
.await
.expect("debugpy adapter should spawn");
let caps: Capabilities = client
.request(
"initialize",
serde_json::json!({
"adapterID": "debugpy",
"clientID": "dirge-smoke",
"linesStartAt1": true,
"columnsStartAt1": true,
"pathFormat": "path",
"locale": "en-us"
}),
SMOKE_TIMEOUT,
)
.await
.expect("debugpy initialize should succeed");
assert!(
caps.supports_configuration_done_request.unwrap_or(false),
"debugpy should support configurationDoneRequest"
);
let _ = client
.request::<_, Value>(
"disconnect",
serde_json::json!({"terminateDebuggee": false}),
SMOKE_TIMEOUT,
)
.await;
}
#[tokio::test]
async fn smoke_lldb_dap_c() {
skip_if_missing!("lldb-dap", "lldb-dap");
let client = super::DapClient::spawn_stdio(
"lldb-dap",
std::path::Path::new("lldb-dap"),
&[],
std::path::Path::new("."),
)
.await
.expect("lldb-dap adapter should spawn");
let caps: Capabilities = client
.request(
"initialize",
serde_json::json!({
"adapterID": "lldb",
"clientID": "dirge-smoke",
"linesStartAt1": true,
"columnsStartAt1": true,
"pathFormat": "path",
"locale": "en-us"
}),
SMOKE_TIMEOUT,
)
.await
.expect("lldb-dap initialize should succeed");
assert!(
caps.supports_configuration_done_request.unwrap_or(false),
"lldb-dap should support configurationDoneRequest"
);
let _ = client
.request::<_, Value>(
"disconnect",
serde_json::json!({"terminateDebuggee": false}),
SMOKE_TIMEOUT,
)
.await;
}
#[tokio::test]
async fn smoke_debugpy_launch_test_program() {
skip_if_missing!("python3", "debugpy");
let check = std::process::Command::new("python3")
.args(["-c", "import debugpy"])
.output();
if check.map_or(true, |o| !o.status.success()) {
eprintln!("SKIP: debugpy not installed");
return;
}
let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("tests")
.join("dap")
.join("fixtures")
.join("test_program.py");
assert!(
fixture.exists(),
"test_program.py must exist at {}",
fixture.display()
);
let client = super::DapClient::spawn_stdio(
"debugpy",
std::path::Path::new("python3"),
&["-m".to_string(), "debugpy.adapter".to_string()],
std::path::Path::new("."),
)
.await
.expect("debugpy adapter should spawn");
let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel();
client
.on_event(
"stopped",
Box::new(move |body: serde_json::Value| {
let _ = evt_tx.send(body);
}),
)
.await;
client
.on_event("output", Box::new(|_: serde_json::Value| {}))
.await;
client
.on_event("terminated", Box::new(|_: serde_json::Value| {}))
.await;
let caps: crate::dap::types::Capabilities = client
.request(
"initialize",
serde_json::json!({
"adapterID": "debugpy",
"clientID": "dirge-smoke",
"linesStartAt1": true,
"columnsStartAt1": true,
"pathFormat": "path",
"locale": "en-us"
}),
SMOKE_TIMEOUT,
)
.await
.expect("initialize should succeed");
assert!(caps.supports_configuration_done_request.unwrap_or(false));
client
.notify(
"launch",
&serde_json::json!({
"program": fixture.to_string_lossy(),
"stopOnEntry": true,
"console": "internalConsole"
}),
)
.await
.expect("launch notify should succeed");
client
.request::<_, serde_json::Value>(
"configurationDone",
serde_json::json!({}),
SMOKE_TIMEOUT,
)
.await
.expect("configurationDone should succeed");
let stopped = tokio::time::timeout(SMOKE_TIMEOUT, evt_rx.recv())
.await
.expect("timed out waiting for stopped event")
.expect("adapter disconnected before stopped event");
assert_eq!(stopped["reason"], "entry", "expected stop-on-entry");
client
.request::<_, serde_json::Value>(
"continue",
serde_json::json!({"threadId": stopped["threadId"]}),
SMOKE_TIMEOUT,
)
.await
.expect("continue should succeed");
client
.request::<_, serde_json::Value>("terminate", serde_json::json!({}), SMOKE_TIMEOUT)
.await
.expect("terminate should succeed");
client
.request::<_, serde_json::Value>(
"disconnect",
serde_json::json!({"terminateDebuggee": true}),
SMOKE_TIMEOUT,
)
.await
.expect("disconnect should succeed");
}
#[tokio::test]
async fn smoke_lldb_dap_launch_c() {
skip_if_missing!("lldb-dap", "lldb-dap");
let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("tests")
.join("dap")
.join("fixtures")
.join("test_program_c");
assert!(
fixture.exists(),
"test_program_c must exist at {} — compile with: gcc -g src/tests/dap/fixtures/test_program.c -o src/tests/dap/fixtures/test_program_c",
fixture.display()
);
let client = super::DapClient::spawn_stdio(
"lldb-dap",
std::path::Path::new("lldb-dap"),
&[],
std::path::Path::new("."),
)
.await
.expect("lldb-dap adapter should spawn");
let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel();
client
.on_event(
"stopped",
Box::new(move |body: serde_json::Value| {
let _ = evt_tx.send(body);
}),
)
.await;
client
.on_event("output", Box::new(|_: serde_json::Value| {}))
.await;
let caps: crate::dap::types::Capabilities = client
.request(
"initialize",
serde_json::json!({
"adapterID": "lldb",
"clientID": "dirge-smoke",
"linesStartAt1": true,
"columnsStartAt1": true,
"pathFormat": "path",
"locale": "en-us"
}),
SMOKE_TIMEOUT,
)
.await
.expect("initialize should succeed");
assert!(caps.supports_configuration_done_request.unwrap_or(false));
client
.notify(
"launch",
&serde_json::json!({
"program": fixture.to_string_lossy(),
"stopOnEntry": true
}),
)
.await
.expect("launch notify should succeed");
client
.request::<_, serde_json::Value>(
"configurationDone",
serde_json::json!({}),
SMOKE_TIMEOUT,
)
.await
.expect("configurationDone should succeed");
let stopped = tokio::time::timeout(SMOKE_TIMEOUT, evt_rx.recv())
.await
.expect("timed out waiting for stopped event")
.expect("adapter disconnected before stopped event");
client
.on_event("terminated", Box::new(|_: serde_json::Value| {}))
.await;
client
.request::<_, serde_json::Value>(
"continue",
serde_json::json!({"threadId": stopped["threadId"]}),
SMOKE_TIMEOUT,
)
.await
.expect("continue should succeed");
let _ = client
.request::<_, Value>(
"disconnect",
serde_json::json!({"terminateDebuggee": false}),
SMOKE_TIMEOUT,
)
.await;
}
#[tokio::test]
async fn smoke_lldb_dap_launch_rs() {
skip_if_missing!("lldb-dap", "lldb-dap");
let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("tests")
.join("dap")
.join("fixtures")
.join("test_program_rs");
assert!(
fixture.exists(),
"test_program_rs must exist at {} — compile with: rustc -g src/tests/dap/fixtures/test_program.rs -o src/tests/dap/fixtures/test_program_rs",
fixture.display()
);
let client = super::DapClient::spawn_stdio(
"lldb-dap",
std::path::Path::new("lldb-dap"),
&[],
std::path::Path::new("."),
)
.await
.expect("lldb-dap adapter should spawn");
let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel();
client
.on_event(
"stopped",
Box::new(move |body: serde_json::Value| {
let _ = evt_tx.send(body);
}),
)
.await;
client
.on_event("output", Box::new(|_: serde_json::Value| {}))
.await;
client
.on_event("terminated", Box::new(|_: serde_json::Value| {}))
.await;
let caps: crate::dap::types::Capabilities = client
.request(
"initialize",
serde_json::json!({
"adapterID": "lldb",
"clientID": "dirge-smoke",
"linesStartAt1": true,
"columnsStartAt1": true,
"pathFormat": "path",
"locale": "en-us"
}),
SMOKE_TIMEOUT,
)
.await
.expect("initialize should succeed");
assert!(caps.supports_configuration_done_request.unwrap_or(false));
client
.notify(
"launch",
&serde_json::json!({
"program": fixture.to_string_lossy(),
"stopOnEntry": true
}),
)
.await
.expect("launch notify should succeed");
client
.request::<_, serde_json::Value>(
"configurationDone",
serde_json::json!({}),
SMOKE_TIMEOUT,
)
.await
.expect("configurationDone should succeed");
let stopped = tokio::time::timeout(SMOKE_TIMEOUT, evt_rx.recv())
.await
.expect("timed out waiting for stopped event")
.expect("adapter disconnected before stopped event");
client
.request::<_, serde_json::Value>(
"continue",
serde_json::json!({"threadId": stopped["threadId"]}),
SMOKE_TIMEOUT,
)
.await
.expect("continue should succeed");
let _ = client
.request::<_, Value>(
"disconnect",
serde_json::json!({"terminateDebuggee": false}),
SMOKE_TIMEOUT,
)
.await;
}
#[tokio::test]
async fn smoke_node_dap_js() {
skip_if_missing!("node", "node");
let adapter = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("dap_node_adapter.js");
assert!(adapter.exists(), "dap_node_adapter.js must exist");
let client = super::DapClient::spawn_stdio(
"node-dap",
std::path::Path::new("node"),
&[adapter.to_string_lossy().to_string()],
std::path::Path::new("."),
)
.await
.expect("node dap adapter should spawn");
let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel();
client
.on_event(
"stopped",
Box::new(move |body: serde_json::Value| {
let _ = evt_tx.send(body);
}),
)
.await;
client
.on_event("output", Box::new(|_: serde_json::Value| {}))
.await;
let caps: crate::dap::types::Capabilities = client
.request(
"initialize",
serde_json::json!({
"adapterID": "node-dap",
"clientID": "dirge-smoke",
"linesStartAt1": true,
"columnsStartAt1": true,
"pathFormat": "path",
"locale": "en-us"
}),
SMOKE_TIMEOUT,
)
.await
.expect("initialize should succeed");
assert!(caps.supports_configuration_done_request.unwrap_or(false));
client
.notify(
"launch",
&serde_json::json!({
"program": "test_fixture.js",
"stopOnEntry": true
}),
)
.await
.expect("launch notify should succeed");
client
.request::<_, serde_json::Value>(
"configurationDone",
serde_json::json!({}),
SMOKE_TIMEOUT,
)
.await
.expect("configurationDone should succeed");
let stopped = tokio::time::timeout(SMOKE_TIMEOUT, evt_rx.recv())
.await
.expect("timed out waiting for stopped event")
.expect("adapter disconnected before stopped event");
client
.request::<_, serde_json::Value>(
"continue",
serde_json::json!({"threadId": stopped["threadId"]}),
SMOKE_TIMEOUT,
)
.await
.expect("continue should succeed");
client
.request::<_, serde_json::Value>("terminate", serde_json::json!({}), SMOKE_TIMEOUT)
.await
.expect("terminate should succeed");
client
.request::<_, serde_json::Value>(
"disconnect",
serde_json::json!({"terminateDebuggee": true}),
SMOKE_TIMEOUT,
)
.await
.expect("disconnect should succeed");
}
}