commit 9632a5796b7b20c330a12aad680cda4f88a57492
parent 7de7e5080a90d91a00d677e827922c093c285bcd
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:
8 files changed, 191 insertions(+), 119 deletions(-)
diff --git a/core/crates/tera_core/Cargo.toml b/core/crates/tera_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/core/crates/tera_core/src/error.rs b/core/crates/tera_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/core/crates/tera_core/src/lib.rs b/core/crates/tera_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/core/crates/tera_core/src/runtime/info.rs b/core/crates/tera_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/core/crates/tera_core/src/runtime/mod.rs b/core/crates/tera_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/core/crates/tera_core/src/runtime/sdk.rs b/core/crates/tera_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/core/crates/tera_core/tests/sdk_runtime.rs b/core/crates/tera_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/core/crates/tera_core/tests/uniffi_contract.rs b/core/crates/tera_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");
+}