app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit db2b97a87c392afbd0f5b8eceacf4a2ec03c240a
parent 32e461b30288b12af237a5be9baaa1dda79ded80
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 07:58:17 +0000

runtime: bind actor waits to native scheduler

- execute mailbox receipt timeouts on the owned Tokio runtime
- preserve a safe closed result when a scheduled wait terminates
- isolate native JVM tests behind a development-only data root
- cover generated recovery through the real Kotlin and UniFFI boundary

Diffstat:
Mapp/desktop/build.gradle.kts | 7+++++++
Aapp/desktop/src/test/kotlin/org/radroots/studio/application/NativeGeneratedRecoveryTest.kt | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/ffi/src/commands.rs | 9+++++++--
Mcore/crates/storage/src/runtime_actor.rs | 12+++++++++---
4 files changed, 76 insertions(+), 5 deletions(-)

diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -563,6 +563,13 @@ tasks.named("runKtlintFormatOverMainSourceSet") { } tasks.withType<Test>().configureEach { dependsOn(buildRustCoreDebug) + environment( + "RADROOTS_STUDIO_DEVELOPMENT_DATA_DIR", + layout.buildDirectory + .dir("native-test-data") + .get() + .asFile.absolutePath, + ) systemProperty( "jna.library.path", rustDebugLibrary.asFile.parentFile.absolutePath, diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/NativeGeneratedRecoveryTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/NativeGeneratedRecoveryTest.kt @@ -0,0 +1,53 @@ +package org.radroots.studio.application + +import kotlinx.coroutines.test.runTest +import org.radroots.studio.ffi.StudioAppCore +import org.radroots.studio.ffi.compatibilityDescriptor +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NativeGeneratedRecoveryTest { + private val dataDirectory = + Path.of(checkNotNull(System.getenv("RADROOTS_STUDIO_DEVELOPMENT_DATA_DIR"))) + + @BeforeTest + fun prepareDataDirectory() { + dataDirectory.toFile().deleteRecursively() + Files.createDirectories(dataDirectory) + } + + @AfterTest + fun removeDataDirectory() { + dataDirectory.toFile().deleteRecursively() + } + + @Test + fun generatedRecoveryCrossesTheNativeBoundaryAndCancelsWithoutPersistence() = + runTest { + val core = + StudioAppCore.openCompatible( + expectation = verifyNativeCompatibility(compatibilityDescriptor()), + developmentMode = true, + ) + val gateway = NativeStudioCoreGateway(core) + try { + gateway.bootstrap() + val recovery = gateway.beginGeneratedAccount() + + assertTrue(recovery.account.npub.startsWith("npub1")) + assertTrue(recovery.takeRecoveryNsec().startsWith("nsec1")) + assertTrue(recovery.cancel()) + assertFalse(recovery.cancel()) + assertEquals(0, gateway.snapshot().accounts.size) + recovery.close() + } finally { + gateway.shutdown() + } + } +} diff --git a/core/crates/ffi/src/commands.rs b/core/crates/ffi/src/commands.rs @@ -23,6 +23,7 @@ const DATABASE_QUALIFIER: &str = "org"; const DATABASE_ORGANIZATION: &str = "radroots"; const DATABASE_APPLICATION: &str = "studio"; const DATABASE_FILENAME: &str = "studio.sqlite3"; +const DEVELOPMENT_DATA_DIR_ENVIRONMENT: &str = "RADROOTS_STUDIO_DEVELOPMENT_DATA_DIR"; pub(crate) const ACTOR_MAILBOX_CAPACITY: usize = 64; pub const FFI_CONTRACT_MAJOR: u16 = 2; pub const FFI_CONTRACT_MINOR: u16 = 0; @@ -229,7 +230,7 @@ impl StudioAppCore { expectation: CompatibilityExpectation, development_mode: bool, ) -> Result<Arc<Self>, StudioError> { - let path = canonical_database_path()?; + let path = application_database_path(development_mode)?; Self::open_path_compatible(&path, &expectation, development_mode) } @@ -540,7 +541,11 @@ impl Clock for SystemClock { } } -fn canonical_database_path() -> Result<PathBuf, StudioError> { +fn application_database_path(development_mode: bool) -> Result<PathBuf, StudioError> { + if development_mode && let Some(directory) = std::env::var_os(DEVELOPMENT_DATA_DIR_ENVIRONMENT) + { + return Ok(PathBuf::from(directory).join(DATABASE_FILENAME)); + } ProjectDirs::from( DATABASE_QUALIFIER, DATABASE_ORGANIZATION, diff --git a/core/crates/storage/src/runtime_actor.rs b/core/crates/storage/src/runtime_actor.rs @@ -115,6 +115,7 @@ pub struct RuntimeActorHandle { mailbox: ActorMailbox<RuntimeCommand, RuntimeCommandValue>, adapter: Arc<PersistentAppCore>, lifecycle: Arc<Mutex<LifecycleGate>>, + runtime: Handle, next_request: Arc<AtomicU64>, session_generation: Arc<AtomicU64>, foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>, @@ -234,6 +235,7 @@ impl RuntimeActorHandle { mailbox, adapter, lifecycle, + runtime: runtime.clone(), next_request: Arc::new(AtomicU64::new(1)), session_generation, foreground_session, @@ -560,9 +562,13 @@ impl RuntimeActorHandle { let receipt = match self.mailbox.submit(context, command) { CommandSubmission::Accepted(ticket) => { let remaining = deadline.saturating_duration_since(Instant::now()); - match tokio::time::timeout(remaining, ticket.receipt()).await { - Ok(receipt) => receipt, - Err(_) => CommandReceipt::new(request_id, CommandResult::TimedOut), + let waiting = self + .runtime + .spawn(async move { tokio::time::timeout(remaining, ticket.receipt()).await }); + match waiting.await { + Ok(Ok(receipt)) => receipt, + Ok(Err(_)) => CommandReceipt::new(request_id, CommandResult::TimedOut), + Err(_) => CommandReceipt::new(request_id, CommandResult::Closed), } } CommandSubmission::Rejected(receipt) => receipt,