gitbutlerapp/gitbutler · error · napi::Error

GenericFailure

GenericFailure

Error message

spawn_blocking join error: {e}

What it means

Generated by the but-api-macros N-API wrapper: the Rust function body runs inside tokio's spawn_blocking, and the JoinError returned when awaiting it is converted into a napi GenericFailure with 'spawn_blocking join error'. A JoinError practically always means the closure panicked (or the runtime shut it down), so this message indicates a Rust-side panic crossing the JS boundary, not a normal API error.

Source

Thrown at crates/but-api-macros/src/lib.rs:303

        quote! {
            ::tokio::task::spawn_blocking(move || {
                #(#napi_param_conversions);*
                let __napi_body_result: ::anyhow::Result<::serde_json::Value> = (|| {
                    let result = #napi_call_fn_args?;
                    #convert_to_json_result_type
                    Ok(::serde_json::to_value(result)?)
                })();
                __napi_body_result.map_err(|e: ::anyhow::Error| {
                    let ctx = but_error::AnyhowContextExt::custom_context_or_error_chain(&e);
                    let message = ctx
                        .message
                        .map(|m| m.to_string())
                        .unwrap_or_else(|| format!("{e:#}"));
                    napi::Error::new(napi::Status::GenericFailure, message)
                })
            })
            .await
            .map_err(|e| napi::Error::new(napi::Status::GenericFailure, format!("spawn_blocking join error: {e}")))?
        }
    };

    // For async functions, param conversions happen outside the body (in the async fn).
    // For sync functions, they're already inside spawn_blocking in napi_body.
    let napi_external_conversions = if asyncness.is_some() {
        quote! { #(#napi_param_conversions);* }
    } else {
        quote! {}
    };

    let js_name = fn_name
        .to_string()
        .split("_")
        .enumerate()
        .map(|(idx, word)| {
            if idx == 0 {
                word.into()

View on GitHub (pinned to caf1f223d3)

Solutions

  1. Reproduce with the same arguments and inspect the Rust backtrace (RUST_BACKTRACE=1 in the desktop app logs) to find the actual panic site inside the function body.
  2. Fix the panic at its source (replace unwrap/expect with proper error propagation) — the join-error text itself is only a symptom.
  3. If it happens only at shutdown, ensure API calls complete before tearing down the runtime.
  4. If the panic is inside a GitButler crate you do not own, report it with the backtrace and the repository state that triggered it.
Defensive patterns

Strategy: try-catch

Try / catch

try {
  await api.someFn(args);
} catch (e) {
  if (String(e?.message).includes('spawn_blocking join error')) {
    // Rust-side panic: capture args + app logs and report; not retryable as-is
    reportPanic('api.someFn', args, e);
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling any #[but_api]-exported function from JS whose Rust implementation panics (unwrap on None, index out of bounds, assertion failure); shutting down the tokio runtime while a blocking API call is still in flight.

Common situations: A panic path in library code reachable only for unusual repository states (missing ref, corrupt index); a regression introduced by a refactoring that added an unwrap; calling the desktop app's N-API layer during app teardown.

Related errors


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