use std::io::{BufRead, BufReader};
use std::net::TcpStream;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use tracing::{debug, trace, warn};
use crate::error::{Error, Result, ResultExt};
const MAX_PORT_RETRIES: u32 = 3;
#[derive(Debug)]
pub struct LldbProcess {
pub child: Child,
#[allow(dead_code)]
pub host: String,
pub port: u16,
}
pub struct LldbManager {
lldb_dap_path: PathBuf,
timeout: Duration,
}
impl LldbManager {
pub fn new(lldb_dap_path: PathBuf, timeout: Duration) -> Self {
Self {
lldb_dap_path,
timeout,
}
}
pub fn spawn_and_attach(&self, host: &str, port: u16) -> Result<LldbProcess> {
if port != 0 {
return self.spawn_lldb(host, port);
}
let mut last_err = None;
for attempt in 0..MAX_PORT_RETRIES {
let actual_port = allocate_port(host)?;
debug!(
"Attempt {}: allocated ephemeral port {}",
attempt + 1,
actual_port
);
match self.spawn_lldb(host, actual_port) {
Ok(process) => return Ok(process),
Err(e) => {
if is_port_bind_error(&e) {
warn!("Port {} taken (TOCTOU race), retrying...", actual_port);
last_err = Some(e);
continue;
}
return Err(e);
}
}
}
Err(last_err
.unwrap_or_else(|| Error::LldbStartFailed("failed after max port retries".to_string())))
}
fn spawn_lldb(&self, host: &str, port: u16) -> Result<LldbProcess> {
let listen_addr = format!("listen://{}:{}", host, port);
debug!(
"Starting lldb-dap: {:?} --connection {}",
self.lldb_dap_path, listen_addr
);
let mut child = Command::new(&self.lldb_dap_path)
.args(["--connection", &listen_addr])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.lldb("failed to start process")?;
if let Err(e) = self.wait_for_ready(host, port, &mut child) {
let _ = self.kill_process(&mut child);
return Err(e);
}
debug!(
"lldb-dap ready on {}:{}, daemon will attach to PID {}",
host,
port,
std::process::id()
);
Ok(LldbProcess {
child,
host: host.to_string(),
port,
})
}
fn wait_for_ready(&self, host: &str, port: u16, child: &mut Child) -> Result<()> {
let deadline = Instant::now() + self.timeout;
let check_interval = Duration::from_millis(100);
while Instant::now() < deadline {
if let Ok(Some(status)) = child.try_wait() {
let stderr = child
.stderr
.take()
.map(|s| {
BufReader::new(s)
.lines()
.take(10)
.filter_map(|l| l.ok())
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
return Err(Error::LldbStartFailed(format!(
"lldb-dap exited with status {}: {}",
status, stderr
)));
}
if is_port_in_use(port) {
trace!("lldb-dap listening on {}:{}", host, port);
return Ok(());
}
std::thread::sleep(check_interval);
}
Err(Error::Timeout(format!(
"lldb-dap did not become ready within {:?}",
self.timeout
)))
}
#[allow(dead_code)]
fn send_attach_request(&self, host: &str, port: u16, pid: u32) -> Result<()> {
let addr = format!("{}:{}", host, port);
debug!("Connecting to lldb-dap at {}", addr);
let mut stream = TcpStream::connect(&addr).lldb("failed to connect to lldb-dap")?;
debug!("Connected to lldb-dap");
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
stream.set_write_timeout(Some(Duration::from_secs(5))).ok();
let init_request = serde_json::json!({
"seq": 1,
"type": "request",
"command": "initialize",
"arguments": {
"clientID": "detrix-rust-client",
"clientName": "Detrix Rust Client",
"adapterID": "lldb-dap",
"pathFormat": "path",
"linesStartAt1": true,
"columnsStartAt1": true,
"supportsVariableType": true,
"supportsVariablePaging": true,
"supportsRunInTerminalRequest": false,
"locale": "en-US"
}
});
debug!("Sending initialize request");
send_dap_message(&mut stream, &init_request)?;
debug!("Waiting for initialize response");
let response = read_dap_response(&mut stream, "initialize")?;
debug!("Initialize response: {:?}", response);
if response.get("success") != Some(&serde_json::Value::Bool(true)) {
let message = response
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("unknown error");
return Err(Error::LldbStartFailed(format!(
"initialize failed: {}",
message
)));
}
let attach_request = serde_json::json!({
"seq": 2,
"type": "request",
"command": "attach",
"arguments": {
"pid": pid,
"stopOnEntry": false
}
});
debug!("Sending attach request for PID {}", pid);
send_dap_message(&mut stream, &attach_request)?;
debug!("Waiting for attach response (may receive events first)");
let response = read_dap_response(&mut stream, "attach")?;
debug!("Attach response: {:?}", response);
if response.get("success") != Some(&serde_json::Value::Bool(true)) {
let message = response
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("unknown error");
return Err(Error::LldbStartFailed(format!(
"attach failed: {}",
message
)));
}
let config_done_request = serde_json::json!({
"seq": 3,
"type": "request",
"command": "configurationDone",
"arguments": {}
});
debug!("Sending configurationDone request");
send_dap_message(&mut stream, &config_done_request)?;
debug!("Waiting for configurationDone response");
let response = read_dap_response(&mut stream, "configurationDone")?;
debug!("ConfigurationDone response: {:?}", response);
debug!("lldb-dap attached to PID {}", pid);
Ok(())
}
pub fn kill(&self, process: &mut LldbProcess) -> Result<()> {
self.kill_process(&mut process.child)
}
fn kill_process(&self, child: &mut Child) -> Result<()> {
#[cfg(unix)]
{
use nix::sys::signal::{kill, Signal};
use nix::unistd::Pid;
let pid = Pid::from_raw(child.id() as i32);
if kill(pid, Signal::SIGTERM).is_ok() {
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline {
if child.try_wait().ok().flatten().is_some() {
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
}
}
let _ = child.kill();
let _ = child.wait();
}
#[cfg(not(unix))]
{
let _ = child.kill();
let _ = child.wait();
}
Ok(())
}
}
fn allocate_port(host: &str) -> Result<u16> {
use std::net::TcpListener;
let addr = format!("{}:0", host);
let listener = TcpListener::bind(&addr).port_bind("failed to bind")?;
let port = listener
.local_addr()
.port_bind("failed to get local addr")?
.port();
drop(listener);
Ok(port)
}
fn is_port_in_use(port: u16) -> bool {
use std::net::TcpListener;
TcpListener::bind(("127.0.0.1", port)).is_err()
}
fn is_port_bind_error(err: &Error) -> bool {
match err {
Error::PortBindError(_) => true,
Error::LldbStartFailed(msg) => {
msg.contains("address already in use") || msg.contains("bind")
}
_ => false,
}
}
fn send_dap_message(stream: &mut TcpStream, message: &serde_json::Value) -> Result<()> {
use std::io::Write;
let body = serde_json::to_string(message)?;
let header = format!("Content-Length: {}\r\n\r\n", body.len());
stream
.write_all(header.as_bytes())
.lldb("failed to write header")?;
stream
.write_all(body.as_bytes())
.lldb("failed to write body")?;
stream.flush().lldb("failed to flush")?;
Ok(())
}
fn read_dap_response(stream: &mut TcpStream, expected_command: &str) -> Result<serde_json::Value> {
let start = Instant::now();
let timeout = Duration::from_secs(10);
loop {
if start.elapsed() > timeout {
return Err(Error::Timeout(format!(
"timed out waiting for {} response",
expected_command
)));
}
let msg = read_dap_message(stream)?;
let msg_type = msg.get("type").and_then(|t| t.as_str()).unwrap_or("");
match msg_type {
"response" => {
return Ok(msg);
}
"event" => {
let event_name = msg.get("event").and_then(|e| e.as_str()).unwrap_or("?");
eprintln!("[LLDB] Skipping event: {}", event_name);
continue;
}
other => {
eprintln!("[LLDB] Unexpected message type: {}", other);
continue;
}
}
}
}
fn read_dap_message(stream: &mut TcpStream) -> Result<serde_json::Value> {
use std::io::{BufRead, BufReader, Read};
let mut reader = BufReader::new(stream.try_clone().lldb("failed to clone stream")?);
let mut content_length: Option<usize> = None;
loop {
let mut line = String::new();
reader.read_line(&mut line).lldb("failed to read header")?;
let line = line.trim();
if line.is_empty() {
break;
}
if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = value.trim().parse().ok();
}
}
let content_length = content_length
.ok_or_else(|| Error::LldbStartFailed("missing Content-Length header".to_string()))?;
let mut body = vec![0u8; content_length];
reader.read_exact(&mut body).lldb("failed to read body")?;
serde_json::from_slice(&body).lldb("failed to parse response")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_allocate_port() {
let port = allocate_port("127.0.0.1").unwrap();
assert!(port > 0);
}
#[test]
fn test_is_port_bind_error() {
assert!(is_port_bind_error(&Error::PortBindError(
"test".to_string()
)));
assert!(is_port_bind_error(&Error::LldbStartFailed(
"address already in use".to_string()
)));
assert!(!is_port_bind_error(&Error::LldbNotFound(
"test".to_string()
)));
}
}