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
- 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.
- Fix the panic at its source (replace unwrap/expect with proper error propagation) — the join-error text itself is only a symptom.
- If it happens only at shutdown, ensure API calls complete before tearing down the runtime.
- 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
- Treat 'spawn_blocking join error' as a Rust panic: always attach the desktop app logs when reporting.
- Keep a reproducible argument set; panics are input- or state-dependent.
- Library authors: prefer Result propagation over unwrap/expect in code reachable through #[but_api] exports.
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
- handler task panicked: {e}
- broker already configured
- broker has not been configured
- BUG: {id} is queued
- BUG: It should not be possible to omit sources
AI-assisted analysis of gitbutlerapp/gitbutler@caf1f223d3 (2026-08-20).
Data as JSON: /api/errors/e95b43e076fb2d8b.
Report an issue: GitHub.