Debugging Rust Async Code in VS Code Containers

Async Rust is hard to debug because execution jumps across await points and futures don't map to a simple call stack. This page sets up rust-analyzer and CodeLLDB in a devcontainer so you can set breakpoints across await boundaries, step through a Tokio runtime, and inspect future state.

The reason this matters is that a synchronous debugger assumes a stack that grows and shrinks in one direction: you call a function, a frame is pushed, you return, it pops. An async fn breaks that assumption. When a task hits .await and the underlying future returns Poll::Pending, the runtime unwinds the poll call entirely and hands control back to the executor, which is free to run other tasks before it ever polls yours again. The linear stack you were stepping through simply evaporates, and when the task resumes it does so from a state machine that the compiler generated behind your back. Without the right tooling that resumption looks like a jump to nowhere.

You reach for this setup when a bug lives in the seams between await points — a value that is correct before an .await and wrong after it, a task that never resumes because a channel was dropped, or a future that completes in the wrong order relative to its siblings. The mental model to hold is that rust-analyzer and CodeLLDB do two different jobs and you need both: rust-analyzer resolves the types and the await context so the source you are stepping through actually means something, and CodeLLDB binds native breakpoints against the compiled binary so execution genuinely halts on the machine code the runtime is executing. Get both running inside the container against a symbol-bearing debug build and the state machine becomes legible: you can watch a future move from one suspension point to the next and inspect the captured locals at each stop.

Prerequisites

You need a Rust devcontainer with rust-analyzer and a debugger extension.

  • Rust installed via a Feature, with a pinned toolchain.
  • The rust-analyzer and CodeLLDB extensions in the container.
  • A debug build (--debug) so symbols are present.

Each of these is load-bearing, and the order they appear in matters. The pinned toolchain comes first because rust-analyzer and CodeLLDB both introspect artifacts produced by a specific compiler version; if the container rebuilds against a different rustc than the one that emitted your target/debug binary, the debug info can drift and the source lines you set breakpoints on stop lining up with the machine code. Installing Rust through a Feature rather than an ad-hoc curl | sh in a RUN step keeps that version reproducible across rebuilds and across every teammate who opens the container.

The detail people most often get wrong is treating the debug build as automatic. It is easy to assume that because you have not passed --release, symbols are simply there — but a stray RUSTFLAGS setting, an inherited [profile.dev] override in Cargo.toml, or a CI-oriented base image can quietly strip or reorder the debug info. When that happens breakpoints show as hollow circles that never bind, and reconfiguring CodeLLDB will not fix it because the problem is upstream in how the binary was compiled. Confirm the profile before you touch the launch config.

Rust debug prerequisitesYou need a pinned toolchain, rust-analyzer, CodeLLDB, and a symbol-bearing debug build.Rust toolchainpinnedrust-analyzerin containerCodeLLDBdebug adapterDebug buildsymbols

Step-by-Step Implementation

  1. Set up the toolchain and debugger in the devcontainer.
{
  "features": { "ghcr.io/devcontainers/features/rust:1": {} },
  "customizations": {
    "vscode": { "extensions": ["rust-lang.rust-analyzer", "vadimcn.vscode-lldb"] }
  },
  "remoteUser": "vscode"
}

This block does three jobs in one place, which is why it belongs in devcontainer.json rather than being pieced together by hand. The rust Feature installs the toolchain into the image so the compiler and the binary you debug share one origin. The two extension IDs — rust-lang.rust-analyzer and vadimcn.vscode-lldb — are declared under customizations.vscode so they install into the container's VS Code server, not your host; that placement is the whole reason the debug adapter later runs against the container binary instead of trying to reach across to the host. Setting remoteUser to vscode means the build artifacts and the debugger process share a non-root identity, which keeps file ownership on target/debug consistent between the compiler that writes it and the adapter that reads it.

  1. Build with debug symbols so breakpoints bind.
cargo build   # debug profile keeps symbols

The absence of --release here is the entire point: cargo build selects the dev profile, which compiles with opt-level = 0 and debug = true, so the resulting binary carries full DWARF debug info and unoptimized code that maps cleanly line-for-line back to your source. In async code this matters even more than in synchronous code, because the compiler lowers each async fn into a generated state-machine enum, and optimizations aggressively inline and coalesce those states. A debug build keeps the state transitions visible as distinct steps, so when CodeLLDB stops at a suspension point you can still see which variant of the future you are in and which locals it captured. Run this before every debug session; a stale release binary left in target is a common reason breakpoints silently refuse to bind.

  1. Configure a launch config targeting the debug binary.
{
  "type": "lldb",
  "request": "launch",
  "program": "${workspaceFolder}/target/debug/app"
}

The "type": "lldb" field is what routes the session to CodeLLDB rather than a generic C++ adapter, and it is the piece that makes native Rust breakpoints and value inspection work. "request": "launch" tells the adapter to start the process itself rather than attach to a running one, which is the right choice when you want breakpoints to bind from the very first line — attaching after startup can miss early await points that have already executed. The program path points at ${workspaceFolder}/target/debug/app, and both halves are deliberate: target/debug is the debug-profile output directory from the previous step, and ${workspaceFolder} resolves inside the container so the adapter loads the binary the container built, not a host copy. If you rename the crate, the app segment must change to match the produced binary or the launch fails with a missing-program error.

  1. Set breakpoints across await points and inspect futures at each.
Place a breakpoint before and after an .await; step to observe the resumed state.

Bracketing an .await with two breakpoints is the technique that turns an invisible suspension into an observable event. The pre-await breakpoint catches the task in the state where it is about to yield, letting you inspect the locals the future is holding before it hands control back to the executor. The post-await breakpoint then catches the same task after the runtime has scheduled and resumed it, so you can confirm what the awaited value actually resolved to and whether any captured state changed while the task was parked. Do not expect the "step over" button to walk you smoothly from one to the other: between the two breakpoints the executor may poll dozens of unrelated tasks, so a plain step can appear to hang or land in runtime frames. Relying on the two breakpoints instead of stepping is what prevents that confusion and keeps you focused on your own task's progression rather than the scheduler's bookkeeping.

Async debug anatomyrust-analyzer plus CodeLLDB with debug symbols lets you step across await points and inspect futures.rust-analyzertypes + await contextCodeLLDBnative debug adapterdebug symbolsbreakpoints bindawait steppingobserve resumed state

Common Pitfalls

Async debugging issues are stripped symbols, an optimized build, or a debugger not routed in-container.

A recurring source of trouble is ownership of the build cache when the container writes to target from more than one identity. If part of your build runs as root — say a postCreateCommand that invokes cargo build before the vscode user takes over — the object files under target/debug end up owned by root, and the later debug session running as vscode may fail to read or overwrite them. The symptom is easy to misread as a debugger fault when it is really a permission fault. Keeping every compile and every debug launch under the same remoteUser avoids the split; if you have already tripped over it, a one-time chown -R vscode target resets ownership so subsequent builds stay consistent.

The topic-specific pitfall worth dwelling on is the "can't step across await" row in the table below. When execution seems stuck or the stepping controls drop you into unfamiliar runtime frames, the instinct is to assume the debugger is broken. It usually is not — you are simply seeing the Tokio executor's poll loop, which sits between your task suspending and resuming. The fix is not to keep stepping but to set an explicit breakpoint on the line just after the .await and let the program continue to it. The runtime will resume your task in its own time, hit that breakpoint, and hand you back a coherent view of the resumed state with its captured locals intact.

Async debug triageA triage path from breakpoints that won't bind to steppable async flow.Do breakpoints bind at all?NOBuild debug; check symbolsDoes execution stop across await?YESDebugger runs in the containerAsync flow is steppable

SymptomRoot CauseRemediation
Breakpoints don't bindOptimized/release build, no symbolsUse the debug profile
Can't step across awaitRuntime frames hiddenStep into the resumed task; inspect state
Debugger uses host, not containerAdapter not routed in-containerRun CodeLLDB in the devcontainer
Types missing in inspectorrust-analyzer not indexedLet rust-analyzer finish indexing

Conclusion

Async Rust is debuggable in a container with the right pair: rust-analyzer for type and await context, and CodeLLDB for native breakpoints, both running inside the container against a debug build. Set breakpoints around await points and step to watch a future resume — the execution jumps, but the debugger follows.

The strategic payoff is that once this setup exists in devcontainer.json, debugging async Rust stops being a per-person art and becomes a property of the repository. Anyone who opens the container inherits the same pinned toolchain, the same two extensions in the container server, and the same target/debug launch config, so a breakpoint that binds on your machine binds on theirs. That reproducibility is what separates a debug session you can hand off from one that only works because of undocumented state on your laptop, and it is the same discipline that makes a bug report actionable: "set this breakpoint in the checked-in launch config" is a far stronger instruction than "attach lldb somehow and hope."

This ties directly into the broader pin-and-cache theme that runs through container-based development. Pinning the toolchain via a Feature is the pin; keeping the target cache under a single consistent owner is what lets that cache be reused safely across rebuilds instead of being invalidated or fought over. Debug info is just another artifact that benefits from being produced once, deterministically, and read the same way every time. Treat the debug build, the launch config, and the extension set as a single reproducible unit, and the async state machine that once looked like a jump to nowhere becomes something you can step through as confidently as ordinary synchronous code.

Analyzer vs debuggerrust-analyzer supplies semantic context; CodeLLDB supplies runtime stepping.rust-analyzer givesTypes + hoversAwait contextGo-to-definitionCodeLLDB givesNative breakpointsStep/continueVariable inspection

FAQ

Why won't my breakpoints bind in async Rust? Usually because the binary was built in release/optimized mode, which strips or reorders the symbols breakpoints rely on. Build with the debug profile (cargo build without --release) so symbols are present, and confirm CodeLLDB targets the target/debug binary. If the profile is correct and breakpoints still show hollow, check whether a [profile.dev] override in Cargo.toml has raised opt-level or disabled debug — those settings quietly reintroduce the same stripping. A stale binary is the other frequent culprit: if you edited source but did not rebuild, the adapter is loading yesterday's object code whose line table no longer matches the file, so run cargo build again and relaunch.

How do I step across an await point? Set breakpoints both before and after the .await. When execution suspends, the runtime schedules the task and resumes it later; stepping continues at the post-await breakpoint with the resumed state. You're observing the future's progression rather than a single linear stack, which is normal for async. Avoid the "step over" button across the await itself, because between suspension and resumption the executor may poll many unrelated tasks and the step can appear to hang or drop you into runtime internals. Let the program continue to the post-await breakpoint instead, and treat the two stops as bookends around the suspension rather than expecting one continuous walk of a single stack frame.

Does the debugger run on the host or in the container? In the container — CodeLLDB's debug adapter runs inside the devcontainer alongside your code, so it debugs the actual container binary against the container's toolchain. Declaring the extension under customizations.vscode installs it into the server, keeping the whole debug path in-container. This matters because a host-side adapter would try to load a binary compiled for a different environment, and any mismatch in glibc, target triple, or toolchain version leaves the debug info subtly wrong. Keeping the adapter, the compiler, and the binary in one place removes that whole class of "works on my machine" discrepancy.

Can I inspect the contents of a future while it's suspended? Partly, and it helps to know what you are looking at. A suspended async fn is compiled into a generated state-machine enum, so in CodeLLDB's variables pane the future appears as that enum with a variant per suspension point and the captured locals stored as fields on the active variant. The names are compiler-generated and can look opaque, but the captured values are real and inspectable. Combined with rust-analyzer's type information in the editor, you can usually match a variant back to the specific .await it corresponds to and read the state the task was holding when it parked.

Do I need a special Tokio configuration to debug this way? No — CodeLLDB works against the ordinary debug binary regardless of which runtime you use, so a stock Tokio setup needs no debugger-specific flags. For richer async introspection you can add the tokio-console tooling as a complement: it surfaces task-level scheduling and wakers over a live connection, while CodeLLDB gives you line-level breakpoints and local inspection. Use the debugger for "what is this value at this suspension point" and the console for "why is this task not being polled."