VT Code Async Architecture Guide
July 17, 2026 · View on GitHub
This guide explains VT Code's use of async/await and tokio, based on Ratatui and terminal UI best practices.
When Should VT Code Use Async?
Based on the Ratatui FAQ: "When should I use tokio and async/await?", VT Code uses async for three key reasons:
1. Non-Blocking Event Handling
VT Code's main loop must handle three independent timers and one blocking I/O read without blocking:
Tick Timer (4 Hz) Update app state
Render Timer (60 FPS) Redraw UI
Crossterm Event Read (blocking) Read terminal input
Cancellation Token Graceful shutdown
Without async, polling each source would require sleeping, causing latency. With tokio::select!, VT Code reacts to whichever is ready first.
File: crates/codegen/vtcode-ui/src/tui/core_tui/runner/
tokio::select! {
_ = _cancellation_token.cancelled() => {
break; // Exit immediately on cancel
}
_ = tick_interval.tick() => {
let _ = _event_tx.send(Event::Tick);
}
_ = render_interval.tick() => {
let _ = _event_tx.send(Event::Render);
}
result = tokio::task::spawn_blocking(|| {
crossterm::event::read() // Blocking read
}) => {
// Process crossterm event
}
}
2. Concurrent Tool Execution
VT Code spawns multiple async tasks for MCP tools, PTY commands, and LLM requests:
// Multiple tools run concurrently
let results = tokio::join!(
tool1.execute(),
tool2.execute(),
llm.stream(),
);
Without async: VT Code would block on the first tool, then the second. Response time = Tool1 + Tool2 + LLM.
With async: Tools execute in parallel. Response time ≈ max(Tool1, Tool2, LLM).
3. Streaming API Responses
VT Code streams LLM responses token-by-token without blocking:
let mut stream = llm.stream(&prompt).await?;
while let Some(token) = stream.next().await {
// Receive token, update UI immediately
// UI remains responsive during streaming
}
Architecture: Async vs. Synchronous Paths
VT Code's event loop uses two modes:
Mode 1: Single-Threaded Async (Recommended)
Main tokio runtime
Event handler task (spawned)
Tick interval (async)
Render interval (async)
Crossterm read (spawned_blocking)
Event dispatch (mpsc channel)
Agent loop (async)
Tool execution (concurrent tasks)
LLM streaming (async)
State updates (tokio::sync::Mutex)
Lifecycle hooks (spawned async)
Shell commands (tokio::process::Command)
Used for:
- Interactive chat mode
- ACP protocol integration
- Streaming responses
Mode 2: Synchronous (Fallback)
Synchronous main
Simple print/exec commands (no event loop)
Used for:
- One-shot CLI commands (
vtcode ask "prompt") - Automation runs (
-aflag) - Tool policy testing
Key Async Patterns in VT Code
Pattern 1: Spawned Event Handler
File: crates/codegen/vtcode-ui/src/tui/core_tui/runner/
fn start(&mut self) {
self.task = tokio::spawn(async move {
// This task runs on the tokio runtime
loop {
tokio::select! {
// Three independent sources
}
}
});
}
Why spawn separately?
- The event handler runs independently
- It can be stopped/restarted without blocking the main loop
- Main loop can continue processing events from the channel
Pattern 2: Blocking I/O in Async Context
File: crates/codegen/vtcode-ui/src/tui/core_tui/runner/
let event_fut = tokio::task::spawn_blocking(|| {
crossterm::event::read() // Blocks!
});
Why spawn_blocking?
crossterm::event::read()is a blocking syscall- Calling it directly in async code would block the entire runtime
spawn_blockingruns it in a thread pool (non-blocking to tokio)
Pattern 3: Multiple Concurrent Operations
File: src/agent/runloop/unified/tool_pipeline.rs:6-9
use tokio::sync::Notify;
use tokio::task::JoinHandle;
use tokio::time;
use tokio_util::sync::CancellationToken;
// Tools execute concurrently
let tool_tasks: Vec<JoinHandle<_>> = tools
.iter()
.map(|tool| {
tokio::spawn(async move {
tool.execute().await
})
})
.collect();
// Wait for all to finish
let results = tokio::join_all(tool_tasks).await;
Pattern 4: Graceful Shutdown with CancellationToken
File: crates/codegen/vtcode-ui/src/tui/
pub fn cancel(&self) {
self.cancellation_token.cancel();
}
In event loop:
tokio::select! {
_ = _cancellation_token.cancelled() => {
break; // Exit immediately
}
// ... other branches
}
Why CancellationToken?
- Allows graceful shutdown of spawned tasks
- Tasks check for cancellation and clean up
- No need for forceful
abort()
Pattern 5: Sync primitives for Shared State
File: src/agent/runloop/unified/progress.rs:6
use tokio::sync::Mutex;
use tokio::sync::RwLock;
// Multiple tasks share state safely
let state = Arc::new(Mutex::new(AppState::new()));
let state1 = state.clone();
tokio::spawn(async move {
let mut guard = state1.lock().await;
guard.update();
});
let state2 = state.clone();
tokio::spawn(async move {
let guard = state2.lock().await;
println!("{}", guard);
});
Note: Use tokio::sync::*, not std::sync::* for async code.
Pattern 6: Actor Pattern (Handle + Background Task)
The actor pattern separates the handle (what callers interact with) from the background task (which owns state and performs I/O). This is the recommended pattern when a component needs to own exclusive access to a resource while accepting messages from multiple callers.
Core recipe:
// --- Actor message enum ---
enum ActorMessage {
DoWork { data: String, respond_to: oneshot::Sender<Result<()>> },
Shutdown,
}
// --- Handle: what callers see ---
#[derive(Clone)]
struct MyActorHandle {
tx: mpsc::Sender<ActorMessage>,
}
impl MyActorHandle {
fn new() -> Self {
let (tx, rx) = mpsc::channel(64); // bounded for backpressure
tokio::spawn(actor_loop(rx));
Self { tx }
}
async fn do_work(&self, data: String) -> Result<()> {
let (respond_to, response) = oneshot::channel();
let _ = self.tx.send(ActorMessage::DoWork { data, respond_to }).await;
response.await.expect("actor task panicked")
}
}
// --- Background task: owns state ---
async fn actor_loop(mut rx: mpsc::Receiver<ActorMessage>) {
while let Some(msg) = rx.recv().await {
match msg {
ActorMessage::DoWork { data, respond_to } => {
let result = process(data).await;
let _ = respond_to.send(result);
}
ActorMessage::Shutdown => break,
}
}
}
Key principles (from Actors with Tokio):
-
Handle is separate from task. The handle struct holds only a channel sender. The background task owns all mutable state. This enforces single-ownership of state at compile time.
-
Handle is Clone. Because
mpsc::SenderisClone, multiple callers can talk to the same actor concurrently without locks. -
Bounded channels for backpressure. Use
mpsc::channel(N)with a reasonable capacity. If the channel is full, the caller blocks (or you can usetry_sendfor fire-and-forget paths). Never use unbounded channels for data that could grow without bound. -
oneshotfor request-response. When a caller needs a result, include aoneshot::Senderin the message. The actor sends the result back on that channel. The caller awaits the receiver. -
Graceful shutdown via dropped sender. When all handles are dropped, the channel closes,
rx.recv()returnsNone, and the loop exits. No explicit shutdown signal needed for the common case. -
Never
tokio::spawninsideDrop. Spawning fromDropcreates fire-and-forget tasks that cannot be awaited and are lost during shutdown. Instead, send a message on a channel (unboundedsendis sync and non-blocking). -
Avoid cycles of bounded channels. If Actor A sends to Actor B and B sends to A, both using bounded channels, a deadlock can occur if both channels fill up. Break cycles with
tokio::select!on a "primary" channel, or usetry_sendfor the cycle-closing path.
Real examples in vtcode:
| Component | File | Pattern |
|---|---|---|
StdioTransport | crates/codegen/vtcode-acp/src/transport.rs | Handle sends JSON-RPC via mpsc::UnboundedSender; background tasks handle stdin write, stdout read, stderr log. Uses oneshot for RPC responses. |
AsyncLineWriter | crates/codegen/vtcode-core/src/utils/async_line_writer.rs | Cloneable handle sends LogMessage via bounded mpsc; background task buffers and flushes via spawn_blocking. |
TimeoutDetector | crates/codegen/vtcode-core/src/core/timeout_detector.rs | Global detector with mpsc::UnboundedSender<String> cleanup channel; background task processes end-operation requests from dropped TimeoutHandles. |
ProcessHandle | crates/codegen/vtcode-bash-runner/src/pipe.rs | Handle wraps channels for stdin, output broadcast, and exit status; separate writer, reader, and wait tasks. |
When to use the actor pattern vs. simpler alternatives:
| Scenario | Recommended approach |
|---|---|
| One-shot background work (e.g. prefetch) | tokio::spawn + JoinHandle |
| Shared read-only state | Arc<RwLock<T>> |
| Exclusive ownership of a resource | Actor pattern |
| Multiple producers, single consumer event stream | mpsc channel (bounded) |
| Broadcasting to multiple subscribers | broadcast channel |
Anti-Patterns to Avoid
Anti-Pattern 1: Mixing Blocking I/O with Async
Bad:
async fn handle_event(key: KeyEvent) {
let result = std::fs::read("file.txt"); // Blocks the runtime!
process(result).await;
}
Good:
async fn handle_event(key: KeyEvent) {
let result = tokio::fs::read("file.txt").await; // Async read
process(result).await;
}
Anti-Pattern 2: Spawning Tasks Without Tracking
Bad:
tokio::spawn(async {
expensive_operation().await;
// Task runs in background, result lost
});
Good:
let handle = tokio::spawn(async {
expensive_operation().await
});
// Later: wait for result
let result = handle.await?;
Anti-Pattern 3: std::sync Locks in Async Code
Bad:
let state = Arc::new(Mutex::new(data));
tokio::spawn(async move {
let guard = state.lock().unwrap(); // Can deadlock!
// ... if another task holds the lock
});
Good:
let state = Arc::new(tokio::sync::Mutex::new(data));
tokio::spawn(async move {
let guard = state.lock().await; // Async lock
// ... safe, can yield
});
Anti-Pattern 4: tokio::spawn in Drop
Bad:
impl Drop for MyHandle {
fn drop(&mut self) {
let resource = self.resource.clone();
tokio::spawn(async move {
resource.cleanup().await; // Fire-and-forget, lost on shutdown
});
}
}
Good: Use a channel-based cleanup task instead:
impl Drop for MyHandle {
fn drop(&mut self) {
// Channel send is synchronous and non-blocking
let _ = self.cleanup_tx.send(self.operation_id.clone());
}
}
// A background actor task receives these messages and does the async work.
Why? tokio::spawn in Drop creates a fire-and-forget task. If the runtime is shutting down, the task is silently lost. You cannot await its completion, and there is no way to handle errors.
Anti-Pattern 5: Not Handling Cancellation
Bad:
tokio::spawn(async {
loop {
process().await;
// Never checks for cancellation!
}
});
Good:
let cancel_token = CancellationToken::new();
let cancel_clone = cancel_token.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = cancel_token.cancelled() => break,
result = process() => handle(result),
}
}
});
// Later:
cancel_clone.cancel(); // Gracefully stop the task
Integration with Event Loop
The Main Event Loop (Async)
VT Code's main loop (in chat mode) typically looks like:
#[tokio::main]
async fn main() {
let mut tui = Tui::new()?;
tui.enter()?; // Start event handler task
loop {
match tui.next().await {
Some(Event::Key(key)) => {
// Handle key (may spawn async tasks)
}
Some(Event::Render) => {
// Redraw UI
}
Some(Event::Tick) => {
// Update state (may await)
}
Some(Event::Quit) => break,
_ => {}
}
}
tui.exit()?; // Stop event handler task
}
Spawning Long-Running Operations
When a key is pressed that triggers a long operation:
Event::Key(key) if key.code == KeyCode::Enter => {
// Start an async tool execution in the background
let task = tokio::spawn(async move {
let result = tool.execute().await;
// Post result to UI via channel
});
// Main loop continues, responding to events
// Task runs concurrently
}
Testing Async Code
VT Code uses #[tokio::test] for async tests:
File: crates/codegen/vtcode-core/src/hooks/lifecycle/tests.rs
#[tokio::test]
async fn test_lifecycle_hook_execution() {
let config = load_test_config();
let result = execute_hook(&config, event).await;
assert!(result.is_ok());
}
Run async tests:
cargo test --lib # Runs all #[tokio::test] tests
Configuration
Tokio Runtime
VT Code uses the default tokio runtime (work-stealing scheduler, multiple OS threads):
#[tokio::main]
async fn main() {
// Default: multi-threaded runtime
// All async code runs here
}
For custom runtime config:
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
// Explicitly set 4 worker threads
}
Timeouts
VT Code uses tokio::time::timeout for long operations:
File: src/agent/runloop/unified/async_mcp_manager.rs:5
use tokio::time::{Duration, timeout};
let result = timeout(
Duration::from_secs(30),
tool.execute()
).await;
match result {
Ok(Ok(output)) => {}, // Completed in time
Ok(Err(e)) => {}, // Tool error
Err(_) => {}, // Timeout!
}
Performance Considerations
Memory: Task Overhead
Each spawned task allocates ~64 bytes. VT Code typically spawns:
- 1 event handler task
- N tool execution tasks (concurrent)
- Lifecycle hook tasks (per event)
For typical usage (5-10 concurrent operations), memory overhead is negligible.
CPU: Context Switching
Tokio's work-stealing scheduler minimizes context switches. Most of VT Code's async operations are I/O-bound (waiting for network, terminal, file system), so context switching is cheap.
Latency: select! Fairness
tokio::select! picks the first ready future. If multiple futures are ready, it picks in definition order. VT Code prioritizes shutdown > ticks > renders > events to ensure responsiveness.
See Also
- Ratatui FAQ: Async & Tokio
- Tokio Tutorial
- Tokio Select Documentation
- Rust Async Book
crates/codegen/vtcode-ui/src/tui/- Event loop implementationsrc/agent/runloop/unified/tool_pipeline.rs- Concurrent tool execution