From 8952bd6feec1dcc3c30693e79a33d3400926882e Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 1 Aug 2026 10:22:04 -0600 Subject: [PATCH 1/4] fix(reticulum): give TCP Nomad links a flat ~18s proof budget 1-hop hub peers were clamped to link_hops=1 (6s proof) after #756. Always use initiator hops=3 for TCP/network and surface link-budget diagnostics on page/file failures for triage. --- docs/troubleshooting.md | 3 + reticulum-sidecar/src/stack/live.rs | 178 +++++++++++++++--- reticulum-sidecar/src/stack/nomad_timeouts.rs | 17 +- src/renderer/stores/nomadNetworkStore.test.ts | 19 +- src/renderer/stores/nomadNetworkStore.ts | 56 +++++- src/shared/nomad-types.ts | 18 +- 6 files changed, 254 insertions(+), 37 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1f1bdaebc..48db76d93 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1028,6 +1028,8 @@ In dev, **Start stack** now rebuilds when `reticulum-sidecar/src/**/*.rs` or `Ca Unrecognized codes pass through unchanged. +TCP/network Nomad Links use a **flat ~18s** link-proof budget (`link_hops=3` × 6s, MeshChat parity) regardless of path-table hops — not `6s × path hops`. Failure lines in the app log may include `link_hops`, `proof_budget_secs`, `timeout_secs`, and `raw=` for triage. + **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). **Fix**: @@ -1036,6 +1038,7 @@ Unrecognized codes pass through unchanged. 2. Rebuild sidecar: `pnpm run reticulum:sidecar:build`, restart stack. 3. Prefer low-hop nodes while testing; hop count is shown in the Nomad list. 4. Match the humanized message to the table above — `path_timeout` / high hops often mean RF reachability limits, not a mesh-client bug. +5. For TCP `link_timeout`, check log fields `link_hops` / `proof_budget_secs` / `raw=` — short fails with `link_hops=1` were a known budget bug; persistent fails after ~18s proof usually mean the peer/hub did not return LRPROOF. ### Reticulum sidecar stops during dev (Vite HMR) diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index d3ed195c0..ee300f3ca 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -827,8 +827,16 @@ impl LiveBridge { payload: Vec, interfaces: &[InterfaceRow], force_path_refresh: bool, - ) -> Result<(Vec, &'static str, u64), (String, Option<&'static str>)> { - let remote_hash = parse_hash16(identity_hash_hex).map_err(|e| (e, None))?; + ) -> Result<(Vec, &'static str, u64), NomadRemoteQueryError> { + let remote_hash = parse_hash16(identity_hash_hex).map_err(|e| NomadRemoteQueryError { + code: e, + egress: None, + path_hops: None, + link_hops: None, + timeout_secs: None, + force_path_ok: None, + raw_error: 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. let key = hash_hex.to_lowercase(); @@ -847,6 +855,7 @@ impl LiveBridge { (hops, iface) }; let (mut cached_hops, mut path_iface) = read_path_cache(); + let mut force_path_ok: Option = None; // Stale cached hops often survive LinkClient pubkey recall; force a // RequestPath on retry so the second attempt is not a no-op. Nomad uses a // shorter wait and may fall through on never-absent hub routes. @@ -859,6 +868,7 @@ impl LiveBridge { true, ) .await; + force_path_ok = Some(path_ok); tracing::info!( target: "nomad", dest = %hash_hex, @@ -880,9 +890,18 @@ impl LiveBridge { self.primary_local_serial_id().as_deref(), ); if !nomad_timeouts::nomad_remote_network_ready(interfaces, path_iface.as_deref()) { - return Err(("network_not_ready".into(), Some(egress))); + return Err(NomadRemoteQueryError { + code: "network_not_ready".into(), + egress: Some(egress), + path_hops: Some(hops), + link_hops: None, + timeout_secs: Some(timeout_secs), + force_path_ok, + raw_error: None, + }); } let link_hops = nomad_timeouts::nomad_link_initiator_hops(egress, hops); + let proof_budget_secs = u64::from(link_hops).saturating_mul(6); // 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 @@ -903,10 +922,26 @@ impl LiveBridge { if self.nomad_link_generation.load(Ordering::SeqCst) == my_gen { *self.nomad_link_cancel.lock().await = None; } - return Err(("nomad_busy".into(), Some(egress))); + 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, + raw_error: None, + }); }; if self.nomad_link_generation.load(Ordering::SeqCst) != my_gen { - return Err(("nomad_busy".into(), Some(egress))); + 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, + raw_error: None, + }); } let client = LinkClient::new(self.handle.transport_tx.clone(), self.identity.clone()); let query_fut = client.query( @@ -919,17 +954,54 @@ impl LiveBridge { ); let result = tokio::select! { biased; - _ = cancel_rx => Err("nomad_busy".into()), + _ = cancel_rx => 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, + raw_error: None, + }), query_result = query_fut => { - query_result.map_err(|e| map_nomad_link_error(&format!("{e}"))) + query_result.map_err(|e| { + let raw = format!("{e}"); + let code = map_nomad_link_error(&raw); + NomadRemoteQueryError { + code, + egress: Some(egress), + path_hops: Some(hops), + link_hops: Some(link_hops), + timeout_secs: Some(timeout_secs), + force_path_ok, + raw_error: Some(raw), + } + }) } }; if self.nomad_link_generation.load(Ordering::SeqCst) == my_gen { *self.nomad_link_cancel.lock().await = None; } - result - .map(|bytes| (bytes, egress, timeout_secs)) - .map_err(|e| (e, Some(egress))) + match result { + Ok(bytes) => Ok((bytes, egress, timeout_secs)), + Err(err) => { + 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, + timeout_secs = ?err.timeout_secs, + error = %err.code, + raw_error = err.raw_error.as_deref().unwrap_or(""), + "Nomad page/file Link query failed" + ); + Err(err) + } + } } pub async fn fetch_nomad_file( @@ -990,7 +1062,7 @@ impl LiveBridge { "timeout_secs": timeout_secs, }) } - Err((e, egress)) => nomad_remote_error_json(&e, egress), + Err(e) => nomad_remote_error_json(&e), } } @@ -1067,7 +1139,7 @@ impl LiveBridge { "timeout_secs": timeout_secs, }) } - Err((e, egress)) => nomad_remote_error_json(&e, egress), + Err(e) => nomad_remote_error_json(&e), } } @@ -3440,12 +3512,45 @@ fn resolve_inbound_sender_name_map(names: &HashMap, sender_hash: .unwrap_or_else(|| prefix.to_string()) } -/// Remote Nomad page/file error JSON; include path-aware egress when known. -fn nomad_remote_error_json(error: &str, egress: Option<&'static str>) -> serde_json::Value { - match egress { - Some(egress) => serde_json::json!({ "ok": false, "error": error, "egress": egress }), - None => serde_json::json!({ "ok": false, "error": error }), +/// Diagnostics for a failed remote Nomad Link query (page or file). +struct NomadRemoteQueryError { + code: String, + egress: Option<&'static str>, + path_hops: Option, + link_hops: Option, + timeout_secs: Option, + force_path_ok: Option, + raw_error: Option, +} + +/// Remote Nomad page/file error JSON; include path-aware egress and Link budgets when known. +fn nomad_remote_error_json(err: &NomadRemoteQueryError) -> serde_json::Value { + let mut out = serde_json::json!({ "ok": false, "error": err.code }); + let obj = out.as_object_mut().expect("json object"); + if let Some(egress) = err.egress { + obj.insert("egress".into(), serde_json::json!(egress)); + } + if let Some(path_hops) = err.path_hops { + obj.insert("path_hops".into(), serde_json::json!(path_hops)); + } + if let Some(link_hops) = err.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) = err.timeout_secs { + obj.insert("timeout_secs".into(), serde_json::json!(timeout_secs)); } + if let Some(force_path_ok) = err.force_path_ok { + obj.insert("force_path_ok".into(), serde_json::json!(force_path_ok)); + } + if let Some(raw) = err.raw_error.as_deref().filter(|s| !s.is_empty()) { + obj.insert("raw_error".into(), serde_json::json!(raw)); + } + out } /// After a forced DropPath, accept a path only once it has been observed absent @@ -3750,17 +3855,40 @@ mod announce_display_name_tests { } #[test] - fn nomad_remote_error_json_includes_egress_when_known() { - let with_egress = nomad_remote_error_json("link_timeout", Some("tcp")); - assert_eq!(with_egress["ok"], false); - assert_eq!(with_egress["error"], "link_timeout"); - assert_eq!(with_egress["egress"], "tcp"); - assert!(with_egress.get("timeout_secs").is_none()); - - let without = nomad_remote_error_json("missing_identity_hash", None); + fn nomad_remote_error_json_includes_egress_and_link_budget_when_known() { + let with_diag = nomad_remote_error_json(&NomadRemoteQueryError { + code: "link_timeout".into(), + egress: Some("tcp"), + path_hops: Some(1), + link_hops: Some(3), + timeout_secs: Some(45), + force_path_ok: Some(true), + raw_error: Some("timed out waiting for link proof".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["timeout_secs"], 45); + assert_eq!(with_diag["force_path_ok"], true); + assert_eq!(with_diag["raw_error"], "timed out waiting for link proof"); + + let without = nomad_remote_error_json(&NomadRemoteQueryError { + code: "missing_identity_hash".into(), + egress: None, + path_hops: None, + link_hops: None, + timeout_secs: None, + force_path_ok: None, + raw_error: None, + }); assert_eq!(without["ok"], false); assert_eq!(without["error"], "missing_identity_hash"); assert!(without.get("egress").is_none()); + assert!(without.get("link_hops").is_none()); + assert!(without.get("timeout_secs").is_none()); } #[test] diff --git a/reticulum-sidecar/src/stack/nomad_timeouts.rs b/reticulum-sidecar/src/stack/nomad_timeouts.rs index abbfc6af8..cb409e208 100644 --- a/reticulum-sidecar/src/stack/nomad_timeouts.rs +++ b/reticulum-sidecar/src/stack/nomad_timeouts.rs @@ -75,14 +75,17 @@ pub fn resolve_nomad_page_timeout_secs( /// /// MeshChat uses a flat 15s TCP link establishment timeout. Path-table hops on /// hub routes are often inflated (e.g. 8) and must not stretch proof waits to -/// ~48s / the full overall TCP budget. +/// ~48s / the full overall TCP budget. TCP/network always use a flat 3 hops +/// (3 × 6s ≈ 18s) so true 1-hop peers are not shortened to a 6s proof wait. pub fn nomad_link_initiator_hops(egress_via: &str, path_hops: u8) -> u8 { if egress_via == "rf" || egress_via == "ble" { path_hops.clamp(1, 32) } else { - // 3 × 6s = 18s ≈ MeshChat TCP link_establishment_timeout (15s). + // Flat MeshChat-like TCP establish (~18s). Do not use path_hops — clamp(1, 3) + // incorrectly left 1-hop peers at 6s after the #756 proof-budget cap. const TCP_LINK_INITIATOR_HOPS: u8 = 3; - path_hops.clamp(1, TCP_LINK_INITIATOR_HOPS) + let _ = path_hops; + TCP_LINK_INITIATOR_HOPS } } @@ -195,11 +198,15 @@ mod tests { } #[test] - fn tcp_link_initiator_hops_capped_for_meshchat_establish() { + fn tcp_link_initiator_hops_flat_for_meshchat_establish() { assert_eq!(nomad_link_initiator_hops("tcp", 8), 3); - assert_eq!(nomad_link_initiator_hops("network", 1), 1); + 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); + assert_eq!(nomad_link_initiator_hops("network", 8), 3); assert_eq!(nomad_link_initiator_hops("rf", 8), 8); assert_eq!(nomad_link_initiator_hops("ble", 6), 6); + assert_eq!(nomad_link_initiator_hops("rf", 1), 1); } #[test] diff --git a/src/renderer/stores/nomadNetworkStore.test.ts b/src/renderer/stores/nomadNetworkStore.test.ts index d07c99639..d64b7ec55 100644 --- a/src/renderer/stores/nomadNetworkStore.test.ts +++ b/src/renderer/stores/nomadNetworkStore.test.ts @@ -189,19 +189,34 @@ describe('nomadNetworkStore', () => { try { getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); fetchReticulumInterfaces.mockResolvedValue([{ type: 'tcp', enabled: true }]); - proxyGet.mockResolvedValue({ ok: false, error: 'link_timeout' }); + proxyGet.mockResolvedValue({ + ok: false, + error: 'link_timeout', + egress: 'tcp', + path_hops: 1, + link_hops: 3, + proof_budget_secs: 18, + timeout_secs: 45, + force_path_ok: true, + raw_error: 'timed out waiting for link proof', + }); const res = await useNomadNetworkStore .getState() .fetchNomadPage('abcdef12', '/page/index.mu'); - expect(res).toEqual({ ok: false, error: 'link_timeout' }); + expect(res).toMatchObject({ ok: false, error: 'link_timeout', link_hops: 3 }); expect(spy).toHaveBeenCalled(); const firstArg = spy.mock.calls[0]?.[0]; expect(typeof firstArg).toBe('string'); expect(firstArg).toContain('[nomadNetworkStore] page fetch failed'); expect(firstArg).toContain('error=link_timeout'); expect(firstArg).toContain('hash=abcdef12'); + expect(firstArg).toContain('link_hops=3'); + expect(firstArg).toContain('proof_budget_secs=18'); + expect(firstArg).toContain('timeout_secs=45'); + expect(firstArg).toContain('force_path_ok=true'); + expect(firstArg).toContain('raw=timed out waiting for link proof'); } finally { restore(); } diff --git a/src/renderer/stores/nomadNetworkStore.ts b/src/renderer/stores/nomadNetworkStore.ts index d8d1f0962..f83823f0c 100644 --- a/src/renderer/stores/nomadNetworkStore.ts +++ b/src/renderer/stores/nomadNetworkStore.ts @@ -58,16 +58,50 @@ function nomadHashPrefixForLog(hash: string): string { function logNomadFetchFailure( kind: 'page' | 'file', - opts: { hash: string; path: string; hops: number; egress: string; error: string }, + opts: { + hash: string; + path: string; + hops: number; + egress: string; + error: string; + pathHops?: number; + linkHops?: number; + proofBudgetSecs?: number; + timeoutSecs?: number; + forcePathOk?: boolean; + rawError?: string; + }, ): void { const pathSafe = opts.path.replace(/[\r\n]+/g, ' ').slice(0, 200); const errorSafe = opts.error.replace(/[\r\n]+/g, ' ').slice(0, 200); + const parts = [ + `path=${pathSafe}`, + `hops=${opts.hops}`, + `egress=${opts.egress}`, + `error=${errorSafe}`, + ]; + if (opts.pathHops != null) parts.push(`path_hops=${opts.pathHops}`); + if (opts.linkHops != null) parts.push(`link_hops=${opts.linkHops}`); + if (opts.proofBudgetSecs != null) parts.push(`proof_budget_secs=${opts.proofBudgetSecs}`); + if (opts.timeoutSecs != null) parts.push(`timeout_secs=${opts.timeoutSecs}`); + if (opts.forcePathOk != null) parts.push(`force_path_ok=${opts.forcePathOk}`); + if (opts.rawError) { + parts.push(`raw=${opts.rawError.replace(/[\r\n]+/g, ' ').slice(0, 200)}`); + } console.warn( `[nomadNetworkStore] ${kind} fetch failed hash=${nomadHashPrefixForLog(opts.hash)}… ` + - `path=${pathSafe} hops=${opts.hops} egress=${opts.egress} error=${errorSafe}`, + parts.join(' '), ); } +function optionalFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function optionalBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + function hopsForNomadHash(nodes: Map, hash: string): number { return nodes.get(hash.toLowerCase())?.hops ?? 8; } @@ -111,14 +145,30 @@ async function fetchNomadResource( const apiPath = `/api/v1/nomadnetwork/${kind}/${cleanHash}?${qs.toString()}`; const res = (await window.electronAPI.reticulum.proxyGet(apiPath)) as T; if (!res.ok) { - const resRecord = res as { egress?: unknown }; + const resRecord = res as { + egress?: unknown; + path_hops?: unknown; + link_hops?: unknown; + proof_budget_secs?: unknown; + timeout_secs?: unknown; + force_path_ok?: unknown; + raw_error?: unknown; + }; const resEgress = typeof resRecord.egress === 'string' ? resRecord.egress : egress; + const rawError = + typeof resRecord.raw_error === 'string' ? resRecord.raw_error.trim() : undefined; logNomadFetchFailure(kind, { hash: cleanHash, path: opts.path, hops, egress: resEgress, error: res.error?.trim() || 'unknown', + pathHops: optionalFiniteNumber(resRecord.path_hops), + linkHops: optionalFiniteNumber(resRecord.link_hops), + proofBudgetSecs: optionalFiniteNumber(resRecord.proof_budget_secs), + timeoutSecs: optionalFiniteNumber(resRecord.timeout_secs), + forcePathOk: optionalBoolean(resRecord.force_path_ok), + rawError: rawError || undefined, }); } return res; diff --git a/src/shared/nomad-types.ts b/src/shared/nomad-types.ts index 3f1642f62..4fc300ddd 100644 --- a/src/shared/nomad-types.ts +++ b/src/shared/nomad-types.ts @@ -9,7 +9,21 @@ export interface NomadNodeRow { status?: string | null; } -export interface NomadPageResponse { +/** Optional Link-budget diagnostics on failed Nomad page/file fetches. */ +export interface NomadLinkFailureDiagnostics { + /** Path-table hop count used for overall timeout classification. */ + path_hops?: number; + /** Hops passed to Link::new_initiator (TCP/network flat 3 → ~18s proof). */ + link_hops?: number; + /** Effective link-proof wait (seconds): link_hops × 6. */ + proof_budget_secs?: number; + /** Sidecar `force_path_refresh` result when that retry path ran. */ + force_path_ok?: boolean; + /** Unmapped LinkClient error string before sidecar code mapping. */ + raw_error?: string; +} + +export interface NomadPageResponse extends NomadLinkFailureDiagnostics { ok: boolean; content?: string; content_type?: string; @@ -23,7 +37,7 @@ export interface NomadPageResponse { /** NomadNet link request field map (`field_*` / `var_*` keys). */ export type NomadPageRequestData = Record; -export interface NomadFileResponse { +export interface NomadFileResponse extends NomadLinkFailureDiagnostics { ok: boolean; file_name?: string; content_base64?: string; From 5707bdd11a366dda1b9b66fae7ea5e17a2d12e61 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 1 Aug 2026 11:49:02 -0600 Subject: [PATCH 2/4] fix(reticulum): scale TCP Nomad proof budget and clarify path retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore multi-hop headroom (path hops clamped 3–7) after a flat ~18s window still timed out for hub-routed peers, and expose honest path-ensure / link-timeout errors so retries are visible and triageable. --- docs/troubleshooting.md | 6 +- reticulum-sidecar/src/stack/live.rs | 366 ++++++++++++++++-- reticulum-sidecar/src/stack/nomad_timeouts.rs | 26 +- src/renderer/components/NomadNetworkPanel.tsx | 20 +- .../lib/nomad/nomadPageErrorHumanize.test.ts | 22 ++ .../lib/nomad/nomadPageErrorHumanize.ts | 62 ++- src/renderer/locales/cs/translation.json | 11 +- src/renderer/locales/de/translation.json | 11 +- src/renderer/locales/en/translation.json | 11 +- src/renderer/locales/es/translation.json | 11 +- src/renderer/locales/fr/translation.json | 11 +- src/renderer/locales/id/translation.json | 11 +- src/renderer/locales/it/translation.json | 11 +- src/renderer/locales/ja/translation.json | 11 +- src/renderer/locales/ko/translation.json | 11 +- src/renderer/locales/nl/translation.json | 11 +- src/renderer/locales/pl/translation.json | 11 +- src/renderer/locales/pt-BR/translation.json | 11 +- src/renderer/locales/ru/translation.json | 11 +- src/renderer/locales/tr/translation.json | 11 +- src/renderer/locales/uk/translation.json | 11 +- src/renderer/locales/zh/translation.json | 11 +- src/renderer/stores/nomadNetworkStore.test.ts | 40 +- src/renderer/stores/nomadNetworkStore.ts | 104 +++-- .../stores/nomadPageViewerLoad.test.ts | 43 +- src/renderer/stores/nomadPageViewerStore.ts | 47 ++- src/shared/nomad-types.ts | 16 +- 27 files changed, 747 insertions(+), 181 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 48db76d93..7c2003267 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1018,7 +1018,7 @@ In dev, **Start stack** now rebuilds when `reticulum-sidecar/src/**/*.rs` or `Ca | ----------------------- | ------------------------------------------------------------------- | | `path_timeout` | No route to the node (path lookup timed out) | | `pubkey_not_found` | Destination identity key not cached yet — wait for a Nomad announce | -| `link_timeout` | Link could not be established in time | +| `link_timeout` | Link could not be established in time (UI may say path OK vs stale) | | `response_timeout` | Link opened but page payload did not arrive in time | | `missing_identity_hash` | No remembered identity for the node yet | | `transport_unavailable` | Reticulum transport unavailable — restart stack | @@ -1028,7 +1028,7 @@ In dev, **Start stack** now rebuilds when `reticulum-sidecar/src/**/*.rs` or `Ca Unrecognized codes pass through unchanged. -TCP/network Nomad Links use a **flat ~18s** link-proof budget (`link_hops=3` × 6s, MeshChat parity) regardless of path-table hops — not `6s × path hops`. Failure lines in the app log may include `link_hops`, `proof_budget_secs`, `timeout_secs`, and `raw=` for triage. +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. **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). @@ -1038,7 +1038,7 @@ TCP/network Nomad Links use a **flat ~18s** link-proof budget (`link_hops=3` × 2. Rebuild sidecar: `pnpm run reticulum:sidecar:build`, restart stack. 3. Prefer low-hop nodes while testing; hop count is shown in the Nomad list. 4. Match the humanized message to the table above — `path_timeout` / high hops often mean RF reachability limits, not a mesh-client bug. -5. For TCP `link_timeout`, check log fields `link_hops` / `proof_budget_secs` / `raw=` — short fails with `link_hops=1` were a known budget bug; persistent fails after ~18s proof usually mean the peer/hub did not return LRPROOF. +5. For TCP `link_timeout`, check log fields `path_hops` / `link_hops` / `proof_budget_secs` / `raw=` — UI hop counts can lag the path table; trust `path_hops`. Persistent fails after the full proof budget usually mean the peer/hub did not return LRPROOF. ### Reticulum sidecar stops during dev (Vite HMR) diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index ee300f3ca..58aebf5ca 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -827,7 +827,8 @@ impl LiveBridge { payload: Vec, interfaces: &[InterfaceRow], force_path_refresh: bool, - ) -> Result<(Vec, &'static str, u64), NomadRemoteQueryError> { + ) -> Result<(Vec, NomadRemoteQueryOk), NomadRemoteQueryError> { + let query_started = tokio::time::Instant::now(); let remote_hash = parse_hash16(identity_hash_hex).map_err(|e| NomadRemoteQueryError { code: e, egress: None, @@ -835,7 +836,9 @@ impl LiveBridge { link_hops: None, timeout_secs: None, force_path_ok: None, + path_ensure_kind: None, raw_error: None, + elapsed_ms: 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. @@ -855,12 +858,13 @@ impl LiveBridge { (hops, iface) }; let (mut cached_hops, mut path_iface) = read_path_cache(); - let mut force_path_ok: Option = None; - // Stale cached hops often survive LinkClient pubkey recall; force a - // RequestPath on retry so the second attempt is not a no-op. Nomad uses a - // shorter wait and may fall through on never-absent hub routes. + // 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>; if force_path_refresh { - let path_ok = self + let report = self .ensure_path_for_direct_with_opts( hash_hex, true, @@ -868,16 +872,77 @@ impl LiveBridge { true, ) .await; + let kind = report.kind.as_str(); + // Honest signal: true only for rediscovered-after-absence. + let path_ok = matches!(report.kind, PathEnsureKind::Rediscovered); force_path_ok = Some(path_ok); - tracing::info!( + path_ensure_kind = Some(kind); + tracing::debug!( target: "nomad", dest = %hash_hex, - path_ok, - "forced path refresh before Nomad query" + kind, + ok = report.ok, + force_path_ok = path_ok, + had_cached = report.had_cached, + saw_path_absent = report.saw_path_absent, + "Nomad path ensure (retry)" ); let refreshed = read_path_cache(); cached_hops = refreshed.0; path_iface = refreshed.1; + } else { + let had_cached = self + .outbound + .lock() + .map(|d| d.has_path_to(hash_hex)) + .unwrap_or(false); + if had_cached { + force_path_ok = Some(false); + path_ensure_kind = Some(PathEnsureKind::CachedHit.as_str()); + } else { + let report = self + .ensure_path_for_direct_with_opts( + hash_hex, + false, + NOMAD_TCP_PATH_PROBE_WAIT, + false, + ) + .await; + let kind = report.kind.as_str(); + let path_ok = matches!(report.kind, PathEnsureKind::Rediscovered); + force_path_ok = Some(path_ok); + path_ensure_kind = Some(kind); + tracing::debug!( + target: "nomad", + dest = %hash_hex, + kind, + ok = report.ok, + "Nomad path ensure (first, missing)" + ); + let refreshed = read_path_cache(); + cached_hops = refreshed.0; + path_iface = refreshed.1; + if !report.ok { + let hops = cached_hops.unwrap_or(8); + let (timeout_secs, egress) = nomad_timeouts::resolve_nomad_page_timeout_secs( + interfaces, + hops, + path_iface.as_deref(), + self.primary_local_serial_id().as_deref(), + ); + return Err(NomadRemoteQueryError { + code: "path_timeout".into(), + egress: Some(egress), + path_hops: Some(hops), + link_hops: None, + timeout_secs: Some(timeout_secs), + force_path_ok, + path_ensure_kind, + raw_error: Some(format!("path ensure kind={kind} (no cached path)")), + elapsed_ms: Some(elapsed_ms_since(query_started)), + }); + } + } } let hops = match cached_hops { Some(h) => h, @@ -897,11 +962,37 @@ impl LiveBridge { link_hops: None, timeout_secs: Some(timeout_secs), force_path_ok, + path_ensure_kind, raw_error: None, + elapsed_ms: Some(elapsed_ms_since(query_started)), }); } let link_hops = nomad_timeouts::nomad_link_initiator_hops(egress, hops); let proof_budget_secs = u64::from(link_hops).saturating_mul(6); + // 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, + Some(&remote_hash), + )); + let announce_dest_matches_link_dest = link_dest_hex.eq_ignore_ascii_case(&key); + tracing::debug!( + target: "nomad", + dest = %hash_hex, + identity = %identity_hash_hex, + link_dest = %link_dest_hex, + announce_dest_matches_link_dest, + path = %path, + path_hops = hops, + link_hops, + proof_budget_secs, + timeout_secs, + egress, + path_iface = ?path_iface, + force_path_refresh, + force_path_ok = ?force_path_ok, + 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 @@ -929,7 +1020,9 @@ impl LiveBridge { link_hops: Some(link_hops), timeout_secs: Some(timeout_secs), force_path_ok, + path_ensure_kind, raw_error: None, + elapsed_ms: Some(elapsed_ms_since(query_started)), }); }; if self.nomad_link_generation.load(Ordering::SeqCst) != my_gen { @@ -940,7 +1033,9 @@ impl LiveBridge { link_hops: Some(link_hops), timeout_secs: Some(timeout_secs), force_path_ok, + path_ensure_kind, raw_error: None, + elapsed_ms: Some(elapsed_ms_since(query_started)), }); } let client = LinkClient::new(self.handle.transport_tx.clone(), self.identity.clone()); @@ -961,7 +1056,9 @@ impl LiveBridge { link_hops: Some(link_hops), timeout_secs: Some(timeout_secs), force_path_ok, + path_ensure_kind, raw_error: None, + elapsed_ms: None, }), query_result = query_fut => { query_result.map_err(|e| { @@ -974,7 +1071,9 @@ impl LiveBridge { link_hops: Some(link_hops), timeout_secs: Some(timeout_secs), force_path_ok, + path_ensure_kind, raw_error: Some(raw), + elapsed_ms: None, } }) } @@ -982,9 +1081,39 @@ 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) => Ok((bytes, egress, timeout_secs)), - Err(err) => { + 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, + proof_budget_secs, + force_path_ok, + path_ensure_kind, + elapsed_ms, + }, + )) + } + Err(mut err) => { + err.elapsed_ms = Some(elapsed_ms); tracing::warn!( target: "nomad", dest = %hash_hex, @@ -994,10 +1123,12 @@ impl LiveBridge { 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 page/file Link query failed" + "Nomad Link query failed" ); Err(err) } @@ -1047,20 +1178,20 @@ impl LiveBridge { ) .await { - Ok((bytes, egress, timeout_secs)) => { + Ok((bytes, meta)) => { if bytes.len() > NOMAD_FILE_MAX_BYTES { return serde_json::json!({ "ok": false, "error": "response_too_large" }); } let file_name = nomad_file_name_from_path(path); let content_base64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); - serde_json::json!({ + let mut out = serde_json::json!({ "ok": true, "file_name": file_name, "content_base64": content_base64, - "egress": egress, - "timeout_secs": timeout_secs, - }) + }); + merge_nomad_remote_ok_fields(&mut out, &meta); + out } Err(e) => nomad_remote_error_json(&e), } @@ -1121,7 +1252,7 @@ impl LiveBridge { ) .await { - Ok((bytes, egress, timeout_secs)) => { + Ok((bytes, meta)) => { if bytes.len() > NOMAD_PAGE_MAX_BYTES { return serde_json::json!({ "ok": false, "error": "response_too_large" }); } @@ -1131,13 +1262,13 @@ impl LiveBridge { } else { "text" }; - serde_json::json!({ + let mut out = serde_json::json!({ "ok": true, "content": content, "content_type": content_type, - "egress": egress, - "timeout_secs": timeout_secs, - }) + }); + merge_nomad_remote_ok_fields(&mut out, &meta); + out } Err(e) => nomad_remote_error_json(&e), } @@ -2005,29 +2136,43 @@ impl LiveBridge { async fn ensure_path_for_direct(&self, destination_hex: &str, force: bool) -> bool { self.ensure_path_for_direct_with_opts(destination_hex, force, Duration::from_secs(8), false) .await + .ok } /// Like [`Self::ensure_path_for_direct`], with a custom wait and optional /// fall-through when a forced DropPath never observes path absence (common /// on TCP hub routes that reinstall immediately). + /// + /// `ok` alone is not enough for Nomad TCP: `kind` distinguishes a cache hit, + /// a real DropPath→RequestPath rediscovery, and stale accept fall-through. async fn ensure_path_for_direct_with_opts( &self, destination_hex: &str, force: bool, max_wait: Duration, accept_existing_on_timeout: bool, - ) -> bool { + ) -> PathEnsureReport { let already = self .outbound .lock() .map(|d| d.has_path_to(destination_hex)) .unwrap_or(false); if already && !force { - return true; + return PathEnsureReport { + ok: true, + kind: PathEnsureKind::CachedHit, + had_cached: true, + saw_path_absent: false, + }; } let hops_before = self.hops_to_destination(destination_hex).await; let Ok(dest) = parse_hash16(destination_hex) else { - return false; + return PathEnsureReport { + ok: false, + kind: PathEnsureKind::Missing, + had_cached: already, + saw_path_absent: false, + }; }; // Drop the installed route first so the wait loop cannot succeed on the @@ -2088,7 +2233,12 @@ impl LiveBridge { "refreshed path hops before propagation sync" ); } - return true; + return PathEnsureReport { + ok: true, + kind: PathEnsureKind::Rediscovered, + had_cached: already, + saw_path_absent, + }; } // Forced refresh: only accept a path that passed the same absence gate as // the wait loop — never the never-invalidated stale route (unless Nomad @@ -2118,7 +2268,19 @@ impl LiveBridge { "path refresh timed out after dropping cached route" ); } - accept + let kind = if !accept { + PathEnsureKind::Missing + } else if force && already && !saw_path_absent && accept_existing_on_timeout { + PathEnsureKind::StaleAccept + } else { + PathEnsureKind::Rediscovered + }; + PathEnsureReport { + ok: accept, + kind, + had_cached: already, + saw_path_absent, + } } /// Ensure destination public key is known before choosing Direct delivery. @@ -3512,6 +3674,18 @@ fn resolve_inbound_sender_name_map(names: &HashMap, sender_hash: .unwrap_or_else(|| prefix.to_string()) } +/// Success metadata for a remote Nomad Link query (page or file). +struct NomadRemoteQueryOk { + egress: &'static str, + timeout_secs: u64, + path_hops: u8, + link_hops: u8, + proof_budget_secs: u64, + force_path_ok: Option, + path_ensure_kind: Option<&'static str>, + elapsed_ms: u64, +} + /// Diagnostics for a failed remote Nomad Link query (page or file). struct NomadRemoteQueryError { code: String, @@ -3520,20 +3694,28 @@ struct NomadRemoteQueryError { link_hops: Option, timeout_secs: Option, force_path_ok: Option, + path_ensure_kind: Option<&'static str>, raw_error: Option, + elapsed_ms: Option, } -/// Remote Nomad page/file error JSON; include path-aware egress and Link budgets when known. -fn nomad_remote_error_json(err: &NomadRemoteQueryError) -> serde_json::Value { - let mut out = serde_json::json!({ "ok": false, "error": err.code }); - let obj = out.as_object_mut().expect("json object"); - if let Some(egress) = err.egress { - obj.insert("egress".into(), serde_json::json!(egress)); - } - if let Some(path_hops) = err.path_hops { +fn elapsed_ms_since(started: tokio::time::Instant) -> u64 { + u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX) +} + +fn insert_nomad_link_budget_fields( + obj: &mut serde_json::Map, + path_hops: Option, + link_hops: Option, + timeout_secs: Option, + force_path_ok: Option, + path_ensure_kind: Option<&str>, + elapsed_ms: Option, +) { + if let Some(path_hops) = path_hops { obj.insert("path_hops".into(), serde_json::json!(path_hops)); } - if let Some(link_hops) = err.link_hops { + 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( @@ -3541,12 +3723,55 @@ fn nomad_remote_error_json(err: &NomadRemoteQueryError) -> serde_json::Value { serde_json::json!(u64::from(link_hops).saturating_mul(6)), ); } - if let Some(timeout_secs) = err.timeout_secs { + if let Some(timeout_secs) = timeout_secs { obj.insert("timeout_secs".into(), serde_json::json!(timeout_secs)); } - if let Some(force_path_ok) = err.force_path_ok { + if let Some(force_path_ok) = force_path_ok { obj.insert("force_path_ok".into(), serde_json::json!(force_path_ok)); } + if let Some(kind) = path_ensure_kind.filter(|s| !s.is_empty()) { + obj.insert("path_ensure_kind".into(), serde_json::json!(kind)); + } + if let Some(elapsed_ms) = elapsed_ms { + obj.insert("elapsed_ms".into(), serde_json::json!(elapsed_ms)); + } +} + +fn merge_nomad_remote_ok_fields(out: &mut serde_json::Value, meta: &NomadRemoteQueryOk) { + let obj = out.as_object_mut().expect("json object"); + obj.insert("egress".into(), serde_json::json!(meta.egress)); + insert_nomad_link_budget_fields( + obj, + Some(meta.path_hops), + Some(meta.link_hops), + Some(meta.timeout_secs), + meta.force_path_ok, + meta.path_ensure_kind, + Some(meta.elapsed_ms), + ); + // Prefer the explicit proof budget from the query (same as link_hops × 6). + obj.insert( + "proof_budget_secs".into(), + serde_json::json!(meta.proof_budget_secs), + ); +} + +/// Remote Nomad page/file error JSON; include path-aware egress and Link budgets when known. +fn nomad_remote_error_json(err: &NomadRemoteQueryError) -> serde_json::Value { + let mut out = serde_json::json!({ "ok": false, "error": err.code }); + let obj = out.as_object_mut().expect("json object"); + if let Some(egress) = err.egress { + obj.insert("egress".into(), serde_json::json!(egress)); + } + insert_nomad_link_budget_fields( + obj, + err.path_hops, + err.link_hops, + err.timeout_secs, + err.force_path_ok, + err.path_ensure_kind, + err.elapsed_ms, + ); if let Some(raw) = err.raw_error.as_deref().filter(|s| !s.is_empty()) { obj.insert("raw_error".into(), serde_json::json!(raw)); } @@ -3684,6 +3909,41 @@ const NOMAD_LINK_LOCK_WAIT: Duration = Duration::from_secs(8); /// Cap DropPath + rediscover before a Nomad Link attempt (inside overall TCP budget). 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); + +/// Outcome of [`LiveStack::ensure_path_for_direct_with_opts`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PathEnsureKind { + /// `has_path_to` was already true and `force` was false — not a reachability check. + CachedHit, + /// Path was absent (or never present), then reappeared after RequestPath. + Rediscovered, + /// Forced refresh timed out but accepted the never-cleared route (`accept_existing`). + StaleAccept, + /// No usable path after the wait. + Missing, +} + +impl PathEnsureKind { + const fn as_str(self) -> &'static str { + match self { + Self::CachedHit => "cached_hit", + Self::Rediscovered => "rediscovered", + Self::StaleAccept => "stale_accept", + Self::Missing => "missing", + } + } +} + +#[derive(Debug, Clone, Copy)] +struct PathEnsureReport { + ok: bool, + kind: PathEnsureKind, + had_cached: bool, + saw_path_absent: bool, +} + fn path_table_added_hashes_capped(prev: &HashSet, next: &HashSet) -> Vec { let mut added = path_table_added_hashes(prev, next); if added.len() > MAX_PEERS_UPDATED_ADDED { @@ -3863,7 +4123,9 @@ mod announce_display_name_tests { link_hops: Some(3), timeout_secs: Some(45), force_path_ok: Some(true), + path_ensure_kind: None, raw_error: Some("timed out waiting for link proof".into()), + elapsed_ms: Some(18_250), }); assert_eq!(with_diag["ok"], false); assert_eq!(with_diag["error"], "link_timeout"); @@ -3873,6 +4135,7 @@ mod announce_display_name_tests { assert_eq!(with_diag["proof_budget_secs"], 18); 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"); let without = nomad_remote_error_json(&NomadRemoteQueryError { @@ -3882,7 +4145,9 @@ mod announce_display_name_tests { link_hops: None, timeout_secs: None, force_path_ok: None, + path_ensure_kind: None, raw_error: None, + elapsed_ms: None, }); assert_eq!(without["ok"], false); assert_eq!(without["error"], "missing_identity_hash"); @@ -3891,6 +4156,31 @@ mod announce_display_name_tests { assert!(without.get("timeout_secs").is_none()); } + #[test] + fn nomad_remote_ok_json_includes_link_budget_fields() { + let mut out = serde_json::json!({ "ok": true, "content": "hi" }); + merge_nomad_remote_ok_fields( + &mut out, + &NomadRemoteQueryOk { + egress: "tcp", + timeout_secs: 45, + path_hops: 1, + link_hops: 3, + proof_budget_secs: 18, + force_path_ok: None, + path_ensure_kind: None, + elapsed_ms: 4200, + }, + ); + 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["timeout_secs"], 45); + assert_eq!(out["elapsed_ms"], 4200); + assert!(out.get("force_path_ok").is_none()); + } + #[test] fn force_path_refresh_timeout_accepts_fallthrough_when_never_absent() { // Nomad force refresh: path never left the table, but fall-through is on. diff --git a/reticulum-sidecar/src/stack/nomad_timeouts.rs b/reticulum-sidecar/src/stack/nomad_timeouts.rs index cb409e208..ed9a9d35b 100644 --- a/reticulum-sidecar/src/stack/nomad_timeouts.rs +++ b/reticulum-sidecar/src/stack/nomad_timeouts.rs @@ -73,19 +73,17 @@ pub fn resolve_nomad_page_timeout_secs( /// Hops passed to `Link::new_initiator` (scales establishment timeout at 6s/hop). /// -/// MeshChat uses a flat 15s TCP link establishment timeout. Path-table hops on -/// hub routes are often inflated (e.g. 8) and must not stretch proof waits to -/// ~48s / the full overall TCP budget. TCP/network always use a flat 3 hops -/// (3 × 6s ≈ 18s) so true 1-hop peers are not shortened to a 6s proof wait. +/// TCP/network: restore release-like multi-hop proof windows (v5.25.0 passed raw +/// path hops into LinkClient). Floor at 3 (~18s) so 1-hop UI/path under-budget +/// does not collapse to 6s; cap at 7 (~42s) under the 45s MeshChat TCP overall. +/// #756's flat max-3 (~18s) is why some hub pages work on release but fail on HEAD. pub fn nomad_link_initiator_hops(egress_via: &str, path_hops: u8) -> u8 { if egress_via == "rf" || egress_via == "ble" { path_hops.clamp(1, 32) } else { - // Flat MeshChat-like TCP establish (~18s). Do not use path_hops — clamp(1, 3) - // incorrectly left 1-hop peers at 6s after the #756 proof-budget cap. - const TCP_LINK_INITIATOR_HOPS: u8 = 3; - let _ = path_hops; - TCP_LINK_INITIATOR_HOPS + const TCP_LINK_INITIATOR_HOPS_MIN: u8 = 3; + const TCP_LINK_INITIATOR_HOPS_MAX: u8 = 7; + path_hops.clamp(TCP_LINK_INITIATOR_HOPS_MIN, TCP_LINK_INITIATOR_HOPS_MAX) } } @@ -198,12 +196,16 @@ mod tests { } #[test] - fn tcp_link_initiator_hops_flat_for_meshchat_establish() { - assert_eq!(nomad_link_initiator_hops("tcp", 8), 3); + fn tcp_link_initiator_hops_floored_and_capped_for_release_parity() { + // Floor 3 (~18s); scale with path; cap 7 (~42s) under 45s overall. 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); - assert_eq!(nomad_link_initiator_hops("network", 8), 3); + assert_eq!(nomad_link_initiator_hops("tcp", 5), 5); + assert_eq!(nomad_link_initiator_hops("network", 4), 4); + assert_eq!(nomad_link_initiator_hops("tcp", 7), 7); + assert_eq!(nomad_link_initiator_hops("tcp", 8), 7); + assert_eq!(nomad_link_initiator_hops("network", 32), 7); assert_eq!(nomad_link_initiator_hops("rf", 8), 8); assert_eq!(nomad_link_initiator_hops("ble", 6), 6); assert_eq!(nomad_link_initiator_hops("rf", 1), 1); diff --git a/src/renderer/components/NomadNetworkPanel.tsx b/src/renderer/components/NomadNetworkPanel.tsx index c55f08fa5..c2124ab83 100644 --- a/src/renderer/components/NomadNetworkPanel.tsx +++ b/src/renderer/components/NomadNetworkPanel.tsx @@ -253,8 +253,10 @@ export default function NomadNetworkPanel({ const pageLoading = useNomadPageViewerStore((s) => s.pageLoading); const pageLoadingStartedAt = useNomadPageViewerStore((s) => s.pageLoadingStartedAt); const pageLoadingBudgetSec = useNomadPageViewerStore((s) => s.pageLoadingBudgetSec); + const pageLoadingRetrying = useNomadPageViewerStore((s) => s.pageLoadingRetrying); const pageErrorRaw = useNomadPageViewerStore((s) => s.pageErrorRaw); const pageErrorEgress = useNomadPageViewerStore((s) => s.pageErrorEgress); + const pageErrorDiag = useNomadPageViewerStore((s) => s.pageErrorDiag); const pageErrorNodeSnapshot = useNomadPageViewerStore((s) => s.pageErrorNodeSnapshot); const announceReloadDone = useNomadPageViewerStore((s) => s.announceReloadDone); const loadPage = useNomadPageViewerStore((s) => s.loadPage); @@ -263,7 +265,7 @@ export default function NomadNetworkPanel({ const setInvalidUrlError = useNomadPageViewerStore((s) => s.setInvalidUrlError); const markAnnounceReloadDone = useNomadPageViewerStore((s) => s.markAnnounceReloadDone); - const pageError = pageErrorRaw ? humanizeNomadPageError(pageErrorRaw, t) : null; + const pageError = pageErrorRaw ? humanizeNomadPageError(pageErrorRaw, t, pageErrorDiag) : null; const pageErrorCode = pageErrorRaw; const [activeTab, setActiveTab] = useState('favourites'); @@ -1041,11 +1043,17 @@ export default function NomadNetworkPanel({

{pageLoadingStartedAt == null ? t('nomadNetwork.pageLoading') - : pageLoadingRemainingSec > 0 - ? t('nomadNetwork.pageLoadingCountdown', { - time: formatNomadPageCountdown(pageLoadingRemainingSec), - }) - : t('nomadNetwork.pageLoadingCountdownOverdue')} + : pageLoadingRetrying + ? pageLoadingRemainingSec > 0 + ? t('nomadNetwork.pageLoadingRetryCountdown', { + time: formatNomadPageCountdown(pageLoadingRemainingSec), + }) + : t('nomadNetwork.pageLoadingRetryOverdue') + : pageLoadingRemainingSec > 0 + ? t('nomadNetwork.pageLoadingCountdown', { + time: formatNomadPageCountdown(pageLoadingRemainingSec), + }) + : t('nomadNetwork.pageLoadingCountdownOverdue')}

) : pageError ? (
diff --git a/src/renderer/lib/nomad/nomadPageErrorHumanize.test.ts b/src/renderer/lib/nomad/nomadPageErrorHumanize.test.ts index 3c96a2885..a30119021 100644 --- a/src/renderer/lib/nomad/nomadPageErrorHumanize.test.ts +++ b/src/renderer/lib/nomad/nomadPageErrorHumanize.test.ts @@ -48,6 +48,28 @@ describe('nomadPageErrorHumanize', () => { expect(humanizeNomadPageError(null, t)).toBe('t:common.error'); }); + it('uses path-ensure diagnostics for link_timeout copy', () => { + expect(nomadPageErrorI18nKey('link_timeout')).toBe('nomadNetwork.errors.linkTimeout'); + expect( + nomadPageErrorI18nKey('link_timeout', { + forcePathOk: true, + pathEnsureKind: 'rediscovered', + }), + ).toBe('nomadNetwork.errors.linkTimeoutPathOk'); + expect( + nomadPageErrorI18nKey('link_timeout', { pathEnsureKind: 'cached_hit', forcePathOk: false }), + ).toBe('nomadNetwork.errors.linkTimeoutCachedPath'); + expect(nomadPageErrorI18nKey('path_timeout', { pathEnsureKind: 'stale_accept' })).toBe( + 'nomadNetwork.errors.pathTimeoutStale', + ); + expect( + humanizeNomadPageError('link_timeout', t, { + forcePathOk: true, + pathEnsureKind: 'rediscovered', + }), + ).toBe('t:nomadNetwork.errors.linkTimeoutPathOk'); + }); + it('classifies announce-reload vs force-path-refresh errors', () => { expect(isRetryableNomadPageError('path_timeout')).toBe(true); expect(isRetryableNomadPageError('link_timeout')).toBe(true); diff --git a/src/renderer/lib/nomad/nomadPageErrorHumanize.ts b/src/renderer/lib/nomad/nomadPageErrorHumanize.ts index c0e1054eb..99e1dd569 100644 --- a/src/renderer/lib/nomad/nomadPageErrorHumanize.ts +++ b/src/renderer/lib/nomad/nomadPageErrorHumanize.ts @@ -1,5 +1,12 @@ /** Map sidecar Nomad page/file error codes to i18n keys / display text. */ +/** Optional path/link diagnostics from the sidecar Nomad fetch response. */ +export interface NomadPageErrorDiag { + forcePathOk?: boolean | null; + pathEnsureKind?: string | null; + pathHops?: number | null; +} + const NOMAD_ERROR_I18N_KEYS: Record = { path_timeout: 'nomadNetwork.errors.pathTimeout', pubkey_not_found: 'nomadNetwork.errors.pubkeyNotFound', @@ -54,10 +61,46 @@ function isRfOrBleNomadEgress(egress: string | null | undefined): boolean { return atom === 'rf' || atom === 'ble'; } -export function nomadPageErrorI18nKey(error: string | null | undefined): string | null { +function normalizePathEnsureKind(kind: string | null | undefined): string | null { + const trimmed = kind?.trim().toLowerCase(); + return trimmed ? trimmed : null; +} + +/** + * Pick an i18n key for a Nomad page/file error, using path-ensure diagnostics when present. + * We do not run a separate peer probe API — `path_ensure_kind` / `force_path_ok` are the + * DropPath→RequestPath (or cache-hit) result from the Nomad Link attempt. + */ +export function nomadPageErrorI18nKey( + error: string | null | undefined, + diag?: NomadPageErrorDiag | null, +): string | null { if (error == null) return null; const trimmed = error.trim(); if (!trimmed) return null; + + const kind = normalizePathEnsureKind(diag?.pathEnsureKind); + if (trimmed === 'link_timeout') { + // Retry rediscovered a path after DropPath, but LRPROOF / page still failed. + if (diag?.forcePathOk === true || kind === 'rediscovered') { + return 'nomadNetwork.errors.linkTimeoutPathOk'; + } + // First attempt used a listed path that never completed a link. + if (kind === 'cached_hit') { + return 'nomadNetwork.errors.linkTimeoutCachedPath'; + } + return 'nomadNetwork.errors.linkTimeout'; + } + if (trimmed === 'path_timeout') { + if (kind === 'stale_accept') { + return 'nomadNetwork.errors.pathTimeoutStale'; + } + return 'nomadNetwork.errors.pathTimeout'; + } + if (trimmed === 'response_timeout') { + return 'nomadNetwork.errors.responseTimeout'; + } + return NOMAD_ERROR_I18N_KEYS[trimmed] ?? null; } @@ -89,11 +132,26 @@ export function shouldForceNomadPathRefreshRetry( export function humanizeNomadPageError( error: string | null | undefined, t: (key: string) => string, + diag?: NomadPageErrorDiag | null, ): string { const trimmed = error?.trim(); if (!trimmed) { return t('common.error'); } - const key = nomadPageErrorI18nKey(trimmed); + const key = nomadPageErrorI18nKey(trimmed, diag); return key ? t(key) : trimmed; } + +/** Build diag from a Nomad page/file API response for humanize / store. */ +export function nomadPageErrorDiagFromResponse(res: { + force_path_ok?: unknown; + path_ensure_kind?: unknown; + path_hops?: unknown; +}): NomadPageErrorDiag { + return { + forcePathOk: typeof res.force_path_ok === 'boolean' ? res.force_path_ok : null, + pathEnsureKind: typeof res.path_ensure_kind === 'string' ? res.path_ensure_kind : null, + pathHops: + typeof res.path_hops === 'number' && Number.isFinite(res.path_hops) ? res.path_hops : null, + }; +} diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 529d74395..f55940674 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -2951,7 +2951,7 @@ "collapseNodeList": "Sbalit seznam uzlů", "expandNodeList": "Rozbalit seznam uzlů", "errors": { - "pathTimeout": "K tomuto uzlu neexistuje cesta (vypršel časový limit vyhledávání cesty). Zkuste bližší uzel nebo počkejte na novější announce.", + "pathTimeout": "K tomuto uzlu neexistuje použitelná cesta (vypršel časový limit vyhledávání cesty). Počkejte na další oznámení Nomad a zkuste to znovu. Pokud to stále selže, zkuste jiný hub nebo bližší uzel.", "pubkeyNotFound": "Nelze najít identifikační klíč tohoto uzlu. Počkejte na oznámení Nomad a zkuste to znovu.", "linkTimeout": "Propojení s uzlem se nepodařilo navázat včas.", "responseTimeout": "Uzel přijal odkaz, ale stránku nevrátil včas.", @@ -2961,7 +2961,10 @@ "responseTooLarge": "The node returned more data than mesh-client will accept for this request.", "nomadBusy": "Jiný požadavek na stránku nebo soubor Nomad stále probíhá. Zkuste to znovu za chvíli.", "nomadNotServing": "Toto je váš uzel Nomad, ale hosting My Pages neběží. Otevřete Moje stránky, vyberte složku a začněte poskytovat místní náhled.", - "networkNotReady": "Čekání na připojení hubu nebo rádiového rozhraní. Zkuste to znovu, jakmile připojení zobrazí rozhraní." + "networkNotReady": "Čekání na připojení hubu nebo rádiového rozhraní. Zkuste to znovu, jakmile připojení zobrazí rozhraní.", + "pathTimeoutStale": "Nelze obnovit trasu k tomuto uzlu (zastaralá cesta). Počkej na další oznámení a zkus to znovu. Trvalé poruchy často znamenají, že uzel je offline.", + "linkTimeoutCachedPath": "Je uvedena cesta k tomuto uzlu, ale odkaz nebyl nikdy dokončen — trasa může být zastaralá. Počkej na další oznámení a zkus to znovu. Trvalé poruchy obvykle znamenají, že uzel je offline nebo není dosažitelný přes TCP.", + "linkTimeoutPathOk": "Trasa k tomuto uzlu byla potvrzena, ale stránka Nomad neodpověděla. Počkejte na čerstvější oznámení a zkuste to znovu. Pokud stále selhává, uzel může být offline nebo nemůže hostit tuto stránku." }, "fitWidth": "Přizpůsobit stránku oknu", "openWidth": "Šířka stránky", @@ -3011,7 +3014,9 @@ "pageLoadingCountdown": "Načítání stránky… Zbývá {{time}}", "pageLoadingCountdownOverdue": "Načítání stránky… stále funguje", "pageReadyToast": "Nomad — Nomádská stránka připravena: {{name}}", - "staleLastSeenHint": "Naposledy slyšet {{time}} – tento uzel může být offline, i když je uveden jako online." + "staleLastSeenHint": "Naposledy slyšet {{time}} – tento uzel může být offline, i když je uveden jako online.", + "pageLoadingRetryCountdown": "Vypršel časový limit prvního pokusu — osvěžující cesta a opakování… zbývá {{time}}", + "pageLoadingRetryOverdue": "Obnovení cesty a opakování... stále funguje" }, "packetDistribution": { "overallDistribution": "Celková distribuce", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index dda3134e3..627791145 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -2949,7 +2949,7 @@ "collapseNodeList": "Knotenliste einklappen", "expandNodeList": "Knotenliste erweitern", "errors": { - "pathTimeout": "Keine Route zu diesem Knoten (Pfadabfrage zeitüberschritten). Versuche einen näheren Knoten oder warte auf einen aktuelleren Announce.", + "pathTimeout": "Kein nutzbarer Pfad zu diesem Knoten (Pfadabfrage zeitüberschritten). Warten Sie auf die nächste Nomad-Ankündigung und versuchen Sie es erneut. Wenn es weiterhin fehlschlägt, versuchen Sie einen anderen Hub oder einen näheren Knoten.", "pubkeyNotFound": "Der Identitätsschlüssel dieses Knotens konnte nicht gefunden werden. Warten Sie auf eine Nomad-Ankündigung und versuchen Sie es dann erneut.", "linkTimeout": "Verbindung zum Knoten konnte nicht rechtzeitig hergestellt werden.", "responseTimeout": "Der Knoten hat den Link akzeptiert, aber die Seite nicht rechtzeitig zurückgegeben.", @@ -2959,7 +2959,10 @@ "responseTooLarge": "Der Knoten hat mehr Daten zurückgegeben, als mesh-client für diese Anfrage akzeptiert.", "nomadBusy": "Eine andere Nomad-Seiten- oder Dateianfrage läuft noch. Versuche es gleich noch einmal.", "nomadNotServing": "Dies ist Ihr Nomad-Knoten, aber das Hosting „Meine Seiten“ läuft nicht. Öffnen Sie „Meine Seiten“, wählen Sie einen Ordner aus und beginnen Sie mit der Bereitstellung zur lokalen Vorschau.", - "networkNotReady": "Warten darauf, dass ein Hub oder eine Funkschnittstelle online geht. Versuchen Sie es erneut, sobald Connection eine Schnittstelle anzeigt." + "networkNotReady": "Warten darauf, dass ein Hub oder eine Funkschnittstelle online geht. Versuchen Sie es erneut, sobald Connection eine Schnittstelle anzeigt.", + "pathTimeoutStale": "Die Route zu diesem Knoten (veralteter Pfad) konnte nicht aktualisiert werden. Warten Sie auf die nächste Ankündigung und versuchen Sie es dann erneut. Anhaltende Fehler bedeuten oft, dass der Knoten offline ist.", + "linkTimeoutCachedPath": "Ein Pfad zu diesem Knoten wird aufgelistet, aber die Verbindung wurde nie abgeschlossen — die Route kann veraltet sein. Warten Sie auf die nächste Ankündigung und versuchen Sie es dann erneut. Anhaltende Ausfälle bedeuten in der Regel, dass der Knoten offline oder nicht über TCP erreichbar ist.", + "linkTimeoutPathOk": "Eine Route zu diesem Knoten wurde bestätigt, aber die Nomad-Seite antwortete nicht. Warten Sie auf eine frischere Ankündigung und versuchen Sie es erneut. Wenn es weiterhin fehlschlägt, ist der Knoten möglicherweise offline oder hostet diese Seite nicht." }, "fitWidth": "An Fenster anpassen", "openWidth": "Seitenbreite", @@ -3009,7 +3012,9 @@ "pageLoadingCountdown": "Seite wird geladen… {{time}} übrig", "pageLoadingCountdownOverdue": "Seite wird geladen... funktioniert immer noch", "pageReadyToast": "Nomad-Seite bereit: {{name}}", - "staleLastSeenHint": "Zuletzt gehört {{time}} – dieser Knoten ist möglicherweise offline, auch wenn er als online aufgeführt ist." + "staleLastSeenHint": "Zuletzt gehört {{time}} – dieser Knoten ist möglicherweise offline, auch wenn er als online aufgeführt ist.", + "pageLoadingRetryCountdown": "Zeitüberschreitung beim ersten Versuch — Pfad wird aktualisiert und es wird erneut versucht… {{time}} left", + "pageLoadingRetryOverdue": "Erfrischungspfad und erneuter Versuch… funktioniert noch" }, "packetDistribution": { "overallDistribution": "Gesamtverteilung", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index df83ebf1a..f30efed77 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -3094,14 +3094,19 @@ "pageLoading": "Loading page…", "pageLoadingCountdown": "Loading page… {{time}} left", "pageLoadingCountdownOverdue": "Loading page… still working", + "pageLoadingRetryCountdown": "First attempt timed out — refreshing path and retrying… {{time}} left", + "pageLoadingRetryOverdue": "Refreshing path and retrying… still working", "pageReadyToast": "Nomad page ready: {{name}}", "pageFailed": "Failed to load page: {{error}}", "pageTruncated": "Page truncated for display", "errors": { - "pathTimeout": "No route to this node (path lookup timed out). Try a closer node or wait for a fresher announce.", + "pathTimeout": "No usable path to this node (path lookup timed out). Wait for the next Nomad announce and try again. If it still fails, try another hub or a closer node.", + "pathTimeoutStale": "Could not refresh the route to this node (stale path). Wait for the next announce, then retry. Persistent failures often mean the node is offline.", "pubkeyNotFound": "Could not find this node's identity key. Wait for a Nomad announce, then try again.", - "linkTimeout": "Link to the node could not be established in time.", - "responseTimeout": "The node accepted the link but did not return the page in time.", + "linkTimeout": "Link to the node could not be established in time. Wait for a fresher announce and try again. If it keeps failing, the node may be offline.", + "linkTimeoutCachedPath": "A path to this node is listed, but the link never completed — the route may be stale. Wait for the next announce, then retry. Persistent failures usually mean the node is offline or not reachable over TCP.", + "linkTimeoutPathOk": "A route to this node was confirmed, but the Nomad page did not answer. Wait for a fresher announce and try again. If it keeps failing, the node may be offline or not hosting that page.", + "responseTimeout": "The node accepted the link but did not return the page. It may be busy or not serving that path — try again after the next announce.", "missingIdentity": "This node has no remembered identity yet. Wait for a Nomad announce, then try again.", "transportUnavailable": "Reticulum transport is unavailable. Restart the stack and try again.", "sidecarNotRunning": "Reticulum sidecar is not running. Start the stack from Connection, then try again.", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index dcff26ad4..fadcb1cde 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -2949,7 +2949,7 @@ "collapseNodeList": "Contraer lista de nodos", "expandNodeList": "Expandir lista de nodos", "errors": { - "pathTimeout": "No hay ruta a este nodo (agotó el tiempo de búsqueda de ruta). Prueba un nodo más cercano o espera un anuncio más reciente.", + "pathTimeout": "No hay una ruta usable a este nodo (agotó el tiempo de búsqueda de ruta). Espera el siguiente anuncio Nomad e inténtalo de nuevo. Si sigue fallando, prueba otro hub o un nodo más cercano.", "pubkeyNotFound": "No se ha podido encontrar la clave de identidad de este nodo. Espera a un anuncio Nomad e inténtalo de nuevo.", "linkTimeout": "No se ha podido establecer el enlace al nodo a tiempo.", "responseTimeout": "El nodo aceptó el enlace pero no devolvió la página a tiempo.", @@ -2959,7 +2959,10 @@ "responseTooLarge": "El nodo devolvió más datos de los que mesh-client acepta para esta solicitud.", "nomadBusy": "Otra solicitud de página o archivo Nomad aún está en curso. Inténtalo de nuevo en un momento.", "nomadNotServing": "Este es su nodo Nomad, pero el alojamiento Mis páginas no se está ejecutando. Abra Mis páginas, elija una carpeta y comience a publicar para obtener una vista previa local.", - "networkNotReady": "Esperando a que se conecte un concentrador o una interfaz de radio. Inténtelo de nuevo una vez que Connection muestre una interfaz." + "networkNotReady": "Esperando a que se conecte un concentrador o una interfaz de radio. Inténtelo de nuevo una vez que Connection muestre una interfaz.", + "pathTimeoutStale": "No se ha podido actualizar la ruta a este nodo (ruta obsoleta). Espere el próximo anuncio y vuelva a intentarlo. Las fallas persistentes a menudo significan que el nodo está desconectado.", + "linkTimeoutCachedPath": "Se muestra una ruta a este nodo, pero el enlace nunca se completó; la ruta puede estar obsoleta. Espere el próximo anuncio y vuelva a intentarlo. Las fallas persistentes generalmente significan que el nodo está desconectado o no es accesible a través de TCP.", + "linkTimeoutPathOk": "Se confirmó una ruta a este nodo, pero la página Nomad no respondió. Espere un anuncio más reciente e inténtelo de nuevo. Si sigue fallando, el nodo puede estar desconectado o no alojar esa página." }, "fitWidth": "Ajustar a la ventana", "openWidth": "Anchura de la página", @@ -3009,7 +3012,9 @@ "pageLoadingCountdown": "Cargando página... {{time}} izquierda", "pageLoadingCountdownOverdue": "Cargando página… sigue funcionando", "pageReadyToast": "Nomad — Página nómada lista: {{name}}", - "staleLastSeenHint": "Escuchado por última vez {{time}}: este nodo puede estar fuera de línea incluso si figura como en línea." + "staleLastSeenHint": "Escuchado por última vez {{time}}: este nodo puede estar fuera de línea incluso si figura como en línea.", + "pageLoadingRetryCountdown": "Se agotó el tiempo de espera del primer intento: actualizar la ruta y volver a intentarlo… {{time}} left", + "pageLoadingRetryOverdue": "Actualizando ruta y reintentando... sigue funcionando" }, "packetDistribution": { "overallDistribution": "Distribución general", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index b75b854d4..25a948bbd 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -2949,7 +2949,7 @@ "collapseNodeList": "Réduire la liste des nœuds", "expandNodeList": "Développer la liste des nœuds", "errors": { - "pathTimeout": "Aucun itinéraire vers ce nœud (délai de recherche de chemin dépassé). Essayez un nœud plus proche ou attendez une annonce plus récente.", + "pathTimeout": "Aucun chemin utilisable vers ce nœud (délai de recherche de chemin dépassé). Attendez la prochaine annonce Nomad, puis réessayez. Si cela échoue encore, essayez un autre hub ou un nœud plus proche.", "pubkeyNotFound": "Impossible de trouver la clé d'identité de ce nœud. Attendez une annonce Nomad, puis réessayez.", "linkTimeout": "Le lien vers le nœud n'a pas pu être établi à temps.", "responseTimeout": "Le nœud a accepté le lien mais n'a pas renvoyé la page à temps.", @@ -2959,7 +2959,10 @@ "responseTooLarge": "Le nœud a renvoyé plus de données que mesh-client n’accepte pour cette requête.", "nomadBusy": "Une autre requête de page ou de fichier Nomad est encore en cours. Réessayez dans un instant.", "nomadNotServing": "Il s'agit de votre nœud Nomad, mais l'hébergement Mes pages ne fonctionne pas. Ouvrez Mes pages, choisissez un dossier et commencez à diffuser un aperçu localement.", - "networkNotReady": "En attente de la mise en ligne d'un hub ou d'une interface radio. Réessayez une fois que Connection affiche une interface." + "networkNotReady": "En attente de la mise en ligne d'un hub ou d'une interface radio. Réessayez une fois que Connection affiche une interface.", + "pathTimeoutStale": "Impossible d'actualiser l'itinéraire vers ce nœud (chemin obsolète). Attendez la prochaine annonce, puis réessayez. Les défaillances persistantes signifient souvent que le nœud est hors ligne.", + "linkTimeoutCachedPath": "Un chemin d'accès à ce nœud est répertorié, mais le lien n'est jamais terminé — l'itinéraire peut être obsolète. Attendez la prochaine annonce, puis réessayez. Les défaillances persistantes signifient généralement que le nœud est hors ligne ou inaccessible via TCP.", + "linkTimeoutPathOk": "Un itinéraire vers ce nœud a été confirmé, mais la page Nomad n'a pas répondu. Attendez une annonce plus fraîche et réessayez. S'il continue à échouer, le nœud peut être hors ligne ou ne pas héberger cette page." }, "fitWidth": "Ajuster la page à la fenêtre", "openWidth": "Largeur de la page", @@ -3009,7 +3012,9 @@ "pageLoadingCountdown": "Chargement de la page… {{time}} gauche", "pageLoadingCountdownOverdue": "Chargement de la page… fonctionne toujours", "pageReadyToast": "Nomad — Page nomade prête : {{name}}", - "staleLastSeenHint": "Dernière écoute {{time}} — ce nœud peut être hors ligne même s'il est répertorié comme en ligne." + "staleLastSeenHint": "Dernière écoute {{time}} — ce nœud peut être hors ligne même s'il est répertorié comme en ligne.", + "pageLoadingRetryCountdown": "Première tentative expirée — actualisation du chemin et nouvelle tentative… {{time}} restante", + "pageLoadingRetryOverdue": "Actualiser le chemin et réessayer… toujours en cours" }, "packetDistribution": { "overallDistribution": "Répartition globale", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 2ba357f2c..a3dd269ef 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -2949,7 +2949,7 @@ "collapseNodeList": "Ciutkan daftar simpul", "expandNodeList": "Perluas daftar simpul", "errors": { - "pathTimeout": "Tidak ada rute ke node ini (pencarian jalur habis waktu). Coba node yang lebih dekat atau tunggu announce yang lebih baru.", + "pathTimeout": "Tidak ada jalur yang dapat digunakan ke node ini (pencarian jalur habis waktu). Tunggu pengumuman Nomad berikutnya lalu coba lagi. Jika masih gagal, coba hub lain atau node yang lebih dekat.", "pubkeyNotFound": "Tidak dapat menemukan kunci identitas simpul ini. Tunggu pengumuman Nomad, lalu coba lagi.", "linkTimeout": "Tautan ke simpul tidak dapat dibuat tepat waktu.", "responseTimeout": "Node menerima tautan tetapi tidak mengembalikan halaman tepat waktu.", @@ -2959,7 +2959,10 @@ "responseTooLarge": "The node returned more data than mesh-client will accept for this request.", "nomadBusy": "Permintaan halaman atau berkas Nomad lain masih berlangsung. Coba lagi sebentar lagi.", "nomadNotServing": "Ini adalah node Nomad Anda, tetapi hosting Halaman Saya tidak berjalan. Buka Halaman Saya, pilih folder, dan mulai melayani untuk melihat pratinjau secara lokal.", - "networkNotReady": "Menunggu hub atau antarmuka radio online. Coba lagi setelah Koneksi menampilkan antarmuka." + "networkNotReady": "Menunggu hub atau antarmuka radio online. Coba lagi setelah Koneksi menampilkan antarmuka.", + "pathTimeoutStale": "Tidak dapat menyegarkan rute ke simpul ini (jalur basi). Tunggu pengumuman berikutnya, lalu coba lagi. Kegagalan terus - menerus sering berarti node sedang offline.", + "linkTimeoutCachedPath": "Jalur ke simpul ini terdaftar, tetapi tautannya tidak pernah selesai — rutenya mungkin basi. Tunggu pengumuman berikutnya, lalu coba lagi. Kegagalan persisten biasanya berarti node offline atau tidak dapat dijangkau melalui TCP.", + "linkTimeoutPathOk": "Rute ke simpul ini dikonfirmasi, tetapi halaman Nomad tidak menjawab. Tunggu pengumuman yang lebih segar dan coba lagi. Jika terus gagal, node mungkin offline atau tidak menghosting halaman tersebut." }, "fitWidth": "Muat halaman ke jendela", "openWidth": "Buka lebar halaman", @@ -3009,7 +3012,9 @@ "pageLoadingCountdown": "Memuat halaman… {{time}} tersisa", "pageLoadingCountdownOverdue": "Memuat halaman… masih berfungsi", "pageReadyToast": "Nomad — Halaman pengembara siap: {{name}}", - "staleLastSeenHint": "Terakhir terdengar {{time}} — node ini mungkin offline meskipun terdaftar sebagai online." + "staleLastSeenHint": "Terakhir terdengar {{time}} — node ini mungkin offline meskipun terdaftar sebagai online.", + "pageLoadingRetryCountdown": "Waktu percobaan pertama habis — jalur yang menyegarkan dan mencoba lagi… {{time}} tersisa", + "pageLoadingRetryOverdue": "Menyegarkan jalur dan mencoba lagi... masih berfungsi" }, "packetDistribution": { "overallDistribution": "Distribusi Keseluruhan", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index e3d8f2b54..733decbbb 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -2949,7 +2949,7 @@ "collapseNodeList": "Comprimi l'elenco dei nodi", "expandNodeList": "Espandi elenco nodi", "errors": { - "pathTimeout": "Nessun percorso verso questo nodo (timeout ricerca percorso). Prova un nodo più vicino o attendi un announce più recente.", + "pathTimeout": "Nessun percorso utilizzabile verso questo nodo (timeout ricerca percorso). Attendi il prossimo annuncio Nomad e riprova. Se continua a fallire, prova un altro hub o un nodo più vicino.", "pubkeyNotFound": "Impossibile trovare la chiave di identità di questo nodo. Attendi un annuncio Nomad, quindi riprova.", "linkTimeout": "Impossibile stabilire in tempo il collegamento al nodo.", "responseTimeout": "Il nodo ha accettato il collegamento ma non ha restituito la pagina in tempo.", @@ -2959,7 +2959,10 @@ "responseTooLarge": "The node returned more data than mesh-client will accept for this request.", "nomadBusy": "Un'altra richiesta di pagina o file Nomad è ancora in corso. Riprova tra un momento.", "nomadNotServing": "Questo è il tuo nodo Nomad, ma l'hosting di Le mie pagine non è in esecuzione. Apri Le mie pagine, scegli una cartella e inizia a pubblicare per visualizzare l'anteprima localmente.", - "networkNotReady": "In attesa che un hub o un'interfaccia radio siano online. Riprovare quando Connection mostra un'interfaccia." + "networkNotReady": "In attesa che un hub o un'interfaccia radio siano online. Riprovare quando Connection mostra un'interfaccia.", + "pathTimeoutStale": "Impossibile aggiornare il percorso a questo nodo (percorso obsoleto). Attendere l'annuncio successivo, quindi riprovare. Gli errori persistenti spesso indicano che il nodo è offline.", + "linkTimeoutCachedPath": "Viene elencato un percorso per questo nodo, ma il collegamento non è mai stato completato: il percorso potrebbe essere obsoleto. Attendere l'annuncio successivo, quindi riprovare. Gli errori persistenti di solito indicano che il nodo è offline o non raggiungibile tramite TCP.", + "linkTimeoutPathOk": "Un percorso verso questo nodo è stato confermato, ma la pagina Nomad non ha risposto. Attendi un annuncio più recente e riprova. Se continua a fallire, il nodo potrebbe essere offline o non ospitare quella pagina." }, "fitWidth": "Adatta alla finestra", "openWidth": "Larghezza pagina", @@ -3009,7 +3012,9 @@ "pageLoadingCountdown": "Caricamento pagina… {{time}} sinistra", "pageLoadingCountdownOverdue": "Caricamento della pagina... ancora funzionante", "pageReadyToast": "Pagina Nomad pronta: {{name}}", - "staleLastSeenHint": "Ultimo ascolto {{time}}: questo nodo potrebbe essere offline anche se elencato come online." + "staleLastSeenHint": "Ultimo ascolto {{time}}: questo nodo potrebbe essere offline anche se elencato come online.", + "pageLoadingRetryCountdown": "Timeout del primo tentativo: aggiornamento del percorso e nuovo tentativo... {{time}} rimasti", + "pageLoadingRetryOverdue": "Aggiornamento del percorso e nuovo tentativo... ancora in corso" }, "packetDistribution": { "overallDistribution": "Distribuzione complessiva", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index beb94800c..775da9023 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -2949,7 +2949,7 @@ "collapseNodeList": "ノードリストを折りたたむ", "expandNodeList": "ノードリストを展開", "errors": { - "pathTimeout": "このノードへの経路がありません(パス検索がタイムアウトしました)。近いノードを試すか、新しいannounceを待ってください。", + "pathTimeout": "このノードへの使用可能な経路がありません(パス検索がタイムアウトしました)。次の Nomad アナウンスを待ってから再試行してください。それでも失敗する場合は、別のハブまたは近いノードを試してください。", "pubkeyNotFound": "このノードの ID キーが見つかりませんでした。Nomad アナウンスを待ってから、もう一度お試しください。", "linkTimeout": "ノードへのリンクを時間内に確立できませんでした。", "responseTimeout": "ノードはリンクを受け入れましたが、時間内にページを返しませんでした。", @@ -2959,7 +2959,10 @@ "responseTooLarge": "ノードが返すデータ量が、この要求で mesh-client が受け入れる上限を超えました。", "nomadBusy": "別の Nomad ページまたはファイル要求が処理中です。しばらくしてから再試行してください。", "nomadNotServing": "これは Nomad ノードですが、My Pages ホスティングは実行されていません。 [マイ ページ] を開いてフォルダーを選択し、ローカルでプレビューするための配信を開始します。", - "networkNotReady": "ハブまたは無線インターフェイスがオンラインになるのを待っています。接続にインターフェイスが表示されたら、もう一度試してください。" + "networkNotReady": "ハブまたは無線インターフェイスがオンラインになるのを待っています。接続にインターフェイスが表示されたら、もう一度試してください。", + "pathTimeoutStale": "このノードへのルートを更新できませんでした(古いパス)。次のアナウンスを待ってから、もう一度お試しください。継続的な障害は、多くの場合、ノードがオフラインであることを意味します。", + "linkTimeoutCachedPath": "このノードへのパスがリストされていますが、リンクは完了していません。ルートが古くなっている可能性があります。次のアナウンスを待ってから、もう一度お試しください。永続的な障害は、通常、ノードがオフラインであるか、TCP経由で到達できないことを意味します。", + "linkTimeoutPathOk": "このノードへのルートが確認されましたが、Nomad ページは応答しませんでした。新鮮なお知らせを待って、もう一度お試しください。失敗し続けると、ノードがオフラインになっているか、そのページをホストしていない可能性があります。" }, "fitWidth": "ウィンドウに適合", "openWidth": "ページ幅", @@ -3009,7 +3012,9 @@ "pageLoadingCountdown": "ページを読み込み中… {{time}} 残り", "pageLoadingCountdownOverdue": "ページを読み込み中…まだ動作中", "pageReadyToast": "Nomadページの準備ができました: {{name}}", - "staleLastSeenHint": "最後に受信したのは {{time}} — このノードはオンラインとしてリストされている場合でもオフラインである可能性があります。" + "staleLastSeenHint": "最後に受信したのは {{time}} — このノードはオンラインとしてリストされている場合でもオフラインである可能性があります。", + "pageLoadingRetryCountdown": "最初の試行がタイムアウトしました—パスを更新して再試行しています… {{time}}残り", + "pageLoadingRetryOverdue": "パスを更新して再試行しています...まだ機能しています" }, "packetDistribution": { "overallDistribution": "全体の分布", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index c726202f2..312d53edb 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -2949,7 +2949,7 @@ "collapseNodeList": "노드 목록 축소", "expandNodeList": "노드 목록 펼치기", "errors": { - "pathTimeout": "이 노드로 가는 경로가 없습니다(경로 조회 시간 초과). 더 가까운 노드를 시도하거나 새 announce를 기다리세요.", + "pathTimeout": "이 노드로 가는 사용 가능한 경로가 없습니다(경로 조회 시간 초과). 다음 Nomad 공지를 기다렸다가 다시 시도하세요. 계속 실패하면 다른 허브나 더 가까운 노드를 시도하세요.", "pubkeyNotFound": "이 노드의 ID 키를 찾을 수 없습니다. Nomad 공지를 기다렸다가 다시 시도하세요.", "linkTimeout": "노드에 대한 링크를 제시간에 설정할 수 없습니다.", "responseTimeout": "노드가 링크를 수락했지만 제시간에 페이지를 반환하지 않았습니다.", @@ -2959,7 +2959,10 @@ "responseTooLarge": "The node returned more data than mesh-client will accept for this request.", "nomadBusy": "다른 Nomad 페이지 또는 파일 요청이 아직 진행 중입니다. 잠시 후 다시 시도하세요.", "nomadNotServing": "이것은 Nomad 노드이지만 내 페이지 호스팅이 실행되고 있지 않습니다. 내 페이지를 열고 폴더를 선택한 후 로컬에서 미리 볼 수 있는 서비스를 시작하세요.", - "networkNotReady": "허브 또는 무선 인터페이스가 온라인 상태가 될 때까지 기다리는 중입니다. Connection에 인터페이스가 표시되면 다시 시도하십시오." + "networkNotReady": "허브 또는 무선 인터페이스가 온라인 상태가 될 때까지 기다리는 중입니다. Connection에 인터페이스가 표시되면 다시 시도하십시오.", + "pathTimeoutStale": "이 노드로의 경로를 새로 고칠 수 없습니다 (오래된 경로). 다음 발표를 기다렸다가 다시 시도하세요. 지속적인 실패는 종종 노드가 오프라인 상태임을 의미합니다.", + "linkTimeoutCachedPath": "이 노드로 연결되는 경로가 나열되지만 링크가 완료되지 않았습니다. 경로가 오래되었을 수 있습니다. 다음 발표를 기다렸다가 다시 시도하세요. 지속적인 장애는 일반적으로 노드가 오프라인이거나 TCP를 통해 연결할 수 없음을 의미합니다.", + "linkTimeoutPathOk": "이 노드로 가는 경로가 확인되었지만 Nomad 페이지는 응답하지 않았습니다. 새로 고침될 때까지 기다렸다가 다시 시도하세요. 계속 실패하면 노드가 오프라인이거나 해당 페이지를 호스팅하지 않을 수 있습니다." }, "fitWidth": "창에 맞춤", "openWidth": "페이지 폭", @@ -3009,7 +3012,9 @@ "pageLoadingCountdown": "페이지 로드 중… {{time}} 남음", "pageLoadingCountdownOverdue": "페이지 로드 중… 아직 작동 중", "pageReadyToast": "Nomad 페이지 준비됨: {{name}}", - "staleLastSeenHint": "마지막으로 청취된 {{time}} — 이 노드는 온라인으로 나열되어 있어도 오프라인일 수 있습니다." + "staleLastSeenHint": "마지막으로 청취된 {{time}} — 이 노드는 온라인으로 나열되어 있어도 오프라인일 수 있습니다.", + "pageLoadingRetryCountdown": "첫 번째 시도 시간 초과 — 경로 새로 고침 및 재시도 중… {{time}} 남음", + "pageLoadingRetryOverdue": "경로 새로 고침 및 재시도 중... 여전히 작동 중" }, "packetDistribution": { "overallDistribution": "전체 분포", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index ea471891e..641163793 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -2949,7 +2949,7 @@ "collapseNodeList": "Knooppuntenlijst samenvouwen", "expandNodeList": "Knooppuntenlijst uitvouwen", "errors": { - "pathTimeout": "Geen route naar dit knooppunt (padzoekopdracht time-out). Probeer een dichter knooppunt of wacht op een recentere announce.", + "pathTimeout": "Geen bruikbaar pad naar dit knooppunt (padzoekopdracht time-out). Wacht op de volgende Nomad-aankondiging en probeer het opnieuw. Als het blijft mislukken, probeer een andere hub of een dichter knooppunt.", "pubkeyNotFound": "Kon de identiteitssleutel van dit knooppunt niet vinden. Wacht op een Nomad-aankondiging en probeer het dan opnieuw.", "linkTimeout": "Link naar het knooppunt kon niet op tijd worden vastgesteld.", "responseTimeout": "Het knooppunt heeft de link geaccepteerd, maar de pagina niet op tijd teruggestuurd.", @@ -2959,7 +2959,10 @@ "responseTooLarge": "The node returned more data than mesh-client will accept for this request.", "nomadBusy": "Een ander Nomad-pagina- of bestandsverzoek is nog bezig. Probeer het zo opnieuw.", "nomadNotServing": "Dit is uw Nomad-knooppunt, maar de My Pages-hosting is niet actief. Open Mijn pagina's, kies een map en begin met het weergeven van lokale voorbeelden.", - "networkNotReady": "Wachten tot een hub of radio-interface online komt. Probeer het opnieuw zodra Connection een interface toont." + "networkNotReady": "Wachten tot een hub of radio-interface online komt. Probeer het opnieuw zodra Connection een interface toont.", + "pathTimeoutStale": "Kon de route naar dit knooppunt (oud pad) niet vernieuwen. Wacht op de volgende aankondiging en probeer het dan opnieuw. Aanhoudende storingen betekenen vaak dat het knooppunt offline is.", + "linkTimeoutCachedPath": "Er wordt een pad naar dit knooppunt weergegeven, maar de link is nooit voltooid — de route kan verouderd zijn. Wacht op de volgende aankondiging en probeer het dan opnieuw. Aanhoudende storingen betekenen meestal dat het knooppunt offline is of niet bereikbaar is via TCP.", + "linkTimeoutPathOk": "Een route naar dit knooppunt werd bevestigd, maar de Nomad-pagina antwoordde niet. Wacht op een versere aankondiging en probeer het opnieuw. Als het blijft mislukken, is het knooppunt mogelijk offline of host het die pagina niet." }, "fitWidth": "In venster passen", "openWidth": "Paginabreedte", @@ -3009,7 +3012,9 @@ "pageLoadingCountdown": "Pagina wordt geladen… {{time}} over", "pageLoadingCountdownOverdue": "Pagina wordt geladen... werkt nog steeds", "pageReadyToast": "Nomad-pagina klaar: {{name}}", - "staleLastSeenHint": "Laatst gehoord {{time}}: dit knooppunt is mogelijk offline, zelfs als het als online wordt vermeld." + "staleLastSeenHint": "Laatst gehoord {{time}}: dit knooppunt is mogelijk offline, zelfs als het als online wordt vermeld.", + "pageLoadingRetryCountdown": "Time-out voor eerste poging — pad verversen en opnieuw proberen… {{time}} over", + "pageLoadingRetryOverdue": "Pad verversen en opnieuw proberen... werkt nog steeds" }, "packetDistribution": { "overallDistribution": "Algemene distributie", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index f089d5c09..e392776c8 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -2953,7 +2953,7 @@ "collapseNodeList": "Zwiń listę węzłów", "expandNodeList": "Rozwiń listę węzłów", "errors": { - "pathTimeout": "Brak trasy do tego węzła (przekroczono limit czasu wyszukiwania ścieżki). Spróbuj bliższego węzła lub poczekaj na nowszy announce.", + "pathTimeout": "Brak użytecznej trasy do tego węzła (przekroczono limit czasu wyszukiwania ścieżki). Poczekaj na następne ogłoszenie Nomad i spróbuj ponownie. Jeśli nadal się nie uda, spróbuj innego huba lub bliższego węzła.", "pubkeyNotFound": "Nie można znaleźć klucza tożsamości tego węzła. Poczekaj na ogłoszenie Nomad, a następnie spróbuj ponownie.", "linkTimeout": "Połączenie z węzłem nie mogło zostać ustanowione na czas.", "responseTimeout": "Węzeł zaakceptował link, ale nie zwrócił strony na czas.", @@ -2963,7 +2963,10 @@ "responseTooLarge": "The node returned more data than mesh-client will accept for this request.", "nomadBusy": "Inne żądanie strony lub pliku Nomad jest nadal w toku. Spróbuj ponownie za chwilę.", "nomadNotServing": "To jest Twój węzeł Nomad, ale hosting Moich stron nie jest uruchomiony. Otwórz Moje strony, wybierz folder i rozpocznij udostępnianie, aby uzyskać podgląd lokalny.", - "networkNotReady": "Oczekiwanie na połączenie koncentratora lub interfejsu radiowego z Internetem. Spróbuj ponownie, gdy połączenie wyświetli interfejs." + "networkNotReady": "Oczekiwanie na połączenie koncentratora lub interfejsu radiowego z Internetem. Spróbuj ponownie, gdy połączenie wyświetli interfejs.", + "pathTimeoutStale": "Nie można odświeżyć trasy do tego węzła (nieaktualna ścieżka). Poczekaj na następne ogłoszenie, a następnie spróbuj ponownie. Trwałe awarie często oznaczają, że węzeł jest offline.", + "linkTimeoutCachedPath": "Podana jest ścieżka do tego węzła, ale link nigdy nie został ukończony — trasa może być nieświeża. Poczekaj na następne ogłoszenie, a następnie spróbuj ponownie. Trwałe awarie zwykle oznaczają, że węzeł jest offline lub nieosiągalny przez TCP.", + "linkTimeoutPathOk": "Trasa do tego węzła została potwierdzona, ale strona Nomad nie odpowiedziała. Poczekaj na świeższe ogłoszenie i spróbuj ponownie. Jeśli to się nie powiedzie, węzeł może być w trybie offline lub nie hostować tej strony." }, "fitWidth": "Dopasuj do okna", "openWidth": "Szerokość strony", @@ -3013,7 +3016,9 @@ "pageLoadingCountdown": "Ładowanie strony… Pozostało {{time}}", "pageLoadingCountdownOverdue": "Ładowanie strony… nadal działa", "pageReadyToast": "Strona Nomad gotowa: {{name}}", - "staleLastSeenHint": "Ostatnio słuchano {{time}} — ten węzeł może być w trybie offline, nawet jeśli jest wymieniony jako online." + "staleLastSeenHint": "Ostatnio słuchano {{time}} — ten węzeł może być w trybie offline, nawet jeśli jest wymieniony jako online.", + "pageLoadingRetryCountdown": "Upłynął limit czasu pierwszej próby — odświeżanie ścieżki i ponowna próba… pozostało: {{time}}", + "pageLoadingRetryOverdue": "Odświeżanie ścieżki i ponawianie próby… nadal działa" }, "packetDistribution": { "overallDistribution": "Ogólna dystrybucja", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 71393580f..d72116075 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -2949,7 +2949,7 @@ "collapseNodeList": "Recolher lista de nós", "expandNodeList": "Expandir lista de nós", "errors": { - "pathTimeout": "Nenhuma rota para este nó (tempo esgotado na busca de caminho). Tente um nó mais próximo ou aguarde um announce mais recente.", + "pathTimeout": "Nenhum caminho utilizável para este nó (tempo esgotado na busca de caminho). Aguarde o próximo anúncio Nomad e tente novamente. Se continuar falhando, tente outro hub ou um nó mais próximo.", "pubkeyNotFound": "Não foi possível encontrar a chave de identidade deste nó. Aguarde um anúncio do Nomad e tente novamente.", "linkTimeout": "O link para o nó não pôde ser estabelecido a tempo.", "responseTimeout": "O nó aceitou o link, mas não retornou a página a tempo.", @@ -2959,7 +2959,10 @@ "responseTooLarge": "O nó retornou mais dados do que o mesh-client aceita para esta solicitação.", "nomadBusy": "Outra solicitação de página ou arquivo Nomad ainda está em andamento. Tente novamente em um momento.", "nomadNotServing": "Este é o seu nó Nomad, mas a hospedagem My Pages não está em execução. Abra Minhas páginas, escolha uma pasta e comece a servir para visualização local.", - "networkNotReady": "Aguardando que um hub ou interface de rádio fique online. Tente novamente quando o Connection mostrar uma interface." + "networkNotReady": "Aguardando que um hub ou interface de rádio fique online. Tente novamente quando o Connection mostrar uma interface.", + "pathTimeoutStale": "Não foi possível atualizar a rota para este nó (caminho obsoleto). Aguarde o próximo anúncio e tente novamente. Falhas persistentes geralmente significam que o nó está offline.", + "linkTimeoutCachedPath": "Um caminho para este nó está listado, mas o link nunca foi concluído — a rota pode estar obsoleta. Aguarde o próximo anúncio e tente novamente. Falhas persistentes geralmente significam que o nó está offline ou não pode ser acessado pelo TCP.", + "linkTimeoutPathOk": "Uma rota para este nó foi confirmada, mas a página Nomad não respondeu. Aguarde um anúncio mais recente e tente novamente. Se continuar falhando, o nó pode estar offline ou não hospedar essa página." }, "fitWidth": "Encaixar na janela", "openWidth": "Largura da Página", @@ -3009,7 +3012,9 @@ "pageLoadingCountdown": "Carregando página… {{time}} esquerda", "pageLoadingCountdownOverdue": "Carregando página… ainda funcionando", "pageReadyToast": "Página Nomad pronta: {{name}}", - "staleLastSeenHint": "Ouvido pela última vez em {{time}} — este nó pode estar offline mesmo se estiver listado como online." + "staleLastSeenHint": "Ouvido pela última vez em {{time}} — este nó pode estar offline mesmo se estiver listado como online.", + "pageLoadingRetryCountdown": "A primeira tentativa expirou — atualizando o caminho e tentando novamente… {{time}} restante", + "pageLoadingRetryOverdue": "Atualizando o caminho e tentando novamente... ainda funcionando" }, "packetDistribution": { "overallDistribution": "Distribuição Geral", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index bae3dc666..499962ac0 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -2951,7 +2951,7 @@ "collapseNodeList": "Свернуть список узлов", "expandNodeList": "Развернуть список узлов", "errors": { - "pathTimeout": "Нет маршрута к этому узлу (тайм-аут поиска пути). Попробуйте более близкий узел или дождитесь более свежего announce.", + "pathTimeout": "Нет пригодного пути к этому узлу (тайм-аут поиска пути). Дождитесь следующего объявления Nomad и повторите попытку. Если ошибка сохраняется, попробуйте другой хаб или более близкий узел.", "pubkeyNotFound": "Не удалось найти идентификационный ключ этого узла. Дождитесь объявления Nomad и повторите попытку.", "linkTimeout": "Не удалось вовремя установить ссылку на узел.", "responseTimeout": "Узел принял ссылку, но не вернул страницу вовремя.", @@ -2961,7 +2961,10 @@ "responseTooLarge": "The node returned more data than mesh-client will accept for this request.", "nomadBusy": "Другой запрос страницы или файла Nomad всё ещё выполняется. Попробуйте снова через мгновение.", "nomadNotServing": "Это ваш узел Nomad, но хостинг My Pages не работает. Откройте «Мои страницы», выберите папку и начните локальный предварительный просмотр.", - "networkNotReady": "Ожидание подключения концентратора или радиоинтерфейса к сети. Попробуйте еще раз, как только Connection отобразит интерфейс." + "networkNotReady": "Ожидание подключения концентратора или радиоинтерфейса к сети. Попробуйте еще раз, как только Connection отобразит интерфейс.", + "pathTimeoutStale": "Не удалось обновить маршрут к этому узлу (устаревший путь). Дождитесь следующего объявления и повторите попытку. Постоянные сбои часто означают, что узел находится в автономном режиме.", + "linkTimeoutCachedPath": "Путь к этому узлу указан в списке, но ссылка так и не была завершена — маршрут может быть устаревшим. Дождитесь следующего объявления и повторите попытку. Постоянные сбои обычно означают, что узел находится в автономном режиме или недоступен через TCP.", + "linkTimeoutPathOk": "Маршрут к этому узлу был подтвержден, но страница Nomad не ответила. Дождитесь свежего объявления и повторите попытку. Если он продолжает выходить из строя, узел может быть отключен от сети или не размещать эту страницу." }, "fitWidth": "Уместить в окне", "openWidth": "ширина страницы", @@ -3011,7 +3014,9 @@ "pageLoadingCountdown": "Загрузка страницы… Осталось {{time}}", "pageLoadingCountdownOverdue": "Загрузка страницы… все еще работает", "pageReadyToast": "Страница Nomad готова: {{name}}", - "staleLastSeenHint": "Последний раз слышал {{time}} — этот узел может быть отключен, даже если он указан как подключенный." + "staleLastSeenHint": "Последний раз слышал {{time}} — этот узел может быть отключен, даже если он указан как подключенный.", + "pageLoadingRetryCountdown": "Время первой попытки истекло. Обновление пути и повторная попытка… Осталось {{time}}", + "pageLoadingRetryOverdue": "Освежающий путь и повторная попытка... все еще работает" }, "packetDistribution": { "overallDistribution": "Общее распределение", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index fba2b6664..adb9286a8 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -2949,7 +2949,7 @@ "collapseNodeList": "Düğüm listesini daralt", "expandNodeList": "Düğüm listesini genişlet", "errors": { - "pathTimeout": "Bu düğüme rota yok (yol araması zaman aşımına uğradı). Daha yakın bir düğüm deneyin veya daha yeni bir announce bekleyin.", + "pathTimeout": "Bu düğüme kullanılabilir bir yol yok (yol araması zaman aşımına uğradı). Sonraki Nomad duyurusunu bekleyin ve tekrar deneyin. Hâlâ başarısız olursa başka bir hub veya daha yakın bir düğüm deneyin.", "pubkeyNotFound": "Bu düğümün kimlik anahtarı bulunamadı. Bir Nomad duyurusunu bekleyin, sonra tekrar deneyin.", "linkTimeout": "Düğüme bağlantı zamanında kurulamadı.", "responseTimeout": "Düğüm bağlantıyı kabul etti ancak sayfayı zamanında döndürmedi.", @@ -2959,7 +2959,10 @@ "responseTooLarge": "The node returned more data than mesh-client will accept for this request.", "nomadBusy": "Başka bir Nomad sayfa veya dosya isteği hâlâ devam ediyor. Biraz sonra tekrar deneyin.", "nomadNotServing": "Bu sizin Nomad düğümünüz, ancak Sayfalarım barındırma çalışmıyor. Sayfalarım'ı açın, bir klasör seçin ve yerel olarak önizlemeye sunmaya başlayın.", - "networkNotReady": "Bir hub veya radyo arayüzünün çevrimiçi olması bekleniyor. Bağlantı bir arayüz gösterdiğinde tekrar deneyin." + "networkNotReady": "Bir hub veya radyo arayüzünün çevrimiçi olması bekleniyor. Bağlantı bir arayüz gösterdiğinde tekrar deneyin.", + "pathTimeoutStale": "Bu düğüme giden yol yenilenemedi (eski yol). Bir sonraki duyuruyu bekleyin, ardından yeniden deneyin. Kalıcı arızalar genellikle düğümün çevrimdışı olduğu anlamına gelir.", + "linkTimeoutCachedPath": "Bu düğüme giden bir yol listelenir, ancak bağlantı asla tamamlanmaz — rota eski olabilir. Bir sonraki duyuruyu bekleyin, ardından yeniden deneyin. Kalıcı arızalar genellikle düğümün çevrimdışı olduğu veya TCP üzerinden erişilemediği anlamına gelir.", + "linkTimeoutPathOk": "Bu düğüme giden bir rota onaylandı, ancak Nomad sayfası yanıt vermedi. Daha yeni bir duyuru bekleyin ve tekrar deneyin. Başarısız olmaya devam ederse, düğüm çevrimdışı olabilir veya o sayfayı barındırmıyor olabilir." }, "fitWidth": "Pencereye sığdır", "openWidth": "Sayfa Genişliği", @@ -3009,7 +3012,9 @@ "pageLoadingCountdown": "Sayfa yükleniyor… {{time}} kaldı", "pageLoadingCountdownOverdue": "Sayfa yükleniyor… hala çalışıyor", "pageReadyToast": "Nomad sayfası hazır: {{name}}", - "staleLastSeenHint": "Son duyulan {{time}} — bu düğüm çevrimiçi olarak listelense bile çevrimdışı olabilir." + "staleLastSeenHint": "Son duyulan {{time}} — bu düğüm çevrimiçi olarak listelense bile çevrimdışı olabilir.", + "pageLoadingRetryCountdown": "İlk deneme zaman aşımına uğradı — yol yenileniyor ve yeniden deneniyor… {{time}} kaldı", + "pageLoadingRetryOverdue": "Yol yenileniyor ve yeniden deneniyor... hala çalışıyor" }, "packetDistribution": { "overallDistribution": "Genel Dağıtım", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 7ca2a946d..f8304d23f 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -2951,7 +2951,7 @@ "collapseNodeList": "Згорнути список вузлів", "expandNodeList": "Розгорнути список вузлів", "errors": { - "pathTimeout": "Немає маршруту до цього вузла (тайм-аут пошуку шляху). Спробуйте ближчий вузол або зачекайте нановіший announce.", + "pathTimeout": "Немає придатного шляху до цього вузла (тайм-аут пошуку шляху). Дочекайтеся наступного оголошення Nomad і спробуйте знову. Якщо все ще не вдається, спробуйте інший хаб або ближчий вузол.", "pubkeyNotFound": "Не вдалося знайти ідентифікаційний ключ цього вузла. Дочекайтеся оголошення Nomad, а потім повторіть спробу.", "linkTimeout": "Посилання на вузол не вдалося встановити вчасно.", "responseTimeout": "Вузол прийняв посилання, але вчасно не повернув сторінку.", @@ -2961,7 +2961,10 @@ "responseTooLarge": "The node returned more data than mesh-client will accept for this request.", "nomadBusy": "Інший запит сторінки або файлу Nomad ще виконується. Спробуйте знову за мить.", "nomadNotServing": "Це ваш вузол Nomad, але хостинг My Pages не працює. Відкрийте «Мої сторінки», виберіть папку та почніть показ для попереднього перегляду локально.", - "networkNotReady": "Очікування підключення концентратора або радіоінтерфейсу. Повторіть спробу, коли підключення відобразить інтерфейс." + "networkNotReady": "Очікування підключення концентратора або радіоінтерфейсу. Повторіть спробу, коли підключення відобразить інтерфейс.", + "pathTimeoutStale": "Не вдалося оновити маршрут до цього вузла (застарілий шлях). Дочекайтеся наступного оголошення, а потім повторіть спробу. Постійні збої часто означають, що вузол не в мережі.", + "linkTimeoutCachedPath": "Шлях до цього вузла вказано, але посилання так і не було завершено — маршрут може бути застарілим. Дочекайтеся наступного оголошення, а потім повторіть спробу. Постійні збої зазвичай означають, що вузол не в мережі або недоступний через TCP.", + "linkTimeoutPathOk": "Підтверджено маршрут до цього вузла, але сторінка Nomad не відповіла. Дочекайтеся більш свіжого оголошення та спробуйте ще раз. Якщо він продовжує виходити з ладу, вузол може перебувати в автономному режимі або не розміщувати цю сторінку." }, "fitWidth": "Вмістити у вікно", "openWidth": "Відкрити ширину сторінки", @@ -3011,7 +3014,9 @@ "pageLoadingCountdown": "Завантаження сторінки... Залишилося {{time}}", "pageLoadingCountdownOverdue": "Завантаження сторінки… все ще працює", "pageReadyToast": "Сторінка Nomad готова: {{name}}", - "staleLastSeenHint": "Востаннє почуто {{time}} — цей вузол може бути офлайн, навіть якщо вказано як онлайн." + "staleLastSeenHint": "Востаннє почуто {{time}} — цей вузол може бути офлайн, навіть якщо вказано як онлайн.", + "pageLoadingRetryCountdown": "Тайм-аут першої спроби — оновлення шляху та повторна спроба… {{time}} ліворуч", + "pageLoadingRetryOverdue": "Освіження шляху та повторні спроби… все ще працюють" }, "packetDistribution": { "overallDistribution": "Загальний розподіл", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 5b01dd0f2..8659c6f9c 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -2949,7 +2949,7 @@ "collapseNodeList": "折叠节点列表", "expandNodeList": "展开节点列表", "errors": { - "pathTimeout": "无法到达此节点(路径查找超时)。请尝试更近的节点,或等待更新的 announce。", + "pathTimeout": "无法到达此节点(路径查找超时)。请等待下一次 Nomad 公告后重试。如果仍然失败,请尝试其他 hub 或更近的节点。", "pubkeyNotFound": "找不到此节点的标识密钥。等待 Nomad 公告,然后重试。", "linkTimeout": "无法及时建立到节点的链接。", "responseTimeout": "节点接受了链接,但没有及时返回页面。", @@ -2959,7 +2959,10 @@ "responseTooLarge": "该节点返回的数据超出了 mesh-client 对此请求的接受上限。", "nomadBusy": "另一个 Nomad 页面或文件请求仍在进行中。请稍后再试。", "nomadNotServing": "这是您的 Nomad 节点,但“我的页面”托管未运行。打开“我的页面”,选择一个文件夹,然后开始在本地预览。", - "networkNotReady": "等待集线器或无线电接口上线。连接显示界面后重试。" + "networkNotReady": "等待集线器或无线电接口上线。连接显示界面后重试。", + "pathTimeoutStale": "无法刷新到此节点的路由(过时的路径)。等待下一个公告,然后重试。持续故障通常意味着节点处于离线状态。", + "linkTimeoutCachedPath": "列出了指向此节点的路径,但链接从未完成—路由可能已过时。等待下一个公告,然后重试。持续故障通常意味着节点处于离线状态或无法通过TCP访问。", + "linkTimeoutPathOk": "已确认到此节点的路由,但 Nomad 页面未应答。请等待更新的公告,然后重试。如果持续出现故障,则节点可能处于脱机状态或未托管该页面。" }, "fitWidth": "适合窗口", "openWidth": "图框宽度", @@ -3009,7 +3012,9 @@ "pageLoadingCountdown": "正在加载页面... {{time}} 左", "pageLoadingCountdownOverdue": "正在加载页面...仍在工作", "pageReadyToast": "Nomad 页面就绪:{{name}}", - "staleLastSeenHint": "最后听说 {{time}} — 即使列为在线,该节点也可能处于离线状态。" + "staleLastSeenHint": "最后听说 {{time}} — 即使列为在线,该节点也可能处于离线状态。", + "pageLoadingRetryCountdown": "首次尝试超时—刷新路径并重试……剩余{{time}}", + "pageLoadingRetryOverdue": "正在刷新路径并重试……仍在工作" }, "packetDistribution": { "overallDistribution": "总体分布", diff --git a/src/renderer/stores/nomadNetworkStore.test.ts b/src/renderer/stores/nomadNetworkStore.test.ts index d64b7ec55..001b5641e 100644 --- a/src/renderer/stores/nomadNetworkStore.test.ts +++ b/src/renderer/stores/nomadNetworkStore.test.ts @@ -184,7 +184,7 @@ describe('nomadNetworkStore', () => { expect(res).toEqual({ ok: true, file_name: 'readme.txt', content_base64: 'aGVsbG8=' }); }); - it('logs a warning when page fetch returns ok:false', async () => { + it('logs failure warning with link budget when page fetch returns ok:false', async () => { const { spy, restore } = mockConsoleWarn(); try { getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); @@ -198,6 +198,8 @@ describe('nomadNetworkStore', () => { proof_budget_secs: 18, timeout_secs: 45, force_path_ok: true, + path_ensure_kind: 'rediscovered', + elapsed_ms: 18250, raw_error: 'timed out waiting for link proof', }); @@ -206,17 +208,20 @@ describe('nomadNetworkStore', () => { .fetchNomadPage('abcdef12', '/page/index.mu'); expect(res).toMatchObject({ ok: false, error: 'link_timeout', link_hops: 3 }); - expect(spy).toHaveBeenCalled(); - const firstArg = spy.mock.calls[0]?.[0]; - expect(typeof firstArg).toBe('string'); - expect(firstArg).toContain('[nomadNetworkStore] page fetch failed'); - expect(firstArg).toContain('error=link_timeout'); - expect(firstArg).toContain('hash=abcdef12'); - expect(firstArg).toContain('link_hops=3'); - expect(firstArg).toContain('proof_budget_secs=18'); - expect(firstArg).toContain('timeout_secs=45'); - expect(firstArg).toContain('force_path_ok=true'); - expect(firstArg).toContain('raw=timed out waiting for link proof'); + const messages = spy.mock.calls + .map((c) => c[0]) + .filter((m): m is string => typeof m === 'string'); + const failed = messages.find((m) => m.includes('[nomadNetworkStore] page fetch failed')); + expect(failed).toBeTruthy(); + expect(failed).toContain('error=link_timeout'); + expect(failed).toContain('hash=abcdef12'); + expect(failed).toContain('link_hops=3'); + expect(failed).toContain('proof_budget_secs=18'); + expect(failed).toContain('timeout_secs=45'); + expect(failed).toContain('force_path_ok=true'); + expect(failed).toContain('path_ensure=rediscovered'); + expect(failed).toContain('elapsed_ms=18250'); + expect(failed).toContain('raw=timed out waiting for link proof'); } finally { restore(); } @@ -234,11 +239,12 @@ describe('nomadNetworkStore', () => { .fetchNomadFile('abcdef12', '/file/readme.txt'); expect(res).toEqual({ ok: false, error: 'path_timeout' }); - expect(spy).toHaveBeenCalled(); - const firstArg = spy.mock.calls[0]?.[0]; - expect(typeof firstArg).toBe('string'); - expect(firstArg).toContain('[nomadNetworkStore] file fetch failed'); - expect(firstArg).toContain('error=path_timeout'); + const messages = spy.mock.calls + .map((c) => c[0]) + .filter((m): m is string => typeof m === 'string'); + const failed = messages.find((m) => m.includes('[nomadNetworkStore] file fetch failed')); + expect(failed).toBeTruthy(); + expect(failed).toContain('error=path_timeout'); } finally { restore(); } diff --git a/src/renderer/stores/nomadNetworkStore.ts b/src/renderer/stores/nomadNetworkStore.ts index f83823f0c..83daf35a9 100644 --- a/src/renderer/stores/nomadNetworkStore.ts +++ b/src/renderer/stores/nomadNetworkStore.ts @@ -56,6 +56,67 @@ function nomadHashPrefixForLog(hash: string): string { return clean.slice(0, 8) || 'unknown'; } +interface NomadFetchLogDiag { + pathHops?: number; + linkHops?: number; + proofBudgetSecs?: number; + timeoutSecs?: number; + forcePathOk?: boolean; + pathEnsureKind?: string; + elapsedMs?: number; + rawError?: string; +} + +function optionalFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function optionalBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +function diagFieldsFromResponse(res: unknown): NomadFetchLogDiag { + const r = res as { + path_hops?: unknown; + link_hops?: unknown; + proof_budget_secs?: unknown; + timeout_secs?: unknown; + force_path_ok?: unknown; + path_ensure_kind?: unknown; + elapsed_ms?: unknown; + raw_error?: unknown; + }; + const rawError = typeof r.raw_error === 'string' ? r.raw_error.trim() : undefined; + const pathEnsureKind = + typeof r.path_ensure_kind === 'string' && r.path_ensure_kind.trim() + ? r.path_ensure_kind.trim() + : undefined; + return { + pathHops: optionalFiniteNumber(r.path_hops), + linkHops: optionalFiniteNumber(r.link_hops), + proofBudgetSecs: optionalFiniteNumber(r.proof_budget_secs), + timeoutSecs: optionalFiniteNumber(r.timeout_secs), + forcePathOk: optionalBoolean(r.force_path_ok), + pathEnsureKind, + elapsedMs: optionalFiniteNumber(r.elapsed_ms), + rawError: rawError || undefined, + }; +} + +function appendNomadDiagParts(parts: string[], diag: NomadFetchLogDiag): void { + if (diag.pathHops != null) parts.push(`path_hops=${diag.pathHops}`); + if (diag.linkHops != null) parts.push(`link_hops=${diag.linkHops}`); + if (diag.proofBudgetSecs != null) parts.push(`proof_budget_secs=${diag.proofBudgetSecs}`); + if (diag.timeoutSecs != null) parts.push(`timeout_secs=${diag.timeoutSecs}`); + if (diag.forcePathOk != null) parts.push(`force_path_ok=${diag.forcePathOk}`); + if (diag.pathEnsureKind) parts.push(`path_ensure=${diag.pathEnsureKind}`); + if (diag.elapsedMs != null) parts.push(`elapsed_ms=${diag.elapsedMs}`); + if (diag.rawError) { + parts.push(`raw=${diag.rawError.replace(/[\r\n]+/g, ' ').slice(0, 200)}`); + } +} + +/** Failure-only warn — keep link-budget / path-ensure fields for triage (not success spam). */ function logNomadFetchFailure( kind: 'page' | 'file', opts: { @@ -64,12 +125,7 @@ function logNomadFetchFailure( hops: number; egress: string; error: string; - pathHops?: number; - linkHops?: number; - proofBudgetSecs?: number; - timeoutSecs?: number; - forcePathOk?: boolean; - rawError?: string; + diag?: NomadFetchLogDiag; }, ): void { const pathSafe = opts.path.replace(/[\r\n]+/g, ' ').slice(0, 200); @@ -80,28 +136,13 @@ function logNomadFetchFailure( `egress=${opts.egress}`, `error=${errorSafe}`, ]; - if (opts.pathHops != null) parts.push(`path_hops=${opts.pathHops}`); - if (opts.linkHops != null) parts.push(`link_hops=${opts.linkHops}`); - if (opts.proofBudgetSecs != null) parts.push(`proof_budget_secs=${opts.proofBudgetSecs}`); - if (opts.timeoutSecs != null) parts.push(`timeout_secs=${opts.timeoutSecs}`); - if (opts.forcePathOk != null) parts.push(`force_path_ok=${opts.forcePathOk}`); - if (opts.rawError) { - parts.push(`raw=${opts.rawError.replace(/[\r\n]+/g, ' ').slice(0, 200)}`); - } + appendNomadDiagParts(parts, opts.diag ?? {}); console.warn( `[nomadNetworkStore] ${kind} fetch failed hash=${nomadHashPrefixForLog(opts.hash)}… ` + parts.join(' '), ); } -function optionalFiniteNumber(value: unknown): number | undefined { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; -} - -function optionalBoolean(value: unknown): boolean | undefined { - return typeof value === 'boolean' ? value : undefined; -} - function hopsForNomadHash(nodes: Map, hash: string): number { return nodes.get(hash.toLowerCase())?.hops ?? 8; } @@ -145,30 +186,15 @@ async function fetchNomadResource( const apiPath = `/api/v1/nomadnetwork/${kind}/${cleanHash}?${qs.toString()}`; const res = (await window.electronAPI.reticulum.proxyGet(apiPath)) as T; if (!res.ok) { - const resRecord = res as { - egress?: unknown; - path_hops?: unknown; - link_hops?: unknown; - proof_budget_secs?: unknown; - timeout_secs?: unknown; - force_path_ok?: unknown; - raw_error?: unknown; - }; + const resRecord = res as { egress?: unknown }; const resEgress = typeof resRecord.egress === 'string' ? resRecord.egress : egress; - const rawError = - typeof resRecord.raw_error === 'string' ? resRecord.raw_error.trim() : undefined; logNomadFetchFailure(kind, { hash: cleanHash, path: opts.path, hops, egress: resEgress, error: res.error?.trim() || 'unknown', - pathHops: optionalFiniteNumber(resRecord.path_hops), - linkHops: optionalFiniteNumber(resRecord.link_hops), - proofBudgetSecs: optionalFiniteNumber(resRecord.proof_budget_secs), - timeoutSecs: optionalFiniteNumber(resRecord.timeout_secs), - forcePathOk: optionalBoolean(resRecord.force_path_ok), - rawError: rawError || undefined, + diag: diagFieldsFromResponse(res), }); } return res; diff --git a/src/renderer/stores/nomadPageViewerLoad.test.ts b/src/renderer/stores/nomadPageViewerLoad.test.ts index 397d05f36..93ba16eaa 100644 --- a/src/renderer/stores/nomadPageViewerLoad.test.ts +++ b/src/renderer/stores/nomadPageViewerLoad.test.ts @@ -94,26 +94,47 @@ describe('nomadPageViewerStore loadPage cache', () => { vi.useFakeTimers(); const { restore } = mockConsoleWarn(); try { + let resolveFirst: ((value: unknown) => void) | undefined; + let resolveSecond: ((value: unknown) => void) | undefined; + const firstFetch = new Promise((resolve) => { + resolveFirst = resolve; + }); + const secondFetch = new Promise((resolve) => { + resolveSecond = resolve; + }); const fetchNomadPage = vi .fn() - .mockResolvedValueOnce({ - ok: false, - error: 'link_timeout', - egress: 'tcp', - }) - .mockResolvedValueOnce({ - ok: true, - content: 'hello after tcp retry', - content_type: 'micron', - egress: 'tcp', - }); + .mockImplementationOnce(() => firstFetch) + .mockImplementationOnce(() => secondFetch); useNomadNetworkStore.setState({ fetchNomadPage }); const loadPromise = useNomadPageViewerStore .getState() .loadPage('abc1234567890', '/page/index.mu'); await vi.advanceTimersByTimeAsync(NOMAD_PAGE_FETCH_DEBOUNCE_MS); + const firstStartedAt = useNomadPageViewerStore.getState().pageLoadingStartedAt; + expect(firstStartedAt).toBeTypeOf('number'); + + resolveFirst?.({ + ok: false, + error: 'link_timeout', + egress: 'tcp', + }); + await Promise.resolve(); await vi.advanceTimersByTimeAsync(NOMAD_PAGE_FETCH_RETRY_SETTLE_MS); + const retryStartedAt = useNomadPageViewerStore.getState().pageLoadingStartedAt; + expect(retryStartedAt).toBeTypeOf('number'); + expect(retryStartedAt).toBeGreaterThan(firstStartedAt!); + // Retry countdown = 4s path refresh + clamp(path_hops,3,7)×6 proof (no fake 45s). + expect(useNomadPageViewerStore.getState().pageLoadingBudgetSec).toBe(4 + 3 * 6); + expect(useNomadPageViewerStore.getState().pageLoadingRetrying).toBe(true); + + resolveSecond?.({ + ok: true, + content: 'hello after tcp retry', + content_type: 'micron', + egress: 'tcp', + }); await loadPromise; expect(fetchNomadPage).toHaveBeenCalledTimes(2); diff --git a/src/renderer/stores/nomadPageViewerStore.ts b/src/renderer/stores/nomadPageViewerStore.ts index 80a3a05b4..362b2415e 100644 --- a/src/renderer/stores/nomadPageViewerStore.ts +++ b/src/renderer/stores/nomadPageViewerStore.ts @@ -13,7 +13,11 @@ import { MAX_NOMAD_PAGE_CACHE_CHARS, setNomadPageCache, } from '@/renderer/lib/nomad/nomadPageCache'; -import { shouldForceNomadPathRefreshRetry } from '@/renderer/lib/nomad/nomadPageErrorHumanize'; +import { + type NomadPageErrorDiag, + nomadPageErrorDiagFromResponse, + shouldForceNomadPathRefreshRetry, +} from '@/renderer/lib/nomad/nomadPageErrorHumanize'; import { NOMAD_PAGE_FETCH_DEBOUNCE_MS, NOMAD_PAGE_FETCH_RETRY_SETTLE_MS, @@ -56,10 +60,14 @@ interface NomadPageViewerState { pageLoadingStartedAt: number | null; /** Sidecar/proxy budget used for the countdown (seconds). */ pageLoadingBudgetSec: number; + /** True while the one-shot force-path auto-retry is running (explain countdown restart). */ + pageLoadingRetrying: boolean; /** Raw sidecar/proxy error code or message (humanize in UI). */ pageErrorRaw: string | null; /** Sidecar egress atom from the failed fetch (`tcp` / `rf` / …) for retry policy. */ pageErrorEgress: string | null; + /** Path-ensure / force_path diagnostics for richer error copy. */ + pageErrorDiag: NomadPageErrorDiag | null; pageErrorNodeSnapshot: NomadPageErrorNodeSnapshot | null; announceReloadDone: boolean; /** True while Nomad tab is visible — suppress completion toast when true. */ @@ -182,8 +190,10 @@ const initialViewerState = { pageLoading: false, pageLoadingStartedAt: null as number | null, pageLoadingBudgetSec: 0, + pageLoadingRetrying: false, pageErrorRaw: null as string | null, pageErrorEgress: null as string | null, + pageErrorDiag: null as NomadPageErrorDiag | null, pageErrorNodeSnapshot: null as NomadPageErrorNodeSnapshot | null, announceReloadDone: false, panelActive: false, @@ -204,7 +214,12 @@ export const useNomadPageViewerStore = create((set, get) = }, clearPageErrorForAnnounceReload: () => { - set({ pageErrorRaw: null, pageErrorEgress: null, pageErrorNodeSnapshot: null }); + set({ + pageErrorRaw: null, + pageErrorEgress: null, + pageErrorDiag: null, + pageErrorNodeSnapshot: null, + }); }, markAnnounceReloadDone: () => { @@ -215,6 +230,7 @@ export const useNomadPageViewerStore = create((set, get) = set({ pageErrorRaw: 'invalid_url', pageErrorEgress: null, + pageErrorDiag: null, pageErrorNodeSnapshot: null, pageLoading: false, pageLoadingStartedAt: null, @@ -249,8 +265,10 @@ export const useNomadPageViewerStore = create((set, get) = pageLoading: true, pageLoadingStartedAt: null, pageLoadingBudgetSec: budgetSec, + pageLoadingRetrying: false, pageErrorRaw: null, pageErrorEgress: null, + pageErrorDiag: null, pageErrorNodeSnapshot: null, announceReloadDone: false, loadGeneration: generation, @@ -271,6 +289,7 @@ export const useNomadPageViewerStore = create((set, get) = pageLoading: false, pageLoadingStartedAt: null, pageLoadingBudgetSec: 0, + pageLoadingRetrying: false, pageContent: cached.content, pageContentType: cached.content_type, pageContentTruncated: false, @@ -321,6 +340,24 @@ export const useNomadPageViewerStore = create((set, get) = window.setTimeout(resolve, NOMAD_PAGE_FETCH_RETRY_SETTLE_MS); }); if (get().loadGeneration !== generation) return; + // Restart countdown for the retry using the sidecar proof window (not a + // fresh fake 45s) so the timer does not jump back up mid-load. + const retryEgress = egressFromNomadPageResponse(res); + const pathHops = + typeof res.path_hops === 'number' && Number.isFinite(res.path_hops) + ? Math.max(1, Math.trunc(res.path_hops)) + : (node?.hops ?? 8); + const retryLinkHops = + retryEgress === 'rf' || retryEgress === 'ble' + ? Math.min(32, pathHops) + : Math.min(7, Math.max(3, pathHops)); + // ~4s force-path refresh + link_hops × 6s proof (MeshChat-style). + budgetSec = 4 + retryLinkHops * 6; + set({ + pageLoadingStartedAt: Date.now(), + pageLoadingBudgetSec: budgetSec, + pageLoadingRetrying: true, + }); res = await fetchNomadPageDeduped(hash, normalizedPath, normalizedRequest, true); if (get().loadGeneration !== generation) return; if (typeof res.egress === 'string' && res.egress.trim()) { @@ -336,8 +373,10 @@ export const useNomadPageViewerStore = create((set, get) = set({ pageLoading: false, pageLoadingStartedAt: null, + pageLoadingRetrying: false, pageErrorRaw: 'unknown', pageErrorEgress: null, + pageErrorDiag: null, pageErrorNodeSnapshot: snapshotNomadNodeForPageError(hash, liveNode), }); return; @@ -351,8 +390,10 @@ export const useNomadPageViewerStore = create((set, get) = set({ pageLoading: false, pageLoadingStartedAt: null, + pageLoadingRetrying: false, pageErrorRaw: rawCode, pageErrorEgress: egressFromNomadPageResponse(res), + pageErrorDiag: nomadPageErrorDiagFromResponse(res), pageErrorNodeSnapshot: snapshotNomadNodeForPageError(hash, liveNode), announceReloadDone: false, }); @@ -375,11 +416,13 @@ export const useNomadPageViewerStore = create((set, get) = pageLoading: false, pageLoadingStartedAt: null, pageLoadingBudgetSec: 0, + pageLoadingRetrying: false, pageContent: text, pageContentType: res.content_type, pageContentTruncated: truncated, pageErrorRaw: null, pageErrorEgress: null, + pageErrorDiag: null, pageErrorNodeSnapshot: null, }); diff --git a/src/shared/nomad-types.ts b/src/shared/nomad-types.ts index 4fc300ddd..19d5250c9 100644 --- a/src/shared/nomad-types.ts +++ b/src/shared/nomad-types.ts @@ -9,16 +9,26 @@ export interface NomadNodeRow { status?: string | null; } -/** Optional Link-budget diagnostics on failed Nomad page/file fetches. */ +/** Optional Link-budget diagnostics on Nomad page/file fetches (ok or error). */ export interface NomadLinkFailureDiagnostics { /** Path-table hop count used for overall timeout classification. */ path_hops?: number; - /** Hops passed to Link::new_initiator (TCP/network flat 3 → ~18s proof). */ + /** Hops passed to Link::new_initiator (TCP/network: path hops clamped 3–7). */ link_hops?: number; /** Effective link-proof wait (seconds): link_hops × 6. */ proof_budget_secs?: number; - /** Sidecar `force_path_refresh` result when that retry path ran. */ + /** + * True only when DropPath→RequestPath rediscovered a path after absence. + * First-attempt cache hits set false — not a reachability proof. + */ force_path_ok?: boolean; + /** + * Path-ensure outcome: `cached_hit` | `rediscovered` | `stale_accept` | `missing`. + * Not a separate peer probe — used to humanize link vs path failures. + */ + path_ensure_kind?: string; + /** Sidecar wall time for the Link query attempt (milliseconds). */ + elapsed_ms?: number; /** Unmapped LinkClient error string before sidecar code mapping. */ raw_error?: string; } From 7354d43b6870998ab9a68be7ddb4fe0dede946aa Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 1 Aug 2026 12:05:55 -0600 Subject: [PATCH 3/4] fix(reticulum): prefer sidecar Nomad proof budget on retry countdown Use reported proof_budget_secs/link_hops for the force-path retry timer, dedupe OK JSON proof fields, and extract path-ensure timeout classification. --- reticulum-sidecar/src/stack/live.rs | 75 +++++++++++++++---- .../stores/nomadPageViewerLoad.test.ts | 6 +- src/renderer/stores/nomadPageViewerStore.ts | 37 ++++++--- 3 files changed, 89 insertions(+), 29 deletions(-) diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 58aebf5ca..5b3de4301 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -1105,7 +1105,6 @@ impl LiveBridge { timeout_secs, path_hops: hops, link_hops, - proof_budget_secs, force_path_ok, path_ensure_kind, elapsed_ms, @@ -2268,16 +2267,15 @@ impl LiveBridge { "path refresh timed out after dropping cached route" ); } - let kind = if !accept { - PathEnsureKind::Missing - } else if force && already && !saw_path_absent && accept_existing_on_timeout { - PathEnsureKind::StaleAccept - } else { - PathEnsureKind::Rediscovered - }; PathEnsureReport { ok: accept, - kind, + kind: path_ensure_kind_after_timeout( + accept, + force, + already, + saw_path_absent, + accept_existing_on_timeout, + ), had_cached: already, saw_path_absent, } @@ -3680,7 +3678,6 @@ struct NomadRemoteQueryOk { timeout_secs: u64, path_hops: u8, link_hops: u8, - proof_budget_secs: u64, force_path_ok: Option, path_ensure_kind: Option<&'static str>, elapsed_ms: u64, @@ -3749,11 +3746,6 @@ fn merge_nomad_remote_ok_fields(out: &mut serde_json::Value, meta: &NomadRemoteQ meta.path_ensure_kind, Some(meta.elapsed_ms), ); - // Prefer the explicit proof budget from the query (same as link_hops × 6). - obj.insert( - "proof_budget_secs".into(), - serde_json::json!(meta.proof_budget_secs), - ); } /// Remote Nomad page/file error JSON; include path-aware egress and Link budgets when known. @@ -3815,6 +3807,24 @@ fn force_path_refresh_timeout_accepts( accept_existing_on_timeout } +/// Classify path-ensure outcome after the RequestPath wait times out. +#[allow(clippy::fn_params_excessive_bools)] // mirrors force_path_refresh_timeout_accepts flags +fn path_ensure_kind_after_timeout( + accept: bool, + force: bool, + had_path_at_start: bool, + saw_path_absent: bool, + accept_existing_on_timeout: bool, +) -> PathEnsureKind { + if !accept { + PathEnsureKind::Missing + } else if force && had_path_at_start && !saw_path_absent && accept_existing_on_timeout { + PathEnsureKind::StaleAccept + } else { + PathEnsureKind::Rediscovered + } +} + /// Hashes present in `next` but not in `prev` (path-table membership growth). fn path_table_added_hashes(prev: &HashSet, next: &HashSet) -> Vec { next.difference(prev).cloned().collect() @@ -4166,7 +4176,6 @@ mod announce_display_name_tests { timeout_secs: 45, path_hops: 1, link_hops: 3, - proof_budget_secs: 18, force_path_ok: None, path_ensure_kind: None, elapsed_ms: 4200, @@ -4208,6 +4217,40 @@ mod announce_display_name_tests { )); } + #[test] + fn path_ensure_kind_after_timeout_matches_accept_matrix() { + // Same input matrix as force_path_refresh_timeout_accepts_fallthrough_when_never_absent. + let cases: &[(bool, bool, bool, bool, bool, PathEnsureKind)] = &[ + // force, had_start, has_path, saw_absent, accept_existing → kind + (true, true, true, false, true, PathEnsureKind::StaleAccept), + (true, true, true, false, false, PathEnsureKind::Missing), + (true, true, true, true, false, PathEnsureKind::Rediscovered), + (true, true, false, true, true, PathEnsureKind::Missing), + (true, false, true, false, true, PathEnsureKind::Rediscovered), + (true, false, true, false, false, PathEnsureKind::Missing), + ]; + for &(force, had_start, has_path, saw_absent, accept_existing, expected) in cases { + let accept = force_path_refresh_timeout_accepts( + force, + had_start, + has_path, + saw_absent, + accept_existing, + ); + assert_eq!( + path_ensure_kind_after_timeout( + accept, + force, + had_start, + saw_absent, + accept_existing, + ), + expected, + "force={force} had_start={had_start} has_path={has_path} saw_absent={saw_absent} accept_existing={accept_existing} accept={accept}" + ); + } + } + #[test] fn path_table_added_hashes_empty_when_membership_unchanged() { let prev: HashSet = ["aa".into()].into_iter().collect(); diff --git a/src/renderer/stores/nomadPageViewerLoad.test.ts b/src/renderer/stores/nomadPageViewerLoad.test.ts index 93ba16eaa..07d71d425 100644 --- a/src/renderer/stores/nomadPageViewerLoad.test.ts +++ b/src/renderer/stores/nomadPageViewerLoad.test.ts @@ -119,14 +119,16 @@ describe('nomadPageViewerStore loadPage cache', () => { ok: false, error: 'link_timeout', egress: 'tcp', + link_hops: 5, + proof_budget_secs: 30, }); await Promise.resolve(); await vi.advanceTimersByTimeAsync(NOMAD_PAGE_FETCH_RETRY_SETTLE_MS); const retryStartedAt = useNomadPageViewerStore.getState().pageLoadingStartedAt; expect(retryStartedAt).toBeTypeOf('number'); expect(retryStartedAt).toBeGreaterThan(firstStartedAt!); - // Retry countdown = 4s path refresh + clamp(path_hops,3,7)×6 proof (no fake 45s). - expect(useNomadPageViewerStore.getState().pageLoadingBudgetSec).toBe(4 + 3 * 6); + // Retry countdown = 4s path refresh + sidecar-reported proof_budget_secs. + expect(useNomadPageViewerStore.getState().pageLoadingBudgetSec).toBe(4 + 30); expect(useNomadPageViewerStore.getState().pageLoadingRetrying).toBe(true); resolveSecond?.({ diff --git a/src/renderer/stores/nomadPageViewerStore.ts b/src/renderer/stores/nomadPageViewerStore.ts index 362b2415e..1ca60a128 100644 --- a/src/renderer/stores/nomadPageViewerStore.ts +++ b/src/renderer/stores/nomadPageViewerStore.ts @@ -206,6 +206,31 @@ function egressFromNomadPageResponse(res: NomadPageResponse): string | null { return trimmed || null; } +/** Force-path retry countdown: prefer sidecar proof budget; clamp only when link_hops absent. */ +function nomadPageRetryLoadingBudgetSec( + res: NomadPageResponse, + nodeHops: number | null | undefined, +): number { + const pathRefreshSec = 4; + if (typeof res.proof_budget_secs === 'number' && Number.isFinite(res.proof_budget_secs)) { + return pathRefreshSec + Math.max(0, Math.trunc(res.proof_budget_secs)); + } + if (typeof res.link_hops === 'number' && Number.isFinite(res.link_hops)) { + return pathRefreshSec + Math.max(1, Math.trunc(res.link_hops)) * 6; + } + // path_timeout (and similar) responses omit link_hops — local clamp fallback. + const pathHops = + typeof res.path_hops === 'number' && Number.isFinite(res.path_hops) + ? Math.max(1, Math.trunc(res.path_hops)) + : Math.max(1, nodeHops ?? 8); + const retryEgress = egressFromNomadPageResponse(res); + const retryLinkHops = + retryEgress === 'rf' || retryEgress === 'ble' + ? Math.min(32, pathHops) + : Math.min(7, Math.max(3, pathHops)); + return pathRefreshSec + retryLinkHops * 6; +} + export const useNomadPageViewerStore = create((set, get) => ({ ...initialViewerState, @@ -342,17 +367,7 @@ export const useNomadPageViewerStore = create((set, get) = if (get().loadGeneration !== generation) return; // Restart countdown for the retry using the sidecar proof window (not a // fresh fake 45s) so the timer does not jump back up mid-load. - const retryEgress = egressFromNomadPageResponse(res); - const pathHops = - typeof res.path_hops === 'number' && Number.isFinite(res.path_hops) - ? Math.max(1, Math.trunc(res.path_hops)) - : (node?.hops ?? 8); - const retryLinkHops = - retryEgress === 'rf' || retryEgress === 'ble' - ? Math.min(32, pathHops) - : Math.min(7, Math.max(3, pathHops)); - // ~4s force-path refresh + link_hops × 6s proof (MeshChat-style). - budgetSec = 4 + retryLinkHops * 6; + budgetSec = nomadPageRetryLoadingBudgetSec(res, node?.hops); set({ pageLoadingStartedAt: Date.now(), pageLoadingBudgetSec: budgetSec, From a4dc1266958a6140c8fde9344bc570b72f9a3ff4 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 1 Aug 2026 12:13:34 -0600 Subject: [PATCH 4/4] fix(reticulum): keep Nomad path-probe accepts and oversized-link diags Accept non-force paths that appear by timeout, retain link-budget fields on response_too_large, clear retry UI state on invalid URL, and cover retry-budget fallbacks in tests. --- reticulum-sidecar/src/stack/live.rs | 77 +++++++++-- .../stores/nomadPageViewerLoad.test.ts | 125 ++++++++++++++++++ src/renderer/stores/nomadPageViewerStore.ts | 2 + 3 files changed, 194 insertions(+), 10 deletions(-) diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 5b3de4301..933cb688a 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -1179,7 +1179,7 @@ impl LiveBridge { { Ok((bytes, meta)) => { if bytes.len() > NOMAD_FILE_MAX_BYTES { - return serde_json::json!({ "ok": false, "error": "response_too_large" }); + return nomad_response_too_large_json(&meta); } let file_name = nomad_file_name_from_path(path); let content_base64 = @@ -1253,7 +1253,7 @@ impl LiveBridge { { Ok((bytes, meta)) => { if bytes.len() > NOMAD_PAGE_MAX_BYTES { - return serde_json::json!({ "ok": false, "error": "response_too_large" }); + return nomad_response_too_large_json(&meta); } let content = String::from_utf8_lossy(&bytes).into_owned(); let content_type = if path.split('`').next().is_some_and(|p| p.ends_with(".mu")) { @@ -3748,6 +3748,13 @@ fn merge_nomad_remote_ok_fields(out: &mut serde_json::Value, meta: &NomadRemoteQ ); } +/// Oversized remote Nomad page/file response — keep Link-budget diagnostics. +fn nomad_response_too_large_json(meta: &NomadRemoteQueryOk) -> serde_json::Value { + let mut out = serde_json::json!({ "ok": false, "error": "response_too_large" }); + merge_nomad_remote_ok_fields(&mut out, meta); + out +} + /// Remote Nomad page/file error JSON; include path-aware egress and Link budgets when known. fn nomad_remote_error_json(err: &NomadRemoteQueryError) -> serde_json::Value { let mut out = serde_json::json!({ "ok": false, "error": err.code }); @@ -3785,9 +3792,9 @@ fn force_path_refresh_accepts_current_path( /// Timeout decision for [`LiveStack::ensure_path_for_direct_with_opts`]. /// -/// When `accept_existing_on_timeout` is set (Nomad force refresh), a path that -/// never went absent may still be accepted so the Link attempt can proceed -/// inside the overall budget. +/// `accept_existing_on_timeout` is only for forced refresh of a path that was +/// already present (stale fall-through). Non-force probes accept any path that +/// appeared by timeout without that flag. #[allow(clippy::fn_params_excessive_bools)] // mirrors ensure_path wait-loop flags fn force_path_refresh_timeout_accepts( force: bool, @@ -3800,11 +3807,12 @@ fn force_path_refresh_timeout_accepts( return false; } if force && had_path_at_start { + // Reserve accept_existing_on_timeout for forced stale-path fall-through. return force_path_refresh_accepts_current_path(force, had_path_at_start, saw_path_absent) || accept_existing_on_timeout; } - // Non-force miss, or force with no path at start: accept any path that appeared. - accept_existing_on_timeout + // Non-force probe (or force with no path at start): accept a path that appeared. + true } /// Classify path-ensure outcome after the RequestPath wait times out. @@ -4190,6 +4198,31 @@ mod announce_display_name_tests { assert!(out.get("force_path_ok").is_none()); } + #[test] + fn nomad_response_too_large_json_retains_link_budget_diagnostics() { + let meta = NomadRemoteQueryOk { + egress: "tcp", + timeout_secs: 45, + path_hops: 5, + link_hops: 5, + force_path_ok: Some(false), + path_ensure_kind: Some("cached_hit"), + elapsed_ms: 1200, + }; + // Same helper used by remote page and file oversized branches. + let out = nomad_response_too_large_json(&meta); + assert_eq!(out["ok"], false); + assert_eq!(out["error"], "response_too_large"); + 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["timeout_secs"], 45); + assert_eq!(out["force_path_ok"], false); + assert_eq!(out["path_ensure_kind"], "cached_hit"); + assert_eq!(out["elapsed_ms"], 1200); + } + #[test] fn force_path_refresh_timeout_accepts_fallthrough_when_never_absent() { // Nomad force refresh: path never left the table, but fall-through is on. @@ -4208,13 +4241,21 @@ mod announce_display_name_tests { assert!(!force_path_refresh_timeout_accepts( true, true, false, true, true )); - // Force started without a path: fall-through accepts whatever appeared. + // Force started without a path: accept whatever appeared (accept_existing unused). assert!(force_path_refresh_timeout_accepts( true, false, true, false, true )); - assert!(!force_path_refresh_timeout_accepts( + assert!(force_path_refresh_timeout_accepts( true, false, true, false, false )); + // Non-force first probe: path that appears by timeout is accepted even when + // accept_existing_on_timeout is false (reserved for forced stale fall-through). + assert!(force_path_refresh_timeout_accepts( + false, false, true, true, false + )); + assert!(!force_path_refresh_timeout_accepts( + false, false, false, true, false + )); } #[test] @@ -4227,7 +4268,23 @@ mod announce_display_name_tests { (true, true, true, true, false, PathEnsureKind::Rediscovered), (true, true, false, true, true, PathEnsureKind::Missing), (true, false, true, false, true, PathEnsureKind::Rediscovered), - (true, false, true, false, false, PathEnsureKind::Missing), + ( + true, + false, + true, + false, + false, + PathEnsureKind::Rediscovered, + ), + ( + false, + false, + true, + true, + false, + PathEnsureKind::Rediscovered, + ), + (false, false, false, true, false, PathEnsureKind::Missing), ]; for &(force, had_start, has_path, saw_absent, accept_existing, expected) in cases { let accept = force_path_refresh_timeout_accepts( diff --git a/src/renderer/stores/nomadPageViewerLoad.test.ts b/src/renderer/stores/nomadPageViewerLoad.test.ts index 07d71d425..3e9a9f4cb 100644 --- a/src/renderer/stores/nomadPageViewerLoad.test.ts +++ b/src/renderer/stores/nomadPageViewerLoad.test.ts @@ -155,6 +155,123 @@ describe('nomadPageViewerStore loadPage cache', () => { } }); + it('retry budget uses link_hops when proof_budget_secs is absent', async () => { + vi.useFakeTimers(); + const { restore } = mockConsoleWarn(); + try { + let resolveFirst: ((value: unknown) => void) | undefined; + let resolveSecond: ((value: unknown) => void) | undefined; + const fetchNomadPage = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); + useNomadNetworkStore.setState({ fetchNomadPage }); + + const loadPromise = useNomadPageViewerStore + .getState() + .loadPage('abc1234567890', '/page/index.mu'); + await vi.advanceTimersByTimeAsync(NOMAD_PAGE_FETCH_DEBOUNCE_MS); + const firstStartedAt = useNomadPageViewerStore.getState().pageLoadingStartedAt; + expect(firstStartedAt).toBeTypeOf('number'); + + resolveFirst?.({ + ok: false, + error: 'link_timeout', + egress: 'tcp', + link_hops: 5, + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(NOMAD_PAGE_FETCH_RETRY_SETTLE_MS); + const retryStartedAt = useNomadPageViewerStore.getState().pageLoadingStartedAt; + expect(retryStartedAt).toBeTypeOf('number'); + expect(retryStartedAt).toBeGreaterThan(firstStartedAt!); + // 4s path refresh + link_hops × 6s proof. + expect(useNomadPageViewerStore.getState().pageLoadingBudgetSec).toBe(4 + 5 * 6); + expect(useNomadPageViewerStore.getState().pageLoadingRetrying).toBe(true); + + resolveSecond?.({ + ok: true, + content: 'ok via link_hops budget', + content_type: 'micron', + egress: 'tcp', + }); + await loadPromise; + expect(fetchNomadPage).toHaveBeenCalledTimes(2); + expect(useNomadPageViewerStore.getState().pageContent).toBe('ok via link_hops budget'); + } finally { + restore(); + vi.useRealTimers(); + } + }); + + it('retry budget falls back to local clamp for path_timeout without link fields', async () => { + vi.useFakeTimers(); + const { restore } = mockConsoleWarn(); + try { + let resolveFirst: ((value: unknown) => void) | undefined; + let resolveSecond: ((value: unknown) => void) | undefined; + const fetchNomadPage = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); + useNomadNetworkStore.setState({ fetchNomadPage }); + + const loadPromise = useNomadPageViewerStore + .getState() + .loadPage('abc1234567890', '/page/index.mu'); + await vi.advanceTimersByTimeAsync(NOMAD_PAGE_FETCH_DEBOUNCE_MS); + const firstStartedAt = useNomadPageViewerStore.getState().pageLoadingStartedAt; + expect(firstStartedAt).toBeTypeOf('number'); + + resolveFirst?.({ + ok: false, + error: 'path_timeout', + egress: 'tcp', + }); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(NOMAD_PAGE_FETCH_RETRY_SETTLE_MS); + const retryStartedAt = useNomadPageViewerStore.getState().pageLoadingStartedAt; + expect(retryStartedAt).toBeTypeOf('number'); + expect(retryStartedAt).toBeGreaterThan(firstStartedAt!); + // Node hops=1 → TCP clamp floor 3 → 4 + 3×6. + expect(useNomadPageViewerStore.getState().pageLoadingBudgetSec).toBe(4 + 3 * 6); + expect(useNomadPageViewerStore.getState().pageLoadingRetrying).toBe(true); + + resolveSecond?.({ + ok: true, + content: 'ok via path_timeout fallback', + content_type: 'micron', + egress: 'tcp', + }); + await loadPromise; + expect(fetchNomadPage).toHaveBeenCalledTimes(2); + expect(useNomadPageViewerStore.getState().pageContent).toBe('ok via path_timeout fallback'); + } finally { + restore(); + vi.useRealTimers(); + } + }); + it('snapshots the node on unexpected fetch rejection', async () => { vi.useFakeTimers(); const { restore } = mockConsoleWarn(); @@ -182,11 +299,19 @@ describe('nomadPageViewerStore loadPage cache', () => { }); it('setInvalidUrlError stores the raw invalid_url code', () => { + useNomadPageViewerStore.setState({ + pageLoadingRetrying: true, + pageLoadingBudgetSec: 42, + pageLoading: true, + pageLoadingStartedAt: Date.now(), + }); useNomadPageViewerStore.getState().setInvalidUrlError(); const state = useNomadPageViewerStore.getState(); expect(state.pageErrorRaw).toBe('invalid_url'); expect(state.pageErrorNodeSnapshot).toBeNull(); expect(state.pageLoading).toBe(false); expect(state.pageLoadingStartedAt).toBeNull(); + expect(state.pageLoadingRetrying).toBe(false); + expect(state.pageLoadingBudgetSec).toBe(0); }); }); diff --git a/src/renderer/stores/nomadPageViewerStore.ts b/src/renderer/stores/nomadPageViewerStore.ts index 1ca60a128..5099b7d02 100644 --- a/src/renderer/stores/nomadPageViewerStore.ts +++ b/src/renderer/stores/nomadPageViewerStore.ts @@ -259,6 +259,8 @@ export const useNomadPageViewerStore = create((set, get) = pageErrorNodeSnapshot: null, pageLoading: false, pageLoadingStartedAt: null, + pageLoadingRetrying: false, + pageLoadingBudgetSec: 0, }); },