field_lib

Cross-platform Rust runtime for Radroots iOS and Android apps
git clone https://radroots.dev/git/field_lib.git
Log | Files | Refs | README | LICENSE

commit 93b186fb5cb1d3378bb3a34f76d26968b1eff7ac
parent 6779537ea0947f1759cd61abfb146c1f2a8d8d97
Author: triesap <tyson@radroots.org>
Date:   Fri, 19 Jun 2026 17:53:20 -0700

runtime: fix Nostr lock boundaries

- clone Nostr managers before blocking runtime calls
- avoid re-locking RadrootsRuntime during listing publish
- release the shared net lock before listing fetch network I/O
- add a regression for initialized listing publish lock safety

Diffstat:
Mcrates/field_core/src/runtime/nostr.rs | 93+++++++++++++++++++++++++++++--------------------------------------------------
Mcrates/field_core/src/runtime/trade_listing.rs | 58+++++++++++++++++++++++++++-------------------------------
Mcrates/field_core/tests/error_mapping.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 115 insertions(+), 90 deletions(-)

diff --git a/crates/field_core/src/runtime/nostr.rs b/crates/field_core/src/runtime/nostr.rs @@ -3,6 +3,20 @@ use crate::RadrootsAppError; #[cfg(feature = "nostr-client")] use tokio::sync::broadcast::error::TryRecvError; +#[cfg(feature = "nostr-client")] +fn nostr_manager( + runtime: &RadrootsRuntime, +) -> Result<radroots_net_core::nostr_client::NostrClientManager, RadrootsAppError> { + let guard = runtime + .net + .lock() + .map_err(|err| RadrootsAppError::runtime(format!("{err}")))?; + guard + .nostr + .clone() + .ok_or_else(|| RadrootsAppError::relay("nostr not initialized")) +} + #[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] pub enum NostrLight { Red, @@ -181,18 +195,21 @@ impl RadrootsRuntime { ) -> Result<Option<NostrProfileEventMetadata>, RadrootsAppError> { #[cfg(feature = "nostr-client")] { - let guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), + let (pk, mgr) = { + let guard = self + .net + .lock() + .map_err(|err| RadrootsAppError::runtime(format!("{err}")))?; + let keys = guard.selected_nostr_keys().ok_or_else(|| { + RadrootsAppError::identity("selected signing identity is not configured") + })?; + let pk = keys.public_key(); + let mgr = guard + .nostr + .clone() + .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))?; + (pk, mgr) }; - let keys = guard.selected_nostr_keys().ok_or_else(|| { - RadrootsAppError::identity("selected signing identity is not configured") - })?; - let pk = keys.public_key(); - let mgr = guard - .nostr - .as_ref() - .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))?; let out = mgr .fetch_profile_event_blocking(pk) .map_err(|error| RadrootsAppError::relay(error.to_string()))?; @@ -213,14 +230,7 @@ impl RadrootsRuntime { ) -> Result<String, RadrootsAppError> { #[cfg(feature = "nostr-client")] { - let guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - let mgr = guard - .nostr - .as_ref() - .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))?; + let mgr = nostr_manager(self)?; mgr.publish_profile_event_blocking(name, display_name, nip05, about) .map_err(|e| RadrootsAppError::relay(e.to_string())) } @@ -234,14 +244,7 @@ impl RadrootsRuntime { pub fn nostr_post_text_note(&self, content: String) -> Result<String, RadrootsAppError> { #[cfg(feature = "nostr-client")] { - let guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - let mgr = guard - .nostr - .as_ref() - .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))?; + let mgr = nostr_manager(self)?; mgr.publish_post_event_blocking(content) .map_err(|e| RadrootsAppError::relay(e.to_string())) } @@ -259,14 +262,7 @@ impl RadrootsRuntime { ) -> Result<Vec<NostrPostEventMetadata>, RadrootsAppError> { #[cfg(feature = "nostr-client")] { - let guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - let mgr = guard - .nostr - .as_ref() - .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))?; + let mgr = nostr_manager(self)?; let items = mgr .fetch_post_events_blocking(limit, since_unix) .map_err(|e| RadrootsAppError::relay(e.to_string()))?; @@ -288,14 +284,7 @@ impl RadrootsRuntime { ) -> Result<String, RadrootsAppError> { #[cfg(feature = "nostr-client")] { - let guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - let mgr = guard - .nostr - .as_ref() - .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))?; + let mgr = nostr_manager(self)?; mgr.publish_post_reply_event_blocking( parent_event_id_hex, parent_author_hex, @@ -322,14 +311,7 @@ impl RadrootsRuntime { ) -> Result<(), RadrootsAppError> { #[cfg(feature = "nostr-client")] { - let guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - let mgr = guard - .nostr - .as_ref() - .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))?; + let mgr = nostr_manager(self)?; mgr.start_post_event_stream(since_unix); if let Ok(mut rx_guard) = self.post_events_rx.lock() { if rx_guard.is_none() { @@ -378,14 +360,7 @@ impl RadrootsRuntime { pub fn nostr_stop_post_event_stream(&self) -> Result<(), RadrootsAppError> { #[cfg(feature = "nostr-client")] { - let guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - let mgr = guard - .nostr - .as_ref() - .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))?; + let mgr = nostr_manager(self)?; mgr.stop_post_event_stream(); if let Ok(mut rx_guard) = self.post_events_rx.lock() { *rx_guard = None; diff --git a/crates/field_core/src/runtime/trade_listing.rs b/crates/field_core/src/runtime/trade_listing.rs @@ -104,16 +104,24 @@ impl RadrootsRuntime { ) -> Result<String, RadrootsAppError> { #[cfg(feature = "nostr-client")] { - let guard = self - .net - .lock() - .map_err(|error| RadrootsAppError::runtime(format!("{error}")))?; - let mgr = guard - .nostr - .as_ref() - .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))?; + let (current_pubkey, mgr) = { + let guard = self + .net + .lock() + .map_err(|error| RadrootsAppError::runtime(format!("{error}")))?; + let current_pubkey = guard + .accounts + .default_public_identity() + .map_err(|error| RadrootsAppError::identity(format!("{error}")))? + .ok_or_else(|| RadrootsAppError::identity("default account is not configured"))? + .public_key_hex; + let mgr = guard + .nostr + .clone() + .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))?; + (current_pubkey, mgr) + }; let listing = listing_from_draft(&draft)?; - let current_pubkey = current_pubkey_hex(self)?; if listing.farm.pubkey != current_pubkey { return Err(RadrootsAppError::runtime( "farm_pubkey must match the default account public key", @@ -139,14 +147,16 @@ impl RadrootsRuntime { ) -> Result<Vec<TradeListingSummary>, RadrootsAppError> { #[cfg(feature = "nostr-client")] { - let guard = self - .net - .lock() - .map_err(|error| RadrootsAppError::runtime(format!("{error}")))?; - let mgr = guard - .nostr - .as_ref() - .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))?; + let mgr = { + let guard = self + .net + .lock() + .map_err(|error| RadrootsAppError::runtime(format!("{error}")))?; + guard + .nostr + .clone() + .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))? + }; let mut filter = RadrootsNostrFilter::new().kind(RadrootsNostrKind::Custom(KIND_LISTING as u16)); filter = filter.limit(limit.into()); @@ -295,20 +305,6 @@ fn listing_summary_from_trade( } } -#[cfg(feature = "nostr-client")] -fn current_pubkey_hex(runtime: &RadrootsRuntime) -> Result<String, RadrootsAppError> { - let guard = runtime - .net - .lock() - .map_err(|error| RadrootsAppError::runtime(format!("{error}")))?; - let identity = guard - .accounts - .default_public_identity() - .map_err(|error| RadrootsAppError::identity(format!("{error}")))? - .ok_or_else(|| RadrootsAppError::identity("default account is not configured"))?; - Ok(identity.public_key_hex) -} - fn non_empty(value: String, field: &str) -> Result<String, RadrootsAppError> { let value = value.trim().to_string(); if value.is_empty() { diff --git a/crates/field_core/tests/error_mapping.rs b/crates/field_core/tests/error_mapping.rs @@ -1,5 +1,8 @@ #![cfg(feature = "nostr-client")] +use std::{sync::mpsc, time::Duration}; + +use radroots_field_core::runtime::trade_listing::TradeListingDraft; use radroots_field_core::{RadrootsAppError, RadrootsRuntime}; #[test] @@ -70,3 +73,54 @@ fn post_stream_read_without_started_stream_returns_no_data() { assert!(event.is_none()); } + +#[test] +fn trade_listing_publish_with_initialized_nostr_does_not_relock_runtime() { + let runtime = RadrootsRuntime::new().expect("runtime"); + let identity = radroots_identity::RadrootsIdentity::generate(); + let public_key_hex = identity.public_key_hex(); + runtime + .nostr_identity_restore_host_custody_secret( + identity.secret_key_hex(), + Some("field".to_string()), + true, + ) + .expect("restore identity"); + runtime + .nostr_set_default_relays(Vec::new()) + .expect("initialize nostr manager"); + let draft = TradeListingDraft { + listing_id: Some("AAAAAAAAAAAAAAAAAAAAAg".to_string()), + farm_pubkey: public_key_hex, + farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), + title: "Carrots".to_string(), + description: "Fresh carrots".to_string(), + category: "produce".to_string(), + bin_display_amount: "1".to_string(), + bin_display_unit: "lb".to_string(), + unit_price: "3.50".to_string(), + currency: "USD".to_string(), + bin_label: None, + bin_id: Some("bin-1".to_string()), + inventory: "10".to_string(), + delivery_method: "pickup".to_string(), + location_primary: "farm stand".to_string(), + location_city: None, + location_region: None, + location_country: None, + }; + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let result = runtime.trade_listing_publish(draft); + let _ = tx.send(result); + }); + + let result = rx + .recv_timeout(Duration::from_secs(3)) + .expect("publish must return instead of re-locking the runtime"); + + match result { + Ok(_) | Err(RadrootsAppError::Relay(_)) => {} + other => panic!("unexpected publish result: {other:?}"), + } +}