Debugging Rust async code in VS Code containers

Introduction

Containerized Rust development isolates dependencies but requires specific configuration to enable step-through debugging of tokio and async-std tasks in VS Code DevContainers. This guide provides a deterministic configuration to enable step-through debugging, enforce ptrace capabilities, pre-compile debug symbols, and map async stack frames correctly. For broader language-specific patterns, reference the Language-Specific Environment Configurations pillar.

Sections

1. Container Security & Base Image Requirements

Async debuggers require SYS_PTRACE capabilities to inspect thread states and scheduler continuations. Use mcr.microsoft.com/devcontainers/rust:1 as the base image. Avoid --privileged in production; scope permissions strictly to SYS_PTRACE via runArgs and seccomp=unconfined (required for LLDB to function correctly inside containers). This deterministic permission model aligns with security-first practices documented in the Go Development Environment with gopls & Modules workflow.

2. Deterministic Toolchain & Debug Symbols

Install rustup with explicit default-toolchain and rust-src components. The rust-src component is required for stepping through standard library code and for the debugger to resolve async state machine frames. Compile with RUSTFLAGS="-C debuginfo=2" (or set in .cargo/config.toml) to prevent aggressive symbol stripping. Pin rustc versions in rust-toolchain.toml to guarantee reproducible debug builds across remote and local machines.

3. VS Code Debugger Configuration (launch.json)

Configure lldb (via the vadimcn.vscode-lldb extension) to launch the compiled binary. Set type to lldb, cwd to ${workspaceFolder}, and stopAtEntry to false. Map the preLaunchTask to a deterministic cargo build task. This ensures the debugger attaches to the exact binary with preserved debug metadata.

4. Async Stack Trace Resolution

Standard LLDB flattens async continuations into opaque core::future::poll frames. Install the CodeLLDB extension’s built-in Rust visualizers (vadimcn.vscode-lldb includes these by default). Enable rust-src to allow stepping into tokio internals. The tokio-console tool (tokio_unstable feature flag required) provides runtime-level task introspection, complementing the debugger’s static breakpoint view.

Note: RUSTFLAGS="-Z location-detail=none" is a nightly-only unstable flag and is not relevant for async debugging on the stable toolchain. Async frame resolution requires rust-src and debuginfo=2, not unstable flags.

Code

// .devcontainer/devcontainer.json
{
  "name": "Rust Async Debug",
  "image": "mcr.microsoft.com/devcontainers/rust:1",
  "runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"],
  "customizations": {
    "vscode": {
      "extensions": ["vadimcn.vscode-lldb", "rust-lang.rust-analyzer"]
    }
  }
}
# rust-toolchain.toml
[toolchain]
channel = "1.78.0"
components = ["rust-src", "rustfmt", "clippy"]
profile = "minimal"
// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "lldb",
      "request": "launch",
      "name": "Debug Async Rust",
      "program": "${workspaceFolder}/target/debug/${workspaceFolderBasename}",
      "args": [],
      "cwd": "${workspaceFolder}",
      "preLaunchTask": "cargo: build",
      "env": {
        "RUST_LOG": "debug"
      },
      "sourceMap": {
        "/rustc/${rust_commit_hash}": "${env:HOME}/.rustup/toolchains/${toolchain}/lib/rustlib/src/rust"
      }
    }
  ]
}
// .vscode/tasks.json
{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "cargo: build",
      "type": "shell",
      "command": "cargo",
      "args": ["build", "--message-format=short"],
      "group": {
        "kind": "build",
        "isDefault": true
      },
      "problemMatcher": "$rustc"
    }
  ]
}
# .cargo/config.toml — enable debug symbols for all debug builds
[profile.dev]
debug = 2

Common Pitfalls

  • "Unable to attach to process: Operation not permitted": Missing SYS_PTRACE capability or seccomp=unconfined in devcontainer.json.
  • "No debug symbols found": Missing debug = 2 in .cargo/config.toml or the binary was compiled in release mode.
  • Async frames render as ?? or core::future::poll: Missing rust-src component. Install it with rustup component add rust-src.
  • Breakpoints fail to bind: Ensure the binary path in launch.json matches the actual compiled output. Use ${workspaceFolderBasename} as the binary name for the default Cargo project layout.
  • sourceMap paths incorrect: The rust_commit_hash in the source map path must match the exact compiler version. Use rustc -vV to find the commit hash.

Conclusion

The minimum viable setup for async Rust debugging is: SYS_PTRACE + seccomp=unconfined in devcontainer.json, rust-src component installed, debug = 2 in .cargo/config.toml, and the vadimcn.vscode-lldb extension. The sourceMap entry in launch.json enables stepping into standard library and tokio source, which is what distinguishes useful async debugging from opaque frame dumps.

FAQ

Why does my debugger skip async .await points in the container? LLDB cannot resolve compiler-generated state machines without rust-src and debug = 2. Pin the toolchain, install rust-src, and configure debug = 2 in .cargo/config.toml.

Can I use gdb instead of lldb in a DevContainer? Yes, but lldb via the CodeLLDB extension provides superior async frame visualization for Rust. If using gdb, set MIMode to gdb in launch.json and install rust-gdb in the container. LLDB is recommended for stable async debugging.

How do I attach to a running async process instead of launching it? Change request to attach in launch.json and specify "pid": "${command:pickProcess}". Ensure the container runs with SYS_PTRACE and the target binary was compiled with debug = 2.