lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 035717adf65596f2ec93b59462f5db241232ba5b
parent 6538de41d7992251e06205f1450f6348b7a55cc1
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 16:05:40 +0000

app-rt: migrate uniffi contract to final sdk

- Expose SDK storage and shutdown as native async mobile calls
- Carry versioned secret-safe SDK error and lifecycle records
- Remove the retired synchronous executor and generated error types
- Prove deterministic Swift and Kotlin generation with strict gates

Diffstat:
Mcrates/mobile_core/Cargo.toml | 5++---
Mcrates/mobile_core/src/error.rs | 107++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcrates/mobile_core/src/lib.rs | 7++++++-
Mcrates/mobile_core/src/runtime/info.rs | 2--
Mcrates/mobile_core/src/runtime/mod.rs | 68+++++++++++++++++++-------------------------------------------------
Mcrates/mobile_core/src/runtime/sdk.rs | 54+++++++++++++++++++++++-------------------------------
Mcrates/mobile_core/tests/sdk_runtime.rs | 34++++++++++++++++++++++++----------
Acrates/mobile_core/tests/uniffi_contract.rs | 33+++++++++++++++++++++++++++++++++
8 files changed, 191 insertions(+), 119 deletions(-)

diff --git a/crates/mobile_core/Cargo.toml b/crates/mobile_core/Cargo.toml @@ -17,8 +17,7 @@ crate-type = ["rlib"] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [features] -default = ["rt"] -rt = [] +default = [] [dependencies] radroots_log = { workspace = true } @@ -29,7 +28,7 @@ serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } uniffi = { workspace = true } -tokio = { workspace = true } [dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } tracing-subscriber = { workspace = true } diff --git a/crates/mobile_core/src/error.rs b/crates/mobile_core/src/error.rs @@ -1,15 +1,24 @@ use thiserror::Error; +/// Versioned, secret-safe SDK failure exposed to mobile hosts. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct SdkErrorRecord { + pub schema_version: u16, + pub code: String, + pub class: String, + pub retryable: bool, + pub recovery_actions: Vec<String>, + pub operation_id: Option<String>, + pub capability_id: Option<String>, + pub message: String, +} + #[derive(Debug, Error, uniffi::Error)] pub enum RadrootsAppError { #[error("initialization: {0}")] Initialization(String), - #[error("identity: {0}")] - Identity(String), - #[error("secure store: {0}")] - SecureStore(String), - #[error("relay: {0}")] - Relay(String), + #[error("sdk: {report:?}")] + Sdk { report: SdkErrorRecord }, #[error("runtime: {0}")] Runtime(String), #[error("unsupported: {0}")] @@ -21,29 +30,28 @@ pub enum RadrootsAppError { impl RadrootsAppError { pub(crate) fn from_sdk(error: radroots_sdk::Error) -> Self { let report = error.to_report(); - Self::Runtime(format!( - "{}: {}", - report.code().as_str(), - report.message().as_str() - )) + Self::Sdk { + report: SdkErrorRecord { + schema_version: report.schema_version(), + code: report.code().as_str().to_owned(), + class: debug_label(report.class()), + retryable: report.retryable(), + recovery_actions: report + .recovery_actions() + .iter() + .map(|action| debug_label(*action)) + .collect(), + operation_id: report.operation_id().map(|id| id.as_str().to_owned()), + capability_id: report.capability_id().map(|id| id.as_str().to_owned()), + message: report.message().as_str().to_owned(), + }, + } } pub fn initialization(message: impl Into<String>) -> Self { Self::Initialization(message.into()) } - pub fn identity(message: impl Into<String>) -> Self { - Self::Identity(message.into()) - } - - pub fn secure_store(message: impl Into<String>) -> Self { - Self::SecureStore(message.into()) - } - - pub fn relay(message: impl Into<String>) -> Self { - Self::Relay(message.into()) - } - pub fn runtime(message: impl Into<String>) -> Self { Self::Runtime(message.into()) } @@ -56,3 +64,56 @@ impl RadrootsAppError { Self::Internal(message.into()) } } + +fn debug_label(value: impl std::fmt::Debug) -> String { + let mut label = String::new(); + for (index, character) in format!("{value:?}").chars().enumerate() { + if character.is_ascii_uppercase() && index != 0 { + label.push('_'); + } + label.push(character.to_ascii_lowercase()); + } + label +} + +#[cfg(test)] +mod tests { + use super::{RadrootsAppError, SdkErrorRecord, debug_label}; + + #[test] + fn sdk_error_records_are_versioned_stable_and_secret_safe() { + let error = radroots_sdk::ClientBuilder::new() + .build() + .expect_err("storage is required"); + let RadrootsAppError::Sdk { report } = RadrootsAppError::from_sdk(error) else { + panic!("expected SDK report"); + }; + assert_eq!( + report, + SdkErrorRecord { + schema_version: 1, + code: "missing_storage".to_owned(), + class: "capability".to_owned(), + retryable: false, + recovery_actions: vec!["configure_storage".to_owned()], + operation_id: None, + capability_id: Some("storage.canonical".to_owned()), + message: "SDK storage capability is not configured".to_owned(), + } + ); + assert!(!format!("{report:?}").contains("source")); + } + + #[test] + fn debug_labels_use_stable_mobile_case() { + assert_eq!( + debug_label(SampleLabel::RetryAfterClose), + "retry_after_close" + ); + } + + #[derive(Debug)] + enum SampleLabel { + RetryAfterClose, + } +} diff --git a/crates/mobile_core/src/lib.rs b/crates/mobile_core/src/lib.rs @@ -1,8 +1,13 @@ +// UniFFI errors are serialized value contracts. Keeping the complete stable +// SDK report on the error is more important than optimizing the Rust enum's +// in-process size; mobile calls cross this boundary by value in all cases. +#![allow(clippy::result_large_err)] + uniffi::setup_scaffolding!("radroots"); pub mod error; pub mod logging; pub mod runtime; -pub use error::RadrootsAppError; +pub use error::{RadrootsAppError, SdkErrorRecord}; pub use runtime::RadrootsRuntime; diff --git a/crates/mobile_core/src/runtime/info.rs b/crates/mobile_core/src/runtime/info.rs @@ -73,7 +73,5 @@ mod tests { assert_eq!(info.sdk.crate_name, "radroots_sdk"); assert_eq!(info.sdk.crate_version, "0.1.0-alpha"); assert!(!info.sdk_closed); - runtime.stop(); - assert!(runtime.info().sdk_closed); } } diff --git a/crates/mobile_core/src/runtime/mod.rs b/crates/mobile_core/src/runtime/mod.rs @@ -7,7 +7,7 @@ pub mod sdk; use chrono::Utc; use radroots_sdk::{Client, ClientBuilder}; use std::sync::{ - Mutex, RwLock, + RwLock, atomic::{AtomicBool, Ordering}, }; @@ -20,8 +20,6 @@ use crate::RadrootsAppError; #[derive(uniffi::Object)] pub struct RadrootsRuntime { pub(crate) client: Client, - #[cfg(feature = "rt")] - executor: Mutex<Option<tokio::runtime::Runtime>>, pub(crate) started_unix_ms: i64, pub(crate) shutting_down: AtomicBool, pub(crate) platform_app: RwLock<Option<AppInfoPlatform>>, @@ -34,55 +32,32 @@ impl RadrootsRuntime { let client = ClientBuilder::memory_default() .build() .map_err(RadrootsAppError::from_sdk)?; - #[cfg(feature = "rt")] - let executor = tokio::runtime::Builder::new_multi_thread() - .thread_name("radroots-app-sdk") - .enable_all() - .build() - .map_err(|error| RadrootsAppError::initialization(error.to_string()))?; Ok(Self { client, - #[cfg(feature = "rt")] - executor: Mutex::new(Some(executor)), started_unix_ms: Utc::now().timestamp_millis(), shutting_down: AtomicBool::new(false), platform_app: RwLock::new(None), }) } - pub fn stop(&self) { - if self.shutting_down.swap(true, Ordering::SeqCst) { - let _ = crate::logging::log_info( - "Runtime stop already in progress or completed.".to_owned(), - ); - return; - } - - #[cfg(feature = "rt")] - match self.executor.lock() { - Ok(mut executor) => { - if let Some(executor) = executor.take() { - if let Err(error) = executor.block_on(self.client.close()) { - let _ = crate::logging::log_error(format!( - "SDK runtime close failed safely: {error}" - )); - } - } - } - Err(_) => { - let _ = crate::logging::log_error( - "SDK executor lock was unavailable during shutdown.".to_owned(), - ); - } - } - - #[cfg(not(feature = "rt"))] - { - let _ = crate::logging::log_info( - "Host must complete asynchronous SDK close for this runtime build.".to_owned(), - ); - } + /// Closes SDK resources asynchronously across every runtime reference. + /// + /// Dropping the returned future before its first poll has no effect. If a + /// host cancels after close begins, it must call `shutdown` again; the SDK + /// remains unavailable and resumes the explicit close attempt. Completed + /// calls are idempotent and no blocking destructor is installed. + pub async fn shutdown(&self) -> Result<sdk::SdkShutdownRecord, RadrootsAppError> { + let already_closed = self.client.is_closed(); + self.shutting_down.store(true, Ordering::Release); + self.client + .close() + .await + .map_err(RadrootsAppError::from_sdk)?; + Ok(sdk::SdkShutdownRecord { + state: "closed".to_owned(), + already_closed, + }) } pub fn uptime_millis(&self) -> i64 { @@ -128,7 +103,7 @@ mod tests { } #[test] - fn runtime_owns_one_sdk_client_and_closes_idempotently() { + fn runtime_owns_one_sdk_client() { let runtime = RadrootsRuntime::new().expect("runtime"); let storage = runtime .client @@ -137,10 +112,6 @@ mod tests { .expect("storage capability"); assert_eq!(storage.availability(), Availability::Available); assert!(!runtime.client.is_closed()); - - runtime.stop(); - assert!(runtime.client.is_closed()); - runtime.stop(); } #[test] @@ -164,6 +135,5 @@ mod tests { ); poison_platform_lock(&runtime); runtime.set_app_info_platform(None, None, None, None, None); - runtime.stop(); } } diff --git a/crates/mobile_core/src/runtime/sdk.rs b/crates/mobile_core/src/runtime/sdk.rs @@ -20,6 +20,12 @@ pub struct SdkStorageStatusRecord { pub integrity: String, } +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct SdkShutdownRecord { + pub state: String, + pub already_closed: bool, +} + #[cfg_attr(not(coverage_nightly), uniffi::export)] impl RadrootsRuntime { pub fn sdk_capabilities(&self) -> Vec<SdkCapabilityRecord> { @@ -36,32 +42,18 @@ impl RadrootsRuntime { .collect() } - pub fn sdk_storage_status(&self) -> Result<SdkStorageStatusRecord, RadrootsAppError> { - #[cfg(feature = "rt")] - { - let executor = self - .executor - .lock() - .map_err(|_| RadrootsAppError::runtime("SDK executor lock is unavailable"))?; - let executor = executor - .as_ref() - .ok_or_else(|| RadrootsAppError::runtime("SDK runtime is closed"))?; - let status = executor - .block_on(self.client.storage_status()) - .map_err(RadrootsAppError::from_sdk)?; - Ok(SdkStorageStatusRecord { - backend: format!("{:?}", status.backend()).to_ascii_lowercase(), - open_mode: format!("{:?}", status.open_mode()).to_ascii_lowercase(), - shutdown: format!("{:?}", status.shutdown()).to_ascii_lowercase(), - integrity: format!("{:?}", status.integrity().health()).to_ascii_lowercase(), - }) - } - #[cfg(not(feature = "rt"))] - { - Err(RadrootsAppError::unsupported( - "SDK storage status requires a host async executor", - )) - } + pub async fn sdk_storage_status(&self) -> Result<SdkStorageStatusRecord, RadrootsAppError> { + let status = self + .client + .storage_status() + .await + .map_err(RadrootsAppError::from_sdk)?; + Ok(SdkStorageStatusRecord { + backend: format!("{:?}", status.backend()).to_ascii_lowercase(), + open_mode: format!("{:?}", status.open_mode()).to_ascii_lowercase(), + shutdown: format!("{:?}", status.shutdown()).to_ascii_lowercase(), + integrity: format!("{:?}", status.integrity().health()).to_ascii_lowercase(), + }) } } @@ -86,8 +78,8 @@ const fn maturity_label(value: Maturity) -> &'static str { mod tests { use super::RadrootsRuntime; - #[test] - fn sdk_records_are_stable_and_storage_is_memory_backed() { + #[tokio::test] + async fn sdk_records_are_stable_and_storage_is_memory_backed() { let runtime = RadrootsRuntime::new().expect("runtime"); let capabilities = runtime.sdk_capabilities(); assert!(capabilities.iter().any(|capability| { @@ -96,10 +88,10 @@ mod tests { && capability.availability == "available" && capability.maturity == "stable" })); - let status = runtime.sdk_storage_status().expect("storage status"); + let status = runtime.sdk_storage_status().await.expect("storage status"); assert_eq!(status.backend, "memory"); assert_eq!(status.integrity, "healthy"); - runtime.stop(); - assert!(runtime.sdk_storage_status().is_err()); + runtime.shutdown().await.expect("shutdown"); + assert!(runtime.sdk_storage_status().await.is_err()); } } diff --git a/crates/mobile_core/tests/sdk_runtime.rs b/crates/mobile_core/tests/sdk_runtime.rs @@ -2,8 +2,8 @@ use std::sync::Arc; use radroots_app_core::{RadrootsAppError, RadrootsRuntime}; -#[test] -fn runtime_is_send_sync_and_shares_one_sdk_lifecycle() { +#[tokio::test] +async fn runtime_is_send_sync_and_shares_one_sdk_lifecycle() { fn require_send_sync<T: Send + Sync>() {} require_send_sync::<RadrootsRuntime>(); @@ -18,21 +18,35 @@ fn runtime_is_send_sync_and_shares_one_sdk_lifecycle() { .iter() .any(|capability| capability.id == "storage.canonical") ); - runtime.stop(); - runtime.stop(); + let first = runtime.shutdown().await.expect("first shutdown"); + let second = runtime.shutdown().await.expect("second shutdown"); + assert!(!first.already_closed); + assert!(second.already_closed); assert!(runtime.info().sdk_closed); } -#[test] -fn operations_fail_safely_after_explicit_close() { +#[tokio::test] +async fn operations_fail_safely_after_explicit_close() { let runtime = RadrootsRuntime::new().expect("runtime"); assert_eq!( - runtime.sdk_storage_status().expect("status").backend, + runtime.sdk_storage_status().await.expect("status").backend, "memory" ); - runtime.stop(); + runtime.shutdown().await.expect("shutdown"); assert!(matches!( - runtime.sdk_storage_status(), - Err(RadrootsAppError::Runtime(_)) + runtime.sdk_storage_status().await, + Err(RadrootsAppError::Sdk { .. }) )); } + +#[tokio::test] +async fn dropping_unpolled_shutdown_has_no_effect_and_retry_closes() { + let runtime = RadrootsRuntime::new().expect("runtime"); + drop(runtime.shutdown()); + assert!(!runtime.info().sdk_closed); + assert!(!runtime.info().app.shutting_down); + + runtime.shutdown().await.expect("retry shutdown"); + assert!(runtime.info().sdk_closed); + assert!(runtime.info().app.shutting_down); +} diff --git a/crates/mobile_core/tests/uniffi_contract.rs b/crates/mobile_core/tests/uniffi_contract.rs @@ -0,0 +1,33 @@ +use radroots_app_core::{RadrootsAppError, RadrootsRuntime, SdkErrorRecord}; + +#[tokio::test] +async fn final_mobile_abi_uses_async_sdk_dtos_and_versioned_errors() { + let runtime = RadrootsRuntime::new().expect("runtime"); + let storage = runtime.sdk_storage_status().await.expect("storage status"); + assert_eq!(storage.backend, "memory"); + + runtime.shutdown().await.expect("shutdown"); + let error = runtime + .sdk_storage_status() + .await + .expect_err("closed client must reject operations"); + let RadrootsAppError::Sdk { + report: + SdkErrorRecord { + schema_version, + code, + class, + retryable, + message, + .. + }, + } = error + else { + panic!("expected versioned SDK error record"); + }; + assert_eq!(schema_version, 1); + assert_eq!(code, "client_closed"); + assert_eq!(class, "runtime"); + assert!(!retryable); + assert_eq!(message, "SDK client is closed"); +}