Skip to content

Latest commit

 

History

History
937 lines (725 loc) · 24.7 KB

File metadata and controls

937 lines (725 loc) · 24.7 KB

Developer Guide

Welcome to debugger-cli! This guide covers everything you need to contribute to the project.

Quick Links: README | Changelog | Architecture

Table of Contents

  1. Architecture Overview
  2. Project Structure
  3. Command Flow
  4. Adding New Commands
  5. Working with the DAP Client
  6. IPC Protocol
  7. Error Handling
  8. Configuration System
  9. Testing
  10. Debugging Tips

Prerequisites

  • Rust 1.70+ (rustup update stable)
  • A debug adapter for testing (see README)
  • Optional debug adapters for end-to-end testing:
    • lldb-dap or codelldb for C/C++/Rust
    • delve for Go
    • gdb 14.1+ for GDB tests
    • cuda-gdb for CUDA tests (Linux only)
# Build the project
cargo build

# Run tests
cargo test

# Run with logging
RUST_LOG=debug cargo run -- status

Architecture Overview

The 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

Project Structure

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

Command Flow

Understanding how a command flows through the system is essential. Here's what happens when you run debugger break src/main.rs:42:

1. CLI Parsing (src/main.rs)

// 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
}

2. CLI Dispatch (src/cli/mod.rs)

// 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
}

3. IPC Transport (src/ipc/)

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 }
  }
}

4. Daemon Handler (src/daemon/handler.rs)

// 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)))
}

5. Session → DAP Client (src/daemon/session.rs, src/dap/client.rs)

// 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
}

6. Response Flows Back

The response travels back through the same path: DAP → Session → Handler → IPC → CLI → User output.


Adding New Commands

Adding a new command involves changes to 4-5 files. Here's a step-by-step guide:

Step 1: Define CLI Arguments (src/commands.rs)

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,
    },
}

Step 2: Define IPC Protocol (src/ipc/protocol.rs)

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>,
}

Step 3: Implement CLI Handler (src/cli/mod.rs)

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(())
}

Step 4: Implement Daemon Handler (src/daemon/handler.rs)

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)?)
        }
    }
}

Step 5: Implement Session Logic (src/daemon/session.rs)

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],
        })
    }
}

Complete Example: Adding a "memory read" Command

// 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)?,
    })
}

Working with the DAP Client

The DapClient (src/dap/client.rs) handles all communication with debug adapters.

Spawning the Adapter

let client = DapClient::spawn(&adapter_path, &adapter_args).await?;

This:

  1. Spawns the adapter as a subprocess with stdin/stdout pipes
  2. Starts a background reader task for async event handling
  3. Returns a client ready for the initialize handshake

Sending Requests

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);
}

Common DAP Requests

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

Handling Events

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;
        }
        _ => {}
    }
}

Key Event Types

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

Race Condition Prevention

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 arrived

IPC Protocol

Communication between CLI and daemon uses a simple length-prefixed JSON protocol.

Message Format

┌─────────────────┬─────────────────────────────────┐
│ Length (4 bytes)│ JSON Payload (variable)         │
│ Little-endian   │                                 │
└─────────────────┴─────────────────────────────────┘

Request Structure

pub struct Request {
    pub id: u64,           // For request-response correlation
    pub command: Command,  // The command enum
}

Response Structure

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
}

Adding New Protocol Types

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)?;

Error Handling

Error Types (src/common/error.rs)

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
}

Creating New Error Variants

// 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"));

IPC Error Conversion

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() }
    }
}

Error Handling Patterns

// 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);
}

Configuration System

Config File Location

Platform Path
Linux ~/.config/debugger-cli/config.toml
macOS ~/Library/Application Support/debugger-cli/config.toml
Windows %APPDATA%\debugger-cli\config.toml

Config Structure

# 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

Accessing Config

// 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;

Adding New Config Options

// 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,
}

Testing

Unit Tests

Run unit tests:

cargo test

Unit 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

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 -- --ignored

Test Fixtures

Test 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)]);

Test Artifact Cleanup

By default, test artifacts are preserved for debugging. To clean up:

PRESERVE_DEBUGGER_TEST_ARTIFACTS=0 cargo test

Debugging Tips

Enable Tracing

The 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 daemon

Inspect DAP Messages

DAP 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,...}

Debug the Daemon

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

Common Issues

"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

Useful Commands During Development

# 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

Quick Reference

Key Files for Common Tasks

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 Responsibilities

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)

Session State Machine

     ┌─────────────────────────────────────┐
     │                                     │
     ▼                                     │
   Idle ──▶ Initializing ──▶ Configuring ──┼──▶ Running ◀──▶ Stopped
     ▲                                     │        │          │
     │                                     │        ▼          │
     │                                     │     Exited ◀──────┘
     │                                     │        │
     └─────────── Terminating ◀────────────┴────────┘

Happy debugging! If you have questions, check the existing docs in docs/plan/ or ask the team.