From 6a3b46b5a9cf758f619f74434c327cafa4725394 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 22 Jul 2026 19:44:32 -0400 Subject: [PATCH] =?UTF-8?q?fix(handshake):=20peer=20requests=20actually=20?= =?UTF-8?q?arrive=20=E2=80=94=20background=20poll,=20unified=20relays,=20r?= =?UTF-8?q?eal=20send=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three silent failure modes killed nostr peer requests (analyzed from the live code 2026-07-22): (1) nothing ever polled the relays except the manual Federation Poll button — a 5-minute background poll now fetches inbound requests and nudges the websocket when something lands (the handler's discoverability gate still applies); (2) handshake send/poll used only the two hardcoded config relays (one defunct) and ignored the user-managed relay list — every nostr op now uses the merged set; (3) publish errors were swallowed (let _ =) so 'ok' was reported even when no relay accepted the event — now a delivery failure is an error the UI shows. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/api/rpc/handshake.rs | 55 +++++++++++++++++++++-- core/archipelago/src/nostr_handshake.rs | 14 +++++- core/archipelago/src/nostr_relays.rs | 15 +++++++ core/archipelago/src/server.rs | 25 ++++++++++- 4 files changed, 102 insertions(+), 7 deletions(-) diff --git a/core/archipelago/src/api/rpc/handshake.rs b/core/archipelago/src/api/rpc/handshake.rs index 98e9fa31..d1275b95 100644 --- a/core/archipelago/src/api/rpc/handshake.rs +++ b/core/archipelago/src/api/rpc/handshake.rs @@ -86,7 +86,7 @@ impl RpcHandler { let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey) .unwrap_or_default(); let version = data.server_info.version.clone(); - let relays = self.config.nostr_relays.clone(); + let relays = self.handshake_relays().await; let tor_proxy = self.config.nostr_tor_proxy.clone(); tokio::spawn(async move { if let Err(e) = nostr_handshake::publish_presence( @@ -106,6 +106,17 @@ impl RpcHandler { Ok(serde_json::json!({ "enabled": enabled })) } + /// The relay set every handshake operation uses: the user-managed relay + /// list (Settings → Relays, `nostr_relays.json`) merged with the config + /// defaults. Before 2026-07-22 handshake send/poll used ONLY the two + /// hardcoded config relays (one of which is defunct) and ignored user + /// relay edits entirely — so a sender publishing where the receiver + /// never read was a routine, silent way for peer requests to vanish. + pub(super) async fn handshake_relays(&self) -> Vec { + crate::nostr_relays::merged_relay_list(&self.config.data_dir, &self.config.nostr_relays) + .await + } + /// Discover discoverable nodes via Nostr presence events. /// Returns (nostr_pubkey, npub, DID, version) only — never an onion. pub(super) async fn handle_handshake_discover(&self) -> Result { @@ -113,9 +124,10 @@ impl RpcHandler { // to query relays as long as the user is actively browsing — they're // an anonymous observer of presence events, not publishing anything. let identity_dir = self.config.data_dir.join("identity"); + let relays = self.handshake_relays().await; let nodes = nostr_handshake::discover_nodes( &identity_dir, - &self.config.nostr_relays, + &relays, self.config.nostr_tor_proxy.as_deref(), ) .await?; @@ -161,7 +173,7 @@ impl RpcHandler { our_version, our_name, message, - &self.config.nostr_relays, + &self.handshake_relays().await, self.config.nostr_tor_proxy.as_deref(), ) .await?; @@ -191,6 +203,40 @@ impl RpcHandler { /// - `PeerReject` → mark matching outbound row as `Rejected` /// /// Never auto-adds peers, never auto-responds, never sends our onion. + /// Background relay poll (2026-07-22): before this, `handshake.poll` ran + /// ONLY when a user opened Federation and pressed the Poll button — a + /// peer request sat on the relay until the target's operator happened to + /// click, i.e. for most nodes forever ("requests never arrive"). Runs the + /// same poll+dispatch as the RPC (the disabled gate inside still applies) + /// and nudges the websocket revision when anything new lands so open UIs + /// refresh immediately. + pub async fn background_handshake_poll(self: &std::sync::Arc) { + match self.handle_handshake_poll().await { + Ok(res) => { + let new = res + .get("new_requests") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let applied = res + .get("applied_invites") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + if new > 0 || applied > 0 { + tracing::info!( + new_requests = new, + applied_invites = applied, + "handshake poll: inbound peer activity" + ); + let (data, _) = self.state_manager.get_snapshot().await; + self.state_manager.update_data(data).await; + } + } + Err(e) => tracing::debug!("background handshake poll failed: {e:#}"), + } + } + pub(super) async fn handle_handshake_poll(&self) -> Result { // Runtime gate: if the user hasn't enabled discoverability, don't // touch the relays. The poll endpoint is a hard no-op until they @@ -207,9 +253,10 @@ impl RpcHandler { })); } let identity_dir = self.config.data_dir.join("identity"); + let relays = self.handshake_relays().await; let handshakes = nostr_handshake::poll_handshakes( &identity_dir, - &self.config.nostr_relays, + &relays, self.config.nostr_tor_proxy.as_deref(), None, ) diff --git a/core/archipelago/src/nostr_handshake.rs b/core/archipelago/src/nostr_handshake.rs index 69f1a708..4d0a2edf 100644 --- a/core/archipelago/src/nostr_handshake.rs +++ b/core/archipelago/src/nostr_handshake.rs @@ -307,9 +307,19 @@ async fn send_handshake_message( let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted) .tag(Tag::public_key(recipient_pk)); - let _ = client.send_event_builder(builder).await; + // Surface real delivery failures. Before 2026-07-22 this was `let _ =`, + // so a request no relay accepted still reported ok:true to the UI and + // the sender believed it was delivered. + let send = client.send_event_builder(builder).await; client.disconnect().await; - Ok(()) + match send { + Ok(output) if !output.success.is_empty() => Ok(()), + Ok(output) => anyhow::bail!( + "no relay accepted the handshake event (tried {}, all failed)", + output.failed.len() + ), + Err(e) => anyhow::bail!("failed to publish handshake event: {e}"), + } } /// Send a `PeerRequest` to a discovered node's nostr pubkey. We never diff --git a/core/archipelago/src/nostr_relays.rs b/core/archipelago/src/nostr_relays.rs index 5a594019..dab7a190 100644 --- a/core/archipelago/src/nostr_relays.rs +++ b/core/archipelago/src/nostr_relays.rs @@ -47,6 +47,21 @@ const DEFAULT_RELAYS: &[&str] = &[ "wss://relay.current.fyi", ]; +/// The union of the boot-config relay list and the user-managed (enabled) +/// relays — the set every nostr-facing operation should use, so senders and +/// receivers always overlap regardless of which list the operator edited. +pub async fn merged_relay_list(data_dir: &Path, config_relays: &[String]) -> Vec { + let mut relays: Vec = config_relays.to_vec(); + if let Ok(store) = load_relays(data_dir).await { + for r in store.relays { + if r.enabled && !relays.contains(&r.url) { + relays.push(r.url); + } + } + } + relays +} + pub async fn load_relays(data_dir: &Path) -> Result { let path = data_dir.join(RELAYS_FILE); if !path.exists() { diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index a4e57433..973ef6ed 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -209,9 +209,15 @@ impl Server { let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default(); let version = data.server_info.version.clone(); - let relays = config.nostr_relays.clone(); + // Merged relay set (config + user-managed) — publish presence + // where handshake peers actually read (2026-07-22 unification). + let data_dir_for_relays = config.data_dir.clone(); + let config_relays = config.nostr_relays.clone(); let tor_proxy = config.nostr_tor_proxy.clone(); tokio::spawn(async move { + let relays = + crate::nostr_relays::merged_relay_list(&data_dir_for_relays, &config_relays) + .await; if let Err(e) = nostr_handshake::publish_presence( &identity_dir, &did, @@ -253,6 +259,23 @@ impl Server { .await?, ); + // Background handshake poll: fetch inbound nostr peer requests every + // 5 minutes instead of only when a user presses the Federation Poll + // button (requests used to sit on relays unseen — 2026-07-22). The + // handler's own discoverability gate makes this a no-op until the + // user opts in. + { + let rpc = api_handler.rpc_handler().clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(300)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tick.tick().await; + rpc.background_handshake_poll().await; + } + }); + } + // Initialize mesh networking service (if config has enabled: true) { let data_dir = config.data_dir.clone();