How webtor-rs Manages Tor Circuits: Architecture and Implementation Guide

webtor-rs manages Tor circuits through a three-tier architecture where Circuit structs track individual circuit state, CircuitManager handles pool maintenance and stream isolation, and TorClient provides the high-level API, all implemented in src/circuit.rs and src/client.rs.

The webtor-rs repository provides a Rust-based Tor client implementation designed for WebAssembly environments. Understanding how webtor-rs manages Tor circuits is essential for developers building privacy-preserving applications that require fine-grained control over circuit creation, stream isolation, and lifecycle maintenance.

Core Components of Circuit Management

The Circuit Struct

The Circuit struct in src/circuit.rs (lines 35-48) represents a single Tor circuit with the following fields:

pub struct Circuit {
    pub id: String,                     // Human-readable identifier
    pub status: CircuitStatus,          // Creating, Ready, Extending, Failed, Closed
    pub created_at: Instant,            // When the circuit was built
    pub last_used: Instant,             // Updated on every request
    pub relays: Vec<Relay>,             // Bridge, middle and exit relays
    pub(crate) internal_circuit: Option<Arc<ClientTunnel>>,
    pub isolation_key: Option<IsolationKey>, // Optional binding for stream isolation
    _private: (),
}

The status enum drives state checks through methods like is_ready(), is_failed(), and is_closed() (lines 26-33). The Circuit::new() constructor stamps the creation time and defaults the status to Creating (lines 63-76).

The CircuitManager

The CircuitManager in src/circuit.rs (lines 90-115) acts as the central pool controller:

pub struct CircuitManager {
    circuits: Arc<RwLock<Vec<Arc<RwLock<Circuit>>>>>,
    relay_manager: Arc<RwLock<RelayManager>>,
    channel: Arc<RwLock<Option<Arc<Channel>>>>,
    prebuild_in_progress: Arc<AtomicBool>,
}

Key responsibilities include:

  • Storage: A thread-safe Vec of Arc<RwLock<Circuit>> allows concurrent async access.
  • Creation: Building new circuits through create_circuit_with_isolation().
  • Isolation: Assigning circuits to specific isolation keys.
  • Maintenance: Pre-building spares and cleaning up expired circuits.

The TorClient API

The TorClient in src/client.rs (lines 30-38) provides the high-level interface that delegates all circuit work to the CircuitManager. During construction (TorClient::new), the client initializes the manager and optionally creates an early circuit (lines 69-73).

Circuit Creation and Stream Isolation

Building a New Circuit

The CircuitManager::create_circuit_with_isolation() method (lines 117-180) implements the construction logic:

  1. Pick the bridge: A Snowflake bridge serves as the first hop using a FAST handshake.
  2. Select middle and exit relays: Uses RelayManager::select_relay with criteria excluding previously chosen relays.
  3. Build the ClientTunnel: Via the Arti Channel using new_tunnel, create_firsthop_fast, and extend operations.
  4. Populate the Circuit struct: Stores the three relays, sets status = Ready, and optionally binds an IsolationKey before adding to the pool.

The function returns an Arc<RwLock<Circuit>> for use by the rest of the library.

Implementing Stream Isolation

The CircuitManager::get_circuit_for_isolation_key() method (lines 332-376) enforces isolation:

Step Action
1️⃣ Search for a ready circuit already bound to the requested key.
2️⃣ If none exists, find a ready, unassigned circuit, bind it to the key, and reuse it.
3️⃣ Enforce MAX_CIRCUITS_PER_ISOLATION_KEY. If the limit is reached, reuse any existing circuit for that key.
4️⃣ If no suitable circuit exists, create a new one already bound to the key via create_circuit_with_isolation.

All operations occur under the same lock scope to prevent race conditions where two concurrent requests might steal the same unassigned circuit.

Lifecycle Management and Maintenance

Pre-building Spare Circuits

The maybe_prebuild_circuit() method (lines 629-702) maintains pool health:

  • Trigger conditions: Checks current pool size and average circuit age via CircuitStatusInfo.
  • Concurrency control: Uses an AtomicBool (prebuild_in_progress) to guarantee only one pre-build runs simultaneously.
  • Background execution: Spawns the creation task using tokio::spawn or wasm_bindgen_futures::spawn_local depending on the target platform.

This runs periodically or after successful requests based on the circuit_update_interval configuration.

Circuit Cleanup and Status Monitoring

The cleanup_circuits() method (lines 704-755) removes:

  • Failed circuits (status Failed).
  • Very old circuits (age ≥ 1 hour).
  • Idle circuits (unused > 10 minutes) while preserving at least one circuit in the pool.

The method iterates the vector, gathers indices to drop, then removes them in reverse order to maintain index stability.

The get_circuit_status() method (lines 390-421) aggregates counts (ready_circuits, creating_circuits, failed_circuits) and calculates average age, which TorClient uses to generate human-readable status strings via get_circuit_status_string.

Practical Implementation Examples

Creating a Client and Fetching a URL

use webtor::client::TorClient;
use webtor::config::TorClientOptions;

#[tokio::main]
async fn main() -> webtor::error::Result<()> {
    // Snowflake bridge URL – the default bridge type
    let options = TorClientOptions::new("wss://snowflake.torproject.net/".into())
        .with_create_circuit_early(true);

    let client = TorClient::new(options).await?;
    client.bootstrap().await?;                 // fetch consensus (optional)

    let resp = client.get("https://httpbin.org/ip").await?;
    println!("Response: {}", resp.body_as_string()?);
    client.close().await;
    Ok(())
}

Key flow: TorClient::new → establish_channel → CircuitManager::create_circuit → client.get → Circuit::begin_stream.

Using Stream Isolation Per Domain

let client = TorClient::new(options).await?;
let isolation_key = webtor::isolation::IsolationKey::from_string("example.com");

// Get or create a circuit bound to this key
let circ = client
    .circuit_manager
    .read()
    .await
    .get_circuit_for_isolation_key(Some(isolation_key))
    .await?;

// Use the circuit to open a stream
let stream = circ.read().await.begin_stream("example.com", 80).await?;

The manager guarantees that two different isolation keys never share the same circuit path.

Triggering Pre-build Manually

let manager = client.circuit_manager.read().await.clone();
manager.maybe_prebuild_circuit(10, std::time::Duration::from_secs(300)).await;

When the average circuit age exceeds the 5-minute threshold and the pool is below the maximum size, a spare circuit builds in the background.

Summary

  • webtor-rs manages Tor circuits through a layered architecture separating state (Circuit), pool logic (CircuitManager), and public API (TorClient).
  • Deterministic lifecycle states (Creating, Ready, Failed, Closed) tracked in src/circuit.rs enable reliable cleanup and status reporting.
  • Stream isolation binds circuits to specific keys via get_circuit_for_isolation_key(), ensuring different origins never share paths while enforcing per-key limits.
  • Proactive maintenance includes pre-building spare circuits when pools age and automatic cleanup of failed or idle circuits after timeouts.
  • WASM compatibility throughout the stack allows the same circuit management logic to run in both native Rust and WebAssembly environments.

Frequently Asked Questions

How does webtor-rs ensure that different applications or domains don't share the same Tor circuit?

webtor-rs implements stream isolation through the IsolationKey type. When fetching content, the CircuitManager::get_circuit_for_isolation_key() method (lines 332-376 in src/circuit.rs) either finds an existing circuit bound to that key, binds an unassigned ready circuit to the key, or creates a new circuit already associated with the key. The system enforces MAX_CIRCUITS_PER_ISOLATION_KEY to prevent resource exhaustion while guaranteeing that different keys never share the same circuit path.

What triggers the creation of a new Tor circuit in webtor-rs?

Circuits are created through three primary mechanisms in src/circuit.rs. First, explicit creation via create_circuit_with_isolation() (lines 117-180) when no suitable circuit exists for a request. Second, pre-building via maybe_prebuild_circuit() (lines 629-702), which spawns background circuit creation when the pool's average age exceeds configured thresholds. Third, early initialization when TorClient is constructed with with_create_circuit_early(true), establishing the first circuit during client bootstrap.

How does webtor-rs handle failed or expired circuits?

The CircuitManager performs periodic cleanup through cleanup_circuits() (lines 704-755 in src/circuit.rs). This method removes circuits with Failed status, circuits older than one hour, and circuits idle for more than ten minutes, while always preserving at least one circuit in the pool to prevent complete drainage. Additionally, the get_circuit_status() method (lines 390-421) continuously monitors circuit health, enabling TorClient to report aggregate status and trigger maintenance cycles based on the circuit_update_interval configuration.

Can webtor-rs build circuits in the background to improve latency?

Yes, webtor-rs implements proactive circuit pre-building via maybe_prebuild_circuit() in src/circuit.rs (lines 629-702). When the circuit pool size falls below a threshold or the average circuit age exceeds a specified duration (default 300 seconds), the manager spawns a background task using tokio::spawn or wasm_bindgen_futures::spawn_local to construct a new circuit. An AtomicBool flag (prebuild_in_progress) prevents duplicate concurrent pre-builds, ensuring efficient resource utilization while maintaining a pool of fresh circuits ready for immediate use.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →