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:
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,