diff --git a/omrf/src/lib.rs b/omrf/src/lib.rs index f1f29a6..d17db67 100644 --- a/omrf/src/lib.rs +++ b/omrf/src/lib.rs @@ -1,6 +1,7 @@ pub mod cffi; pub mod cstr_ffi; pub mod last_error; +pub mod library_lifecycle; pub mod object_pool; #[macro_export] diff --git a/omrf/src/library_lifecycle.rs b/omrf/src/library_lifecycle.rs new file mode 100644 index 0000000..a69c979 --- /dev/null +++ b/omrf/src/library_lifecycle.rs @@ -0,0 +1,150 @@ +//! Reference-counted startup/shutdown lifecycle for a DLL-style FFI library, in the spirit of +//! COM's `CoInitialize` / `CoUninitialize`. +//! +//! [`LibraryLifecycle`] holds an internal reference count together with a piece of state `S`: +//! +//! - on the `0 -> 1` transition ([`startup`](LibraryLifecycle::startup)) the supplied init closure +//! runs and its produced `S` is stored; +//! - on the `-> 0` transition ([`shutdown`](LibraryLifecycle::shutdown)) the stored `S` is taken +//! out and handed to the supplied destroy closure. +//! +//! Repeated calls are allowed as long as they are paired. The count and the state are guarded by a +//! [`RwLock`]; [`with_state`](LibraryLifecycle::with_state) takes a read lock (read access proceeds +//! concurrently) while `startup` / `shutdown` take a write lock. +//! +//! # Typical usage +//! +//! A single instance is usually stored in a `static LazyLock` and shared across all FFI entry +//! points. The state `S` typically aggregates the library's DLL-level resources -- for example a +//! number of [`ObjectPool`](crate::object_pool::ObjectPool)s and other globals that must be +//! constructed on first startup and torn down on last shutdown. +//! +//! # Call order +//! +//! Every `shutdown` must match a preceding successful `startup`. Calling `shutdown` when the count +//! is already zero is an error. +//! +//! # Closures +//! +//! Init and destroy closures are supplied **per call** (not at construction). Only the closure +//! passed to the transition call (`0 -> 1` for init, `-> 0` for destroy) is actually run; on any +//! other call the passed closure is dropped without being run, so callers should pass the same +//! init/destroy each time. +//! +//! The init closure may fail; its error is type-erased. The destroy closure must never fail +//! (destructor semantics) -- if its cleanup can fail it must be handled inside the closure. +//! +//! # Re-entrancy +//! +//! Closures run while the internal lock is held and **must not re-enter** the same +//! [`LibraryLifecycle`] (the lock is not re-entrant and would deadlock). + +use std::sync::RwLock; +use thiserror::Error as TeError; + +struct Inner { + count: usize, + state: Option, +} + +/// Reference-counted startup/shutdown lifecycle holding a piece of state `S`. +/// +/// See the module-level documentation for the overall contract, call order, and typical usage. +pub struct LibraryLifecycle { + inner: RwLock>, +} + +/// Error returned by [`LibraryLifecycle`] operations. +#[derive(Debug, TeError)] +pub enum Error { + /// The initialization closure failed; the original error is type-erased. + #[error("initialization failed: {0}")] + Init(#[source] Box), + /// `shutdown` was called when the reference count was already zero (unbalanced call). + #[error("shutdown called when reference count is already zero")] + Underflow, + /// The state was accessed while the lifecycle is not active (before `startup` or after the + /// matching `shutdown`). + #[error("state accessed while not active")] + NotActive, +} + +impl LibraryLifecycle { + /// Create a new lifecycle with reference count zero and no state. + pub fn new() -> Self { + Self { + inner: RwLock::new(Inner { + count: 0, + state: None, + }), + } + } + + /// Increment the reference count. + /// + /// On the `0 -> 1` transition the supplied `init` closure runs and its produced `S` is stored. + /// If `init` fails, neither the count nor the state is changed and an error is returned. + /// + /// On any other call (count already `> 0`) the supplied `init` is **dropped without being run** + /// and the count is simply incremented. + /// + /// The init closure runs under the write lock and must not re-enter this lifecycle. Its error + /// type `E` is type-erased. + pub fn startup(&self, init: impl FnOnce() -> Result) -> Result<(), Error> + where + E: std::error::Error + Send + Sync + 'static, + { + let mut inner = self.inner.write().expect("unexpected poison lock"); + if inner.count == 0 { + let s = init().map_err(|e| Error::Init(Box::new(e)))?; + inner.state = Some(s); + inner.count = 1; + } else { + inner.count += 1; + } + Ok(()) + } + + /// Decrement the reference count. + /// + /// On the `-> 0` transition the stored `S` is taken out and handed to the supplied `destroy` + /// closure, which must never fail (destructor semantics). On any other call (count stays `> 0`) + /// the supplied `destroy` is **dropped without being run** and the count is simply decremented. + /// + /// Returns an error if the count was already zero. + /// + /// The destroy closure runs under the write lock and must not re-enter this lifecycle. + pub fn shutdown(&self, destroy: impl FnOnce(S)) -> Result<(), Error> { + let mut inner = self.inner.write().expect("unexpected poison lock"); + if inner.count == 0 { + return Err(Error::Underflow); + } + inner.count -= 1; + if inner.count == 0 { + // Invariant: state is Some whenever count > 0. + let s = inner.state.take().expect("state present while count > 0"); + destroy(s); + } + Ok(()) + } + + /// Access the stored state by shared reference under a read lock. + /// + /// Multiple `with_state` calls can run concurrently. Returns an error if the lifecycle is not + /// currently active (count is zero). + /// + /// `S` is immutable from the outside; if mutation is needed, build it into `S` via interior + /// mutability (e.g. `Mutex`, atomics). The closure must not re-enter this lifecycle. + pub fn with_state(&self, f: impl FnOnce(&S) -> R) -> Result { + let inner = self.inner.read().expect("unexpected poison lock"); + match &inner.state { + Some(s) => Ok(f(s)), + None => Err(Error::NotActive), + } + } + + /// Current reference count. + pub fn count(&self) -> usize { + self.inner.read().expect("unexpected poison lock").count + } +} diff --git a/omrf/tests/library_lifecycle.rs b/omrf/tests/library_lifecycle.rs new file mode 100644 index 0000000..ea51df5 --- /dev/null +++ b/omrf/tests/library_lifecycle.rs @@ -0,0 +1,141 @@ +//! Integration tests for the `library_lifecycle` module. + +use sarasacw_omrf::library_lifecycle::LibraryLifecycle; +use std::sync::atomic::{AtomicUsize, Ordering}; +use thiserror::Error as TeError; + +#[derive(Debug, TeError)] +enum TestErr { + #[error("boom")] + Boom, +} + +#[derive(Debug)] +struct State { + value: usize, +} + +/// Coverage: a single paired startup/shutdown runs init once and destroy once, destroy receives the +/// state produced by init, and the count returns to zero. +#[test] +fn paired_startup_shutdown() { + let inits = AtomicUsize::new(0); + let destroys = AtomicUsize::new(0); + let lc: LibraryLifecycle = LibraryLifecycle::new(); + + lc.startup(|| -> Result { + inits.fetch_add(1, Ordering::SeqCst); + Ok(State { value: 42 }) + }) + .unwrap(); + + lc.shutdown(|s| { + destroys.fetch_add(1, Ordering::SeqCst); + assert_eq!(s.value, 42); + }) + .unwrap(); + + assert_eq!(inits.load(Ordering::SeqCst), 1); + assert_eq!(destroys.load(Ordering::SeqCst), 1); + assert_eq!(lc.count(), 0); +} + +/// Coverage: multiple startups followed by matching shutdowns run init and destroy only on the +/// boundary transitions (once each); the init/destroy closures passed on non-transition calls are +/// dropped without being run. +#[test] +fn multiple_startups_shutdowns_run_only_on_boundaries() { + let inits = AtomicUsize::new(0); + let destroys = AtomicUsize::new(0); + let lc: LibraryLifecycle = LibraryLifecycle::new(); + + for _ in 0..3 { + lc.startup(|| -> Result { + inits.fetch_add(1, Ordering::SeqCst); + Ok(State { value: 7 }) + }) + .unwrap(); + } + assert_eq!(lc.count(), 3); + assert_eq!(inits.load(Ordering::SeqCst), 1); + + for _ in 0..3 { + lc.shutdown(|_s| { + destroys.fetch_add(1, Ordering::SeqCst); + }) + .unwrap(); + } + + assert_eq!(inits.load(Ordering::SeqCst), 1); + assert_eq!(destroys.load(Ordering::SeqCst), 1); + assert_eq!(lc.count(), 0); +} + +/// Coverage: after a full cycle, a new cycle re-runs init and destroy (re-initialization works). +#[test] +fn reinitialization_runs_init_and_destroy_again() { + let inits = AtomicUsize::new(0); + let destroys = AtomicUsize::new(0); + let lc: LibraryLifecycle = LibraryLifecycle::new(); + + for _ in 0..2 { + lc.startup(|| -> Result { + inits.fetch_add(1, Ordering::SeqCst); + Ok(State { value: 1 }) + }) + .unwrap(); + lc.shutdown(|_s| { + destroys.fetch_add(1, Ordering::SeqCst); + }) + .unwrap(); + } + + assert_eq!(inits.load(Ordering::SeqCst), 2); + assert_eq!(destroys.load(Ordering::SeqCst), 2); + assert_eq!(lc.count(), 0); +} + +/// Coverage: `shutdown` with the count already at zero is an error (unbalanced call). +#[test] +fn shutdown_when_idle_is_error() { + let lc: LibraryLifecycle = LibraryLifecycle::new(); + assert!(lc.shutdown(|_| ()).is_err()); +} + +/// Coverage: `with_state` before any `startup` (and after the matching `shutdown`) is an error +/// because there is no active state. +#[test] +fn with_state_when_not_active_is_error() { + let lc: LibraryLifecycle = LibraryLifecycle::new(); + assert!(lc.with_state(|_s| ()).is_err()); + + lc.startup(|| -> Result { Ok(State { value: 5 }) }) + .unwrap(); + lc.shutdown(|_| ()).unwrap(); + assert!(lc.with_state(|_s| ()).is_err()); +} + +/// Coverage: a failing init closure leaves the count at zero and the state absent, and the error is +/// propagated (type-erased into `Error::Init`). +#[test] +fn init_failure_keeps_count_zero_and_propagates_error() { + let lc: LibraryLifecycle = LibraryLifecycle::new(); + let r = lc.startup(|| -> Result { Err(TestErr::Boom) }); + assert!(r.is_err()); + assert_eq!(lc.count(), 0); + // State is absent, so accessing it errors. + assert!(lc.with_state(|_s| ()).is_err()); +} + +/// Coverage: `with_state` reads the stored state during an active lifecycle. +#[test] +fn with_state_reads_active_state() { + let lc: LibraryLifecycle = LibraryLifecycle::new(); + lc.startup(|| -> Result { Ok(State { value: 99 }) }) + .unwrap(); + + let observed = lc.with_state(|s| s.value).unwrap(); + assert_eq!(observed, 99); + + lc.shutdown(|_| ()).unwrap(); +}