Kill Async Deadlocks in Tauri with Tokio Task Dumps + Tracing

7 min readYaseen Khatib · MERN + AI Architect
Cover illustration: Kill Async Deadlocks in Tauri with Tokio Task Dumps + Tracing

Why Tauri apps deadlock under load (and what “deadlock” really means here)

In async Rust, most “deadlocks” aren’t lock-order bugs. They’re starvation in a cooperative scheduler. Typical triggers:

  • Taking a std::sync::Mutex or doing blocking IO in async code, then holding it across an .await.
  • Waiting on a task that’s also waiting on you (cycles over channels or locks).
  • Single-threaded sections saturated (local tasks, JS event loop handoffs) so nothing gets scheduled.
  • Back-pressure with no timeouts: bounded channels fill up and no cancellation path exists.

Tokio and Tauri don’t prevent this. You need task and span visibility and a way to cancel work.

How do I dump Tokio tasks in a production Tauri app?

Enable tokio-console using console-subscriber and build with tokio_unstable. Bind the console server to loopback and connect with the tokio-console TUI. In production, guard it behind a feature flag or an authenticated command, then capture a task dump and line it up with tracing spans.

1) Wire up console-subscriber and tracing

# Cargo.toml
[dependencies]
tauri = "1" # or "2"; works the same for the Rust side
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
console-subscriber = "0.2"
once_cell = "1"
dashmap = "5"
tokio-util = { version = "0.7", features = ["rt"] } # for CancellationToken
uuid = { version = "1", features = ["v4"] }
# .cargo/config.toml
[build]
rustflags = ["--cfg", "tokio_unstable"]  # required by console-subscriber/console
// src/main.rs
use once_cell::sync::Lazy;
use dashmap::DashMap;
use std::time::{Duration, Instant};
use tokio::{task::JoinHandle, time};
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
use tracing_subscriber::{prelude::*, EnvFilter, Registry};

#[derive(Clone)]
struct TrackedTask {
    name: &'static str,
    started: Instant,
    cancel: CancellationToken,
    handle: JoinHandle<()>,
}

static TASKS: Lazy<DashMap<uuid::Uuid, TrackedTask>> = Lazy::new(DashMap::new);

fn init_observability() {
    let console_layer = console_subscriber::spawn();
    let fmt = tracing_subscriber::fmt::layer().with_target(false);
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new("info,tauri_app=trace,tokio=info"));

    Registry::default().with(console_layer).with(filter).with(fmt).init();
}

fn spawn_named<F>(name: &'static str, fut: F) -> uuid::Uuid
where
    F: std::future::Future<Output = ()> + Send + 'static,
{
    let id = uuid::Uuid::new_v4();
    let cancel = CancellationToken::new();
    let span = tracing::info_span!("task", %id, name);
    let handle = tokio::spawn({
        let cancel = cancel.clone();
        async move {
            let _e = span.enter();
            tokio::select! {
                _ = fut => {},
                _ = cancel.cancelled() => warn!(%id, name, "task cancelled"),
            }
        }
    });

    TASKS.insert(
        id,
        TrackedTask {
            name,
            started: Instant::now(),
            cancel,
            handle,
        },
    );
    id
}

#[tauri::command]
async fn diagnostics_start_console() -> String {
    // With console-subscriber this env var controls the bind address for the gRPC server
    // Set once at startup via env: TOKIO_CONSOLE_BIND=127.0.0.1:6669
    // Here we just return where to connect.
    std::env::var("TOKIO_CONSOLE_BIND").unwrap_or_else(|_| "127.0.0.1:6669".to_string())
}

#[tauri::command]
async fn diagnostics_dump_tasks() -> Vec<String> {
    TASKS
        .iter()
        .map(|e| format!("{} {} age={:?}", e.key(), e.value().name, e.value().started.elapsed()))
        .collect()
}

#[tauri::command]
async fn diagnostics_kill_task(id: String) -> bool {
    if let Ok(uuid) = uuid::Uuid::parse_str(&id) {
        if let Some((_, t)) = TASKS.remove(&uuid) {
            t.cancel.cancel();
            t.handle.abort();
            return true;
        }
    }
    false
}

fn main() {
    init_observability();

    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            diagnostics_start_console,
            diagnostics_dump_tasks,
            diagnostics_kill_task
        ])
        .setup(|_app| {
            // watchdog: log any task older than N seconds
            tokio::spawn(async move {
                loop {
                    for e in TASKS.iter() {
                        if e.started.elapsed() > Duration::from_secs(30) {
                            warn!(task.id=%e.key(), name=e.name, age=?e.started.elapsed(), "long-running task");
                        }
                    }
                    time::sleep(Duration::from_secs(5)).await;
                }
            });
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

2) Run tokio-console against your live app

# Build with tokio_unstable, run locally or on a host you can port-forward from
export RUST_LOG=info,tauri_app=trace,tokio=info
export TOKIO_CONSOLE_BIND=127.0.0.1:6669
./YourTauriApp

# On your workstation
cargo install tokio-console
TOKIO_CONSOLE_ADDR=127.0.0.1:6669 tokio-console

You’ll get a live view of tasks, their state (running/idle), waker counts, and spans. Filter on span fields to find long-lived work and its causal chain.

How do I kill a stuck Tokio task safely?

Track long jobs with CancellationTokens and JoinHandles. When an admin command arrives, request cancellation first (cooperative). If the task ignores it, abort.

Wrap work in a tokio::select! that listens to the token. Add timeouts around awaits and channel operations so the cancel or abort path can run instead of getting trapped behind full queues.

#[tracing::instrument(name = "index_large_tree", skip_all, fields(req_id=%req_id))]
pub async fn index_large_tree(req_id: &str, root: String) {
    let id = spawn_named("indexer", async move {
        // Simulate: walk filesystem + channel fanout with timeout guards
        if let Err(e) = do_index(root).await {
            tracing::error!(?e, "index failed");
        }
    });
    info!(%id, "spawned indexer");
}

async fn do_index(root: String) -> anyhow::Result<()> {
    use tokio::fs;
    use tokio::time::{timeout, Duration};

    // Time-box IO hot spots
    let entries = timeout(Duration::from_secs(10), fs::read_dir(&root)).await??;
    // ... fan out work over channels with bounded capacity and timeouts
    Ok(())
}

#[tauri::command]
async fn kill_long_tasks(older_than_secs: u64) -> usize {
    let mut killed = 0;
    let threshold = Duration::from_secs(older_than_secs);
    for e in TASKS.iter() {
        if e.started.elapsed() > threshold {
            e.cancel.cancel();
            e.handle.abort();
            killed += 1;
        }
    }
    killed
}

Key points:

  • Provide a cooperative cancellation path.
  • Put timeouts around external IO and channel sends.
  • Abort only when needed; it stops the future immediately and drops resources.

What are the classic async deadlocks in Tokio (and how do I avoid them)?

Holding a std::sync::Mutex across awaits

use std::sync::Mutex; // Bad in async hot-paths
use tokio::task;

struct Shared { data: Mutex<Vec<u8>> }

async fn bad(shared: &'static Shared) {
    let mut g = shared.data.lock().unwrap();
    // Any .await here can starve other threads waiting for this OS mutex
    task::yield_now().await; // <- boom: other tasks can’t progress
    g.push(1);
}

Prefer tokio::sync::Mutex (or better, avoid holding any lock across an await). Keep critical sections short.

use tokio::sync::Mutex; // Async-aware lock

struct Shared { data: Mutex<Vec<u8>> }

async fn good(shared: &'static Shared) {
    {
        let mut g = shared.data.lock().await;
        g.push(1);
    } // lock released before any .await following
    tokio::task::yield_now().await;
}

Cyclic waits via channels

Bounded channels with no timeouts often freeze pipelines.

use tokio::sync::mpsc;

async fn bad_cycle() {
    let (tx, mut rx) = mpsc::channel::<()>(1);
    let tx2 = tx.clone();

    let _t1 = tokio::spawn(async move {
        // waits for t2, but t2 waits for t1 to recv -> deadlock
        tx2.send(()).await.ok();
    });

    // t2
    let _t2 = tokio::spawn(async move {
        rx.recv().await; // waiting forever if no capacity
    });
}

Fix by breaking cycles and adding select! with timeouts or cancellation; use try_send where it fits.

How do I propagate a request ID from React to Rust tracing?

Generate a UUID in the frontend, send it as a command argument, and record it as a span field via #[instrument]. That gives end-to-end correlation in logs and tokio-console, so you can filter the exact workflow.

// src/renderer/diagnostics.ts
import { invoke } from "@tauri-apps/api/tauri";
import { v4 as uuid } from "uuid";

export async function runIndex(root: string) {
  const reqId = uuid();
  await invoke("index_large_tree_cmd", { reqId, root });
  return reqId;
}
// src/commands.rs
use tracing::instrument;

#[tauri::command]
#[instrument(name = "index_large_tree_cmd", skip_all, fields(req_id = %req_id, root = %root))]
pub async fn index_large_tree_cmd(req_id: String, root: String) -> Result<(), String> {
    index_large_tree(&req_id, root).await;
    Ok(())
}

With RUST_LOG=tauri_app=trace, logs include req_id=.... In tokio-console, filter on that field to see every related task/span.

Operating this in production

  • Gate diagnostics with a feature flag and enforce a role check in the Tauri command.
  • Bind tokio-console on loopback and reach it via SSH/tailscale port-forwarding; never expose it to the internet.
  • Emit JSON logs via tracing-subscriber and include span.current fields for ingestion (Loki/ELK).
  • Put a timeout and cancellation path on every await; log anything older than your SLO threshold.

Troubleshooting checklist

  • Tasks in “running” for >30s with no waker activity are likely blocking or stuck in a cycle.
  • If long tasks hold std::sync::Mutex or do filesystem work without spawn_blocking, move that work to spawn_blocking or use async IO.
  • If channels are bounded and full, add select! with a timeout and cancellation.
  • Reproduce under tokio-console with simulated load; if it reproduces, wire automated kill via diagnostics_kill_task.

With task dumps from tokio-console and precise tracing spans, you can stop guessing. You’ll see who’s waiting on whom, kill the right tasks safely, and keep the Tauri UI responsive without a restart.

Need an engineer who can build this?

I'm Yaseen Khatib — a Senior Full-Stack AI Engineer (MERN + TypeScript) who ships production AI systems solo. Open to senior and lead roles, remote or on-site.