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:

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 (-a flag)
  • 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_blocking runs 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):

  1. 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.

  2. Handle is Clone. Because mpsc::Sender is Clone, multiple callers can talk to the same actor concurrently without locks.

  3. Bounded channels for backpressure. Use mpsc::channel(N) with a reasonable capacity. If the channel is full, the caller blocks (or you can use try_send for fire-and-forget paths). Never use unbounded channels for data that could grow without bound.

  4. oneshot for request-response. When a caller needs a result, include a oneshot::Sender in the message. The actor sends the result back on that channel. The caller awaits the receiver.

  5. Graceful shutdown via dropped sender. When all handles are dropped, the channel closes, rx.recv() returns None, and the loop exits. No explicit shutdown signal needed for the common case.

  6. Never tokio::spawn inside Drop. Spawning from Drop creates fire-and-forget tasks that cannot be awaited and are lost during shutdown. Instead, send a message on a channel (unbounded send is sync and non-blocking).

  7. 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 use try_send for the cycle-closing path.

Real examples in vtcode:

ComponentFilePattern
StdioTransportcrates/codegen/vtcode-acp/src/transport.rsHandle sends JSON-RPC via mpsc::UnboundedSender; background tasks handle stdin write, stdout read, stderr log. Uses oneshot for RPC responses.
AsyncLineWritercrates/codegen/vtcode-core/src/utils/async_line_writer.rsCloneable handle sends LogMessage via bounded mpsc; background task buffers and flushes via spawn_blocking.
TimeoutDetectorcrates/codegen/vtcode-core/src/core/timeout_detector.rsGlobal detector with mpsc::UnboundedSender<String> cleanup channel; background task processes end-operation requests from dropped TimeoutHandles.
ProcessHandlecrates/codegen/vtcode-bash-runner/src/pipe.rsHandle wraps channels for stdin, output broadcast, and exit status; separate writer, reader, and wait tasks.

When to use the actor pattern vs. simpler alternatives:

ScenarioRecommended approach
One-shot background work (e.g. prefetch)tokio::spawn + JoinHandle
Shared read-only stateArc<RwLock<T>>
Exclusive ownership of a resourceActor pattern
Multiple producers, single consumer event streammpsc channel (bounded)
Broadcasting to multiple subscribersbroadcast 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