From d8d9057c6a01ec3ed637567a8e56732a71abfaed Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 9 Aug 2026 15:14:24 -0600 Subject: [PATCH 1/4] fix: make remote PN Sync client /get-primary Remote Sync hung at AwaitingResponse when the local messagestore had inventory to offer. Drive Sync from client /get, keep peer /offer on the host loop, and fail fast when the RNS stack is not live. --- docs/agents/reticulum.md | 2 +- docs/troubleshooting.md | 2 +- reticulum-sidecar/src/stack/live.rs | 134 +++++++++++++----- reticulum-sidecar/src/stack/mod.rs | 27 ++-- reticulum-sidecar/src/stack/persistence.rs | 2 + .../src/stack/propagation_bridge.rs | 87 ++++++++++-- src/main/reticulum-sidecar-manager.test.ts | 3 +- src/main/reticulumSidecarStderrLog.ts | 4 +- .../reticulumPropagationSync.test.ts | 3 + .../lib/reticulum/reticulumPropagationSync.ts | 4 + src/renderer/locales/cs/translation.json | 3 +- src/renderer/locales/de/translation.json | 3 +- src/renderer/locales/en/translation.json | 1 + src/renderer/locales/es/translation.json | 3 +- src/renderer/locales/fr/translation.json | 3 +- src/renderer/locales/id/translation.json | 3 +- src/renderer/locales/it/translation.json | 3 +- src/renderer/locales/ja/translation.json | 3 +- src/renderer/locales/ko/translation.json | 3 +- src/renderer/locales/nl/translation.json | 3 +- src/renderer/locales/pl/translation.json | 3 +- src/renderer/locales/pt-BR/translation.json | 3 +- src/renderer/locales/ru/translation.json | 3 +- src/renderer/locales/tr/translation.json | 3 +- src/renderer/locales/uk/translation.json | 3 +- src/renderer/locales/zh/translation.json | 3 +- .../stores/reticulumPropagationStore.ts | 6 +- 27 files changed, 246 insertions(+), 74 deletions(-) diff --git a/docs/agents/reticulum.md b/docs/agents/reticulum.md index 63e121c30..a8791ae11 100644 --- a/docs/agents/reticulum.md +++ b/docs/agents/reticulum.md @@ -8,7 +8,7 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Re - **Deep links / QR:** OS scheme is **`lxm://`** (not `mesh-client://`); `MeshClientDeepLinkHost`, `meshClientDeepLink.ts` (`lxmPaperMessage` kind + `looksLikeLxmPaperBlob`; Games `lxm://game/` / Ratspeak `lrgp:` → `lxmGameSession`), `handleReticulumQrIngest.ts` (shared Network/Chat/OS paper + in-app contact ingest), `applyLxmPaperIngest` → `POST /api/v1/lxmf/paper/ingest`, `QrIngestControl` / `QrCodeImage`. OS contact / MeshCore imports confirm before upsert; **paper OS deep links ingest without confirm**; Games session links open Reticulum Games tab via `openReticulumGameSession`. - **Decommissioned hubs:** `src/shared/reticulumDecommissionedHubs.ts` (Amsterdam only) — stack-start auto-disable + **Add default backbones** disables matching enabled TCP rows; UI badge + enable-block in `ReticulumInterfacesPanel.tsx` (`isDecommissionedReticulumTcpInterfaceRow`); keep TS↔Rust synced via `pnpm run check:reticulum-decommissioned-hubs`. Default backbone picker + region-grouped interface list (Primary & Global / North America / Europe / Asia & Oceania / Specialty / User Defined) in `reticulumDefaultHubPresets.ts` + `ReticulumDefaultHubsPickerModal.tsx`; muted disabled rows + checkbox bulk delete; `countEnabledDefaultHubPresets` / >3 enable warning - **BLE RNode RSSI:** `useReticulumBleRnodeRssiMap` gates on sidecar **running** (not api-ready), burst-then-steady scans via nested `acquireReticulumBleScan`, clears sticky targets immediately when all BLE RNodes are disabled -- **Propagation mode / sync:** Network → Propagation nodes owns Off/Auto/Manual (default **Off**; persisted values including legacy App-panel `auto` are honored). Auto one-time syncs via `startPropagationSyncCascade` + sidecar `destination_hash` sync in order: **finite-hop discovered** (no Add/Preferred) → **configured remotes** → **unknown-hop discovered** → local-prop (skips remotes when no enabled interfaces); runtime hook `useReticulumPropagationAutoSync`. Sidecar `start_propagation_sync` hard-fails with `PROPAGATION_PATH_UNKNOWN` when `ensure_path_for_direct` fails after announce settle (same code as offer probe — no Establishing stall). Manual uses Preferred, else picks the best configured remote **for that sync only** (no Preferred write), then the remaining remotes, then local-prop. Off = **no PN support**: `startPropagationSyncCascade` returns early (per-row Sync is disabled in UI), `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` are false, `ReticulumPropagationNotice` is hidden, and the sidecar disarms the outbound PN plus empties cascade candidates (`propagation_mode` in `mesh_client_stack.json`, `POST /api/v1/propagation/mode`, `candidates_for_propagation_mode`); renderer pushes the mode on change and on sidecar-ready. `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Complete on HaveAll, Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation. **Nothing-to-sync is not a failure:** when the cascade contacts no node it writes `syncNoTarget` / `syncLocalLoading` (never overwriting a real error from an attempted node), the local row reports sidecar `status: "loading"` while the messagestore reads (`local_propagation_status` + `PropagationBridge::messagestore_load_pending`, per-row Sync disabled), and the 30 s tick calls `refreshFromSidecar` while `hasPropagationCascadeCandidate` is false so a fresh stack recovers on its own — `refreshFromSidecar` must **not** clear the active attempt while `sync.active`. Debug snapshot `propagationClient` exposes mode/preferred/autoTarget/resolvedSyncTargetId. **Auto also deposits on Discovered PNs:** sidecar `auto_discovered_candidates` (`pn_cascade.rs`, Auto only, cap 3, hop-sorted, skips inactive / self / already-configured / over `max_peering_cost`) appends after configured remotes and before local-prop, rebuilt from the shared `rebuild_pn_cascade_candidates` helper in `live.rs` (called by `refresh_pn_cascade_candidates` **and** the PN announce handler); `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` therefore count discovered rows in Auto, so the Chat notice hides and the link-timeout failure bridge holds off. **Chat notice dismiss:** `chatNoticeDismissed` (`mesh-client:reticulumPropagationNoticeDismissed`) with **Don't show again** on the banner and **Show propagation reminder in Chat** in the Network section. **Named sync target:** `startSync` stamps `syncTargetId`; progress line, inline error, and Sync toasts resolve it with `resolveReticulumPropagationTargetLabel`; the cascade clears it when nothing was contacted so `syncNoTarget` / `syncLocalLoading` stay unprefixed. **Attempts settle before the cascade advances:** `startSync` returns `accepted` | `deferred` | `failed` (not a boolean) — only sidecar _acceptance_ starts `awaitPropagationSyncSettled` (terminal WS frame or stall/ceiling watchdog). `failed` advances with ~15 min session-memory omit via `reticulumPropagationSyncBackoff.ts`; `deferred` (`PROPAGATION_SYNC_OUTBOUND_BUSY` — outbound deposit owns the PN link) advances **without** backoff so the next tick may retry; `cancelled` (user Cancel) stops; `success` ends the run. Remote steps are capped by `PROPAGATION_CASCADE_BUDGET_MS` (5 min) then fall through to local-prop; each remote attempt is capped by `PROPAGATION_CASCADE_ATTEMPT_TIMEOUT_MS` (~60s); local fallback refreshes nodes when local looks disabled; the cascade is single-flight (`resetPropagationSyncCascadeState` is the test seam) so overlapping 30 s ticks join one run while an explicit per-row Sync supersedes it. Auto `/api/v1/interfaces` probe **fails open** (assumes interfaces enabled) so a broken proxy still tries remotes before local. **Retrieval vs peer sync:** the WS progress bar / HaveAll reflects the `/offer` peer sync (`PropagationSyncTask`, inventory replication only). The half that actually pulls your mail into Chat is the **client `/get` download** (`PropagationClient`), wired in `propagation_download.rs` + `propagation_bridge.rs::poll_client_download` and driven by `live.rs::spawn_client_download_driver` (list → get → purge; decrypt with local identity → router delivery callback). `local-prop` Sync uses `PropagationBridge::drain_local_inbox` (in-process node `/get` replay). Logs: real inbox retrieval is `propagation-retrieve` (`retrieve_mode=get|local`, `listed`/`downloaded`/`delivered`); the peer-offer outcome is `propagation-sync … peer_outcome=have_all|transfer` (**not** retrieval). +- **Propagation mode / sync:** Network → Propagation nodes owns Off/Auto/Manual (default **Off**; persisted values including legacy App-panel `auto` are honored). Auto one-time syncs via `startPropagationSyncCascade` + sidecar `destination_hash` sync in order: **finite-hop discovered** (no Add/Preferred) → **configured remotes** → **unknown-hop discovered** → local-prop (skips remotes when no enabled interfaces); runtime hook `useReticulumPropagationAutoSync`. Sidecar `start_propagation_sync` is **client `/get`-primary** (inbox retrieval; UI progress from `PropagationClient`) — peer `/offer` inventory push stays on the local-host peer loop when serving (avoids AwaitingResponse hangs against non-peer remotes with a nonempty messagestore). Hard-fails with `PROPAGATION_PATH_UNKNOWN` when `ensure_path_for_direct` fails after announce settle (same path gate as offer probe). Manual uses Preferred, else picks the best configured remote **for that sync only** (no Preferred write), then the remaining remotes, then local-prop. Off = **no PN support**: `startPropagationSyncCascade` returns early (per-row Sync is disabled in UI), `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` are false, `ReticulumPropagationNotice` is hidden, and the sidecar disarms the outbound PN plus empties cascade candidates (`propagation_mode` in `mesh_client_stack.json`, `POST /api/v1/propagation/mode`, `candidates_for_propagation_mode`); renderer pushes the mode on change and on sidecar-ready. `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Complete on HaveAll, Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation. **Nothing-to-sync is not a failure:** when the cascade contacts no node it writes `syncNoTarget` / `syncLocalLoading` (never overwriting a real error from an attempted node), the local row reports sidecar `status: "loading"` while the messagestore reads (`local_propagation_status` + `PropagationBridge::messagestore_load_pending`, per-row Sync disabled), and the 30 s tick calls `refreshFromSidecar` while `hasPropagationCascadeCandidate` is false so a fresh stack recovers on its own — `refreshFromSidecar` must **not** clear the active attempt while `sync.active`. Debug snapshot `propagationClient` exposes mode/preferred/autoTarget/resolvedSyncTargetId. **Auto also deposits on Discovered PNs:** sidecar `auto_discovered_candidates` (`pn_cascade.rs`, Auto only, cap 3, hop-sorted, skips inactive / self / already-configured / over `max_peering_cost`) appends after configured remotes and before local-prop, rebuilt from the shared `rebuild_pn_cascade_candidates` helper in `live.rs` (called by `refresh_pn_cascade_candidates` **and** the PN announce handler); `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` therefore count discovered rows in Auto, so the Chat notice hides and the link-timeout failure bridge holds off. **Chat notice dismiss:** `chatNoticeDismissed` (`mesh-client:reticulumPropagationNoticeDismissed`) with **Don't show again** on the banner and **Show propagation reminder in Chat** in the Network section. **Named sync target:** `startSync` stamps `syncTargetId`; progress line, inline error, and Sync toasts resolve it with `resolveReticulumPropagationTargetLabel`; the cascade clears it when nothing was contacted so `syncNoTarget` / `syncLocalLoading` stay unprefixed. **Attempts settle before the cascade advances:** `startSync` returns `accepted` | `deferred` | `failed` (not a boolean) — only sidecar _acceptance_ starts `awaitPropagationSyncSettled` (terminal WS frame or stall/ceiling watchdog). `failed` advances with ~15 min session-memory omit via `reticulumPropagationSyncBackoff.ts`; `deferred` (`PROPAGATION_SYNC_OUTBOUND_BUSY` — outbound deposit owns the PN link) advances **without** backoff so the next tick may retry; `cancelled` (user Cancel) stops; `success` ends the run. Remote steps are capped by `PROPAGATION_CASCADE_BUDGET_MS` (5 min) then fall through to local-prop; each remote attempt is capped by `PROPAGATION_CASCADE_ATTEMPT_TIMEOUT_MS` (~60s); local fallback refreshes nodes when local looks disabled; the cascade is single-flight (`resetPropagationSyncCascadeState` is the test seam) so overlapping 30 s ticks join one run while an explicit per-row Sync supersedes it. Auto `/api/v1/interfaces` probe **fails open** (assumes interfaces enabled) so a broken proxy still tries remotes before local. **Retrieval vs peer sync:** the WS progress bar / HaveAll reflects the `/offer` peer sync (`PropagationSyncTask`, inventory replication only). The half that actually pulls your mail into Chat is the **client `/get` download** (`PropagationClient`), wired in `propagation_download.rs` + `propagation_bridge.rs::poll_client_download` and driven by `live.rs::spawn_client_download_driver` (list → get → purge; decrypt with local identity → router delivery callback). `local-prop` Sync uses `PropagationBridge::drain_local_inbox` (in-process node `/get` replay). Logs: real inbox retrieval is `propagation-retrieve` (`retrieve_mode=get|local`, `listed`/`downloaded`/`delivered`); the peer-offer outcome is `propagation-sync … peer_outcome=have_all|transfer` (**not** retrieval). - **PN hosting:** Network **Advanced PN hosting** / `ReticulumPnHostingDangerZone`; shared `pnHostingPolicy.ts` + sidecar `pn_hosting_policy.rs` / `pn_hosting_apply.rs`; `POST /api/v1/propagation/hosting-policy`; rsLXMF policy-setters overlay ([ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6)). Messagestore loads in background on live attach; enabled `local-prop` serve/announce waits until load completes. - **Interface modes:** rnsd `mode` via `reticulumInterfaceMode.ts` + sidecar `normalize_interface_mode` (keep catalogs in sync — `pnpm run check:reticulum-interface-modes` in pre-commit/`release.sh`); add defaults TCP/UDP/I2P → `boundary`, RNode → `access_point`; UI in `ReticulumInterfacesPanel`; default hub presets add/repair missing mode to `boundary` (do not overwrite valid non-boundary). See [../reticulum.md#interface-modes](../reticulum.md#interface-modes). - **Share instance defaults:** missing keys bootstrap to `share_instance = No` / `instance_name = mesh-client` (does not overwrite explicit Yes/`default`); SharedInstanceClient banner + `disable_share_instance` repair; offline lint via `reticulum:validateConfig` / Network **Check config** / `pnpm run reticulum:config:check` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f87728a2e..725b94d21 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1337,7 +1337,7 @@ Bond-stale **TX queue full** hints (`txQueueDropsHintBleBondStale`) point at the **Cause (any-node model)**: LXMF does **not** require both parties to prefer the same PN. Deposit on PN A and retrieve via Sync from PN B is valid when autopeer/static peering moves inventory. Empty Chat after Sync is usually a fabric/retrieve/ingest gap (mail never reached the synced node, stamp/admission drop on a host PN, or inbound ring not catch-up’d into Chat) — not “wrong preferred PN.” -**Progress bar ≠ retrieval.** Sync runs two independent operations against the PN: the **`/offer` peer sync** (inventory replication — this is what drives the progress bar and the `have_all`/`transfer` outcome) and the **client `/get` download** (the part that actually pulls _your_ mail into Chat). A Sync reaching **Complete / HaveAll** only tells you the peer-offer finished; it does **not** mean anything was retrieved. Look for the `/get` retrieve counts, not the peer-offer outcome. +**Progress bar = client `/get` retrieval.** User Sync against a remote PN drives the progress bar from the **client `/get` download** (inbox mail into Chat). Peer `/offer` inventory replication runs on the **local Host peer loop** when you are serving a PN — not on the Sync button — so a nonempty messagestore cannot hang Sync at AwaitingResponse against remotes that are not your peers. Look for `propagation-retrieve` `/get` Completes in Device logs for retrieve counts. **Do not** tell users they must share the same preferred PN. Prefer log correlation instead: diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 1b4148ed5..143078701 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -3642,17 +3642,8 @@ impl LiveBridge { ); return Err("PROPAGATION_PATH_UNKNOWN".into()); } - let peering = match self.resolve_propagation_peering(&dest_hex).await { - Ok(p) => p, - Err(e) => { - if let Ok(mut driver) = self.outbound.lock() { - driver.set_propagation_sync_target(None); - } - return Err(e); - } - }; - // Pin PN pubkey for the duration of Establishing so announce-flood eviction - // cannot drop it before LRPROOF validation (see known_identities cap). + // Pin PN pubkey for the duration of the client `/get` link so announce-flood + // eviction cannot drop it before LRPROOF validation (see known_identities cap). let pinned = if let Ok(mut driver) = self.outbound.lock() { if let Some(pub_key) = driver.public_key_for(&dest_hex) { driver.pin_identity_for_propagation(&dest_hex, pub_key); @@ -3663,13 +3654,18 @@ impl LiveBridge { } else { false }; + let local_serving = self.propagation.is_local_serving(); + let (msg_count, msg_bytes) = self.propagation.local_stats(); tracing::info!( target: "propagation-sync", dest = %dest_hex, path_ok, hops = ?hops, pinned, - "starting remote propagation sync" + local_serving, + msg_count, + msg_bytes, + "starting remote propagation sync (client /get; peer /offer deferred to host loop)" ); // Fresh cancel token + generation so a prior emitter cannot cancel/clear this run. let (cancel, run_id) = { @@ -3690,18 +3686,11 @@ impl LiveBridge { let run_id = self.sync_run_id.fetch_add(1, Ordering::SeqCst) + 1; (cancel, run_id) }; - if !self.propagation.start_sync(hash, Some(peering)) { - if let Ok(mut driver) = self.outbound.lock() { - driver.clear_propagation_identity_pins(); - driver.set_propagation_sync_target(None); - } - return Err("propagation sync unavailable".into()); - } - // Retrieval half of Sync (Python `request_messages_from_propagation_node`): - // pull our own store-and-forward mail from this PN via the client `/get` - // download and deliver it into Chat. Runs alongside the `/offer` peer sync - // above, which only replicates inventory and never fills our inbox. - self.spawn_client_download_driver(hash, dest_hex.clone(), Arc::clone(&cancel), run_id); + // User Sync retrieves inbox mail via client `/get` (Python + // `request_messages_from_propagation_node`). Peer `/offer` inventory push is + // owned by the local-host peer loop when serving — running it here with a + // nonempty messagestore hangs at AwaitingResponse against remotes that are + // not our peers, and the UI progress bar never Completes. let outbound = Arc::clone(&self.outbound); let on_terminal: Arc = Arc::new(move || { if let Ok(mut driver) = outbound.lock() { @@ -3709,37 +3698,48 @@ impl LiveBridge { driver.set_propagation_sync_target(None); } }); - self.propagation.spawn_sync_progress_emitter( - self.event_tx.clone(), - cancel, + if !self.spawn_client_download_driver( + hash, + dest_hex.clone(), + Arc::clone(&cancel), run_id, - Arc::clone(&self.sync_run_id), + self.event_tx.clone(), Some(on_terminal), - ); + ) { + if let Ok(mut driver) = self.outbound.lock() { + driver.clear_propagation_identity_pins(); + driver.set_propagation_sync_target(None); + } + return Err("propagation sync unavailable".into()); + } Ok(()) } - /// Drive the client `/get` download to completion off the sync request path. + /// Drive the client `/get` download to completion and emit Sync UI progress. /// /// Ticks [`PropagationBridge::poll_client_download`] on a short interval, /// feeding it the current known-identity map (for link-proof validation) and, /// on a terminal Complete, delivering each decoded message through the router /// delivery callback — the same path Direct/opportunistic inbound uses, so WS /// `lxmf_message`, the recent ring, and renderer catch-up all fire unchanged. + /// + /// Returns `false` when the client refuses to start (already active). fn spawn_client_download_driver( &self, pn_hash: [u8; 16], pn_hex: String, cancel: Arc, run_id: u64, - ) { + event_tx: broadcast::Sender, + on_terminal: Option>, + ) -> bool { if !self.propagation.start_client_download(pn_hash) { tracing::debug!( target: "propagation-retrieve", pn_hash = %pn_hex, "client /get download not started (already active or unavailable)" ); - return; + return false; } let bridge = Arc::clone(&self.propagation); let router = Arc::clone(&self.router); @@ -3749,8 +3749,33 @@ impl LiveBridge { // Client tick cadence; the client's own 120s timeout bounds a stuck link. const POLL_INTERVAL: Duration = Duration::from_millis(500); const DOWNLOAD_WATCHDOG: Duration = Duration::from_secs(180); + const ESTABLISH_STALL: Duration = Duration::from_secs(45); let mut interval = tokio::time::interval(POLL_INTERVAL); let started = Instant::now(); + let mut last_logged_progress = -1.0_f64; + let emit_progress = |active: bool, progress: f64, message: Option<&str>| { + let payload = serde_json::json!({ + "active": active, + "progress": progress, + "message": message, + }); + let frame = serde_json::json!({ + "type": "propagation_sync", + "payload": payload, + }); + let _ = event_tx.send(frame.to_string()); + }; + let clear_pins = || { + bridge.run_if_current(&active_run_id, run_id, || { + if let Some(ref cb) = on_terminal { + cb(); + } + }); + }; + // Immediate Establishing frame so the renderer stall watchdog sees progress. + bridge.run_if_current(&active_run_id, run_id, || { + emit_progress(true, 10.0, None); + }); loop { interval.tick().await; // Superseded by a newer sync run: that run now owns the client, @@ -3769,6 +3794,26 @@ impl LiveBridge { }); break; } + let progress = bridge.client_download_progress(); + if progress <= 10.0 + && bridge.client_download_active() + && started.elapsed() > ESTABLISH_STALL + { + tracing::info!( + target: "propagation-retrieve", + pn_hash = %pn_hex, + "client /get stalled while establishing" + ); + bridge.run_if_current(&active_run_id, run_id, || { + bridge.cancel_client_download(); + emit_progress( + false, + 0.0, + Some("propagation establish failed: NoLinkProof"), + ); + }); + break; + } if started.elapsed() > DOWNLOAD_WATCHDOG { tracing::info!( target: "propagation-retrieve", @@ -3777,9 +3822,20 @@ impl LiveBridge { ); bridge.run_if_current(&active_run_id, run_id, || { bridge.cancel_client_download(); + emit_progress( + false, + 0.0, + Some("propagation establish failed: NoLinkProof"), + ); }); break; } + if (progress - last_logged_progress).abs() >= 0.5 { + last_logged_progress = progress; + bridge.run_if_current(&active_run_id, run_id, || { + emit_progress(true, progress, None); + }); + } let known = outbound .lock() .ok() @@ -3797,7 +3853,14 @@ impl LiveBridge { ); // Consume the terminal failed state → Idle so a later // sync can start a fresh retrieval. - bridge.cancel_client_download(); + bridge.run_if_current(&active_run_id, run_id, || { + bridge.cancel_client_download(); + emit_progress( + false, + 0.0, + Some("propagation establish failed: NoLinkProof"), + ); + }); break; } ClientDownloadPoll::Complete { @@ -3824,11 +3887,16 @@ impl LiveBridge { retrieve_mode = "get", "client /get download Completes" ); + bridge.run_if_current(&active_run_id, run_id, || { + emit_progress(false, 100.0, None); + }); break; } } } + clear_pins(); }); + true } /// Drain the in-process (`local-prop`) PN store of our own mail into Chat. diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index bfa874ebf..11d132497 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -1523,15 +1523,24 @@ impl StackHandle { return Err("LOCAL_PROPAGATION_SYNC_UNSUPPORTED".into()); } #[cfg(feature = "rns-stack")] - if let Some(live) = self.live.get() { - live.start_propagation_sync(&prop_hash).await?; - return Ok(()); + { + if let Some(live) = self.live.get() { + live.start_propagation_sync(&prop_hash).await?; + return Ok(()); + } + // Never fall through to the persistence stub: it marks sync active at + // progress 0 with no emitter, so the renderer stall-watchdogs for 45s. + // Match start_propagation_sync_by_hash — cascade can defer/retry. + Err("PROPAGATION_STACK_NOT_LIVE".into()) + } + #[cfg(not(feature = "rns-stack"))] + { + let mut inner = self.inner.write().await; + inner.start_propagation_sync(propagation_id)?; + inner.save(&self.config_dir, &self.storage_dir)?; + self.emit_event("propagation_sync", inner.propagation_sync.clone()); + Ok(()) } - let mut inner = self.inner.write().await; - inner.start_propagation_sync(propagation_id)?; - inner.save(&self.config_dir, &self.storage_dir)?; - self.emit_event("propagation_sync", inner.propagation_sync.clone()); - Ok(()) } /// One-time remote sync by destination hash. Does not add a configured row or change Preferred. @@ -1570,7 +1579,7 @@ impl StackHandle { live.start_propagation_sync(&prop_hash).await?; return Ok(()); } - Err("RNS stack not live".into()) + Err("PROPAGATION_STACK_NOT_LIVE".into()) } pub async fn cancel_propagation_sync(&self) -> Result<(), String> { diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs index 898be622e..9137dcc91 100644 --- a/reticulum-sidecar/src/stack/persistence.rs +++ b/reticulum-sidecar/src/stack/persistence.rs @@ -384,6 +384,8 @@ impl PersistedState { Ok(()) } + // Used by StackHandle when `rns-stack` is off; tests cover the stub. + #[cfg_attr(feature = "rns-stack", allow(dead_code))] pub fn start_propagation_sync(&mut self, propagation_id: &str) -> Result<(), String> { if !self.propagation.iter().any(|p| p.id == propagation_id) { return Err(format!("propagation node not found: {propagation_id}")); diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index e3a811254..e52b1b26b 100644 --- a/reticulum-sidecar/src/stack/propagation_bridge.rs +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -394,6 +394,7 @@ impl PropagationBridge { } /// Whether a post-loop terminal success (progress 100) should be emitted. + #[allow(dead_code)] // used by peer-sync progress emitter + unit tests pub fn should_emit_terminal_success(last_finished_ok: Option) -> bool { last_finished_ok != Some(false) } @@ -412,6 +413,36 @@ impl PropagationBridge { client.start_download() } + /// Map client `/get` state to UI progress (user Sync is `/get`-primary). + pub fn client_download_progress(&self) -> f64 { + let Ok(client) = self.client.lock() else { + return 0.0; + }; + match client.state() { + PropagationClientState::Idle | PropagationClientState::Failed => 0.0, + PropagationClientState::LinkEstablishing => 10.0, + PropagationClientState::LinkEstablished => 20.0, + PropagationClientState::ListRequested => 40.0, + PropagationClientState::GetRequested => 55.0, + PropagationClientState::Receiving => 70.0, + PropagationClientState::PurgeRequested => 90.0, + PropagationClientState::Complete => 100.0, + } + } + + /// True while the client `/get` state machine is mid-transfer. + pub fn client_download_active(&self) -> bool { + let Ok(client) = self.client.lock() else { + return false; + }; + !matches!( + client.state(), + PropagationClientState::Idle + | PropagationClientState::Complete + | PropagationClientState::Failed + ) + } + /// Cancel any in-flight client download (best-effort). The next /// [`Self::start_client_download`] re-arms from Idle. pub fn cancel_client_download(&self) { @@ -667,6 +698,9 @@ impl PropagationBridge { terminal } + /// Emit peer `/offer` sync progress over WS (offer probe / host diagnostics). + /// User Sync drives UI from the client `/get` path instead. + #[allow(dead_code)] // retained for offer-probe / peer-sync diagnostics pub fn spawn_sync_progress_emitter( self: &Arc, event_tx: broadcast::Sender, @@ -1109,23 +1143,52 @@ mod tests { let path_gate_at = sync_body .find("PROPAGATION_PATH_UNKNOWN") .expect("PATH_UNKNOWN in start_propagation_sync"); - let peering_at = sync_body - .find("resolve_propagation_peering") - .expect("peering resolve in start_propagation_sync"); - let start_sync_at = sync_body - .find("start_sync(hash") - .expect("start_sync call in start_propagation_sync"); + let get_at = sync_body + .find("spawn_client_download_driver") + .expect("client /get driver in start_propagation_sync"); + assert!( + path_gate_at < get_at, + "path gate must run before client /get download" + ); + // Peer `/offer` inventory sync belongs on the host peer loop. User Sync that + // starts peer sync with a nonempty messagestore hangs at AwaitingResponse. + assert!( + !sync_body.contains("start_sync(hash"), + "user Sync must not start peer /offer inventory sync" + ); assert!( - path_gate_at < peering_at, - "path gate must run before peering PoW (nonzero peering_cost)" + sync_body.contains("peer /offer deferred to host loop") + || sync_body.contains("peerOfferSkipped"), + "user Sync must document /get-primary peer-offer skip" ); + // Offer probe still validates remotes speak `/offer`. + let probe_start = live + .find("pub async fn probe_propagation_offer") + .expect("probe_propagation_offer"); + let probe_fn = &live[probe_start..]; + let probe_end = probe_fn[1..] + .find("\n pub ") + .map_or(probe_fn.len(), |idx| idx + 1); + assert!( + probe_fn[..probe_end].contains("start_sync(hash"), + "offer probe must still exercise peer /offer" + ); + // Configured-row Sync must not accept via the persistence stub while live is None. + let stack_sync = include_str!("mod.rs"); + let by_id_start = stack_sync + .find("pub async fn start_propagation_sync(&self, propagation_id: &str)") + .expect("start_propagation_sync by id"); + let by_id = &stack_sync[by_id_start..]; + let by_id_end = by_id[1..] + .find("\n pub ") + .map_or(by_id.len(), |idx| idx + 1); assert!( - peering_at < start_sync_at, - "peering must run after path succeeds and before start_sync" + by_id[..by_id_end].contains("PROPAGATION_STACK_NOT_LIVE"), + "by-id Sync must hard-fail when RNS live is not attached" ); assert!( - path_gate_at < start_sync_at, - "path gate must run before start_sync / Establishing" + by_id[..by_id_end].contains("#[cfg(not(feature = \"rns-stack\"))]"), + "persistence stub Sync must stay gated behind not(rns-stack)" ); } diff --git a/src/main/reticulum-sidecar-manager.test.ts b/src/main/reticulum-sidecar-manager.test.ts index 539dcf274..fdf69196b 100644 --- a/src/main/reticulum-sidecar-manager.test.ts +++ b/src/main/reticulum-sidecar-manager.test.ts @@ -93,6 +93,7 @@ import { import { reticulumConfigDirHasEnabledBleRnode } from './reticulum-ble-rnode-config'; import { ReticulumSidecarManager } from './reticulum-sidecar-manager'; import { ensureDevSidecarBinary } from './reticulum-sidecar-path'; +import { SIDECAR_DEFAULT_RUST_LOG } from './reticulumSidecarStderrLog'; const SIDECAR_MANAGER_SOURCE = fs.readFileSync( join(import.meta.dirname ?? __dirname, 'reticulum-sidecar-manager.ts'), @@ -243,7 +244,7 @@ describe('ReticulumSidecarManager', () => { expect(first.port).toBeGreaterThan(0); expect(first.pid).toBe(4242); const spawnEnv = spawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv | undefined; - expect(spawnEnv?.RUST_LOG).toBe('warn'); + expect(spawnEnv?.RUST_LOG).toBe(SIDECAR_DEFAULT_RUST_LOG); await manager.stop(); diff --git a/src/main/reticulumSidecarStderrLog.ts b/src/main/reticulumSidecarStderrLog.ts index 5b32f725b..fb369ba39 100644 --- a/src/main/reticulumSidecarStderrLog.ts +++ b/src/main/reticulumSidecarStderrLog.ts @@ -7,7 +7,9 @@ const AUTO_BEACON_TX_FAILED_MARKER = 'auto: beacon TX failed'; const BEACON_FAIL_WARN_INTERVAL_MS = 60 * MS_PER_SECOND; /** Default tracing filter for sidecar child processes (overridable via env). */ -export const SIDECAR_DEFAULT_RUST_LOG = 'warn'; +// PN connect triage: keep global warn, but surface remote sync/deposit INFO in mesh-client.log. +export const SIDECAR_DEFAULT_RUST_LOG = + 'warn,propagation-sync=info,propagation-retrieve=info,propagation-deposit=info,lxmf-outbound=info'; /** * Whether a sidecar stdout line should be written to the app log. diff --git a/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts b/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts index 804ed0228..1d18c189a 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts @@ -125,6 +125,9 @@ describe('reticulumPropagationSync', () => { expect(mapPropagationSyncError('PROPAGATION_SYNC_OUTBOUND_BUSY')).toBe( 'reticulumPropagation.syncOutboundBusy', ); + expect(mapPropagationSyncError('PROPAGATION_STACK_NOT_LIVE')).toBe( + 'reticulumPropagation.syncStackNotLive', + ); expect(mapPropagationSyncError('propagation sync cancelled')).toBe( 'reticulumPropagation.syncCancelled', ); diff --git a/src/renderer/lib/reticulum/reticulumPropagationSync.ts b/src/renderer/lib/reticulum/reticulumPropagationSync.ts index 6211d975c..daad507fe 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationSync.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationSync.ts @@ -150,6 +150,10 @@ export function mapPropagationSyncError(error: string | null | undefined): strin if (error === 'PROPAGATION_SYNC_OUTBOUND_BUSY') { return 'reticulumPropagation.syncOutboundBusy'; } + // Live attach lag — startSync treats this as deferred; map if it surfaces elsewhere. + if (error === 'PROPAGATION_STACK_NOT_LIVE' || error === 'RNS stack not live') { + return 'reticulumPropagation.syncStackNotLive'; + } return mapPropagationSyncErrorBySubstring(error) ?? SYNC_FAILED_KEY; } diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 778976b97..d82045ec9 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -4292,7 +4292,8 @@ "showChatNotice": "Zobrazit připomenutí propagace v chatu", "showChatNoticeAria": "Zobrazit banner připomenutí propagačního uzlu v chatu", "showChatNoticeHint": "Vypnutím tohoto tlačítka skryjete banner chatu, který se zobrazí, když není k dispozici žádný propagační uzel.", - "syncPathUnknown": "Zatím neexistuje síťová cesta k tomuto propagačnímu uzlu. Zkontrolujte rozhraní nebo rozbočovač, oznamte to a zkuste to znovu." + "syncPathUnknown": "Zatím neexistuje síťová cesta k tomuto propagačnímu uzlu. Zkontrolujte rozhraní nebo rozbočovač, oznamte to a zkuste to znovu.", + "syncStackNotLive": "Synchronizace propagace odložena — zásobník Reticulum se stále spouští." }, "reticulumRmapDiscovery": { "sectionTitle": "Objevení RMAP v4", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index a89f71e39..77b750017 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -4290,7 +4290,8 @@ "showChatNotice": "Ausbreitungserinnerung im Chat anzeigen", "showChatNoticeAria": "Verbreitungsknoten-Erinnerungsbanner im Chat anzeigen", "showChatNoticeHint": "Deaktivieren Sie diese Option, um das Chat-Banner auszublenden, das angezeigt wird, wenn kein Ausbreitungsknoten verfügbar ist.", - "syncPathUnknown": "Noch kein Netzwerkpfad zu diesem Ausbreitungsknoten. Überprüfen Sie Ihre Schnittstellen oder Ihren Hub, kündigen Sie an und versuchen Sie es dann erneut." + "syncPathUnknown": "Noch kein Netzwerkpfad zu diesem Ausbreitungsknoten. Überprüfen Sie Ihre Schnittstellen oder Ihren Hub, kündigen Sie an und versuchen Sie es dann erneut.", + "syncStackNotLive": "Ausbreitungssynchronisation verschoben — der Reticulum-Stack wird noch gestartet." }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4 Entdeckung", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 399991390..1db0558d9 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -4594,6 +4594,7 @@ "syncNoTarget": "No propagation node is available yet — none has been discovered, and you have no added nodes. Sync runs on its own as soon as one is available.", "syncLocalLoading": "The local propagation node is still loading its stored messages. Sync runs on its own once it finishes.", "syncOutboundBusy": "Propagation sync deferred — an outbound message is depositing to this node.", + "syncStackNotLive": "Propagation sync deferred — the Reticulum stack is still starting.", "syncTimedOut": "Propagation sync timed out — the node may be unreachable.", "syncLocalNotSupported": "The local host propagation node cannot be synced over the network like a remote LXMF propagation node.", "syncIdentityUnknown": "Propagation node identity is unknown — wait for an announce or path response, then try again.", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index f5591861e..8b21da9ed 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -4290,7 +4290,8 @@ "showChatNotice": "Mostrar recordatorio de propagación en el chat", "showChatNoticeAria": "Mostrar el banner de recordatorio del nodo de propagación en el chat", "showChatNoticeHint": "Desactive esta opción para ocultar el banner de chat que aparece cuando no hay ningún nodo de propagación disponible.", - "syncPathUnknown": "Aún no hay ruta de red a este nodo de propagación. Comprueba tus interfaces o hub, anuncia y vuelve a intentarlo." + "syncPathUnknown": "Aún no hay ruta de red a este nodo de propagación. Comprueba tus interfaces o hub, anuncia y vuelve a intentarlo.", + "syncStackNotLive": "Sincronización de propagación diferida: la pila de Reticulum todavía está comenzando." }, "reticulumRmapDiscovery": { "sectionTitle": "Descubrimiento de RMAP v4", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 6b593ab94..9b246e950 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -4290,7 +4290,8 @@ "showChatNotice": "Afficher le rappel de propagation dans le chat", "showChatNoticeAria": "Afficher la bannière de rappel du nœud de propagation dans le chat", "showChatNoticeHint": "Désactivez cette option pour masquer la bannière de chat qui apparaît lorsqu'aucun nœud de propagation n'est disponible.", - "syncPathUnknown": "Pas encore de chemin réseau vers ce nœud de propagation. Vérifiez vos interfaces ou votre hub, annoncez, puis réessayez." + "syncPathUnknown": "Pas encore de chemin réseau vers ce nœud de propagation. Vérifiez vos interfaces ou votre hub, annoncez, puis réessayez.", + "syncStackNotLive": "Synchronisation de la propagation différée — la pile Reticulum est toujours en cours de démarrage." }, "reticulumRmapDiscovery": { "sectionTitle": "Découverte RMAP v4", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index b91f0e2f9..a1eec0d05 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -4290,7 +4290,8 @@ "showChatNotice": "Tampilkan pengingat propagasi di Obrolan", "showChatNoticeAria": "Tampilkan spanduk pengingat simpul propagasi di Obrolan", "showChatNoticeHint": "Nonaktifkan ini untuk menyembunyikan spanduk Obrolan yang muncul ketika tidak ada node propagasi yang tersedia.", - "syncPathUnknown": "Belum ada jalur jaringan ke simpul propagasi ini. Periksa antarmuka atau hub Anda, umumkan, lalu coba lagi." + "syncPathUnknown": "Belum ada jalur jaringan ke simpul propagasi ini. Periksa antarmuka atau hub Anda, umumkan, lalu coba lagi.", + "syncStackNotLive": "Sinkronisasi propagasi ditangguhkan — tumpukan Reticulum masih dimulai." }, "reticulumRmapDiscovery": { "sectionTitle": "Penemuan RMAP v4", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 14b7b261a..6baa38cb2 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -4290,7 +4290,8 @@ "showChatNotice": "Mostra promemoria di propagazione in chat", "showChatNoticeAria": "Mostra il banner di promemoria del nodo di propagazione in Chat", "showChatNoticeHint": "Disattiva questa opzione per nascondere il banner della chat che appare quando non è disponibile alcun nodo di propagazione.", - "syncPathUnknown": "Ancora nessun percorso di rete a questo nodo di propagazione. Controlla le interfacce o l'hub, annuncia, quindi riprova." + "syncPathUnknown": "Ancora nessun percorso di rete a questo nodo di propagazione. Controlla le interfacce o l'hub, annuncia, quindi riprova.", + "syncStackNotLive": "Sincronizzazione della propagazione posticipata — lo stack Reticulum è ancora in fase di avvio." }, "reticulumRmapDiscovery": { "sectionTitle": "Scoperta RMAP v4", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index a760a1eef..6e18425f4 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -4290,7 +4290,8 @@ "showChatNotice": "チャットに伝播リマインダーを表示", "showChatNoticeAria": "チャットで伝播ノードのリマインダーバナーを表示する", "showChatNoticeHint": "これをオフにすると、伝播ノードが利用できないときに表示されるチャットバナーが非表示になります。", - "syncPathUnknown": "この伝播ノードへのネットワークパスはまだありません。インターフェースまたはハブを確認し、お知らせしてから、もう一度お試しください。" + "syncPathUnknown": "この伝播ノードへのネットワークパスはまだありません。インターフェースまたはハブを確認し、お知らせしてから、もう一度お試しください。", + "syncStackNotLive": "伝播同期が延期されました—Reticulumスタックはまだ開始しています。" }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v 4検出", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 7fc6fbfaa..c9aba300f 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -4290,7 +4290,8 @@ "showChatNotice": "Chat에 전파 알림 표시", "showChatNoticeAria": "Chat에 전파 노드 알림 배너 표시", "showChatNoticeHint": "전파 노드를 사용할 수 없을 때 나타나는 채팅 배너를 숨기려면 이 옵션을 끄십시오.", - "syncPathUnknown": "이 전파 노드에 대한 네트워크 경로가 아직 없습니다. 인터페이스 또는 허브를 확인하고 공지한 다음 다시 시도하십시오." + "syncPathUnknown": "이 전파 노드에 대한 네트워크 경로가 아직 없습니다. 인터페이스 또는 허브를 확인하고 공지한 다음 다시 시도하십시오.", + "syncStackNotLive": "전파 동기화 지연 — Reticulum 스택이 아직 시작 중입니다." }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4 검색", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 0bba9afac..f25bbdcc6 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -4290,7 +4290,8 @@ "showChatNotice": "Toon propagatieherinnering in Chat", "showChatNoticeAria": "Toon de herinneringsbanner voor het propagatieknooppunt in Chat", "showChatNoticeHint": "Schakel dit uit om de chatbanner te verbergen die verschijnt als er geen propagatieknooppunt beschikbaar is.", - "syncPathUnknown": "Nog geen netwerkpad naar dit propagatieknooppunt. Controleer uw interfaces of hub, kondig aan en probeer het opnieuw." + "syncPathUnknown": "Nog geen netwerkpad naar dit propagatieknooppunt. Controleer uw interfaces of hub, kondig aan en probeer het opnieuw.", + "syncStackNotLive": "Uitgestelde propagatiesynchronisatie — de Reticulum-stack begint nog steeds." }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4-detectie", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index c62113169..e9bb6245f 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -4294,7 +4294,8 @@ "showChatNotice": "Pokaż przypomnienie o propagacji na czacie", "showChatNoticeAria": "Pokaż baner przypominający o węźle propagacji na czacie", "showChatNoticeHint": "Wyłącz tę opcję, aby ukryć baner czatu, który pojawia się, gdy żaden węzeł propagacji nie jest dostępny.", - "syncPathUnknown": "Nie ma jeszcze ścieżki sieciowej do tego węzła propagacji. Sprawdź interfejsy lub koncentrator, ogłoś, a następnie spróbuj ponownie." + "syncPathUnknown": "Nie ma jeszcze ścieżki sieciowej do tego węzła propagacji. Sprawdź interfejsy lub koncentrator, ogłoś, a następnie spróbuj ponownie.", + "syncStackNotLive": "Odroczona synchronizacja propagacji — stos Reticulum nadal się rozpoczyna." }, "reticulumRmapDiscovery": { "sectionTitle": "Wykrywanie RMAP v4", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 9a26550b7..7f27f7d16 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -4290,7 +4290,8 @@ "showChatNotice": "Mostrar lembrete de propagação no chat", "showChatNoticeAria": "Mostrar o banner de lembrete do nó de propagação no Chat", "showChatNoticeHint": "Desative isso para ocultar o banner de bate-papo que aparece quando nenhum nó de propagação está disponível.", - "syncPathUnknown": "Nenhum caminho de rede para este nó de propagação ainda. Verifique suas interfaces ou hub, anuncie e tente novamente." + "syncPathUnknown": "Nenhum caminho de rede para este nó de propagação ainda. Verifique suas interfaces ou hub, anuncie e tente novamente.", + "syncStackNotLive": "Sincronização de propagação adiada — a pilha Reticulum ainda está começando." }, "reticulumRmapDiscovery": { "sectionTitle": "Descoberta do RMAP v4", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index dbd3763f1..f7adafb88 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -4292,7 +4292,8 @@ "showChatNotice": "Показывать напоминание о распространении в чате", "showChatNoticeAria": "Показывать баннер с напоминанием об узле распространения в чате", "showChatNoticeHint": "Отключите этот параметр, чтобы скрыть баннер чата, который появляется, когда узел распространения недоступен.", - "syncPathUnknown": "Нет сетевого пути к этому узлу распространения. Проверьте интерфейсы или концентратор, объявите и повторите попытку." + "syncPathUnknown": "Нет сетевого пути к этому узлу распространения. Проверьте интерфейсы или концентратор, объявите и повторите попытку.", + "syncStackNotLive": "Синхронизация распространения отложена — стек Reticulum все еще запускается." }, "reticulumRmapDiscovery": { "sectionTitle": "Обнаружение RMAP v4", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 167023b1a..fd29bc586 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -4290,7 +4290,8 @@ "showChatNotice": "Chat'te yayılma hatırlatıcısını göster", "showChatNoticeAria": "Yayılma düğümü hatırlatma banner'ını Sohbet'te göster", "showChatNoticeHint": "Yayılma düğümü bulunmadığında görünen Sohbet başlığını gizlemek için bunu kapatın.", - "syncPathUnknown": "Bu yayılım düğümüne henüz bir ağ yolu yok. Arayüzlerinizi veya hub'ınızı kontrol edin, anons yapın ve tekrar deneyin." + "syncPathUnknown": "Bu yayılım düğümüne henüz bir ağ yolu yok. Arayüzlerinizi veya hub'ınızı kontrol edin, anons yapın ve tekrar deneyin.", + "syncStackNotLive": "Yayılma senkronizasyonu ertelendi — Reticulum yığını hala başlıyor." }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4 keşfi", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index f5e19da53..eb2f90cdd 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -4292,7 +4292,8 @@ "showChatNotice": "Показати нагадування про поширення в чаті", "showChatNoticeAria": "Показати банер нагадування про вузол поширення в чаті", "showChatNoticeHint": "Вимкніть, щоб приховати банер чату, який з'являється, коли вузол поширення недоступний.", - "syncPathUnknown": "Ще немає мережевого шляху до цього вузла поширення. Перевірте інтерфейси або концентратор, оголосіть, а потім повторіть спробу." + "syncPathUnknown": "Ще немає мережевого шляху до цього вузла поширення. Перевірте інтерфейси або концентратор, оголосіть, а потім повторіть спробу.", + "syncStackNotLive": "Синхронізація поширення відкладена — стек Reticulum все ще запускається." }, "reticulumRmapDiscovery": { "sectionTitle": "Виявлення RMAP v4", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index d6d934b2c..5ffbbf311 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -4290,7 +4290,8 @@ "showChatNotice": "在聊天中显示传播提醒", "showChatNoticeAria": "在聊天中显示传播节点提醒横幅", "showChatNoticeHint": "关闭此选项可隐藏在没有可用传播节点时显示的聊天横幅。", - "syncPathUnknown": "还没有到此传播节点的网络路径。请检查您的接口或集线器,宣布,然后重试。" + "syncPathUnknown": "还没有到此传播节点的网络路径。请检查您的接口或集线器,宣布,然后重试。", + "syncStackNotLive": "传播同步延迟—Reticulum 堆栈仍在启动。" }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4发现", diff --git a/src/renderer/stores/reticulumPropagationStore.ts b/src/renderer/stores/reticulumPropagationStore.ts index d60d03e2e..33087d516 100644 --- a/src/renderer/stores/reticulumPropagationStore.ts +++ b/src/renderer/stores/reticulumPropagationStore.ts @@ -399,7 +399,11 @@ export const useReticulumPropagationStore = create Date: Sun, 9 Aug 2026 15:37:17 -0600 Subject: [PATCH 2/4] feat: auto-deliver Host PN fabric mail into Chat Close the lxmd-style gap: drain local inbox after peer Resource accept, and after host peer /offer Completes run a sequenced silent client /get so Chat updates without a manual local-prop Sync. --- docs/agents/reticulum.md | 5 +- docs/troubleshooting.md | 12 +- reticulum-sidecar/src/stack/live.rs | 517 +++++++++++------- reticulum-sidecar/src/stack/lxmf_outbound.rs | 91 +++ .../src/stack/propagation_bridge.rs | 347 +++++++++++- .../src/stack/propagation_serve.rs | 17 + 6 files changed, 795 insertions(+), 194 deletions(-) diff --git a/docs/agents/reticulum.md b/docs/agents/reticulum.md index a8791ae11..4a8535329 100644 --- a/docs/agents/reticulum.md +++ b/docs/agents/reticulum.md @@ -8,7 +8,8 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Re - **Deep links / QR:** OS scheme is **`lxm://`** (not `mesh-client://`); `MeshClientDeepLinkHost`, `meshClientDeepLink.ts` (`lxmPaperMessage` kind + `looksLikeLxmPaperBlob`; Games `lxm://game/` / Ratspeak `lrgp:` → `lxmGameSession`), `handleReticulumQrIngest.ts` (shared Network/Chat/OS paper + in-app contact ingest), `applyLxmPaperIngest` → `POST /api/v1/lxmf/paper/ingest`, `QrIngestControl` / `QrCodeImage`. OS contact / MeshCore imports confirm before upsert; **paper OS deep links ingest without confirm**; Games session links open Reticulum Games tab via `openReticulumGameSession`. - **Decommissioned hubs:** `src/shared/reticulumDecommissionedHubs.ts` (Amsterdam only) — stack-start auto-disable + **Add default backbones** disables matching enabled TCP rows; UI badge + enable-block in `ReticulumInterfacesPanel.tsx` (`isDecommissionedReticulumTcpInterfaceRow`); keep TS↔Rust synced via `pnpm run check:reticulum-decommissioned-hubs`. Default backbone picker + region-grouped interface list (Primary & Global / North America / Europe / Asia & Oceania / Specialty / User Defined) in `reticulumDefaultHubPresets.ts` + `ReticulumDefaultHubsPickerModal.tsx`; muted disabled rows + checkbox bulk delete; `countEnabledDefaultHubPresets` / >3 enable warning - **BLE RNode RSSI:** `useReticulumBleRnodeRssiMap` gates on sidecar **running** (not api-ready), burst-then-steady scans via nested `acquireReticulumBleScan`, clears sticky targets immediately when all BLE RNodes are disabled -- **Propagation mode / sync:** Network → Propagation nodes owns Off/Auto/Manual (default **Off**; persisted values including legacy App-panel `auto` are honored). Auto one-time syncs via `startPropagationSyncCascade` + sidecar `destination_hash` sync in order: **finite-hop discovered** (no Add/Preferred) → **configured remotes** → **unknown-hop discovered** → local-prop (skips remotes when no enabled interfaces); runtime hook `useReticulumPropagationAutoSync`. Sidecar `start_propagation_sync` is **client `/get`-primary** (inbox retrieval; UI progress from `PropagationClient`) — peer `/offer` inventory push stays on the local-host peer loop when serving (avoids AwaitingResponse hangs against non-peer remotes with a nonempty messagestore). Hard-fails with `PROPAGATION_PATH_UNKNOWN` when `ensure_path_for_direct` fails after announce settle (same path gate as offer probe). Manual uses Preferred, else picks the best configured remote **for that sync only** (no Preferred write), then the remaining remotes, then local-prop. Off = **no PN support**: `startPropagationSyncCascade` returns early (per-row Sync is disabled in UI), `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` are false, `ReticulumPropagationNotice` is hidden, and the sidecar disarms the outbound PN plus empties cascade candidates (`propagation_mode` in `mesh_client_stack.json`, `POST /api/v1/propagation/mode`, `candidates_for_propagation_mode`); renderer pushes the mode on change and on sidecar-ready. `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Complete on HaveAll, Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation. **Nothing-to-sync is not a failure:** when the cascade contacts no node it writes `syncNoTarget` / `syncLocalLoading` (never overwriting a real error from an attempted node), the local row reports sidecar `status: "loading"` while the messagestore reads (`local_propagation_status` + `PropagationBridge::messagestore_load_pending`, per-row Sync disabled), and the 30 s tick calls `refreshFromSidecar` while `hasPropagationCascadeCandidate` is false so a fresh stack recovers on its own — `refreshFromSidecar` must **not** clear the active attempt while `sync.active`. Debug snapshot `propagationClient` exposes mode/preferred/autoTarget/resolvedSyncTargetId. **Auto also deposits on Discovered PNs:** sidecar `auto_discovered_candidates` (`pn_cascade.rs`, Auto only, cap 3, hop-sorted, skips inactive / self / already-configured / over `max_peering_cost`) appends after configured remotes and before local-prop, rebuilt from the shared `rebuild_pn_cascade_candidates` helper in `live.rs` (called by `refresh_pn_cascade_candidates` **and** the PN announce handler); `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` therefore count discovered rows in Auto, so the Chat notice hides and the link-timeout failure bridge holds off. **Chat notice dismiss:** `chatNoticeDismissed` (`mesh-client:reticulumPropagationNoticeDismissed`) with **Don't show again** on the banner and **Show propagation reminder in Chat** in the Network section. **Named sync target:** `startSync` stamps `syncTargetId`; progress line, inline error, and Sync toasts resolve it with `resolveReticulumPropagationTargetLabel`; the cascade clears it when nothing was contacted so `syncNoTarget` / `syncLocalLoading` stay unprefixed. **Attempts settle before the cascade advances:** `startSync` returns `accepted` | `deferred` | `failed` (not a boolean) — only sidecar _acceptance_ starts `awaitPropagationSyncSettled` (terminal WS frame or stall/ceiling watchdog). `failed` advances with ~15 min session-memory omit via `reticulumPropagationSyncBackoff.ts`; `deferred` (`PROPAGATION_SYNC_OUTBOUND_BUSY` — outbound deposit owns the PN link) advances **without** backoff so the next tick may retry; `cancelled` (user Cancel) stops; `success` ends the run. Remote steps are capped by `PROPAGATION_CASCADE_BUDGET_MS` (5 min) then fall through to local-prop; each remote attempt is capped by `PROPAGATION_CASCADE_ATTEMPT_TIMEOUT_MS` (~60s); local fallback refreshes nodes when local looks disabled; the cascade is single-flight (`resetPropagationSyncCascadeState` is the test seam) so overlapping 30 s ticks join one run while an explicit per-row Sync supersedes it. Auto `/api/v1/interfaces` probe **fails open** (assumes interfaces enabled) so a broken proxy still tries remotes before local. **Retrieval vs peer sync:** the WS progress bar / HaveAll reflects the `/offer` peer sync (`PropagationSyncTask`, inventory replication only). The half that actually pulls your mail into Chat is the **client `/get` download** (`PropagationClient`), wired in `propagation_download.rs` + `propagation_bridge.rs::poll_client_download` and driven by `live.rs::spawn_client_download_driver` (list → get → purge; decrypt with local identity → router delivery callback). `local-prop` Sync uses `PropagationBridge::drain_local_inbox` (in-process node `/get` replay). Logs: real inbox retrieval is `propagation-retrieve` (`retrieve_mode=get|local`, `listed`/`downloaded`/`delivered`); the peer-offer outcome is `propagation-sync … peer_outcome=have_all|transfer` (**not** retrieval). +- **Propagation mode / sync:** Network → Propagation nodes owns Off/Auto/Manual (default **Off**; persisted values including legacy App-panel `auto` are honored). Auto one-time syncs via `startPropagationSyncCascade` + sidecar `destination_hash` sync in order: **finite-hop discovered** (no Add/Preferred) → **configured remotes** → **unknown-hop discovered** → local-prop (skips remotes when no enabled interfaces); runtime hook `useReticulumPropagationAutoSync`. Sidecar `start_propagation_sync` is **client `/get`-primary** (inbox retrieval; UI progress from `PropagationClient`) — peer `/offer` inventory push stays on the local-host peer loop when serving (avoids AwaitingResponse hangs against non-peer remotes with a nonempty messagestore). Hard-fails with `PROPAGATION_PATH_UNKNOWN` when `ensure_path_for_direct` fails after announce settle (same path gate as offer probe). Manual uses Preferred, else picks the best configured remote **for that sync only** (no Preferred write), then the remaining remotes, then local-prop. Off = **no PN support**: `startPropagationSyncCascade` returns early (per-row Sync is disabled in UI), `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` are false, `ReticulumPropagationNotice` is hidden, and the sidecar disarms the outbound PN plus empties cascade candidates (`propagation_mode` in `mesh_client_stack.json`, `POST /api/v1/propagation/mode`, `candidates_for_propagation_mode`); renderer pushes the mode on change and on sidecar-ready. `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Complete on HaveAll, Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation. **Nothing-to-sync is not a failure:** when the cascade contacts no node it writes `syncNoTarget` / `syncLocalLoading` (never overwriting a real error from an attempted node), the local row reports sidecar `status: "loading"` while the messagestore reads (`local_propagation_status` + `PropagationBridge::messagestore_load_pending`, per-row Sync disabled), and the 30 s tick calls `refreshFromSidecar` while `hasPropagationCascadeCandidate` is false so a fresh stack recovers on its own — `refreshFromSidecar` must **not** clear the active attempt while `sync.active`. Debug snapshot `propagationClient` exposes mode/preferred/autoTarget/resolvedSyncTargetId. **Auto also deposits on Discovered PNs:** sidecar `auto_discovered_candidates` (`pn_cascade.rs`, Auto only, cap 3, hop-sorted, skips inactive / self / already-configured / over `max_peering_cost`) appends after configured remotes and before local-prop, rebuilt from the shared `rebuild_pn_cascade_candidates` helper in `live.rs` (called by `refresh_pn_cascade_candidates` **and** the PN announce handler); `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` therefore count discovered rows in Auto, so the Chat notice hides and the link-timeout failure bridge holds off. **Chat notice dismiss:** `chatNoticeDismissed` (`mesh-client:reticulumPropagationNoticeDismissed`) with **Don't show again** on the banner and **Show propagation reminder in Chat** in the Network section. **Named sync target:** `startSync` stamps `syncTargetId`; progress line, inline error, and Sync toasts resolve it with `resolveReticulumPropagationTargetLabel`; the cascade clears it when nothing was contacted so `syncNoTarget` / `syncLocalLoading` stay unprefixed. **Attempts settle before the cascade advances:** `startSync` returns `accepted` | `deferred` | `failed` (not a boolean) — only sidecar _acceptance_ starts `awaitPropagationSyncSettled` (terminal WS frame or stall/ceiling watchdog). `failed` advances with ~15 min session-memory omit via `reticulumPropagationSyncBackoff.ts`; `deferred` (`PROPAGATION_SYNC_OUTBOUND_BUSY` — outbound deposit owns the PN link) advances **without** backoff so the next tick may retry; `cancelled` (user Cancel) stops; `success` ends the run. Remote steps are capped by `PROPAGATION_CASCADE_BUDGET_MS` (5 min) then fall through to local-prop; each remote attempt is capped by `PROPAGATION_CASCADE_ATTEMPT_TIMEOUT_MS` (~60s); local fallback refreshes nodes when local looks disabled; the cascade is single-flight (`resetPropagationSyncCascadeState` is the test seam) so overlapping 30 s ticks join one run while an explicit per-row Sync supersedes it. Auto `/api/v1/interfaces` probe **fails open** (assumes interfaces enabled) so a broken proxy still tries remotes before local. **Retrieval vs peer sync:** User Sync progress is **client `/get`-primary** (`PropagationClient` → `spawn_client_download_driver` with UI emits). Peer `/offer` inventory push runs only on the **local Host peer loop** when serving (`drive_local_host_peer_sync`). Logs: inbox retrieval is `propagation-retrieve` (`retrieve_mode=get|get_post_peer|local`); peer-offer outcome is `propagation-sync … peer_outcome=have_all|transfer` (**not** retrieval). `local-prop` Sync still uses `drain_local_inbox` (in-process). +- **Host PN fabric → Chat (lxmd-style glue):** When local Host is enabled, mesh-client is both PN and end-client on rsLXMF (not a second lxmd). Path: outbound deposit → host peer `/offer` push; inbound peer Resource accept → `request_inbox_drain` → maintenance `drain_local_inbox` → `delivery_callback` → Chat (no manual local-prop Sync); after host peer `/offer` Completes for peer `P`, sequenced **silent** client `/get` against `P` (`emit_ui: false`, `retrieve_mode=get_post_peer`) pulls mail waiting on that remote for our `lxmf.delivery`. Guards: coalesce drain; one internal `/get`; skip when user Sync target / outbound deposit owns the hash or `sync_active` / `client_download_active`. Do **not** re-attach peer `/offer` to the Sync button. True dual full-index exchange in one Link stays upstream rsLXMF. - **PN hosting:** Network **Advanced PN hosting** / `ReticulumPnHostingDangerZone`; shared `pnHostingPolicy.ts` + sidecar `pn_hosting_policy.rs` / `pn_hosting_apply.rs`; `POST /api/v1/propagation/hosting-policy`; rsLXMF policy-setters overlay ([ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6)). Messagestore loads in background on live attach; enabled `local-prop` serve/announce waits until load completes. - **Interface modes:** rnsd `mode` via `reticulumInterfaceMode.ts` + sidecar `normalize_interface_mode` (keep catalogs in sync — `pnpm run check:reticulum-interface-modes` in pre-commit/`release.sh`); add defaults TCP/UDP/I2P → `boundary`, RNode → `access_point`; UI in `ReticulumInterfacesPanel`; default hub presets add/repair missing mode to `boundary` (do not overwrite valid non-boundary). See [../reticulum.md#interface-modes](../reticulum.md#interface-modes). - **Share instance defaults:** missing keys bootstrap to `share_instance = No` / `instance_name = mesh-client` (does not overwrite explicit Yes/`default`); SharedInstanceClient banner + `disable_share_instance` repair; offline lint via `reticulum:validateConfig` / Network **Check config** / `pnpm run reticulum:config:check` @@ -16,7 +17,7 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Re - **RNode flasher timeouts:** `RNODE_COMMAND_TIMEOUT_MS` (30 s serial), `RNODE_BT_PAIRING_TIMEOUT_MS` (90 s BLE pairing), `ESP32_FLASH_STALL_TIMEOUT_MS` / `NRF52_DFU_STALL_TIMEOUT_MS` (60 s no-progress → `ESP32_FLASH_STALLED` / `NRF52_DFU_STALLED`); humanized via `flasherErrorHumanize.ts` - **Peer aliases / History vs Contacts:** LXMF/Nomad announce names overlay path-table peers; SQLite `reticulum_destinations.last_heard` = History, `is_contact` = Contacts (Save as contact only — inbound/outbound LXMF does **not** auto-add Contacts; sidecar `/contacts` wire rows are History hints unless SQLite `is_contact=1`); default avatars via vendored LXMFace (`lib/reticulum/lxmface.ts`); renderer refresh + `reticulumContactToNodeRecordPreservingLabel` refuse hash-prefix wipes of Chat/`nodeStore` labels; ingest stamps History via `persistReticulumHistoryFromPayload` + `stampHistoryPeer`; SQL upsert guard preserves real names over hash-prefix aliases; destination upsert requires exact 32-hex (lowercase) and omits `favorited` on icon-only patches so favorites/icons survive path/probe refresh - **Stores/lib:** `reticulumIdentityStore.ts` (session-global sidecar identity status shared by `useReticulumSidecarApi` — distinct from identity-scoped `identityStore`), `reticulumPeerStore.ts` (path-table `peers` + `history` + saved `contacts`; soft-TTL reads, forced `?refresh=1`, incremental `peers_updated` route-field patches, 50ms batching, name/appearance preservation, 30s/60s large-mesh poll), `reticulumDiscoveryMapStore.ts`, `reticulumRmapDiscovery.ts`, `reticulumDiscoveryMapLayout.ts`, `nomadNetworkStore.ts`, `rrcHubStore.ts` / `rrcSessionStore.ts` (RRC hubs + multi-hub sessions; hydrate/clear room history via `rrcRoomHistory.ts`; persist → SQLite `rrc_messages` via `rrcMessagePersist.ts` + `ipc/rrc-db-handlers.ts`; prefs in `rrcHubPrefs` / `rrcRoomPrefs` / `rrcRecentRooms`; notifications in `rrcInactiveNotifications` / `rrcMention`); **Remote (rnsh/rncp):** `rncpTransferStore.ts`, `rnshSessionStore.ts`, `reticulumInboundPolicyStore.ts`, `reticulumRemoteAddressStore.ts`, `rncpEnableRequestStore.ts` + lib `remoteSettingsStorage.ts`, `pushRncpListenerPolicy.ts`, `rncpInboundPolicyLists.ts`, `sendRncpRequestEnable.ts`, `rncpRequestEnableRateLimit.ts`, `applyRncpReceiveDestShare.ts` / `rncpReceiveDestSharePending.ts` (mark pending on request-enable; consume on ingest within TTL), `hooks/useRemotePathCapability.ts`, `components/remote/*`; WS events `rmap.discovery`, `lxmf_outbound_status`, `nomadnetwork.node`, `rrc.*`, `rnsh.*` / `rncp.*` in `useReticulumRuntime` (sidecar also emits `nomad.serving_start` / `nomad.serving_stop`; renderer polls serving status via HTTP, not those WS events) -- **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` / `pn_cascade.rs` (Direct-first; after Direct exhausts **multi-PN cascade**: preferred remote → other enabled remotes hop-sorted → in **Auto** only, up to 3 heard-but-not-added Discovered PNs hop-sorted → local-prop last; intermediate WS `sending` + `delivery_method: "propagated"` or `"stored_locally"`; terminal `delivered` at remote PN vs `stored_locally` for local hosted PN). **Local-prop** is a full PN (in-process cascade deposit via `accept_stamped_propagated_blob`; host peer `/offer` sync; local Sync/retrieve via in-process `PropagationBridge::drain_local_inbox` — not remote `PropagationClient` `/get`) — not an outbox; clients need not Prefer you. Propagated **link establishment timeout** advances the cascade when other PNs remain (avoids Prefer-hash timeout storms). Sync vs deposit: `PROPAGATION_SYNC_OUTBOUND_BUSY` / `PN_DEPOSIT_DEFER_ADVANCE_AFTER`. Renderer `applyReticulumOutboundDeliveryStatus.ts` (WS `lxmf_outbound_status` → Zustand + SQLite `delivery_status` + `delivery_method`; early-status buffer; hash/status allowlist), `reticulumOutboundFailureBridge.ts` (`shouldApplyLinkDeliveryTimeoutFailureBridge` skips the link-timeout Failed bridge when cascade capacity remains — remote **or** enabled local-prop; also skips `propagated` / `stored_locally` rows so cascade is not killed), `markStaleReticulumOutbound.ts`. Optimistic pending rows use `reticulum-pending-*`; send-path rekey passes `replaces_message_hash` on SQLite upsert to delete the prior pending hash. Remote PN Completes UI: **Stored at propagation node** (`ReticulumMessageStatusBadge` PN + green check); local-prop Completes: deposited on your hosted node (PN + amber house; peer sync may still propagate). Mode Off has no cascade capacity, so the link-timeout bridge fails the row. **Paper exception:** `createReticulumPaperMessage` / paper create Completes immediately (`delivery_method: paper`, `ReticulumMessageStatusBadge` **Paper**) via `lxmf_message` — no `lxmf_outbound_status`; shared `reticulumMessageTransport` / `reticulumPaperErrors` keep IPC allowlists and i18n codes aligned. +- **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` / `pn_cascade.rs` (Direct-first; after Direct exhausts **multi-PN cascade**: preferred remote → other enabled remotes hop-sorted → in **Auto** only, up to 3 heard-but-not-added Discovered PNs hop-sorted → local-prop last; intermediate WS `sending` + `delivery_method: "propagated"` or `"stored_locally"`; terminal `delivered` at remote PN vs `stored_locally` for local hosted PN). **Local-prop** is a full PN (in-process cascade deposit via `accept_stamped_propagated_blob`; host peer `/offer` sync; auto Chat drain after peer ingress + post-peer silent `/get`; explicit local Sync via `drain_local_inbox`) — not an outbox; clients need not Prefer you. Propagated **link establishment timeout** advances the cascade when other PNs remain (avoids Prefer-hash timeout storms). Sync vs deposit: `PROPAGATION_SYNC_OUTBOUND_BUSY` / `PN_DEPOSIT_DEFER_ADVANCE_AFTER`. Renderer `applyReticulumOutboundDeliveryStatus.ts` (WS `lxmf_outbound_status` → Zustand + SQLite `delivery_status` + `delivery_method`; early-status buffer; hash/status allowlist), `reticulumOutboundFailureBridge.ts` (`shouldApplyLinkDeliveryTimeoutFailureBridge` skips the link-timeout Failed bridge when cascade capacity remains — remote **or** enabled local-prop; also skips `propagated` / `stored_locally` rows so cascade is not killed), `markStaleReticulumOutbound.ts`. Optimistic pending rows use `reticulum-pending-*`; send-path rekey passes `replaces_message_hash` on SQLite upsert to delete the prior pending hash. Remote PN Completes UI: **Stored at propagation node** (`ReticulumMessageStatusBadge` PN + green check); local-prop Completes: deposited on your hosted node (PN + amber house; peer sync may still propagate). Mode Off has no cascade capacity, so the link-timeout bridge fails the row. **Paper exception:** `createReticulumPaperMessage` / paper create Completes immediately (`delivery_method: paper`, `ReticulumMessageStatusBadge` **Paper**) via `lxmf_message` — no `lxmf_outbound_status`; shared `reticulumMessageTransport` / `reticulumPaperErrors` keep IPC allowlists and i18n codes aligned. - **DM path reachability:** `useReticulumDmPathProbe.ts`, `reticulumDmPathReachability.ts`, `ReticulumDmPathReachabilityBadge.tsx` — Chat **Probe** matches Peer List (sidecar running check → `/probe` → toast → refresh); `applyProbeResult(forHash, …)` applies the settle without a second `/probe` and ignores stale completions after DM switch; manual reprobe forces Checking… even when passive hops look reachable; Peers virtualizes above 100 rows via `reticulumPeerListRows.ts`; peer refresh policy in `reticulumSidecarPeerRefreshEvents.ts` - **Inbound transport labels:** `received_via` resolves the path-table interface name against local interface config type, so a TCP hub display name still renders as TCP. - **Topology:** `via_hash` is an immediate transport id; sidecar synthesizes missing relay nodes. `ReticulumTopologyPanel` uses force layout; sidecar caps graph input at 2,000 peers and renderer caps visible peers at 800 (grid repulsion above 400). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 725b94d21..87f9b5c60 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1327,9 +1327,9 @@ Bond-stale **TX queue full** hints (`txQueueDropsHintBleBondStale`) point at the **Symptoms**: Local Host propagation node is enabled but peers never hear your PN announce / cannot `/offer` or `/get`. -**Cause**: Hosting requires a live stack with identity signing key; enable starts `lxmf.propagation` LinkManager + announce loop (Resource deposit ingress + stamp validation into the local store, `/offer` admission, and outbound peer inventory sync when idle). +**Cause**: Hosting requires a live stack with identity signing key; enable starts `lxmf.propagation` LinkManager + announce loop (Resource deposit ingress + stamp validation into the local store, `/offer` admission, outbound peer inventory sync when idle, auto inbox drain into Chat, and sequenced post-peer `/get` after host `/offer` Completes). -**Fix**: Confirm sidecar is running, identity is configured, **Network → Propagation → Host propagation node** is Enabled, and check logs for `[propagation-serve]` / `[propagation-announce]` / `[propagation-deposit]`. Tune announce interval under **Advanced PN hosting**. Peers depositing to your host should see your `lxmf.propagation` hash; bad stamps are rejected and logged under `propagation-deposit`. +**Fix**: Confirm sidecar is running, identity is configured, **Network → Propagation → Host propagation node** is Enabled, and check logs for `[propagation-serve]` / `[propagation-announce]` / `[propagation-deposit]` / auto-drain / `get_post_peer`. Tune announce interval under **Advanced PN hosting**. Peers depositing to your host should see your `lxmf.propagation` hash; bad stamps are rejected and logged under `propagation-deposit`. ### Reticulum: Stored at PN but Sync leaves Chat empty @@ -1339,14 +1339,16 @@ Bond-stale **TX queue full** hints (`txQueueDropsHintBleBondStale`) point at the **Progress bar = client `/get` retrieval.** User Sync against a remote PN drives the progress bar from the **client `/get` download** (inbox mail into Chat). Peer `/offer` inventory replication runs on the **local Host peer loop** when you are serving a PN — not on the Sync button — so a nonempty messagestore cannot hang Sync at AwaitingResponse against remotes that are not your peers. Look for `propagation-retrieve` `/get` Completes in Device logs for retrieve counts. +**Host PN auto Chat path:** With **Host propagation node** enabled, mail that lands in the local store via peer Resource ingress should appear in Chat via **auto-drain** (`local-prop inbox auto-drain Completes`, `retrieve_mode=local`) without pressing Sync on local-prop. After your host peer `/offer` Completes to a peered remote, a **silent** client `/get` to that peer may also run (`retrieve_mode=get_post_peer`, no Sync UI bar). Explicit Sync remains `/get`-primary (Prefer / cascade). + **Do not** tell users they must share the same preferred PN. Prefer log correlation instead: 1. Sender Device log: `propagation-deposit` with `message_hash`, `transient_id`, `pn_hash` (deposit Completes). -2. Recipient log (the real retrieval): `propagation-retrieve … retrieve_mode=get pn_hash=… listed=N downloaded=N delivered=N` (the client `/get` download; `listed=0` is a valid empty-inbox success). Per-message `propagation-retrieve` with matching `message_hash` / `transient_id` fires as each downloaded message hits the delivery callback. `local-prop` Sync logs `retrieve_mode=local`. The peer-offer side logs `propagation-sync … peer_outcome=have_all|transfer` — that is **not** retrieval. +2. Recipient log (the real retrieval): `propagation-retrieve … retrieve_mode=get|get_post_peer pn_hash=… listed=N downloaded=N delivered=N` (the client `/get` download; `listed=0` is a valid empty-inbox success). Per-message `propagation-retrieve` with matching `message_hash` / `transient_id` fires as each downloaded message hits the delivery callback. Host auto-drain / `local-prop` Sync logs `retrieve_mode=local`. The peer-offer side logs `propagation-sync … peer_outcome=have_all|transfer` — that is **not** retrieval. 3. Renderer: `[catchUpRecentInboundLxmf] … reason=propagation_sync` or `propagation-retrieve catch-up after sync Completes count=N` (`count=0 (empty ring)` means Sync Completes with no new inbound for Chat). -4. Confirm remote Sync Completes and that Host PN (if used) shows `[propagation-deposit] local PN accepted stamped propagated blob`. +4. Confirm remote Sync Completes and that Host PN (if used) shows `[propagation-deposit] local PN accepted stamped propagated blob` plus auto-drain / post-peer retrieve lines when expecting Chat without a manual Sync. -**Fix**: Retry Sync after path/announce settle; if using local Host, confirm ingress logs and peer sync ticks (`local host queued outbound peer inventory sync`). Export developer bundles from both sides and `rg 'propagation-deposit|propagation-retrieve'`. +**Fix**: Retry Sync after path/announce settle; if using local Host, confirm ingress + auto-drain / post-peer `/get` logs and peer sync ticks (`local host queued outbound peer inventory sync`). Export developer bundles from both sides and `rg 'propagation-deposit|propagation-retrieve'`. ### Reticulum PN hosting policy apply fails diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 143078701..a4efe00e4 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -2625,29 +2625,117 @@ impl LiveBridge { ); None }; - let mut router = router.lock().await; - if let Ok(mut driver) = outbound.lock() { - if let Some(ref entries) = path_entries { - driver.update_path_table(entries); - } - driver.process_tick(&mut router, &event_tx); - let known_identities = driver.known_identities_for_propagation(); - let terminal = propagation.tick(&known_identities); - if terminal.is_some() { - driver.set_propagation_sync_target(None); + let need_inbox_drain = propagation.take_inbox_drain_request(); + let mut start_silent_get: Option<[u8; 16]> = None; + { + let mut router = router.lock().await; + if let Ok(mut driver) = outbound.lock() { + if let Some(ref entries) = path_entries { + driver.update_path_table(entries); + } + driver.process_tick(&mut router, &event_tx); + let known_identities = driver.known_identities_for_propagation(); + let terminal = propagation.tick(&known_identities); + if let Some((ok, peer_hash)) = terminal { + driver.set_propagation_sync_target(None); + // After host peer `/offer` Completes, pull our inbox from that + // peer via silent client `/get` (sequenced; no dual Link race). + if ok + && propagation.is_local_serving() + && !driver.has_inflight_delivery_to(&peer_hash) + { + propagation.queue_post_peer_get(peer_hash); + } + } + if let Some(peer_hash) = propagation.take_pending_post_peer_get() { + if propagation.is_local_serving() + && !propagation.sync_active() + && !driver.has_inflight_delivery_to(&peer_hash) + && driver.propagation_sync_target().is_none() + { + driver.set_propagation_sync_target(Some(peer_hash)); + start_silent_get = Some(peer_hash); + } else { + propagation.queue_post_peer_get(peer_hash); + } + } + // Local Host: push inventory to peered PNs when the sync task is idle + // and no user Sync / deposit / silent /get owns the PN Link. + if propagation.is_local_serving() + && !propagation.sync_active() + && !propagation.client_download_active() + && driver.propagation_sync_target().is_none() + { + drive_local_host_peer_sync( + &propagation, + &mut router, + &mut driver, + local_identity_hash, + ); + } } - // Local Host: push inventory to peered PNs when the sync task is idle - // and no user Sync / deposit owns the PN Link (lxmd drive_pending parity). - if propagation.is_local_serving() - && !propagation.sync_active() - && driver.propagation_sync_target().is_none() - { - drive_local_host_peer_sync( - &propagation, - &mut router, - &mut driver, - local_identity_hash, + } + // Peer Resource accept → drain our lxmf.delivery mail into Chat. + if need_inbox_drain && propagation.is_local_serving() { + let bridge = Arc::clone(&propagation); + let router_for_drain = Arc::clone(&router); + tokio::spawn(async move { + let (messages, listed) = + tokio::task::spawn_blocking(move || bridge.drain_local_inbox()) + .await + .unwrap_or_else(|_| (Vec::new(), 0)); + let delivered = messages.len(); + if delivered > 0 { + let router = router_for_drain.lock().await; + if let Some(ref cb) = router.delivery_callback { + for msg in &messages { + cb(msg); + } + } + } + tracing::info!( + target: "propagation-retrieve", + listed, + delivered, + retrieve_mode = "local", + "local-prop inbox auto-drain Completes" ); + }); + } + if let Some(peer_hash) = start_silent_get { + let pn_hex = hex::encode(peer_hash); + let outbound_for_clear = Arc::clone(&outbound); + let on_terminal: Arc = Arc::new(move || { + if let Ok(mut driver) = outbound_for_clear.lock() { + driver.clear_propagation_identity_pins(); + driver.set_propagation_sync_target(None); + } + }); + // Dedicated cancel; user Sync cancel_propagation_sync still + // cancel_client_download()s so this poll loop exits on Idle/Failed. + let cancel = Arc::new(AtomicBool::new(false)); + let started = spawn_client_download_driver_task( + Arc::clone(&propagation), + Arc::clone(&router), + Arc::clone(&outbound), + Arc::new(AtomicU64::new(0)), + peer_hash, + pn_hex.clone(), + cancel, + 0, + event_tx.clone(), + Some(on_terminal), + false, + ); + if started { + tracing::info!( + target: "propagation-retrieve", + pn_hash = %pn_hex, + "post-peer silent client /get queued" + ); + } else if let Ok(mut driver) = outbound.lock() { + driver.set_propagation_sync_target(None); + propagation.queue_post_peer_get(peer_hash); } } // Skip tick when outbound is locked — never drain LRPROOF against an empty map. @@ -3368,12 +3456,17 @@ impl LiveBridge { .ok() .map(|p| p.clone()) .unwrap_or_default(); + let drain_bridge = Arc::clone(&self.propagation); + let on_inbound_accepted: Arc = Arc::new(move || { + drain_bridge.request_inbox_drain(); + }); if let Err(e) = self.prop_serve.start( &self.handle.transport_tx, &self.identity, self.propagation.local_dest_hash_bytes(), &self.propagation.local_node(), &policy, + Some(on_inbound_accepted), ) { tracing::error!(target: "propagation-serve", "failed to start serve: {e}"); let mut router = self.router.lock().await; @@ -3705,6 +3798,7 @@ impl LiveBridge { run_id, self.event_tx.clone(), Some(on_terminal), + true, ) { if let Ok(mut driver) = self.outbound.lock() { driver.clear_propagation_identity_pins(); @@ -3715,7 +3809,11 @@ impl LiveBridge { Ok(()) } - /// Drive the client `/get` download to completion and emit Sync UI progress. + /// Drive the client `/get` download to completion. + /// + /// When `emit_ui` is true, emits Sync UI progress over WS. Host-loop retrieves + /// after peer `/offer` Completes use `emit_ui: false` so they do not stomp the + /// user's Sync bar. /// /// Ticks [`PropagationBridge::poll_client_download`] on a short interval, /// feeding it the current known-identity map (for link-proof validation) and, @@ -3724,6 +3822,7 @@ impl LiveBridge { /// `lxmf_message`, the recent ring, and renderer catch-up all fire unchanged. /// /// Returns `false` when the client refuses to start (already active). + #[allow(clippy::too_many_arguments)] // shared /get driver args mirror spawn_client_download_driver_task fn spawn_client_download_driver( &self, pn_hash: [u8; 16], @@ -3732,171 +3831,21 @@ impl LiveBridge { run_id: u64, event_tx: broadcast::Sender, on_terminal: Option>, + emit_ui: bool, ) -> bool { - if !self.propagation.start_client_download(pn_hash) { - tracing::debug!( - target: "propagation-retrieve", - pn_hash = %pn_hex, - "client /get download not started (already active or unavailable)" - ); - return false; - } - let bridge = Arc::clone(&self.propagation); - let router = Arc::clone(&self.router); - let outbound = Arc::clone(&self.outbound); - let active_run_id = Arc::clone(&self.sync_run_id); - tokio::spawn(async move { - // Client tick cadence; the client's own 120s timeout bounds a stuck link. - const POLL_INTERVAL: Duration = Duration::from_millis(500); - const DOWNLOAD_WATCHDOG: Duration = Duration::from_secs(180); - const ESTABLISH_STALL: Duration = Duration::from_secs(45); - let mut interval = tokio::time::interval(POLL_INTERVAL); - let started = Instant::now(); - let mut last_logged_progress = -1.0_f64; - let emit_progress = |active: bool, progress: f64, message: Option<&str>| { - let payload = serde_json::json!({ - "active": active, - "progress": progress, - "message": message, - }); - let frame = serde_json::json!({ - "type": "propagation_sync", - "payload": payload, - }); - let _ = event_tx.send(frame.to_string()); - }; - let clear_pins = || { - bridge.run_if_current(&active_run_id, run_id, || { - if let Some(ref cb) = on_terminal { - cb(); - } - }); - }; - // Immediate Establishing frame so the renderer stall watchdog sees progress. - bridge.run_if_current(&active_run_id, run_id, || { - emit_progress(true, 10.0, None); - }); - loop { - interval.tick().await; - // Superseded by a newer sync run: that run now owns the client, - // so exit without touching shared client state. - if !PropagationBridge::is_current_sync_run( - active_run_id.load(Ordering::SeqCst), - run_id, - ) { - break; - } - // This run was cancelled: cancel the client only while we are - // still the active run (lifecycle-lock guarded via run_if_current). - if cancel.load(Ordering::SeqCst) { - bridge.run_if_current(&active_run_id, run_id, || { - bridge.cancel_client_download(); - }); - break; - } - let progress = bridge.client_download_progress(); - if progress <= 10.0 - && bridge.client_download_active() - && started.elapsed() > ESTABLISH_STALL - { - tracing::info!( - target: "propagation-retrieve", - pn_hash = %pn_hex, - "client /get stalled while establishing" - ); - bridge.run_if_current(&active_run_id, run_id, || { - bridge.cancel_client_download(); - emit_progress( - false, - 0.0, - Some("propagation establish failed: NoLinkProof"), - ); - }); - break; - } - if started.elapsed() > DOWNLOAD_WATCHDOG { - tracing::info!( - target: "propagation-retrieve", - pn_hash = %pn_hex, - "client /get download watchdog timeout" - ); - bridge.run_if_current(&active_run_id, run_id, || { - bridge.cancel_client_download(); - emit_progress( - false, - 0.0, - Some("propagation establish failed: NoLinkProof"), - ); - }); - break; - } - if (progress - last_logged_progress).abs() >= 0.5 { - last_logged_progress = progress; - bridge.run_if_current(&active_run_id, run_id, || { - emit_progress(true, progress, None); - }); - } - let known = outbound - .lock() - .ok() - .map(|d| d.known_identities_for_propagation()) - .unwrap_or_default(); - match bridge.poll_client_download(&known) { - ClientDownloadPoll::Idle => break, - // Keep polling on the next interval tick. - ClientDownloadPoll::InProgress => {} - ClientDownloadPoll::Failed => { - tracing::info!( - target: "propagation-retrieve", - pn_hash = %pn_hex, - "client /get download failed" - ); - // Consume the terminal failed state → Idle so a later - // sync can start a fresh retrieval. - bridge.run_if_current(&active_run_id, run_id, || { - bridge.cancel_client_download(); - emit_progress( - false, - 0.0, - Some("propagation establish failed: NoLinkProof"), - ); - }); - break; - } - ClientDownloadPoll::Complete { - messages, - listed, - downloaded, - } => { - let delivered = messages.len(); - { - let router = router.lock().await; - if let Some(ref cb) = router.delivery_callback { - for msg in &messages { - cb(msg); - } - } - } - // Empty list is success (result=0), not failure. - tracing::info!( - target: "propagation-retrieve", - pn_hash = %pn_hex, - listed, - downloaded, - delivered, - retrieve_mode = "get", - "client /get download Completes" - ); - bridge.run_if_current(&active_run_id, run_id, || { - emit_progress(false, 100.0, None); - }); - break; - } - } - } - clear_pins(); - }); - true + spawn_client_download_driver_task( + Arc::clone(&self.propagation), + Arc::clone(&self.router), + Arc::clone(&self.outbound), + Arc::clone(&self.sync_run_id), + pn_hash, + pn_hex, + cancel, + run_id, + event_tx, + on_terminal, + emit_ui, + ) } /// Drain the in-process (`local-prop`) PN store of our own mail into Chat. @@ -4041,6 +3990,7 @@ impl LiveBridge { } self.propagation.cancel_sync(); self.propagation.cancel_client_download(); + self.propagation.clear_pending_post_peer_get(); if let Ok(mut driver) = self.outbound.lock() { driver.clear_propagation_identity_pins(); driver.set_propagation_sync_target(None); @@ -5787,6 +5737,201 @@ fn peer_route_fields_equal(a: &PeerRow, b: &PeerRow) -> bool { && a.public_key == b.public_key } +/// Shared client `/get` driver used by user Sync (`emit_ui: true`) and post-peer +/// silent retrieve (`emit_ui: false`). +#[allow(clippy::too_many_arguments)] // bridge + router + outbound + UI/callback wiring +fn spawn_client_download_driver_task( + bridge: Arc, + router: Arc>, + outbound: Arc>, + active_run_id: Arc, + pn_hash: [u8; 16], + pn_hex: String, + cancel: Arc, + run_id: u64, + event_tx: broadcast::Sender, + on_terminal: Option>, + emit_ui: bool, +) -> bool { + if !bridge.start_client_download(pn_hash) { + tracing::debug!( + target: "propagation-retrieve", + pn_hash = %pn_hex, + "client /get download not started (already active or unavailable)" + ); + return false; + } + tokio::spawn(async move { + // Client tick cadence; the client's own 120s timeout bounds a stuck link. + const POLL_INTERVAL: Duration = Duration::from_millis(500); + const DOWNLOAD_WATCHDOG: Duration = Duration::from_secs(180); + const ESTABLISH_STALL: Duration = Duration::from_secs(45); + let mut interval = tokio::time::interval(POLL_INTERVAL); + let started = Instant::now(); + let mut last_logged_progress = -1.0_f64; + let emit_progress = |active: bool, progress: f64, message: Option<&str>| { + if !emit_ui { + return; + } + let payload = serde_json::json!({ + "active": active, + "progress": progress, + "message": message, + }); + let frame = serde_json::json!({ + "type": "propagation_sync", + "payload": payload, + }); + let _ = event_tx.send(frame.to_string()); + }; + let clear_pins = || { + if emit_ui { + bridge.run_if_current(&active_run_id, run_id, || { + if let Some(ref cb) = on_terminal { + cb(); + } + }); + } else if let Some(ref cb) = on_terminal { + cb(); + } + }; + let still_current = || { + if !emit_ui { + return true; + } + PropagationBridge::is_current_sync_run(active_run_id.load(Ordering::SeqCst), run_id) + }; + let run_guarded = |f: &dyn Fn()| { + if emit_ui { + bridge.run_if_current(&active_run_id, run_id, f); + } else { + f(); + } + }; + // Immediate Establishing frame so the renderer stall watchdog sees progress. + run_guarded(&|| { + emit_progress(true, 10.0, None); + }); + loop { + interval.tick().await; + // Superseded by a newer sync run: that run now owns the client, + // so exit without touching shared client state. + if !still_current() { + break; + } + // This run was cancelled: cancel the client only while we are + // still the active run (lifecycle-lock guarded via run_if_current). + if cancel.load(Ordering::SeqCst) { + run_guarded(&|| { + bridge.cancel_client_download(); + }); + break; + } + let progress = bridge.client_download_progress(); + if progress <= 10.0 + && bridge.client_download_active() + && started.elapsed() > ESTABLISH_STALL + { + tracing::info!( + target: "propagation-retrieve", + pn_hash = %pn_hex, + "client /get stalled while establishing" + ); + run_guarded(&|| { + bridge.cancel_client_download(); + emit_progress( + false, + 0.0, + Some("propagation establish failed: NoLinkProof"), + ); + }); + break; + } + if started.elapsed() > DOWNLOAD_WATCHDOG { + tracing::info!( + target: "propagation-retrieve", + pn_hash = %pn_hex, + "client /get download watchdog timeout" + ); + run_guarded(&|| { + bridge.cancel_client_download(); + emit_progress( + false, + 0.0, + Some("propagation establish failed: NoLinkProof"), + ); + }); + break; + } + if (progress - last_logged_progress).abs() >= 0.5 { + last_logged_progress = progress; + run_guarded(&|| { + emit_progress(true, progress, None); + }); + } + let known = outbound + .lock() + .ok() + .map(|d| d.known_identities_for_propagation()) + .unwrap_or_default(); + match bridge.poll_client_download(&known) { + ClientDownloadPoll::Idle => break, + // Keep polling on the next interval tick. + ClientDownloadPoll::InProgress => {} + ClientDownloadPoll::Failed => { + tracing::info!( + target: "propagation-retrieve", + pn_hash = %pn_hex, + "client /get download failed" + ); + // Consume the terminal failed state → Idle so a later + // sync can start a fresh retrieval. + run_guarded(&|| { + bridge.cancel_client_download(); + emit_progress( + false, + 0.0, + Some("propagation establish failed: NoLinkProof"), + ); + }); + break; + } + ClientDownloadPoll::Complete { + messages, + listed, + downloaded, + } => { + let delivered = messages.len(); + { + let router = router.lock().await; + if let Some(ref cb) = router.delivery_callback { + for msg in &messages { + cb(msg); + } + } + } + // Empty list is success (result=0), not failure. + tracing::info!( + target: "propagation-retrieve", + pn_hash = %pn_hex, + listed, + downloaded, + delivered, + retrieve_mode = if emit_ui { "get" } else { "get_post_peer" }, + "client /get download Completes" + ); + run_guarded(&|| { + emit_progress(false, 100.0, None); + }); + break; + } + } + } + clear_pins(); + }); + true +} + /// Pure announce classification for propagation sync targets. /// /// `entries` is `(dest_hash_hex, name_hash)` pairs from recent announces. diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index 220d4a88a..7b659e0c3 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -2840,4 +2840,95 @@ mod tests { "timeout advance path must be logged for Prefer PN storms" ); } + + /// Outbound fabric: mail for someone else stays in peer `/offer` inventory; + /// our delivery-hash drain must not consume it. + #[test] + fn local_prop_deposit_for_other_recipient_stays_in_offer_not_drained() { + use lxmf_core::constants::DeliveryMethod; + use lxmf_core::message::LxMessage; + use lxmf_core::router::{LxmRouter, RouterConfig}; + use rns_identity::destination::Destination; + use tokio::sync::broadcast; + + let dir = std::env::temp_dir().join(format!("mesh-prop-offer-keep-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + + let sender = Identity::new(); + let us = Identity::new(); + let other = Identity::new(); + let local_prop = [0xadu8; 16]; + let peer_pn = [0xbfu8; 16]; + let zero_stamp_policy = crate::stack::pn_hosting_policy::PnHostingPolicy { + propagation_stamp_cost: 0, + propagation_stamp_flex: 0, + ..Default::default() + }; + let (tx, _rx) = mpsc::channel(32); + let bridge = crate::stack::propagation_bridge::PropagationBridge::new( + tx.clone(), + local_prop, + dir.clone(), + &us, + &zero_stamp_policy, + ) + .expect("bridge"); + + let sender_delivery = + Destination::hash_from_name_and_identity("lxmf.delivery", Some(&sender.hash)); + let other_delivery = + Destination::hash_from_name_and_identity("lxmf.delivery", Some(&other.hash)); + let mut driver = + LxmfOutboundDriver::new(tx, &sender, hex::encode(sender_delivery), "me".into()); + driver.register_identity_key(&hex::encode(other_delivery), other.get_public_key()); + driver.set_local_prop_node(Some(bridge.local_node())); + driver.set_pn_cascade_candidates(vec![PnCascadeCandidate { + hash: local_prop, + is_local: true, + is_discovered: false, + hops: Some(0), + id: "local-prop".into(), + }]); + + let mut router = LxmRouter::new(RouterConfig::default()); + let (event_tx, _event_rx) = broadcast::channel(8); + let mut msg = LxMessage::new( + other_delivery, + sender_delivery, + "", + "reprop inventory", + DeliveryMethod::Direct, + ); + msg.sign(&sender.get_signing_key().expect("sk")) + .expect("sign"); + assert!( + driver + .try_advance_pn_cascade(&mut router, &event_tx, msg) + .is_ok() + ); + driver.process_tick(&mut router, &event_tx); + + let offer = { + let node_arc = bridge.local_node(); + let mut node = node_arc.lock().expect("lock"); + assert_eq!(node.message_count(), 1); + node.prepare_sync_offer(peer_pn) + }; + assert!( + !offer.transient_ids.is_empty(), + "peer /offer must still list mail for other recipients" + ); + + let (messages, listed) = bridge.drain_local_inbox(); + assert!(messages.is_empty()); + assert_eq!(listed, 0); + assert_eq!( + bridge.local_node().lock().expect("lock").message_count(), + 1, + "drain must not purge re-propagation inventory" + ); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index e52b1b26b..67a7e365b 100644 --- a/reticulum-sidecar/src/stack/propagation_bridge.rs +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -52,6 +52,10 @@ pub struct PropagationBridge { /// In-flight peering-key PoW jobs for local-host outbound peer sync. peering_key_jobs: Mutex>, peering_key_results: Mutex>, + /// Set when inbound peer Resource accept should drain our inbox into Chat. + inbox_drain_requested: AtomicBool, + /// After host peer `/offer` Completes, `/get` this peer for our mail (sequenced). + pending_post_peer_get: Mutex>, } impl PropagationBridge { @@ -106,9 +110,46 @@ impl PropagationBridge { peak_progress: Mutex::new(0.0), peering_key_jobs: Mutex::new(HashSet::new()), peering_key_results: Mutex::new(Vec::new()), + inbox_drain_requested: AtomicBool::new(false), + pending_post_peer_get: Mutex::new(None), }) } + /// Ask maintenance to drain our `lxmf.delivery` mail from the local PN store into Chat. + pub fn request_inbox_drain(&self) { + self.inbox_drain_requested.store(true, Ordering::SeqCst); + } + + /// Consume a pending inbox-drain request (coalesced; one drain per take). + pub fn take_inbox_drain_request(&self) -> bool { + self.inbox_drain_requested.swap(false, Ordering::SeqCst) + } + + /// Queue a silent client `/get` against `peer_hash` after host peer `/offer` Completes. + pub fn queue_post_peer_get(&self, peer_hash: [u8; 16]) { + if let Ok(mut slot) = self.pending_post_peer_get.lock() { + *slot = Some(peer_hash); + } + } + + /// Take a queued post-peer `/get` target when the client is idle. + pub fn take_pending_post_peer_get(&self) -> Option<[u8; 16]> { + if self.client_download_active() { + return None; + } + self.pending_post_peer_get + .lock() + .ok() + .and_then(|mut slot| slot.take()) + } + + /// Drop a queued post-peer `/get` (user Sync cancel / supersession). + pub fn clear_pending_post_peer_get(&self) { + if let Ok(mut slot) = self.pending_post_peer_get.lock() { + *slot = None; + } + } + /// Load historical PN messages off the live-ready path (spawn_blocking). pub fn spawn_messagestore_load(self: &Arc) { let this = Arc::clone(self); @@ -904,6 +945,242 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn inbox_drain_request_coalesces_until_taken() { + let dir = std::env::temp_dir().join(format!("mesh-prop-drain-req-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + let (tx, _rx) = mpsc::channel(8); + let us = Identity::new(); + let bridge = PropagationBridge::new( + tx, + [0xab; 16], + dir.clone(), + &us, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); + assert!(!bridge.take_inbox_drain_request()); + bridge.request_inbox_drain(); + bridge.request_inbox_drain(); + assert!(bridge.take_inbox_drain_request()); + assert!(!bridge.take_inbox_drain_request()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn peer_ingress_accept_signals_drain_and_delivers_our_mail() { + let dir = + std::env::temp_dir().join(format!("mesh-prop-ingress-drain-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + let (tx, _rx) = mpsc::channel(8); + let us = Identity::new(); + let bridge = PropagationBridge::new( + tx, + [0xcd; 16], + dir.clone(), + &us, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); + + let sender = Identity::new(); + let blob = super::super::propagation_download::build_client_download_blob( + &sender, + &us, + "peer ingress mail", + ); + { + let mut node = bridge.local_node.lock().expect("node lock"); + assert!(node.accept_stamped_propagated_blob(&blob, &[0u8; 32], u8::MAX)); + } + // Serve completion path calls this after accepted > 0. + bridge.request_inbox_drain(); + assert!(bridge.take_inbox_drain_request()); + + let (messages, listed) = bridge.drain_local_inbox(); + assert_eq!(listed, 1); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content, "peer ingress mail"); + + let (again, listed_again) = bridge.drain_local_inbox(); + assert!(again.is_empty()); + assert_eq!(listed_again, 0); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn peer_ingress_other_recipient_accepted_but_drain_delivers_zero() { + let dir = + std::env::temp_dir().join(format!("mesh-prop-ingress-foreign-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + let (tx, _rx) = mpsc::channel(8); + let us = Identity::new(); + let bridge = PropagationBridge::new( + tx, + [0xce; 16], + dir.clone(), + &us, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); + + let sender = Identity::new(); + let other = Identity::new(); + let blob = super::super::propagation_download::build_client_download_blob( + &sender, + &other, + "stays for peer offer", + ); + { + let mut node = bridge.local_node.lock().expect("node lock"); + assert!(node.accept_stamped_propagated_blob(&blob, &[0x5A; 32], u8::MAX)); + assert_eq!(node.message_count(), 1); + } + bridge.request_inbox_drain(); + assert!(bridge.take_inbox_drain_request()); + let (messages, listed) = bridge.drain_local_inbox(); + assert!(messages.is_empty()); + assert_eq!(listed, 0); + assert_eq!( + bridge.local_node.lock().expect("lock").message_count(), + 1, + "foreign mail must remain for later peer /offer" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn two_node_inventory_handoff_then_drain_on_recipient_pn() { + use lxmf_core::propagation_node::{PropagationNode, PropagationNodeConfig}; + + let base = std::env::temp_dir().join(format!("mesh-prop-two-node-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let dir_a = base.join("a"); + let dir_b = base.join("b"); + std::fs::create_dir_all(&dir_a).expect("dir a"); + std::fs::create_dir_all(&dir_b).expect("dir b"); + + let sender = Identity::new(); + let recipient = Identity::new(); + let hash_a = [0xa1u8; 16]; + let hash_b = [0xb2u8; 16]; + + let mut node_a = PropagationNode::with_storage( + PropagationNodeConfig { + min_stamp_cost: 0, + ..Default::default() + }, + hash_a, + dir_a, + ) + .expect("node a"); + let mut node_b = PropagationNode::with_storage( + PropagationNodeConfig { + min_stamp_cost: 0, + ..Default::default() + }, + hash_b, + dir_b, + ) + .expect("node b"); + + let blob = super::super::propagation_download::build_client_download_blob( + &sender, + &recipient, + "pn to pn handoff", + ); + let stamp = [0x11u8; 32]; + assert!(node_a.accept_stamped_propagated_blob(&blob, &stamp, u8::MAX)); + + let offer = node_a.prepare_sync_offer(hash_b); + assert!( + !offer.transient_ids.is_empty(), + "A must offer inventory toward B" + ); + + let wanted: Vec<[u8; 32]> = offer + .transient_ids + .iter() + .filter_map(|id| { + if id.len() != 32 { + return None; + } + let mut tid = [0u8; 32]; + tid.copy_from_slice(id); + Some(tid) + }) + .collect(); + let packed = node_a.message_get_request(&wanted); + assert!(!packed.is_empty()); + + let (tx, _rx) = mpsc::channel(8); + let bridge_b = PropagationBridge::new( + tx, + hash_b, + base.join("bridge-b"), + &recipient, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge b"); + + for (tid, stored) in &packed { + assert!(stored.len() >= 32); + let lxmf_data = &stored[..stored.len() - 32]; + let mut stamp_data = [0u8; 32]; + stamp_data.copy_from_slice(&stored[stored.len() - 32..]); + assert!(node_b.accept_stamped_propagated_blob(lxmf_data, &stamp_data, u8::MAX)); + node_a.mark_peer_handled(&hash_b, tid); + // Chat path uses the same stamped accept + drain_local_inbox seam. + assert!( + bridge_b + .local_node + .lock() + .expect("lock") + .accept_stamped_propagated_blob(lxmf_data, &stamp_data, u8::MAX) + ); + } + node_a.complete_sync(&hash_b); + assert_eq!(node_b.message_count(), 1); + + let (messages, listed) = bridge_b.drain_local_inbox(); + assert_eq!(listed, 1); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content, "pn to pn handoff"); + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn post_peer_get_queue_respects_client_active_guard() { + let dir = + std::env::temp_dir().join(format!("mesh-prop-post-peer-q-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + let (tx, _rx) = mpsc::channel(8); + let us = Identity::new(); + let bridge = PropagationBridge::new( + tx, + [0xdf; 16], + dir.clone(), + &us, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); + let peer = [0xeeu8; 16]; + bridge.queue_post_peer_get(peer); + assert_eq!(bridge.take_pending_post_peer_get(), Some(peer)); + assert!(bridge.take_pending_post_peer_get().is_none()); + bridge.queue_post_peer_get(peer); + bridge.clear_pending_post_peer_get(); + assert!(bridge.take_pending_post_peer_get().is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + /// A blob addressed to someone else must never leak into our inbox drain /// (server ownership gate + decrypt both reject it). #[test] @@ -1090,13 +1367,32 @@ mod tests { assert!( live.contains("is_local_serving()") && live.contains("!propagation.sync_active()") + && live.contains("!propagation.client_download_active()") && live.contains("propagation_sync_target().is_none()"), - "peer sync tick must require serving + idle + no user sync target" + "peer sync tick must require serving + idle + no client /get + no sync target" ); assert!( live.contains("start_sync_with_policy"), "host peer loop must start policy-aware sync" ); + assert!( + live.contains("queue_post_peer_get") + && live.contains("take_pending_post_peer_get") + && live.contains("emit_ui") + && live.contains("get_post_peer"), + "peer /offer Complete must sequence silent client /get" + ); + assert!( + live.contains("request_inbox_drain") + && live.contains("take_inbox_drain_request") + && live.contains("local-prop inbox auto-drain Completes"), + "inbound peer accept must auto-drain store into Chat" + ); + let serve = include_str!("propagation_serve.rs"); + assert!( + serve.contains("on_inbound_accepted") && serve.contains("accepted > 0"), + "serve Resource accept must signal inbox drain" + ); let bridge = include_str!("propagation_bridge.rs"); assert!( bridge.contains("start_sync_with_policy"), @@ -1192,6 +1488,55 @@ mod tests { ); } + /// End-to-end wiring graph for Host PN fabric → Chat (non-flaky source contracts). + #[test] + fn source_local_pn_fabric_to_chat_wiring() { + let serve = include_str!("propagation_serve.rs"); + let bridge = include_str!("propagation_bridge.rs"); + let live = include_str!("live.rs"); + assert!( + serve.contains("on_inbound_accepted") && serve.contains("accepted > 0"), + "serve accept → drain signal" + ); + assert!( + bridge.contains("fn request_inbox_drain") + && bridge.contains("fn take_inbox_drain_request"), + "bridge must expose coalesced inbox drain request" + ); + assert!( + live.contains("take_inbox_drain_request") + && live.contains("drain_local_inbox") + && live.contains("local-prop inbox auto-drain Completes"), + "maintenance drain when requested" + ); + assert!( + live.contains("queue_post_peer_get") + && live.contains("spawn_client_download_driver_task") + && live.contains("false,"), + "peer terminal → silent /get (emit_ui false)" + ); + assert!( + live.contains("delivery_callback") && live.contains("get_post_peer"), + "/get Complete → delivery_callback (post-peer path)" + ); + let sync_fn_start = live + .find("pub async fn start_propagation_sync") + .expect("start_propagation_sync"); + let sync_fn = &live[sync_fn_start..]; + let sync_fn_end = sync_fn[1..] + .find("\n pub ") + .map_or(sync_fn.len(), |idx| idx + 1); + let sync_body = &sync_fn[..sync_fn_end]; + assert!( + !sync_body.contains("start_sync(hash"), + "user Sync remains /get-primary (no peer start_sync)" + ); + assert!( + sync_body.contains("spawn_client_download_driver") && sync_body.contains("true,"), + "user Sync still drives UI client /get" + ); + } + /// Auto deposits on discovered PNs, so a newly heard announce must refresh the cascade /// shortlist immediately instead of waiting for a settings write or stack restart. #[test] diff --git a/reticulum-sidecar/src/stack/propagation_serve.rs b/reticulum-sidecar/src/stack/propagation_serve.rs index 96b753fb1..1b11a43fa 100644 --- a/reticulum-sidecar/src/stack/propagation_serve.rs +++ b/reticulum-sidecar/src/stack/propagation_serve.rs @@ -45,6 +45,9 @@ impl PropagationServeHandle { } /// Register `lxmf.propagation` and spawn LinkManager with `/offer`, `/get`, and Resource ingress. + /// + /// `on_inbound_accepted` fires after at least one stamped blob is accepted into the + /// local store (peer Resource ingress) so the live stack can drain our inbox into Chat. pub fn start( &self, transport_tx: &mpsc::Sender, @@ -52,6 +55,7 @@ impl PropagationServeHandle { propagation_dest_hash: [u8; 16], local_node: &Arc>, policy: &PnHostingPolicy, + on_inbound_accepted: Option>, ) -> Result<(), String> { self.stop(); let local_node = Arc::clone(local_node); @@ -222,6 +226,7 @@ impl PropagationServeHandle { Arc::clone(&local_node_for_loop), pn_hash_hex.clone(), link_cmd_for_close.clone(), + on_inbound_accepted.clone(), ); } } @@ -374,6 +379,7 @@ fn validate_pn_resource_job( (outcome, validated, rejected) } +#[allow(clippy::too_many_arguments)] // validation job + admission + inbox drain hook fn spawn_propagation_validation( job: PnValidationJob, max_transfer_bytes: usize, @@ -382,6 +388,7 @@ fn spawn_propagation_validation( local_node: Arc>, pn_hash_hex: String, link_cmd_tx: mpsc::Sender, + on_inbound_accepted: Option>, ) { let token = job.token(); let link_id = job.link_id(); @@ -466,6 +473,12 @@ fn spawn_propagation_validation( "processed inbound propagation Resource" ); + if accepted > 0 { + if let Some(ref on_accepted) = on_inbound_accepted { + on_accepted(); + } + } + if claim.should_close_link() { let link_id = claim.link_id(); let _ = link_cmd_tx @@ -504,6 +517,10 @@ mod tests { src.contains("accept_stamped_propagated_blob"), "validated deposits must enter PropagationNode store" ); + assert!( + src.contains("on_inbound_accepted") && src.contains("accepted > 0"), + "inbound peer accept must signal inbox drain for Chat delivery" + ); assert!( src.contains("evaluate_offer_request"), "/offer must go through admission + evaluate_offer_request" From c073a02492390c9e78ed665fafb90faa5bc9f625 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 9 Aug 2026 15:51:14 -0600 Subject: [PATCH 3/4] fix: restore Host PN peer lifecycle and periodic /get Apply lxmd-parity sync_complete/handled updates so peered remotes can re-offer after store growth, and add a 90s silent client /get when Host is serving so quiet nodes still refresh inbox mail. --- docs/agents/reticulum.md | 2 +- docs/troubleshooting.md | 10 +- reticulum-sidecar/src/stack/live.rs | 84 +++++- reticulum-sidecar/src/stack/lxmf_outbound.rs | 5 + .../src/stack/propagation_bridge.rs | 271 +++++++++++++++++- 5 files changed, 347 insertions(+), 25 deletions(-) diff --git a/docs/agents/reticulum.md b/docs/agents/reticulum.md index 4a8535329..df4b6d69f 100644 --- a/docs/agents/reticulum.md +++ b/docs/agents/reticulum.md @@ -9,7 +9,7 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Re - **Decommissioned hubs:** `src/shared/reticulumDecommissionedHubs.ts` (Amsterdam only) — stack-start auto-disable + **Add default backbones** disables matching enabled TCP rows; UI badge + enable-block in `ReticulumInterfacesPanel.tsx` (`isDecommissionedReticulumTcpInterfaceRow`); keep TS↔Rust synced via `pnpm run check:reticulum-decommissioned-hubs`. Default backbone picker + region-grouped interface list (Primary & Global / North America / Europe / Asia & Oceania / Specialty / User Defined) in `reticulumDefaultHubPresets.ts` + `ReticulumDefaultHubsPickerModal.tsx`; muted disabled rows + checkbox bulk delete; `countEnabledDefaultHubPresets` / >3 enable warning - **BLE RNode RSSI:** `useReticulumBleRnodeRssiMap` gates on sidecar **running** (not api-ready), burst-then-steady scans via nested `acquireReticulumBleScan`, clears sticky targets immediately when all BLE RNodes are disabled - **Propagation mode / sync:** Network → Propagation nodes owns Off/Auto/Manual (default **Off**; persisted values including legacy App-panel `auto` are honored). Auto one-time syncs via `startPropagationSyncCascade` + sidecar `destination_hash` sync in order: **finite-hop discovered** (no Add/Preferred) → **configured remotes** → **unknown-hop discovered** → local-prop (skips remotes when no enabled interfaces); runtime hook `useReticulumPropagationAutoSync`. Sidecar `start_propagation_sync` is **client `/get`-primary** (inbox retrieval; UI progress from `PropagationClient`) — peer `/offer` inventory push stays on the local-host peer loop when serving (avoids AwaitingResponse hangs against non-peer remotes with a nonempty messagestore). Hard-fails with `PROPAGATION_PATH_UNKNOWN` when `ensure_path_for_direct` fails after announce settle (same path gate as offer probe). Manual uses Preferred, else picks the best configured remote **for that sync only** (no Preferred write), then the remaining remotes, then local-prop. Off = **no PN support**: `startPropagationSyncCascade` returns early (per-row Sync is disabled in UI), `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` are false, `ReticulumPropagationNotice` is hidden, and the sidecar disarms the outbound PN plus empties cascade candidates (`propagation_mode` in `mesh_client_stack.json`, `POST /api/v1/propagation/mode`, `candidates_for_propagation_mode`); renderer pushes the mode on change and on sidecar-ready. `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Complete on HaveAll, Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation. **Nothing-to-sync is not a failure:** when the cascade contacts no node it writes `syncNoTarget` / `syncLocalLoading` (never overwriting a real error from an attempted node), the local row reports sidecar `status: "loading"` while the messagestore reads (`local_propagation_status` + `PropagationBridge::messagestore_load_pending`, per-row Sync disabled), and the 30 s tick calls `refreshFromSidecar` while `hasPropagationCascadeCandidate` is false so a fresh stack recovers on its own — `refreshFromSidecar` must **not** clear the active attempt while `sync.active`. Debug snapshot `propagationClient` exposes mode/preferred/autoTarget/resolvedSyncTargetId. **Auto also deposits on Discovered PNs:** sidecar `auto_discovered_candidates` (`pn_cascade.rs`, Auto only, cap 3, hop-sorted, skips inactive / self / already-configured / over `max_peering_cost`) appends after configured remotes and before local-prop, rebuilt from the shared `rebuild_pn_cascade_candidates` helper in `live.rs` (called by `refresh_pn_cascade_candidates` **and** the PN announce handler); `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` therefore count discovered rows in Auto, so the Chat notice hides and the link-timeout failure bridge holds off. **Chat notice dismiss:** `chatNoticeDismissed` (`mesh-client:reticulumPropagationNoticeDismissed`) with **Don't show again** on the banner and **Show propagation reminder in Chat** in the Network section. **Named sync target:** `startSync` stamps `syncTargetId`; progress line, inline error, and Sync toasts resolve it with `resolveReticulumPropagationTargetLabel`; the cascade clears it when nothing was contacted so `syncNoTarget` / `syncLocalLoading` stay unprefixed. **Attempts settle before the cascade advances:** `startSync` returns `accepted` | `deferred` | `failed` (not a boolean) — only sidecar _acceptance_ starts `awaitPropagationSyncSettled` (terminal WS frame or stall/ceiling watchdog). `failed` advances with ~15 min session-memory omit via `reticulumPropagationSyncBackoff.ts`; `deferred` (`PROPAGATION_SYNC_OUTBOUND_BUSY` — outbound deposit owns the PN link) advances **without** backoff so the next tick may retry; `cancelled` (user Cancel) stops; `success` ends the run. Remote steps are capped by `PROPAGATION_CASCADE_BUDGET_MS` (5 min) then fall through to local-prop; each remote attempt is capped by `PROPAGATION_CASCADE_ATTEMPT_TIMEOUT_MS` (~60s); local fallback refreshes nodes when local looks disabled; the cascade is single-flight (`resetPropagationSyncCascadeState` is the test seam) so overlapping 30 s ticks join one run while an explicit per-row Sync supersedes it. Auto `/api/v1/interfaces` probe **fails open** (assumes interfaces enabled) so a broken proxy still tries remotes before local. **Retrieval vs peer sync:** User Sync progress is **client `/get`-primary** (`PropagationClient` → `spawn_client_download_driver` with UI emits). Peer `/offer` inventory push runs only on the **local Host peer loop** when serving (`drive_local_host_peer_sync`). Logs: inbox retrieval is `propagation-retrieve` (`retrieve_mode=get|get_post_peer|local`); peer-offer outcome is `propagation-sync … peer_outcome=have_all|transfer` (**not** retrieval). `local-prop` Sync still uses `drain_local_inbox` (in-process). -- **Host PN fabric → Chat (lxmd-style glue):** When local Host is enabled, mesh-client is both PN and end-client on rsLXMF (not a second lxmd). Path: outbound deposit → host peer `/offer` push; inbound peer Resource accept → `request_inbox_drain` → maintenance `drain_local_inbox` → `delivery_callback` → Chat (no manual local-prop Sync); after host peer `/offer` Completes for peer `P`, sequenced **silent** client `/get` against `P` (`emit_ui: false`, `retrieve_mode=get_post_peer`) pulls mail waiting on that remote for our `lxmf.delivery`. Guards: coalesce drain; one internal `/get`; skip when user Sync target / outbound deposit owns the hash or `sync_active` / `client_download_active`. Do **not** re-attach peer `/offer` to the Sync button. True dual full-index exchange in one Link stays upstream rsLXMF. +- **Host PN fabric → Chat (lxmd-style glue):** When local Host is enabled, mesh-client is both PN and end-client on rsLXMF (not a second lxmd). Path: outbound deposit → host peer `/offer` push (generation-gated; lxmd terminal bookkeeping: `sync_complete` / `mark_offer_generation_processed` / `take_handled_updates` + `save_peer`); inbound peer Resource accept → `request_inbox_drain` → maintenance `drain_local_inbox` → `delivery_callback` → Chat; after host peer `/offer` Completes for peer `P`, sequenced **silent** client `/get` (`retrieve_mode=get_post_peer`); while serving and quiet, **~90s** periodic silent `/get` round-robin over peered remotes (Prefer/outbound first, `retrieve_mode=get_periodic`) for inbox catch-up — not a timed empty re-`/offer`. Re-`/offer` when `offer_generation` advances, offer policy changes, or a partial sync left work; maintenance polls every ~2s but only _starts_ work when idle. Guards: coalesce drain; one internal `/get`; skip when user Sync target / outbound deposit owns the hash or `sync_active` / `client_download_active`. Do **not** re-attach peer `/offer` to the Sync button. Dual full-index exchange in one Link stays upstream rsLXMF; remote inventory for re-propagation arrives when peers `/offer` to our serve path. - **PN hosting:** Network **Advanced PN hosting** / `ReticulumPnHostingDangerZone`; shared `pnHostingPolicy.ts` + sidecar `pn_hosting_policy.rs` / `pn_hosting_apply.rs`; `POST /api/v1/propagation/hosting-policy`; rsLXMF policy-setters overlay ([ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6)). Messagestore loads in background on live attach; enabled `local-prop` serve/announce waits until load completes. - **Interface modes:** rnsd `mode` via `reticulumInterfaceMode.ts` + sidecar `normalize_interface_mode` (keep catalogs in sync — `pnpm run check:reticulum-interface-modes` in pre-commit/`release.sh`); add defaults TCP/UDP/I2P → `boundary`, RNode → `access_point`; UI in `ReticulumInterfacesPanel`; default hub presets add/repair missing mode to `boundary` (do not overwrite valid non-boundary). See [../reticulum.md#interface-modes](../reticulum.md#interface-modes). - **Share instance defaults:** missing keys bootstrap to `share_instance = No` / `instance_name = mesh-client` (does not overwrite explicit Yes/`default`); SharedInstanceClient banner + `disable_share_instance` repair; offline lint via `reticulum:validateConfig` / Network **Check config** / `pnpm run reticulum:config:check` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 87f9b5c60..2d6fb2326 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1329,7 +1329,7 @@ Bond-stale **TX queue full** hints (`txQueueDropsHintBleBondStale`) point at the **Cause**: Hosting requires a live stack with identity signing key; enable starts `lxmf.propagation` LinkManager + announce loop (Resource deposit ingress + stamp validation into the local store, `/offer` admission, outbound peer inventory sync when idle, auto inbox drain into Chat, and sequenced post-peer `/get` after host `/offer` Completes). -**Fix**: Confirm sidecar is running, identity is configured, **Network → Propagation → Host propagation node** is Enabled, and check logs for `[propagation-serve]` / `[propagation-announce]` / `[propagation-deposit]` / auto-drain / `get_post_peer`. Tune announce interval under **Advanced PN hosting**. Peers depositing to your host should see your `lxmf.propagation` hash; bad stamps are rejected and logged under `propagation-deposit`. +**Fix**: Confirm sidecar is running, identity is configured, **Network → Propagation → Host propagation node** is Enabled, and check logs for `[propagation-serve]` / `[propagation-announce]` / `[propagation-deposit]` / auto-drain / `get_post_peer` / `get_periodic`. Tune announce interval under **Advanced PN hosting**. Peers depositing to your host should see your `lxmf.propagation` hash; bad stamps are rejected and logged under `propagation-deposit`. ### Reticulum: Stored at PN but Sync leaves Chat empty @@ -1339,16 +1339,16 @@ Bond-stale **TX queue full** hints (`txQueueDropsHintBleBondStale`) point at the **Progress bar = client `/get` retrieval.** User Sync against a remote PN drives the progress bar from the **client `/get` download** (inbox mail into Chat). Peer `/offer` inventory replication runs on the **local Host peer loop** when you are serving a PN — not on the Sync button — so a nonempty messagestore cannot hang Sync at AwaitingResponse against remotes that are not your peers. Look for `propagation-retrieve` `/get` Completes in Device logs for retrieve counts. -**Host PN auto Chat path:** With **Host propagation node** enabled, mail that lands in the local store via peer Resource ingress should appear in Chat via **auto-drain** (`local-prop inbox auto-drain Completes`, `retrieve_mode=local`) without pressing Sync on local-prop. After your host peer `/offer` Completes to a peered remote, a **silent** client `/get` to that peer may also run (`retrieve_mode=get_post_peer`, no Sync UI bar). Explicit Sync remains `/get`-primary (Prefer / cascade). +**Host PN auto Chat path:** With **Host propagation node** enabled, mail that lands in the local store via peer Resource ingress should appear in Chat via **auto-drain** (`local-prop inbox auto-drain Completes`, `retrieve_mode=local`) without pressing Sync on local-prop. After your host peer `/offer` Completes to a peered remote, a **silent** client `/get` to that peer may also run (`retrieve_mode=get_post_peer`, no Sync UI bar). While Host is on and quiet, a **~90s** periodic silent `/get` (`retrieve_mode=get_periodic`) revisits Prefer/peered remotes for inbox catch-up — this is not a 2s poll and does not re-`/offer` an unchanged store. Host peer `/offer` re-runs when the local messagestore generation advances (or after a failed/partial sync), with lxmd-style peer Idle bookkeeping. Explicit Sync remains `/get`-primary (Prefer / cascade). **Do not** tell users they must share the same preferred PN. Prefer log correlation instead: 1. Sender Device log: `propagation-deposit` with `message_hash`, `transient_id`, `pn_hash` (deposit Completes). -2. Recipient log (the real retrieval): `propagation-retrieve … retrieve_mode=get|get_post_peer pn_hash=… listed=N downloaded=N delivered=N` (the client `/get` download; `listed=0` is a valid empty-inbox success). Per-message `propagation-retrieve` with matching `message_hash` / `transient_id` fires as each downloaded message hits the delivery callback. Host auto-drain / `local-prop` Sync logs `retrieve_mode=local`. The peer-offer side logs `propagation-sync … peer_outcome=have_all|transfer` — that is **not** retrieval. +2. Recipient log (the real retrieval): `propagation-retrieve … retrieve_mode=get|get_post_peer|get_periodic pn_hash=… listed=N downloaded=N delivered=N` (the client `/get` download; `listed=0` is a valid empty-inbox success). Per-message `propagation-retrieve` with matching `message_hash` / `transient_id` fires as each downloaded message hits the delivery callback. Host auto-drain / `local-prop` Sync logs `retrieve_mode=local`. The peer-offer side logs `propagation-sync … peer_outcome=have_all|transfer` — that is **not** retrieval. 3. Renderer: `[catchUpRecentInboundLxmf] … reason=propagation_sync` or `propagation-retrieve catch-up after sync Completes count=N` (`count=0 (empty ring)` means Sync Completes with no new inbound for Chat). -4. Confirm remote Sync Completes and that Host PN (if used) shows `[propagation-deposit] local PN accepted stamped propagated blob` plus auto-drain / post-peer retrieve lines when expecting Chat without a manual Sync. +4. Confirm remote Sync Completes and that Host PN (if used) shows `[propagation-deposit] local PN accepted stamped propagated blob` plus auto-drain / post-peer / periodic retrieve lines when expecting Chat without a manual Sync. -**Fix**: Retry Sync after path/announce settle; if using local Host, confirm ingress + auto-drain / post-peer `/get` logs and peer sync ticks (`local host queued outbound peer inventory sync`). Export developer bundles from both sides and `rg 'propagation-deposit|propagation-retrieve'`. +**Fix**: Retry Sync after path/announce settle; if using local Host, confirm ingress + auto-drain / silent `/get` (`get_post_peer` / `get_periodic`) logs and peer sync ticks (`local host queued outbound peer inventory sync`). Export developer bundles from both sides and `rg 'propagation-deposit|propagation-retrieve'`. ### Reticulum PN hosting policy apply fails diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index a4efe00e4..03871bff3 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -90,6 +90,10 @@ const LXMF_EGRESS_TAP_SETTLE_MS: u64 = 1500; /// before the Electron IPC proxy GET timeout (10s default). const TRANSPORT_QUERY_TIMEOUT: Duration = Duration::from_secs(20); +/// lxmd `last_propagation_check` parity: Host-serving silent client `/get` when +/// the local store is quiet (inbox catch-up from peered remotes). +const HOST_PERIODIC_GET_INTERVAL: Duration = Duration::from_secs(90); + /// Aspect Nomad Network nodes announce and serve page/file requests under. const NOMAD_NODE_ASPECT: &str = "nomadnetwork.node"; @@ -2442,6 +2446,8 @@ impl LiveBridge { let mut interval = tokio::time::interval(Duration::from_secs(2)); let mut known_path_hashes: HashSet = HashSet::new(); let mut prev_peer_by_hash: HashMap = HashMap::new(); + let mut last_host_periodic_get_at: Option = None; + let mut host_periodic_get_rr: usize = 0; loop { interval.tick().await; // Keep Auto / private-LAN policy inputs fresh (host from config, online from stats). @@ -2626,7 +2632,7 @@ impl LiveBridge { None }; let need_inbox_drain = propagation.take_inbox_drain_request(); - let mut start_silent_get: Option<[u8; 16]> = None; + let mut start_silent_get: Option<([u8; 16], &'static str)> = None; { let mut router = router.lock().await; if let Ok(mut driver) = outbound.lock() { @@ -2635,7 +2641,7 @@ impl LiveBridge { } driver.process_tick(&mut router, &event_tx); let known_identities = driver.known_identities_for_propagation(); - let terminal = propagation.tick(&known_identities); + let terminal = propagation.tick(&known_identities, &mut router); if let Some((ok, peer_hash)) = terminal { driver.set_propagation_sync_target(None); // After host peer `/offer` Completes, pull our inbox from that @@ -2654,11 +2660,38 @@ impl LiveBridge { && driver.propagation_sync_target().is_none() { driver.set_propagation_sync_target(Some(peer_hash)); - start_silent_get = Some(peer_hash); + start_silent_get = Some((peer_hash, "get_post_peer")); } else { propagation.queue_post_peer_get(peer_hash); } } + // Host quiet-store inbox refresh (lxmd ~90s outbound /get parity). + let due_periodic = last_host_periodic_get_at + .is_none_or(|at| at.elapsed() >= HOST_PERIODIC_GET_INTERVAL); + if due_periodic + && start_silent_get.is_none() + && propagation.is_local_serving() + && !propagation.sync_active() + && !propagation.client_download_active() + && driver.propagation_sync_target().is_none() + { + if let Some(peer_hash) = next_host_periodic_get_target( + &router, + driver.preferred_pn_hash(), + &driver, + &mut host_periodic_get_rr, + ) { + if !driver.has_inflight_delivery_to(&peer_hash) { + driver.set_propagation_sync_target(Some(peer_hash)); + start_silent_get = Some((peer_hash, "get_periodic")); + last_host_periodic_get_at = Some(Instant::now()); + } + } else { + // No eligible peer yet — still advance the timer so we do + // not spin every 2s while peers are missing identity. + last_host_periodic_get_at = Some(Instant::now()); + } + } // Local Host: push inventory to peered PNs when the sync task is idle // and no user Sync / deposit / silent /get owns the PN Link. if propagation.is_local_serving() @@ -2702,7 +2735,7 @@ impl LiveBridge { ); }); } - if let Some(peer_hash) = start_silent_get { + if let Some((peer_hash, retrieve_mode)) = start_silent_get { let pn_hex = hex::encode(peer_hash); let outbound_for_clear = Arc::clone(&outbound); let on_terminal: Arc = Arc::new(move || { @@ -2726,16 +2759,20 @@ impl LiveBridge { event_tx.clone(), Some(on_terminal), false, + retrieve_mode, ); if started { tracing::info!( target: "propagation-retrieve", pn_hash = %pn_hex, - "post-peer silent client /get queued" + retrieve_mode, + "silent client /get queued" ); } else if let Ok(mut driver) = outbound.lock() { driver.set_propagation_sync_target(None); - propagation.queue_post_peer_get(peer_hash); + if retrieve_mode == "get_post_peer" { + propagation.queue_post_peer_get(peer_hash); + } } } // Skip tick when outbound is locked — never drain LRPROOF against an empty map. @@ -3845,6 +3882,7 @@ impl LiveBridge { event_tx, on_terminal, emit_ui, + if emit_ui { "get" } else { "get_post_peer" }, ) } @@ -5752,6 +5790,7 @@ fn spawn_client_download_driver_task( event_tx: broadcast::Sender, on_terminal: Option>, emit_ui: bool, + retrieve_mode: &'static str, ) -> bool { if !bridge.start_client_download(pn_hash) { tracing::debug!( @@ -5917,7 +5956,7 @@ fn spawn_client_download_driver_task( listed, downloaded, delivered, - retrieve_mode = if emit_ui { "get" } else { "get_post_peer" }, + retrieve_mode, "client /get download Completes" ); run_guarded(&|| { @@ -5932,6 +5971,37 @@ fn spawn_client_download_driver_task( true } +/// Pick the next Host periodic `/get` target: Prefer/outbound first when peered +/// and identity-known, else round-robin over alive peered remotes. +fn next_host_periodic_get_target( + router: &LxmRouter, + preferred: Option<[u8; 16]>, + driver: &LxmfOutboundDriver, + rr: &mut usize, +) -> Option<[u8; 16]> { + let identity_known = |hash: &[u8; 16]| driver.identity_known_for(&hex::encode(hash)); + if let Some(pref) = preferred { + if let Some(peer) = router.peers.get(&pref) { + if peer.alive && identity_known(&pref) { + return Some(pref); + } + } + } + let mut candidates: Vec<[u8; 16]> = router + .peers + .values() + .filter(|p| p.alive && identity_known(&p.destination_hash)) + .map(|p| p.destination_hash) + .collect(); + if candidates.is_empty() { + return None; + } + candidates.sort_unstable(); + let idx = *rr % candidates.len(); + *rr = rr.wrapping_add(1); + Some(candidates[idx]) +} + /// Pure announce classification for propagation sync targets. /// /// `entries` is `(dest_hash_hex, name_hash)` pairs from recent announces. diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index 7b659e0c3..895c49e58 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -299,6 +299,11 @@ impl LxmfOutboundDriver { router.set_outbound_propagation_node(hash); } + /// Preferred / outbound PN hash used for cascade and Host periodic `/get`. + pub fn preferred_pn_hash(&self) -> Option<[u8; 16]> { + self.preferred_pn_hash + } + /// Refresh enabled PN candidates used after Direct path failover exhausts. pub fn set_pn_cascade_candidates(&mut self, candidates: Vec) { tracing::info!( diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index 67a7e365b..4a649db4b 100644 --- a/reticulum-sidecar/src/stack/propagation_bridge.rs +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -10,8 +10,11 @@ use lxmf_core::message::LxMessage; use lxmf_core::peer::OutboundOfferPolicy; use lxmf_core::propagation_client::{PropagationClient, PropagationClientState}; use lxmf_core::propagation_node::{PropagationNode, PropagationNodeConfig}; -use lxmf_core::propagation_sync::{PeerSyncTerminalState, PropagationSyncTask, SyncTaskState}; +use lxmf_core::propagation_sync::{ + PeerSyncTerminalResult, PeerSyncTerminalState, PropagationSyncTask, SyncTaskState, +}; use lxmf_core::router::LxmRouter; +use lxmf_core::types::PropagationTransientId; use rns_identity::destination::Destination; use rns_identity::identity::Identity; use rns_transport::messages::TransportMessage; @@ -697,22 +700,44 @@ impl PropagationBridge { } /// Drain sync events and return `Some((success, peer_hash))` when a peer sync just finished. - pub fn tick(&self, known_identities: &HashMap) -> Option<(bool, [u8; 16])> { - let terminal = if let Ok(mut task) = self.sync_task.lock() { + /// + /// Applies lxmd-parity router peer bookkeeping: handled-message updates, + /// `sync_complete` / `sync_failed`, and `mark_offer_generation_processed` when + /// the generation is exhausted — then persists the peer via `save_peer`. + pub fn tick( + &self, + known_identities: &HashMap, + router: &mut LxmRouter, + ) -> Option<(bool, [u8; 16])> { + let (handled, terminal_result) = if let Ok(mut task) = self.sync_task.lock() { // Sample before drain/tick: tip collapses Complete|Failed → Idle in tick(). self.note_peak_progress(Self::progress_for_state(task.state)); task.drain_events(known_identities); self.note_peak_progress(Self::progress_for_state(task.state)); task.tick(); - task.take_terminal_peer_result().map(|result| { - ( - matches!(result.state, PeerSyncTerminalState::Complete), - result.peer_hash, - ) - }) + let handled = task.take_handled_updates(); + let peer_hash_for_handled = if handled.is_empty() { + None + } else { + task.node_dest_hash() + }; + let terminal = task.take_terminal_peer_result(); + (peer_hash_for_handled.map(|h| (h, handled)), terminal) } else { - None + (None, None) }; + + if let Some((peer_hash, updates)) = handled { + apply_peer_handled_updates(router, &self.local_node, peer_hash, &updates); + } + + let terminal = terminal_result.map(|result| { + apply_peer_sync_terminal(router, &self.local_node, &result); + ( + matches!(result.state, PeerSyncTerminalState::Complete), + result.peer_hash, + ) + }); if let Some((ok, peer_hash)) = terminal { if let Ok(mut slot) = self.last_finished_ok.lock() { *slot = Some(ok); @@ -892,6 +917,70 @@ impl PropagationBridge { } } +/// Merge peer-handled transient IDs into the router peer and persist (lxmd parity). +pub(crate) fn apply_peer_handled_updates( + router: &mut LxmRouter, + local_node: &Arc>, + peer_hash: [u8; 16], + updates: &[PropagationTransientId], +) { + if updates.is_empty() { + return; + } + let Some(peer) = router.peers.get_mut(&peer_hash) else { + return; + }; + for transient_id in updates { + peer.add_handled_message(transient_id); + } + persist_router_peer(local_node, router, peer_hash); +} + +/// Apply peer `/offer` terminal result onto the router peer (lxmd parity). +pub(crate) fn apply_peer_sync_terminal( + router: &mut LxmRouter, + local_node: &Arc>, + result: &PeerSyncTerminalResult, +) { + let Some(peer) = router.peers.get_mut(&result.peer_hash) else { + return; + }; + match result.state { + PeerSyncTerminalState::Complete => { + peer.sync_complete(); + if result.generation_exhausted { + if let Some(generation) = result.offer_generation { + peer.mark_offer_generation_processed(generation); + } + } + } + PeerSyncTerminalState::Failed => { + peer.sync_failed(); + } + } + persist_router_peer(local_node, router, result.peer_hash); +} + +fn persist_router_peer( + local_node: &Arc>, + router: &LxmRouter, + peer_hash: [u8; 16], +) { + let Some(peer) = router.peers.get(&peer_hash) else { + return; + }; + if let Ok(node) = local_node.lock() { + if let Err(error) = node.save_peer(peer) { + tracing::warn!( + target: "propagation-sync", + peer = %hex::encode(peer_hash), + error = %error, + "failed to persist peer sync state" + ); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1357,6 +1446,144 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn apply_peer_sync_terminal_complete_returns_idle_and_marks_generation() { + use lxmf_core::constants::PeerState; + use lxmf_core::peer::LxmPeer; + use lxmf_core::router::RouterConfig; + + let dir = + std::env::temp_dir().join(format!("mesh-prop-peer-terminal-ok-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + let (tx, _rx) = mpsc::channel(8); + let us = Identity::new(); + let bridge = PropagationBridge::new( + tx, + [0xab; 16], + dir.clone(), + &us, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); + + let peer_hash = [0x11u8; 16]; + let mut router = LxmRouter::new(RouterConfig::default()); + let mut peer = LxmPeer::new(peer_hash); + peer.begin_sync(); + assert_ne!(peer.state, PeerState::Idle); + router.peers.insert(peer_hash, peer); + + apply_peer_sync_terminal( + &mut router, + &bridge.local_node(), + &PeerSyncTerminalResult { + peer_hash, + state: PeerSyncTerminalState::Complete, + offer_generation: Some(5), + generation_exhausted: true, + }, + ); + let peer = router.peers.get(&peer_hash).expect("peer"); + assert_eq!(peer.state, PeerState::Idle); + assert!( + !peer.needs_offer_generation(5), + "exhausted generation must not remain due" + ); + assert!( + peer.needs_offer_generation(6), + "newer store generation must re-enable sync" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn apply_peer_sync_terminal_failed_returns_idle_without_marking_generation() { + use lxmf_core::constants::PeerState; + use lxmf_core::peer::LxmPeer; + use lxmf_core::router::RouterConfig; + + let dir = std::env::temp_dir().join(format!( + "mesh-prop-peer-terminal-fail-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + let (tx, _rx) = mpsc::channel(8); + let us = Identity::new(); + let bridge = PropagationBridge::new( + tx, + [0xab; 16], + dir.clone(), + &us, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); + + let peer_hash = [0x22u8; 16]; + let mut router = LxmRouter::new(RouterConfig::default()); + let mut peer = LxmPeer::new(peer_hash); + peer.begin_sync(); + router.peers.insert(peer_hash, peer); + + apply_peer_sync_terminal( + &mut router, + &bridge.local_node(), + &PeerSyncTerminalResult { + peer_hash, + state: PeerSyncTerminalState::Failed, + offer_generation: Some(3), + generation_exhausted: false, + }, + ); + let peer = router.peers.get(&peer_hash).expect("peer"); + assert_eq!(peer.state, PeerState::Idle); + assert!( + peer.needs_offer_generation(3), + "failed sync must leave generation retryable" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn apply_peer_handled_updates_merges_ids() { + use lxmf_core::peer::LxmPeer; + use lxmf_core::router::RouterConfig; + + let dir = + std::env::temp_dir().join(format!("mesh-prop-peer-handled-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + let (tx, _rx) = mpsc::channel(8); + let us = Identity::new(); + let bridge = PropagationBridge::new( + tx, + [0xab; 16], + dir.clone(), + &us, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); + + let peer_hash = [0x33u8; 16]; + let mut router = LxmRouter::new(RouterConfig::default()); + router.peers.insert(peer_hash, LxmPeer::new(peer_hash)); + let tid = [0xAAu8; 32]; + apply_peer_handled_updates(&mut router, &bridge.local_node(), peer_hash, &[tid]); + assert!( + router + .peers + .get(&peer_hash) + .expect("peer") + .handled_messages + .contains(&tid) + ); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn source_host_peer_sync_idle_gate_and_policy_start() { let live = include_str!("live.rs"); @@ -1382,6 +1609,12 @@ mod tests { && live.contains("get_post_peer"), "peer /offer Complete must sequence silent client /get" ); + assert!( + live.contains("HOST_PERIODIC_GET_INTERVAL") + && live.contains("next_host_periodic_get_target") + && live.contains("get_periodic"), + "Host serving must schedule periodic silent /get when idle" + ); assert!( live.contains("request_inbox_drain") && live.contains("take_inbox_drain_request") @@ -1398,6 +1631,14 @@ mod tests { bridge.contains("start_sync_with_policy"), "bridge must expose policy sync for host peer loop" ); + assert!( + bridge.contains("apply_peer_sync_terminal") + && bridge.contains("sync_complete") + && bridge.contains("mark_offer_generation_processed") + && bridge.contains("take_handled_updates") + && bridge.contains("save_peer"), + "host sync tick must apply lxmd peer terminal + handled updates" + ); // Inbox retrieval is the client `/get` download, driven from live.rs; the // bridge only logs peer-sync (`/offer`) outcomes, never inbox retrieval. assert!( @@ -1516,8 +1757,14 @@ mod tests { "peer terminal → silent /get (emit_ui false)" ); assert!( - live.contains("delivery_callback") && live.contains("get_post_peer"), - "/get Complete → delivery_callback (post-peer path)" + live.contains("delivery_callback") + && live.contains("get_post_peer") + && live.contains("get_periodic"), + "/get Complete → delivery_callback (post-peer + periodic paths)" + ); + assert!( + bridge.contains("apply_peer_sync_terminal") && bridge.contains("take_handled_updates"), + "peer terminal bookkeeping must stay wired" ); let sync_fn_start = live .find("pub async fn start_propagation_sync") From f759a331cbe191f4e15027300dbc3e33b511ce68 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 9 Aug 2026 15:52:46 -0600 Subject: [PATCH 4/4] fix: tighten PN sync busy errors and default retrieve logging Return PROPAGATION_RETRIEVE_BUSY when client /get is already active, map soft deferrals in the renderer, keep propagation-retrieve off INFO by default, and log auto-drain join failures. --- reticulum-sidecar/src/stack/live.rs | 17 ++++++++++++++--- src/main/reticulumSidecarStderrLog.ts | 6 ++++-- .../reticulum/reticulumPropagationSync.test.ts | 6 ++++++ .../lib/reticulum/reticulumPropagationSync.ts | 4 ++++ src/renderer/locales/cs/translation.json | 3 ++- src/renderer/locales/de/translation.json | 3 ++- src/renderer/locales/en/translation.json | 1 + src/renderer/locales/es/translation.json | 3 ++- src/renderer/locales/fr/translation.json | 3 ++- src/renderer/locales/id/translation.json | 3 ++- src/renderer/locales/it/translation.json | 3 ++- src/renderer/locales/ja/translation.json | 3 ++- src/renderer/locales/ko/translation.json | 3 ++- src/renderer/locales/nl/translation.json | 3 ++- src/renderer/locales/pl/translation.json | 3 ++- src/renderer/locales/pt-BR/translation.json | 3 ++- src/renderer/locales/ru/translation.json | 3 ++- src/renderer/locales/tr/translation.json | 3 ++- src/renderer/locales/uk/translation.json | 3 ++- src/renderer/locales/zh/translation.json | 3 ++- .../stores/reticulumPropagationStore.test.ts | 14 ++++++++++++++ .../stores/reticulumPropagationStore.ts | 1 + 22 files changed, 74 insertions(+), 20 deletions(-) diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 03871bff3..3b5b32406 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -2714,9 +2714,19 @@ impl LiveBridge { let router_for_drain = Arc::clone(&router); tokio::spawn(async move { let (messages, listed) = - tokio::task::spawn_blocking(move || bridge.drain_local_inbox()) + match tokio::task::spawn_blocking(move || bridge.drain_local_inbox()) .await - .unwrap_or_else(|_| (Vec::new(), 0)); + { + Ok(result) => result, + Err(error) => { + tracing::error!( + target: "propagation-retrieve", + error = %error, + "local-prop inbox auto-drain join failed" + ); + (Vec::new(), 0) + } + }; let delivered = messages.len(); if delivered > 0 { let router = router_for_drain.lock().await; @@ -3841,7 +3851,8 @@ impl LiveBridge { driver.clear_propagation_identity_pins(); driver.set_propagation_sync_target(None); } - return Err("propagation sync unavailable".into()); + // Client `/get` already in flight (host silent retrieve / prior Sync). + return Err("PROPAGATION_RETRIEVE_BUSY".into()); } Ok(()) } diff --git a/src/main/reticulumSidecarStderrLog.ts b/src/main/reticulumSidecarStderrLog.ts index fb369ba39..6719bbb24 100644 --- a/src/main/reticulumSidecarStderrLog.ts +++ b/src/main/reticulumSidecarStderrLog.ts @@ -7,9 +7,11 @@ const AUTO_BEACON_TX_FAILED_MARKER = 'auto: beacon TX failed'; const BEACON_FAIL_WARN_INTERVAL_MS = 60 * MS_PER_SECOND; /** Default tracing filter for sidecar child processes (overridable via env). */ -// PN connect triage: keep global warn, but surface remote sync/deposit INFO in mesh-client.log. +// PN connect triage: keep global warn; surface sync/deposit/outbound INFO. Keep +// propagation-retrieve at warn by default (inbound peer/message metadata); opt in +// via MESH_CLIENT_RUST_LOG (e.g. add propagation-retrieve=info). export const SIDECAR_DEFAULT_RUST_LOG = - 'warn,propagation-sync=info,propagation-retrieve=info,propagation-deposit=info,lxmf-outbound=info'; + 'warn,propagation-sync=info,propagation-deposit=info,lxmf-outbound=info'; /** * Whether a sidecar stdout line should be written to the app log. diff --git a/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts b/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts index 1d18c189a..c41d5b393 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts @@ -125,9 +125,15 @@ describe('reticulumPropagationSync', () => { expect(mapPropagationSyncError('PROPAGATION_SYNC_OUTBOUND_BUSY')).toBe( 'reticulumPropagation.syncOutboundBusy', ); + expect(mapPropagationSyncError('PROPAGATION_RETRIEVE_BUSY')).toBe( + 'reticulumPropagation.syncRetrieveBusy', + ); expect(mapPropagationSyncError('PROPAGATION_STACK_NOT_LIVE')).toBe( 'reticulumPropagation.syncStackNotLive', ); + expect(mapPropagationSyncError('RNS stack not live')).toBe( + 'reticulumPropagation.syncStackNotLive', + ); expect(mapPropagationSyncError('propagation sync cancelled')).toBe( 'reticulumPropagation.syncCancelled', ); diff --git a/src/renderer/lib/reticulum/reticulumPropagationSync.ts b/src/renderer/lib/reticulum/reticulumPropagationSync.ts index daad507fe..0c142d308 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationSync.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationSync.ts @@ -150,6 +150,10 @@ export function mapPropagationSyncError(error: string | null | undefined): strin if (error === 'PROPAGATION_SYNC_OUTBOUND_BUSY') { return 'reticulumPropagation.syncOutboundBusy'; } + // Client `/get` already active (host silent retrieve / overlapping Sync). + if (error === 'PROPAGATION_RETRIEVE_BUSY') { + return 'reticulumPropagation.syncRetrieveBusy'; + } // Live attach lag — startSync treats this as deferred; map if it surfaces elsewhere. if (error === 'PROPAGATION_STACK_NOT_LIVE' || error === 'RNS stack not live') { return 'reticulumPropagation.syncStackNotLive'; diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index d82045ec9..de2c00a63 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -4293,7 +4293,8 @@ "showChatNoticeAria": "Zobrazit banner připomenutí propagačního uzlu v chatu", "showChatNoticeHint": "Vypnutím tohoto tlačítka skryjete banner chatu, který se zobrazí, když není k dispozici žádný propagační uzel.", "syncPathUnknown": "Zatím neexistuje síťová cesta k tomuto propagačnímu uzlu. Zkontrolujte rozhraní nebo rozbočovač, oznamte to a zkuste to znovu.", - "syncStackNotLive": "Synchronizace propagace odložena — zásobník Reticulum se stále spouští." + "syncStackNotLive": "Synchronizace propagace odložena — zásobník Reticulum se stále spouští.", + "syncRetrieveBusy": "Synchronizace propagace odložena — vyhledávání doručené pošty již probíhá." }, "reticulumRmapDiscovery": { "sectionTitle": "Objevení RMAP v4", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 77b750017..75ac0beb5 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -4291,7 +4291,8 @@ "showChatNoticeAria": "Verbreitungsknoten-Erinnerungsbanner im Chat anzeigen", "showChatNoticeHint": "Deaktivieren Sie diese Option, um das Chat-Banner auszublenden, das angezeigt wird, wenn kein Ausbreitungsknoten verfügbar ist.", "syncPathUnknown": "Noch kein Netzwerkpfad zu diesem Ausbreitungsknoten. Überprüfen Sie Ihre Schnittstellen oder Ihren Hub, kündigen Sie an und versuchen Sie es dann erneut.", - "syncStackNotLive": "Ausbreitungssynchronisation verschoben — der Reticulum-Stack wird noch gestartet." + "syncStackNotLive": "Ausbreitungssynchronisation verschoben — der Reticulum-Stack wird noch gestartet.", + "syncRetrieveBusy": "Die Propagationssynchronisierung wurde verschoben — der Abruf des Posteingangs wird bereits ausgeführt." }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4 Entdeckung", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 1db0558d9..46e90d31e 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -4594,6 +4594,7 @@ "syncNoTarget": "No propagation node is available yet — none has been discovered, and you have no added nodes. Sync runs on its own as soon as one is available.", "syncLocalLoading": "The local propagation node is still loading its stored messages. Sync runs on its own once it finishes.", "syncOutboundBusy": "Propagation sync deferred — an outbound message is depositing to this node.", + "syncRetrieveBusy": "Propagation sync deferred — inbox retrieval is already in progress.", "syncStackNotLive": "Propagation sync deferred — the Reticulum stack is still starting.", "syncTimedOut": "Propagation sync timed out — the node may be unreachable.", "syncLocalNotSupported": "The local host propagation node cannot be synced over the network like a remote LXMF propagation node.", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 8b21da9ed..e3d034719 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -4291,7 +4291,8 @@ "showChatNoticeAria": "Mostrar el banner de recordatorio del nodo de propagación en el chat", "showChatNoticeHint": "Desactive esta opción para ocultar el banner de chat que aparece cuando no hay ningún nodo de propagación disponible.", "syncPathUnknown": "Aún no hay ruta de red a este nodo de propagación. Comprueba tus interfaces o hub, anuncia y vuelve a intentarlo.", - "syncStackNotLive": "Sincronización de propagación diferida: la pila de Reticulum todavía está comenzando." + "syncStackNotLive": "Sincronización de propagación diferida: la pila de Reticulum todavía está comenzando.", + "syncRetrieveBusy": "Sincronización de propagación diferida: la recuperación de la bandeja de entrada ya está en curso." }, "reticulumRmapDiscovery": { "sectionTitle": "Descubrimiento de RMAP v4", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 9b246e950..92b524d20 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -4291,7 +4291,8 @@ "showChatNoticeAria": "Afficher la bannière de rappel du nœud de propagation dans le chat", "showChatNoticeHint": "Désactivez cette option pour masquer la bannière de chat qui apparaît lorsqu'aucun nœud de propagation n'est disponible.", "syncPathUnknown": "Pas encore de chemin réseau vers ce nœud de propagation. Vérifiez vos interfaces ou votre hub, annoncez, puis réessayez.", - "syncStackNotLive": "Synchronisation de la propagation différée — la pile Reticulum est toujours en cours de démarrage." + "syncStackNotLive": "Synchronisation de la propagation différée — la pile Reticulum est toujours en cours de démarrage.", + "syncRetrieveBusy": "Synchronisation de la propagation différée — la récupération de la boîte de réception est déjà en cours." }, "reticulumRmapDiscovery": { "sectionTitle": "Découverte RMAP v4", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index a1eec0d05..102b2aa62 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -4291,7 +4291,8 @@ "showChatNoticeAria": "Tampilkan spanduk pengingat simpul propagasi di Obrolan", "showChatNoticeHint": "Nonaktifkan ini untuk menyembunyikan spanduk Obrolan yang muncul ketika tidak ada node propagasi yang tersedia.", "syncPathUnknown": "Belum ada jalur jaringan ke simpul propagasi ini. Periksa antarmuka atau hub Anda, umumkan, lalu coba lagi.", - "syncStackNotLive": "Sinkronisasi propagasi ditangguhkan — tumpukan Reticulum masih dimulai." + "syncStackNotLive": "Sinkronisasi propagasi ditangguhkan — tumpukan Reticulum masih dimulai.", + "syncRetrieveBusy": "Sinkronisasi propagasi ditangguhkan — pengambilan kotak masuk sudah berlangsung." }, "reticulumRmapDiscovery": { "sectionTitle": "Penemuan RMAP v4", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 6baa38cb2..4cd6d4114 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -4291,7 +4291,8 @@ "showChatNoticeAria": "Mostra il banner di promemoria del nodo di propagazione in Chat", "showChatNoticeHint": "Disattiva questa opzione per nascondere il banner della chat che appare quando non è disponibile alcun nodo di propagazione.", "syncPathUnknown": "Ancora nessun percorso di rete a questo nodo di propagazione. Controlla le interfacce o l'hub, annuncia, quindi riprova.", - "syncStackNotLive": "Sincronizzazione della propagazione posticipata — lo stack Reticulum è ancora in fase di avvio." + "syncStackNotLive": "Sincronizzazione della propagazione posticipata — lo stack Reticulum è ancora in fase di avvio.", + "syncRetrieveBusy": "Sincronizzazione della propagazione posticipata — il recupero della posta in arrivo è già in corso." }, "reticulumRmapDiscovery": { "sectionTitle": "Scoperta RMAP v4", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 6e18425f4..00f36db5f 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -4291,7 +4291,8 @@ "showChatNoticeAria": "チャットで伝播ノードのリマインダーバナーを表示する", "showChatNoticeHint": "これをオフにすると、伝播ノードが利用できないときに表示されるチャットバナーが非表示になります。", "syncPathUnknown": "この伝播ノードへのネットワークパスはまだありません。インターフェースまたはハブを確認し、お知らせしてから、もう一度お試しください。", - "syncStackNotLive": "伝播同期が延期されました—Reticulumスタックはまだ開始しています。" + "syncStackNotLive": "伝播同期が延期されました—Reticulumスタックはまだ開始しています。", + "syncRetrieveBusy": "伝播同期が延期されました—受信トレイの取得はすでに進行中です。" }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v 4検出", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index c9aba300f..bb6cbee59 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -4291,7 +4291,8 @@ "showChatNoticeAria": "Chat에 전파 노드 알림 배너 표시", "showChatNoticeHint": "전파 노드를 사용할 수 없을 때 나타나는 채팅 배너를 숨기려면 이 옵션을 끄십시오.", "syncPathUnknown": "이 전파 노드에 대한 네트워크 경로가 아직 없습니다. 인터페이스 또는 허브를 확인하고 공지한 다음 다시 시도하십시오.", - "syncStackNotLive": "전파 동기화 지연 — Reticulum 스택이 아직 시작 중입니다." + "syncStackNotLive": "전파 동기화 지연 — Reticulum 스택이 아직 시작 중입니다.", + "syncRetrieveBusy": "전파 동기화 연기됨 — 받은 편지함 검색이 이미 진행 중입니다." }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4 검색", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index f25bbdcc6..19ff2c307 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -4291,7 +4291,8 @@ "showChatNoticeAria": "Toon de herinneringsbanner voor het propagatieknooppunt in Chat", "showChatNoticeHint": "Schakel dit uit om de chatbanner te verbergen die verschijnt als er geen propagatieknooppunt beschikbaar is.", "syncPathUnknown": "Nog geen netwerkpad naar dit propagatieknooppunt. Controleer uw interfaces of hub, kondig aan en probeer het opnieuw.", - "syncStackNotLive": "Uitgestelde propagatiesynchronisatie — de Reticulum-stack begint nog steeds." + "syncStackNotLive": "Uitgestelde propagatiesynchronisatie — de Reticulum-stack begint nog steeds.", + "syncRetrieveBusy": "Voortplantingssynchronisatie uitgesteld — het ophalen van de inbox is al bezig." }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4-detectie", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index e9bb6245f..089d49fe3 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -4295,7 +4295,8 @@ "showChatNoticeAria": "Pokaż baner przypominający o węźle propagacji na czacie", "showChatNoticeHint": "Wyłącz tę opcję, aby ukryć baner czatu, który pojawia się, gdy żaden węzeł propagacji nie jest dostępny.", "syncPathUnknown": "Nie ma jeszcze ścieżki sieciowej do tego węzła propagacji. Sprawdź interfejsy lub koncentrator, ogłoś, a następnie spróbuj ponownie.", - "syncStackNotLive": "Odroczona synchronizacja propagacji — stos Reticulum nadal się rozpoczyna." + "syncStackNotLive": "Odroczona synchronizacja propagacji — stos Reticulum nadal się rozpoczyna.", + "syncRetrieveBusy": "Synchronizacja propagacji odroczona — trwa już pobieranie skrzynki odbiorczej." }, "reticulumRmapDiscovery": { "sectionTitle": "Wykrywanie RMAP v4", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 7f27f7d16..b701f4be6 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -4291,7 +4291,8 @@ "showChatNoticeAria": "Mostrar o banner de lembrete do nó de propagação no Chat", "showChatNoticeHint": "Desative isso para ocultar o banner de bate-papo que aparece quando nenhum nó de propagação está disponível.", "syncPathUnknown": "Nenhum caminho de rede para este nó de propagação ainda. Verifique suas interfaces ou hub, anuncie e tente novamente.", - "syncStackNotLive": "Sincronização de propagação adiada — a pilha Reticulum ainda está começando." + "syncStackNotLive": "Sincronização de propagação adiada — a pilha Reticulum ainda está começando.", + "syncRetrieveBusy": "Sincronização de propagação adiada — a recuperação da caixa de entrada já está em andamento." }, "reticulumRmapDiscovery": { "sectionTitle": "Descoberta do RMAP v4", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index f7adafb88..605f78a11 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -4293,7 +4293,8 @@ "showChatNoticeAria": "Показывать баннер с напоминанием об узле распространения в чате", "showChatNoticeHint": "Отключите этот параметр, чтобы скрыть баннер чата, который появляется, когда узел распространения недоступен.", "syncPathUnknown": "Нет сетевого пути к этому узлу распространения. Проверьте интерфейсы или концентратор, объявите и повторите попытку.", - "syncStackNotLive": "Синхронизация распространения отложена — стек Reticulum все еще запускается." + "syncStackNotLive": "Синхронизация распространения отложена — стек Reticulum все еще запускается.", + "syncRetrieveBusy": "Синхронизация распространения отложена — извлечение входящих сообщений уже выполняется." }, "reticulumRmapDiscovery": { "sectionTitle": "Обнаружение RMAP v4", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index fd29bc586..7609defdb 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -4291,7 +4291,8 @@ "showChatNoticeAria": "Yayılma düğümü hatırlatma banner'ını Sohbet'te göster", "showChatNoticeHint": "Yayılma düğümü bulunmadığında görünen Sohbet başlığını gizlemek için bunu kapatın.", "syncPathUnknown": "Bu yayılım düğümüne henüz bir ağ yolu yok. Arayüzlerinizi veya hub'ınızı kontrol edin, anons yapın ve tekrar deneyin.", - "syncStackNotLive": "Yayılma senkronizasyonu ertelendi — Reticulum yığını hala başlıyor." + "syncStackNotLive": "Yayılma senkronizasyonu ertelendi — Reticulum yığını hala başlıyor.", + "syncRetrieveBusy": "Yayılma senkronizasyonu ertelendi — gelen kutusu alma işlemi zaten devam ediyor." }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4 keşfi", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index eb2f90cdd..78ce292d5 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -4293,7 +4293,8 @@ "showChatNoticeAria": "Показати банер нагадування про вузол поширення в чаті", "showChatNoticeHint": "Вимкніть, щоб приховати банер чату, який з'являється, коли вузол поширення недоступний.", "syncPathUnknown": "Ще немає мережевого шляху до цього вузла поширення. Перевірте інтерфейси або концентратор, оголосіть, а потім повторіть спробу.", - "syncStackNotLive": "Синхронізація поширення відкладена — стек Reticulum все ще запускається." + "syncStackNotLive": "Синхронізація поширення відкладена — стек Reticulum все ще запускається.", + "syncRetrieveBusy": "Синхронізація поширення відкладена — пошук у папці \"Вхідні\" вже виконується." }, "reticulumRmapDiscovery": { "sectionTitle": "Виявлення RMAP v4", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 5ffbbf311..33c952f7e 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -4291,7 +4291,8 @@ "showChatNoticeAria": "在聊天中显示传播节点提醒横幅", "showChatNoticeHint": "关闭此选项可隐藏在没有可用传播节点时显示的聊天横幅。", "syncPathUnknown": "还没有到此传播节点的网络路径。请检查您的接口或集线器,宣布,然后重试。", - "syncStackNotLive": "传播同步延迟—Reticulum 堆栈仍在启动。" + "syncStackNotLive": "传播同步延迟—Reticulum 堆栈仍在启动。", + "syncRetrieveBusy": "传播同步已延迟—收件箱检索已在进行中。" }, "reticulumRmapDiscovery": { "sectionTitle": "RMAP v4发现", diff --git a/src/renderer/stores/reticulumPropagationStore.test.ts b/src/renderer/stores/reticulumPropagationStore.test.ts index bc06858ff..060c32b89 100644 --- a/src/renderer/stores/reticulumPropagationStore.test.ts +++ b/src/renderer/stores/reticulumPropagationStore.test.ts @@ -406,6 +406,20 @@ describe('reticulumPropagationStore', () => { expect(useReticulumPropagationStore.getState().activePropagationSyncAttemptAt).toBeNull(); }); + it.each([ + 'PROPAGATION_STACK_NOT_LIVE', + 'RNS stack not live', + 'PROPAGATION_RETRIEVE_BUSY', + ] as const)('startSync soft-defers %s without a lastSyncError', async (error) => { + getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); + useReticulumPropagationStore.setState({ preferredId: 'pn-vegas' }); + proxyPost.mockResolvedValueOnce({ ok: false, error }); + await expect(useReticulumPropagationStore.getState().startSync()).resolves.toBe('deferred'); + expect(useReticulumPropagationStore.getState().sync.active).toBe(false); + expect(useReticulumPropagationStore.getState().lastSyncError).toBeNull(); + expect(useReticulumPropagationStore.getState().activePropagationSyncAttemptAt).toBeNull(); + }); + it('removePropagationNode deletes then refreshes', async () => { getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); proxyDelete.mockResolvedValueOnce({ ok: true }); diff --git a/src/renderer/stores/reticulumPropagationStore.ts b/src/renderer/stores/reticulumPropagationStore.ts index 33087d516..e89172040 100644 --- a/src/renderer/stores/reticulumPropagationStore.ts +++ b/src/renderer/stores/reticulumPropagationStore.ts @@ -401,6 +401,7 @@ export const useReticulumPropagationStore = create