From 6c95e47b66abbf80466cfe2d581e89f174c2bbdd Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 30 Jul 2026 18:54:49 -0600 Subject: [PATCH 1/4] fix(reticulum): stop announce WS storms from dropping inbound LXMF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coalesce announce.received to ≤1 frame per window at 100k scale, merge-safe connect DB refresh, and add watermarked periodic catch-up plus diagnostics. --- docs/reticulum-sidecar-ipc.md | 4 +- reticulum-sidecar/src/api/lxmf.rs | 3 +- .../src/stack/announce_ws_coalesce.rs | 210 ++++++++++++++++++ reticulum-sidecar/src/stack/live.rs | 113 +++++++--- reticulum-sidecar/src/stack/lxmf_delivery.rs | 22 +- .../src/stack/lxmf_inbound_log.rs | 4 + reticulum-sidecar/src/stack/mod.rs | 5 + .../lib/reticulum/catchUpInboundLxmf.test.ts | 79 +++++-- .../reticulum/fetchRecentInboundLxmf.test.ts | 17 +- .../lib/reticulum/fetchRecentInboundLxmf.ts | 33 ++- .../reticulum/reticulumDiagnosticSnapshot.ts | 12 + .../reticulumInboundLxmfDiagnostics.test.ts | 31 +++ .../reticulumInboundLxmfDiagnostics.ts | 62 ++++++ src/renderer/runtime/useReticulumRuntime.ts | 147 ++++++++---- src/renderer/stores/messageStore.test.ts | 16 ++ src/renderer/stores/messageStore.ts | 30 +++ .../reticulumIdentityActivityStore.test.ts | 11 + .../stores/reticulumIdentityActivityStore.ts | 19 +- .../stores/reticulumPeerStore.test.ts | 24 ++ src/renderer/stores/reticulumPeerStore.ts | 34 ++- 20 files changed, 767 insertions(+), 109 deletions(-) create mode 100644 reticulum-sidecar/src/stack/announce_ws_coalesce.rs create mode 100644 src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.test.ts create mode 100644 src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.ts diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index b1748a4c7..9ee73ebbf 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -71,7 +71,7 @@ The Connection tab UI edits a subset: **name** and **mode** for all types; **hos | ------ | ------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | POST | `/api/v1/lxmf/send` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` | Live: stamps LXMF `FIELD_REPLY_TO` (0x30) / optional `FIELD_REPLY_QUOTE` (0x31) before sign; `{ ok, delivery_method?, delivery_status?, sent_via?, message? }` or `{ ok: false, error: "no_propagation_node" }`. **`delivery_status` on this response is initial enqueue state only** (`queued` or `sending`) — not delivery confirmation. Stub: `{ ok, sent_via?, message? }` | | POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | `{ ok, message? }` | -| GET | `/api/v1/lxmf/recent` | `?since_ts=` (ms, optional), `?limit=` (default 200, max 500) | `{ messages: [] }` — ring buffer of recent **inbound** LXMF payloads for WS lag/reconnect catch-up (not durable across sidecar restart; capped at 200) | +| GET | `/api/v1/lxmf/recent` | `?since_ts=` (ms, optional), `?limit=` (default 200, max 500) | `{ messages: [], ring_len }` — ring buffer of recent **inbound** LXMF payloads for WS lag/reconnect catch-up (not durable across sidecar restart; capped at 200); `ring_len` is current buffer occupancy | | DELETE | `/api/v1/lxmf/messages/{hash}` | | `{ ok }` | | GET | `/api/v1/contacts` | | `{ contacts: [] }` — overlays announce/peer/Nomad labels onto nameless or hash-prefix contact `display_name` values (does not overwrite a real name) and may persist fills | | DELETE | `/api/v1/contacts` | | `{ ok, cleared }` — clears LXMF contacts after demoting them into the peer cache (keeps Peers; does not delete chat messages) | @@ -227,7 +227,7 @@ Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscri - **`rrc.disconnected`:** payload `{ hub_dest_hash, reason, will_reconnect? }`. When `will_reconnect` is `false` (or `reason` is `local_disconnect`), the renderer drops that hub session. When `true` (or omitted on older sidecars), the UI shows reconnecting and keeps volatile rooms until WELCOME. - **`lxmf_outbound_status`:** authoritative outbound delivery updates. Payload: `{ message_hash, status, delivery_method?, to_hash?, sent_via? }` where `status` is `delivered`, `failed`, or intermediate `sending` (egress upgrade or Direct→PN fallback). mesh-client maps `delivered` → UI Completes (`acked`) and persists `delivery_status` (+ `delivery_method` when present) to SQLite; Propagated Completes show **Stored at propagation node**; `failed` → Failed. Do **not** treat `/api/v1/lxmf/send` response `delivery_status` (`queued`/`sending`) as terminal. After Direct failure with a preferred remote PN, the sidecar re-queues once as Propagated and emits `sending` + `delivery_method: "propagated"` before a final `delivered`/`failed`. -- **`announce.received`:** emitted for every LXMF identity announce / path response the sidecar observes (named or nameless). Payload: `{ destination_hash, display_name?, hops }`. Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. +- **`announce.received`:** coalesced WS notify for LXMF identity announces / path responses (named or nameless). Sidecar applies identity-key + display-name cache updates immediately, but emits **at most one** WS frame per coalesce window (500ms normal / 1000ms when >256 distinct destinations are pending) so announce storms stay O(1) bus pressure on large meshes (~100k). Payload is either a single `{ destination_hash, display_name?, hops }` (legacy / one-row flush) or `{ announces: [{ destination_hash, display_name?, hops }, ...] }` (capped at 1024, named preferred; overflow dropped — slow peer poll recovers). Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. - **`peers_updated`:** also emitted when the live path table **gains** new destination hashes (maintenance tick). Payload may include `{ added: string[], patches: PeerRow[], count }` (added/patches capped at 1024). Renderer applies patches incrementally, including route-field changes. A full peer dump is used on connect, manual Refresh, restart, safety poll, or a `peers_updated` payload that cannot be applied incrementally: `cleared`, `demoted_from_contacts`, or a single-`hash` probe/path event. Hop/timestamp-only churn does not emit. `lxmf_message` payload fields include `sender_hash`, `text`, `timestamp`, `message_hash`, optional `direction` (`inbound` / `outbound`), optional `delivery_status` (`sending` on optimistic outbound rows), optional `reply_to_hash` / `reply_preview_text` (from LXMF `FIELD_REPLY_TO` / `FIELD_REPLY_QUOTE`), and transport markers `received_via` / `sent_via`. Outbound `sent_via` is **path-table / PacketTap evidence**, not “any local RNode enabled”: atomic values are `rf`, `ble`, `tcp`, or `network`; multi-egress observes join with `+` (e.g. `rf+tcp`, `ble+network`). Inbound `received_via` uses the path-table interface name **matched to local interface config** (same atoms — so a TCP hub named “RNS Testnet” is `tcp`, not `network`). Never use Meshtastic-style `both` for Reticulum. diff --git a/reticulum-sidecar/src/api/lxmf.rs b/reticulum-sidecar/src/api/lxmf.rs index 2eb3907cd..aa126f0db 100644 --- a/reticulum-sidecar/src/api/lxmf.rs +++ b/reticulum-sidecar/src/api/lxmf.rs @@ -143,5 +143,6 @@ pub async fn list_recent_lxmf( ) -> Json { let limit = q.limit.unwrap_or(200).clamp(1, 500); let messages = stack.list_recent_inbound_lxmf(q.since_ts, limit); - Json(serde_json::json!({ "messages": messages })) + let ring_len = stack.inbound_lxmf_ring_len(); + Json(serde_json::json!({ "messages": messages, "ring_len": ring_len })) } diff --git a/reticulum-sidecar/src/stack/announce_ws_coalesce.rs b/reticulum-sidecar/src/stack/announce_ws_coalesce.rs new file mode 100644 index 000000000..c90b81a63 --- /dev/null +++ b/reticulum-sidecar/src/stack/announce_ws_coalesce.rs @@ -0,0 +1,210 @@ +//! Coalesce `announce.received` WS frames so announce storms stay O(1) bus pressure +//! (≤1 frame per flush window) even at ~100k path-table scale. + +use std::collections::HashMap; +use std::time::Duration; + +/// Normal flush window — mirror renderer peer-refresh coalesce (~400ms) with a small buffer. +pub const ANNOUNCE_WS_COALESCE_MS: u64 = 500; +/// Widen under storm when many distinct destinations are pending in the window. +pub const ANNOUNCE_WS_STORM_COALESCE_MS: u64 = 1000; +/// Pending distinct destinations that trigger the storm flush window. +pub const ANNOUNCE_WS_STORM_PENDING: usize = 256; +/// Max announces included in one WS frame (named preferred); overflow dropped. +pub const ANNOUNCE_WS_FLUSH_MAX: usize = 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnnounceWsRow { + pub destination_hash: String, + pub display_name: Option, + pub hops: u8, +} + +/// Pending announces keyed by destination hash (last write wins). +#[derive(Debug, Default)] +pub struct AnnounceWsCoalescer { + pending: HashMap, +} + +impl AnnounceWsCoalescer { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.pending.is_empty() + } + + /// Insert or replace the row for this destination. + pub fn push(&mut self, row: AnnounceWsRow) { + self.pending.insert(row.destination_hash.clone(), row); + } + + /// Flush window based on current pending size (storm widens the timer). + pub fn coalesce_duration(&self) -> Duration { + if self.pending.len() > ANNOUNCE_WS_STORM_PENDING { + Duration::from_millis(ANNOUNCE_WS_STORM_COALESCE_MS) + } else { + Duration::from_millis(ANNOUNCE_WS_COALESCE_MS) + } + } + + /// Drain pending into a capped list (named first), newest-map order otherwise. + pub fn take_flush_rows(&mut self) -> Vec { + if self.pending.is_empty() { + return Vec::new(); + } + let mut named = Vec::new(); + let mut nameless = Vec::new(); + for (_, row) in self.pending.drain() { + if row + .display_name + .as_ref() + .is_some_and(|n| !n.trim().is_empty()) + { + named.push(row); + } else { + nameless.push(row); + } + } + // Stable-ish: named first (prefer keeping labels), then nameless. + named.extend(nameless); + if named.len() > ANNOUNCE_WS_FLUSH_MAX { + named.truncate(ANNOUNCE_WS_FLUSH_MAX); + } + named + } +} + +/// Build the WS text frame for one flush. Single-row keeps the legacy payload shape. +pub fn build_announce_received_frame(rows: &[AnnounceWsRow]) -> Option { + if rows.is_empty() { + return None; + } + let payload = if rows.len() == 1 { + let r = &rows[0]; + serde_json::json!({ + "destination_hash": r.destination_hash, + "display_name": r.display_name, + "hops": r.hops, + }) + } else { + let announces: Vec = rows + .iter() + .map(|r| { + serde_json::json!({ + "destination_hash": r.destination_hash, + "display_name": r.display_name, + "hops": r.hops, + }) + }) + .collect(); + serde_json::json!({ "announces": announces }) + }; + Some( + serde_json::json!({ + "type": "announce.received", + "payload": payload, + }) + .to_string(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(hash: &str, name: Option<&str>) -> AnnounceWsRow { + AnnounceWsRow { + destination_hash: hash.to_string(), + display_name: name.map(str::to_string), + hops: 1, + } + } + + #[test] + fn last_write_wins_per_destination() { + let mut c = AnnounceWsCoalescer::new(); + c.push(row("aa", Some("Old"))); + c.push(row("aa", Some("New"))); + c.push(row("bb", None)); + let flushed = c.take_flush_rows(); + assert!(c.is_empty()); + assert_eq!(flushed.len(), 2); + let aa = flushed + .iter() + .find(|r| r.destination_hash == "aa") + .expect("aa"); + assert_eq!(aa.display_name.as_deref(), Some("New")); + } + + #[test] + fn flush_prefers_named_when_over_cap() { + let mut c = AnnounceWsCoalescer::new(); + for i in 0..(ANNOUNCE_WS_FLUSH_MAX + 50) { + c.push(row(&format!("{i:032x}"), None)); + } + for i in 0..10 { + c.push(row(&format!("n{i:030x}"), Some(&format!("Peer{i}")))); + } + let flushed = c.take_flush_rows(); + assert_eq!(flushed.len(), ANNOUNCE_WS_FLUSH_MAX); + let named = flushed + .iter() + .filter(|r| { + r.display_name + .as_ref() + .is_some_and(|n| n.starts_with("Peer")) + }) + .count(); + assert_eq!(named, 10); + } + + #[test] + fn storm_widens_coalesce_duration() { + let mut c = AnnounceWsCoalescer::new(); + for i in 0..=ANNOUNCE_WS_STORM_PENDING { + c.push(row(&format!("{i:032x}"), None)); + } + assert_eq!( + c.coalesce_duration(), + Duration::from_millis(ANNOUNCE_WS_STORM_COALESCE_MS) + ); + let mut small = AnnounceWsCoalescer::new(); + small.push(row("aa", None)); + assert_eq!( + small.coalesce_duration(), + Duration::from_millis(ANNOUNCE_WS_COALESCE_MS) + ); + } + + #[test] + fn build_frame_single_keeps_legacy_shape() { + let frame = build_announce_received_frame(&[row("aa", Some("Alice"))]).unwrap(); + let v: serde_json::Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(v["type"], "announce.received"); + assert_eq!(v["payload"]["destination_hash"], "aa"); + assert!(v["payload"].get("announces").is_none()); + } + + #[test] + fn build_frame_many_uses_announces_array() { + let frame = + build_announce_received_frame(&[row("aa", Some("A")), row("bb", None)]).unwrap(); + let v: serde_json::Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(v["payload"]["announces"].as_array().unwrap().len(), 2); + } + + #[test] + fn many_distinct_dests_still_one_flush_batch() { + let mut c = AnnounceWsCoalescer::new(); + for i in 0..5000 { + c.push(row(&format!("{i:032x}"), None)); + } + // One take_flush_rows call = one WS frame worth of rows (capped). + let flushed = c.take_flush_rows(); + assert_eq!(flushed.len(), ANNOUNCE_WS_FLUSH_MAX); + assert!(c.is_empty()); + assert!(build_announce_received_frame(&flushed).is_some()); + } +} diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 28fa8618c..1c6828079 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -31,6 +31,9 @@ use rns_transport::messages::{ use tokio::sync::{RwLock, broadcast}; use super::StackHandle; +use super::announce_ws_coalesce::{ + AnnounceWsCoalescer, AnnounceWsRow, build_announce_received_frame, +}; use super::config; use super::local_rnode_primary; use super::lxmf_delivery::{ @@ -335,14 +338,12 @@ impl LiveBridge { ); // Buffer before WS emit so lag/reconnect can catch up via GET /api/v1/lxmf/recent. inbound_lxmf_cb.push(payload.clone()); - tracing::info!( - from = %sender_hex, - message_hash = %payload - .get("message_hash") - .and_then(|v| v.as_str()) - .unwrap_or(""), - "inbound LXMF queued for clients" - ); + let message_hash = payload + .get("message_hash") + .and_then(|v| v.as_str()) + .unwrap_or(""); + // Rate-limited warn so developer bundles can prove sidecar receipt without spam. + rate_limited_inbound_lxmf_warn(&sender_hex, message_hash); let inner = inner_for_cb.clone(); let config_dir = config_dir_for_cb.clone(); let storage_dir = storage_dir_for_cb.clone(); @@ -1714,31 +1715,57 @@ impl LiveBridge { return; } - while let Some(evt) = callback_rx.recv().await { - let dest_hex = hex::encode(evt.destination_hash); - if let Some(pub_key) = evt.public_key { - if let Ok(mut driver) = outbound.lock() { - driver.register_identity_key(&dest_hex, pub_key); + // Coalesce WS emits: ≤1 frame per flush window (O(1) bus), even under + // 100k-scale announce storms. Side effects (keys / name cache) stay immediate. + let mut coalescer = AnnounceWsCoalescer::new(); + let mut window_start: Option = None; + loop { + let flush_deadline = + window_start.map(|start| start + coalescer.coalesce_duration()); + tokio::select! { + evt = callback_rx.recv() => { + let Some(evt) = evt else { break; }; + let dest_hex = hex::encode(evt.destination_hash); + if let Some(pub_key) = evt.public_key { + if let Ok(mut driver) = outbound.lock() { + driver.register_identity_key(&dest_hex, pub_key); + } + } + // Named announces update the display-name cache for peer labels only — + // do not upsert LXMF contacts (contacts are messaged / explicitly saved). + let display_name = parse_announce_display_name(evt.app_data.as_deref()); + if let Some(ref name) = display_name { + if let Ok(mut cache) = display_name_cache.lock() { + insert_display_name_bounded(&mut cache, dest_hex.clone(), name.clone()); + } + } + if coalescer.is_empty() { + window_start = Some(tokio::time::Instant::now()); + } + coalescer.push(AnnounceWsRow { + destination_hash: dest_hex, + display_name, + hops: evt.hops, + }); } - } - // Named announces update the display-name cache for peer labels only — - // do not upsert LXMF contacts (contacts are messaged / explicitly saved). - let display_name = parse_announce_display_name(evt.app_data.as_deref()); - if let Some(ref name) = display_name { - if let Ok(mut cache) = display_name_cache.lock() { - insert_display_name_bounded(&mut cache, dest_hex.clone(), name.clone()); + () = async { + match flush_deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => std::future::pending::<()>().await, + } + }, if flush_deadline.is_some() => { + let rows = coalescer.take_flush_rows(); + window_start = None; + if let Some(frame) = build_announce_received_frame(&rows) { + let _ = event_tx.send(frame); + } } } - // Always notify the UI so nameless announces still refresh Peers promptly. - let frame = serde_json::json!({ - "type": "announce.received", - "payload": { - "destination_hash": dest_hex, - "display_name": display_name, - "hops": evt.hops, - } - }); - let _ = event_tx.send(frame.to_string()); + } + // Drain any leftover pending on handler exit. + let rows = coalescer.take_flush_rows(); + if let Some(frame) = build_announce_received_frame(&rows) { + let _ = event_tx.send(frame); } }); } @@ -3346,6 +3373,32 @@ fn parse_optional_reply_to_hash(hex_str: Option<&str>) -> Option<[u8; 32]> { } } +/// Min interval between inbound-LXMF receipt warns (developer-bundle visibility without spam). +const INBOUND_LXMF_WARN_INTERVAL: Duration = Duration::from_secs(5); +static LAST_INBOUND_LXMF_WARN_MS: AtomicU64 = AtomicU64::new(0); + +fn rate_limited_inbound_lxmf_warn(from: &str, message_hash: &str) { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let prev = LAST_INBOUND_LXMF_WARN_MS.load(Ordering::Relaxed); + if now_ms.saturating_sub(prev) < INBOUND_LXMF_WARN_INTERVAL.as_millis() as u64 { + tracing::debug!( + from = %from, + message_hash = %message_hash, + "inbound LXMF queued for clients" + ); + return; + } + LAST_INBOUND_LXMF_WARN_MS.store(now_ms, Ordering::Relaxed); + tracing::warn!( + from = %from, + message_hash = %message_hash, + "inbound LXMF queued for clients" + ); +} + /// Cap membership growth event payloads under path-table floods. const MAX_PEERS_UPDATED_ADDED: usize = 1024; /// Bound announce / contact display-name labels independently of the live path table. diff --git a/reticulum-sidecar/src/stack/lxmf_delivery.rs b/reticulum-sidecar/src/stack/lxmf_delivery.rs index a04acb378..b2b70c537 100644 --- a/reticulum-sidecar/src/stack/lxmf_delivery.rs +++ b/reticulum-sidecar/src/stack/lxmf_delivery.rs @@ -1,6 +1,7 @@ //! LXMF delivery destination announce + inbound link receive (Ratspeak/lxmd parity). use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -26,6 +27,23 @@ pub const LXMF_APP: &str = "lxmf.delivery"; /// before LinkRequest (matches the effective delay of “Announce now, then Sync”). pub const PROPAGATION_SYNC_ANNOUNCE_SETTLE: Duration = Duration::from_secs(2); +const UNPACK_WARN_INTERVAL: Duration = Duration::from_secs(5); +static LAST_UNPACK_WARN_MS: AtomicU64 = AtomicU64::new(0); + +fn rate_limited_unpack_warn(error: &str, len: usize) { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let prev = LAST_UNPACK_WARN_MS.load(Ordering::Relaxed); + if now_ms.saturating_sub(prev) < UNPACK_WARN_INTERVAL.as_millis() as u64 { + tracing::debug!(error = %error, len, "link data not an LXMF message"); + return; + } + LAST_UNPACK_WARN_MS.store(now_ms, Ordering::Relaxed); + tracing::warn!(error = %error, len, "link data not an LXMF message"); +} + fn mark_announce_sent(last_at: &Arc>>) { if let Ok(mut slot) = last_at.lock() { *slot = Some(Instant::now()); @@ -223,11 +241,11 @@ async fn handle_link_delivered_data( let msg = match LxMessage::unpack(&unpack_data) { Ok(msg) => msg, Err(e) => { - tracing::debug!("link data not an LXMF message: {e}"); + rate_limited_unpack_warn(&e.to_string(), unpack_data.len()); return; } }; - tracing::info!( + tracing::debug!( from = %hex::encode(msg.source_hash), len = msg.content.len(), "inbound LXMF message via link" diff --git a/reticulum-sidecar/src/stack/lxmf_inbound_log.rs b/reticulum-sidecar/src/stack/lxmf_inbound_log.rs index a3fe56e6d..87541f2c3 100644 --- a/reticulum-sidecar/src/stack/lxmf_inbound_log.rs +++ b/reticulum-sidecar/src/stack/lxmf_inbound_log.rs @@ -44,6 +44,10 @@ impl LxmfInboundBuffer { buf.push_back(payload); } + pub fn len(&self) -> usize { + self.inner.lock().map(|buf| buf.len()).unwrap_or(0) + } + /// Snapshot newest-first filtered by optional `since_ts` (inclusive, ms), then reverse to /// chronological order for ingest catch-up. pub fn snapshot(&self, since_ts: Option, limit: usize) -> Vec { diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 1118c8624..4ce2e51ca 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -1,5 +1,6 @@ //! Persistent stack state + optional live RNS/LXMF bridge. +mod announce_ws_coalesce; mod ble; pub mod config; pub mod config_audit; @@ -275,6 +276,10 @@ impl StackHandle { self.inbound_lxmf.snapshot(since_ts, limit) } + pub fn inbound_lxmf_ring_len(&self) -> usize { + self.inbound_lxmf.len() + } + async fn sync_interfaces_from_config(&self) { if let Ok(ifaces) = config::interfaces_from_config_dir(&self.config_dir) { let mut inner = self.inner.write().await; diff --git a/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts b/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts index 01809ed0d..878087620 100644 --- a/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts @@ -1,21 +1,31 @@ // @vitest-environment jsdom /** * Runtime catch-up after WS lag / reconnect / stack restart: - * useReticulumRuntime → fetchRecentInboundLxmf → ingest (dedupe by message hash). + * useReticulumRuntime → fetchRecentInboundLxmfDetailed → ingest (dedupe by message hash). */ import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ingestReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest'; import { OFFLINE_RETICULUM_IDENTITY_ID } from '@/renderer/lib/offlineProtocolIdentities'; -import { fetchRecentInboundLxmf } from '@/renderer/lib/reticulum/fetchRecentInboundLxmf'; +import { fetchRecentInboundLxmfDetailed } from '@/renderer/lib/reticulum/fetchRecentInboundLxmf'; +import { + getReticulumInboundLxmfDiagnostics, + resetReticulumInboundLxmfDiagnosticsForTests, +} from '@/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics'; import { resetReticulumManualStackStopSuppressForTests } from '@/renderer/lib/reticulum/reticulumManualStackStopSuppress'; import { useReticulumRuntime } from '@/renderer/runtime/useReticulumRuntime'; -import { useMessageStore } from '@/renderer/stores/messageStore'; +import { + addMessage, + mergeMessageRecordsFromDbForIdentity, + type MessageRecord, + useMessageStore, +} from '@/renderer/stores/messageStore'; import type { ReticulumSidecarEvent } from '@/shared/reticulum-types'; vi.mock('@/renderer/lib/reticulum/fetchRecentInboundLxmf', () => ({ fetchRecentInboundLxmf: vi.fn(), + fetchRecentInboundLxmfDetailed: vi.fn(), })); vi.mock('@/renderer/lib/reticulum/useReticulumNobleBleYieldWatcher', () => ({ @@ -26,12 +36,12 @@ vi.mock('@/renderer/lib/reticulum/useReticulumPropagationAutoSync', () => ({ useReticulumPropagationAutoSync: () => {}, })); -function sampleInbound(hash: string, text: string) { +function sampleInbound(hash: string, text: string, timestamp = 1_000) { return { sender_hash: 'e16af7d675a0ae7f3067185800a46678', sender_name: 'Runr02', text, - timestamp: 1_000, + timestamp, direction: 'inbound' as const, message_hash: hash, received_via: 'tcp', @@ -41,13 +51,16 @@ function sampleInbound(hash: string, text: string) { describe('useReticulumRuntime inbound LXMF catch-up', () => { const identityId = OFFLINE_RETICULUM_IDENTITY_ID; let eventHandler: ((evt: ReticulumSidecarEvent) => void) | null = null; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); beforeEach(() => { useMessageStore.setState({ messages: {} }); resetReticulumManualStackStopSuppressForTests(); + resetReticulumInboundLxmfDiagnosticsForTests(); eventHandler = null; - vi.mocked(fetchRecentInboundLxmf).mockReset(); - vi.mocked(fetchRecentInboundLxmf).mockResolvedValue([]); + warnSpy.mockClear(); + vi.mocked(fetchRecentInboundLxmfDetailed).mockReset(); + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ messages: [], ringLen: 0 }); vi.mocked(window.electronAPI.reticulum.onEvent).mockImplementation((cb) => { eventHandler = cb; return () => { @@ -75,7 +88,10 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { it('connect catch-up ingests buffered inbound that never arrived live', async () => { const hash = 'ab'.repeat(32); - vi.mocked(fetchRecentInboundLxmf).mockResolvedValue([sampleInbound(hash, 'Test back 1')]); + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ + messages: [sampleInbound(hash, 'Test back 1')], + ringLen: 1, + }); const { result, unmount } = renderHook(() => useReticulumRuntime()); await act(async () => { @@ -85,7 +101,8 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { await waitFor(() => { expect(useMessageStore.getState().messages[identityId][hash].payload).toBe('Test back 1'); }); - expect(fetchRecentInboundLxmf).toHaveBeenCalledWith({ limit: 200 }); + expect(fetchRecentInboundLxmfDetailed).toHaveBeenCalledWith({ limit: 200 }); + expect(getReticulumInboundLxmfDiagnostics().lastInboundCatchUpCount).toBe(1); unmount(); }); @@ -94,7 +111,10 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { const payload = sampleInbound(hash, 'already live'); expect(ingestReticulumLxmfPayload(identityId, payload)).toBe(true); - vi.mocked(fetchRecentInboundLxmf).mockResolvedValue([payload]); + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ + messages: [payload], + ringLen: 1, + }); const { result, unmount } = renderHook(() => useReticulumRuntime()); await act(async () => { @@ -103,7 +123,7 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { expect(eventHandler).toBeTruthy(); const onEvent = eventHandler!; - const callsAfterConnect = vi.mocked(fetchRecentInboundLxmf).mock.calls.length; + const callsAfterConnect = vi.mocked(fetchRecentInboundLxmfDetailed).mock.calls.length; act(() => { onEvent({ type: 'events_lagged', payload: { skipped: 12 } }); @@ -117,9 +137,13 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { }); await waitFor(() => { - expect(vi.mocked(fetchRecentInboundLxmf).mock.calls.length).toBe(callsAfterConnect + 2); + expect(vi.mocked(fetchRecentInboundLxmfDetailed).mock.calls.length).toBe( + callsAfterConnect + 2, + ); }); + expect(getReticulumInboundLxmfDiagnostics().lastEventsLaggedSkipped).toBe(12); + const matches = Object.values(useMessageStore.getState().messages[identityId] ?? {}).filter( (m) => m.payload === 'already live', ); @@ -129,14 +153,17 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { it('sidecar restartStack catch-up ingests missed inbound', async () => { const hash = 'ef'.repeat(32); - vi.mocked(fetchRecentInboundLxmf).mockResolvedValue([]); + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ messages: [], ringLen: 0 }); const { result, unmount } = renderHook(() => useReticulumRuntime()); await act(async () => { await result.current.connect(); }); - vi.mocked(fetchRecentInboundLxmf).mockResolvedValue([sampleInbound(hash, 'after restart')]); + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ + messages: [sampleInbound(hash, 'after restart')], + ringLen: 1, + }); const restartStack = result.current.restartStack; if (!restartStack) { throw new Error('expected restartStack on Reticulum runtime'); @@ -150,4 +177,28 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { }); unmount(); }); + + it('mergeMessageRecordsFromDbForIdentity preserves live rows absent from DB snapshot', () => { + const live: MessageRecord = { + id: 'live-hash', + from: 1, + to: 0, + payload: 'from WS', + channelIndex: 0, + timestamp: 2_000, + }; + const fromDb: MessageRecord = { + id: 'db-hash', + from: 2, + to: 0, + payload: 'from DB', + channelIndex: 0, + timestamp: 1_000, + }; + addMessage(identityId, live); + mergeMessageRecordsFromDbForIdentity(identityId, [fromDb]); + const bucket = useMessageStore.getState().messages[identityId]; + expect(bucket['live-hash'].payload).toBe('from WS'); + expect(bucket['db-hash'].payload).toBe('from DB'); + }); }); diff --git a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts index b6645c557..e0cda35a4 100644 --- a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts @@ -10,11 +10,19 @@ vi.stubGlobal('window', { }, }); -import { fetchRecentInboundLxmf } from './fetchRecentInboundLxmf'; +import { fetchRecentInboundLxmf, fetchRecentInboundLxmfDetailed } from './fetchRecentInboundLxmf'; +import { + getReticulumInboundLxmfDiagnostics, + resetReticulumInboundLxmfDiagnosticsForTests, +} from './reticulumInboundLxmfDiagnostics'; describe('fetchRecentInboundLxmf', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + beforeEach(() => { proxyGet.mockReset(); + warnSpy.mockClear(); + resetReticulumInboundLxmfDiagnosticsForTests(); }); it('returns inbound rows from sidecar recent API', async () => { @@ -34,16 +42,21 @@ describe('fetchRecentInboundLxmf', () => { }, { sender_hash: 'dd'.repeat(16) }, ], + ring_len: 3, }); const rows = await fetchRecentInboundLxmf({ sinceTs: 500, limit: 50 }); expect(proxyGet).toHaveBeenCalledWith('/api/v1/lxmf/recent?since_ts=500&limit=50'); expect(rows).toHaveLength(1); expect(rows[0]?.text).toBe('hello'); + expect(getReticulumInboundLxmfDiagnostics().lastInboundRingLen).toBe(3); }); - it('returns empty array on proxy failure', async () => { + it('returns empty array and warns on proxy failure', async () => { proxyGet.mockRejectedValue(new Error('offline')); await expect(fetchRecentInboundLxmf()).resolves.toEqual([]); + expect(warnSpy).toHaveBeenCalled(); + const detailed = await fetchRecentInboundLxmfDetailed(); + expect(detailed).toEqual({ messages: [], ringLen: null }); }); }); diff --git a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts index f7ab6ccfc..7d5978e32 100644 --- a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts +++ b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts @@ -1,5 +1,6 @@ import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import type { ReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest'; +import { noteReticulumInboundRingLen } from '@/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics'; export interface FetchRecentInboundLxmfOpts { /** Inclusive lower bound on payload timestamp (ms). */ @@ -7,6 +8,11 @@ export interface FetchRecentInboundLxmfOpts { limit?: number; } +export interface FetchRecentInboundLxmfResult { + messages: ReticulumLxmfPayload[]; + ringLen: number | null; +} + /** * Fetch recent inbound LXMF payloads from the sidecar ring buffer * (`GET /api/v1/lxmf/recent`) for WS lag / reconnect catch-up. @@ -14,6 +20,14 @@ export interface FetchRecentInboundLxmfOpts { export async function fetchRecentInboundLxmf( opts: FetchRecentInboundLxmfOpts = {}, ): Promise { + const result = await fetchRecentInboundLxmfDetailed(opts); + return result.messages; +} + +/** Same as {@link fetchRecentInboundLxmf} but also returns ring size when present. */ +export async function fetchRecentInboundLxmfDetailed( + opts: FetchRecentInboundLxmfOpts = {}, +): Promise { const params = new URLSearchParams(); if (opts.sinceTs != null && Number.isFinite(opts.sinceTs)) { params.set('since_ts', String(Math.floor(opts.sinceTs))); @@ -26,12 +40,23 @@ export async function fetchRecentInboundLxmf( try { const body = (await window.electronAPI.reticulum.proxyGet(path)) as { messages?: unknown; + ring_len?: unknown; + }; + const ringLen = + typeof body.ring_len === 'number' && Number.isFinite(body.ring_len) + ? Math.trunc(body.ring_len) + : null; + noteReticulumInboundRingLen(ringLen); + if (!Array.isArray(body.messages)) { + return { messages: [], ringLen }; + } + return { + messages: body.messages.filter(isInboundLxmfPayload), + ringLen, }; - if (!Array.isArray(body.messages)) return []; - return body.messages.filter(isInboundLxmfPayload); } catch (e) { - console.debug('[fetchRecentInboundLxmf] ' + errLikeToLogString(e)); - return []; + console.warn('[fetchRecentInboundLxmf] ' + errLikeToLogString(e)); + return { messages: [], ringLen: null }; } } diff --git a/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.ts b/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.ts index 8f7641fcd..f96bb9f7c 100644 --- a/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.ts +++ b/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.ts @@ -5,6 +5,7 @@ import { isReticulumDiagnosticRow } from '../diagnostics/ReticulumDiagnosticEngi import { errLikeToLogString } from '../errLikeToLogString'; import type { DiagnosticRow } from '../types'; import type { ReticulumConfigAuditIssue } from './reticulumConfigAudit'; +import { getReticulumInboundLxmfDiagnostics } from './reticulumInboundLxmfDiagnostics'; const RETICULUM_PROXY_ROUTES = [ '/api/v1/status', @@ -49,6 +50,15 @@ export interface ReticulumDiagnosticSidecarSnapshot { stack: ReticulumStackDiagnosticPayload | null; diagnosticRows: DiagnosticRow[]; fetchErrors: Partial>; + /** Inbound LXMF catch-up / WS lag counters (renderer process-local). */ + inboundLxmf?: { + lastEventsLaggedAt: number | null; + lastEventsLaggedSkipped: number | null; + lastInboundCatchUpAt: number | null; + lastInboundCatchUpCount: number | null; + inboundCatchUpWatermarkTs: number | null; + lastInboundRingLen: number | null; + }; } function selectReticulumDiagnosticRows(): DiagnosticRow[] { @@ -109,6 +119,7 @@ export function buildReticulumDiagnosticSnapshotSync(): ReticulumDiagnosticSidec stack: null, diagnosticRows: selectReticulumDiagnosticRows(), fetchErrors: {}, + inboundLxmf: getReticulumInboundLxmfDiagnostics(), }; } @@ -152,5 +163,6 @@ export async function fetchReticulumDiagnosticSnapshot(): Promise { + beforeEach(() => { + resetReticulumInboundLxmfDiagnosticsForTests(); + }); + + it('records lag, catch-up, watermark, and ring len', () => { + noteReticulumEventsLagged(7); + noteReticulumInboundCatchUp(3); + advanceReticulumInboundCatchUpWatermark(1_000); + advanceReticulumInboundCatchUpWatermark(500); + noteReticulumInboundRingLen(12); + const snap = getReticulumInboundLxmfDiagnostics(); + expect(snap.lastEventsLaggedSkipped).toBe(7); + expect(snap.lastInboundCatchUpCount).toBe(3); + expect(snap.inboundCatchUpWatermarkTs).toBe(1_000); + expect(snap.lastInboundRingLen).toBe(12); + expect(snap.lastEventsLaggedAt).toEqual(expect.any(Number)); + expect(snap.lastInboundCatchUpAt).toEqual(expect.any(Number)); + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.ts b/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.ts new file mode 100644 index 000000000..374773b37 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.ts @@ -0,0 +1,62 @@ +/** + * Process-local diagnostics for inbound LXMF catch-up / WS lag (debug snapshot + logs). + * Not persisted; cleared only in tests. + */ + +export interface ReticulumInboundLxmfDiagnosticsSnapshot { + lastEventsLaggedAt: number | null; + lastEventsLaggedSkipped: number | null; + lastInboundCatchUpAt: number | null; + lastInboundCatchUpCount: number | null; + /** Inclusive watermark (ms) for periodic `since_ts` catch-up. */ + inboundCatchUpWatermarkTs: number | null; + lastInboundRingLen: number | null; +} + +const state: ReticulumInboundLxmfDiagnosticsSnapshot = { + lastEventsLaggedAt: null, + lastEventsLaggedSkipped: null, + lastInboundCatchUpAt: null, + lastInboundCatchUpCount: null, + inboundCatchUpWatermarkTs: null, + lastInboundRingLen: null, +}; + +export function getReticulumInboundLxmfDiagnostics(): ReticulumInboundLxmfDiagnosticsSnapshot { + return { ...state }; +} + +export function noteReticulumEventsLagged(skipped: number | undefined): void { + state.lastEventsLaggedAt = Date.now(); + state.lastEventsLaggedSkipped = + typeof skipped === 'number' && Number.isFinite(skipped) ? Math.trunc(skipped) : null; +} + +export function noteReticulumInboundCatchUp(count: number): void { + state.lastInboundCatchUpAt = Date.now(); + state.lastInboundCatchUpCount = count; +} + +export function advanceReticulumInboundCatchUpWatermark(timestampMs: number): void { + if (!Number.isFinite(timestampMs)) return; + const ts = Math.floor(timestampMs); + if (state.inboundCatchUpWatermarkTs == null || ts > state.inboundCatchUpWatermarkTs) { + state.inboundCatchUpWatermarkTs = ts; + } +} + +export function noteReticulumInboundRingLen(len: number | null | undefined): void { + if (typeof len === 'number' && Number.isFinite(len) && len >= 0) { + state.lastInboundRingLen = Math.trunc(len); + } +} + +/** Test helper. */ +export function resetReticulumInboundLxmfDiagnosticsForTests(): void { + state.lastEventsLaggedAt = null; + state.lastEventsLaggedSkipped = null; + state.lastInboundCatchUpAt = null; + state.lastInboundCatchUpCount = null; + state.inboundCatchUpWatermarkTs = null; + state.lastInboundRingLen = null; +} diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 981064e5c..ec76b5f6c 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -37,7 +37,7 @@ import { resolveReticulumDestinationHash, reticulumHashToNodeId, } from '@/renderer/lib/reticulum/destHash'; -import { fetchRecentInboundLxmf } from '@/renderer/lib/reticulum/fetchRecentInboundLxmf'; +import { fetchRecentInboundLxmfDetailed } from '@/renderer/lib/reticulum/fetchRecentInboundLxmf'; import { extractLxmfPayloadFromSendResponse } from '@/renderer/lib/reticulum/lxmfSendResponse'; import { markStaleReticulumOutboundInStore, @@ -46,6 +46,12 @@ import { } from '@/renderer/lib/reticulum/markStaleReticulumOutbound'; import { cacheReticulumInboundAttachment } from '@/renderer/lib/reticulum/reticulumAttachmentCache'; import { fetchReticulumConfigAudit } from '@/renderer/lib/reticulum/reticulumConfigAudit'; +import { + advanceReticulumInboundCatchUpWatermark, + getReticulumInboundLxmfDiagnostics, + noteReticulumEventsLagged, + noteReticulumInboundCatchUp, +} from '@/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics'; import { logReticulumInterfaceStateEvent, logReticulumLocalInterfaceHealthChanges, @@ -127,6 +133,7 @@ import { setConnection, useConnectionStore } from '../stores/connectionStore'; import { useDiagnosticsStore } from '../stores/diagnosticsStore'; import { useIdentityStore } from '../stores/identityStore'; import { + mergeMessageRecordsFromDbForIdentity, renameMessageId, replaceMessageRecordsForIdentity, updateMessageStatus, @@ -166,6 +173,10 @@ import type { ProtocolRuntime } from './protocolRuntime'; /** Safety poll interval when the path table is large. */ const RETICULUM_PEER_REFRESH_LARGE_MS = 60_000; +/** Periodic inbound LXMF ring catch-up on large meshes (O(1) HTTP poll). */ +const RETICULUM_INBOUND_LXMF_CATCHUP_LARGE_MS = 15_000; +/** Periodic inbound LXMF ring catch-up on smaller meshes. */ +const RETICULUM_INBOUND_LXMF_CATCHUP_MS = 60_000; const INITIAL_STATE: DeviceState = { status: 'disconnected', @@ -597,39 +608,70 @@ export function useReticulumRuntime(): ProtocolRuntime { [identityId, selfLxmfHash], ); - const catchUpRecentInboundLxmf = useCallback(async () => { - if (!identityId) return; - const rows = await fetchRecentInboundLxmf({ limit: 200 }); - if (rows.length === 0) return; - console.debug(`[useReticulumRuntime] inbound LXMF catch-up count=${rows.length}`); - for (const p of rows) { - ingestLxmfPayload(p); - } - }, [identityId, ingestLxmfPayload]); + const catchUpRecentInboundLxmf = useCallback( + async (opts?: { sinceTs?: number; reason?: string }) => { + if (!identityId) return; + const { messages: rows } = await fetchRecentInboundLxmfDetailed({ + limit: 200, + ...(opts?.sinceTs != null ? { sinceTs: opts.sinceTs } : {}), + }); + if (rows.length === 0) return; + const reason = opts?.reason ?? 'catch-up'; + console.warn( + `[useReticulumRuntime] inbound LXMF catch-up count=${rows.length} reason=${reason}`, + ); + noteReticulumInboundCatchUp(rows.length); + let maxTs = opts?.sinceTs ?? 0; + for (const p of rows) { + ingestLxmfPayload(p); + if ( + typeof p.timestamp === 'number' && + Number.isFinite(p.timestamp) && + p.timestamp > maxTs + ) { + maxTs = p.timestamp; + } + } + if (maxTs > 0) { + advanceReticulumInboundCatchUpWatermark(maxTs); + } + }, + [identityId, ingestLxmfPayload], + ); + + const loadMessagesFromDb = useCallback( + async (mode: 'replace' | 'merge') => { + if (!identityId) return; + try { + const rows = (await window.electronAPI.db.getReticulumMessages(identityId, 500)) as { + sender_id: string; + sender_name?: string; + payload: string; + timestamp: number; + to_hash?: string | null; + reply_to_hash?: string | null; + message_hash?: string | null; + received_via?: string | null; + delivery_status?: string | null; + attachment_path?: string | null; + }[]; + const records = rows.map((row) => reticulumDbRowToMessageRecord(row)); + if (mode === 'merge') { + mergeMessageRecordsFromDbForIdentity(identityId, records); + } else { + replaceMessageRecordsForIdentity(identityId, records); + } + } catch (e) { + console.warn('[useReticulumRuntime] refresh messages ' + errLikeToLogString(e)); + } + }, + [identityId], + ); + /** Full replace — prune / manual reload. Connect uses merge via loadMessagesFromDb. */ const refreshMessagesFromDb = useCallback(async () => { - if (!identityId) return; - try { - const rows = (await window.electronAPI.db.getReticulumMessages(identityId, 500)) as { - sender_id: string; - sender_name?: string; - payload: string; - timestamp: number; - to_hash?: string | null; - reply_to_hash?: string | null; - message_hash?: string | null; - received_via?: string | null; - delivery_status?: string | null; - attachment_path?: string | null; - }[]; - replaceMessageRecordsForIdentity( - identityId, - rows.map((row) => reticulumDbRowToMessageRecord(row)), - ); - } catch (e) { - console.warn('[useReticulumRuntime] refresh messages ' + errLikeToLogString(e)); - } - }, [identityId]); + await loadMessagesFromDb('replace'); + }, [loadMessagesFromDb]); const recordAnnounceActivity = useCallback((payload: unknown, defaultAspect?: string) => { const rows = parseAnnounceActivityRows(payload); @@ -668,16 +710,17 @@ export function useReticulumRuntime(): ProtocolRuntime { evt.payload && typeof evt.payload === 'object' ? (evt.payload as { skipped?: number }).skipped : undefined; + noteReticulumEventsLagged(skipped); console.warn( `[useReticulumRuntime] sidecar WS lagged skipped=${skipped ?? '?'} — catching up inbound LXMF`, ); - void catchUpRecentInboundLxmf(); + void catchUpRecentInboundLxmf({ reason: 'events_lagged' }); } if (evt.type === 'ws_connected' && evt.payload && typeof evt.payload === 'object') { const reconnect = (evt.payload as { reconnect?: boolean }).reconnect === true; if (reconnect) { console.debug('[useReticulumRuntime] sidecar WS reconnected — catching up inbound LXMF'); - void catchUpRecentInboundLxmf(); + void catchUpRecentInboundLxmf({ reason: 'ws_reconnect' }); } } if (evt.type === 'lxmf_outbound_status' && evt.payload && typeof evt.payload === 'object') { @@ -1307,10 +1350,11 @@ export function useReticulumRuntime(): ProtocolRuntime { if (identityId) { await markStaleReticulumOutboundMessages(identityId, RETICULUM_STALE_OUTBOUND_MS); markStaleReticulumOutboundInStore(identityId, RETICULUM_STALE_OUTBOUND_MS); - await refreshMessagesFromDb(); + // Merge — do not wipe live WS ingest whose fire-and-forget DB persist is still in flight. + await loadMessagesFromDb('merge'); } // Catch up any inbound LXMF that arrived while WS was lagging or before subscribe. - await catchUpRecentInboundLxmf(); + await catchUpRecentInboundLxmf({ reason: 'connect' }); if (resumeGenerationRef.current !== generation) { // A later power-suspend fired while this connect attempt was still in flight — the // sidecar keeps running (no RF link to go stale), but a fresher resume/suspend cycle @@ -1348,7 +1392,7 @@ export function useReticulumRuntime(): ProtocolRuntime { refreshContactsFromSidecar, refreshIdentityFromSidecar, refreshLocalInterfacesFromSidecar, - refreshMessagesFromDb, + loadMessagesFromDb, syncDiagnosticsFromSidecar, hydrateRawPackets, catchUpRecentInboundLxmf, @@ -1413,9 +1457,9 @@ export function useReticulumRuntime(): ProtocolRuntime { if (identityId) { await markStaleReticulumOutboundMessages(identityId, RETICULUM_STALE_OUTBOUND_MS); markStaleReticulumOutboundInStore(identityId, RETICULUM_STALE_OUTBOUND_MS); - await refreshMessagesFromDb(); + await loadMessagesFromDb('merge'); } - await catchUpRecentInboundLxmf(); + await catchUpRecentInboundLxmf({ reason: 'restartStack' }); setState({ status: 'configured', myNodeNum: connectedNodeId, connectionType: null }); syncConnectionStore({ status: 'configured', @@ -1444,7 +1488,7 @@ export function useReticulumRuntime(): ProtocolRuntime { refreshContactsFromSidecar, refreshIdentityFromSidecar, refreshLocalInterfacesFromSidecar, - refreshMessagesFromDb, + loadMessagesFromDb, syncConnectionStore, syncDiagnosticsFromSidecar, hydrateRawPackets, @@ -1483,6 +1527,29 @@ export function useReticulumRuntime(): ProtocolRuntime { }; }, [state.status, refreshContactsFromSidecar, refreshSelfNodeDisplayNameFromSidecar]); + /** Watermarked ring catch-up — safety net when WS lag notices are missed (O(1) work). */ + useEffect(() => { + if (state.status !== 'configured' && state.status !== 'connected' && state.status !== 'stale') { + return; + } + let timeoutId: ReturnType | null = null; + const scheduleNext = () => { + const ms = + useReticulumPeerStore.getState().peers.size > LARGE_MESH_NODE_THRESHOLD + ? RETICULUM_INBOUND_LXMF_CATCHUP_LARGE_MS + : RETICULUM_INBOUND_LXMF_CATCHUP_MS; + timeoutId = setTimeout(() => { + const sinceTs = getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs ?? undefined; + void catchUpRecentInboundLxmf({ sinceTs, reason: 'periodic' }); + scheduleNext(); + }, ms); + }; + scheduleNext(); + return () => { + if (timeoutId != null) clearTimeout(timeoutId); + }; + }, [state.status, catchUpRecentInboundLxmf]); + /** Keep nodeStore longName in sync when Network panel updates identity display_name. */ useEffect(() => { return useReticulumIdentityStore.subscribe((identityState, prev) => { diff --git a/src/renderer/stores/messageStore.test.ts b/src/renderer/stores/messageStore.test.ts index 348e43613..4d5515139 100644 --- a/src/renderer/stores/messageStore.test.ts +++ b/src/renderer/stores/messageStore.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { addMessage, + mergeMessageRecordsFromDbForIdentity, type MessageRecord, pruneMessageRecordsForIdentityByChannel, renameMessageId, @@ -70,6 +71,21 @@ describe('messageStore replace and prune', () => { expect(bucket?.new?.from).toBe(2); }); + it('mergeMessageRecordsFromDbForIdentity keeps live rows missing from DB', () => { + addMessage(ID_A, sampleRecord('live', 1)); + mergeMessageRecordsFromDbForIdentity(ID_A, [sampleRecord('db', 2)]); + const bucket = useMessageStore.getState().messages[ID_A]; + expect(bucket?.live?.from).toBe(1); + expect(bucket?.db?.from).toBe(2); + }); + + it('mergeMessageRecordsFromDbForIdentity lets DB win on id collision', () => { + addMessage(ID_A, { ...sampleRecord('same', 1), payload: 'live' }); + mergeMessageRecordsFromDbForIdentity(ID_A, [{ ...sampleRecord('same', 9), payload: 'db' }]); + expect(useMessageStore.getState().messages[ID_A]?.same?.payload).toBe('db'); + expect(useMessageStore.getState().messages[ID_A]?.same?.from).toBe(9); + }); + it('pruneMessageRecordsForIdentityByChannel removes one channel slice', () => { addMessage(ID_A, sampleRecord('ch0', 1)); addMessage(ID_A, { ...sampleRecord('ch1', 1), id: 'ch1', channelIndex: 1 }); diff --git a/src/renderer/stores/messageStore.ts b/src/renderer/stores/messageStore.ts index fd35d6ce2..1f0037528 100644 --- a/src/renderer/stores/messageStore.ts +++ b/src/renderer/stores/messageStore.ts @@ -210,6 +210,36 @@ export function replaceMessageRecordsForIdentity( }); } +/** + * Union DB snapshot into the in-memory bucket without dropping live rows that are + * not yet persisted (connect-time race with fire-and-forget SQLite writes). + * DB rows win on id collision when fields differ. + */ +export function mergeMessageRecordsFromDbForIdentity( + identityId: IdentityId, + records: MessageRecord[], +): void { + useMessageStore.setState((s) => { + const prior = s.messages[identityId] ?? {}; + const byIdentity: Record = { ...prior }; + let changed = false; + for (const message of records) { + const existing = byIdentity[message.id]; + if (!existing) { + byIdentity[message.id] = message; + changed = true; + continue; + } + if (!messageRecordFieldsEqual(existing, message)) { + byIdentity[message.id] = message; + changed = true; + } + } + if (!changed) return s; + return mergeIdentityMessages(s, identityId, byIdentity); + }); +} + /** Remove all store messages matching a cleared SQLite channel index. */ export function pruneMessageRecordsForIdentityByChannel( identityId: IdentityId, diff --git a/src/renderer/stores/reticulumIdentityActivityStore.test.ts b/src/renderer/stores/reticulumIdentityActivityStore.test.ts index 7bf654aeb..d0f99270a 100644 --- a/src/renderer/stores/reticulumIdentityActivityStore.test.ts +++ b/src/renderer/stores/reticulumIdentityActivityStore.test.ts @@ -28,4 +28,15 @@ describe('parseAnnounceActivityRows', () => { }); expect(rows.map((r) => r.aspect)).toEqual(['nomadnetwork.node', 'lxmf.delivery']); }); + + it('parses batched announces array payload', () => { + const rows = parseAnnounceActivityRows({ + announces: [ + { destination_hash: 'aaa', hops: 1 }, + { destination_hash: 'bbb', display_name: 'Bob', hops: 2 }, + ], + }); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.destination_hash)).toEqual(['aaa', 'bbb']); + }); }); diff --git a/src/renderer/stores/reticulumIdentityActivityStore.ts b/src/renderer/stores/reticulumIdentityActivityStore.ts index 02e18f088..695362024 100644 --- a/src/renderer/stores/reticulumIdentityActivityStore.ts +++ b/src/renderer/stores/reticulumIdentityActivityStore.ts @@ -125,9 +125,7 @@ export const useReticulumIdentityActivityStore = create; +function parseOneAnnounceActivityRow(p: Record): ReticulumIdentityActivityRow[] { const destinationHash = typeof p.destination_hash === 'string' ? p.destination_hash @@ -161,3 +159,18 @@ export function parseAnnounceActivityRows(payload: unknown): ReticulumIdentityAc hops, })); } + +/** Parse single-legacy or batched `{ announces: [...] }` announce.received payloads. */ +export function parseAnnounceActivityRows(payload: unknown): ReticulumIdentityActivityRow[] { + if (!payload || typeof payload !== 'object') return []; + const p = payload as Record; + if (Array.isArray(p.announces)) { + const out: ReticulumIdentityActivityRow[] = []; + for (const row of p.announces) { + if (!row || typeof row !== 'object') continue; + out.push(...parseOneAnnounceActivityRow(row as Record)); + } + return out; + } + return parseOneAnnounceActivityRow(p); +} diff --git a/src/renderer/stores/reticulumPeerStore.test.ts b/src/renderer/stores/reticulumPeerStore.test.ts index 298b242e4..0d63772e7 100644 --- a/src/renderer/stores/reticulumPeerStore.test.ts +++ b/src/renderer/stores/reticulumPeerStore.test.ts @@ -643,6 +643,30 @@ describe('reticulumPeerStore', () => { expect(peer?.display_name).toBeNull(); }); + it('applyReticulumAnnounceReceivedOptimistic applies batched announces array', () => { + applyReticulumAnnounceReceivedOptimistic({ + announces: [ + { + destination_hash: 'AaBbCcDdEeFf00112233445566778899', + display_name: 'Batch A', + hops: 1, + }, + { + destination_hash: '11223344556677889900aabbccddeeff', + display_name: 'Batch B', + hops: 2, + }, + ], + }); + applyReticulumPeerPatchesNow([]); + expect( + useReticulumPeerStore.getState().peers.get('aabbccddeeff00112233445566778899')?.display_name, + ).toBe('Batch A'); + expect( + useReticulumPeerStore.getState().peers.get('11223344556677889900aabbccddeeff')?.display_name, + ).toBe('Batch B'); + }); + it('refresh preserves announce alias when path-table peer omits display_name', async () => { const hash = 'aabbccddeeff00112233445566778899'; applyReticulumAnnounceReceivedOptimistic({ diff --git a/src/renderer/stores/reticulumPeerStore.ts b/src/renderer/stores/reticulumPeerStore.ts index 651c3caac..02420e52a 100644 --- a/src/renderer/stores/reticulumPeerStore.ts +++ b/src/renderer/stores/reticulumPeerStore.ts @@ -805,20 +805,32 @@ export function reticulumHashForNodeId(nodeId: number): string | null { export const RETICULUM_PEER_REFRESH_MS = 30_000; -/** Optimistic Peers-tab row from an `announce.received` WS payload (batched patch). */ +/** Optimistic Peers-tab row(s) from an `announce.received` WS payload (single or batched). */ export function applyReticulumAnnounceReceivedOptimistic(payload: unknown): void { if (!payload || typeof payload !== 'object') return; const p = payload as Record; - const peer = peerFromWirePatch({ - ...p, - last_seen: typeof p.last_seen === 'number' ? p.last_seen : Date.now(), - }); - if (!peer) return; - bufferReticulumPeerPatches([peer]); - registerReticulumDestinationHash( - reticulumHashToNodeId(peer.destination_hash), - peer.destination_hash, - ); + const rows: unknown[] = Array.isArray(p.announces) ? p.announces : [p]; + const now = Date.now(); + const peers: ReticulumPeer[] = []; + for (const row of rows) { + if (!row || typeof row !== 'object') continue; + const peer = peerFromWirePatch({ + ...(row as Record), + last_seen: + typeof (row as { last_seen?: unknown }).last_seen === 'number' + ? (row as { last_seen: number }).last_seen + : now, + }); + if (!peer) continue; + peers.push(peer); + registerReticulumDestinationHash( + reticulumHashToNodeId(peer.destination_hash), + peer.destination_hash, + ); + } + if (peers.length > 0) { + bufferReticulumPeerPatches(peers); + } } function appearancesFromDbRows( From f221c096589e1bc4eec3805af79310d9ba7daf38 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 30 Jul 2026 18:59:24 -0600 Subject: [PATCH 2/4] feat(diagnostics): warn when announce storms pressure LXMF delivery Expose sidecar announce_ws coalesce counters and emit reticulum/announce-bus-pressure so large-mesh users see why Chat may lag while catch-up runs. --- docs/diagnostics.md | 11 +- docs/reticulum-sidecar-ipc.md | 22 +-- .../src/stack/announce_ws_coalesce.rs | 83 +++++++++++- reticulum-sidecar/src/stack/mod.rs | 8 ++ .../ReticulumDiagnosticEngine.test.ts | 128 ++++++++++++++++++ .../diagnostics/ReticulumDiagnosticEngine.ts | 76 +++++++++++ src/renderer/locales/cs/translation.json | 3 +- src/renderer/locales/de/translation.json | 3 +- src/renderer/locales/en/translation.json | 3 +- 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 +- src/renderer/runtime/useReticulumRuntime.ts | 1 + 23 files changed, 341 insertions(+), 36 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 2b3b91fe1..a85040206 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -433,11 +433,12 @@ Runtime interface-issue rows from the sidecar latch (`interfaceIssueAlert`) are Additional runtime rows (refreshed from sidecar status + `reticulumPropagationStore`, not only the config audit poll): -| Condition | Trigger | Severity | Action | -| ------------------------------------ | --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------- | -| `reticulum/sidecar-unhealthy` | Sidecar `running && healthy === false` for ≥ 60 s (`sidecarUnhealthySince`) | error | **Restart stack** | -| `reticulum/propagation-sync-stuck` | Sync active ≥ ~45 s (`RETICULUM_PROPAGATION_SYNC_STALL_MS`) with progress still Establishing (< 15) | warning | Retry sync; check PN path / announce | -| `reticulum/propagation-sync-failing` | Sync idle with `lastSyncError` (excludes user cancel) and attempt within 1 h | warning | See Network → Propagation / troubleshooting | +| Condition | Trigger | Severity | Action | +| ------------------------------------ | --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------- | +| `reticulum/sidecar-unhealthy` | Sidecar `running && healthy === false` for ≥ 60 s (`sidecarUnhealthySince`) | error | **Restart stack** | +| `reticulum/announce-bus-pressure` | Recent WS `events_lagged` (skipped ≥ 8) **or** sidecar `announce_ws` storm/overflow within 5 min | warning | Informational — catch-up active; not a broken radio | +| `reticulum/propagation-sync-stuck` | Sync active ≥ ~45 s (`RETICULUM_PROPAGATION_SYNC_STALL_MS`) with progress still Establishing (< 15) | warning | Retry sync; check PN path / announce | +| `reticulum/propagation-sync-failing` | Sync idle with `lastSyncError` (excludes user cancel) and attempt within 1 h | warning | See Network → Propagation / troubleshooting | | Issue kind | Typical cause | In-app action | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 9ee73ebbf..1ad77a901 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -201,16 +201,16 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi ### System -| Method | Path | Body / notes | Response | -| ------ | ------------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| GET | `/api/v1/diagnostics` | | Reticulum-native health snapshot | -| POST | `/api/v1/system/factory-reset` | | `{ ok }` — Electron UI must call `electronAPI.reticulum.factoryReset` (generic `proxyPost` blocks this path) | -| GET | `/api/v1/voice/status` | | LXST stub status | -| GET | `/api/v1/games/status` | | LRGP stub status | -| GET | `/api/v1/identities` | | `{ identities: […] }` — slots under `config/identities//` + `active_identity`; working key remains `config/identity`; flat identity migrates to `identities/default/` | -| POST | `/api/v1/identities` | `{ display_name? }` | `{ ok, id, identity }` or `{ ok: false, error }` — `rns-stack` only; stage slot → apply working key → commit pointer last (rollback on failure). Cap 16 slots. Errors: `identity_slot_limit_reached`, `display_name_*`. Emits restart. | -| POST | `/api/v1/identities/switch` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — stash, install target → working, reconcile, then commit pointer. Errors: `identity_slot_not_configured`, `identity_not_found` | -| POST | `/api/v1/identities/delete` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — refuses `cannot_delete_active_identity` / `cannot_delete_last_identity` | +| Method | Path | Body / notes | Response | +| ------ | ------------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/api/v1/diagnostics` | | Reticulum-native health snapshot — includes `announce_ws` coalesce pressure (`last_window_ingress` / `unique` / `overflow`, `last_storm_at_ms`, `last_flush_at_ms`). Renderer Diagnostics emits `reticulum/announce-bus-pressure` when lag/storm/overflow is recent. | +| POST | `/api/v1/system/factory-reset` | | `{ ok }` — Electron UI must call `electronAPI.reticulum.factoryReset` (generic `proxyPost` blocks this path) | +| GET | `/api/v1/voice/status` | | LXST stub status | +| GET | `/api/v1/games/status` | | LRGP stub status | +| GET | `/api/v1/identities` | | `{ identities: […] }` — slots under `config/identities//` + `active_identity`; working key remains `config/identity`; flat identity migrates to `identities/default/` | +| POST | `/api/v1/identities` | `{ display_name? }` | `{ ok, id, identity }` or `{ ok: false, error }` — `rns-stack` only; stage slot → apply working key → commit pointer last (rollback on failure). Cap 16 slots. Errors: `identity_slot_limit_reached`, `display_name_*`. Emits restart. | +| POST | `/api/v1/identities/switch` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — stash, install target → working, reconcile, then commit pointer. Errors: `identity_slot_not_configured`, `identity_not_found` | +| POST | `/api/v1/identities/delete` | `{ identity_id }` | `{ ok }` or `{ ok: false, error }` — refuses `cannot_delete_active_identity` / `cannot_delete_last_identity` | ## WebSocket @@ -227,7 +227,7 @@ Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscri - **`rrc.disconnected`:** payload `{ hub_dest_hash, reason, will_reconnect? }`. When `will_reconnect` is `false` (or `reason` is `local_disconnect`), the renderer drops that hub session. When `true` (or omitted on older sidecars), the UI shows reconnecting and keeps volatile rooms until WELCOME. - **`lxmf_outbound_status`:** authoritative outbound delivery updates. Payload: `{ message_hash, status, delivery_method?, to_hash?, sent_via? }` where `status` is `delivered`, `failed`, or intermediate `sending` (egress upgrade or Direct→PN fallback). mesh-client maps `delivered` → UI Completes (`acked`) and persists `delivery_status` (+ `delivery_method` when present) to SQLite; Propagated Completes show **Stored at propagation node**; `failed` → Failed. Do **not** treat `/api/v1/lxmf/send` response `delivery_status` (`queued`/`sending`) as terminal. After Direct failure with a preferred remote PN, the sidecar re-queues once as Propagated and emits `sending` + `delivery_method: "propagated"` before a final `delivered`/`failed`. -- **`announce.received`:** coalesced WS notify for LXMF identity announces / path responses (named or nameless). Sidecar applies identity-key + display-name cache updates immediately, but emits **at most one** WS frame per coalesce window (500ms normal / 1000ms when >256 distinct destinations are pending) so announce storms stay O(1) bus pressure on large meshes (~100k). Payload is either a single `{ destination_hash, display_name?, hops }` (legacy / one-row flush) or `{ announces: [{ destination_hash, display_name?, hops }, ...] }` (capped at 1024, named preferred; overflow dropped — slow peer poll recovers). Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. +- **`announce.received`:** coalesced WS notify for LXMF identity announces / path responses (named or nameless). Sidecar applies identity-key + display-name cache updates immediately, but emits **at most one** WS frame per coalesce window (500ms normal / 1000ms when >256 distinct destinations are pending) so announce storms stay O(1) bus pressure on large meshes (~100k). Payload is either a single `{ destination_hash, display_name?, hops }` (legacy / one-row flush) or `{ announces: [{ destination_hash, display_name?, hops }, ...] }` (capped at 1024, named preferred; overflow dropped — slow peer poll recovers). Each flush publishes pressure counters under `GET /api/v1/diagnostics` → `announce_ws` (ingress/unique/overflow + storm/flush timestamps) for the Diagnostics `reticulum/announce-bus-pressure` warning. Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. - **`peers_updated`:** also emitted when the live path table **gains** new destination hashes (maintenance tick). Payload may include `{ added: string[], patches: PeerRow[], count }` (added/patches capped at 1024). Renderer applies patches incrementally, including route-field changes. A full peer dump is used on connect, manual Refresh, restart, safety poll, or a `peers_updated` payload that cannot be applied incrementally: `cleared`, `demoted_from_contacts`, or a single-`hash` probe/path event. Hop/timestamp-only churn does not emit. `lxmf_message` payload fields include `sender_hash`, `text`, `timestamp`, `message_hash`, optional `direction` (`inbound` / `outbound`), optional `delivery_status` (`sending` on optimistic outbound rows), optional `reply_to_hash` / `reply_preview_text` (from LXMF `FIELD_REPLY_TO` / `FIELD_REPLY_QUOTE`), and transport markers `received_via` / `sent_via`. Outbound `sent_via` is **path-table / PacketTap evidence**, not “any local RNode enabled”: atomic values are `rf`, `ble`, `tcp`, or `network`; multi-egress observes join with `+` (e.g. `rf+tcp`, `ble+network`). Inbound `received_via` uses the path-table interface name **matched to local interface config** (same atoms — so a TCP hub named “RNS Testnet” is `tcp`, not `network`). Never use Meshtastic-style `both` for Reticulum. diff --git a/reticulum-sidecar/src/stack/announce_ws_coalesce.rs b/reticulum-sidecar/src/stack/announce_ws_coalesce.rs index c90b81a63..1daa239fa 100644 --- a/reticulum-sidecar/src/stack/announce_ws_coalesce.rs +++ b/reticulum-sidecar/src/stack/announce_ws_coalesce.rs @@ -2,7 +2,8 @@ //! (≤1 frame per flush window) even at ~100k path-table scale. use std::collections::HashMap; -use std::time::Duration; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// Normal flush window — mirror renderer peer-refresh coalesce (~400ms) with a small buffer. pub const ANNOUNCE_WS_COALESCE_MS: u64 = 500; @@ -20,10 +21,58 @@ pub struct AnnounceWsRow { pub hops: u8, } +/// Snapshot published for `GET /api/v1/diagnostics` (`announce_ws`). +/// Field names match the IPC JSON contract (`last_*`). +#[allow(clippy::struct_field_names)] +#[derive(Debug, Clone, Copy, Default)] +pub struct AnnounceWsPressureSnapshot { + pub last_window_ingress: u64, + pub last_window_unique: u64, + pub last_window_overflow: u64, + pub last_storm_at_ms: u64, + pub last_flush_at_ms: u64, +} + +static LAST_WINDOW_INGRESS: AtomicU64 = AtomicU64::new(0); +static LAST_WINDOW_UNIQUE: AtomicU64 = AtomicU64::new(0); +static LAST_WINDOW_OVERFLOW: AtomicU64 = AtomicU64::new(0); +static LAST_STORM_AT_MS: AtomicU64 = AtomicU64::new(0); +static LAST_FLUSH_AT_MS: AtomicU64 = AtomicU64::new(0); + +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Publish last-flush pressure metrics for diagnostics. +pub fn record_announce_ws_flush(stats: AnnounceWsPressureSnapshot) { + LAST_WINDOW_INGRESS.store(stats.last_window_ingress, Ordering::Relaxed); + LAST_WINDOW_UNIQUE.store(stats.last_window_unique, Ordering::Relaxed); + LAST_WINDOW_OVERFLOW.store(stats.last_window_overflow, Ordering::Relaxed); + LAST_FLUSH_AT_MS.store(stats.last_flush_at_ms, Ordering::Relaxed); + if stats.last_storm_at_ms > 0 { + LAST_STORM_AT_MS.store(stats.last_storm_at_ms, Ordering::Relaxed); + } +} + +pub fn announce_ws_pressure_snapshot() -> AnnounceWsPressureSnapshot { + AnnounceWsPressureSnapshot { + last_window_ingress: LAST_WINDOW_INGRESS.load(Ordering::Relaxed), + last_window_unique: LAST_WINDOW_UNIQUE.load(Ordering::Relaxed), + last_window_overflow: LAST_WINDOW_OVERFLOW.load(Ordering::Relaxed), + last_storm_at_ms: LAST_STORM_AT_MS.load(Ordering::Relaxed), + last_flush_at_ms: LAST_FLUSH_AT_MS.load(Ordering::Relaxed), + } +} + /// Pending announces keyed by destination hash (last write wins). #[derive(Debug, Default)] pub struct AnnounceWsCoalescer { pending: HashMap, + /// Total push() calls in the current coalesce window (includes overwrites). + window_ingress: u64, } impl AnnounceWsCoalescer { @@ -37,6 +86,7 @@ impl AnnounceWsCoalescer { /// Insert or replace the row for this destination. pub fn push(&mut self, row: AnnounceWsRow) { + self.window_ingress = self.window_ingress.saturating_add(1); self.pending.insert(row.destination_hash.clone(), row); } @@ -49,11 +99,15 @@ impl AnnounceWsCoalescer { } } - /// Drain pending into a capped list (named first), newest-map order otherwise. + /// Drain pending into a capped list (named first) and publish pressure metrics. pub fn take_flush_rows(&mut self) -> Vec { if self.pending.is_empty() { + self.window_ingress = 0; return Vec::new(); } + let unique_before = self.pending.len(); + let storm = unique_before > ANNOUNCE_WS_STORM_PENDING; + let ingress = self.window_ingress; let mut named = Vec::new(); let mut nameless = Vec::new(); for (_, row) in self.pending.drain() { @@ -69,9 +123,19 @@ impl AnnounceWsCoalescer { } // Stable-ish: named first (prefer keeping labels), then nameless. named.extend(nameless); + let overflow = unique_before.saturating_sub(ANNOUNCE_WS_FLUSH_MAX) as u64; if named.len() > ANNOUNCE_WS_FLUSH_MAX { named.truncate(ANNOUNCE_WS_FLUSH_MAX); } + let now_ms = now_unix_ms(); + record_announce_ws_flush(AnnounceWsPressureSnapshot { + last_window_ingress: ingress, + last_window_unique: unique_before as u64, + last_window_overflow: overflow, + last_storm_at_ms: if storm { now_ms } else { 0 }, + last_flush_at_ms: now_ms, + }); + self.window_ingress = 0; named } } @@ -136,10 +200,15 @@ mod tests { .find(|r| r.destination_hash == "aa") .expect("aa"); assert_eq!(aa.display_name.as_deref(), Some("New")); + let snap = announce_ws_pressure_snapshot(); + assert_eq!(snap.last_window_ingress, 3); + assert_eq!(snap.last_window_unique, 2); + assert_eq!(snap.last_window_overflow, 0); + assert!(snap.last_flush_at_ms > 0); } #[test] - fn flush_prefers_named_when_over_cap() { + fn flush_prefers_named_when_over_cap_and_records_overflow() { let mut c = AnnounceWsCoalescer::new(); for i in 0..(ANNOUNCE_WS_FLUSH_MAX + 50) { c.push(row(&format!("{i:032x}"), None)); @@ -158,10 +227,13 @@ mod tests { }) .count(); assert_eq!(named, 10); + let snap = announce_ws_pressure_snapshot(); + assert!(snap.last_window_overflow >= 50); + assert!(snap.last_flush_at_ms > 0); } #[test] - fn storm_widens_coalesce_duration() { + fn storm_widens_coalesce_duration_and_stamps_storm_time() { let mut c = AnnounceWsCoalescer::new(); for i in 0..=ANNOUNCE_WS_STORM_PENDING { c.push(row(&format!("{i:032x}"), None)); @@ -170,6 +242,9 @@ mod tests { c.coalesce_duration(), Duration::from_millis(ANNOUNCE_WS_STORM_COALESCE_MS) ); + let _ = c.take_flush_rows(); + let snap = announce_ws_pressure_snapshot(); + assert!(snap.last_storm_at_ms > 0); let mut small = AnnounceWsCoalescer::new(); small.push(row("aa", None)); assert_eq!( diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 4ce2e51ca..2bb28c0f3 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -2194,6 +2194,7 @@ impl StackHandle { }) }) .collect(); + let announce_ws = announce_ws_coalesce::announce_ws_pressure_snapshot(); serde_json::json!({ "rns_ready": inner.rns_ready, "lxmf_ready": inner.lxmf_ready, @@ -2202,6 +2203,13 @@ impl StackHandle { "peer_count": inner.peers.len(), "message_count": inner.messages.len(), "interfaces": interfaces, + "announce_ws": { + "last_window_ingress": announce_ws.last_window_ingress, + "last_window_unique": announce_ws.last_window_unique, + "last_window_overflow": announce_ws.last_window_overflow, + "last_storm_at_ms": announce_ws.last_storm_at_ms, + "last_flush_at_ms": announce_ws.last_flush_at_ms, + }, }) } diff --git a/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.test.ts b/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.test.ts index 959df55b6..6de8472b8 100644 --- a/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.test.ts +++ b/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.test.ts @@ -5,6 +5,8 @@ import type { RfDiagnosticRow } from '@/renderer/lib/types'; import { buildReticulumDiagnosticRows, mergeReticulumDiagnosticRows, + RETICULUM_ANNOUNCE_BUS_PRESSURE_TTL_MS, + shouldEmitAnnounceBusPressure, } from './ReticulumDiagnosticEngine'; describe('ReticulumDiagnosticEngine', () => { @@ -331,6 +333,132 @@ describe('ReticulumDiagnosticEngine', () => { ).toBe(true); }); + it('flags announce-bus-pressure from recent WS lag with enough skipped frames', () => { + const now = 1_700_000_000_000; + vi.useFakeTimers(); + try { + vi.setSystemTime(now); + const rows = buildReticulumDiagnosticRows( + { rns_ready: true, lxmf_ready: true, interface_count: 1, peer_count: 1 }, + { + inboundLxmf: { + lastEventsLaggedAt: now - 60_000, + lastEventsLaggedSkipped: 8, + lastInboundCatchUpAt: null, + lastInboundCatchUpCount: null, + inboundCatchUpWatermarkTs: null, + lastInboundRingLen: null, + }, + }, + ); + const row = rows.find( + (r): r is RfDiagnosticRow => + r.kind === 'rf' && r.condition === 'reticulum/announce-bus-pressure', + ); + expect(row).toBeDefined(); + expect(row?.severity).toBe('warning'); + expect(row?.causeI18n?.key).toBe('diagnosticsPanel.reticulum.runtime.announceBusPressure'); + } finally { + vi.useRealTimers(); + } + }); + + it('flags announce-bus-pressure from recent sidecar storm stamp', () => { + const now = 1_700_000_000_000; + vi.useFakeTimers(); + try { + vi.setSystemTime(now); + const rows = buildReticulumDiagnosticRows( + { + rns_ready: true, + lxmf_ready: true, + interface_count: 1, + peer_count: 5000, + announce_ws: { + last_window_ingress: 900, + last_window_unique: 400, + last_window_overflow: 0, + last_storm_at_ms: now - 30_000, + last_flush_at_ms: now - 30_000, + }, + }, + {}, + ); + expect( + rows.some((r) => r.kind === 'rf' && r.condition === 'reticulum/announce-bus-pressure'), + ).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('does not flag announce-bus-pressure without lag, storm, or fresh overflow', () => { + const now = 1_700_000_000_000; + expect( + shouldEmitAnnounceBusPressure( + { + last_window_ingress: 10, + last_window_unique: 8, + last_window_overflow: 0, + last_storm_at_ms: 0, + last_flush_at_ms: now - 1_000, + }, + { + lastEventsLaggedAt: now - 60_000, + lastEventsLaggedSkipped: 3, + lastInboundCatchUpAt: null, + lastInboundCatchUpCount: null, + inboundCatchUpWatermarkTs: null, + lastInboundRingLen: null, + }, + now, + ), + ).toBe(false); + expect( + shouldEmitAnnounceBusPressure( + { + last_window_overflow: 50, + last_flush_at_ms: now - RETICULUM_ANNOUNCE_BUS_PRESSURE_TTL_MS - 1, + last_storm_at_ms: now - RETICULUM_ANNOUNCE_BUS_PRESSURE_TTL_MS - 1, + }, + { + lastEventsLaggedAt: now - RETICULUM_ANNOUNCE_BUS_PRESSURE_TTL_MS - 1, + lastEventsLaggedSkipped: 20, + lastInboundCatchUpAt: null, + lastInboundCatchUpCount: null, + inboundCatchUpWatermarkTs: null, + lastInboundRingLen: null, + }, + now, + ), + ).toBe(false); + expect(shouldEmitAnnounceBusPressure(undefined, undefined, now)).toBe(false); + // peer_count alone must not fire + expect( + buildReticulumDiagnosticRows({ + rns_ready: true, + lxmf_ready: true, + interface_count: 1, + peer_count: 50_000, + }).some((r) => r.kind === 'rf' && r.condition === 'reticulum/announce-bus-pressure'), + ).toBe(false); + }); + + it('flags announce-bus-pressure from fresh coalesce overflow', () => { + const now = 1_700_000_000_000; + expect( + shouldEmitAnnounceBusPressure( + { + last_window_overflow: 12, + last_flush_at_ms: now - 10_000, + last_storm_at_ms: 0, + }, + undefined, + now, + ), + ).toBe(true); + }); + it('flags sidecar-unhealthy when running and unhealthy past grace', () => { const rows = buildReticulumDiagnosticRows( { rns_ready: true, lxmf_ready: true, interface_count: 1, peer_count: 1 }, diff --git a/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts b/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts index 9ccfe216b..585419242 100644 --- a/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts +++ b/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts @@ -2,6 +2,7 @@ import { auditIssuesToDiagnosticRows, type ReticulumConfigAuditIssue, } from '@/renderer/lib/reticulum/reticulumConfigAudit'; +import type { ReticulumInboundLxmfDiagnosticsSnapshot } from '@/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics'; import { collectReticulumLocalInterfaceAlerts, collectReticulumRemoteInterfaceAlerts, @@ -20,6 +21,7 @@ import type { ReticulumAutoBeaconAlert, ReticulumInterfaceIssueAlert, } from '@/shared/reticulum-types'; +import { MS_PER_MINUTE } from '@/shared/timeConstants'; export interface ReticulumDiagnosticsSnapshot { rns_ready?: boolean; @@ -29,8 +31,24 @@ export interface ReticulumDiagnosticsSnapshot { peer_count?: number; message_count?: number; interfaces?: ReticulumLocalInterfaceInput[]; + /** Sidecar announce coalesce pressure (from GET /api/v1/diagnostics). */ + announce_ws?: ReticulumAnnounceWsDiagnostics; } +/** Sidecar `announce_ws` block — last coalesce-window pressure metrics. */ +export interface ReticulumAnnounceWsDiagnostics { + last_window_ingress?: number; + last_window_unique?: number; + last_window_overflow?: number; + last_storm_at_ms?: number; + last_flush_at_ms?: number; +} + +/** How long announce-bus pressure signals stay actionable in Diagnostics. */ +export const RETICULUM_ANNOUNCE_BUS_PRESSURE_TTL_MS = 5 * MS_PER_MINUTE; +/** Minimum WS frames skipped before lag alone opens the announce-bus pressure row. */ +export const RETICULUM_ANNOUNCE_BUS_PRESSURE_MIN_SKIPPED = 8; + /** Propagation sync snapshot for diagnostics (derived from reticulumPropagationStore). */ export interface ReticulumPropagationDiagnosticsInput { syncActive: boolean; @@ -54,6 +72,8 @@ export interface ReticulumDiagnosticsBuildOptions { sidecarHealthy?: boolean; sidecarUnhealthySince?: number; propagation?: ReticulumPropagationDiagnosticsInput; + /** Renderer-local WS lag / inbound catch-up counters. */ + inboundLxmf?: ReticulumInboundLxmfDiagnosticsSnapshot; } function runtimeCauseI18n( @@ -85,6 +105,7 @@ export const RETICULUM_RUNTIME_CAUSE_I18N_KEYS = [ 'diagnosticsPanel.reticulum.runtime.sidecarUnhealthy', 'diagnosticsPanel.reticulum.runtime.propagationSyncStuck', 'diagnosticsPanel.reticulum.runtime.propagationSyncFailing', + 'diagnosticsPanel.reticulum.runtime.announceBusPressure', ] as const; /** Sidecar must stay unhealthy this long before emitting an error diagnostic. */ @@ -404,6 +425,20 @@ export function buildReticulumDiagnosticRows( } } + if (shouldEmitAnnounceBusPressure(snapshot.announce_ws, options?.inboundLxmf, now)) { + rows.push({ + kind: 'rf', + id: rfRowId(homeNodeId, 'reticulum/announce-bus-pressure'), + nodeId: homeNodeId, + condition: 'reticulum/announce-bus-pressure', + cause: + 'High announce/path-response rate may delay inbound LXMF Chat delivery (WS catch-up active)', + causeI18n: runtimeCauseI18n('announceBusPressure'), + severity: 'warning', + detectedAt: now, + }); + } + const propagation = options?.propagation; if (propagation) { const attemptAt = propagation.lastAttemptAt; @@ -449,6 +484,47 @@ export function buildReticulumDiagnosticRows( return rows; } +/** True when recent WS lag or sidecar announce coalesce pressure may affect Chat. */ +export function shouldEmitAnnounceBusPressure( + announceWs: ReticulumAnnounceWsDiagnostics | undefined, + inboundLxmf: ReticulumInboundLxmfDiagnosticsSnapshot | undefined, + nowMs: number = Date.now(), +): boolean { + const ttl = RETICULUM_ANNOUNCE_BUS_PRESSURE_TTL_MS; + if (inboundLxmf?.lastEventsLaggedAt != null) { + const age = nowMs - inboundLxmf.lastEventsLaggedAt; + const skipped = inboundLxmf.lastEventsLaggedSkipped ?? 0; + if (age >= 0 && age < ttl && skipped >= RETICULUM_ANNOUNCE_BUS_PRESSURE_MIN_SKIPPED) { + return true; + } + } + if (announceWs) { + const stormAt = announceWs.last_storm_at_ms; + if ( + typeof stormAt === 'number' && + Number.isFinite(stormAt) && + stormAt > 0 && + nowMs - stormAt >= 0 && + nowMs - stormAt < ttl + ) { + return true; + } + const overflow = announceWs.last_window_overflow ?? 0; + const flushAt = announceWs.last_flush_at_ms; + if ( + overflow > 0 && + typeof flushAt === 'number' && + Number.isFinite(flushAt) && + flushAt > 0 && + nowMs - flushAt >= 0 && + nowMs - flushAt < ttl + ) { + return true; + } + } + return false; +} + /** Merge Reticulum rows into an existing diagnostic row list (replace prior Reticulum rows). */ export function mergeReticulumDiagnosticRows( current: DiagnosticRow[], diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index a9cc52555..cbaae51fc 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -1584,7 +1584,8 @@ "blePairingTimedOut": "Vypršel časový limit výměny klíčů ble RNode „{{name}}“ — zadejte aktuální PIN (ne 123456) a znovu spárujte", "sidecarUnhealthy": "Postranní vozík Reticulum běží, ale nereaguje na zdravotní prohlídky", "propagationSyncStuck": "Synchronizace propagačního uzlu se zasekla při vytváření odkazu", - "propagationSyncFailing": "Synchronizace propagačního uzlu se nezdařila" + "propagationSyncFailing": "Synchronizace propagačního uzlu se nezdařila", + "announceBusPressure": "Vysoká míra odezvy na oznámení/cestu může zpozdit příchozí doručení chatu LXMF — dohnání je aktivní; očekávají se velké tabulky cest" } }, "routingPort": { diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index b19e0131e..a97b09ba3 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -1582,7 +1582,8 @@ "blePairingTimedOut": "BLE RNode \"{{name}}\" Passkey-Austausch abgelaufen — geben Sie die aktuelle PIN (nicht 123456) ein und koppeln Sie erneut", "sidecarUnhealthy": "Reticulum Sidecar läuft, reagiert aber nicht auf Gesundheitschecks", "propagationSyncStuck": "Die Synchronisierung des Ausbreitungsknotens bleibt hängen, um eine Verbindung herzustellen", - "propagationSyncFailing": "Synchronisierung des Ausbreitungsknotens fehlgeschlagen" + "propagationSyncFailing": "Synchronisierung des Ausbreitungsknotens fehlgeschlagen", + "announceBusPressure": "Hohe Ansage-/Pfad-Antwort-Rate kann eingehende LXMF-Chat-Zustellung verzögern — Aufholjagd ist aktiv; große Pfad-Tabellen werden erwartet" } }, "routingPort": { diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index dd1d22beb..593696ab8 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -1583,7 +1583,8 @@ "autoBeaconPhysicalFailures": "AutoInterface beacon TX is failing on {{ifaces}} — local LAN discovery may not work until this is fixed.", "sidecarUnhealthy": "Reticulum sidecar is running but not responding to health checks", "propagationSyncStuck": "Propagation node sync is stuck establishing a link", - "propagationSyncFailing": "Propagation node sync failed" + "propagationSyncFailing": "Propagation node sync failed", + "announceBusPressure": "High announce/path-response rate may delay inbound LXMF Chat delivery — catch-up is active; large path tables are expected" }, "audit": { "tcp_enable_key": "TCP interface \"{{name}}\" uses the wrong enable key — RNS will not load it.", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index cb4c5fc60..5f8273f98 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -1582,7 +1582,8 @@ "blePairingTimedOut": "Se agotó el tiempo de espera para el intercambio de clave de acceso de BLE RNode \"{{name}}\": ingrese el PIN actual (no 123456) y vuelva a emparejar", "sidecarUnhealthy": "El sidecar de Reticulum está en ejecución pero no responde a las comprobaciones de estado", "propagationSyncStuck": "La sincronización del nodo de propagación está atascada estableciendo un enlace", - "propagationSyncFailing": "Error en la sincronización del nodo de propagación" + "propagationSyncFailing": "Error en la sincronización del nodo de propagación", + "announceBusPressure": "La alta tasa de anuncio/respuesta de ruta puede retrasar la entrega de chat LXMF entrante: la recuperación está activa; se esperan tablas de ruta grandes" } }, "routingPort": { diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index e30d2db69..62dc063c2 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -1582,7 +1582,8 @@ "blePairingTimedOut": "L'échange de clé ble RNode \"{{name}}\" a expiré — saisissez le code PIN actuel (pas 123456) et réappareillez", "sidecarUnhealthy": "Le sidecar Reticulum est en cours d’exécution mais ne répond pas aux contrôles de santé", "propagationSyncStuck": "La synchronisation du nœud de propagation est bloquée pour établir un lien", - "propagationSyncFailing": "Échec de la synchronisation du nœud de propagation" + "propagationSyncFailing": "Échec de la synchronisation du nœud de propagation", + "announceBusPressure": "Un taux élevé d'annonce/réponse au chemin peut retarder la livraison du chat LXMF entrant — le rattrapage est actif ; de grandes tables de chemin sont attendues" } }, "routingPort": { diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 385e65416..1ff097d44 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -1582,7 +1582,8 @@ "blePairingTimedOut": "Waktu pertukaran kunci sandi BLE RNode \"{{name}}\" habis — masukkan PIN saat ini (bukan 123456) dan pasangkan kembali", "sidecarUnhealthy": "Reticulum sidecar sedang berjalan tetapi tidak menanggapi pemeriksaan kesehatan", "propagationSyncStuck": "Sinkronisasi simpul propagasi macet membuat tautan", - "propagationSyncFailing": "Sinkronisasi simpul propagasi gagal" + "propagationSyncFailing": "Sinkronisasi simpul propagasi gagal", + "announceBusPressure": "Tingkat pengumuman/respons jalur yang tinggi dapat menunda pengiriman Obrolan LXMF masuk — tangkapan aktif; tabel jalur besar diharapkan" } }, "routingPort": { diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index cd2a9580e..88d4cc6ed 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -1582,7 +1582,8 @@ "blePairingTimedOut": "Scambio passkey BLE RNode \"{{name}}\" scaduto: inserire il PIN corrente (non 123456) e associare nuovamente", "sidecarUnhealthy": "Il sidecar Reticulum è in esecuzione ma non risponde ai controlli di integrità", "propagationSyncStuck": "La sincronizzazione del nodo di propagazione è bloccata stabilendo un collegamento", - "propagationSyncFailing": "Sincronizzazione del nodo di propagazione non riuscita" + "propagationSyncFailing": "Sincronizzazione del nodo di propagazione non riuscita", + "announceBusPressure": "Un alto tasso di annuncio/risposta al percorso può ritardare la consegna della chat LXMF in entrata — il recupero è attivo; sono previste tabelle di percorso di grandi dimensioni" } }, "routingPort": { diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index cfd679b1c..3006b10ed 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -1582,7 +1582,8 @@ "blePairingTimedOut": "BLE RNode \"{{name}}\" パスキー交換がタイムアウトしました — 現在の PIN (123456 ではない) を入力して、再ペアリングしてください", "sidecarUnhealthy": "Reticulumサイドカーは実行中ですが、ヘルスチェックに応答していません", "propagationSyncStuck": "伝播ノードの同期がリンクの確立に固執しています", - "propagationSyncFailing": "伝播ノードの同期に失敗しました" + "propagationSyncFailing": "伝播ノードの同期に失敗しました", + "announceBusPressure": "高いアナウンス/パス応答率は、インバウンドLXMFチャット配信を遅らせる可能性があります—キャッチアップがアクティブです。大きなパステーブルが予想されます" } }, "routingPort": { diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index ce946c6b0..d83325a47 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -1582,7 +1582,8 @@ "blePairingTimedOut": "BLE RNode \"{{name}}\" 암호 키 교환 시간이 초과되었습니다. 현재 PIN(123456 아님)을 입력하고 다시 페어링하세요.", "sidecarUnhealthy": "Reticulum 사이드카가 작동 중이지만 상태 점검에 응답하지 않습니다", "propagationSyncStuck": "전파 노드 동기화가 링크 설정 중 중단됨", - "propagationSyncFailing": "전파 노드 동기화 실패" + "propagationSyncFailing": "전파 노드 동기화 실패", + "announceBusPressure": "높은 발표/경로 응답률로 인해 인바운드 LXMF 채팅 전달이 지연될 수 있음 — 캐치업이 활성화되어 있음, 큰 경로 테이블이 예상됨" } }, "routingPort": { diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 9e430dec8..0347ad611 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -1582,7 +1582,8 @@ "blePairingTimedOut": "Er is een time-out opgetreden bij het uitwisselen van de BLE RNode \"{{name}}\"-wachtwoord: voer de huidige pincode in (niet 123456) en koppel opnieuw", "sidecarUnhealthy": "Reticulum-zijspan loopt, maar reageert niet op gezondheidscontroles", "propagationSyncStuck": "Synchronisatie van propagatieknooppunt zit vast bij het tot stand brengen van een link", - "propagationSyncFailing": "Synchronisatie van propagatieknooppunt mislukt" + "propagationSyncFailing": "Synchronisatie van propagatieknooppunt mislukt", + "announceBusPressure": "Hoog aankondigings-/padresponspercentage kan inkomende LXMF-chatlevering vertragen — inhaalactie is actief; er worden grote padtabellen verwacht" } }, "routingPort": { diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 99798a49c..0ddf2fadd 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -1586,7 +1586,8 @@ "blePairingTimedOut": "Upłynął limit czasu wymiany hasła BLE RNode „{{name}}” — wprowadź bieżący kod PIN (nie 123456) i sparuj ponownie", "sidecarUnhealthy": "Sidecar Reticulum działa, ale nie odpowiada na kontrole stanu", "propagationSyncStuck": "Synchronizacja węzła propagacji utknęła, tworząc łącze", - "propagationSyncFailing": "Synchronizacja węzła propagacji nie powiodła się" + "propagationSyncFailing": "Synchronizacja węzła propagacji nie powiodła się", + "announceBusPressure": "Wysoki wskaźnik zapowiedzi/reakcji na ścieżkę może opóźnić dostawę przychodzącego czatu LXMF — nadrabianie zaległości jest aktywne; oczekiwane są duże tabele ścieżek" } }, "routingPort": { diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index a67552809..773b5deb4 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -1582,7 +1582,8 @@ "blePairingTimedOut": "BLE RNode \"{{name}}\" a troca de senha expirou - insira o PIN atual (não 123456) e emparelhe novamente", "sidecarUnhealthy": "O sidecar Reticulum está em execução, mas não responde às verificações de integridade", "propagationSyncStuck": "A sincronização do nó de propagação está travada, estabelecendo um link", - "propagationSyncFailing": "Falha na sincronização do nó de propagação" + "propagationSyncFailing": "Falha na sincronização do nó de propagação", + "announceBusPressure": "A alta taxa de anúncio/resposta ao caminho pode atrasar a entrega do chat LXMF de entrada — a recuperação está ativa; grandes tabelas de caminho são esperadas" } }, "routingPort": { diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 1ecbe4572..248bc0d0f 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -1584,7 +1584,8 @@ "blePairingTimedOut": "Тайм-аут обмена ключами BLE RNode «{{name}}» — введите текущий PIN-код (не 123456) и повторите сопряжение.", "sidecarUnhealthy": "Sidecar Reticulum работает, но не отвечает на проверки состояния", "propagationSyncStuck": "Синхронизация узла распространения застряла при установлении канала", - "propagationSyncFailing": "Ошибка синхронизации узла распространения" + "propagationSyncFailing": "Ошибка синхронизации узла распространения", + "announceBusPressure": "Высокая скорость ответа на объявление/путь может задержать доставку входящего чата LXMF — активен догоняющий процесс; ожидаются большие таблицы путей" } }, "routingPort": { diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index aab046b86..3fa90078f 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -1582,7 +1582,8 @@ "blePairingTimedOut": "BLE RNode \"{{name}}\" şifre değişimi zaman aşımına uğradı — mevcut PIN'i girin (123456 değil) ve yeniden eşleştirin", "sidecarUnhealthy": "Reticulum sepeti çalışıyor ancak sağlık kontrollerine yanıt vermiyor", "propagationSyncStuck": "Yayılım düğümü senkronizasyonu bir bağlantı kurarken takıldı", - "propagationSyncFailing": "Yayılma düğümü senkronizasyonu başarısız oldu" + "propagationSyncFailing": "Yayılma düğümü senkronizasyonu başarısız oldu", + "announceBusPressure": "Yüksek anons/yol - yanıt oranı, gelen LXMF Sohbet teslimatını geciktirebilir — telafi aktif; büyük yol tabloları bekleniyor" } }, "routingPort": { diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 50d3aedbc..a8154e759 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -1584,7 +1584,8 @@ "blePairingTimedOut": "Час очікування обміну ключем доступу BLE RNode \"{{name}}\" минув — введіть поточний PIN-код (не 123456) і повторіть сполучення", "sidecarUnhealthy": "Sidecar Reticulum працює, але не відповідає на перевірки здоров'я", "propagationSyncStuck": "Синхронізація вузла розповсюдження застрягла під час встановлення зв'язку", - "propagationSyncFailing": "Помилка синхронізації вузла поширення" + "propagationSyncFailing": "Помилка синхронізації вузла поширення", + "announceBusPressure": "Висока частота оголошень/шлях-відповідь може затримати вхідну доставку чату LXMF — наздоганяючий ефект активний; очікується велика таблиця шляхів" } }, "routingPort": { diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index eaccc237d..234e89870 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -1582,7 +1582,8 @@ "blePairingTimedOut": "BLE RNode“{{name}}”密钥交换超时 — 输入当前 PIN(不是 123456)并重新配对", "sidecarUnhealthy": "Reticulum sidecar正在运行,但未响应健康检查", "propagationSyncStuck": "传播节点同步卡住,无法建立链接", - "propagationSyncFailing": "传播节点同步失败" + "propagationSyncFailing": "传播节点同步失败", + "announceBusPressure": "高公告/路径响应率可能会延迟入站LXMF聊天交付—追赶处于活动状态;预计会有大型路径表" } }, "routingPort": { diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index ec76b5f6c..15483b5a0 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -445,6 +445,7 @@ export function useReticulumRuntime(): ProtocolRuntime { sidecarRunning: sidecarStatus.running, sidecarHealthy: sidecarStatus.healthy, sidecarUnhealthySince: sidecarStatus.unhealthySince, + inboundLxmf: getReticulumInboundLxmfDiagnostics(), propagation: { syncActive: propState.sync.active, syncProgress: propState.sync.progress, From 389e99bdbc172cf553ee46cf7b705847bb0cac9c Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 30 Jul 2026 19:08:07 -0600 Subject: [PATCH 3/4] feat(diagnostics): guide users on announce-bus-pressure tips Show four practical tips under the warning and an Open Interfaces action so large-mesh users know what they can try while catch-up runs. --- docs/diagnostics.md | 12 +++--- .../ReticulumDiagnosticsSection.test.tsx | 43 +++++++++++++++++++ .../ReticulumDiagnosticsSection.tsx | 26 +++++++++-- .../ReticulumDiagnosticEngine.test.ts | 1 + .../diagnostics/ReticulumDiagnosticEngine.ts | 13 ++++++ src/renderer/lib/types.ts | 3 +- src/renderer/locales/cs/translation.json | 9 +++- src/renderer/locales/de/translation.json | 9 +++- src/renderer/locales/en/translation.json | 9 +++- src/renderer/locales/es/translation.json | 9 +++- src/renderer/locales/fr/translation.json | 9 +++- src/renderer/locales/id/translation.json | 9 +++- src/renderer/locales/it/translation.json | 9 +++- src/renderer/locales/ja/translation.json | 9 +++- src/renderer/locales/ko/translation.json | 9 +++- src/renderer/locales/nl/translation.json | 9 +++- src/renderer/locales/pl/translation.json | 9 +++- src/renderer/locales/pt-BR/translation.json | 9 +++- src/renderer/locales/ru/translation.json | 9 +++- src/renderer/locales/tr/translation.json | 9 +++- src/renderer/locales/uk/translation.json | 9 +++- src/renderer/locales/zh/translation.json | 9 +++- 22 files changed, 200 insertions(+), 42 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index a85040206..8704fa3c4 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -433,12 +433,12 @@ Runtime interface-issue rows from the sidecar latch (`interfaceIssueAlert`) are Additional runtime rows (refreshed from sidecar status + `reticulumPropagationStore`, not only the config audit poll): -| Condition | Trigger | Severity | Action | -| ------------------------------------ | --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------- | -| `reticulum/sidecar-unhealthy` | Sidecar `running && healthy === false` for ≥ 60 s (`sidecarUnhealthySince`) | error | **Restart stack** | -| `reticulum/announce-bus-pressure` | Recent WS `events_lagged` (skipped ≥ 8) **or** sidecar `announce_ws` storm/overflow within 5 min | warning | Informational — catch-up active; not a broken radio | -| `reticulum/propagation-sync-stuck` | Sync active ≥ ~45 s (`RETICULUM_PROPAGATION_SYNC_STALL_MS`) with progress still Establishing (< 15) | warning | Retry sync; check PN path / announce | -| `reticulum/propagation-sync-failing` | Sync idle with `lastSyncError` (excludes user cancel) and attempt within 1 h | warning | See Network → Propagation / troubleshooting | +| Condition | Trigger | Severity | Action | +| ------------------------------------ | --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------- | +| `reticulum/sidecar-unhealthy` | Sidecar `running && healthy === false` for ≥ 60 s (`sidecarUnhealthySince`) | error | **Restart stack** | +| `reticulum/announce-bus-pressure` | Recent WS `events_lagged` (skipped ≥ 8) **or** sidecar `announce_ws` storm/overflow within 5 min | warning | Tips under issue + **Open Interfaces** (disable unused hubs; Share instance / announce interval / wait) | +| `reticulum/propagation-sync-stuck` | Sync active ≥ ~45 s (`RETICULUM_PROPAGATION_SYNC_STALL_MS`) with progress still Establishing (< 15) | warning | Retry sync; check PN path / announce | +| `reticulum/propagation-sync-failing` | Sync idle with `lastSyncError` (excludes user cancel) and attempt within 1 h | warning | See Network → Propagation / troubleshooting | | Issue kind | Typical cause | In-app action | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | diff --git a/src/renderer/components/ReticulumDiagnosticsSection.test.tsx b/src/renderer/components/ReticulumDiagnosticsSection.test.tsx index b978a989e..84c9ff081 100644 --- a/src/renderer/components/ReticulumDiagnosticsSection.test.tsx +++ b/src/renderer/components/ReticulumDiagnosticsSection.test.tsx @@ -69,6 +69,49 @@ describe('ReticulumDiagnosticsSection', () => { expect(screen.getByText('diagnosticsPanel.reticulum.runtime.rnsNotReady')).toBeInTheDocument(); }); + it('renders announce-bus-pressure tips and Open Interfaces action', async () => { + const user = userEvent.setup(); + const onNavigateToConnection = vi.fn(); + const pressureRow: RfDiagnosticRow = { + kind: 'rf', + id: 'rf:1:reticulum/announce-bus-pressure', + nodeId: 1, + condition: 'reticulum/announce-bus-pressure', + cause: 'announce pressure', + severity: 'warning', + detectedAt: Date.now(), + causeI18n: { key: 'diagnosticsPanel.reticulum.runtime.announceBusPressure' }, + reticulumRepairKind: 'open_interfaces', + }; + render( + , + ); + expect( + screen.getByText('diagnosticsPanel.reticulum.runtime.announceBusPressure'), + ).toBeInTheDocument(); + expect( + screen.getByText('diagnosticsPanel.reticulum.runtime.announceBusPressureTipDisableHubs'), + ).toBeInTheDocument(); + expect( + screen.getByText('diagnosticsPanel.reticulum.runtime.announceBusPressureTipShareInstance'), + ).toBeInTheDocument(); + expect( + screen.getByText('diagnosticsPanel.reticulum.runtime.announceBusPressureTipAnnounceInterval'), + ).toBeInTheDocument(); + expect( + screen.getByText('diagnosticsPanel.reticulum.runtime.announceBusPressureTipWait'), + ).toBeInTheDocument(); + await user.click( + screen.getByRole('button', { + name: 'diagnosticsPanel.reticulum.action.open_interfaces', + }), + ); + expect(onNavigateToConnection).toHaveBeenCalledTimes(1); + }); + it('renders audit rows with repair action', () => { render(); expect(screen.getByText('diagnosticsPanel.reticulum.action.repair_config')).toBeInTheDocument(); diff --git a/src/renderer/components/ReticulumDiagnosticsSection.tsx b/src/renderer/components/ReticulumDiagnosticsSection.tsx index e87514be0..1317b83f2 100644 --- a/src/renderer/components/ReticulumDiagnosticsSection.tsx +++ b/src/renderer/components/ReticulumDiagnosticsSection.tsx @@ -9,7 +9,10 @@ import { DIAGNOSTICS_SEVERITY_TEXT, reticulumMeshHealthBand, } from '@/renderer/lib/diagnostics/diagnosticsPanelStyles'; -import { isReticulumDiagnosticRow } from '@/renderer/lib/diagnostics/ReticulumDiagnosticEngine'; +import { + isReticulumDiagnosticRow, + RETICULUM_ANNOUNCE_BUS_PRESSURE_TIP_I18N_KEYS, +} from '@/renderer/lib/diagnostics/ReticulumDiagnosticEngine'; import { translateReticulumDiagnosticCause } from '@/renderer/lib/diagnostics/reticulumDiagnosticLabels'; import { useIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; import { restartReticulumStack } from '@/renderer/lib/reticulum/restartReticulumStack'; @@ -51,7 +54,11 @@ function severityHeaderKey(severity: 'error' | 'warning' | 'info', count: number } function remedyCategoryForRow(row: RfDiagnosticRow): keyof typeof DIAGNOSTICS_CATEGORY_STYLES { - if (row.reticulumRepairKind === 'repair_config' || row.reticulumRepairKind === 'add_auto') { + if ( + row.reticulumRepairKind === 'repair_config' || + row.reticulumRepairKind === 'add_auto' || + row.reticulumRepairKind === 'open_interfaces' + ) { return 'Configuration'; } if ( @@ -149,6 +156,10 @@ export function ReticulumDiagnosticsSection({ onNavigateToConnection?.(); return; } + if (kind === 'open_interfaces') { + onNavigateToConnection?.(); + return; + } if (kind === 'restart_stack') { setBusyKey(row.id); try { @@ -247,7 +258,16 @@ export function ReticulumDiagnosticsSection({ - {row.causeI18n ? translateReticulumDiagnosticCause(t, row) : row.cause} +
+ {row.causeI18n ? translateReticulumDiagnosticCause(t, row) : row.cause} + {row.condition === 'reticulum/announce-bus-pressure' ? ( +
    + {RETICULUM_ANNOUNCE_BUS_PRESSURE_TIP_I18N_KEYS.map((key) => ( +
  • {t(key)}
  • + ))} +
+ ) : null} +
{ expect(row).toBeDefined(); expect(row?.severity).toBe('warning'); expect(row?.causeI18n?.key).toBe('diagnosticsPanel.reticulum.runtime.announceBusPressure'); + expect(row?.reticulumRepairKind).toBe('open_interfaces'); } finally { vi.useRealTimers(); } diff --git a/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts b/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts index 585419242..e9b96f08b 100644 --- a/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts +++ b/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.ts @@ -106,6 +106,18 @@ export const RETICULUM_RUNTIME_CAUSE_I18N_KEYS = [ 'diagnosticsPanel.reticulum.runtime.propagationSyncStuck', 'diagnosticsPanel.reticulum.runtime.propagationSyncFailing', 'diagnosticsPanel.reticulum.runtime.announceBusPressure', + 'diagnosticsPanel.reticulum.runtime.announceBusPressureTipDisableHubs', + 'diagnosticsPanel.reticulum.runtime.announceBusPressureTipShareInstance', + 'diagnosticsPanel.reticulum.runtime.announceBusPressureTipAnnounceInterval', + 'diagnosticsPanel.reticulum.runtime.announceBusPressureTipWait', +] as const; + +/** Tip keys shown under reticulum/announce-bus-pressure in Diagnostics. */ +export const RETICULUM_ANNOUNCE_BUS_PRESSURE_TIP_I18N_KEYS = [ + 'diagnosticsPanel.reticulum.runtime.announceBusPressureTipDisableHubs', + 'diagnosticsPanel.reticulum.runtime.announceBusPressureTipShareInstance', + 'diagnosticsPanel.reticulum.runtime.announceBusPressureTipAnnounceInterval', + 'diagnosticsPanel.reticulum.runtime.announceBusPressureTipWait', ] as const; /** Sidecar must stay unhealthy this long before emitting an error diagnostic. */ @@ -436,6 +448,7 @@ export function buildReticulumDiagnosticRows( causeI18n: runtimeCauseI18n('announceBusPressure'), severity: 'warning', detectedAt: now, + reticulumRepairKind: 'open_interfaces', }); } diff --git a/src/renderer/lib/types.ts b/src/renderer/lib/types.ts index da480b574..c75185967 100644 --- a/src/renderer/lib/types.ts +++ b/src/renderer/lib/types.ts @@ -107,7 +107,8 @@ export interface RfDiagnosticRow { | 'edit' | 'restart_stack' | 'add_auto' - | 'disable_share_instance'; + | 'disable_share_instance' + | 'open_interfaces'; } export type DiagnosticRow = RoutingDiagnosticRow | RfDiagnosticRow; diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index cbaae51fc..04c404029 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -1541,7 +1541,8 @@ "edit": "Upravit rozhraní", "restart_stack": "Restartujte zásobník", "add_auto": "Přidat automatické rozhraní", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "Otevřená rozhraní." }, "audit": { "tcp_enable_key": "Rozhraní TCP „{{name}}“ používá nesprávný povolovací klíč – RNS jej nenačte.", @@ -1585,7 +1586,11 @@ "sidecarUnhealthy": "Postranní vozík Reticulum běží, ale nereaguje na zdravotní prohlídky", "propagationSyncStuck": "Synchronizace propagačního uzlu se zasekla při vytváření odkazu", "propagationSyncFailing": "Synchronizace propagačního uzlu se nezdařila", - "announceBusPressure": "Vysoká míra odezvy na oznámení/cestu může zpozdit příchozí doručení chatu LXMF — dohnání je aktivní; očekávají se velké tabulky cest" + "announceBusPressure": "Vysoká míra odezvy na oznámení/cestu může zpozdit příchozí doručení chatu LXMF — dohnání je aktivní; očekávají se velké tabulky cest", + "announceBusPressureTipDisableHubs": "Zakázat nepoužívané rozbočovače TCP (Connection → Interfaces) — největší páka pro otočení traťového stolu.", + "announceBusPressureTipShareInstance": "Vypněte možnost Sdílet instanci, pokud s tímto zásobníkem bojují jiné místní aplikace Reticulum.", + "announceBusPressureTipAnnounceInterval": "Interval oznamování sítě řídí pouze odchozí oznámení tohoto uzlu — nezastaví příchozí zaplavení cesty a odezvy.", + "announceBusPressureTipWait": "Dohánění je již aktivní; toto varování se po asi 5 minutách vymaže, aniž by došlo ke zpoždění, bouři nebo přetečení." } }, "routingPort": { diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index a97b09ba3..79e07f873 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -1539,7 +1539,8 @@ "edit": "Schnittstelle bearbeiten", "restart_stack": "Stapel neu starten", "add_auto": "Automatische Schnittstelle hinzufügen", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "Offene Schnittstellen" }, "audit": { "tcp_enable_key": "Die TCP-Schnittstelle „{{name}}“ verwendet den falschen Aktivierungsschlüssel – RNS lädt ihn nicht.", @@ -1583,7 +1584,11 @@ "sidecarUnhealthy": "Reticulum Sidecar läuft, reagiert aber nicht auf Gesundheitschecks", "propagationSyncStuck": "Die Synchronisierung des Ausbreitungsknotens bleibt hängen, um eine Verbindung herzustellen", "propagationSyncFailing": "Synchronisierung des Ausbreitungsknotens fehlgeschlagen", - "announceBusPressure": "Hohe Ansage-/Pfad-Antwort-Rate kann eingehende LXMF-Chat-Zustellung verzögern — Aufholjagd ist aktiv; große Pfad-Tabellen werden erwartet" + "announceBusPressure": "Hohe Ansage-/Pfad-Antwort-Rate kann eingehende LXMF-Chat-Zustellung verzögern — Aufholjagd ist aktiv; große Pfad-Tabellen werden erwartet", + "announceBusPressureTipDisableHubs": "Deaktivieren Sie unbenutzte TCP-Hubs (→Verbindungsschnittstellen) — der größte Hebel für Pfad-Tabellen-Abwanderung.", + "announceBusPressureTipShareInstance": "Deaktivieren Sie die Share-Instanz, wenn andere lokale Reticulum-Apps mit diesem Stack konkurrieren.", + "announceBusPressureTipAnnounceInterval": "Das Netzwerk-Ankündigungsintervall steuert nur die ausgehenden Ansagen dieses Knotens — es stoppt keine eingehenden Pfad-Antwort-Überschwemmungen.", + "announceBusPressureTipWait": "Catch-up ist bereits aktiv; diese Warnung löscht sich nach ca. 5 Minuten ohne Verzögerung, Sturm oder Überlauf." } }, "routingPort": { diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 593696ab8..cd147c559 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -1561,7 +1561,8 @@ "edit": "Edit interface", "restart_stack": "Restart stack", "add_auto": "Add Auto interface", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "Open Interfaces" }, "runtime": { "rnsNotReady": "RNS stack is not ready", @@ -1584,7 +1585,11 @@ "sidecarUnhealthy": "Reticulum sidecar is running but not responding to health checks", "propagationSyncStuck": "Propagation node sync is stuck establishing a link", "propagationSyncFailing": "Propagation node sync failed", - "announceBusPressure": "High announce/path-response rate may delay inbound LXMF Chat delivery — catch-up is active; large path tables are expected" + "announceBusPressure": "High announce/path-response rate may delay inbound LXMF Chat delivery — catch-up is active; large path tables are expected", + "announceBusPressureTipDisableHubs": "Disable unused TCP hubs (Connection → Interfaces) — the biggest lever for path-table churn.", + "announceBusPressureTipShareInstance": "Turn off Share instance if other local Reticulum apps are contending with this stack.", + "announceBusPressureTipAnnounceInterval": "Network announce interval only controls this node’s outbound announces — it does not stop inbound path-response floods.", + "announceBusPressureTipWait": "Catch-up is already active; this warning clears after about 5 minutes without lag, storm, or overflow." }, "audit": { "tcp_enable_key": "TCP interface \"{{name}}\" uses the wrong enable key — RNS will not load it.", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 5f8273f98..651bc2ac0 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -1539,7 +1539,8 @@ "edit": "Editar interfaz", "restart_stack": "Reiniciar pila", "add_auto": "Agregar interfaz automática", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "Interfaces abiertas" }, "audit": { "tcp_enable_key": "La interfaz TCP \"{{name}}\" utiliza la clave de habilitación incorrecta: RNS no la cargará.", @@ -1583,7 +1584,11 @@ "sidecarUnhealthy": "El sidecar de Reticulum está en ejecución pero no responde a las comprobaciones de estado", "propagationSyncStuck": "La sincronización del nodo de propagación está atascada estableciendo un enlace", "propagationSyncFailing": "Error en la sincronización del nodo de propagación", - "announceBusPressure": "La alta tasa de anuncio/respuesta de ruta puede retrasar la entrega de chat LXMF entrante: la recuperación está activa; se esperan tablas de ruta grandes" + "announceBusPressure": "La alta tasa de anuncio/respuesta de ruta puede retrasar la entrega de chat LXMF entrante: la recuperación está activa; se esperan tablas de ruta grandes", + "announceBusPressureTipDisableHubs": "Deshabilite los concentradores TCP (→interfaces de conexión) no utilizados: la palanca más grande para la rotación de la mesa de ruta.", + "announceBusPressureTipShareInstance": "Desactive la instancia Compartir si otras aplicaciones locales de Reticulum están compitiendo con esta pila.", + "announceBusPressureTipAnnounceInterval": "El intervalo de anuncio de red solo controla los anuncios salientes de este nodo: no detiene las inundaciones de respuesta de ruta entrante.", + "announceBusPressureTipWait": "La recuperación ya está activa; esta advertencia se borra después de unos 5 minutos sin retraso, tormenta o desbordamiento." } }, "routingPort": { diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 62dc063c2..6eacfa378 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -1539,7 +1539,8 @@ "edit": "Modifier l'interface", "restart_stack": "Redémarrer la pile", "add_auto": "Ajouter une interface automatique", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "interfaces ouverts" }, "audit": { "tcp_enable_key": "L'interface TCP \"{{name}}\" utilise la mauvaise clé d'activation — RNS ne la chargera pas.", @@ -1583,7 +1584,11 @@ "sidecarUnhealthy": "Le sidecar Reticulum est en cours d’exécution mais ne répond pas aux contrôles de santé", "propagationSyncStuck": "La synchronisation du nœud de propagation est bloquée pour établir un lien", "propagationSyncFailing": "Échec de la synchronisation du nœud de propagation", - "announceBusPressure": "Un taux élevé d'annonce/réponse au chemin peut retarder la livraison du chat LXMF entrant — le rattrapage est actif ; de grandes tables de chemin sont attendues" + "announceBusPressure": "Un taux élevé d'annonce/réponse au chemin peut retarder la livraison du chat LXMF entrant — le rattrapage est actif ; de grandes tables de chemin sont attendues", + "announceBusPressureTipDisableHubs": "Désactivez les concentrateurs TCP inutilisés (→interfaces de connexion) — le plus grand levier pour le désabonnement des tables de cheminement.", + "announceBusPressureTipShareInstance": "Désactivez Partager l'instance si d'autres applications Reticulum locales sont en conflit avec cette pile.", + "announceBusPressureTipAnnounceInterval": "L'intervalle d'annonce du réseau contrôle uniquement les annonces sortantes de ce nœud — il n'arrête pas les inondations de chemin-réponse entrantes.", + "announceBusPressureTipWait": "Le rattrapage est déjà actif ; cet avertissement disparaît après environ 5 minutes sans décalage, tempête ou débordement." } }, "routingPort": { diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 1ff097d44..84accaacf 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -1539,7 +1539,8 @@ "edit": "Sunting antarmuka", "restart_stack": "Mulai ulang tumpukan", "add_auto": "Tambahkan antarmuka Otomatis", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "Buka Antarmuka" }, "audit": { "tcp_enable_key": "Antarmuka TCP \"{{name}}\" menggunakan kunci pengaktifan yang salah — RNS tidak akan memuatnya.", @@ -1583,7 +1584,11 @@ "sidecarUnhealthy": "Reticulum sidecar sedang berjalan tetapi tidak menanggapi pemeriksaan kesehatan", "propagationSyncStuck": "Sinkronisasi simpul propagasi macet membuat tautan", "propagationSyncFailing": "Sinkronisasi simpul propagasi gagal", - "announceBusPressure": "Tingkat pengumuman/respons jalur yang tinggi dapat menunda pengiriman Obrolan LXMF masuk — tangkapan aktif; tabel jalur besar diharapkan" + "announceBusPressure": "Tingkat pengumuman/respons jalur yang tinggi dapat menunda pengiriman Obrolan LXMF masuk — tangkapan aktif; tabel jalur besar diharapkan", + "announceBusPressureTipDisableHubs": "Nonaktifkan hub TCP yang tidak digunakan (→Antarmuka Koneksi) — tuas terbesar untuk churn tabel jalur.", + "announceBusPressureTipShareInstance": "Nonaktifkan Bagikan instance jika aplikasi Reticulum lokal lainnya bersaing dengan tumpukan ini.", + "announceBusPressureTipAnnounceInterval": "Interval pengumuman jaringan hanya mengontrol pengumuman keluar simpul ini — tidak menghentikan banjir respons jalur masuk.", + "announceBusPressureTipWait": "Penangkapan sudah aktif; peringatan ini hilang setelah sekitar 5 menit tanpa lag, badai, atau luapan." } }, "routingPort": { diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 88d4cc6ed..dea8bf381 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -1539,7 +1539,8 @@ "edit": "Modifica interfaccia", "restart_stack": "Riavvia lo stack", "add_auto": "Aggiungi l'interfaccia automatica", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "Interfacce Aperte" }, "audit": { "tcp_enable_key": "L'interfaccia TCP \"{{name}}\" utilizza la chiave di abilitazione errata: RNS non la caricherà.", @@ -1583,7 +1584,11 @@ "sidecarUnhealthy": "Il sidecar Reticulum è in esecuzione ma non risponde ai controlli di integrità", "propagationSyncStuck": "La sincronizzazione del nodo di propagazione è bloccata stabilendo un collegamento", "propagationSyncFailing": "Sincronizzazione del nodo di propagazione non riuscita", - "announceBusPressure": "Un alto tasso di annuncio/risposta al percorso può ritardare la consegna della chat LXMF in entrata — il recupero è attivo; sono previste tabelle di percorso di grandi dimensioni" + "announceBusPressure": "Un alto tasso di annuncio/risposta al percorso può ritardare la consegna della chat LXMF in entrata — il recupero è attivo; sono previste tabelle di percorso di grandi dimensioni", + "announceBusPressureTipDisableHubs": "Disabilita gli hub TCP (→interfacce di connessione) inutilizzati, la più grande leva per l'abbandono della tabella di percorso.", + "announceBusPressureTipShareInstance": "Disattiva l'istanza di condivisione se altre app Reticulum locali sono in conflitto con questo stack.", + "announceBusPressureTipAnnounceInterval": "L'intervallo di annuncio della rete controlla solo gli annunci in uscita di questo nodo: non arresta le inondazioni di risposta al percorso in entrata.", + "announceBusPressureTipWait": "Il recupero è già attivo; questo avviso scompare dopo circa 5 minuti senza ritardi, tempeste o traboccamenti." } }, "routingPort": { diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 3006b10ed..0946419a4 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -1539,7 +1539,8 @@ "edit": "編集インターフェース", "restart_stack": "スタックを再起動します", "add_auto": "自動インターフェースの追加", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "インターフェースを開く" }, "audit": { "tcp_enable_key": "TCP インターフェイス「{{name}}」は間違った有効化キーを使用しています。RNS はそれをロードしません。", @@ -1583,7 +1584,11 @@ "sidecarUnhealthy": "Reticulumサイドカーは実行中ですが、ヘルスチェックに応答していません", "propagationSyncStuck": "伝播ノードの同期がリンクの確立に固執しています", "propagationSyncFailing": "伝播ノードの同期に失敗しました", - "announceBusPressure": "高いアナウンス/パス応答率は、インバウンドLXMFチャット配信を遅らせる可能性があります—キャッチアップがアクティブです。大きなパステーブルが予想されます" + "announceBusPressure": "高いアナウンス/パス応答率は、インバウンドLXMFチャット配信を遅らせる可能性があります—キャッチアップがアクティブです。大きなパステーブルが予想されます", + "announceBusPressureTipDisableHubs": "未使用のTCPハブ(接続→インターフェース)を無効にします。これは、パステーブルチャーンの最大のレバーです。", + "announceBusPressureTipShareInstance": "他のローカルReticulumアプリがこのスタックと競合している場合は、共有インスタンスをオフにします。", + "announceBusPressureTipAnnounceInterval": "ネットワークアナウンス間隔は、このノードのアウトバウンドアナウンスのみを制御します。インバウンドパス応答フラッドは停止しません。", + "announceBusPressureTipWait": "キャッチアップはすでに有効になっています。この警告は、約5分後にラグ、嵐、またはオーバーフローなしでクリアされます。" } }, "routingPort": { diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index d83325a47..91b4ad3c1 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -1539,7 +1539,8 @@ "edit": "인터페이스 수정", "restart_stack": "스택 다시 시작", "add_auto": "자동 인터페이스 추가", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "인터페이스 열기" }, "audit": { "tcp_enable_key": "TCP 인터페이스 \"{{name}}\"이 잘못된 활성화 키를 사용합니다. RNS가 이를 로드하지 않습니다.", @@ -1583,7 +1584,11 @@ "sidecarUnhealthy": "Reticulum 사이드카가 작동 중이지만 상태 점검에 응답하지 않습니다", "propagationSyncStuck": "전파 노드 동기화가 링크 설정 중 중단됨", "propagationSyncFailing": "전파 노드 동기화 실패", - "announceBusPressure": "높은 발표/경로 응답률로 인해 인바운드 LXMF 채팅 전달이 지연될 수 있음 — 캐치업이 활성화되어 있음, 큰 경로 테이블이 예상됨" + "announceBusPressure": "높은 발표/경로 응답률로 인해 인바운드 LXMF 채팅 전달이 지연될 수 있음 — 캐치업이 활성화되어 있음, 큰 경로 테이블이 예상됨", + "announceBusPressureTipDisableHubs": "사용되지 않는 TCP 허브 (연결 → 인터페이스) 를 비활성화합니다. 이는 경로 테이블 이탈을 위한 가장 큰 레버입니다.", + "announceBusPressureTipShareInstance": "다른 로컬 Reticulum 앱이 이 스택과 경쟁하는 경우 인스턴스 공유를 끄십시오.", + "announceBusPressureTipAnnounceInterval": "네트워크 알림 간격은 이 노드의 아웃바운드 알림만 제어하며 인바운드 경로 응답 플러드를 중지하지 않습니다.", + "announceBusPressureTipWait": "따라잡기가 이미 활성화되어 있습니다. 이 경고는 지연, 폭풍 또는 오버플로 없이 약 5분 후에 지워집니다." } }, "routingPort": { diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 0347ad611..ed07d4753 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -1539,7 +1539,8 @@ "edit": "Bewerkingsinterface", "restart_stack": "Start de stapel opnieuw", "add_auto": "Automatische interface toevoegen", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "Open interfaces" }, "audit": { "tcp_enable_key": "TCP-interface \"{{name}}\" gebruikt de verkeerde inschakelsleutel - RNS zal deze niet laden.", @@ -1583,7 +1584,11 @@ "sidecarUnhealthy": "Reticulum-zijspan loopt, maar reageert niet op gezondheidscontroles", "propagationSyncStuck": "Synchronisatie van propagatieknooppunt zit vast bij het tot stand brengen van een link", "propagationSyncFailing": "Synchronisatie van propagatieknooppunt mislukt", - "announceBusPressure": "Hoog aankondigings-/padresponspercentage kan inkomende LXMF-chatlevering vertragen — inhaalactie is actief; er worden grote padtabellen verwacht" + "announceBusPressure": "Hoog aankondigings-/padresponspercentage kan inkomende LXMF-chatlevering vertragen — inhaalactie is actief; er worden grote padtabellen verwacht", + "announceBusPressureTipDisableHubs": "Schakel ongebruikte TCP-hubs (Connection → Interfaces) uit — de grootste hendel voor het churn van de path-table.", + "announceBusPressureTipShareInstance": "Schakel instantie delen uit als andere lokale Reticulum-apps met deze stapel te maken hebben.", + "announceBusPressureTipAnnounceInterval": "Netwerkaankondigingsinterval regelt alleen de uitgaande aankondigingen van dit knooppunt — het stopt inkomende padresponsoverstromingen niet.", + "announceBusPressureTipWait": "Inhalen is al actief; deze waarschuwing verdwijnt na ongeveer 5 minuten zonder vertraging, storm of overloop." } }, "routingPort": { diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 0ddf2fadd..0a328e293 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -1543,7 +1543,8 @@ "edit": "Edytuj interfejs", "restart_stack": "Uruchom ponownie stos", "add_auto": "Dodaj interfejs automatyczny", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "Otwarte interfejsy" }, "audit": { "tcp_enable_key": "Interfejs TCP „{{name}}” używa niewłaściwego klucza włączającego — RNS go nie załaduje.", @@ -1587,7 +1588,11 @@ "sidecarUnhealthy": "Sidecar Reticulum działa, ale nie odpowiada na kontrole stanu", "propagationSyncStuck": "Synchronizacja węzła propagacji utknęła, tworząc łącze", "propagationSyncFailing": "Synchronizacja węzła propagacji nie powiodła się", - "announceBusPressure": "Wysoki wskaźnik zapowiedzi/reakcji na ścieżkę może opóźnić dostawę przychodzącego czatu LXMF — nadrabianie zaległości jest aktywne; oczekiwane są duże tabele ścieżek" + "announceBusPressure": "Wysoki wskaźnik zapowiedzi/reakcji na ścieżkę może opóźnić dostawę przychodzącego czatu LXMF — nadrabianie zaległości jest aktywne; oczekiwane są duże tabele ścieżek", + "announceBusPressureTipDisableHubs": "Wyłącz nieużywane koncentratory TCP (→interfejsy połączeń) — największa dźwignia do migracji tabeli ścieżek.", + "announceBusPressureTipShareInstance": "Wyłącz opcję Udostępnij instancję, jeśli inne lokalne aplikacje Reticulum walczą z tym stosem.", + "announceBusPressureTipAnnounceInterval": "Interwał ogłaszania sieci kontroluje tylko komunikaty wychodzące tego węzła — nie zatrzymuje powodzi odpowiedzi na trasę przychodzącą.", + "announceBusPressureTipWait": "Nadrabianie zaległości jest już aktywne; to ostrzeżenie zostaje usunięte po około 5 minutach bez opóźnień, burzy lub przepełnienia." } }, "routingPort": { diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 773b5deb4..9b113fb28 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -1539,7 +1539,8 @@ "edit": "Editar interface", "restart_stack": "Reiniciar pilha", "add_auto": "Adicionar interface automática", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "Open interfaces" }, "audit": { "tcp_enable_key": "A interface TCP \"{{name}}\" usa a chave de ativação errada — o RNS não a carregará.", @@ -1583,7 +1584,11 @@ "sidecarUnhealthy": "O sidecar Reticulum está em execução, mas não responde às verificações de integridade", "propagationSyncStuck": "A sincronização do nó de propagação está travada, estabelecendo um link", "propagationSyncFailing": "Falha na sincronização do nó de propagação", - "announceBusPressure": "A alta taxa de anúncio/resposta ao caminho pode atrasar a entrega do chat LXMF de entrada — a recuperação está ativa; grandes tabelas de caminho são esperadas" + "announceBusPressure": "A alta taxa de anúncio/resposta ao caminho pode atrasar a entrega do chat LXMF de entrada — a recuperação está ativa; grandes tabelas de caminho são esperadas", + "announceBusPressureTipDisableHubs": "Desative os hubs TCP (→Interfaces de Conexão) não utilizados — a maior alavanca para rotatividade de mesa de caminho.", + "announceBusPressureTipShareInstance": "Desative a instância Compartilhar se outros aplicativos Reticulum locais estiverem em conflito com esta pilha.", + "announceBusPressureTipAnnounceInterval": "O intervalo de anúncio de rede controla apenas os anúncios de saída deste nó — ele não interrompe as inundações de resposta ao caminho de entrada.", + "announceBusPressureTipWait": "A recuperação já está ativa; este aviso desaparece após cerca de 5 minutos sem atraso, tempestade ou transbordamento." } }, "routingPort": { diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 248bc0d0f..3f3b9ffe3 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -1541,7 +1541,8 @@ "edit": "Редактировать интерфейс", "restart_stack": "Перезапустить стек", "add_auto": "Добавить автоматический интерфейс", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "Открытые интерфейсы" }, "audit": { "tcp_enable_key": "TCP-интерфейс «{{name}}» использует неверный ключ включения — RNS не загрузит его.", @@ -1585,7 +1586,11 @@ "sidecarUnhealthy": "Sidecar Reticulum работает, но не отвечает на проверки состояния", "propagationSyncStuck": "Синхронизация узла распространения застряла при установлении канала", "propagationSyncFailing": "Ошибка синхронизации узла распространения", - "announceBusPressure": "Высокая скорость ответа на объявление/путь может задержать доставку входящего чата LXMF — активен догоняющий процесс; ожидаются большие таблицы путей" + "announceBusPressure": "Высокая скорость ответа на объявление/путь может задержать доставку входящего чата LXMF — активен догоняющий процесс; ожидаются большие таблицы путей", + "announceBusPressureTipDisableHubs": "Отключите неиспользуемые концентраторы TCP (→интерфейсы подключения) — самый большой рычаг для оттока таблиц путей.", + "announceBusPressureTipShareInstance": "Отключите Share instance, если с этим стеком борются другие локальные приложения Reticulum.", + "announceBusPressureTipAnnounceInterval": "Сетевой интервал объявлений управляет только исходящими объявлениями этого узла — он не останавливает флуды входящего пути-ответа.", + "announceBusPressureTipWait": "Перехват уже активен; это предупреждение исчезает примерно через 5 минут без задержки, шторма или переполнения." } }, "routingPort": { diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 3fa90078f..f3bb70a9f 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -1539,7 +1539,8 @@ "edit": "Arayüzü düzenle", "restart_stack": "Yığını yeniden başlat", "add_auto": "Otomatik arayüz ekle", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "Açık Arayüzler" }, "audit": { "tcp_enable_key": "TCP arayüzü \"{{name}}\" yanlış etkinleştirme anahtarını kullanıyor — RNS bunu yüklemeyecek.", @@ -1583,7 +1584,11 @@ "sidecarUnhealthy": "Reticulum sepeti çalışıyor ancak sağlık kontrollerine yanıt vermiyor", "propagationSyncStuck": "Yayılım düğümü senkronizasyonu bir bağlantı kurarken takıldı", "propagationSyncFailing": "Yayılma düğümü senkronizasyonu başarısız oldu", - "announceBusPressure": "Yüksek anons/yol - yanıt oranı, gelen LXMF Sohbet teslimatını geciktirebilir — telafi aktif; büyük yol tabloları bekleniyor" + "announceBusPressure": "Yüksek anons/yol - yanıt oranı, gelen LXMF Sohbet teslimatını geciktirebilir — telafi aktif; büyük yol tabloları bekleniyor", + "announceBusPressureTipDisableHubs": "Yol tablosu dalgalanması için en büyük kaldıraç olan kullanılmayan TCP hub'larını (Bağlantı → Arabirimleri) devre dışı bırakın.", + "announceBusPressureTipShareInstance": "Diğer yerel Reticulum uygulamaları bu yığınla mücadele ediyorsa Paylaşım örneğini kapat.", + "announceBusPressureTipAnnounceInterval": "Ağ duyuru aralığı yalnızca bu düğümün giden duyurularını kontrol eder — gelen yol - yanıt sellerini durdurmaz.", + "announceBusPressureTipWait": "Yetişme zaten aktif; bu uyarı yaklaşık 5 dakika sonra gecikme, fırtına veya taşma olmadan silinir." } }, "routingPort": { diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index a8154e759..4885e5274 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -1541,7 +1541,8 @@ "edit": "Редагувати інтерфейс", "restart_stack": "Перезапустити стек", "add_auto": "Додати автоматичний інтерфейс", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "Відкриті інтерфейси" }, "audit": { "tcp_enable_key": "Інтерфейс TCP \"{{name}}\" використовує неправильний ключ увімкнення — RNS не завантажить його.", @@ -1585,7 +1586,11 @@ "sidecarUnhealthy": "Sidecar Reticulum працює, але не відповідає на перевірки здоров'я", "propagationSyncStuck": "Синхронізація вузла розповсюдження застрягла під час встановлення зв'язку", "propagationSyncFailing": "Помилка синхронізації вузла поширення", - "announceBusPressure": "Висока частота оголошень/шлях-відповідь може затримати вхідну доставку чату LXMF — наздоганяючий ефект активний; очікується велика таблиця шляхів" + "announceBusPressure": "Висока частота оголошень/шлях-відповідь може затримати вхідну доставку чату LXMF — наздоганяючий ефект активний; очікується велика таблиця шляхів", + "announceBusPressureTipDisableHubs": "Вимкніть невикористані TCP-хаби (З'єднання → Інтерфейси) — найбільший важіль для зменшення плинності таблиці шляхів.", + "announceBusPressureTipShareInstance": "Вимкніть функцію Поділитися екземпляром, якщо інші локальні програми Reticulum стикаються з цим стеком.", + "announceBusPressureTipAnnounceInterval": "Мережевий інтервал оголошень контролює лише вихідні оголошення цього вузла — він не зупиняє вхідні потоки шлях-відповідь.", + "announceBusPressureTipWait": "Перехоплення вже активне; це попередження зникає приблизно через 5 хвилин без затримки, шторму або переповнення." } }, "routingPort": { diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 234e89870..18353716e 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -1539,7 +1539,8 @@ "edit": "编辑界面", "restart_stack": "重启堆栈", "add_auto": "添加自动接口", - "disable_share_instance": "Turn off Share instance" + "disable_share_instance": "Turn off Share instance", + "open_interfaces": "打开接口" }, "audit": { "tcp_enable_key": "TCP 接口“{{name}}”使用了错误的启用密钥 — RNS 将不会加载它。", @@ -1583,7 +1584,11 @@ "sidecarUnhealthy": "Reticulum sidecar正在运行,但未响应健康检查", "propagationSyncStuck": "传播节点同步卡住,无法建立链接", "propagationSyncFailing": "传播节点同步失败", - "announceBusPressure": "高公告/路径响应率可能会延迟入站LXMF聊天交付—追赶处于活动状态;预计会有大型路径表" + "announceBusPressure": "高公告/路径响应率可能会延迟入站LXMF聊天交付—追赶处于活动状态;预计会有大型路径表", + "announceBusPressureTipDisableHubs": "禁用未使用的TCP集线器(连接→接口) —路径表流失的最大杠杆。", + "announceBusPressureTipShareInstance": "如果其他本地Reticulum应用程序正在与此堆栈竞争,请关闭共享实例。", + "announceBusPressureTipAnnounceInterval": "网络通告间隔仅控制此节点的出站通告—它不会停止入站路径响应泛洪。", + "announceBusPressureTipWait": "追赶已激活;此警告在大约5分钟后清除,没有滞后、暴风雨或溢出。" } }, "routingPort": { From 8992a42beb28600d466aaeebf710d26a76a79600 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 30 Jul 2026 19:26:55 -0600 Subject: [PATCH 4/4] refactor(reticulum): serialize announce pressure tests and extract LXMF catch-up Avoid flaky parallel reads of process-global announce_ws atomics, and move catch-up fetch/ingest/watermark into a lib helper so the runtime only wires diagnostics callbacks. --- .../src/stack/announce_ws_coalesce.rs | 13 ++++ .../catchUpRecentInboundLxmf.test.ts | 61 +++++++++++++++++++ .../lib/reticulum/catchUpRecentInboundLxmf.ts | 49 +++++++++++++++ src/renderer/runtime/useReticulumRuntime.ts | 31 +++------- 4 files changed, 132 insertions(+), 22 deletions(-) create mode 100644 src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts create mode 100644 src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts diff --git a/reticulum-sidecar/src/stack/announce_ws_coalesce.rs b/reticulum-sidecar/src/stack/announce_ws_coalesce.rs index 1daa239fa..6392d63a9 100644 --- a/reticulum-sidecar/src/stack/announce_ws_coalesce.rs +++ b/reticulum-sidecar/src/stack/announce_ws_coalesce.rs @@ -177,6 +177,15 @@ pub fn build_announce_received_frame(rows: &[AnnounceWsRow]) -> Option { #[cfg(test)] mod tests { use super::*; + use std::sync::{Mutex, OnceLock}; + + /// Process-global pressure atomics — serialize tests that flush / read them. + fn pressure_metrics_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } fn row(hash: &str, name: Option<&str>) -> AnnounceWsRow { AnnounceWsRow { @@ -188,6 +197,7 @@ mod tests { #[test] fn last_write_wins_per_destination() { + let _guard = pressure_metrics_lock(); let mut c = AnnounceWsCoalescer::new(); c.push(row("aa", Some("Old"))); c.push(row("aa", Some("New"))); @@ -209,6 +219,7 @@ mod tests { #[test] fn flush_prefers_named_when_over_cap_and_records_overflow() { + let _guard = pressure_metrics_lock(); let mut c = AnnounceWsCoalescer::new(); for i in 0..(ANNOUNCE_WS_FLUSH_MAX + 50) { c.push(row(&format!("{i:032x}"), None)); @@ -234,6 +245,7 @@ mod tests { #[test] fn storm_widens_coalesce_duration_and_stamps_storm_time() { + let _guard = pressure_metrics_lock(); let mut c = AnnounceWsCoalescer::new(); for i in 0..=ANNOUNCE_WS_STORM_PENDING { c.push(row(&format!("{i:032x}"), None)); @@ -272,6 +284,7 @@ mod tests { #[test] fn many_distinct_dests_still_one_flush_batch() { + let _guard = pressure_metrics_lock(); let mut c = AnnounceWsCoalescer::new(); for i in 0..5000 { c.push(row(&format!("{i:032x}"), None)); diff --git a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts new file mode 100644 index 000000000..068851842 --- /dev/null +++ b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest'; +import { fetchRecentInboundLxmfDetailed } from '@/renderer/lib/reticulum/fetchRecentInboundLxmf'; + +import { catchUpRecentInboundLxmf } from './catchUpRecentInboundLxmf'; + +vi.mock('@/renderer/lib/reticulum/fetchRecentInboundLxmf', () => ({ + fetchRecentInboundLxmfDetailed: vi.fn(), +})); + +function sample(hash: string, timestamp: number): ReticulumLxmfPayload { + return { + sender_hash: 'e16af7d675a0ae7f3067185800a46678', + text: 'hi', + timestamp, + direction: 'inbound', + message_hash: hash, + }; +} + +describe('catchUpRecentInboundLxmf', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + beforeEach(() => { + warnSpy.mockClear(); + vi.mocked(fetchRecentInboundLxmfDetailed).mockReset(); + }); + + it('returns null when identityId is empty', async () => { + await expect(catchUpRecentInboundLxmf({ identityId: '', ingest: vi.fn() })).resolves.toBeNull(); + expect(fetchRecentInboundLxmfDetailed).not.toHaveBeenCalled(); + }); + + it('returns null when the ring is empty', async () => { + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ messages: [], ringLen: 0 }); + await expect( + catchUpRecentInboundLxmf({ identityId: 'id-1', ingest: vi.fn() }), + ).resolves.toBeNull(); + }); + + it('ingests rows, warns, and returns count plus watermark', async () => { + const ingest = vi.fn(); + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ + messages: [sample('aa'.repeat(32), 1_000), sample('bb'.repeat(32), 2_500)], + ringLen: 2, + }); + + const outcome = await catchUpRecentInboundLxmf({ + identityId: 'id-1', + ingest, + sinceTs: 500, + reason: 'periodic', + }); + + expect(fetchRecentInboundLxmfDetailed).toHaveBeenCalledWith({ limit: 200, sinceTs: 500 }); + expect(ingest).toHaveBeenCalledTimes(2); + expect(outcome).toEqual({ count: 2, watermarkTs: 2_500 }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('count=2 reason=periodic')); + }); +}); diff --git a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts new file mode 100644 index 000000000..ab5cc4a27 --- /dev/null +++ b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts @@ -0,0 +1,49 @@ +import type { ReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest'; +import { fetchRecentInboundLxmfDetailed } from '@/renderer/lib/reticulum/fetchRecentInboundLxmf'; + +export interface CatchUpRecentInboundLxmfOpts { + identityId: string; + ingest: (payload: ReticulumLxmfPayload) => void; + sinceTs?: number; + reason?: string; +} + +export interface CatchUpRecentInboundLxmfOutcome { + count: number; + /** Max payload timestamp among ingested rows; null when none usable for watermark. */ + watermarkTs: number | null; +} + +/** + * Fetch recent inbound LXMF, ingest rows, and compute the catch-up watermark. + * Caller applies diagnostics (`noteReticulumInboundCatchUp` / watermark advance). + */ +export async function catchUpRecentInboundLxmf( + opts: CatchUpRecentInboundLxmfOpts, +): Promise { + if (!opts.identityId) return null; + + const { messages: rows } = await fetchRecentInboundLxmfDetailed({ + limit: 200, + ...(opts.sinceTs != null ? { sinceTs: opts.sinceTs } : {}), + }); + if (rows.length === 0) return null; + + const reason = opts.reason ?? 'catch-up'; + console.warn( + `[catchUpRecentInboundLxmf] inbound LXMF catch-up count=${rows.length} reason=${reason}`, + ); + + let maxTs = opts.sinceTs ?? 0; + for (const p of rows) { + opts.ingest(p); + if (typeof p.timestamp === 'number' && Number.isFinite(p.timestamp) && p.timestamp > maxTs) { + maxTs = p.timestamp; + } + } + + return { + count: rows.length, + watermarkTs: maxTs > 0 ? maxTs : null, + }; +} diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 15483b5a0..dfa0d4eb5 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -28,6 +28,7 @@ import { applyReticulumOutboundDeliveryStatus, flushPendingReticulumOutboundDeliveryStatus, } from '@/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus'; +import { catchUpRecentInboundLxmf as runInboundLxmfCatchUp } from '@/renderer/lib/reticulum/catchUpRecentInboundLxmf'; import { resolveReticulumOutboundViaFromPath, reticulumViaToMessageTransport, @@ -37,7 +38,6 @@ import { resolveReticulumDestinationHash, reticulumHashToNodeId, } from '@/renderer/lib/reticulum/destHash'; -import { fetchRecentInboundLxmfDetailed } from '@/renderer/lib/reticulum/fetchRecentInboundLxmf'; import { extractLxmfPayloadFromSendResponse } from '@/renderer/lib/reticulum/lxmfSendResponse'; import { markStaleReticulumOutboundInStore, @@ -612,29 +612,16 @@ export function useReticulumRuntime(): ProtocolRuntime { const catchUpRecentInboundLxmf = useCallback( async (opts?: { sinceTs?: number; reason?: string }) => { if (!identityId) return; - const { messages: rows } = await fetchRecentInboundLxmfDetailed({ - limit: 200, + const outcome = await runInboundLxmfCatchUp({ + identityId, + ingest: ingestLxmfPayload, ...(opts?.sinceTs != null ? { sinceTs: opts.sinceTs } : {}), + ...(opts?.reason != null ? { reason: opts.reason } : {}), }); - if (rows.length === 0) return; - const reason = opts?.reason ?? 'catch-up'; - console.warn( - `[useReticulumRuntime] inbound LXMF catch-up count=${rows.length} reason=${reason}`, - ); - noteReticulumInboundCatchUp(rows.length); - let maxTs = opts?.sinceTs ?? 0; - for (const p of rows) { - ingestLxmfPayload(p); - if ( - typeof p.timestamp === 'number' && - Number.isFinite(p.timestamp) && - p.timestamp > maxTs - ) { - maxTs = p.timestamp; - } - } - if (maxTs > 0) { - advanceReticulumInboundCatchUpWatermark(maxTs); + if (!outcome) return; + noteReticulumInboundCatchUp(outcome.count); + if (outcome.watermarkTs != null) { + advanceReticulumInboundCatchUpWatermark(outcome.watermarkTs); } }, [identityId, ingestLxmfPayload],