diff --git a/.prettierignore b/.prettierignore index b7acd7b35..da483e25b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -38,3 +38,10 @@ reticulum-sidecar/ *.gif *.svg *.webp + +# Fonts (binary; no Prettier parser) +*.woff +*.woff2 +*.ttf +*.otf +*.eot diff --git a/docs/credits.md b/docs/credits.md index 22b29e997..4ccf2911d 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -36,6 +36,12 @@ We were inspired by features from these projects: | ----------------------- | -------- | ---------------------------------------------------------------------------------------- | | `mesh-client-reticulum` | AGPL-3.0 | Spawned Reticulum/LXMF sidecar (separate process; see [docs/reticulum.md](reticulum.md)) | +### Bundled fonts + +| Font / file | License | Role | +| ------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------- | +| `MeshClientNomadMono.woff2` (JetBrains Mono Nerd Font Mono, subset) | OFL-1.1 | Nomad Micron viewer monospace + Nerd/FA PUA icons ([OFL](../src/renderer/assets/fonts/OFL-JetBrainsMonoNerdFont.txt)) | + Exact semver ranges live in [`package.json`](https://github.com/Colorado-Mesh/mesh-client/blob/main/package.json) at the repository root; the tables below mirror them for attribution. ### Runtime dependencies diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index cd0ddb243..54fd7d309 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1034,7 +1034,7 @@ In dev, **Start stack** now rebuilds when `reticulum-sidecar/src/**/*.rs` or `Ca Unrecognized codes pass through unchanged. -TCP/network Nomad Links use path-scaled proof budgets (`link_hops = clamp(path_hops, 3, 7)` → ~18–42s), matching released v5.25.0 multi-hop behavior more closely than #756’s flat ~18s cap (a HEAD regression for slower hub peers). First attempts use a cached path when present (no DropPath storm); missing paths RequestPath briefly and may return `path_timeout`. Retries may DropPath + rediscover; `force_path_ok=true` means rediscovered after absence only (cache hits log `force_path_ok=false`). Failure logs (`[nomadNetworkStore] … fetch failed` and sidecar `Nomad Link query failed`) include `path_hops`, `link_hops`, `proof_budget_secs`, `force_path_ok`, `path_ensure`, `elapsed_ms`, and `raw=`. UI errors distinguish cached-path vs rediscovered-path link failures. +TCP/network Nomad Links use path-scaled initiator hops (`link_hops = clamp(path_hops, 3, 7)`) and a LinkClient proof wait of the **remaining overall MeshChat deadline** (~45s TCP after instant pubkey recall), matching v5.25.0. Do not cap LRPROOF at hops×6 or a 30s floor — that false-failed multi-hop hub pages that still load on release. First attempts use a cached path when present (no DropPath storm); missing paths RequestPath briefly and may return `path_timeout`. Retries may DropPath + rediscover; `force_path_ok=true` means rediscovered after absence only (cache hits log `force_path_ok=false`). Failure logs (`[nomadNetworkStore] … fetch failed` and sidecar `Nomad Link query failed`) include `path_hops`, `link_hops`, `proof_budget_secs`, `force_path_ok`, `path_ensure_kind`, `elapsed_ms`, and `raw=`. UI errors distinguish cached-path vs rediscovered-path link failures. **Cause**: Older `LinkClient` always waited for a fresh path-response announce for the destination public key, even when Nomad announces had already cached it. Successful fetches could also deregister all `nomadnetwork.node` announce handlers. Distant/high-hop nodes can still time out at the path stage (expected RF/mesh reachability limits). diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index 15cf48cd4..dea6ea6b0 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -164,7 +164,7 @@ When [ratspeak/rsReticulum#14](https://github.com/ratspeak/rsReticulum/pull/14) ## rsReticulum-link-client-proof-budget.patch -Cap `LinkClient::query` proof wait at `link.establishment_timeout` so a cached path cannot burn the entire overall Nomad deadline (MeshChat TCP link stage ~15s). Apply **after** the LinkClient Nomad overlay. +Keep `LinkClient::query` proof wait on the **remaining overall deadline** (v5.25.0 / release parity). Earlier overlays capped at `establishment_timeout` (hops×6) or `max(establishment, 30s)` and false-failed multi-hop TCP hub Nomad pages (e.g. Northern Ireland) that need the rest of the MeshChat 45s window. Apply **after** the LinkClient Nomad overlay; the apply script migrates those older caps to remaining-deadline. | Field | Value | | ----- | ----- | diff --git a/reticulum-sidecar/patches/rsReticulum-link-client-proof-budget.patch b/reticulum-sidecar/patches/rsReticulum-link-client-proof-budget.patch index 8c312cd6d..22fc6e271 100644 --- a/reticulum-sidecar/patches/rsReticulum-link-client-proof-budget.patch +++ b/reticulum-sidecar/patches/rsReticulum-link-client-proof-budget.patch @@ -6,10 +6,10 @@ diff --git a/crates/rns-runtime/src/link_client.rs b/crates/rns-runtime/src/link .await?; - let proof_data = wait_for_proof(&mut dest_rx, link_id, time_remaining(deadline)?).await?; -+ // Cap proof wait at link establishment timeout (6s × hops). Otherwise a -+ // cached path lets wait_for_proof burn the entire overall deadline -+ // (e.g. TCP 45s) even when MeshChat would fail the link stage in ~15s. -+ let proof_budget = time_remaining(deadline)?.min(link.establishment_timeout); ++ // Release / v5.25.0 parity: use remaining overall deadline for LRPROOF. ++ // Do not cap at establishment (hops×6) or a 30s floor — that false-failed ++ // multi-hop TCP hub Nomad pages that need the rest of the MeshChat 45s window. ++ let proof_budget = time_remaining(deadline)?; + let proof_data = wait_for_proof(&mut dest_rx, link_id, proof_budget).await?; let identity_ed25519_pub: [u8; 32] = pubkey[32..64].try_into().map_err(|_| { diff --git a/reticulum-sidecar/src/api/nomad.rs b/reticulum-sidecar/src/api/nomad.rs index c00466cba..6bf4f5a5e 100644 --- a/reticulum-sidecar/src/api/nomad.rs +++ b/reticulum-sidecar/src/api/nomad.rs @@ -37,6 +37,8 @@ pub struct NomadPageQuery { /// When true, RequestPath even if a cached path exists (stale-route retry). #[serde(default)] pub force_path_refresh: bool, + /// Client correlation id echoed on `nomad.page_progress` WS events. + pub request_id: Option, } pub async fn get_nomad_page( @@ -51,6 +53,7 @@ pub async fn get_nomad_page( &query.path, query.data.as_deref(), query.force_path_refresh, + query.request_id.as_deref(), ) .await, ) @@ -218,6 +221,7 @@ mod force_path_refresh_query_tests { let q: NomadPageQuery = serde_urlencoded::from_str("path=%2Fpage%2Findex.mu").expect("query"); assert!(!q.force_path_refresh); + assert!(q.request_id.is_none()); assert_eq!(q.path, "/page/index.mu"); } @@ -229,6 +233,14 @@ mod force_path_refresh_query_tests { assert!(q.force_path_refresh); } + #[test] + fn nomad_page_query_parses_request_id() { + let q: NomadPageQuery = + serde_urlencoded::from_str("path=%2Fpage%2Findex.mu&request_id=load-42") + .expect("query"); + assert_eq!(q.request_id.as_deref(), Some("load-42")); + } + #[test] fn nomad_file_query_parses_force_path_refresh_true() { let q: NomadFileQuery = diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index f83d193d4..9f72c4a8e 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -844,6 +844,7 @@ impl LiveBridge { /// destination on our side. /// Returns page/file bytes plus the egress atom and overall timeout used for the Link. /// Remote errors after egress is known include that atom so the UI countdown can update. + #[allow(clippy::too_many_arguments)] // path ensure + progress correlation travel with Link args async fn query_nomad_node( &self, hash_hex: &str, @@ -852,6 +853,7 @@ impl LiveBridge { payload: Vec, interfaces: &[InterfaceRow], force_path_refresh: bool, + progress_request_id: Option<&str>, ) -> Result<(Vec, NomadRemoteQueryOk), NomadRemoteQueryError> { let query_started = tokio::time::Instant::now(); let remote_hash = parse_hash16(identity_hash_hex).map_err(|e| NomadRemoteQueryError { @@ -864,6 +866,7 @@ impl LiveBridge { path_ensure_kind: None, raw_error: None, elapsed_ms: None, + tried_interfaces: None, })?; // Prefer path-peer cache (maintenance refreshes every ~2s). Avoid a synchronous // GetPathTable here — that control query alone can stall TCP page loads for seconds. @@ -886,8 +889,8 @@ impl LiveBridge { // Release-like: do not DropPath on every first TCP load (causes storms and // did not predict LRPROOF). Force refresh only on retry; on first attempt // only RequestPath when the path table has no row yet. - let force_path_ok: Option; - let path_ensure_kind: Option<&'static str>; + let mut force_path_ok: Option; + let mut path_ensure_kind: Option<&'static str>; if force_path_refresh { let report = self .ensure_path_for_direct_with_opts( @@ -965,15 +968,16 @@ impl LiveBridge { path_ensure_kind, raw_error: Some(format!("path ensure kind={kind} (no cached path)")), elapsed_ms: Some(elapsed_ms_since(query_started)), + tried_interfaces: None, }); } } } - let hops = match cached_hops { + let mut hops = match cached_hops { Some(h) => h, None => self.hops_to_destination(hash_hex).await.unwrap_or(8), }; - let (timeout_secs, egress) = nomad_timeouts::resolve_nomad_page_timeout_secs( + let (mut timeout_secs, mut egress) = nomad_timeouts::resolve_nomad_page_timeout_secs( interfaces, hops, path_iface.as_deref(), @@ -990,10 +994,11 @@ impl LiveBridge { path_ensure_kind, raw_error: None, elapsed_ms: Some(elapsed_ms_since(query_started)), + tried_interfaces: None, }); } - let link_hops = nomad_timeouts::nomad_link_initiator_hops(egress, hops); - let proof_budget_secs = u64::from(link_hops).saturating_mul(6); + let mut link_hops = nomad_timeouts::nomad_link_initiator_hops(egress, hops); + let mut proof_budget_secs = nomad_timeouts::nomad_link_proof_budget_secs(timeout_secs); // Announce destination (URL/path-table) vs LinkClient dest from identity+aspect. let link_dest_hex = hex::encode(Destination::hash_from_name_and_identity( NOMAD_NODE_ASPECT, @@ -1018,21 +1023,248 @@ impl LiveBridge { path_ensure_kind = ?path_ensure_kind, "Nomad Link query start" ); - // Preempt the prior Link query so switching Nomad nodes does not wait - // for the full TCP/RF deadline (or crash the HTTP client mid-query). - let my_gen = self + let path_slots_snapshot = self + .path_slots(hash_hex) + .await + .map(|(slots, _)| slots) + .unwrap_or_default(); + + let mut current_iface = path_iface.clone(); + let mut current_via = active_via_hash_from_slots(&path_slots_snapshot); + // One generation for this page request + all via failovers. Bumping per + // Link attempt would cancel a newer request when the older one retries. + let link_gen = self .nomad_link_generation .fetch_add(1, Ordering::SeqCst) .wrapping_add(1); + self.emit_nomad_page_progress( + hash_hex, + path, + "link_attempt", + progress_request_id, + serde_json::json!({ + "round": 0, + "iface": current_iface, + "via_prefix": nomad_via_prefix(current_via.as_deref()), + "hops": hops, + "timeout_secs": timeout_secs, + }), + ); + + let mut result = self + .nomad_link_client_query( + remote_hash, + path, + payload.clone(), + hops, + link_hops, + timeout_secs, + egress, + force_path_ok, + path_ensure_kind, + link_gen, + ) + .await; + + // Dead next-hops often reappear on another local iface (same via_hash). + // Suppress the failed iface, DropAllVia that hop, and retry on a truly + // different via — up to two failovers inside one page fetch. + let mut failover_round: u8 = 0; + let mut blocked_ifaces: Vec = Vec::new(); + let mut blocked_vias: Vec = Vec::new(); + if let Some(iface) = current_iface.clone() { + blocked_ifaces.push(iface); + } + if let Some(via) = current_via.clone() { + blocked_vias.push(via); + } + while result + .as_ref() + .err() + .is_some_and(|e| e.code == "link_timeout") + && failover_round < NOMAD_MAX_VIA_FAILOVERS + { + if self.nomad_link_generation.load(Ordering::SeqCst) != link_gen { + break; + } + failover_round = failover_round.saturating_add(1); + self.emit_nomad_page_progress( + hash_hex, + path, + "link_timeout", + progress_request_id, + serde_json::json!({ + "round": failover_round, + "iface": current_iface, + "via_prefix": nomad_via_prefix(current_via.as_deref()), + "hops": hops, + }), + ); + self.emit_nomad_page_progress( + hash_hex, + path, + "searching_route", + progress_request_id, + serde_json::json!({ + "round": failover_round, + "iface": current_iface, + "via_prefix": nomad_via_prefix(current_via.as_deref()), + }), + ); + let Some((failover_hops, failover_iface, failover_via)) = self + .nomad_suppress_via_and_rediscover(hash_hex, &blocked_ifaces, &blocked_vias) + .await + else { + self.emit_nomad_page_progress( + hash_hex, + path, + "no_alternate_route", + progress_request_id, + serde_json::json!({ + "round": failover_round, + "iface": current_iface, + "via_prefix": nomad_via_prefix(current_via.as_deref()), + }), + ); + break; + }; + if self.nomad_link_generation.load(Ordering::SeqCst) != link_gen { + break; + } + if let Some(iface) = failover_iface.clone() { + blocked_ifaces.push(iface); + } + if let Some(via) = failover_via.clone() { + blocked_vias.push(via); + } + let (failover_timeout, failover_egress) = + nomad_timeouts::resolve_nomad_page_timeout_secs( + interfaces, + failover_hops, + failover_iface.as_deref(), + self.primary_local_serial_id().as_deref(), + ); + let failover_link_hops = + nomad_timeouts::nomad_link_initiator_hops(failover_egress, failover_hops); + let failover_proof = nomad_timeouts::nomad_link_proof_budget_secs(failover_timeout); + current_iface = failover_iface.clone(); + current_via = failover_via.clone(); + self.emit_nomad_page_progress( + hash_hex, + path, + "failover", + progress_request_id, + serde_json::json!({ + "round": failover_round, + "iface": current_iface, + "via_prefix": nomad_via_prefix(current_via.as_deref()), + "hops": failover_hops, + "timeout_secs": failover_timeout, + }), + ); + let rediscovered = PathEnsureKind::Rediscovered.as_str(); + result = self + .nomad_link_client_query( + remote_hash, + path, + payload.clone(), + failover_hops, + failover_link_hops, + failover_timeout, + failover_egress, + Some(true), + Some(rediscovered), + link_gen, + ) + .await; + hops = failover_hops; + link_hops = failover_link_hops; + timeout_secs = failover_timeout; + egress = failover_egress; + force_path_ok = Some(true); + path_ensure_kind = Some(rediscovered); + proof_budget_secs = failover_proof; + } + + if let Err(ref mut err) = result { + if !blocked_ifaces.is_empty() { + err.tried_interfaces = Some(blocked_ifaces.clone()); + } + } + + let elapsed_ms = elapsed_ms_since(query_started); + finish_nomad_link_result( + result, + hash_hex, + identity_hash_hex, + hops, + link_hops, + proof_budget_secs, + timeout_secs, + egress, + force_path_ok, + path_ensure_kind, + elapsed_ms, + ) + } + + /// One LinkClient Nomad query under the shared Nomad link lock / cancel slot. + /// + /// `my_gen` is owned by the outer page request (see [`Self::query_nomad_node`]) so + /// via-failover retries do not bump generation or cancel a newer page load. + #[allow(clippy::too_many_arguments)] // hops / budgets / path-ensure diagnostics travel together + async fn nomad_link_client_query( + &self, + remote_hash: [u8; 16], + path: &str, + payload: Vec, + hops: u8, + link_hops: u8, + timeout_secs: u64, + egress: &'static str, + force_path_ok: Option, + path_ensure_kind: Option<&'static str>, + my_gen: u64, + ) -> Result, NomadRemoteQueryError> { + // Abort before touching the cancel slot so a superseded failover cannot + // cancel the newer request that already owns last-request-wins. + if self.nomad_link_generation.load(Ordering::SeqCst) != my_gen { + return Err(NomadRemoteQueryError { + code: "nomad_busy".into(), + egress: Some(egress), + path_hops: Some(hops), + link_hops: Some(link_hops), + timeout_secs: Some(timeout_secs), + force_path_ok, + path_ensure_kind, + raw_error: None, + elapsed_ms: None, + tried_interfaces: None, + }); + } let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel(); { let mut slot = self.nomad_link_cancel.lock().await; + if self.nomad_link_generation.load(Ordering::SeqCst) != my_gen { + return Err(NomadRemoteQueryError { + code: "nomad_busy".into(), + egress: Some(egress), + path_hops: Some(hops), + link_hops: Some(link_hops), + timeout_secs: Some(timeout_secs), + force_path_ok, + path_ensure_kind, + raw_error: None, + elapsed_ms: None, + tried_interfaces: None, + }); + } if let Some(prev) = slot.take() { let _ = prev.send(()); } *slot = Some(cancel_tx); } - let Ok(_guard) = + let Ok(guard) = tokio::time::timeout(NOMAD_LINK_LOCK_WAIT, self.nomad_link_lock.lock()).await else { if self.nomad_link_generation.load(Ordering::SeqCst) == my_gen { @@ -1047,7 +1279,8 @@ impl LiveBridge { force_path_ok, path_ensure_kind, raw_error: None, - elapsed_ms: Some(elapsed_ms_since(query_started)), + elapsed_ms: None, + tried_interfaces: None, }); }; if self.nomad_link_generation.load(Ordering::SeqCst) != my_gen { @@ -1060,7 +1293,8 @@ impl LiveBridge { force_path_ok, path_ensure_kind, raw_error: None, - elapsed_ms: Some(elapsed_ms_since(query_started)), + elapsed_ms: None, + tried_interfaces: None, }); } let client = LinkClient::new(self.handle.transport_tx.clone(), self.identity.clone()); @@ -1081,9 +1315,10 @@ impl LiveBridge { link_hops: Some(link_hops), timeout_secs: Some(timeout_secs), force_path_ok, - path_ensure_kind, + path_ensure_kind, raw_error: None, elapsed_ms: None, + tried_interfaces: None, }), query_result = query_fut => { query_result.map_err(|e| { @@ -1099,6 +1334,7 @@ impl LiveBridge { path_ensure_kind, raw_error: Some(raw), elapsed_ms: None, + tried_interfaces: None, } }) } @@ -1106,57 +1342,110 @@ impl LiveBridge { if self.nomad_link_generation.load(Ordering::SeqCst) == my_gen { *self.nomad_link_cancel.lock().await = None; } - let elapsed_ms = elapsed_ms_since(query_started); - match result { - Ok(bytes) => { - tracing::debug!( - target: "nomad", - dest = %hash_hex, - identity = %identity_hash_hex, - path_hops = hops, - link_hops, - proof_budget_secs, - timeout_secs, - egress, - force_path_ok = ?force_path_ok, - path_ensure_kind = ?path_ensure_kind, - elapsed_ms, - "Nomad Link query ok" - ); - Ok(( - bytes, - NomadRemoteQueryOk { - egress, - timeout_secs, - path_hops: hops, - link_hops, - force_path_ok, - path_ensure_kind, - elapsed_ms, - }, - )) + drop(guard); + result + } + + /// After LRPROOF timeout, suppress the active iface and drop the failed + /// next-hop so rediscovery cannot reinstall the same blackhole via a + /// different local interface name (Ratspeak vs RMAP World sharing a via). + async fn nomad_suppress_via_and_rediscover( + &self, + hash_hex: &str, + blocked_ifaces: &[String], + blocked_vias: &[String], + ) -> Option<(u8, Option, Option)> { + let dest = parse_hash16(hash_hex).ok()?; + let slots_before = self + .path_slots(hash_hex) + .await + .map(|(slots, _)| slots) + .unwrap_or_default(); + let failed_via = active_via_hash_from_slots(&slots_before); + let _ = self + .query_control_timed(TransportQuery::SuppressCurrentPathInterface { + dest, + duration: NOMAD_IFACE_SUPPRESS_SECS, + }) + .await; + // Drop every known-bad next hop (not only the currently active slot — + // after a timeout the table may flip to another iface sharing an older via). + let mut vias_to_drop: Vec = blocked_vias.to_vec(); + if let Some(via) = failed_via.clone() { + if !vias_to_drop.iter().any(|b| b.eq_ignore_ascii_case(&via)) { + vias_to_drop.push(via); } - Err(mut err) => { - err.elapsed_ms = Some(elapsed_ms); - tracing::warn!( - target: "nomad", - dest = %hash_hex, - identity = %identity_hash_hex, - path_hops = ?err.path_hops, - link_hops = ?err.link_hops, - proof_budget_secs, - egress = ?err.egress, - force_path_ok = ?err.force_path_ok, - path_ensure_kind = ?err.path_ensure_kind, - timeout_secs = ?err.timeout_secs, - elapsed_ms, - error = %err.code, - raw_error = err.raw_error.as_deref().unwrap_or(""), - "Nomad Link query failed" - ); - Err(err) + } + for via_hex in &vias_to_drop { + if let Ok(next_hop) = parse_hash16(via_hex) { + let _ = self + .query_control_timed(TransportQuery::DropAllVia { next_hop }) + .await; + } + } + if let Ok(mut driver) = self.outbound.lock() { + driver.clear_path_to(hash_hex); + } + if let Ok(mut cache) = self.peer_via_cache.lock() { + cache.remove(&hash_hex.to_lowercase()); + } + let _ = self + .handle + .transport_tx + .send(TransportMessage::RequestPath { + destination_hash: dest, + }) + .await; + // Longer wait: alternate hubs may be slower to answer path requests. + let deadline = tokio::time::Instant::now() + NOMAD_VIA_FAILOVER_PROBE_WAIT; + let mut found: Option<(u8, Option, Option)> = None; + while tokio::time::Instant::now() < deadline { + let _ = self.refresh_outbound_path_table().await; + let slots = self + .path_slots(hash_hex) + .await + .map(|(slots, _)| slots) + .unwrap_or_default(); + // Prefer any live slot that is not on a blocked iface/via. + for slot in &slots { + let iface = slot + .get("interface") + .and_then(|v| v.as_str()) + .map(str::to_string); + let via = slot + .get("via_hash") + .and_then(|v| v.as_str()) + .map(str::to_string); + let hops = slot + .get("hops") + .and_then(serde_json::Value::as_u64) + .map(|h| h as u8); + let Some(h) = hops else { continue }; + let iface_blocked = iface + .as_ref() + .is_some_and(|i| blocked_ifaces.iter().any(|b| b.eq_ignore_ascii_case(i))); + let via_blocked = via + .as_ref() + .is_some_and(|v| blocked_vias.iter().any(|b| b.eq_ignore_ascii_case(v))); + if iface_blocked || via_blocked { + continue; + } + // Also reject the via we just failed on this round. + if failed_via + .as_ref() + .is_some_and(|fv| via.as_ref().is_some_and(|v| v.eq_ignore_ascii_case(fv))) + { + continue; + } + found = Some((h, iface, via)); + break; } + if found.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(200)).await; } + found } pub async fn fetch_nomad_file( @@ -1199,6 +1488,7 @@ impl LiveBridge { Vec::new(), interfaces, force_path_refresh, + None, ) .await { @@ -1222,6 +1512,7 @@ impl LiveBridge { } /// See `fetch_nomad_file` for `hash_hex` / `identity_hash_hex` semantics. + #[allow(clippy::too_many_arguments)] // page fetch + progress correlation id pub async fn fetch_nomad_page( &self, hash_hex: &str, @@ -1230,6 +1521,7 @@ impl LiveBridge { data_b64: Option<&str>, interfaces: &[InterfaceRow], force_path_refresh: bool, + progress_request_id: Option<&str>, ) -> serde_json::Value { // Self-preview: read hosted content without a Link query to ourselves. if let Some(local) = self.nomad_server.try_read_local_route(hash_hex, path).await { @@ -1273,6 +1565,7 @@ impl LiveBridge { payload, interfaces, force_path_refresh, + progress_request_id, ) .await { @@ -3368,6 +3661,33 @@ impl LiveBridge { let msg = serde_json::json!({ "type": event_type, "payload": payload }); let _ = self.event_tx.send(msg.to_string()); } + + /// Progress for Nomad page/file Link attempts (renderer loading status). + fn emit_nomad_page_progress( + &self, + dest_hash: &str, + path: &str, + phase: &str, + request_id: Option<&str>, + mut payload: serde_json::Value, + ) { + if let Some(obj) = payload.as_object_mut() { + obj.insert( + "destination_hash".into(), + serde_json::json!(dest_hash.to_lowercase()), + ); + obj.insert("path".into(), serde_json::json!(path)); + obj.insert("phase".into(), serde_json::json!(phase)); + if let Some(id) = request_id.map(str::trim).filter(|s| !s.is_empty()) { + obj.insert("request_id".into(), serde_json::json!(id)); + } + } + self.emit_event("nomad.page_progress", payload); + } +} + +fn nomad_via_prefix(via: Option<&str>) -> Option { + via.map(|v| v.chars().take(8).collect()) } pub(super) fn lxmf_payload_from_message( @@ -3789,6 +4109,8 @@ struct NomadRemoteQueryError { path_ensure_kind: Option<&'static str>, raw_error: Option, elapsed_ms: Option, + /// Local interface names attempted (including via-aware failovers). + tried_interfaces: Option>, } fn elapsed_ms_since(started: tokio::time::Instant) -> u64 { @@ -3809,14 +4131,14 @@ fn insert_nomad_link_budget_fields( } if let Some(link_hops) = link_hops { obj.insert("link_hops".into(), serde_json::json!(link_hops)); - // Link::new_initiator uses ESTABLISHMENT_TIMEOUT_PER_HOP (6s) × hops. - obj.insert( - "proof_budget_secs".into(), - serde_json::json!(u64::from(link_hops).saturating_mul(6)), - ); } if let Some(timeout_secs) = timeout_secs { obj.insert("timeout_secs".into(), serde_json::json!(timeout_secs)); + // Matches LinkClient proof-budget overlay: remaining overall deadline. + obj.insert( + "proof_budget_secs".into(), + serde_json::json!(nomad_timeouts::nomad_link_proof_budget_secs(timeout_secs)), + ); } if let Some(force_path_ok) = force_path_ok { obj.insert("force_path_ok".into(), serde_json::json!(force_path_ok)); @@ -3869,6 +4191,9 @@ fn nomad_remote_error_json(err: &NomadRemoteQueryError) -> serde_json::Value { if let Some(raw) = err.raw_error.as_deref().filter(|s| !s.is_empty()) { obj.insert("raw_error".into(), serde_json::json!(raw)); } + if let Some(ifaces) = err.tried_interfaces.as_ref().filter(|v| !v.is_empty()) { + obj.insert("tried_interfaces".into(), serde_json::json!(ifaces)); + } out } @@ -4025,6 +4350,98 @@ const NOMAD_FORCE_PATH_REFRESH_WAIT: Duration = Duration::from_secs(4); /// Strict TCP/network DropPath→RequestPath wait before Link (no stale-accept fall-through). const NOMAD_TCP_PATH_PROBE_WAIT: Duration = Duration::from_secs(5); +/// How long to reject the failed Nomad path interface after LRPROOF timeout so +/// an alternate hub slot (e.g. TTP_TCP vs Ratspeak) can become active. +const NOMAD_IFACE_SUPPRESS_SECS: f64 = 120.0; + +/// Wait for a path with a different via_hash after DropAllVia + suppress. +const NOMAD_VIA_FAILOVER_PROBE_WAIT: Duration = Duration::from_secs(8); + +/// Max in-request via failovers after the first link_timeout (total Link tries = 1 + this). +const NOMAD_MAX_VIA_FAILOVERS: u8 = 2; + +fn active_via_hash_from_slots(slots: &[serde_json::Value]) -> Option { + slots.iter().find_map(|slot| { + let active = slot + .get("active") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if !active { + return None; + } + slot.get("via_hash") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }) +} + +#[allow(clippy::too_many_arguments, clippy::result_large_err)] // Nomad Link diagnostics bundle +fn finish_nomad_link_result( + result: Result, NomadRemoteQueryError>, + hash_hex: &str, + identity_hash_hex: &str, + hops: u8, + link_hops: u8, + proof_budget_secs: u64, + timeout_secs: u64, + egress: &'static str, + force_path_ok: Option, + path_ensure_kind: Option<&'static str>, + elapsed_ms: u64, +) -> Result<(Vec, NomadRemoteQueryOk), NomadRemoteQueryError> { + match result { + Ok(bytes) => { + tracing::debug!( + target: "nomad", + dest = %hash_hex, + identity = %identity_hash_hex, + path_hops = hops, + link_hops, + proof_budget_secs, + timeout_secs, + egress, + force_path_ok = ?force_path_ok, + path_ensure_kind = ?path_ensure_kind, + elapsed_ms, + "Nomad Link query ok" + ); + Ok(( + bytes, + NomadRemoteQueryOk { + egress, + timeout_secs, + path_hops: hops, + link_hops, + force_path_ok, + path_ensure_kind, + elapsed_ms, + }, + )) + } + Err(mut err) => { + err.elapsed_ms = Some(elapsed_ms); + tracing::warn!( + target: "nomad", + dest = %hash_hex, + identity = %identity_hash_hex, + path_hops = ?err.path_hops, + link_hops = ?err.link_hops, + proof_budget_secs, + egress = ?err.egress, + force_path_ok = ?err.force_path_ok, + path_ensure_kind = ?err.path_ensure_kind, + timeout_secs = ?err.timeout_secs, + elapsed_ms, + error = %err.code, + raw_error = err.raw_error.as_deref().unwrap_or(""), + "Nomad Link query failed" + ); + Err(err) + } + } +} + /// Outcome of [`LiveStack::ensure_path_for_direct_with_opts`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PathEnsureKind { @@ -4160,6 +4577,41 @@ mod announce_display_name_tests { assert_eq!(added, vec!["cc".to_string()]); } + #[test] + fn active_via_hash_from_slots_skips_inactive_and_empty() { + assert_eq!(active_via_hash_from_slots(&[]), None); + assert_eq!( + active_via_hash_from_slots(&[serde_json::json!({ + "active": true, + "via_hash": "", + })]), + None + ); + assert_eq!( + active_via_hash_from_slots(&[ + serde_json::json!({ + "active": false, + "via_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }), + serde_json::json!({ + "active": true, + "via_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }), + ]), + Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into()) + ); + } + + #[test] + fn nomad_via_prefix_handles_none_and_truncates() { + assert_eq!(nomad_via_prefix(None), None); + assert_eq!( + nomad_via_prefix(Some("abcdefghijklmnop")), + Some("abcdefgh".into()) + ); + assert_eq!(nomad_via_prefix(Some("abcd")), Some("abcd".into())); + } + #[test] fn force_path_refresh_rejects_stale_route_until_absent_then_accepts_refresh() { // Existing stale route still installed — must not accept yet. @@ -4239,17 +4691,22 @@ mod announce_display_name_tests { path_ensure_kind: None, raw_error: Some("timed out waiting for link proof".into()), elapsed_ms: Some(18_250), + tried_interfaces: Some(vec!["Ratspeak".into(), "RNS_Transport_US-East".into()]), }); assert_eq!(with_diag["ok"], false); assert_eq!(with_diag["error"], "link_timeout"); assert_eq!(with_diag["egress"], "tcp"); assert_eq!(with_diag["path_hops"], 1); assert_eq!(with_diag["link_hops"], 3); - assert_eq!(with_diag["proof_budget_secs"], 18); + assert_eq!(with_diag["proof_budget_secs"], 45); assert_eq!(with_diag["timeout_secs"], 45); assert_eq!(with_diag["force_path_ok"], true); assert_eq!(with_diag["elapsed_ms"], 18250); assert_eq!(with_diag["raw_error"], "timed out waiting for link proof"); + assert_eq!( + with_diag["tried_interfaces"], + serde_json::json!(["Ratspeak", "RNS_Transport_US-East"]) + ); let without = nomad_remote_error_json(&NomadRemoteQueryError { code: "missing_identity_hash".into(), @@ -4261,6 +4718,7 @@ mod announce_display_name_tests { path_ensure_kind: None, raw_error: None, elapsed_ms: None, + tried_interfaces: None, }); assert_eq!(without["ok"], false); assert_eq!(without["error"], "missing_identity_hash"); @@ -4287,7 +4745,7 @@ mod announce_display_name_tests { assert_eq!(out["egress"], "tcp"); assert_eq!(out["path_hops"], 1); assert_eq!(out["link_hops"], 3); - assert_eq!(out["proof_budget_secs"], 18); + assert_eq!(out["proof_budget_secs"], 45); assert_eq!(out["timeout_secs"], 45); assert_eq!(out["elapsed_ms"], 4200); assert!(out.get("force_path_ok").is_none()); @@ -4311,7 +4769,7 @@ mod announce_display_name_tests { assert_eq!(out["egress"], "tcp"); assert_eq!(out["path_hops"], 5); assert_eq!(out["link_hops"], 5); - assert_eq!(out["proof_budget_secs"], 30); + assert_eq!(out["proof_budget_secs"], 45); assert_eq!(out["timeout_secs"], 45); assert_eq!(out["force_path_ok"], false); assert_eq!(out["path_ensure_kind"], "cached_hit"); diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index e5c67dcbc..53db23976 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -2211,6 +2211,7 @@ impl StackHandle { path: &str, data_b64: Option<&str>, force_path_refresh: bool, + request_id: Option<&str>, ) -> serde_json::Value { #[cfg(feature = "rns-stack")] if let Some(live) = &self.live { @@ -2224,10 +2225,11 @@ impl StackHandle { data_b64, &interfaces, force_path_refresh, + request_id, ) .await; } - let _ = (hash, path, data_b64, force_path_refresh); + let _ = (hash, path, data_b64, force_path_refresh, request_id); serde_json::json!({ "ok": false, "error": "nomad page fetch requires live rns-stack sidecar" diff --git a/reticulum-sidecar/src/stack/nomad_timeouts.rs b/reticulum-sidecar/src/stack/nomad_timeouts.rs index ed9a9d35b..fe8461d2e 100644 --- a/reticulum-sidecar/src/stack/nomad_timeouts.rs +++ b/reticulum-sidecar/src/stack/nomad_timeouts.rs @@ -87,6 +87,15 @@ pub fn nomad_link_initiator_hops(egress_via: &str, path_hops: u8) -> u8 { } } +/// Effective LRPROOF budget reported to the UI / failure logs. +/// +/// Matches `rsReticulum-link-client-proof-budget.patch` (release parity): +/// LinkClient waits for proof until the overall deadline remaining after +/// pubkey/path discovery. With a cached key that is ~`timeout_secs`. +pub fn nomad_link_proof_budget_secs(timeout_secs: u64) -> u64 { + timeout_secs +} + fn interface_status_live(status: &str) -> bool { matches!( status.to_ascii_lowercase().as_str(), @@ -197,7 +206,8 @@ mod tests { #[test] fn tcp_link_initiator_hops_floored_and_capped_for_release_parity() { - // Floor 3 (~18s); scale with path; cap 7 (~42s) under 45s overall. + // Floor 3; scale with path; cap 7 under 45s overall. Proof wait itself + // uses remaining overall deadline (see nomad_link_proof_budget_secs). assert_eq!(nomad_link_initiator_hops("tcp", 1), 3); assert_eq!(nomad_link_initiator_hops("tcp", 2), 3); assert_eq!(nomad_link_initiator_hops("network", 1), 3); @@ -211,6 +221,12 @@ mod tests { assert_eq!(nomad_link_initiator_hops("rf", 1), 1); } + #[test] + fn link_proof_budget_matches_overall_timeout_release_parity() { + assert_eq!(nomad_link_proof_budget_secs(45), 45); + assert_eq!(nomad_link_proof_budget_secs(99), 99); + } + #[test] fn network_ready_when_path_iface_or_live_egress() { let mut tcp = iface("tcp"); diff --git a/scripts/apply-rsReticulum-link-client-proof-budget.sh b/scripts/apply-rsReticulum-link-client-proof-budget.sh index 4db23ac09..a0a1b72d1 100755 --- a/scripts/apply-rsReticulum-link-client-proof-budget.sh +++ b/scripts/apply-rsReticulum-link-client-proof-budget.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash -# Cap LinkClient wait_for_proof at establishment_timeout (MeshChat-like TCP fail-fast). +# Ensure LinkClient::query uses remaining overall deadline for LRPROOF (v5.25.0 / +# release parity). Older overlays capped at establishment or max(establishment, 30s) +# and false-failed slow TCP hub Nomad pages. Apply **after** the Nomad LinkClient overlay. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -22,12 +24,26 @@ short_head() { git -C "${RNS_DIR}" rev-parse --short HEAD } -# Exact overlay already applied (reverse cleanly) — not a lone proof_budget token. +has_remaining_proof_budget() { + [[ -f "${LINK_CLIENT_RS}" ]] \ + && grep -qE 'let proof_budget[[:space:]]*=[[:space:]]*time_remaining\(deadline\)[[:space:]]*\?;' "${LINK_CLIENT_RS}" \ + && grep -qE 'wait_for_proof\([^;]*proof_budget' "${LINK_CLIENT_RS}" \ + && ! grep -qE 'proof_budget = time_remaining\(deadline\)\?\.min\(link\.establishment_timeout\)' "${LINK_CLIENT_RS}" \ + && ! grep -qE 'establishment_timeout[[:space:]]*\.max\(Duration::from_secs\(30\)\)' "${LINK_CLIENT_RS}" +} + +# Exact remaining-budget overlay already applied (reverse cleanly). if git -C "${RNS_DIR}" apply --reverse --check "${PATCH_FILE}" > /dev/null 2>&1; then echo "link-client proof-budget overlay already present on rsReticulum @ $(short_head)" exit 0 fi +# Already on remaining-deadline (including checkouts with local debug edits). +if has_remaining_proof_budget; then + echo "link-client proof-budget capability already upstream on rsReticulum @ $(short_head)" + exit 0 +fi + apply_err="$(mktemp "${TMPDIR:-/tmp}/mesh-proof-budget-apply.XXXXXX")" trap 'rm -f "${apply_err}"' EXIT @@ -37,12 +53,63 @@ if git -C "${RNS_DIR}" apply --check "${PATCH_FILE}" > "${apply_err}" 2>&1; then exit 0 fi -# Neither reverse nor forward matched. Accept only the full upstream-equivalent data -# flow: proof_budget is capped by establishment_timeout AND passed to wait_for_proof. +# Migrate older establishment / 30s-floor caps → remaining-deadline budget. if [[ -f "${LINK_CLIENT_RS}" ]] \ - && grep -qE 'let proof_budget\s*=\s*time_remaining\(deadline\)\?\.min\(link\.establishment_timeout\)' "${LINK_CLIENT_RS}" \ + && grep -qE 'let proof_budget[[:space:]]*=' "${LINK_CLIENT_RS}" \ && grep -qE 'wait_for_proof\([^;]*proof_budget' "${LINK_CLIENT_RS}"; then - echo "link-client proof-budget capability already upstream on rsReticulum @ $(short_head)" + python3 - "${LINK_CLIENT_RS}" << 'PY' +import pathlib +import re +import sys + +path = pathlib.Path(sys.argv[1]) +text = path.read_text() +patterns = [ + re.compile( + r"[ \t]*// Cap proof wait at link establishment timeout \(6s × hops\)\. Otherwise a\n" + r"[ \t]*// cached path lets wait_for_proof burn the entire overall deadline\n" + r"[ \t]*// \(e\.g\. TCP 45s\) even when MeshChat would fail the link stage in ~15s\.\n" + r"[ \t]*let proof_budget = time_remaining\(deadline\)\?\.min\(link\.establishment_timeout\);\n", + ), + re.compile( + r"[ \t]*// Cap proof wait at establishment \(6s × hops\), but floor at 30s so slow\n" + r"[ \t]*// TCP hub LRPROOFs can succeed under the MeshChat 45s overall\n" + r"[ \t]*// \(45 − 15s transfer grace\)\. Still capped by time remaining\.\n" + r"[ \t]*let proof_budget = time_remaining\(deadline\)\?\.min\(\n" + r"[ \t]*link\.establishment_timeout\n" + r"[ \t]*\.max\(Duration::from_secs\(30\)\),\n" + r"[ \t]*\);\n", + ), + re.compile( + r"[ \t]*// Release / v5\.25\.0 parity: use remaining overall deadline for LRPROOF\.\n" + r"[ \t]*// Capping at establishment \(or a 30s floor\) false-failed multi-hop TCP hub\n" + r"[ \t]*// Nomad pages \(e\.g\. e7d84cef\) that need >30s while remaining is still ~45s\.\n" + r"[ \t]*let proof_budget = time_remaining\(deadline\)\?;\n", + ), +] +new = ( + " // Release / v5.25.0 parity: use remaining overall deadline for LRPROOF.\n" + " // Do not cap at establishment (hops×6) or a 30s floor — that false-failed\n" + " // multi-hop TCP hub Nomad pages that need the rest of the MeshChat 45s window.\n" + " let proof_budget = time_remaining(deadline)?;\n" +) +updated = text +replaced = 0 +for pat in patterns: + updated, n = pat.subn(new, updated, count=1) + replaced += n + if replaced: + break +if replaced != 1: + # Already remaining but comments differ — accept if the assignment is correct. + if re.search( + r"let proof_budget = time_remaining\(deadline\)\?;", text + ) and "wait_for_proof" in text and "proof_budget" in text: + sys.exit(0) + sys.exit(f"migrate: expected one capped proof_budget block, found {replaced}") +path.write_text(updated) +PY + echo "migrated link-client proof-budget overlay to remaining-deadline on rsReticulum @ $(short_head)" exit 0 fi diff --git a/scripts/apply-rsReticulum-link-client-proof-budget.test.mjs b/scripts/apply-rsReticulum-link-client-proof-budget.test.mjs index 00853d1f1..6fa85d531 100644 --- a/scripts/apply-rsReticulum-link-client-proof-budget.test.mjs +++ b/scripts/apply-rsReticulum-link-client-proof-budget.test.mjs @@ -32,36 +32,55 @@ const FRESH_LINK_CLIENT = `impl LinkClient { const UPSTREAM_EQUIVALENT = `impl LinkClient { async fn query(&self) -> Result<(), LinkClientError> { - // Cap proof wait at link establishment timeout (6s × hops). - let proof_budget = time_remaining(deadline)?.min(link.establishment_timeout); + // Release / v5.25.0 parity: use remaining overall deadline for LRPROOF. + let proof_budget = time_remaining(deadline)?; let proof_data = wait_for_proof(&mut dest_rx, link_id, proof_budget).await?; Ok(()) } } `; -const INCOMPATIBLE = `impl LinkClient { +/** Older #756 establishment-only cap — apply script must migrate to remaining. */ +const LEGACY_ESTABLISHMENT_ONLY = `impl LinkClient { async fn query(&self) -> Result<(), LinkClientError> { - let proof_data = wait_for_proof(&mut dest_rx, link_id, Duration::from_secs(99)).await?; + // Cap proof wait at link establishment timeout (6s × hops). Otherwise a + // cached path lets wait_for_proof burn the entire overall deadline + // (e.g. TCP 45s) even when MeshChat would fail the link stage in ~15s. + let proof_budget = time_remaining(deadline)?.min(link.establishment_timeout); + let proof_data = wait_for_proof(&mut dest_rx, link_id, proof_budget).await?; Ok(()) } } `; -/** Has proof_budget but does not cap it with establishment_timeout. */ -const UNCAPPED_PROOF_BUDGET = `impl LinkClient { +/** Intermediate 30s-floor cap — must migrate to remaining. */ +const LEGACY_THIRTY_FLOOR = `impl LinkClient { async fn query(&self) -> Result<(), LinkClientError> { - let proof_budget = time_remaining(deadline)?; + // Cap proof wait at establishment (6s × hops), but floor at 30s so slow + // TCP hub LRPROOFs can succeed under the MeshChat 45s overall + // (45 − 15s transfer grace). Still capped by time remaining. + let proof_budget = time_remaining(deadline)?.min( + link.establishment_timeout + .max(Duration::from_secs(30)), + ); let proof_data = wait_for_proof(&mut dest_rx, link_id, proof_budget).await?; Ok(()) } } `; +const INCOMPATIBLE = `impl LinkClient { + async fn query(&self) -> Result<(), LinkClientError> { + let proof_data = wait_for_proof(&mut dest_rx, link_id, Duration::from_secs(99)).await?; + Ok(()) + } +} +`; + /** Caps proof_budget but wait_for_proof still uses the uncapped remaining deadline. */ const CAPPED_PROOF_BUDGET_UNUSED = `impl LinkClient { async fn query(&self) -> Result<(), LinkClientError> { - let proof_budget = time_remaining(deadline)?.min(link.establishment_timeout); + let proof_budget = time_remaining(deadline)?; let proof_data = wait_for_proof(&mut dest_rx, link_id, time_remaining(deadline)?).await?; Ok(()) } @@ -104,13 +123,14 @@ afterEach(() => { describe('apply-rsReticulum-link-client-proof-budget.sh', () => { it('applies the overlay on a fresh checkout', () => { expect(readFileSync(PATCH_FILE, 'utf8')).toContain('proof_budget'); + expect(readFileSync(PATCH_FILE, 'utf8')).toContain('time_remaining(deadline)?'); const rns = makeFakeRsReticulum(FRESH_LINK_CLIENT); const result = runApply(rns); expect(result.status, result.stderr || result.stdout).toBe(0); expect(result.stdout).toMatch(/applied .*rsReticulum-link-client-proof-budget\.patch/); const body = readFileSync(path.join(rns, 'crates/rns-runtime/src/link_client.rs'), 'utf8'); - expect(body).toContain('let proof_budget'); - expect(body).toContain('link.establishment_timeout'); + expect(body).toContain('let proof_budget = time_remaining(deadline)?;'); + expect(body).toContain('wait_for_proof(&mut dest_rx, link_id, proof_budget)'); }); it('is a no-op when the exact overlay is already applied (repeated run)', () => { @@ -119,28 +139,41 @@ describe('apply-rsReticulum-link-client-proof-budget.sh', () => { expect(first.status, first.stderr || first.stdout).toBe(0); const second = runApply(rns); expect(second.status, second.stderr || second.stdout).toBe(0); - expect(second.stdout).toMatch(/already present/); + expect(second.stdout).toMatch(/already present|already upstream/); }); - it('accepts an upstream-equivalent proof-budget cap when the patch does not apply', () => { - const rns = makeFakeRsReticulum(UPSTREAM_EQUIVALENT); + it('migrates the legacy establishment-only cap to remaining-deadline', () => { + const rns = makeFakeRsReticulum(LEGACY_ESTABLISHMENT_ONLY); const result = runApply(rns); expect(result.status, result.stderr || result.stdout).toBe(0); - expect(result.stdout).toMatch(/already upstream/); + expect(result.stdout).toMatch(/migrated .*remaining-deadline/); + const body = readFileSync(path.join(rns, 'crates/rns-runtime/src/link_client.rs'), 'utf8'); + expect(body).toContain('let proof_budget = time_remaining(deadline)?;'); + expect(body).not.toMatch(/\.min\(link\.establishment_timeout\)/); }); - it('rejects an uncapped proof_budget as upstream-equivalent', () => { - const rns = makeFakeRsReticulum(UNCAPPED_PROOF_BUDGET); + it('migrates the legacy 30s floor cap to remaining-deadline', () => { + const rns = makeFakeRsReticulum(LEGACY_THIRTY_FLOOR); const result = runApply(rns); - expect(result.status).not.toBe(0); - expect(result.stderr).toMatch(/could not be applied|git diagnostic/i); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toMatch(/migrated .*remaining-deadline|already upstream/); + const body = readFileSync(path.join(rns, 'crates/rns-runtime/src/link_client.rs'), 'utf8'); + expect(body).toContain('let proof_budget = time_remaining(deadline)?;'); + expect(body).not.toContain('Duration::from_secs(30)'); }); - it('rejects a capped proof_budget that wait_for_proof does not use', () => { + it('accepts an upstream-equivalent remaining proof budget when the patch does not apply', () => { + const rns = makeFakeRsReticulum(UPSTREAM_EQUIVALENT); + const result = runApply(rns); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toMatch(/already upstream|already present/); + }); + + it('rejects a proof_budget that wait_for_proof does not use', () => { const rns = makeFakeRsReticulum(CAPPED_PROOF_BUDGET_UNUSED); const result = runApply(rns); + // Assignment is remaining but wait_for_proof ignores proof_budget — not acceptable. expect(result.status).not.toBe(0); - expect(result.stderr).toMatch(/could not be applied|git diagnostic/i); }); it('fails with git diagnostic on incompatible checkouts', () => { diff --git a/scripts/update.sh b/scripts/update.sh index 3b92c92b0..7b22ecea4 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -211,7 +211,7 @@ check_ratspeak_patches() { 'rsReticulum-path-medium-slots.patch|ratspeak/rsReticulum||rsReticulum path-medium slots|' 'rsReticulum-auto-beacon-utun.patch|ratspeak/rsReticulum|11|rsReticulum auto-beacon utun|https://github.com/ratspeak/rsReticulum/pull/11' 'rsReticulum-link-client-nomad.patch|ratspeak/rsReticulum|14|rsReticulum LinkClient Nomad|https://github.com/ratspeak/rsReticulum/pull/14' - 'rsReticulum-link-client-proof-budget.patch|ratspeak/rsReticulum||rsReticulum LinkClient proof-budget cap|' + 'rsReticulum-link-client-proof-budget.patch|ratspeak/rsReticulum||rsReticulum LinkClient proof-budget remaining-deadline|' 'rsReticulum-ble-rnode-pairing-transition-debounce.patch|ratspeak/rsReticulum|20|rsReticulum BLE RNode pairing-transition debounce|https://github.com/ratspeak/rsReticulum/pull/20' 'rsReticulum-discovery-announce-egress.patch|ratspeak/rsReticulum|19|rsReticulum discovery announce egress|https://github.com/ratspeak/rsReticulum/pull/19' 'rsLXMF-propagation-sync-peering.patch|ratspeak/rsLXMF|4|rsLXMF propagation sync peering|https://github.com/ratspeak/rsLXMF/pull/4' diff --git a/src/renderer/assets/fonts/MeshClientNomadMono.woff2 b/src/renderer/assets/fonts/MeshClientNomadMono.woff2 new file mode 100644 index 000000000..3994f8c84 Binary files /dev/null and b/src/renderer/assets/fonts/MeshClientNomadMono.woff2 differ diff --git a/src/renderer/assets/fonts/OFL-JetBrainsMonoNerdFont.txt b/src/renderer/assets/fonts/OFL-JetBrainsMonoNerdFont.txt new file mode 100644 index 000000000..8bee4148c --- /dev/null +++ b/src/renderer/assets/fonts/OFL-JetBrainsMonoNerdFont.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/renderer/components/NomadNetworkPanel.test.tsx b/src/renderer/components/NomadNetworkPanel.test.tsx index ec5d5f693..e3c190c95 100644 --- a/src/renderer/components/NomadNetworkPanel.test.tsx +++ b/src/renderer/components/NomadNetworkPanel.test.tsx @@ -120,6 +120,90 @@ describe('NomadNetworkPanel', () => { expect(screen.queryByText('Announce only')).not.toBeInTheDocument(); }); + it('shows empty-state URL entry before a node is selected', async () => { + render(); + + const hint = screen.getByText('nomadNetwork.enterUrlHint'); + expect(hint).toBeInTheDocument(); + expect(screen.getByLabelText('nomadNetwork.urlBarAria')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'nomadNetwork.goToUrl' })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'nomadNetwork.closeViewer' }), + ).not.toBeInTheDocument(); + + // Scope to the empty-state paste UI (panel chrome tabs are outside a tablist). + const emptyState = hint.closest('div'); + expect(emptyState).toBeTruthy(); + hydrateAxeThemeColors(emptyState!); + expect(await axe(emptyState!)).toHaveNoViolations(); + }); + + it('opens a pasted absolute Nomad URL without a listed node', async () => { + const user = userEvent.setup(); + const hash = '53819f99223ed8a5676b5900d285eb3f'; + const fetchNomadPage = vi.fn().mockResolvedValue({ + ok: true, + content: 'pasted page', + content_type: 'text/plain', + }); + useNomadNetworkStore.setState({ + nodes: new Map(), + fetchNomadPage, + }); + + render(); + const urlBar = screen.getByLabelText('nomadNetwork.urlBarAria'); + await user.clear(urlBar); + await user.type(urlBar, `${hash}:/page/index.mu`); + await user.click(screen.getByRole('button', { name: 'nomadNetwork.goToUrl' })); + + await waitFor(() => { + expect(fetchNomadPage).toHaveBeenCalledWith( + hash, + '/page/index.mu', + undefined, + expect.objectContaining({ requestId: expect.any(String) }), + ); + }); + expect(screen.getByText(hash.slice(0, 16))).toBeInTheDocument(); + expect(screen.getByLabelText('nomadNetwork.urlBarAria')).toHaveValue(`${hash}:/page/index.mu`); + expect(screen.getByText('pasted page')).toBeInTheDocument(); + }); + + it('shows invalid URL error from empty-state paste without opening viewer', async () => { + const user = userEvent.setup(); + const fetchNomadPage = vi.fn(); + useNomadNetworkStore.setState({ fetchNomadPage }); + + render(); + const urlBar = screen.getByLabelText('nomadNetwork.urlBarAria'); + await user.type(urlBar, 'not-a-nomad-url'); + await user.click(screen.getByRole('button', { name: 'nomadNetwork.goToUrl' })); + + expect(fetchNomadPage).not.toHaveBeenCalled(); + expect(screen.getByText('nomadNetwork.pageFailed')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'nomadNetwork.closeViewer' }), + ).not.toBeInTheDocument(); + }); + + it('rejects relative path paste when no page is open', async () => { + const user = userEvent.setup(); + const fetchNomadPage = vi.fn(); + useNomadNetworkStore.setState({ fetchNomadPage }); + + render(); + const urlBar = screen.getByLabelText('nomadNetwork.urlBarAria'); + await user.type(urlBar, ':/page/other.mu'); + await user.click(screen.getByRole('button', { name: 'nomadNetwork.goToUrl' })); + + expect(fetchNomadPage).not.toHaveBeenCalled(); + expect(screen.getByText('nomadNetwork.pageFailed')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'nomadNetwork.closeViewer' }), + ).not.toBeInTheDocument(); + }); + it('sorts announces by last heard by default and by hops when selected', async () => { const user = userEvent.setup(); useNomadNetworkStore.setState({ @@ -432,7 +516,7 @@ describe('NomadNetworkPanel', () => { { var_thread_id: 'aaa', }, - undefined, + expect.objectContaining({ requestId: expect.any(String) }), ); await user.click(screen.getByRole('button', { name: 'nomadNetwork.homePage' })); @@ -453,7 +537,7 @@ describe('NomadNetworkPanel', () => { { var_thread_id: 'bbb', }, - undefined, + expect.objectContaining({ requestId: expect.any(String) }), ); const threadFetches = fetchNomadPage.mock.calls.filter( @@ -858,14 +942,14 @@ describe('NomadNetworkPanel', () => { 'abc1234567890', '/page/index.mu', undefined, - undefined, + expect.objectContaining({ requestId: expect.any(String) }), ); expect(fetchNomadPage).toHaveBeenNthCalledWith( 2, 'abc1234567890', '/page/index.mu', undefined, - { forcePathRefresh: true }, + expect.objectContaining({ forcePathRefresh: true, requestId: expect.any(String) }), ); expect(screen.queryByText(/nomadNetwork.pageFailed/)).not.toBeInTheDocument(); } finally { @@ -969,7 +1053,7 @@ describe('NomadNetworkPanel', () => { 'abc1234567890', '/page/index.mu', undefined, - { forcePathRefresh: true }, + expect.objectContaining({ forcePathRefresh: true, requestId: expect.any(String) }), ); } finally { restore(); @@ -1051,7 +1135,7 @@ describe('NomadNetworkPanel', () => { 'abc1234567890', '/page/index.mu', undefined, - { forcePathRefresh: true }, + expect.objectContaining({ forcePathRefresh: true, requestId: expect.any(String) }), ); } finally { restore(); @@ -1179,14 +1263,14 @@ describe('NomadNetworkPanel', () => { 'abc1234567890', '/page/index.mu', undefined, - { forcePathRefresh: true }, + expect.objectContaining({ forcePathRefresh: true, requestId: expect.any(String) }), ); expect(fetchNomadPage).toHaveBeenNthCalledWith( 3, 'abc1234567890', '/page/index.mu', undefined, - { forcePathRefresh: true }, + expect.objectContaining({ forcePathRefresh: true, requestId: expect.any(String) }), ); act(() => { diff --git a/src/renderer/components/NomadNetworkPanel.tsx b/src/renderer/components/NomadNetworkPanel.tsx index c2124ab83..1e4f22fcb 100644 --- a/src/renderer/components/NomadNetworkPanel.tsx +++ b/src/renderer/components/NomadNetworkPanel.tsx @@ -254,6 +254,7 @@ export default function NomadNetworkPanel({ const pageLoadingStartedAt = useNomadPageViewerStore((s) => s.pageLoadingStartedAt); const pageLoadingBudgetSec = useNomadPageViewerStore((s) => s.pageLoadingBudgetSec); const pageLoadingRetrying = useNomadPageViewerStore((s) => s.pageLoadingRetrying); + const pageLoadingProgress = useNomadPageViewerStore((s) => s.pageLoadingProgress); const pageErrorRaw = useNomadPageViewerStore((s) => s.pageErrorRaw); const pageErrorEgress = useNomadPageViewerStore((s) => s.pageErrorEgress); const pageErrorDiag = useNomadPageViewerStore((s) => s.pageErrorDiag); @@ -525,14 +526,19 @@ export default function NomadNetworkPanel({ [historyIndex, historyStack, loadNodePage], ); + const activeDestinationHash = selectedNode?.destination_hash ?? selectedHash; + const submitUrlBar = useCallback(() => { - if (!selectedNode) return; const trimmed = urlBarValue.trim(); if (!trimmed) return; let target = trimmed; if (target.startsWith(':')) { - target = `${selectedNode.destination_hash}${target}`; + if (!activeDestinationHash) { + setInvalidUrlError(); + return; + } + target = `${activeDestinationHash}${target}`; } const { destination: baseDestination, requestData } = buildNomadLinkRequest(target, null, null); @@ -542,12 +548,16 @@ export default function NomadNetworkPanel({ return; } - const hash = parsed.destination_hash ?? selectedNode.destination_hash; + const hash = parsed.destination_hash ?? activeDestinationHash; + if (!hash) { + setInvalidUrlError(); + return; + } const normalizedRequest = normalizeNomadPageRequestData(requestData); void loadNodePage(hash, parsed.path, { requestData: normalizedRequest, }); - }, [loadNodePage, selectedNode, setInvalidUrlError, urlBarValue]); + }, [activeDestinationHash, loadNodePage, setInvalidUrlError, urlBarValue]); const closeViewer = useCallback(() => { closeViewerStore(); @@ -854,24 +864,54 @@ export default function NomadNetworkPanel({ onPreviewHostedSite={handlePreviewHostedSite} /> ) : null} - {activeTab !== 'myPages' && !selectedNode ? ( -

- {t('nomadNetwork.selectNode')} -

+ {activeTab !== 'myPages' && !selectedHash ? ( +
+

{t('nomadNetwork.enterUrlHint')}

+
{ + e.preventDefault(); + submitUrlBar(); + }} + > + { + setUrlBarValue(e.target.value); + }} + aria-label={t('nomadNetwork.urlBarAria')} + placeholder={t('nomadNetwork.enterUrlPlaceholder')} + className="min-w-0 flex-1 rounded border border-gray-600 bg-slate-900 px-2 py-1.5 font-mono text-xs text-gray-200" + /> + +
+ {pageError ? ( +

+ {t('nomadNetwork.pageFailed', { error: pageError })} +

+ ) : null} +
) : null} - {activeTab !== 'myPages' && selectedNode ? ( + {activeTab !== 'myPages' && selectedHash ? (
- {selectedNode.display_name ?? selectedNode.destination_hash.slice(0, 16)} + {selectedNode?.display_name ?? selectedHash.slice(0, 16)} - {selectedNode.hops != null ? ( + {selectedNode?.hops != null ? ( {t('nomadNetwork.hopsAway', { count: selectedNode.hops })} ) : null}
- {onOpenDm ? ( + {onOpenDm && selectedNode ? (