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
- Read the JoinError payload/cancel context and fix the panic in the wrapped Rust function (the join error itself is only a symptom).
- Reproduce with the same arguments on the Rust side (unit test) to get the real panic message and backtrace.
- Check for process shutdown/cancellation races if the error is JoinError::is_cancelled.
- 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
- Treat this as a bug in the native layer, not bad input; capture args when reporting.
- Avoid calling the API during process shutdown where tasks can be cancelled.
- Keep the native crate updated; panics behind this wrapper are usually fixed upstream.
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
- {e:#}
- GenericFailure
- a committed transaction always materializes a workspace
- anchor is always present in the order at this point
- Archive progress counter thread panicked
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)