diff --git a/CHANGELOG.md b/CHANGELOG.md index da22679..307a96b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/nix-bindings-expr/build.rs b/nix-bindings-expr/build.rs index 6a038bd..f56bc3e 100644 --- a/nix-bindings-expr/build.rs +++ b/nix-bindings-expr/build.rs @@ -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"]); } diff --git a/nix-bindings-expr/src/eval_state.rs b/nix-bindings-expr/src/eval_state.rs index b8e27e6..5d8599f 100644 --- a/nix-bindings-expr/src/eval_state.rs +++ b/nix-bindings-expr/src/eval_state.rs @@ -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(); + } } diff --git a/nix-bindings-expr/src/primop.rs b/nix-bindings-expr/src/primop.rs index 02410fc..1504405 100644 --- a/nix-bindings-expr/src/primop.rs +++ b/nix-bindings-expr/src/primop.rs @@ -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) -> 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( eval_state: &mut EvalState, meta: PrimOpMeta, @@ -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("") .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::().is_some() { + return raw_util::err_NIX_ERR_RECOVERABLE; + } + raw_util::err_NIX_ERR_UNKNOWN +} + static FUNCTION_ADAPTER: raw::PrimOpFun = Some(function_adapter);