Welcome to debugger-cli! This guide covers everything you need to contribute to the project.
Quick Links: README | Changelog | Architecture
- Architecture Overview
- Project Structure
- Command Flow
- Adding New Commands
- Working with the DAP Client
- IPC Protocol
- Error Handling
- Configuration System
- Testing
- Debugging Tips
- Rust 1.70+ (
rustup update stable) - A debug adapter for testing (see README)
- Optional debug adapters for end-to-end testing:
lldb-daporcodelldbfor C/C++/Rustdelvefor Gogdb14.1+ for GDB testscuda-gdbfor CUDA tests (Linux only)
# Build the project
cargo build
# Run tests
cargo test
# Run with logging
RUST_LOG=debug cargo run -- statusThe debugger-cli implements an LLM-friendly debugger using the Debug Adapter Protocol (DAP). The key architectural insight is that a single binary runs in two modes:
┌─────────────────┐ ┌─────────────────┐
│ CLI Mode │ ───IPC Socket───▶ │ Daemon Mode │
│ (thin client) │ │ (state manager) │
└─────────────────┘ └────────┬────────┘
│
│ stdio
▼
┌─────────────────┐
│ DAP Adapter │
│ (lldb-dap, etc) │
└─────────────────┘
- CLI Mode: Parses user commands and forwards them to the daemon via IPC
- Daemon Mode: Long-running process that manages the debug session and communicates with the DAP adapter
This separation allows:
- Persistent debug sessions across multiple CLI invocations
- Non-blocking command execution
- Clean process lifecycle management
src/
├── main.rs # Entry point: dispatches CLI vs daemon mode
├── commands.rs # Clap command definitions (CLI argument parsing)
├── lib.rs # Library exports
│
├── cli/ # CLI-side code (thin client)
│ ├── mod.rs # dispatch() routes commands to handlers
│ └── spawn.rs # Daemon spawning and management
│
├── daemon/ # Daemon-side code (state manager)
│ ├── mod.rs # run() entry point for daemon mode
│ ├── server.rs # IPC listener loop, accepts client connections
│ ├── handler.rs # Command handler dispatcher
│ └── session.rs # Debug session state machine & DAP orchestration
│
├── dap/ # DAP client implementation
│ ├── client.rs # DapClient: spawns adapter, sends requests
│ ├── codec.rs # Wire protocol (Content-Length framing)
│ └── types.rs # DAP message types (requests, responses, events)
│
├── ipc/ # CLI ↔ Daemon communication
│ ├── protocol.rs # Request/Response types, Command enum
│ ├── client.rs # DaemonClient for CLI side
│ └── transport.rs # Cross-platform socket/pipe implementation
│
└── common/ # Shared utilities
├── config.rs # TOML config file loading
├── error.rs # Error types (thiserror)
└── paths.rs # Platform-specific paths (socket, config)
tests/
├── integration.rs # End-to-end tests
└── fixtures/ # Test programs (C, Rust)
└── simple.c
Understanding how a command flows through the system is essential. Here's what happens when you run debugger break src/main.rs:42:
// main.rs:18-40
let cli = Cli::parse(); // clap parses args
match cli.command {
Commands::Daemon => daemon::run().await, // Start as daemon
command => cli::dispatch(command).await, // Handle as CLI command
}// cli/mod.rs - dispatch() function
Commands::Break { location, condition } => {
ensure_daemon_running().await?; // Spawn daemon if needed
let mut client = DaemonClient::connect().await?;
let loc = BreakpointLocation::parse(&location)?;
let result = client.send_command(Command::BreakpointAdd {
location: loc,
condition,
hit_count: None,
}).await?;
// Format and print result
}The command is serialized as JSON and sent over a Unix socket (or named pipe on Windows):
{
"id": 1,
"command": {
"type": "breakpoint_add",
"location": { "type": "line", "file": "src/main.rs", "line": 42 }
}
}// handler.rs - handle_command_inner()
Command::BreakpointAdd { location, condition, hit_count } => {
let session = require_session(session)?;
let bp = session.add_breakpoint(location, condition, hit_count).await?;
Ok(json!(BreakpointInfo::from(bp)))
}// session.rs - add_breakpoint()
pub async fn add_breakpoint(&mut self, location: BreakpointLocation, ...) -> Result<...> {
// Store breakpoint locally
self.source_breakpoints.entry(file.clone()).or_default().push(stored);
// Send to DAP adapter
let response = self.client.set_breakpoints(&file, &breakpoints).await?;
// Update with adapter's response (verified status, actual line)
}
// client.rs - set_breakpoints()
pub async fn set_breakpoints(&mut self, source: &Path, breakpoints: &[SourceBreakpoint]) -> Result<...> {
self.request_with_timeout("setBreakpoints", Some(json!({
"source": { "path": source },
"breakpoints": breakpoints
})), self.request_timeout).await
}The response travels back through the same path: DAP → Session → Handler → IPC → CLI → User output.
Adding a new command involves changes to 4-5 files. Here's a step-by-step guide:
Add your command to the Commands enum:
#[derive(Subcommand)]
pub enum Commands {
// ... existing commands ...
/// Your new command description (shown in --help)
#[command(name = "mycommand")]
MyCommand {
/// Argument description
#[arg(long, short)]
some_arg: String,
/// Optional argument with default
#[arg(long, default_value = "10")]
limit: u32,
},
}Add the command variant to the Command enum:
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Command {
// ... existing commands ...
MyCommand {
some_arg: String,
limit: u32,
},
}If your command returns structured data, add a result type:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MyCommandResult {
pub field1: String,
pub field2: Vec<i32>,
}Add your command to the dispatch() function:
pub async fn dispatch(command: Commands) -> Result<()> {
match command {
// ... existing handlers ...
Commands::MyCommand { some_arg, limit } => {
ensure_daemon_running().await?;
let mut client = DaemonClient::connect().await?;
let result = client.send_command(Command::MyCommand {
some_arg,
limit,
}).await?;
// Parse and display result
let data: MyCommandResult = serde_json::from_value(result)?;
println!("Result: {}", data.field1);
for item in data.field2 {
println!(" - {}", item);
}
}
}
Ok(())
}Add your command to handle_command_inner():
async fn handle_command_inner(
session: &mut Option<DebugSession>,
config: &Config,
command: Command,
) -> Result<serde_json::Value> {
match command {
// ... existing handlers ...
Command::MyCommand { some_arg, limit } => {
let session = require_session(session)?;
let result = session.my_command(&some_arg, limit).await?;
Ok(serde_json::to_value(result)?)
}
}
}Add the method to DebugSession:
impl DebugSession {
pub async fn my_command(&mut self, some_arg: &str, limit: u32) -> Result<MyCommandResult> {
// Validate state if needed
self.require_stopped()?;
// Make DAP requests if needed
let response = self.client.some_dap_request(...).await?;
// Process and return result
Ok(MyCommandResult {
field1: some_arg.to_string(),
field2: vec![1, 2, 3],
})
}
}// commands.rs
Commands::Memory {
#[arg(help = "Address to read (hex)")]
address: String,
#[arg(long, default_value = "64")]
count: u32,
},
// ipc/protocol.rs
Command::ReadMemory { address: String, count: u32 },
#[derive(Debug, Serialize, Deserialize)]
pub struct MemoryResult {
pub address: String,
pub data: Vec<u8>,
}
// cli/mod.rs
Commands::Memory { address, count } => {
ensure_daemon_running().await?;
let mut client = DaemonClient::connect().await?;
let result = client.send_command(Command::ReadMemory { address, count }).await?;
let mem: MemoryResult = serde_json::from_value(result)?;
println!("{}: {:02x?}", mem.address, mem.data);
}
// daemon/handler.rs
Command::ReadMemory { address, count } => {
let session = require_session(session)?;
let result = session.read_memory(&address, count).await?;
Ok(serde_json::to_value(result)?)
}
// daemon/session.rs
pub async fn read_memory(&mut self, address: &str, count: u32) -> Result<MemoryResult> {
let addr = u64::from_str_radix(address.trim_start_matches("0x"), 16)
.map_err(|_| Error::InvalidLocation(address.to_string()))?;
let response: ReadMemoryResponse = self.client.request_with_timeout(
"readMemory",
Some(json!({
"memoryReference": format!("0x{:x}", addr),
"count": count
})),
self.request_timeout(),
).await?;
Ok(MemoryResult {
address: address.to_string(),
data: base64::decode(&response.data)?,
})
}The DapClient (src/dap/client.rs) handles all communication with debug adapters.
let client = DapClient::spawn(&adapter_path, &adapter_args).await?;This:
- Spawns the adapter as a subprocess with stdin/stdout pipes
- Starts a background reader task for async event handling
- Returns a client ready for the initialize handshake
Use request_with_timeout<T>() for type-safe requests:
// Generic request with typed response
let response: StackTraceResponseBody = self.client.request_with_timeout(
"stackTrace",
Some(json!({
"threadId": thread_id,
"startFrame": 0,
"levels": 20
})),
Duration::from_secs(30),
).await?;
// Access typed fields
for frame in response.stack_frames {
println!("{}: {} at {}:{}", frame.id, frame.name,
frame.source.map(|s| s.path).flatten().unwrap_or_default(),
frame.line);
}| Request | Purpose | Key Arguments |
|---|---|---|
initialize |
Handshake, exchange capabilities | clientID, adapterID, supportsXxx |
launch |
Start debugging a program | program, args, stopOnEntry |
attach |
Attach to running process | pid |
setBreakpoints |
Set breakpoints in a file | source, breakpoints[] |
setFunctionBreakpoints |
Set function breakpoints | breakpoints[] |
configurationDone |
Signal ready to run | (none) |
continue |
Resume execution | threadId |
next |
Step over | threadId |
stepIn |
Step into | threadId |
stepOut |
Step out | threadId |
pause |
Pause execution | threadId |
stackTrace |
Get call stack | threadId, levels |
scopes |
Get variable scopes | frameId |
variables |
Get variables | variablesReference |
evaluate |
Evaluate expression | expression, frameId, context |
threads |
List threads | (none) |
disconnect |
End session | terminateDebuggee |
Events are received asynchronously by a background task and queued in a channel:
// In session.rs - take the event receiver
let events_rx = client.take_event_receiver()?;
// Process events
while let Ok(event) = events_rx.try_recv() {
match event {
Event::Stopped(body) => {
self.state = SessionState::Stopped;
self.stopped_thread = body.thread_id;
self.stopped_reason = Some(body.reason);
}
Event::Output(body) => {
self.buffer_output(&body.category.unwrap_or_default(), &body.output);
}
Event::Exited(body) => {
self.state = SessionState::Exited;
self.exit_code = Some(body.exit_code);
}
Event::Terminated(_) => {
self.state = SessionState::Exited;
}
_ => {}
}
}| Event | When | Key Fields |
|---|---|---|
initialized |
Adapter ready for configuration | (none) |
stopped |
Execution stopped | reason, threadId, hitBreakpointIds |
continued |
Execution resumed | threadId |
output |
Program output | category (stdout/stderr), output |
thread |
Thread created/exited | reason, threadId |
exited |
Program exited | exitCode |
terminated |
Debug session ended | (none) |
breakpoint |
Breakpoint changed | reason, breakpoint |
When sending requests, always register the response handler before sending:
// CORRECT: Register handler first
let (tx, rx) = oneshot::channel();
pending_guard.insert(seq, tx); // Register BEFORE send
codec::write_message(&mut self.writer, &json).await?; // Then send
let response = rx.await?; // Wait for response
// WRONG: Send first, then register (race condition!)
// codec::write_message(&mut self.writer, &json).await?;
// pending_guard.insert(seq, tx); // Too late! Response may have arrivedCommunication between CLI and daemon uses a simple length-prefixed JSON protocol.
┌─────────────────┬─────────────────────────────────┐
│ Length (4 bytes)│ JSON Payload (variable) │
│ Little-endian │ │
└─────────────────┴─────────────────────────────────┘
pub struct Request {
pub id: u64, // For request-response correlation
pub command: Command, // The command enum
}pub struct Response {
pub id: u64, // Matches request ID
pub success: bool,
pub result: Option<serde_json::Value>, // On success
pub error: Option<IpcError>, // On failure
}When adding new commands that need structured results:
// 1. Define the result type
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewResult {
pub field: String,
}
// 2. Serialize in handler
Ok(serde_json::to_value(NewResult { field: "value".into() })?)
// 3. Deserialize in CLI
let result: NewResult = serde_json::from_value(response)?;The project uses thiserror for ergonomic error definitions:
#[derive(Error, Debug)]
pub enum Error {
// Session errors
#[error("No debug session active. Start one with 'debugger start <program>'")]
SessionNotActive,
// DAP errors
#[error("DAP request '{command}' failed: {message}")]
DapRequestFailed { command: String, message: String },
// State errors
#[error("Cannot {action} while program is {state}")]
InvalidState { action: String, state: String },
// ... many more
}// Add to Error enum
#[error("My new error: {0}")]
MyNewError(String),
// Add helper method
impl Error {
pub fn my_new_error(detail: &str) -> Self {
Self::MyNewError(detail.to_string())
}
}
// Usage
return Err(Error::my_new_error("something went wrong"));Errors are converted to IpcError for transmission:
// In error.rs
impl From<&Error> for IpcError {
fn from(e: &Error) -> Self {
let code = match e {
Error::SessionNotActive => "SESSION_NOT_ACTIVE",
Error::MyNewError(_) => "MY_NEW_ERROR", // Add your error code
_ => "INTERNAL_ERROR",
};
Self { code: code.to_string(), message: e.to_string() }
}
}// In handlers - use ? operator, errors become IPC responses
pub async fn my_handler(session: &mut Option<DebugSession>) -> Result<Value> {
let session = require_session(session)?; // Returns error if no session
let result = session.do_thing().await?; // Propagates DAP errors
Ok(json!(result))
}
// In CLI - display errors nicely
if let Err(e) = result {
eprintln!("Error: {e}");
std::process::exit(1);
}| Platform | Path |
|---|---|
| Linux | ~/.config/debugger-cli/config.toml |
| macOS | ~/Library/Application Support/debugger-cli/config.toml |
| Windows | %APPDATA%\debugger-cli\config.toml |
# Adapter configurations
[adapters.lldb-dap]
path = "lldb-dap"
args = []
[adapters.codelldb]
path = "/path/to/codelldb"
args = ["--port", "13000"]
# Default settings
[defaults]
adapter = "lldb-dap"
# Timeout settings (seconds)
[timeouts]
dap_initialize_secs = 10
dap_request_secs = 30
await_default_secs = 300
# Daemon settings
[daemon]
idle_timeout_minutes = 30
# Output buffer limits
[output]
max_events = 10000
max_bytes_mb = 10// Load config (returns defaults if file missing)
let config = Config::load()?;
// Access adapter config
if let Some(adapter) = config.get_adapter("lldb-dap") {
let path = &adapter.path;
let args = &adapter.args;
}
// Access timeouts
let timeout = Duration::from_secs(config.timeouts.dap_request_secs);
// Access output limits
let max_bytes = config.output.max_bytes_mb * 1024 * 1024;// In config.rs
// 1. Add field to appropriate struct
#[derive(Debug, Deserialize)]
pub struct MySection {
#[serde(default = "default_my_option")]
pub my_option: u32,
}
// 2. Add default function
fn default_my_option() -> u32 { 42 }
// 3. Add section to Config
pub struct Config {
#[serde(default)]
pub my_section: MySection,
}Run unit tests:
cargo testUnit tests are in the same files as the code they test:
// src/ipc/protocol.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_file_line() {
let loc = BreakpointLocation::parse("src/main.rs:42").unwrap();
match loc {
BreakpointLocation::Line { file, line } => {
assert_eq!(file.to_string_lossy(), "src/main.rs");
assert_eq!(line, 42);
}
_ => panic!("Expected Line variant"),
}
}
}Integration tests are in tests/integration.rs. They require a debug adapter:
# Run all tests (integration tests are ignored by default)
cargo test
# Run integration tests (requires lldb-dap)
cargo test -- --ignoredTest fixtures are C/Rust programs in tests/fixtures/:
// tests/fixtures/simple.c
int add(int a, int b) {
// BREAKPOINT_MARKER: add_body
return a + b;
}
int main() {
// BREAKPOINT_MARKER: main_start
int x = 10;
int y = 20;
// BREAKPOINT_MARKER: before_add
int sum = add(x, y);
return 0;
}Breakpoint markers allow tests to find specific lines:
let markers = ctx.find_breakpoint_markers(&ctx.fixtures_dir.join("simple.c"));
let line = markers.get("main_start").unwrap();
ctx.run_debugger_ok(&["break", &format!("simple.c:{}", line)]);By default, test artifacts are preserved for debugging. To clean up:
PRESERVE_DEBUGGER_TEST_ARTIFACTS=0 cargo testThe project uses the tracing crate for logging:
# Show all logs
RUST_LOG=debug cargo run -- start ./myprogram
# Show only DAP messages
RUST_LOG=debugger::dap=trace cargo run -- start ./myprogram
# Show daemon logs
RUST_LOG=debugger::daemon=debug cargo run daemonDAP messages are logged at trace level:
RUST_LOG=trace cargo run -- start ./myprogram 2>&1 | grep "DAP"Output:
DAP >>> {"seq":1,"type":"request","command":"initialize",...}
DAP <<< {"seq":1,"type":"response","request_seq":1,"success":true,...}
Run the daemon in foreground:
# Terminal 1: Start daemon manually
RUST_LOG=debug cargo run -- daemon
# Terminal 2: Run CLI commands
cargo run -- start ./myprogram
cargo run -- break main
cargo run -- continue"Daemon not running"
- The socket file may be stale. Delete it:
rm /tmp/debugger-cli-*/daemon.sock - Or use XDG runtime dir:
rm $XDG_RUNTIME_DIR/debugger-cli/daemon.sock
"Adapter not found"
- Check adapter is in PATH:
which lldb-dap - Or specify full path in config.toml
"DAP request timeout"
- Adapter may have crashed. Check stderr output
- Increase timeout in config.toml
"Session not active"
- Start a session first:
debugger start ./program - Check if daemon is running:
debugger status
# Check daemon status
cargo run -- status
# Stop daemon (and debug session)
cargo run -- stop
# Force kill daemon
pkill -f "debugger daemon"
# Watch daemon socket
ls -la /tmp/debugger-cli-*/daemon.sock
# Test CLI parsing
cargo run -- --help
cargo run -- break --help| Task | Files to Modify |
|---|---|
| Add CLI command | commands.rs, cli/mod.rs |
| Add IPC command | ipc/protocol.rs, daemon/handler.rs |
| Add session logic | daemon/session.rs |
| Add DAP request | dap/client.rs, dap/types.rs |
| Add config option | common/config.rs |
| Add error type | common/error.rs |
| Module | Responsibility |
|---|---|
cli/ |
User interaction, command parsing, output formatting |
daemon/ |
Session state, command handling, DAP orchestration |
dap/ |
DAP protocol implementation, adapter communication |
ipc/ |
CLI↔Daemon communication, message serialization |
common/ |
Shared utilities (config, errors, paths) |
┌─────────────────────────────────────┐
│ │
▼ │
Idle ──▶ Initializing ──▶ Configuring ──┼──▶ Running ◀──▶ Stopped
▲ │ │ │
│ │ ▼ │
│ │ Exited ◀──────┘
│ │ │
└─────────── Terminating ◀────────────┴────────┘
Happy debugging! If you have questions, check the existing docs in docs/plan/ or ask the team.