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:
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:?}"),
+ }
+}