gitbutlerapp/gitbutler · critical
handler task panicked: {e}
Error message
handler task panicked: {e} What it means
`but-server` runs every synchronous command handler inside `tokio::task::spawn_blocking`; if the handler panics, `JoinError` is captured and converted into this anyhow error, which becomes the JSON-RPC/HTTP error response instead of crashing the axum process. The panic message and backtrace are embedded in `{e}`. So this error always means: a command handler hit a Rust panic (unwrap on None, index out of bounds, poisoned lock, assertion).
Source
Thrown at crates/but-server/src/lib.rs:70
#[derive(Clone)]
struct AppState {
broadcaster: Arc<Mutex<Broadcaster>>,
extra: Extra,
app_settings: AppSettingsWithDiskSync,
}
/// Converts a synchronous command handler into an axum `MethodRouter` that works with
/// `Router::route`.
fn but_post<F, S>(f: F) -> MethodRouter<S, Infallible>
where
F: Fn(serde_json::Value) -> anyhow::Result<serde_json::Value> + Copy + Send + Sync + 'static,
S: Clone + Send + Sync + 'static,
{
post(move |Json(params)| async move {
let res = tokio::task::spawn_blocking(move || f(params))
.await
.unwrap_or_else(|e| Err(anyhow::anyhow!("handler task panicked: {e}")));
cmd_result_to_json(res)
})
}
/// Converts an asynchronous command handler into an axum `MethodRouter` that works with
/// `Router::route`.
fn but_post_async<F, Fut, S>(f: F) -> MethodRouter<S, Infallible>
where
F: Fn(serde_json::Value) -> Fut + Copy + Send + Sync + 'static,
Fut: Future<Output = anyhow::Result<serde_json::Value>> + Send,
S: Clone + Send + Sync + 'static,
{
post(move |Json(params)| async move {
let res = f(params).await;
cmd_result_to_json(res)
})
}
View on GitHub (pinned to caf1f223d3)
Solutions
- Read the panic message inside the returned error text — it names the exact unwrap/expect and line that panicked
- Reproduce the handler directly (unit test with the same JSON params) to get a full backtrace
- Replace the panicking unwrap/expect with `ok_or_else(anyhow!)`/`?` so the failure becomes a normal error
- If the panic came from a poisoned lock, find and fix the first panic; consider not sharing state across Mutex without recovery
Example fix
// before (handler registered via but_post)
fn get_project(params: serde_json::Value) -> anyhow::Result<serde_json::Value> {
let id = params["id"].as_str().unwrap(); // panics on missing/null id -> 'handler task panicked'
...
}
// after
fn get_project(params: serde_json::Value) -> anyhow::Result<serde_json::Value> {
let id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing string field 'id'"))?;
...
} Defensive patterns
Strategy: try-catch
Try / catch
// Server side: the spawn_blocking wrapper already catches JoinError; keep it that way and
// add structured logging so panics are never silent:
let res = tokio::task::spawn_blocking(move || f(params))
.await
.unwrap_or_else(|e| {
tracing::error!("handler panicked: {e}");
Err(anyhow::anyhow!("handler task panicked: {e}"))
});
// Client side: treat this error as a server bug — report the request payload; do not retry. Prevention
- Forbid unwrap/expect in command handlers; review diffs for them at PR time (clippy lints like unwrap_used help)
- Return Result from handlers and validate all JSON params explicitly
- Add unit tests per handler for missing/null fields and empty-state inputs
- Watch for lock poisoning: one panic can convert later requests into the same error
When it happens
Trigger: A handler calling `.unwrap()` on an Option/Result that is None/Err for particular inputs (missing project, empty repo, unborn branch); a lock poisoned by an earlier panic; slicing/indexing bugs; `expect` on invariant violations — all invoked through an axum `but_post` route.
Common situations: Server-side command works in the happy path but panics on edge-case payloads; first panic poisons a Mutex so subsequent requests on the same state also panic; regressions introduced by refactors leaving a None case unhandled.
Related errors
- GenericFailure
- failed to create app settings
- broker already configured
- broker has not been configured
- BUG: {id} is queued
AI-assisted analysis of gitbutlerapp/gitbutler@caf1f223d3 (2026-08-20).
Data as JSON: /api/errors/d47c036ef285fbdd.
Report an issue: GitHub.