From f69168afbefe8121ec4091690571d89db1ca9b15 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 9 Aug 2026 17:28:23 -0600 Subject: [PATCH 1/6] fix(reticulum): stop PN Sync re-fetch, stuck /get, and Auto false empties Persist client /get have-ids, abort mid-transfer cancels, keep Host enabled for local settle, demote absurd hop ghosts, and skip known RNCP control LXMF. --- ...MF-propagation-client-abort-transfer.patch | 27 ++ reticulum-sidecar/src/stack/mod.rs | 6 +- .../src/stack/propagation_bridge.rs | 262 +++++++++++++++++- ...sLXMF-propagation-client-abort-transfer.sh | 38 +++ scripts/lib/ratspeak-overlay-apply-list.sh | 1 + scripts/update.sh | 1 + .../reticulumPropagationAutoApply.test.ts | 25 ++ .../reticulumPropagationAutoApply.ts | 22 +- .../reticulumPropagationMode.test.ts | 17 ++ .../lib/reticulum/reticulumPropagationMode.ts | 9 +- src/renderer/runtime/useReticulumRuntime.ts | 18 +- 11 files changed, 404 insertions(+), 22 deletions(-) create mode 100644 reticulum-sidecar/patches/rsLXMF-propagation-client-abort-transfer.patch create mode 100755 scripts/apply-rsLXMF-propagation-client-abort-transfer.sh diff --git a/reticulum-sidecar/patches/rsLXMF-propagation-client-abort-transfer.patch b/reticulum-sidecar/patches/rsLXMF-propagation-client-abort-transfer.patch new file mode 100644 index 000000000..fed85d521 --- /dev/null +++ b/reticulum-sidecar/patches/rsLXMF-propagation-client-abort-transfer.patch @@ -0,0 +1,27 @@ +--- a/crates/lxmf-core/src/propagation_client.rs ++++ b/crates/lxmf-core/src/propagation_client.rs +@@ -182,6 +182,24 @@ + self.status = PropagationTransferStatus::default(); + true + } ++ ++ /// Abort an in-flight or terminal download and return to [`Idle`]. ++ /// ++ /// Unlike [`Self::acknowledge_transfer`], this also tears down mid-transfer ++ /// states (`LinkEstablishing` … `PurgeRequested`) so a cancelled Sync cannot ++ /// leave the client permanently busy (`start_download` would keep failing). ++ pub fn abort_transfer(&mut self) { ++ if matches!(self.status.state, PropagationClientState::Idle) { ++ return; ++ } ++ self.cleanup(); ++ self.available_messages.clear(); ++ self.received_messages.clear(); ++ self.received_ids.clear(); ++ self.status = PropagationTransferStatus::default(); ++ self.identified = false; ++ self.started_at = None; ++ } + + pub fn start_download(&mut self) -> bool { + let node_hash = match self.outbound_propagation_node { diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 11d132497..d00defe90 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -1331,7 +1331,11 @@ impl StackHandle { "storage_bytes".into(), serde_json::Value::Number(stats.bytes.into()), ); - obj.insert("enabled".into(), serde_json::Value::Bool(stats.serving)); + // Keep the user's Host toggle (persisted). Serving is + // reflected in `status` (`active` / `loading` / `idle`) — + // overwriting enabled with serving hid local-prop from + // Auto settle whenever the node was not yet announcing. + obj.insert("enabled".into(), serde_json::Value::Bool(p.enabled)); obj.insert( "status".into(), serde_json::Value::String( diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index 4a649db4b..11b4f8291 100644 --- a/reticulum-sidecar/src/stack/propagation_bridge.rs +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -1,7 +1,7 @@ //! Live propagation node serving and sync against remote propagation nodes. use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::{Duration, Instant}; @@ -28,6 +28,13 @@ type PeeringKeyResult = ([u8; 16], [u8; 32], u32); /// Cap concurrent host-peer peering-key PoW jobs (CPU-heavy stamp generation). const MAX_PEERING_KEY_JOBS: usize = 8; +/// Cap persisted client `/get` have-ids (transient IDs already retrieved). +/// Bound growth on long-lived stacks while covering multi-PN re-sync. +const CLIENT_HAVE_ID_CAP: usize = 8192; + +/// On-disk filename under the propagation storage dir for client have-ids. +const CLIENT_RETRIEVED_IDS_FILE: &str = "client_retrieved_transient_ids.json"; + pub struct PropagationBridge { local_dest_hash: [u8; 16], local_node: Arc>, @@ -36,9 +43,16 @@ pub struct PropagationBridge { /// remote PN into Chat (Python `request_messages_from_propagation_node`). /// Distinct from `sync_task`, which is the `/offer` peer-replication path. client: Mutex, + /// Persisted have-ids so the next `/get` (any PN) reports haves and does not + /// re-download mail we already retrieved (Python local_messages parity). + client_have_path: PathBuf, /// Local identity clone used to decrypt downloaded propagated blobs. identity: Identity, local_serving: AtomicBool, + /// Last successfully observed `(count, bytes)` from [`Self::local_stats`]. + /// Used when `local_node` is held by messagestore load / drain so HTTP list + /// paths never block on that mutex (cold-start proxy timeouts). + cached_local_stats: Mutex<(usize, usize)>, /// Terminal result of background `load_messagestore_from_disk` (`None` while in flight). messagestore_result: Mutex>>, messagestore_notify: Notify, @@ -70,6 +84,7 @@ impl PropagationBridge { policy: &super::pn_hosting_policy::PnHostingPolicy, ) -> Result { std::fs::create_dir_all(&storage_dir).map_err(|e| e.to_string())?; + let client_have_path = storage_dir.join(CLIENT_RETRIEVED_IDS_FILE); let node_config = PropagationNodeConfig { max_storage: policy.message_storage_limit_bytes(), max_message_age: lxmf_core::constants::MESSAGE_EXPIRY, @@ -92,18 +107,33 @@ impl PropagationBridge { sync_task.set_identity(identity.get_public_key(), signing_key); // Client `/get` pull uses the same identity to identify on the PN link // and to decrypt downloaded blobs addressed to our `lxmf.delivery` hash. - let client = PropagationClient::new( + let mut client = PropagationClient::new( transport_tx, Some(identity.get_public_key()), identity.get_signing_key(), ); + // Seed have-ids before any remote `/get` so Auto cascading across PNs + // reports haves instead of re-downloading the same transient IDs. + let seeded = load_client_have_ids(&client_have_path); + for tid in &seeded { + client.add_local_message(*tid); + } + if !seeded.is_empty() { + tracing::info!( + target: "propagation-retrieve", + count = seeded.len(), + "seeded client /get have-ids from disk" + ); + } Ok(Self { local_dest_hash, local_node, sync_task: Mutex::new(sync_task), client: Mutex::new(client), + client_have_path, identity: identity.clone(), local_serving: AtomicBool::new(false), + cached_local_stats: Mutex::new((0, 0)), messagestore_result: Mutex::new(None), messagestore_notify: Notify::new(), sync_lifecycle: Mutex::new(()), @@ -333,10 +363,22 @@ impl PropagationBridge { } pub fn local_stats(&self) -> (usize, usize) { - self.local_node - .lock() - .map(|node| (node.message_count(), node.total_size())) - .unwrap_or((0, 0)) + // Never block HTTP/list callers on messagestore load (or drain): a giant + // Host store can hold `local_node` for many seconds and starve proxyGet. + match self.local_node.try_lock() { + Ok(node) => { + let stats = (node.message_count(), node.total_size()); + if let Ok(mut cache) = self.cached_local_stats.lock() { + *cache = stats; + } + stats + } + Err(_) => self + .cached_local_stats + .lock() + .map(|guard| *guard) + .unwrap_or((0, 0)), + } } fn clear_sticky_errors(&self) { @@ -491,10 +533,11 @@ impl PropagationBridge { /// [`Self::start_client_download`] re-arms from Idle. pub fn cancel_client_download(&self) { if let Ok(mut client) = self.client.lock() { - // Consuming the terminal snapshot returns the client to Idle so the - // next download can start; also drops any half-received blobs. - let _ = client.acknowledge_transfer(); - let _ = client.take_received_messages(); + // Must abort mid-transfer states too — acknowledge_transfer only + // clears Complete/Failed, which left cancelled Sync stuck in + // LinkEstablishing/ListRequested and every later Sync as + // PROPAGATION_RETRIEVE_BUSY (UI then falsely said "no PNs"). + client.abort_transfer(); } } @@ -520,10 +563,23 @@ impl PropagationBridge { let listed = client.available_messages().len(); let downloaded = client.received_count(); let blobs = client.take_received_messages(); + // Remember retrieved tids as haves (survives acknowledge/cleanup) + // so the next `/get` (same or other PN) purges instead of re-serving. + let tids: Vec = blobs + .iter() + .map(|blob| LxMessage::compute_propagation_transient_id(blob)) + .collect(); + for tid in &tids { + client.add_local_message(*tid); + } + let have_added = tids.len(); // Consume the terminal snapshot → Idle so the next // start_client_download can proceed without a cancel first. let _ = client.acknowledge_transfer(); drop(client); + if have_added > 0 { + merge_persist_client_have_ids(&self.client_have_path, &tids); + } let messages = blobs .iter() .filter_map(|blob| decode_downloaded_propagated_blob(&self.identity, blob)) @@ -612,6 +668,16 @@ impl PropagationBridge { Self::encode_value(&Value::Array(vec![Value::Nil, Value::Array(purge_ids)])); let _ = node.handle_get_request(&purge_req, &our_delivery); } + // Mirror remote `/get` have tracking so later remote Sync does not + // re-pull the same mail from peered PNs that still hold copies. + let tids: Vec = + messages.iter().filter_map(|msg| msg.transient_id).collect(); + if let Ok(mut client) = self.client.lock() { + for tid in &tids { + client.add_local_message(*tid); + } + } + merge_persist_client_have_ids(&self.client_have_path, &tids); } (messages, listed) @@ -624,7 +690,72 @@ impl PropagationBridge { let _ = rmpv::encode::write_value(&mut buf, value); buf } +} +/// Load persisted client `/get` have-ids (32-byte transient IDs as hex). +fn load_client_have_ids(path: &Path) -> Vec { + let Ok(bytes) = std::fs::read(path) else { + return Vec::new(); + }; + let Ok(value) = serde_json::from_slice::(&bytes) else { + return Vec::new(); + }; + let Some(arr) = value.get("ids").and_then(|v| v.as_array()) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for item in arr { + let Some(hex) = item.as_str() else { + continue; + }; + let Ok(raw) = hex::decode(hex) else { + continue; + }; + if raw.len() != 32 { + continue; + } + let mut tid = [0u8; 32]; + tid.copy_from_slice(&raw); + out.push(tid); + if out.len() >= CLIENT_HAVE_ID_CAP { + break; + } + } + out +} + +/// Merge newly retrieved transient IDs into the on-disk have set (capped). +fn merge_persist_client_have_ids(path: &Path, new_ids: &[PropagationTransientId]) { + if new_ids.is_empty() { + return; + } + let mut ordered: Vec = load_client_have_ids(path); + let mut seen: HashSet = ordered.iter().copied().collect(); + for tid in new_ids { + if seen.insert(*tid) { + ordered.push(*tid); + } + } + if ordered.len() > CLIENT_HAVE_ID_CAP { + let drop_n = ordered.len() - CLIENT_HAVE_ID_CAP; + ordered.drain(0..drop_n); + } + let ids: Vec = ordered.iter().map(hex::encode).collect(); + let body = serde_json::json!({ "ids": ids }); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::write(path, body.to_string()) { + tracing::warn!( + target: "propagation-retrieve", + error = %e, + path = %path.display(), + "failed to persist client /get have-ids" + ); + } +} + +impl PropagationBridge { /// Decode a msgpack array of binaries (the `/get` list and serve responses). fn decode_binary_array(bytes: &[u8]) -> Vec> { let Ok(value) = rmpv::decode::read_value(&mut &bytes[..]) else { @@ -1584,6 +1715,117 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn client_have_ids_persist_and_seed_across_bridge_restart() { + let dir = std::env::temp_dir().join(format!( + "mesh-prop-client-haves-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + let path = dir.join(CLIENT_RETRIEVED_IDS_FILE); + let tid_a = [0x11u8; 32]; + let tid_b = [0x22u8; 32]; + merge_persist_client_have_ids(&path, &[tid_a]); + merge_persist_client_have_ids(&path, &[tid_a, tid_b]); + let loaded = load_client_have_ids(&path); + assert_eq!(loaded.len(), 2); + assert!(loaded.contains(&tid_a)); + assert!(loaded.contains(&tid_b)); + + let (tx, _rx) = mpsc::channel(8); + let us = Identity::new(); + let bridge = PropagationBridge::new( + tx, + [0xab; 16], + dir.clone(), + &us, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); + // Seeded have-ids must survive into the live client (verified via + // re-persist of an empty merge still retaining disk contents + path). + assert_eq!(bridge.client_have_path, path); + let reseeded = load_client_have_ids(&bridge.client_have_path); + assert_eq!(reseeded.len(), 2); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn source_client_get_remembers_have_ids_after_complete() { + let bridge = include_str!("propagation_bridge.rs"); + assert!( + bridge.contains("add_local_message") + && bridge.contains("merge_persist_client_have_ids") + && bridge.contains("client_retrieved_transient_ids.json"), + "remote /get Completes must seed PropagationClient local_messages + persist" + ); + assert!( + bridge.contains("seeded client /get have-ids from disk"), + "bridge init must rehydrate have-ids before the first Sync" + ); + assert!( + bridge.contains("abort_transfer"), + "cancel_client_download must abort mid-transfer (not only Complete/Failed)" + ); + } + + #[test] + fn local_stats_does_not_block_when_node_lock_held() { + let dir = std::env::temp_dir().join(format!( + "mesh-prop-local-stats-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + let (tx, _rx) = mpsc::channel(8); + let us = Identity::new(); + let bridge = PropagationBridge::new( + tx, + [0xab; 16], + dir.clone(), + &us, + &super::super::pn_hosting_policy::PnHostingPolicy::default(), + ) + .expect("bridge"); + + // Prime cache while uncontended. + assert_eq!(bridge.local_stats(), (0, 0)); + + let node = Arc::clone(&bridge.local_node); + let (held_tx, held_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let joiner = std::thread::spawn(move || { + let guard = node.lock().expect("hold node"); + held_tx.send(()).expect("signal held"); + let _ = release_rx.recv(); + drop(guard); + }); + held_rx.recv().expect("lock held"); + + let started = Instant::now(); + let stats = bridge.local_stats(); + let elapsed = started.elapsed(); + assert_eq!(stats, (0, 0)); + assert!( + elapsed < Duration::from_millis(200), + "local_stats must not wait on a held local_node lock (elapsed={elapsed:?})" + ); + + release_tx.send(()).expect("release"); + joiner.join().expect("holder"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn source_host_peer_sync_idle_gate_and_policy_start() { let live = include_str!("live.rs"); diff --git a/scripts/apply-rsLXMF-propagation-client-abort-transfer.sh b/scripts/apply-rsLXMF-propagation-client-abort-transfer.sh new file mode 100755 index 000000000..82f2f4676 --- /dev/null +++ b/scripts/apply-rsLXMF-propagation-client-abort-transfer.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Apply mesh-client rsLXMF PropagationClient::abort_transfer for rns-stack builds. +# Lets cancelled Sync tear down mid-transfer /get so the next Sync is not RETRIEVE_BUSY. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=lib/apply-ratspeak-overlay.sh +source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" +PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsLXMF-propagation-client-abort-transfer.patch" +LXMF_DIR="${RS_LXMF_DIR:-${REPO_ROOT}/.rsstack/rsLXMF}" +CLIENT_RS="${LXMF_DIR}/crates/lxmf-core/src/propagation_client.rs" + +if [[ ! -d "${LXMF_DIR}/.git" ]]; then + echo "error: rsLXMF not found at ${LXMF_DIR}" >&2 + echo "Clone: git clone https://github.com/ratspeak/rsLXMF.git ${LXMF_DIR}" >&2 + exit 1 +fi + +if [[ ! -f "${PATCH_FILE}" ]]; then + echo "error: patch not found at ${PATCH_FILE}" >&2 + exit 1 +fi + +overlay_already_present() { + [[ -f "${CLIENT_RS}" ]] || return 1 + grep -qE 'fn abort_transfer\(' "${CLIENT_RS}" +} + +if overlay_already_present; then + echo "propagation-client abort_transfer overlay already present on rsLXMF @ $(git -C "${LXMF_DIR}" rev-parse --short HEAD)" + exit 0 +fi + +if apply_ratspeak_overlay_or_die "${LXMF_DIR}" "${PATCH_FILE}" "propagation-client-abort-transfer"; then + exit 0 +fi +exit 1 diff --git a/scripts/lib/ratspeak-overlay-apply-list.sh b/scripts/lib/ratspeak-overlay-apply-list.sh index f0ce424f4..967b011bf 100644 --- a/scripts/lib/ratspeak-overlay-apply-list.sh +++ b/scripts/lib/ratspeak-overlay-apply-list.sh @@ -20,6 +20,7 @@ RS_LXMF_APPLY_SCRIPTS=( apply-rsLXMF-propagation-node-policy-setters.sh apply-rsLXMF-propagation-node-deferred-messagestore-load.sh apply-rsLXMF-link-delivery-has-pending-to.sh + apply-rsLXMF-propagation-client-abort-transfer.sh ) apply_ratspeak_rns_overlays() { diff --git a/scripts/update.sh b/scripts/update.sh index f401fa1fe..a3ed70759 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -223,6 +223,7 @@ check_ratspeak_patches() { 'rsLXMF-propagation-node-policy-setters.patch|ratspeak/rsLXMF|6|rsLXMF PropagationNode policy setters|https://github.com/ratspeak/rsLXMF/pull/6' 'rsLXMF-propagation-node-deferred-messagestore-load.patch|ratspeak/rsLXMF||rsLXMF PropagationNode deferred messagestore load|' 'rsLXMF-link-delivery-has-pending-to.patch|ratspeak/rsLXMF||rsLXMF LinkDeliveryManager has_pending_to|' + 'rsLXMF-propagation-client-abort-transfer.patch|ratspeak/rsLXMF||rsLXMF PropagationClient abort_transfer for cancelled Sync|' ) local patches_dir='reticulum-sidecar/patches' local has_ratspeak_warning=0 diff --git a/src/renderer/lib/reticulum/reticulumPropagationAutoApply.test.ts b/src/renderer/lib/reticulum/reticulumPropagationAutoApply.test.ts index 98df3e0f0..a36bcb2e1 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationAutoApply.test.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationAutoApply.test.ts @@ -623,6 +623,31 @@ describe('reticulumPropagationAutoApply', () => { expect(startSync.mock.calls.map((c) => c[0])[0]).toBe(near); }); + it('RETRIEVE_BUSY-only cascade does not claim syncNoTarget when local is off', async () => { + const startSync = vi.fn(() => Promise.resolve('deferred' as const)); + useReticulumPropagationStore.setState({ + nodes: [{ id: 'local-prop', name: 'Local', enabled: false, status: 'idle' }], + discovered: [ + { destination_hash: near, node_state: true, peering_cost: 0, hops: 1 }, + { destination_hash: far, node_state: true, peering_cost: 0, hops: 2 }, + ], + preferredId: null, + sync: { active: false, progress: 0, message: null }, + lastSyncError: null, + }); + useReticulumPropagationStore.setState({ startSync }); + + await expect(startPropagationSyncCascade({ hasEnabledInterfaces: true })).resolves.toBe( + false, + ); + expect(useReticulumPropagationStore.getState().lastSyncError).toBe( + 'reticulumPropagation.syncRetrieveBusy', + ); + expect(useReticulumPropagationStore.getState().lastSyncError).not.toBe( + 'reticulumPropagation.syncNoTarget', + ); + }); + it('skips a configured remote whose hash was already tried as the Manual seed', async () => { writeReticulumPropagationMode('manual'); const shared = 'ccccdddd'.repeat(4); diff --git a/src/renderer/lib/reticulum/reticulumPropagationAutoApply.ts b/src/renderer/lib/reticulum/reticulumPropagationAutoApply.ts index 399c420d2..cc2b24d9f 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationAutoApply.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationAutoApply.ts @@ -44,6 +44,8 @@ export const PROPAGATION_CASCADE_ATTEMPT_TIMEOUT_MS = export const PROPAGATION_SYNC_NO_TARGET_KEY = 'reticulumPropagation.syncNoTarget'; /** Local inbox is enabled but its messagestore is still loading, so it cannot settle yet. */ export const PROPAGATION_SYNC_LOCAL_LOADING_KEY = 'reticulumPropagation.syncLocalLoading'; +/** Remotes existed but every start was soft-deferred (retrieve already in flight). */ +export const PROPAGATION_SYNC_RETRIEVE_BUSY_KEY = 'reticulumPropagation.syncRetrieveBusy'; /** Shared run for overlapping auto-sync ticks. */ let inFlightCascade: Promise | null = null; @@ -59,6 +61,8 @@ export function resetPropagationSyncCascadeState(): void { /** Tracks whether any node was actually contacted, so a real error is never overwritten. */ interface CascadeAttempts { any: boolean; + /** Soft-defer (retrieve/outbound/not-live busy) — not a missing-target condition. */ + deferred: boolean; } /** @@ -73,7 +77,8 @@ async function attemptSync( ): Promise { const startResult = await useReticulumPropagationStore.getState().startSync(id); if (startResult === 'deferred') { - // Soft defer: do not count as contacted and do not 15-minute-backoff the node. + // Soft defer: do not 15-minute-backoff the node, but remember we had targets. + attempts.deferred = true; return 'deferred'; } if (startResult !== 'accepted') { @@ -104,11 +109,14 @@ function finishWithoutTarget(attempts: CascadeAttempts): boolean { if (attempts.any) return false; const { nodes } = useReticulumPropagationStore.getState(); const loading = isLocalPropagationLoading(nodes); - useReticulumPropagationStore - .getState() - .setLastSyncError( - loading ? PROPAGATION_SYNC_LOCAL_LOADING_KEY : PROPAGATION_SYNC_NO_TARGET_KEY, - ); + // Discovered/configured targets existed but every startSync soft-deferred + // (stuck prior /get). Do not claim "none discovered". + const errorKey = loading + ? PROPAGATION_SYNC_LOCAL_LOADING_KEY + : attempts.deferred + ? PROPAGATION_SYNC_RETRIEVE_BUSY_KEY + : PROPAGATION_SYNC_NO_TARGET_KEY; + useReticulumPropagationStore.getState().setLastSyncError(errorKey); // No node was called, so nothing may be named alongside this error. useReticulumPropagationStore.getState().setSyncTargetId(null); return false; @@ -225,7 +233,7 @@ async function runPropagationSyncCascade( const state = useReticulumPropagationStore.getState(); const { nodes, preferredId, discovered } = state; const first = opts?.firstTargetId ?? null; - const attempts: CascadeAttempts = { any: false }; + const attempts: CascadeAttempts = { any: false, deferred: false }; const remoteDeadlineMs = Date.now() + PROPAGATION_CASCADE_BUDGET_MS; /** Mode changed under us, or a newer cascade took over — abandon this run entirely. */ const superseded = (forMode: ReticulumPropagationMode): boolean => diff --git a/src/renderer/lib/reticulum/reticulumPropagationMode.test.ts b/src/renderer/lib/reticulum/reticulumPropagationMode.test.ts index 38559755b..fdb58ce67 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationMode.test.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationMode.test.ts @@ -8,6 +8,8 @@ import type { import { hasPropagationCascadeCandidate, isLocalPropagationLoading, + listFiniteHopDiscoveredPropagationTargets, + listUnknownHopDiscoveredPropagationTargets, pickAutoPropagationNodeId, pickAutoPropagationTarget, readReticulumPropagationMode, @@ -58,6 +60,21 @@ describe('reticulumPropagationMode', () => { vi.unstubAllGlobals(); }); + it('treats absurd path-table hop counts as unknown for Auto ordering', () => { + const ghost = '54c454aa'.padEnd(32, '0'); + const near = 'aabbccdd'.repeat(4); + const rows = [ + discovered({ destination_hash: ghost, hops: 114, peering_cost: 18 }), + discovered({ destination_hash: near, hops: 2, peering_cost: 5 }), + ]; + expect( + listFiniteHopDiscoveredPropagationTargets([], rows).map((t) => t.destinationHash), + ).toEqual([near]); + expect( + listUnknownHopDiscoveredPropagationTargets([], rows).map((t) => t.destinationHash), + ).toEqual([ghost]); + }); + it('reports no cascade candidate for a fresh stack with a loading local inbox', () => { const loadingLocal = [ row({ id: 'local-prop', name: 'Local', enabled: false, status: 'loading' }), diff --git a/src/renderer/lib/reticulum/reticulumPropagationMode.ts b/src/renderer/lib/reticulum/reticulumPropagationMode.ts index 71ebe666b..826fc2659 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationMode.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationMode.ts @@ -76,9 +76,16 @@ export interface DiscoveredPropagationTarget { hops: number; } +/** + * Path-table hop counts above this are treated as unusable for Auto ordering + * (seen in the wild as 100+ hop ghosts that still advertise a low peering cost). + * Matches the practical clamp used for Reticulum link initiator hops. + */ +export const MAX_PLAUSIBLE_PROPAGATION_HOPS = 32; + /** True when hops came from the path table (not “unknown” / Infinity). */ export function hasFinitePropagationHops(hops: number): boolean { - return Number.isFinite(hops); + return Number.isFinite(hops) && hops >= 0 && hops <= MAX_PLAUSIBLE_PROPAGATION_HOPS; } /** Active discovered remotes not already configured, best (lowest hops) first. */ diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index c818a638b..6bef81697 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -645,6 +645,14 @@ export function useReticulumRuntime(): ProtocolRuntime { if (p.attachment?.data_base64 && p.direction !== 'outbound') { attachmentPath = await cacheReticulumInboundAttachment(p.attachment); } + // Already-known rows (DB hydrate / prior session) must not re-fire RNCP + // control side effects after a cold start clears the in-memory dedup map. + const controlHashForKnown = resolveRncpLxmfControlMessageHash(p); + const knownBucket = useMessageStore.getState().messages[identityId] as + Record | undefined; + const alreadyKnownControl = Boolean( + controlHashForKnown && knownBucket && Object.hasOwn(knownBucket, controlHashForKnown), + ); ingestReticulumLxmfPayloadWithSideEffects(identityId, p, { selfLxmfHash: selfLxmfHash ?? undefined, attachmentPath, @@ -666,8 +674,11 @@ export function useReticulumRuntime(): ProtocolRuntime { lxmfBodyContainsRncpRequestEnable(p.text) ) { // Catch-up / WS duplicates must not re-open the enable modal or auto-share. - const controlHash = resolveRncpLxmfControlMessageHash(p); - if (!controlHash || tryMarkRncpLxmfControlHandled(controlHash)) { + const controlHash = controlHashForKnown; + if ( + !alreadyKnownControl && + (!controlHash || tryMarkRncpLxmfControlHandled(controlHash)) + ) { useRncpEnableRequestStore.getState().enqueue({ peerHash: p.sender_hash, peerLabel: p.sender_name ?? null, @@ -676,7 +687,8 @@ export function useReticulumRuntime(): ProtocolRuntime { } } if (p.direction !== 'outbound' && p.sender_hash && parseRncpReceiveDestShare(p.text)) { - const controlHash = resolveRncpLxmfControlMessageHash(p); + // Reservation dedup (not messageStore): upsert_failed must be allowed to retry. + const controlHash = controlHashForKnown; const reservation = controlHash ? tryReserveRncpLxmfControlHandled(controlHash) : null; if (controlHash && !reservation) { return; From c1cda21616de39991129966306acf73b59336203 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 9 Aug 2026 17:42:28 -0600 Subject: [PATCH 2/6] feat(reticulum): ignore bad PNs for Auto sync and deposit Add a sidecar-persisted Auto blacklist with Network UI Ignore/Allow controls so poorly behaved nodes are skipped without blocking Manual. --- reticulum-sidecar/src/api/mod.rs | 8 + reticulum-sidecar/src/api/propagation.rs | 31 +++ reticulum-sidecar/src/stack/live.rs | 14 +- reticulum-sidecar/src/stack/mod.rs | 34 +++ reticulum-sidecar/src/stack/persistence.rs | 76 ++++++- reticulum-sidecar/src/stack/pn_cascade.rs | 42 +++- .../src/stack/propagation_mode.rs | 4 + .../components/ReticulumPropagationNotice.tsx | 16 +- .../ReticulumPropagationSection.tsx | 193 +++++++++++++++++- .../reticulum/reticulumDiagnosticSnapshot.ts | 5 +- .../reticulumOutboundFailureBridge.ts | 3 +- .../reticulumPropagationAutoApply.test.ts | 21 ++ .../reticulumPropagationAutoApply.ts | 18 +- .../reticulumPropagationEffective.ts | 34 ++- .../reticulumPropagationMode.test.ts | 36 ++++ .../lib/reticulum/reticulumPropagationMode.ts | 59 +++++- .../useReticulumPropagationAutoSync.ts | 10 +- src/renderer/locales/cs/translation.json | 10 +- src/renderer/locales/de/translation.json | 10 +- src/renderer/locales/en/translation.json | 10 +- src/renderer/locales/es/translation.json | 10 +- src/renderer/locales/fr/translation.json | 10 +- src/renderer/locales/id/translation.json | 10 +- src/renderer/locales/it/translation.json | 10 +- src/renderer/locales/ja/translation.json | 10 +- src/renderer/locales/ko/translation.json | 10 +- src/renderer/locales/nl/translation.json | 10 +- src/renderer/locales/pl/translation.json | 10 +- src/renderer/locales/pt-BR/translation.json | 10 +- src/renderer/locales/ru/translation.json | 10 +- src/renderer/locales/tr/translation.json | 10 +- src/renderer/locales/uk/translation.json | 10 +- src/renderer/locales/zh/translation.json | 10 +- src/renderer/runtime/useReticulumRuntime.ts | 1 + .../stores/reticulumPropagationStore.test.ts | 3 + .../stores/reticulumPropagationStore.ts | 48 +++++ 36 files changed, 761 insertions(+), 55 deletions(-) diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index 00f99af19..6b339b39e 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -166,6 +166,14 @@ pub fn router(stack: Arc) -> Router { "/api/v1/propagation/mode", post(propagation::set_propagation_mode), ) + .route( + "/api/v1/propagation/auto-blacklist", + post(propagation::add_propagation_auto_blacklist), + ) + .route( + "/api/v1/propagation/auto-blacklist/{destination_hash}", + delete(propagation::remove_propagation_auto_blacklist), + ) .route( "/api/v1/propagation/hosting-policy", post(propagation::set_pn_hosting_policy), diff --git a/reticulum-sidecar/src/api/propagation.rs b/reticulum-sidecar/src/api/propagation.rs index 6639ea3e0..9bf7aa890 100644 --- a/reticulum-sidecar/src/api/propagation.rs +++ b/reticulum-sidecar/src/api/propagation.rs @@ -41,6 +41,11 @@ pub struct RenamePropagationBody { pub name: String, } +#[derive(Debug, Deserialize)] +pub struct PropagationAutoBlacklistBody { + pub destination_hash: String, +} + pub async fn set_pn_hosting_policy( State(stack): State>, Json(body): Json, @@ -130,6 +135,32 @@ pub async fn set_propagation_mode( } } +pub async fn add_propagation_auto_blacklist( + State(stack): State>, + Json(body): Json, +) -> Json { + match stack + .add_propagation_auto_blacklist(&body.destination_hash) + .await + { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + +pub async fn remove_propagation_auto_blacklist( + State(stack): State>, + Path(destination_hash): Path, +) -> Json { + match stack + .remove_propagation_auto_blacklist(&destination_hash) + .await + { + Ok(()) => Json(serde_json::json!({ "ok": true })), + Err(e) => Json(serde_json::json!({ "ok": false, "error": e })), + } +} + pub async fn start_propagation_sync( State(stack): State>, Json(body): Json, diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 3b5b32406..402696383 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -5746,7 +5746,7 @@ async fn rebuild_pn_cascade_candidates( pn_hosting_policy: &Arc>, ) { use pn_cascade::{auto_discovered_candidates, candidates_for_propagation_mode}; - let (rows, self_hash, mode) = { + let (rows, self_hash, mode, auto_blacklist) = { let state = persisted.read().await; let rows: Vec<(String, bool, Option, Option)> = state .propagation @@ -5754,9 +5754,18 @@ async fn rebuild_pn_cascade_candidates( .map(|p| (p.id.clone(), p.enabled, p.destination_hash.clone(), p.hops)) .collect(); let self_hash = state.identity.lxmf_hash.clone(); - (rows, self_hash, state.propagation_mode) + let auto_blacklist: std::collections::HashSet<[u8; 16]> = state + .propagation_auto_blacklist + .iter() + .filter_map(|h| parse_hash16(h).ok()) + .collect(); + (rows, self_hash, state.propagation_mode, auto_blacklist) }; let mut candidates = candidates_for_propagation_mode(&rows, &self_hash, mode); + // Auto ignores blacklisted remotes for outbound deposit; Manual Prefer/deposit still may. + if mode.is_auto() { + candidates.retain(|c| c.is_local || !auto_blacklist.contains(&c.hash)); + } let discovered_rows: Vec = discovered_propagation .lock() .map(|cache| cache.values().cloned().collect()) @@ -5771,6 +5780,7 @@ async fn rebuild_pn_cascade_candidates( &self_hash, mode, max_peering_cost, + &auto_blacklist, )); if let Ok(mut driver) = outbound.lock() { driver.set_pn_cascade_candidates(candidates); diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index d00defe90..951c67138 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -1357,15 +1357,49 @@ impl StackHandle { row }) .collect(); + let auto_blacklist = inner.propagation_auto_blacklist.clone(); serde_json::json!({ "propagation": propagation, "preferred_id": preferred_id, "auto_sync_interval_sec": auto_sync_interval_sec, "propagation_mode": propagation_mode.as_str(), + "propagation_auto_blacklist": auto_blacklist, "pn_hosting_policy": pn_hosting_policy, }) } + pub async fn add_propagation_auto_blacklist( + &self, + destination_hash: &str, + ) -> Result<(), String> { + { + let mut inner = self.inner.write().await; + inner.add_propagation_auto_blacklist(destination_hash)?; + inner.save(&self.config_dir, &self.storage_dir)?; + } + #[cfg(feature = "rns-stack")] + if let Some(live) = self.live.get() { + live.refresh_pn_cascade_candidates().await; + } + Ok(()) + } + + pub async fn remove_propagation_auto_blacklist( + &self, + destination_hash: &str, + ) -> Result<(), String> { + { + let mut inner = self.inner.write().await; + inner.remove_propagation_auto_blacklist(destination_hash)?; + inner.save(&self.config_dir, &self.storage_dir)?; + } + #[cfg(feature = "rns-stack")] + if let Some(live) = self.live.get() { + live.refresh_pn_cascade_candidates().await; + } + Ok(()) + } + pub fn list_discovered_propagation(&self) -> Vec { #[cfg(feature = "rns-stack")] if let Some(live) = self.live.get() { diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs index 9137dcc91..9e88f3802 100644 --- a/reticulum-sidecar/src/stack/persistence.rs +++ b/reticulum-sidecar/src/stack/persistence.rs @@ -33,6 +33,9 @@ pub struct PersistedState { pub auto_sync_interval_sec: u32, /// Renderer propagation mode; `Off` disables the outbound Direct→PN cascade. pub propagation_mode: PropagationMode, + /// Destination hashes (32 lowercase hex) Auto must never sync or deposit on. + /// Manual Prefer/Sync and explicit Add remain available. + pub propagation_auto_blacklist: Vec, /// LXMF local PN hosting / peering policy (defaults match rsLXMF / lxmd). pub pn_hosting_policy: PnHostingPolicy, pub nomad_nodes: Vec, @@ -89,6 +92,7 @@ impl PersistedState { propagation_sync: serde_json::Value::Null, auto_sync_interval_sec: 3600, propagation_mode: PropagationMode::default(), + propagation_auto_blacklist: Vec::new(), pn_hosting_policy: PnHostingPolicy::default(), nomad_nodes: Vec::new(), rrc_hubs: Vec::new(), @@ -415,6 +419,47 @@ impl PersistedState { self.propagation_mode = mode; } + /// Cap so a misbehaving UI cannot grow the Auto ignore list without bound. + const PROPAGATION_AUTO_BLACKLIST_CAP: usize = 256; + + /// Normalize and validate a PN destination hash for the Auto blacklist. + pub fn normalize_propagation_auto_blacklist_hash(raw: &str) -> Result { + let clean: String = raw + .chars() + .filter(char::is_ascii_hexdigit) + .collect::() + .to_lowercase(); + if clean.len() != 32 { + return Err("destination_hash must be 32 hex characters".into()); + } + Ok(clean) + } + + pub fn add_propagation_auto_blacklist(&mut self, destination_hash: &str) -> Result<(), String> { + let hash = Self::normalize_propagation_auto_blacklist_hash(destination_hash)?; + if self.propagation_auto_blacklist.iter().any(|h| h == &hash) { + return Ok(()); + } + if self.propagation_auto_blacklist.len() >= Self::PROPAGATION_AUTO_BLACKLIST_CAP { + return Err("propagation Auto blacklist is full".into()); + } + self.propagation_auto_blacklist.push(hash); + Ok(()) + } + + pub fn remove_propagation_auto_blacklist( + &mut self, + destination_hash: &str, + ) -> Result<(), String> { + let hash = Self::normalize_propagation_auto_blacklist_hash(destination_hash)?; + let before = self.propagation_auto_blacklist.len(); + self.propagation_auto_blacklist.retain(|h| h != &hash); + if self.propagation_auto_blacklist.len() == before { + return Err(format!("destination_hash not in Auto blacklist: {hash}")); + } + Ok(()) + } + pub fn set_pn_hosting_policy(&mut self, policy: PnHostingPolicy) -> Result<(), String> { let policy = policy.sanitized()?; self.pn_hosting_policy = policy; @@ -817,7 +862,7 @@ impl serde::Serialize for PersistedState { S: serde::Serializer, { use serde::ser::SerializeStruct; - let mut s = serializer.serialize_struct("PersistedState", 28)?; + let mut s = serializer.serialize_struct("PersistedState", 29)?; s.serialize_field("identity", &self.identity)?; s.serialize_field("interfaces", &self.interfaces)?; s.serialize_field("contacts", &self.contacts)?; @@ -834,6 +879,10 @@ impl serde::Serialize for PersistedState { s.serialize_field("propagation_sync", &self.propagation_sync)?; s.serialize_field("auto_sync_interval_sec", &self.auto_sync_interval_sec)?; s.serialize_field("propagation_mode", &self.propagation_mode)?; + s.serialize_field( + "propagation_auto_blacklist", + &self.propagation_auto_blacklist, + )?; s.serialize_field("pn_hosting_policy", &self.pn_hosting_policy)?; s.serialize_field("nomad_nodes", &self.nomad_nodes)?; s.serialize_field("rrc_hubs", &self.rrc_hubs)?; @@ -886,6 +935,8 @@ impl<'de> serde::Deserialize<'de> for PersistedState { #[serde(default)] propagation_mode: PropagationMode, #[serde(default)] + propagation_auto_blacklist: Vec, + #[serde(default)] pn_hosting_policy: PnHostingPolicy, #[serde(default)] nomad_nodes: Vec, @@ -935,6 +986,7 @@ impl<'de> serde::Deserialize<'de> for PersistedState { }, auto_sync_interval_sec: raw.auto_sync_interval_sec, propagation_mode: raw.propagation_mode, + propagation_auto_blacklist: raw.propagation_auto_blacklist, pn_hosting_policy: raw.pn_hosting_policy, nomad_nodes: raw.nomad_nodes, rrc_hubs: raw.rrc_hubs, @@ -1113,6 +1165,28 @@ mod tests { ); } + #[test] + fn propagation_auto_blacklist_add_remove_normalizes_hash() { + let mut state = PersistedState::default_empty(); + let hash = "DEADBEEFcafeBABE0123456789ABCDEF"; + state + .add_propagation_auto_blacklist(hash) + .expect("add blacklist"); + assert_eq!( + state.propagation_auto_blacklist, + vec!["deadbeefcafebabe0123456789abcdef".to_string()] + ); + // Idempotent re-add. + state.add_propagation_auto_blacklist(hash).expect("re-add"); + assert_eq!(state.propagation_auto_blacklist.len(), 1); + assert!(state.add_propagation_auto_blacklist("not-a-hash").is_err()); + state + .remove_propagation_auto_blacklist(hash) + .expect("remove"); + assert!(state.propagation_auto_blacklist.is_empty()); + assert!(state.remove_propagation_auto_blacklist(hash).is_err()); + } + #[test] fn rename_propagation_node_updates_name() { let mut state = PersistedState::default_empty(); diff --git a/reticulum-sidecar/src/stack/pn_cascade.rs b/reticulum-sidecar/src/stack/pn_cascade.rs index 07f0056f3..dbe60fb46 100644 --- a/reticulum-sidecar/src/stack/pn_cascade.rs +++ b/reticulum-sidecar/src/stack/pn_cascade.rs @@ -177,6 +177,7 @@ pub fn auto_discovered_candidates( self_lxmf_hash_hex: &str, mode: PropagationMode, max_peering_cost: u8, + auto_blacklist: &HashSet<[u8; 16]>, ) -> Vec { if mode != PropagationMode::Auto { return Vec::new(); @@ -192,6 +193,9 @@ pub fn auto_discovered_candidates( let Some(hash) = parse_hash16(&row.destination_hash) else { continue; }; + if auto_blacklist.contains(&hash) { + continue; + } if is_self_lxmf_hash(&hash, &self_norm) || !seen.insert(hash) { continue; } @@ -420,6 +424,7 @@ mod tests { "", PropagationMode::Auto, u8::MAX, + &HashSet::new(), ); assert_eq!(extra.len(), 1); let mut all = configured; @@ -436,7 +441,8 @@ mod tests { let discovered = vec![discovered_row(&"ab".repeat(16), Some(1))]; for mode in [PropagationMode::Manual, PropagationMode::Off] { assert!( - auto_discovered_candidates(&discovered, &[], "", mode, u8::MAX).is_empty(), + auto_discovered_candidates(&discovered, &[], "", mode, u8::MAX, &HashSet::new()) + .is_empty(), "{mode:?} must not deposit on a node the user never added" ); } @@ -467,6 +473,7 @@ mod tests { &self_hex, PropagationMode::Auto, 26, + &HashSet::new(), ); assert_eq!(extra.len(), 1); assert_eq!(hex::encode(extra[0].hash), "ee".repeat(16)); @@ -481,8 +488,14 @@ mod tests { discovered_row(&"33".repeat(16), Some(3)), discovered_row(&"22".repeat(16), Some(2)), ]; - let extra = - auto_discovered_candidates(&discovered, &[], "", PropagationMode::Auto, u8::MAX); + let extra = auto_discovered_candidates( + &discovered, + &[], + "", + PropagationMode::Auto, + u8::MAX, + &HashSet::new(), + ); assert_eq!(extra.len(), MAX_AUTO_DISCOVERED_PN_CANDIDATES); assert_eq!( extra.iter().map(|c| c.hops).collect::>(), @@ -499,6 +512,7 @@ mod tests { "", PropagationMode::Auto, u8::MAX, + &HashSet::new(), ); let ordered = build_pn_cascade_order(&extra, None); assert!(cascade_has_capacity(&ordered, &HashSet::new())); @@ -508,6 +522,28 @@ mod tests { ); } + #[test] + fn auto_discovered_skips_auto_blacklist() { + let blocked = "ab".repeat(16); + let ok = "cd".repeat(16); + let mut blocked_hash = [0u8; 16]; + blocked_hash.copy_from_slice(&hex::decode(&blocked).expect("hex")); + let blacklist = HashSet::from([blocked_hash]); + let extra = auto_discovered_candidates( + &[ + discovered_row(&blocked, Some(0)), + discovered_row(&ok, Some(2)), + ], + &[], + "", + PropagationMode::Auto, + u8::MAX, + &blacklist, + ); + assert_eq!(extra.len(), 1); + assert_eq!(hex::encode(extra[0].hash), ok); + } + #[test] fn is_self_lxmf_hash_case_insensitive() { let hash = [0xaa; 16]; diff --git a/reticulum-sidecar/src/stack/propagation_mode.rs b/reticulum-sidecar/src/stack/propagation_mode.rs index ede7b28a9..6732c4285 100644 --- a/reticulum-sidecar/src/stack/propagation_mode.rs +++ b/reticulum-sidecar/src/stack/propagation_mode.rs @@ -27,6 +27,10 @@ impl PropagationMode { pub fn is_off(self) -> bool { matches!(self, PropagationMode::Off) } + + pub fn is_auto(self) -> bool { + matches!(self, PropagationMode::Auto) + } } /// Parse a renderer mode string; unknown values are rejected so a typo cannot diff --git a/src/renderer/components/ReticulumPropagationNotice.tsx b/src/renderer/components/ReticulumPropagationNotice.tsx index a78d0ff7a..ee32fc0bf 100644 --- a/src/renderer/components/ReticulumPropagationNotice.tsx +++ b/src/renderer/components/ReticulumPropagationNotice.tsx @@ -5,6 +5,7 @@ import { hasEffectiveReticulumPropagationTarget } from '@/renderer/lib/reticulum import { listDiscoveredPropagationTargets, pickAutoPropagationTarget, + propagationAutoBlacklistSet, } from '@/renderer/lib/reticulum/reticulumPropagationMode'; import { useReticulumPropagationStore } from '@/renderer/stores/reticulumPropagationStore'; @@ -24,12 +25,17 @@ export function ReticulumPropagationNotice({ const { addToast } = useToast(); const nodes = useReticulumPropagationStore((s) => s.nodes); const discovered = useReticulumPropagationStore((s) => s.discovered); + const autoBlacklistRows = useReticulumPropagationStore((s) => s.autoBlacklist); const preferredId = useReticulumPropagationStore((s) => s.preferredId); const refreshFromSidecar = useReticulumPropagationStore((s) => s.refreshFromSidecar); const addFromDiscovered = useReticulumPropagationStore((s) => s.addFromDiscovered); const dismissed = useReticulumPropagationStore((s) => s.chatNoticeDismissed); const setChatNoticeDismissed = useReticulumPropagationStore((s) => s.setChatNoticeDismissed); const mode = useReticulumPropagationStore((s) => s.propagationMode); + const autoBlacklist = useMemo( + () => propagationAutoBlacklistSet(autoBlacklistRows), + [autoBlacklistRows], + ); useEffect(() => { if (!stackLive) return; @@ -37,8 +43,8 @@ export function ReticulumPropagationNotice({ }, [stackLive, refreshFromSidecar]); const unconfiguredDiscovered = useMemo( - () => listDiscoveredPropagationTargets(nodes, discovered), - [nodes, discovered], + () => listDiscoveredPropagationTargets(nodes, discovered, autoBlacklist), + [nodes, discovered, autoBlacklist], ); if (!stackLive) return null; @@ -46,13 +52,15 @@ export function ReticulumPropagationNotice({ if (mode === 'off') return null; // Re-enable from Network → Propagation nodes. if (dismissed) return null; - if (hasEffectiveReticulumPropagationTarget(nodes, preferredId, mode, discovered)) { + if ( + hasEffectiveReticulumPropagationTarget(nodes, preferredId, mode, discovered, autoBlacklistRows) + ) { return null; } const discoveryCount = unconfiguredDiscovered.length; // Rank discovered for “Add closest”; Auto never soft-upserts — user must add explicitly. - const closestTarget = pickAutoPropagationTarget(nodes, discovered); + const closestTarget = pickAutoPropagationTarget(nodes, discovered, autoBlacklist); const closestHash = closestTarget?.kind === 'discovered' ? closestTarget.destinationHash diff --git a/src/renderer/components/ReticulumPropagationSection.tsx b/src/renderer/components/ReticulumPropagationSection.tsx index 0bee3a2aa..88a16b4eb 100644 --- a/src/renderer/components/ReticulumPropagationSection.tsx +++ b/src/renderer/components/ReticulumPropagationSection.tsx @@ -7,7 +7,9 @@ import { startPropagationSyncWithTarget } from '@/renderer/lib/reticulum/reticul import { configuredPropagationDestinationHashes, hasPropagationCascadeCandidate, + isPropagationHashAutoBlacklisted, isReticulumPropagationMode, + propagationAutoBlacklistSet, resolvePropagationSyncTargetId, type ReticulumPropagationMode, } from '@/renderer/lib/reticulum/reticulumPropagationMode'; @@ -50,31 +52,50 @@ function formatPropagationNodeStatus(status: string, t: (key: string) => string) interface DiscoveredPropagationListProps { discovered: DiscoveredPropagationRow[]; configuredHashes: ReadonlySet; + autoBlacklist: ReadonlySet; onAdd: (destinationHash: string, prefer?: boolean) => void; + onIgnoreForAuto: (destinationHash: string) => void; + onAllowForAuto: (destinationHash: string) => void; adding?: boolean; + ignoreBusy?: boolean; } function DiscoveredPropagationList({ discovered, configuredHashes, + autoBlacklist, onAdd, + onIgnoreForAuto, + onAllowForAuto, adding = false, + ignoreBusy = false, }: Readonly) { const { t } = useTranslation(); const visibleDiscovered = discovered.filter( (d) => !configuredHashes.has(d.destination_hash.toLowerCase()), ); + const activeRows = visibleDiscovered.filter( + (d) => !autoBlacklist.has(d.destination_hash.toLowerCase()), + ); + const ignoredFromDiscovered = visibleDiscovered.filter((d) => + autoBlacklist.has(d.destination_hash.toLowerCase()), + ); + const ignoredOrphanHashes = [...autoBlacklist].filter( + (hash) => + !configuredHashes.has(hash) && + !visibleDiscovered.some((d) => d.destination_hash.toLowerCase() === hash), + ); return (

{t('reticulumPropagation.discoveredTitle')}

- {visibleDiscovered.length === 0 ? ( + {activeRows.length === 0 ? (

{t('reticulumPropagation.discoveredEmpty')}

) : (
    - {visibleDiscovered.map((row) => { + {activeRows.map((row) => { const label = row.display_name?.trim() || row.destination_hash.slice(0, 8); return (
  • {t('reticulumPropagation.discoveredAddPrefer')} +
); })} )} + {ignoredFromDiscovered.length > 0 || ignoredOrphanHashes.length > 0 ? ( +
+
+ {t('reticulumPropagation.ignoredForAutoTitle')} +
+

+ {t('reticulumPropagation.ignoredForAutoHint')} +

+
    + {ignoredFromDiscovered.map((row) => { + const label = row.display_name?.trim() || row.destination_hash.slice(0, 8); + return ( +
  • +
    +
    {label}
    +
    + {t('reticulumPropagation.discoveredHash', { + hash: row.destination_hash.slice(0, 12), + })} +
    +
    + +
  • + ); + })} + {ignoredOrphanHashes.map((hash) => { + const label = hash.slice(0, 8); + return ( +
  • +
    +
    {label}
    +
    + {t('reticulumPropagation.discoveredHash', { + hash: hash.slice(0, 12), + })} +
    +
    + +
  • + ); + })} +
+
+ ) : null} ); } @@ -150,6 +252,7 @@ export default function ReticulumPropagationSection({ const { addToast } = useToast(); const nodes = useReticulumPropagationStore((s) => s.nodes); const discovered = useReticulumPropagationStore((s) => s.discovered); + const autoBlacklistRows = useReticulumPropagationStore((s) => s.autoBlacklist); const preferredId = useReticulumPropagationStore((s) => s.preferredId); const autoSyncIntervalSec = useReticulumPropagationStore((s) => s.autoSyncIntervalSec); const lastPropagationSyncAt = useReticulumPropagationStore((s) => s.lastPropagationSyncAt); @@ -169,6 +272,9 @@ export default function ReticulumPropagationSection({ const addFromDiscovered = useReticulumPropagationStore((s) => s.addFromDiscovered); const removePropagationNode = useReticulumPropagationStore((s) => s.removePropagationNode); const renamePropagationNode = useReticulumPropagationStore((s) => s.renamePropagationNode); + const addAutoBlacklist = useReticulumPropagationStore((s) => s.addAutoBlacklist); + const removeAutoBlacklist = useReticulumPropagationStore((s) => s.removeAutoBlacklist); + const autoBlacklist = propagationAutoBlacklistSet(autoBlacklistRows); const [addHash, setAddHash] = useState(''); const [refreshing, setRefreshing] = useState(false); const [renamingId, setRenamingId] = useState(null); @@ -177,6 +283,7 @@ export default function ReticulumPropagationSection({ const [pendingEnableLocal, setPendingEnableLocal] = useState(false); const [adding, setAdding] = useState(false); const [syncStarting, setSyncStarting] = useState(false); + const [ignoreBusy, setIgnoreBusy] = useState(false); const handleSyncNow = (targetId: string) => { if (syncStarting || sync.active) return; @@ -232,12 +339,48 @@ export default function ReticulumPropagationSection({ }); if (next !== 'auto') return; // Auto: kick discovered hash sync → configured → local (no Add, no Preferred). - if (!hasPropagationCascadeCandidate('auto', nodes, discovered)) return; - const target = resolvePropagationSyncTargetId('auto', nodes, preferredId, discovered); + if (!hasPropagationCascadeCandidate('auto', nodes, discovered, autoBlacklist)) return; + const target = resolvePropagationSyncTargetId( + 'auto', + nodes, + preferredId, + discovered, + autoBlacklist, + ); if (target == null) return; handleSyncNow(target); }; + const handleIgnoreForAuto = (destinationHash: string) => { + if (ignoreBusy) return; + setIgnoreBusy(true); + void addAutoBlacklist(destinationHash) + .then((ok) => { + setIgnoreBusy(false); + if (!ok) addToast(t('reticulumPropagation.ignoreForAutoFailed'), 'error'); + }) + .catch((err: unknown) => { + setIgnoreBusy(false); + console.warn('[ReticulumPropagationSection] ignoreForAuto rejected', err); + addToast(t('reticulumPropagation.ignoreForAutoFailed'), 'error'); + }); + }; + + const handleAllowForAuto = (destinationHash: string) => { + if (ignoreBusy) return; + setIgnoreBusy(true); + void removeAutoBlacklist(destinationHash) + .then((ok) => { + setIgnoreBusy(false); + if (!ok) addToast(t('reticulumPropagation.allowForAutoFailed'), 'error'); + }) + .catch((err: unknown) => { + setIgnoreBusy(false); + console.warn('[ReticulumPropagationSection] allowForAuto rejected', err); + addToast(t('reticulumPropagation.allowForAutoFailed'), 'error'); + }); + }; + const handleRefresh = async () => { if (refreshing) return; setRefreshing(true); @@ -286,7 +429,13 @@ export default function ReticulumPropagationSection({ ? 'reticulumPropagation.modeHelpManual' : 'reticulumPropagation.modeHelpOff'; - const bottomSyncTargetId = resolvePropagationSyncTargetId(mode, nodes, preferredId, discovered); + const bottomSyncTargetId = resolvePropagationSyncTargetId( + mode, + nodes, + preferredId, + discovered, + autoBlacklist, + ); // Manual resolves Preferred, else a picked remote, else local settle; Off disables Sync. // Auto Sync (bottom or per-row) runs the full cascade — ignore firstTargetId. const bottomSyncDisabled = @@ -294,7 +443,7 @@ export default function ReticulumPropagationSection({ syncStarting || mode === 'off' || (mode === 'manual' && !bottomSyncTargetId) || - (mode === 'auto' && !hasPropagationCascadeCandidate('auto', nodes, discovered)); + (mode === 'auto' && !hasPropagationCascadeCandidate('auto', nodes, discovered, autoBlacklist)); const body = ( <> @@ -346,6 +495,11 @@ export default function ReticulumPropagationSection({ const isLocal = node.id === 'local-prop'; const isLoading = node.status === 'loading'; const isRenaming = renamingId === node.id; + const destHash = node.destination_hash ?? null; + const ignoredForAuto = + !isLocal && destHash != null + ? isPropagationHashAutoBlacklisted(destHash, autoBlacklist) + : false; return (
  • + {!isLocal && destHash ? ( + + ) : null} {!isLocal && !isRenaming ? ( <>