From 7661aa145caf452fdb3552401dafadd532d7d8af Mon Sep 17 00:00:00 2001 From: Akeem Jenkins Date: Sat, 8 Aug 2026 11:33:46 -0600 Subject: [PATCH] fix(desktop): explain the pairing 404 instead of surfacing a bare transport error Closes #3779. When a NIP-43 relay advertises no pairing_relay_url, the desktop falls back to appending /pair to the relay's own host. If nothing serves that path the QR panel showed only: WebSocket connection failed: HTTP error: 404 Not Found which names neither the URL that was dialled, nor the branch that chose it, nor either lever that fixes it. Self-hosters on #2734 had to read config.rs and pairing.rs to get unstuck. The route is already a typed enum, but it collapsed to a String before the task that reports the failure could see it. PairingRelay is now Clone and travels into PairingTaskContext, so the connect error can be described in terms of the decision that produced the URL. A 404 on the legacy branch now names the URL, says the relay advertises NIP-43 without a pairing_relay_url, and points at both fixes (BUZZ_PAIRING_RELAY_URL, or routing /pair to a buzz-pair-relay). Every other failure keeps the original transport error and gains the attempted URL, which is what the split-domain case needs when a configured pairing_relay_url is simply wrong. A 502 from a routed /pair whose sidecar is down must not claim nothing is serving the path, and a test pins that. Co-Authored-By: Claude Opus 5 Signed-off-by: Akeem Jenkins --- desktop/src-tauri/src/commands/pairing.rs | 53 +++++++++++++- .../src/commands/pairing_relay_tests.rs | 73 ++++++++++++++++++- 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index aedd67854c..1d4e4ec18b 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -42,6 +42,9 @@ enum PairingMode { #[derive(Clone)] struct PairingTaskContext { mode: PairingMode, + /// Which NIP-11 branch produced the pairing URL. Carried purely so a + /// failed handshake can say why it dialled where it did. + route: PairingRelay, generation: Arc, generation_fence: Arc>, task_generation: u64, @@ -129,7 +132,8 @@ async fn start_pairing_session( let ws_url = relay_ws_url_with_override(&state); let http_url = relay_api_base_url_with_override(&state); - let pairing_relay_url = resolve_pairing_relay_url(&ws_url, probe_pairing_relay(&ws_url).await)?; + let pairing_route = probe_pairing_relay(&ws_url).await; + let pairing_relay_url = resolve_pairing_relay_url(&ws_url, pairing_route.clone())?; let (session, qr_payload) = PairingSession::new_source(pairing_relay_url.clone()); let mut qr_uri = encode_qr(&qr_payload); if mode == PairingMode::RecoverIdentity { @@ -166,6 +170,7 @@ async fn start_pairing_session( Arc::clone(&pairing.session), PairingTaskContext { mode, + route: pairing_route, generation: Arc::clone(&pairing.generation), generation_fence: Arc::clone(&pairing.generation_fence), task_generation, @@ -305,7 +310,7 @@ async fn pairing_ws_task_inner( ) -> Result<(), String> { let (ws, _) = connect_async(relay_url) .await - .map_err(|e| format!("WebSocket connection failed: {e}"))?; + .map_err(|e| describe_connect_failure(&context.route, relay_url, &e))?; let (mut write, mut read) = ws.split(); handle_nip42_auth(&mut read, &mut write, session, relay_url).await?; @@ -663,8 +668,50 @@ fn parse_relay_event(text: &str, sub_id: &str) -> Option { serde_json::from_value(arr[2].clone()).ok() } +/// Explain a failed pairing handshake in terms of the route that produced the +/// URL, rather than as a bare transport error. +/// +/// The 404 case is the one worth spelling out. A relay that enforces +/// membership advertises NIP-43, and if it advertises no `pairing_relay_url` +/// then [`resolve_pairing_relay_url`] appends `/pair` to the relay's own host +/// — an address chosen from an *advertisement*, never probed. When nothing +/// serves it the user saw only `WebSocket connection failed: HTTP error: 404 +/// Not Found`, which names neither the URL that was dialled, nor the branch +/// that chose it, nor either lever that fixes it. Self-hosters have had to +/// read `config.rs` and this file to get unstuck. +/// +/// Every other failure keeps the original transport error and gains the +/// attempted URL, which is what the split-domain case needs when a configured +/// `pairing_relay_url` is simply wrong. +fn describe_connect_failure( + route: &PairingRelay, + url: &str, + error: &tokio_tungstenite::tungstenite::Error, +) -> String { + let not_found = matches!( + error, + tokio_tungstenite::tungstenite::Error::Http(response) if response.status().as_u16() == 404 + ); + + if not_found && matches!(route, PairingRelay::LegacyPath) { + return format!( + "Pairing endpoint {url} not found (404). This relay advertises NIP-43 but no \ + pairing_relay_url, so pairing fell back to {url}, and nothing is serving that \ + path. The relay operator needs to set BUZZ_PAIRING_RELAY_URL on the relay, or \ + route /pair to a buzz-pair-relay instance (see deploy/compose/README.md, \ + \"Device pairing\")." + ); + } + + format!("WebSocket connection failed: {error} (pairing endpoint: {url})") +} + /// Pairing route discovered from the main relay's NIP-11 document. -#[derive(Debug, PartialEq, Eq)] +/// +/// `Clone` so the decision can be carried into the WebSocket task alongside +/// the URL it produced: which branch was taken is most of what makes a failed +/// handshake diagnosable, and it is lost the moment this collapses to a String. +#[derive(Debug, Clone, PartialEq, Eq)] enum PairingRelay { Configured(String), LegacyPath, diff --git a/desktop/src-tauri/src/commands/pairing_relay_tests.rs b/desktop/src-tauri/src/commands/pairing_relay_tests.rs index f0e765eb9c..64d3c2d848 100644 --- a/desktop/src-tauri/src/commands/pairing_relay_tests.rs +++ b/desktop/src-tauri/src/commands/pairing_relay_tests.rs @@ -1,5 +1,6 @@ use super::{ - pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay, + describe_connect_failure, pairing_relay_from_nip11, probe_pairing_relay, + resolve_pairing_relay_url, PairingRelay, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -102,3 +103,73 @@ fn main_relay_pairing_uses_main_relay_url() { assert_eq!(resolved, "wss://sprout-oss.stage.blox.sqprod.co"); } + +/// Build the transport error tungstenite hands back when the upgrade request +/// is answered with an HTTP status instead of a 101. +fn http_error(status: u16) -> tokio_tungstenite::tungstenite::Error { + let response = tokio_tungstenite::tungstenite::http::Response::builder() + .status(status) + .body(None) + .expect("build HTTP error response"); + tokio_tungstenite::tungstenite::Error::Http(Box::new(response)) +} + +#[test] +fn legacy_path_404_explains_the_missing_pairing_endpoint() { + let message = describe_connect_failure( + &PairingRelay::LegacyPath, + "wss://relay.example.com/pair", + &http_error(404), + ); + + // The URL that was actually dialled, because the old message named none. + assert!(message.contains("wss://relay.example.com/pair")); + // Why this URL was chosen at all. + assert!(message.contains("NIP-43")); + assert!(message.contains("pairing_relay_url")); + // Both levers that fix it: neither was discoverable without a source dive. + assert!(message.contains("BUZZ_PAIRING_RELAY_URL")); + assert!(message.contains("buzz-pair-relay")); +} + +#[test] +fn legacy_path_non_404_keeps_the_transport_error_and_gains_the_url() { + // A routed /pair whose sidecar is down answers 502, which is a different + // problem with a different fix, so it must not claim nothing is serving + // the path. + let message = describe_connect_failure( + &PairingRelay::LegacyPath, + "wss://relay.example.com/pair", + &http_error(502), + ); + + assert!(message.contains("wss://relay.example.com/pair")); + assert!(!message.contains("BUZZ_PAIRING_RELAY_URL")); +} + +#[test] +fn configured_pairing_relay_404_reports_the_url_it_was_given() { + // The split-domain case: the operator advertised a pairing_relay_url and + // it is wrong. Naming it is the whole fix; guessing at /pair advice here + // would point at a file that has nothing to do with the failure. + let message = describe_connect_failure( + &PairingRelay::Configured("wss://pairing.example.com".to_string()), + "wss://pairing.example.com", + &http_error(404), + ); + + assert!(message.contains("wss://pairing.example.com")); + assert!(!message.contains("BUZZ_PAIRING_RELAY_URL")); +} + +#[test] +fn main_relay_failure_keeps_the_transport_error() { + let message = describe_connect_failure( + &PairingRelay::MainRelay, + "wss://relay.example.com", + &http_error(500), + ); + + assert!(message.starts_with("WebSocket connection failed:")); + assert!(message.contains("wss://relay.example.com")); +}