gitbutlerapp/gitbutler · error · napi::Error

spawn_blocking join error

Error message

spawn_blocking join error: {e}

What it means

Generated inside the `but_api` proc-macro's N-API wrapper: the result of `tokio::task::spawn_blocking(...)` is awaited and a `JoinError` (task panicked or was cancelled) is mapped to a napi Error with the literal prefix "spawn_blocking join error". This signals the underlying sync API function panicked or the blocking task was aborted, not a normal application error.

Solutions

  1. Read the JoinError payload/cancel context and fix the panic in the wrapped Rust function (the join error itself is only a symptom).
  2. Reproduce with the same arguments on the Rust side (unit test) to get the real panic message and backtrace.
  3. Check for process shutdown/cancellation races if the error is JoinError::is_cancelled.
  4. Replace unwrap/expect calls in the API implementation with proper anyhow error propagation.

Example fix

// before
let len = ids.len() - 1; // panics when empty
// after
let len = ids.len().checked_sub(1).context("empty id list")?;
Defensive patterns

Strategy: try-catch

Try / catch

try {
  await api.someOperation(args);
} catch (e) {
  if (String(e?.message).startsWith('spawn_blocking join error')) {
    // underlying Rust task panicked or was cancelled; log args and report a bug,
    // this is not a recoverable domain error
    console.error('native task crashed', args, e);
  } else { throw e; }
}

Prevention

When it happens

Trigger: Calling any async-wrapped `but_api` N-API function whose spawn_blocking child task panics (unwraps a None/Err, index out of bounds, explicit panic) or is cancelled before completion.

Common situations: A bug/regression inside a library function called from JS; the Node process shutting down and cancelling in-flight tasks; unwrap on poisoned locks; stack overflow or assertion failure in the blocking closure.

Related errors


AI-assisted analysis of gitbutlerapp/gitbutler@58e5313667 (2026-09-18). Data as JSON: /api/errors/f32d706f62cb877b. Report an issue: GitHub.

Appendix: source

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

        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 58e5313667)