feat(nix-bindings-expr): add RecoverableError for non-memoized primop errors

Nix 2.34 memoizes primop errors by default. RecoverableError uses
NIX_ERR_RECOVERABLE so transient errors allow the thunk to be retried.
This commit is contained in:
Robert Hensing 2026-03-02 23:53:53 +01:00
parent cf0e8fff6a
commit fbf5fab083
4 changed files with 126 additions and 2 deletions

View file

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- `primop::RecoverableError` for primop errors that should not be memoized in the thunk, allowing retry on next force. Required by Nix >= 2.34 ([release note](https://nix.dev/manual/nix/2.34/release-notes/rl-2.34.html#c-api-changes)) for recoverable errors to remain recoverable, as Nix 2.34 memoizes errors by default.
## [0.2.0] - 2026-01-13
### Added

View file

@ -2,5 +2,5 @@ use nix_bindings_util::nix_version::emit_version_cfg;
fn main() {
let nix_version = pkg_config::probe_library("nix-expr-c").unwrap().version;
emit_version_cfg(&nix_version, &["2.26"]);
emit_version_cfg(&nix_version, &["2.26", "2.34.0pre"]);
}

View file

@ -2845,4 +2845,81 @@ mod tests {
})
.unwrap();
}
#[test]
#[cfg(nix_at_least = "2.34.0pre")]
fn eval_state_primop_recoverable_error() {
gc_registering_current_thread(|| {
let store = Store::open(None, []).unwrap();
let mut es = EvalState::new(store, []).unwrap();
let call_count = std::cell::Cell::new(0u32);
let v = es
.new_value_thunk(
"recoverable_test",
Box::new(move |es: &mut EvalState| {
let count = call_count.get();
call_count.set(count + 1);
if count == 0 {
Err(primop::RecoverableError::new("transient failure").into())
} else {
es.new_value_int(42)
}
}),
)
.unwrap();
// First force should fail with the recoverable error
let r = es.force(&v);
assert!(r.is_err());
assert!(
r.unwrap_err().to_string().contains("transient failure"),
"Error message should contain 'transient failure'"
);
// Second force should succeed because the error was recoverable
es.force(&v).unwrap();
let i = es.require_int(&v).unwrap();
assert_eq!(i, 42);
})
.unwrap();
}
#[test]
#[cfg(nix_at_least = "2.34.0pre")]
fn eval_state_primop_recoverable_error_in_chain() {
gc_registering_current_thread(|| {
let store = Store::open(None, []).unwrap();
let mut es = EvalState::new(store, []).unwrap();
let call_count = std::cell::Cell::new(0u32);
let v = es
.new_value_thunk(
"recoverable_chain_test",
Box::new(move |es: &mut EvalState| {
let count = call_count.get();
call_count.set(count + 1);
if count == 0 {
// Wrap RecoverableError in .context(), pushing it down the chain
use anyhow::Context;
Err(primop::RecoverableError::new("transient failure"))
.context("wrapper context")?
} else {
es.new_value_int(42)
}
}),
)
.unwrap();
// First force should fail
let r = es.force(&v);
assert!(r.is_err());
// Second force should succeed if RecoverableError is found in the chain
es.force(&v).unwrap();
let i = es.require_int(&v).unwrap();
assert_eq!(i, 42);
})
.unwrap();
}
}

View file

@ -8,6 +8,35 @@ use std::ffi::{c_int, c_void, CStr, CString};
use std::mem::ManuallyDrop;
use std::ptr::{null, null_mut};
/// A primop error that is not memoized in the thunk that triggered it,
/// allowing the thunk to be forced again.
///
/// Since [Nix 2.34](https://nix.dev/manual/nix/2.34/release-notes/rl-2.34.html#c-api-changes),
/// primop errors are memoized by default: once a thunk fails, forcing it
/// again returns the same error. Use `RecoverableError` for errors that
/// are transient, so the caller can retry.
///
/// On Nix < 2.34, all errors are already recoverable, so this type has
/// no additional effect.
///
/// Available since nix-bindings-expr 0.2.1.
#[derive(Debug)]
pub struct RecoverableError(String);
impl RecoverableError {
pub fn new(msg: impl Into<String>) -> Self {
RecoverableError(msg.into())
}
}
impl std::fmt::Display for RecoverableError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::error::Error for RecoverableError {}
/// Metadata for a primop, used with `PrimOp::new`.
pub struct PrimOpMeta<'a, const N: usize> {
/// Name of the primop. Note that primops do not have to be registered as
@ -36,6 +65,11 @@ impl Drop for PrimOp {
}
}
impl PrimOp {
/// Create a new primop with the given metadata and implementation.
///
/// When `f` returns an `Err`, the error is propagated to the Nix evaluator.
/// To return a [recoverable error](RecoverableError), include it in the
/// error chain (e.g. `Err(RecoverableError::new("...").into())`).
pub fn new<const N: usize>(
eval_state: &mut EvalState,
meta: PrimOpMeta<N>,
@ -108,13 +142,22 @@ unsafe extern "C" fn function_adapter(
raw::copy_value(context_out, ret, v.raw_ptr());
},
Err(e) => unsafe {
let err_code = error_code(&e);
let cstr = CString::new(e.to_string()).unwrap_or_else(|_e| {
CString::new("<rust nix-expr application error message contained null byte>")
.unwrap()
});
raw_util::set_err_msg(context_out, raw_util::err_NIX_ERR_UNKNOWN, cstr.as_ptr());
raw_util::set_err_msg(context_out, err_code, cstr.as_ptr());
},
}
}
fn error_code(e: &anyhow::Error) -> raw_util::err {
#[cfg(nix_at_least = "2.34.0pre")]
if e.downcast_ref::<RecoverableError>().is_some() {
return raw_util::err_NIX_ERR_RECOVERABLE;
}
raw_util::err_NIX_ERR_UNKNOWN
}
static FUNCTION_ADAPTER: raw::PrimOpFun = Some(function_adapter);