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

  1. Read the panic message inside the returned error text — it names the exact unwrap/expect and line that panicked
  2. Reproduce the handler directly (unit test with the same JSON params) to get a full backtrace
  3. Replace the panicking unwrap/expect with `ok_or_else(anyhow!)`/`?` so the failure becomes a normal error
  4. 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

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


AI-assisted analysis of gitbutlerapp/gitbutler@caf1f223d3 (2026-08-20). Data as JSON: /api/errors/d47c036ef285fbdd. Report an issue: GitHub.