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.currentfields 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 tospawn_blockingor 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.