sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 1ce1da677c6992ce87129a59cd6db2a9a720e401
parent 4f13a8cf263fd45dd2794270a40058b752f3a27c
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 10:29:56 +0000

sdk: define explicit client lifecycle and shutdown

- share lifecycle state across every client clone
- require explicit asynchronous storage shutdown
- expose deterministic closing and closed errors
- verify cancellation and concurrent close behavior

Diffstat:
Mcrates/sdk/src/client.rs | 212++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/sdk/src/error.rs | 14++++++++++++++
Mcrates/sdk/tests/package_boundary.rs | 19+++++++++++++++++++
3 files changed, 221 insertions(+), 24 deletions(-)

diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs @@ -1,6 +1,9 @@ //! Client construction and lifecycle. -use std::sync::Arc; +use std::sync::{ + Arc, + atomic::{AtomicU8, Ordering}, +}; use radroots_signing::Signer; use radroots_storage::Storage; @@ -34,8 +37,14 @@ struct ClientInner { sink: Option<Arc<dyn EventSink>>, #[cfg(feature = "sync")] sync: Option<radroots_sync::Engine>, + lifecycle: AtomicU8, } +const OPEN: u8 = 0; +const CLOSING: u8 = 1; +const CLOSE_RETRY_REQUIRED: u8 = 2; +const CLOSED: u8 = 3; + impl ClientBuilder { /// Creates an empty builder with no hidden storage, network, signing, or /// runtime side effects. @@ -101,6 +110,7 @@ impl ClientBuilder { sink: self.sink, #[cfg(feature = "sync")] sync: self.sync, + lifecycle: AtomicU8::new(OPEN), }), }) } @@ -108,34 +118,111 @@ impl ClientBuilder { impl Client { /// Returns the injected canonical storage capability. - #[must_use] - pub fn storage(&self) -> &dyn Storage { - self.inner.storage.as_ref() + pub fn storage(&self) -> Result<&dyn Storage> { + self.require_open()?; + Ok(self.inner.storage.as_ref()) } /// Returns the injected signer, when outbound authoring is enabled. - #[must_use] - pub fn signer(&self) -> Option<&dyn Signer> { - self.inner.signer.as_deref() + pub fn signer(&self) -> Result<Option<&dyn Signer>> { + self.require_open()?; + Ok(self.inner.signer.as_deref()) } /// Returns the injected inbound source, when pull is enabled. - #[must_use] - pub fn source(&self) -> Option<&dyn EventSource> { - self.inner.source.as_deref() + pub fn source(&self) -> Result<Option<&dyn EventSource>> { + self.require_open()?; + Ok(self.inner.source.as_deref()) } /// Returns the injected outbound sink, when delivery is enabled. - #[must_use] - pub fn sink(&self) -> Option<&dyn EventSink> { - self.inner.sink.as_deref() + pub fn sink(&self) -> Result<Option<&dyn EventSink>> { + self.require_open()?; + Ok(self.inner.sink.as_deref()) } /// Returns the explicit synchronization engine, when configured. #[cfg(feature = "sync")] + pub fn sync_engine(&self) -> Result<Option<&radroots_sync::Engine>> { + self.require_open()?; + Ok(self.inner.sync.as_ref()) + } + + /// Returns whether explicit close completed successfully or reached the + /// lower storage commit point. #[must_use] - pub fn sync_engine(&self) -> Option<&radroots_sync::Engine> { - self.inner.sync.as_ref() + pub fn is_closed(&self) -> bool { + self.inner.lifecycle.load(Ordering::Acquire) == CLOSED + } + + /// Explicitly closes active storage resources across every client clone. + /// + /// Dropping this future before its first poll has no effect. Cancellation + /// after close begins leaves the client unavailable and permits an + /// explicit retry; it never reports rollback. Repeated completed close is + /// idempotent. No worker, executor, or blocking `Drop` path is installed. + pub async fn close(&self) -> Result<()> { + loop { + match self.inner.lifecycle.load(Ordering::Acquire) { + CLOSED => return Ok(()), + CLOSING => return Err(Error::CloseInProgress), + state @ (OPEN | CLOSE_RETRY_REQUIRED) => { + if self + .inner + .lifecycle + .compare_exchange(state, CLOSING, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + break; + } + } + _ => return Err(Error::ClientClosed), + } + } + + let attempt = CloseAttempt::new(Arc::clone(&self.inner)); + let close_result = radroots_storage::BackupSource::close(self.inner.storage.as_ref()).await; + attempt.complete(); + close_result + .map(|_| ()) + .map_err(|_| Error::StorageCloseFailed) + } + + fn require_open(&self) -> Result<()> { + match self.inner.lifecycle.load(Ordering::Acquire) { + OPEN => Ok(()), + CLOSING | CLOSE_RETRY_REQUIRED => Err(Error::ClientClosing), + _ => Err(Error::ClientClosed), + } + } +} + +struct CloseAttempt { + inner: Arc<ClientInner>, + completed: bool, +} + +impl CloseAttempt { + fn new(inner: Arc<ClientInner>) -> Self { + Self { + inner, + completed: false, + } + } + + fn complete(mut self) { + self.inner.lifecycle.store(CLOSED, Ordering::Release); + self.completed = true; + } +} + +impl Drop for CloseAttempt { + fn drop(&mut self) { + if !self.completed { + self.inner + .lifecycle + .store(CLOSE_RETRY_REQUIRED, Ordering::Release); + } } } @@ -146,6 +233,7 @@ impl std::fmt::Debug for Client { .field("signer", &self.inner.signer.is_some()) .field("source", &self.inner.source.is_some()) .field("sink", &self.inner.sink.is_some()) + .field("closed", &self.is_closed()) .finish_non_exhaustive() } } @@ -173,6 +261,11 @@ mod tests { DeliveryReceipt, DeliveryRequest, Error as TransportError, FetchPage, FetchRequest, SinkStatus, SourceStatus, source::BoxFuture as TransportFuture, }; + use std::{ + future::Future, + sync::Arc, + task::{Context, Poll, Wake, Waker}, + }; struct TestSource; struct TestSink; @@ -238,23 +331,23 @@ mod tests { #[test] fn memory_local_source_only_and_sink_only_compositions_are_explicit() { let local = ClientBuilder::memory(generation()).build().expect("local"); - assert!(local.source().is_none()); - assert!(local.sink().is_none()); - assert!(local.signer().is_none()); + assert!(local.source().expect("source capability").is_none()); + assert!(local.sink().expect("sink capability").is_none()); + assert!(local.signer().expect("signer capability").is_none()); let source = ClientBuilder::memory(generation()) .source(Arc::new(TestSource)) .build() .expect("source-only"); - assert!(source.source().is_some()); - assert!(source.sink().is_none()); + assert!(source.source().expect("source capability").is_some()); + assert!(source.sink().expect("sink capability").is_none()); let sink = ClientBuilder::memory(generation()) .sink(Arc::new(TestSink)) .build() .expect("sink-only"); - assert!(sink.source().is_none()); - assert!(sink.sink().is_some()); + assert!(sink.source().expect("source capability").is_none()); + assert!(sink.sink().expect("sink capability").is_some()); } #[test] @@ -264,10 +357,81 @@ mod tests { .signer(Arc::new(TestSigner)) .build() .expect("outbound client"); - assert!(client.signer().is_some()); + assert!(client.signer().expect("signer capability").is_some()); assert_eq!( format!("{client:?}"), - "Client { signer: true, source: false, sink: true, .. }" + "Client { signer: true, source: false, sink: true, closed: false, .. }" ); } + + #[test] + fn close_is_clone_shared_idempotent_and_rejects_later_capability_access() { + let client = ClientBuilder::memory(generation()).build().expect("client"); + let clone = client.clone(); + assert!(!client.is_closed()); + block_on(client.close()).expect("first close"); + assert!(clone.is_closed()); + block_on(clone.close()).expect("repeated close"); + assert!(matches!(clone.storage(), Err(Error::ClientClosed))); + assert!(matches!(client.source(), Err(Error::ClientClosed))); + } + + #[test] + fn close_cancellation_boundaries_are_explicit_and_retryable() { + let client = ClientBuilder::memory(generation()).build().expect("client"); + let unpolled = client.close(); + drop(unpolled); + assert!(client.storage().is_ok()); + + client.inner.lifecycle.store(CLOSING, Ordering::Release); + let attempt = CloseAttempt::new(Arc::clone(&client.inner)); + drop(attempt); + assert!(matches!(client.storage(), Err(Error::ClientClosing))); + block_on(client.close()).expect("retry close"); + assert!(client.is_closed()); + } + + #[test] + fn concurrent_clones_converge_on_one_closed_state() { + let client = ClientBuilder::memory(generation()).build().expect("client"); + let first = client.clone(); + let second = client.clone(); + let outcomes = std::thread::scope(|scope| { + let first_close = scope.spawn(move || block_on(first.close())); + let second_close = scope.spawn(move || block_on(second.close())); + [ + first_close.join().expect("first thread"), + second_close.join().expect("second thread"), + ] + }); + assert!( + outcomes.iter().all(|outcome| { + outcome.is_ok() || matches!(outcome, Err(Error::CloseInProgress)) + }) + ); + if !client.is_closed() { + block_on(client.close()).expect("finish close"); + } + assert!(client.is_closed()); + } + + struct ThreadWaker; + + impl Wake for ThreadWaker { + fn wake(self: Arc<Self>) { + std::thread::current().unpark(); + } + } + + fn block_on<F: Future>(future: F) -> F::Output { + let waker = Waker::from(Arc::new(ThreadWaker)); + let mut context = Context::from_waker(&waker); + let mut future = Box::pin(future); + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(output) => return output, + Poll::Pending => std::thread::park(), + } + } + } } diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -9,6 +9,14 @@ pub enum Error { MissingStorage, /// A signer cannot be selected without an outbound event sink. SignerWithoutSink, + /// Another clone is actively closing shared resources. + CloseInProgress, + /// Close was cancelled after beginning and requires an explicit retry. + ClientClosing, + /// The client has completed explicit shutdown. + ClientClosed, + /// The storage close operation failed after shutdown began. + StorageCloseFailed, } impl fmt::Display for Error { @@ -18,6 +26,12 @@ impl fmt::Display for Error { Self::SignerWithoutSink => { formatter.write_str("SDK signer requires an outbound event sink") } + Self::CloseInProgress => formatter.write_str("SDK client close is in progress"), + Self::ClientClosing => { + formatter.write_str("SDK client close requires completion or retry") + } + Self::ClientClosed => formatter.write_str("SDK client is closed"), + Self::StorageCloseFailed => formatter.write_str("SDK storage close failed"), } } } diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs @@ -2,6 +2,7 @@ use std::collections::BTreeSet; const MANIFEST: &str = include_str!("../Cargo.toml"); const ROOT: &str = include_str!("../src/lib.rs"); +const CLIENT: &str = include_str!("../src/client.rs"); #[test] fn manifest_has_final_identity_and_dependency_boundary() { @@ -89,6 +90,24 @@ fn nostr_adapter_implements_independent_source_and_sink_capabilities() { assert_sink::<radroots_transport_nostr::NostrTransport>(); } +#[test] +fn lifecycle_source_owns_no_hidden_worker_runtime_or_blocking_drop() { + for forbidden in [ + "tokio::spawn", + "std::thread::spawn", + "Runtime::new", + "block_on(self.close", + "impl Drop for Client", + ] { + assert!( + !CLIENT.contains(forbidden), + "forbidden lifecycle source `{forbidden}`" + ); + } + assert!(CLIENT.contains("pub async fn close(&self)")); + assert!(CLIENT.contains("impl Drop for CloseAttempt")); +} + fn dependency_names(manifest: &str) -> BTreeSet<&str> { let dependencies = manifest .split_once("[dependencies]")