commit d28bfe0e3f5b1e3906c11891915c9df574e3660f
parent d8c73c5fa9e48fb1b927536756f98b4557ac146e
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 15:00:18 +0000
discovery: harden relay availability handling
- separate relay fetch availability from semantic live discovery state
- preserve healthy relay results while surfacing unavailable relay errors explicitly
- require force to refresh when configured discovery relays are unavailable and cover outage cases end to end
- validate with cargo metadata --format-version 1 --no-deps, cargo fmt --all --check, cargo check --locked, and cargo test --locked
Diffstat:
8 files changed, 461 insertions(+), 39 deletions(-)
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
@@ -310,6 +310,7 @@ fn emit_operation_audit_trace(record: &MycOperationAuditRecord) {
),
crate::audit::MycOperationAuditOutcome::Rejected
| crate::audit::MycOperationAuditOutcome::Restored
+ | crate::audit::MycOperationAuditOutcome::Unavailable
| crate::audit::MycOperationAuditOutcome::Drifted
| crate::audit::MycOperationAuditOutcome::Conflicted => tracing::warn!(
operation = ?record.operation,
diff --git a/src/audit.rs b/src/audit.rs
@@ -20,6 +20,7 @@ pub enum MycOperationAuditKind {
ConnectAcceptPublish,
AuthReplayPublish,
AuthReplayRestore,
+ DiscoveryHandlerFetch,
DiscoveryHandlerPublish,
DiscoveryHandlerCompare,
DiscoveryHandlerRefresh,
@@ -31,6 +32,7 @@ pub enum MycOperationAuditOutcome {
Succeeded,
Rejected,
Restored,
+ Unavailable,
Missing,
Matched,
Drifted,
diff --git a/src/cli.rs b/src/cli.rs
@@ -172,6 +172,7 @@ pub struct MycOperationOutcomeCounts {
pub succeeded: usize,
pub rejected: usize,
pub restored: usize,
+ pub unavailable: usize,
pub missing: usize,
pub matched: usize,
pub drifted: usize,
@@ -188,6 +189,7 @@ pub struct MycAuditSummaryOutput {
pub runtime_operation_outcomes: MycOperationOutcomeCounts,
pub runtime_operation_by_kind: BTreeMap<String, MycOperationOutcomeCounts>,
pub runtime_publish_rejection_count: usize,
+ pub runtime_unavailable_count: usize,
pub runtime_replay_restore_count: usize,
}
@@ -454,6 +456,7 @@ fn summarize_audit_output(
let mut runtime_operation_outcomes = MycOperationOutcomeCounts::default();
let mut runtime_operation_by_kind = BTreeMap::new();
let mut runtime_publish_rejection_count = 0;
+ let mut runtime_unavailable_count = 0;
let mut runtime_replay_restore_count = 0;
for record in &audit.runtime_operation_audit {
increment_outcome_counts(&mut runtime_operation_outcomes, record.outcome);
@@ -465,6 +468,9 @@ fn summarize_audit_output(
if record.outcome == MycOperationAuditOutcome::Rejected {
runtime_publish_rejection_count += 1;
}
+ if record.outcome == MycOperationAuditOutcome::Unavailable {
+ runtime_unavailable_count += 1;
+ }
if record.operation == MycOperationAuditKind::AuthReplayRestore
&& record.outcome == MycOperationAuditOutcome::Restored
{
@@ -480,6 +486,7 @@ fn summarize_audit_output(
runtime_operation_outcomes,
runtime_operation_by_kind,
runtime_publish_rejection_count,
+ runtime_unavailable_count,
runtime_replay_restore_count,
})
}
@@ -496,6 +503,7 @@ fn increment_outcome_counts(
MycOperationAuditOutcome::Succeeded => counts.succeeded += 1,
MycOperationAuditOutcome::Rejected => counts.rejected += 1,
MycOperationAuditOutcome::Restored => counts.restored += 1,
+ MycOperationAuditOutcome::Unavailable => counts.unavailable += 1,
MycOperationAuditOutcome::Missing => counts.missing += 1,
MycOperationAuditOutcome::Matched => counts.matched += 1,
MycOperationAuditOutcome::Drifted => counts.drifted += 1,
@@ -510,6 +518,7 @@ fn operation_kind_label(kind: MycOperationAuditKind) -> String {
MycOperationAuditKind::ConnectAcceptPublish => "connect_accept_publish".to_owned(),
MycOperationAuditKind::AuthReplayPublish => "auth_replay_publish".to_owned(),
MycOperationAuditKind::AuthReplayRestore => "auth_replay_restore".to_owned(),
+ MycOperationAuditKind::DiscoveryHandlerFetch => "discovery_handler_fetch".to_owned(),
MycOperationAuditKind::DiscoveryHandlerPublish => "discovery_handler_publish".to_owned(),
MycOperationAuditKind::DiscoveryHandlerCompare => "discovery_handler_compare".to_owned(),
MycOperationAuditKind::DiscoveryHandlerRefresh => "discovery_handler_refresh".to_owned(),
@@ -698,6 +707,7 @@ mod tests {
assert_eq!(summary.runtime_operation_outcomes.succeeded, 1);
assert_eq!(summary.runtime_operation_outcomes.restored, 1);
assert_eq!(summary.runtime_publish_rejection_count, 0);
+ assert_eq!(summary.runtime_unavailable_count, 0);
assert_eq!(summary.runtime_replay_restore_count, 1);
assert_eq!(
summary
diff --git a/src/discovery.rs b/src/discovery.rs
@@ -5,10 +5,10 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use radroots_identity::RadrootsIdentity;
use radroots_nostr::prelude::{
- RadrootsNostrApplicationHandlerSpec, RadrootsNostrClient, RadrootsNostrEvent,
- RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrRelayUrl,
- radroots_nostr_build_application_handler_event, radroots_nostr_filter_tag,
- radroots_nostr_metadata_has_fields, radroots_nostr_tag_first_value,
+ RadrootsNostrApplicationHandlerSpec, RadrootsNostrClient, RadrootsNostrError,
+ RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrMetadata,
+ RadrootsNostrRelayUrl, radroots_nostr_build_application_handler_event,
+ radroots_nostr_filter_tag, radroots_nostr_metadata_has_fields, radroots_nostr_tag_first_value,
};
use radroots_nostr_connect::prelude::{RadrootsNostrConnectBunkerUri, RadrootsNostrConnectUri};
use serde::{Deserialize, Serialize};
@@ -118,13 +118,27 @@ pub struct MycLiveNip89Group {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MycLiveNip89RelayState {
pub relay_url: String,
+ pub fetch_status: MycDiscoveryRelayFetchStatus,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub fetch_error: Option<String>,
pub live_groups: Vec<MycLiveNip89Group>,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum MycDiscoveryRelayFetchStatus {
+ Available,
+ Unavailable,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MycDiscoveryRelayState {
pub relay_url: String,
- pub status: MycDiscoveryLiveStatus,
+ pub fetch_status: MycDiscoveryRelayFetchStatus,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub fetch_error: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub live_status: Option<MycDiscoveryLiveStatus>,
pub differing_fields: Vec<String>,
pub live_groups: Vec<MycLiveNip89Group>,
}
@@ -132,6 +146,7 @@ pub struct MycDiscoveryRelayState {
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct MycDiscoveryRelaySummary {
pub total_relays: usize,
+ pub unavailable_relays: Vec<String>,
pub missing_relays: Vec<String>,
pub matched_relays: Vec<String>,
pub drifted_relays: Vec<String>,
@@ -504,7 +519,7 @@ pub async fn publish_nip89_event(
pub async fn fetch_live_nip89(runtime: &MycRuntime) -> Result<MycFetchedLiveNip89Output, MycError> {
let context = MycDiscoveryContext::from_runtime(runtime)?;
- let fetched = fetch_live_nip89_state(&context).await?;
+ let fetched = fetch_live_nip89_state_for_runtime(runtime, &context).await?;
Ok(MycFetchedLiveNip89Output {
author_public_key_hex: context.app_identity().public_key_hex(),
publish_relays: context
@@ -521,7 +536,7 @@ pub async fn fetch_live_nip89(runtime: &MycRuntime) -> Result<MycFetchedLiveNip8
pub async fn diff_live_nip89(runtime: &MycRuntime) -> Result<MycDiscoveryDiffOutput, MycError> {
let context = MycDiscoveryContext::from_runtime(runtime)?;
let local_handler = context.render_normalized_nip89_handler();
- let fetched = fetch_live_nip89_state(&context).await?;
+ let fetched = fetch_live_nip89_state_for_runtime(runtime, &context).await?;
let relay_states = build_relay_diffs(&local_handler, &fetched.relay_states);
let relay_summary = summarize_relay_diffs(&relay_states);
let live_groups = fetched.live_groups;
@@ -542,7 +557,7 @@ pub async fn refresh_nip89(
) -> Result<MycRefreshedNip89Output, MycError> {
let context = MycDiscoveryContext::from_runtime(runtime)?;
let local_handler = context.render_normalized_nip89_handler();
- let fetched = fetch_live_nip89_state(&context).await?;
+ let fetched = fetch_live_nip89_state_for_runtime(runtime, &context).await?;
let relay_states = build_relay_diffs(&local_handler, &fetched.relay_states);
let relay_summary = summarize_relay_diffs(&relay_states);
let live_groups = fetched.live_groups;
@@ -558,10 +573,29 @@ pub async fn refresh_nip89(
None,
compare_request_id,
relay_count,
- relay_count,
+ relay_count.saturating_sub(relay_summary.unavailable_relays.len()),
compare_summary,
));
+ if !relay_summary.unavailable_relays.is_empty() && !force {
+ runtime.record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::DiscoveryHandlerRefresh,
+ MycOperationAuditOutcome::Unavailable,
+ None,
+ compare_request_id,
+ relay_count,
+ relay_count.saturating_sub(relay_summary.unavailable_relays.len()),
+ format!(
+ "discovery relays were unavailable; rerun refresh with --force to override: {}",
+ relay_summary.unavailable_relays.join(", ")
+ ),
+ ));
+ return Err(MycError::InvalidOperation(format!(
+ "one or more discovery relays were unavailable; rerun `discovery refresh-nip89 --force` to override: {}",
+ relay_summary.unavailable_relays.join(", ")
+ )));
+ }
+
if status == MycDiscoveryLiveStatus::Conflicted && !force {
runtime.record_operation_audit(&MycOperationAuditRecord::new(
MycOperationAuditKind::DiscoveryHandlerRefresh,
@@ -569,7 +603,7 @@ pub async fn refresh_nip89(
None,
compare_request_id,
relay_count,
- relay_count,
+ relay_count.saturating_sub(relay_summary.unavailable_relays.len()),
"live discovery handler state is conflicted; rerun refresh with --force to override"
.to_owned(),
));
@@ -586,7 +620,7 @@ pub async fn refresh_nip89(
None,
compare_request_id,
relay_count,
- relay_count,
+ relay_count.saturating_sub(relay_summary.unavailable_relays.len()),
"local discovery handler already matches live state".to_owned(),
));
return Ok(MycRefreshedNip89Output {
@@ -612,6 +646,54 @@ pub async fn refresh_nip89(
})
}
+async fn fetch_live_nip89_state_for_runtime(
+ runtime: &MycRuntime,
+ context: &MycDiscoveryContext,
+) -> Result<MycFetchedLiveNip89State, MycError> {
+ match fetch_live_nip89_state(context).await {
+ Ok(fetched) => {
+ let unavailable_relays = fetched
+ .relay_states
+ .iter()
+ .filter(|relay_state| {
+ relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable
+ })
+ .collect::<Vec<_>>();
+ if !unavailable_relays.is_empty() {
+ runtime.record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::DiscoveryHandlerFetch,
+ MycOperationAuditOutcome::Unavailable,
+ None,
+ latest_live_event_id(&fetched.live_groups),
+ fetched.relay_states.len(),
+ fetched.relay_states.len() - unavailable_relays.len(),
+ summarize_unavailable_relays(&fetched.relay_states),
+ ));
+ }
+ Ok(fetched)
+ }
+ Err(MycError::DiscoveryFetchUnavailable {
+ relay_count,
+ details,
+ }) => {
+ runtime.record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::DiscoveryHandlerFetch,
+ MycOperationAuditOutcome::Unavailable,
+ None,
+ None,
+ relay_count,
+ 0,
+ details.clone(),
+ ));
+ Err(MycError::DiscoveryFetchUnavailable {
+ relay_count,
+ details,
+ })
+ }
+ Err(error) => Err(error),
+ }
+}
+
pub fn verify_bundle(output_dir: impl AsRef<Path>) -> Result<MycDiscoveryBundleOutput, MycError> {
let output_dir = output_dir.as_ref().to_path_buf();
let manifest_path = output_dir.join(DISCOVERY_BUNDLE_MANIFEST_FILE_NAME);
@@ -642,11 +724,35 @@ async fn fetch_live_nip89_state(
for relay in context.publish_relays() {
let relay_url = relay.to_string();
- let relay_events = fetch_live_nip89_events_for_relay(context, relay).await?;
- all_events.extend(relay_events.iter().cloned());
- relay_states.push(MycLiveNip89RelayState {
- relay_url,
- live_groups: group_live_nip89_events(relay_events)?,
+ match fetch_live_nip89_events_for_relay(context, relay).await {
+ Ok(relay_events) => {
+ all_events.extend(relay_events.iter().cloned());
+ relay_states.push(MycLiveNip89RelayState {
+ relay_url,
+ fetch_status: MycDiscoveryRelayFetchStatus::Available,
+ fetch_error: None,
+ live_groups: group_live_nip89_events(relay_events)?,
+ });
+ }
+ Err(error) => {
+ relay_states.push(MycLiveNip89RelayState {
+ relay_url,
+ fetch_status: MycDiscoveryRelayFetchStatus::Unavailable,
+ fetch_error: Some(error.to_string()),
+ live_groups: Vec::new(),
+ });
+ }
+ }
+ }
+
+ let available_relay_count = relay_states
+ .iter()
+ .filter(|relay_state| relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Available)
+ .count();
+ if available_relay_count == 0 {
+ return Err(MycError::DiscoveryFetchUnavailable {
+ relay_count: relay_states.len(),
+ details: summarize_unavailable_relays(&relay_states),
});
}
@@ -662,10 +768,13 @@ async fn fetch_live_nip89_events_for_relay(
) -> Result<Vec<MycSourcedLiveNip89Event>, MycError> {
let client = RadrootsNostrClient::from_identity(context.app_identity());
let _ = client.add_relay(relay.as_str()).await?;
- client.connect().await;
client
- .wait_for_connection(Duration::from_secs(context.connect_timeout_secs()))
- .await;
+ .try_connect_relay(
+ relay.as_str(),
+ Duration::from_secs(context.connect_timeout_secs()),
+ )
+ .await
+ .map_err(RadrootsNostrError::from)?;
let mut filter = RadrootsNostrFilter::new()
.author(context.app_identity().public_key())
@@ -752,7 +861,7 @@ fn describe_compare_status(
live_groups: &[MycLiveNip89Group],
relay_summary: &MycDiscoveryRelaySummary,
) -> String {
- match status {
+ let base = match status {
MycDiscoveryLiveStatus::Missing => {
"no live NIP-89 handler was found for the configured discovery identity".to_owned()
}
@@ -775,6 +884,15 @@ fn describe_compare_status(
relay_summary.missing_relays.len(),
relay_summary.conflicted_relays.len(),
),
+ };
+
+ if relay_summary.unavailable_relays.is_empty() {
+ base
+ } else {
+ format!(
+ "{base}; unavailable relays: {}",
+ relay_summary.unavailable_relays.join(", ")
+ )
}
}
@@ -935,11 +1053,19 @@ fn build_relay_diffs(
relay_states
.iter()
.map(|relay_state| {
- let (status, differing_fields) =
- compare_live_handler(local_handler, &relay_state.live_groups);
+ let (live_status, differing_fields) =
+ if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable {
+ (None, Vec::new())
+ } else {
+ let (status, differing_fields) =
+ compare_live_handler(local_handler, &relay_state.live_groups);
+ (Some(status), differing_fields)
+ };
MycDiscoveryRelayState {
relay_url: relay_state.relay_url.clone(),
- status,
+ fetch_status: relay_state.fetch_status,
+ fetch_error: relay_state.fetch_error.clone(),
+ live_status,
differing_fields,
live_groups: relay_state.live_groups.clone(),
}
@@ -954,25 +1080,52 @@ fn summarize_relay_diffs(relay_states: &[MycDiscoveryRelayState]) -> MycDiscover
};
for relay_state in relay_states {
- match relay_state.status {
- MycDiscoveryLiveStatus::Missing => {
+ if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable {
+ summary
+ .unavailable_relays
+ .push(relay_state.relay_url.clone());
+ continue;
+ }
+ match relay_state.live_status {
+ Some(MycDiscoveryLiveStatus::Missing) => {
summary.missing_relays.push(relay_state.relay_url.clone())
}
- MycDiscoveryLiveStatus::Matched => {
+ Some(MycDiscoveryLiveStatus::Matched) => {
summary.matched_relays.push(relay_state.relay_url.clone())
}
- MycDiscoveryLiveStatus::Drifted => {
+ Some(MycDiscoveryLiveStatus::Drifted) => {
summary.drifted_relays.push(relay_state.relay_url.clone())
}
- MycDiscoveryLiveStatus::Conflicted => summary
+ Some(MycDiscoveryLiveStatus::Conflicted) => summary
.conflicted_relays
.push(relay_state.relay_url.clone()),
+ None => {}
}
}
summary
}
+fn summarize_unavailable_relays(relay_states: &[MycLiveNip89RelayState]) -> String {
+ let unavailable = relay_states
+ .iter()
+ .filter(|relay_state| relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable)
+ .map(|relay_state| {
+ let details = relay_state
+ .fetch_error
+ .as_deref()
+ .unwrap_or("unknown relay fetch failure");
+ format!("{}: {details}", relay_state.relay_url)
+ })
+ .collect::<Vec<_>>();
+
+ if unavailable.is_empty() {
+ "all configured discovery relays were available".to_owned()
+ } else {
+ format!("unavailable discovery relays: {}", unavailable.join("; "))
+ }
+}
+
fn latest_group_sort_key(group: &MycLiveNip89Group) -> (u64, &str) {
group
.events
diff --git a/src/error.rs b/src/error.rs
@@ -73,6 +73,10 @@ pub enum MycError {
InvalidDiscoveryBundle(String),
#[error("invalid discovery event: {0}")]
InvalidDiscoveryEvent(String),
+ #[error(
+ "failed to fetch discovery state from all configured relays ({relay_count}): {details}"
+ )]
+ DiscoveryFetchUnavailable { relay_count: usize, details: String },
#[error(transparent)]
Identity(#[from] IdentityError),
#[error(transparent)]
diff --git a/src/lib.rs b/src/lib.rs
@@ -23,12 +23,12 @@ pub use config::{
pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput};
pub use discovery::{
MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext,
- MycDiscoveryDiffOutput, MycDiscoveryLiveStatus, MycDiscoveryRelayState,
- MycDiscoveryRelaySummary, MycFetchedLiveNip89Output, MycLiveNip89Event, MycLiveNip89Group,
- MycLiveNip89RelayState, MycNip05Document, MycNip05DocumentSection, MycNip89HandlerDocument,
- MycNormalizedNip89Handler, MycPublishedNip89Output, MycRefreshedNip89Output,
- MycRenderedNip05Output, MycRenderedNip89Output, diff_live_nip89, fetch_live_nip89,
- publish_nip89_event, refresh_nip89, render_nip05_output, verify_bundle,
+ MycDiscoveryDiffOutput, MycDiscoveryLiveStatus, MycDiscoveryRelayFetchStatus,
+ MycDiscoveryRelayState, MycDiscoveryRelaySummary, MycFetchedLiveNip89Output, MycLiveNip89Event,
+ MycLiveNip89Group, MycLiveNip89RelayState, MycNip05Document, MycNip05DocumentSection,
+ MycNip89HandlerDocument, MycNormalizedNip89Handler, MycPublishedNip89Output,
+ MycRefreshedNip89Output, MycRenderedNip05Output, MycRenderedNip89Output, diff_live_nip89,
+ fetch_live_nip89, publish_nip89_event, refresh_nip89, render_nip05_output, verify_bundle,
};
pub use error::MycError;
pub use transport::{MycNostrTransport, MycTransportSnapshot};
diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs
@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::fs;
+use std::net::TcpListener as StdTcpListener;
use std::path::Path;
use std::process::{Command, Output};
use std::sync::Arc;
@@ -357,6 +358,13 @@ fn run_myc(config_path: &Path, args: &[&str]) -> TestResult<Output> {
.output()?)
}
+fn unavailable_relay_url() -> TestResult<String> {
+ let listener = StdTcpListener::bind("127.0.0.1:0")?;
+ let addr = listener.local_addr()?;
+ drop(listener);
+ Ok(format!("ws://{addr}"))
+}
+
async fn publish_handler_event(
relay_url: &str,
identity: &RadrootsIdentity,
@@ -562,7 +570,11 @@ async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> {
1
);
assert_eq!(
- diff_output["relay_states"][0]["status"],
+ diff_output["relay_states"][0]["fetch_status"],
+ Value::String("available".to_owned())
+ );
+ assert_eq!(
+ diff_output["relay_states"][0]["live_status"],
Value::String("matched".to_owned())
);
@@ -633,6 +645,12 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> {
.len(),
1
);
+ assert!(
+ diff_output["relay_summary"]["unavailable_relays"]
+ .as_array()
+ .unwrap()
+ .is_empty()
+ );
let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?;
assert!(
@@ -789,6 +807,97 @@ async fn discovery_diff_surfaces_relay_provenance_through_the_cli() -> TestResul
Value::Array(vec![])
);
assert_eq!(diff_output["relay_states"].as_array().unwrap().len(), 2);
+ for relay_state in diff_output["relay_states"].as_array().unwrap() {
+ assert_eq!(
+ relay_state["fetch_status"],
+ Value::String("available".to_owned())
+ );
+ assert!(relay_state["live_status"].is_string());
+ }
+
+ Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_the_cli()
+-> TestResult<()> {
+ let relay = TestRelay::spawn().await?;
+ let unavailable_relay = unavailable_relay_url()?;
+ let temp = tempfile::tempdir()?;
+ let config_path = temp.path().join("config.toml");
+ let state_dir = temp.path().join("state");
+ let signer_identity_path = temp.path().join("signer.json");
+ let user_identity_path = temp.path().join("user.json");
+ let app_identity_path = temp.path().join("app.json");
+ let app_identity = RadrootsIdentity::from_secret_key_str(
+ "3333333333333333333333333333333333333333333333333333333333333333",
+ )?;
+
+ write_identity(
+ &signer_identity_path,
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ );
+ write_identity(
+ &user_identity_path,
+ "2222222222222222222222222222222222222222222222222222222222222222",
+ );
+ app_identity.save_json(&app_identity_path)?;
+ write_config(
+ &config_path,
+ &state_dir,
+ &signer_identity_path,
+ &user_identity_path,
+ &app_identity_path,
+ &[relay.url(), unavailable_relay.as_str()],
+ );
+
+ let inspect = run_myc(&config_path, &["discovery", "inspect-live-nip89"])?;
+ assert!(
+ inspect.status.success(),
+ "inspect-live-nip89 failed: {}",
+ String::from_utf8_lossy(&inspect.stderr)
+ );
+ let inspect_output: Value = serde_json::from_slice(&inspect.stdout)?;
+ assert_eq!(inspect_output["live_groups"].as_array().unwrap().len(), 0);
+ assert_eq!(inspect_output["relay_states"].as_array().unwrap().len(), 2);
+ assert!(
+ inspect_output["relay_states"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .any(|relay_state| {
+ relay_state["relay_url"] == Value::String(unavailable_relay.clone())
+ && relay_state["fetch_status"] == Value::String("unavailable".to_owned())
+ && relay_state["live_status"].is_null()
+ && relay_state["fetch_error"].is_string()
+ })
+ );
+
+ let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?;
+ assert!(
+ !refresh.status.success(),
+ "refresh-nip89 unexpectedly succeeded: {}",
+ String::from_utf8_lossy(&refresh.stdout)
+ );
+ assert!(
+ String::from_utf8_lossy(&refresh.stderr).contains("unavailable"),
+ "unexpected refresh stderr: {}",
+ String::from_utf8_lossy(&refresh.stderr)
+ );
+
+ let forced_refresh = run_myc(&config_path, &["discovery", "refresh-nip89", "--force"])?;
+ assert!(
+ forced_refresh.status.success(),
+ "refresh-nip89 --force failed: {}",
+ String::from_utf8_lossy(&forced_refresh.stderr)
+ );
+ let forced_refresh_output: Value = serde_json::from_slice(&forced_refresh.stdout)?;
+ assert_eq!(forced_refresh_output["status"], "missing");
+ assert_eq!(
+ forced_refresh_output["relay_summary"]["unavailable_relays"],
+ Value::Array(vec![Value::String(unavailable_relay.clone())])
+ );
+ assert!(forced_refresh_output["published"].is_object());
Ok(())
}
diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs
@@ -1,4 +1,5 @@
use std::collections::{HashMap, VecDeque};
+use std::net::TcpListener as StdTcpListener;
use std::sync::Arc;
use std::time::Duration;
@@ -6,8 +7,9 @@ use futures_util::{SinkExt, StreamExt};
use myc::control;
use myc::{
MycConfig, MycConnectionApproval, MycDiscoveryContext, MycDiscoveryLiveStatus,
- MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, MycRuntime,
- diff_live_nip89, fetch_live_nip89, publish_nip89_event, refresh_nip89,
+ MycDiscoveryRelayFetchStatus, MycOperationAuditKind, MycOperationAuditOutcome,
+ MycOperationAuditRecord, MycRuntime, diff_live_nip89, fetch_live_nip89, publish_nip89_event,
+ refresh_nip89,
};
use nostr::filter::MatchEventOptions;
use nostr::nips::nip44;
@@ -416,6 +418,13 @@ fn identity(secret_key: &str) -> RadrootsIdentity {
RadrootsIdentity::from_secret_key_str(secret_key).expect("identity")
}
+fn unavailable_relay_url() -> TestResult<String> {
+ let listener = StdTcpListener::bind("127.0.0.1:0")?;
+ let addr = listener.local_addr()?;
+ drop(listener);
+ Ok(format!("ws://{addr}"))
+}
+
async fn publish_handler_event(
relay_url: &str,
identity: &RadrootsIdentity,
@@ -1129,6 +1138,43 @@ async fn fetch_live_nip89_reports_missing_when_handler_is_unpublished() -> TestR
assert_eq!(output.handler_identifier, "myc");
assert_eq!(output.publish_relays, vec![relay.url().to_owned()]);
assert!(output.live_groups.is_empty());
+ assert_eq!(output.relay_states.len(), 1);
+ assert_eq!(
+ output.relay_states[0].fetch_status,
+ MycDiscoveryRelayFetchStatus::Available
+ );
+
+ Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn fetch_live_nip89_fails_when_all_discovery_relays_are_unavailable() -> TestResult<()> {
+ let unavailable_a = unavailable_relay_url()?;
+ let unavailable_b = unavailable_relay_url()?;
+ let test_runtime = MycTestRuntime::new_with_discovery_relays(
+ &[unavailable_a.as_str(), unavailable_b.as_str()],
+ MycConnectionApproval::ExplicitUser,
+ );
+
+ let error = fetch_live_nip89(&test_runtime.runtime)
+ .await
+ .expect_err("all-unavailable discovery fetch should fail");
+ assert!(
+ error
+ .to_string()
+ .contains("failed to fetch discovery state from all configured relays")
+ );
+
+ let audit = wait_for_operation_audit_count(&test_runtime.runtime, 1).await?;
+ assert_eq!(
+ audit[0].operation,
+ MycOperationAuditKind::DiscoveryHandlerFetch
+ );
+ assert_eq!(audit[0].outcome, MycOperationAuditOutcome::Unavailable);
+ assert_eq!(audit[0].relay_count, 2);
+ assert_eq!(audit[0].acknowledged_relay_count, 0);
+ assert!(audit[0].relay_outcome_summary.contains(&unavailable_a));
+ assert!(audit[0].relay_outcome_summary.contains(&unavailable_b));
Ok(())
}
@@ -1410,6 +1456,7 @@ async fn diff_live_nip89_surfaces_relay_divergence_with_provenance() -> TestResu
diff.relay_summary.drifted_relays,
vec![relay_b.url().to_owned()]
);
+ assert!(diff.relay_summary.unavailable_relays.is_empty());
assert!(diff.relay_summary.missing_relays.is_empty());
assert!(diff.relay_summary.conflicted_relays.is_empty());
@@ -1418,7 +1465,14 @@ async fn diff_live_nip89_surfaces_relay_divergence_with_provenance() -> TestResu
.iter()
.find(|relay_state| relay_state.relay_url == relay_a.url())
.expect("matched relay");
- assert_eq!(matched_relay.status, MycDiscoveryLiveStatus::Matched);
+ assert_eq!(
+ matched_relay.fetch_status,
+ MycDiscoveryRelayFetchStatus::Available
+ );
+ assert_eq!(
+ matched_relay.live_status,
+ Some(MycDiscoveryLiveStatus::Matched)
+ );
assert_eq!(matched_relay.live_groups.len(), 1);
assert_eq!(
matched_relay.live_groups[0].source_relays,
@@ -1430,7 +1484,14 @@ async fn diff_live_nip89_surfaces_relay_divergence_with_provenance() -> TestResu
.iter()
.find(|relay_state| relay_state.relay_url == relay_b.url())
.expect("drifted relay");
- assert_eq!(drifted_relay.status, MycDiscoveryLiveStatus::Drifted);
+ assert_eq!(
+ drifted_relay.fetch_status,
+ MycDiscoveryRelayFetchStatus::Available
+ );
+ assert_eq!(
+ drifted_relay.live_status,
+ Some(MycDiscoveryLiveStatus::Drifted)
+ );
assert_eq!(drifted_relay.live_groups.len(), 1);
assert_eq!(
drifted_relay.live_groups[0].source_relays,
@@ -1449,6 +1510,88 @@ async fn diff_live_nip89_surfaces_relay_divergence_with_provenance() -> TestResu
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn refresh_nip89_requires_force_when_any_discovery_relay_is_unavailable() -> TestResult<()> {
+ let relay = TestRelay::spawn().await?;
+ let unavailable_relay = unavailable_relay_url()?;
+ let test_runtime = MycTestRuntime::new_with_discovery_relays(
+ &[relay.url(), unavailable_relay.as_str()],
+ MycConnectionApproval::ExplicitUser,
+ );
+ let runtime = test_runtime.runtime;
+ let app_identity = RadrootsIdentity::load_from_path_auto(
+ runtime
+ .config()
+ .discovery
+ .app_identity_path
+ .as_ref()
+ .expect("app identity path"),
+ )?;
+
+ let diff = diff_live_nip89(&runtime).await?;
+ assert_eq!(diff.status, MycDiscoveryLiveStatus::Missing);
+ assert_eq!(
+ diff.relay_summary.unavailable_relays,
+ vec![unavailable_relay.clone()]
+ );
+ assert_eq!(
+ diff.relay_summary.missing_relays,
+ vec![relay.url().to_owned()]
+ );
+
+ let unavailable_state = diff
+ .relay_states
+ .iter()
+ .find(|relay_state| relay_state.relay_url == unavailable_relay)
+ .expect("unavailable relay");
+ assert_eq!(
+ unavailable_state.fetch_status,
+ MycDiscoveryRelayFetchStatus::Unavailable
+ );
+ assert_eq!(unavailable_state.live_status, None);
+ assert!(unavailable_state.fetch_error.is_some());
+
+ let error = refresh_nip89(&runtime, false)
+ .await
+ .expect_err("refresh without force should fail when a relay is unavailable");
+ assert!(error.to_string().contains("unavailable"));
+
+ let audit = wait_for_operation_audit_count(&runtime, 4).await?;
+ assert_eq!(
+ audit[0].operation,
+ MycOperationAuditKind::DiscoveryHandlerFetch
+ );
+ assert_eq!(audit[0].outcome, MycOperationAuditOutcome::Unavailable);
+ assert_eq!(
+ audit[1].operation,
+ MycOperationAuditKind::DiscoveryHandlerFetch
+ );
+ assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Unavailable);
+ assert_eq!(
+ audit[2].operation,
+ MycOperationAuditKind::DiscoveryHandlerCompare
+ );
+ assert_eq!(audit[2].outcome, MycOperationAuditOutcome::Missing);
+ assert_eq!(
+ audit[3].operation,
+ MycOperationAuditKind::DiscoveryHandlerRefresh
+ );
+ assert_eq!(audit[3].outcome, MycOperationAuditOutcome::Unavailable);
+
+ relay
+ .queue_publish_outcomes(app_identity.public_key(), &[true])
+ .await;
+ let refreshed = refresh_nip89(&runtime, true).await?;
+ assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Missing);
+ assert_eq!(
+ refreshed.relay_summary.unavailable_relays,
+ vec![unavailable_relay.clone()]
+ );
+ assert!(refreshed.published.is_some());
+
+ Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn refresh_nip89_requires_force_when_live_handler_is_conflicted() -> TestResult<()> {
let relay = TestRelay::spawn().await?;
let test_runtime =