diff --git a/docs/agents/common-issues.md b/docs/agents/common-issues.md index faf676be6..4f52ddd04 100644 --- a/docs/agents/common-issues.md +++ b/docs/agents/common-issues.md @@ -14,6 +14,9 @@ Deep subsystem reference for AI assistants. Symptom → where-to-check index. Ha | Reticulum Remote transfer / inbound policy | `RemoteTransferSection.tsx` / `RemoteSettingsSection.tsx`, `rncpTransferStore.ts` / `reticulumInboundPolicyStore.ts`, `pushRncpListenerPolicy.ts` — [troubleshooting](../troubleshooting.md#reticulum-remote-transfer-fails-or-path_constrained) | | Reticulum LXST voice fails / silent | `reticulumVoiceSession.ts`, `reticulumVoiceStore.ts`, sidecar `voice_session.rs`; [troubleshooting](../troubleshooting.md#reticulum-lxst-voice-call-fails-or-is-silent) | | Reticulum LXMF hangs with Auto + LAN hub | sidecar `auto_path_policy.rs` / `lxmf_outbound.rs`; [troubleshooting](../troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub) | +| PN Sync PATH_UNKNOWN / stuck retrieve busy | `PROPAGATION_PATH_UNKNOWN` gate in `live.rs`; Cancel → `abort_transfer` overlay; Host silent `/get` latch ownership; [troubleshooting](../troubleshooting.md#reticulum-remote-propagation-sync-fails-or-never-completes) | +| Auto picks a bad Discovered PN | Network → Propagation **Ignore for Auto** (`propagation_auto_blacklist`); filters Auto sync + deposit only | +| Proxy IPC storm after wake | `useReticulumInterfaceSnapshot` must not refresh on `announce.received`; shared proxy rate-limit soft envelope | | Serial port auto-rediscovery | `serialPortAutoRediscovery.ts` (60 s window, 5 s poll) — [troubleshooting](../troubleshooting.md#serial-port-auto-rediscovery-after-reconnect-exhaustion) | | Meshtastic MQTT text on wrong channel tab | `mqtt-manager.ts` (`resolveMqttInboundTextChannelIndex`), debug snapshot `meshtastic.channelPills` / `channelConfigsSummary` / `mqttChannelKeyEntryCount` — [troubleshooting](../troubleshooting.md#meshtastic-inbound-messages-on-the-wrong-channel-tab) | | Chat export fails | `chat:export` handler in `src/main/index.ts` | @@ -42,4 +45,4 @@ Deep subsystem reference for AI assistants. Symptom → where-to-check index. Ha | Renderer hung after wake | `rendererHeartbeatWatchdog.ts`, `useRendererHeartbeat`; visible stall + export `mainLiveness`; [troubleshooting](../troubleshooting.md#macos-sleep--wake-and-auto-reconnect) — quit fully if no `[usePowerRecovery]` after resume watchdog | | MeshCore TCP mid-init peer FIN | `useMeshcoreRuntime` initConn / `meshcore:tcp-*`; [troubleshooting](../troubleshooting.md#meshcore-tcp-connect-stuck-or-reconnect-loop-on-openhop) | | Chat hop pills missing | MeshCore: `meshcoreCompanionRxPathLenToHopCount` / `MeshCoreProtocol` / `meshcoreRawPacketCorrelate` / `meshcoreIngest`; Meshtastic: `meshtasticRfHops.ts` (`viaMqtt` / `hopStart===0` omit by design) | -| Meshtastic SDK routing console noise | `meshtasticSdkRoutingErrorConsoleHook.ts`, `meshtasticSdkRoutingErrorLog.ts` | +| Meshtastic SDK routing console noise | `meshtasticSdkRoutingErrorConsoleHook.ts`, `meshtasticSdkRoutingErrorLog.ts`; late `Packet does not exist` swallow is **post-teardown only** (`armMeshtasticLateConfigureRetryableSwallow` / `rendererUnhandledRejection.ts`) — do not widen to full session | diff --git a/docs/agents/reticulum.md b/docs/agents/reticulum.md index df4b6d69f..ee6260a32 100644 --- a/docs/agents/reticulum.md +++ b/docs/agents/reticulum.md @@ -3,12 +3,12 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Reticulum sidecar, LXMF, propagation, Remote (rnsh/rncp), Nomad, RRC, voice, or games. Hard rules live in [`AGENTS.md`](../../AGENTS.md); this file holds the file-level detail. - **Sidecar:** `reticulum-sidecar/` (AGPL Rust binary `mesh-client-reticulum`; path deps under repo-local `.rsstack/` via `scripts/clone-ratspeak-stack.sh` — `rsReticulum`/`rsLXMF`/`rsNomad`/`rsLXST`/`lrgp-rs`); dev: `pnpm run reticulum:sidecar:dev`. **Listen-first:** HTTP binds before `attach_live`; `/api/v1/status` `status: ok` = listening; `rns_ready`/`lxmf_ready` false until live. PN messagestore load deferred; local-prop serve waits for load. LXMF send/reaction fail closed with live-required errors until live. -- **IPC:** `reticulum:*` main handlers — `start` / `stop` / `getStatus` / `syncInterfaceIssueScope`, `proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete`, **`factoryReset`** (blocked on generic proxy), config file read/import dialog, `showNomadContentSourceDialog`, `setNomadContentSource`, Remote `rncpSend` / `rncpFetch` / `setRncpListener` / `showRncpOpenFileDialog` / `showRncpSaveDirectoryDialog` / `revealInFolder`. Also `media:ensureCameraAccess`, `gps:exportGpx`, `db:setReticulumDestinationVerified`, Remote DB `db:listReticulumRemoteAddresses` / upsert / delete and `db:listReticulumInboundPolicy` / upsert / delete (`src/main/ipc/reticulum-db-handlers.ts`), `mesh-client:openUrl` / `electronAPI.deepLink.onOpenUrl`. Renderer uses `electronAPI.reticulum` proxy (no direct localhost). `ReticulumStackPanel` + `useReticulumInterfaceSnapshot` sync enabled interface names after hydrate so TCP/TX issue banners clear when hubs are disabled; `reticulumSidecarIssueTracker` keeps that enabled set sticky while reading sidecar logs. +- **IPC:** `reticulum:*` main handlers — `start` / `stop` / `getStatus` / `syncInterfaceIssueScope`, `proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete`, **`factoryReset`** (blocked on generic proxy), config file read/import dialog, `showNomadContentSourceDialog`, `setNomadContentSource`, Remote `rncpSend` / `rncpFetch` / `setRncpListener` / `showRncpOpenFileDialog` / `showRncpSaveDirectoryDialog` / `revealInFolder`. Also `media:ensureCameraAccess`, `gps:exportGpx`, `db:setReticulumDestinationVerified`, Remote DB `db:listReticulumRemoteAddresses` / upsert / delete and `db:listReticulumInboundPolicy` / upsert / delete (`src/main/ipc/reticulum-db-handlers.ts`), `mesh-client:openUrl` / `electronAPI.deepLink.onOpenUrl`. Renderer uses `electronAPI.reticulum` proxy (no direct localhost). Proxy rate-limit soft-envelopes live inside the handler `try` (`settleReticulumProxyFailure`). `ReticulumStackPanel` + `useReticulumInterfaceSnapshot` sync enabled interface names after hydrate so TCP/TX issue banners clear when hubs are disabled — **do not** refresh interfaces on `announce.received` / `stats_update` (that flooded `GET /interfaces` after wake); poll + shared proxy backoff only. `reticulumSidecarIssueTracker` keeps that enabled set sticky while reading sidecar logs. - **Panels:** `ReticulumStackPanel` (Connection — stack lifecycle, interfaces, issue banner), `ReticulumNetworkPanel` (Network — identity **slots** + QR share/ingest, stack/announce settings, Propagation mode Off/Auto/Manual + rename/delete, config import), `ChatDmPaperControls` (Chat DM **Share as paper** + **Scan paper**), `ReticulumMapPanel` (Map — RMAP v4 discovery), `ReticulumRmapDiscoveryControls` / `ReticulumRmapConnectionStatus` (RMAP publish: Network enable-all eligible interfaces; Connection **X of Y** status), `ReticulumAdminPanel` (Admin — RNode flasher, factory reset), `ReticulumPeerListPanel` (Peers — **Peers / History / Contacts / Favorites** sub-tabs; path request + probe + verified badge; LXMFace avatars; History = messaged `last_heard`, Contacts = explicit `is_contact` / Save as contact only), `NomadNetworkPanel` (Nomad — browse + **My Pages** watched-folder static host via `NomadPageServerPanel`/rsNomad; `nomad_serving_enabled` + `nomad_serving_content_source` restore hosting after live stack start; lazy-mount keep-alive, dual-axis page scroll; fit-width default and open-width toggle), `ReticulumRemotePanel` (Remote — rnsh multi-session shell + rncp send/receive/fetch; Saved addresses + inbound policy; Chat DM send-file via `ChatDmRncpControl`), `RrcPanel` (RRC — multi-hub relay chat) - **Deep links / QR:** OS scheme is **`lxm://`** (not `mesh-client://`); `MeshClientDeepLinkHost`, `meshClientDeepLink.ts` (`lxmPaperMessage` kind + `looksLikeLxmPaperBlob`; Games `lxm://game/` / Ratspeak `lrgp:` → `lxmGameSession`), `handleReticulumQrIngest.ts` (shared Network/Chat/OS paper + in-app contact ingest), `applyLxmPaperIngest` → `POST /api/v1/lxmf/paper/ingest`, `QrIngestControl` / `QrCodeImage`. OS contact / MeshCore imports confirm before upsert; **paper OS deep links ingest without confirm**; Games session links open Reticulum Games tab via `openReticulumGameSession`. - **Decommissioned hubs:** `src/shared/reticulumDecommissionedHubs.ts` (Amsterdam only) — stack-start auto-disable + **Add default backbones** disables matching enabled TCP rows; UI badge + enable-block in `ReticulumInterfacesPanel.tsx` (`isDecommissionedReticulumTcpInterfaceRow`); keep TS↔Rust synced via `pnpm run check:reticulum-decommissioned-hubs`. Default backbone picker + region-grouped interface list (Primary & Global / North America / Europe / Asia & Oceania / Specialty / User Defined) in `reticulumDefaultHubPresets.ts` + `ReticulumDefaultHubsPickerModal.tsx`; muted disabled rows + checkbox bulk delete; `countEnabledDefaultHubPresets` / >3 enable warning - **BLE RNode RSSI:** `useReticulumBleRnodeRssiMap` gates on sidecar **running** (not api-ready), burst-then-steady scans via nested `acquireReticulumBleScan`, clears sticky targets immediately when all BLE RNodes are disabled -- **Propagation mode / sync:** Network → Propagation nodes owns Off/Auto/Manual (default **Off**; persisted values including legacy App-panel `auto` are honored). Auto one-time syncs via `startPropagationSyncCascade` + sidecar `destination_hash` sync in order: **finite-hop discovered** (no Add/Preferred) → **configured remotes** → **unknown-hop discovered** → local-prop (skips remotes when no enabled interfaces); runtime hook `useReticulumPropagationAutoSync`. Sidecar `start_propagation_sync` is **client `/get`-primary** (inbox retrieval; UI progress from `PropagationClient`) — peer `/offer` inventory push stays on the local-host peer loop when serving (avoids AwaitingResponse hangs against non-peer remotes with a nonempty messagestore). Hard-fails with `PROPAGATION_PATH_UNKNOWN` when `ensure_path_for_direct` fails after announce settle (same path gate as offer probe). Manual uses Preferred, else picks the best configured remote **for that sync only** (no Preferred write), then the remaining remotes, then local-prop. Off = **no PN support**: `startPropagationSyncCascade` returns early (per-row Sync is disabled in UI), `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` are false, `ReticulumPropagationNotice` is hidden, and the sidecar disarms the outbound PN plus empties cascade candidates (`propagation_mode` in `mesh_client_stack.json`, `POST /api/v1/propagation/mode`, `candidates_for_propagation_mode`); renderer pushes the mode on change and on sidecar-ready. `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Complete on HaveAll, Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation. **Nothing-to-sync is not a failure:** when the cascade contacts no node it writes `syncNoTarget` / `syncLocalLoading` (never overwriting a real error from an attempted node), the local row reports sidecar `status: "loading"` while the messagestore reads (`local_propagation_status` + `PropagationBridge::messagestore_load_pending`, per-row Sync disabled), and the 30 s tick calls `refreshFromSidecar` while `hasPropagationCascadeCandidate` is false so a fresh stack recovers on its own — `refreshFromSidecar` must **not** clear the active attempt while `sync.active`. Debug snapshot `propagationClient` exposes mode/preferred/autoTarget/resolvedSyncTargetId. **Auto also deposits on Discovered PNs:** sidecar `auto_discovered_candidates` (`pn_cascade.rs`, Auto only, cap 3, hop-sorted, skips inactive / self / already-configured / over `max_peering_cost`) appends after configured remotes and before local-prop, rebuilt from the shared `rebuild_pn_cascade_candidates` helper in `live.rs` (called by `refresh_pn_cascade_candidates` **and** the PN announce handler); `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` therefore count discovered rows in Auto, so the Chat notice hides and the link-timeout failure bridge holds off. **Chat notice dismiss:** `chatNoticeDismissed` (`mesh-client:reticulumPropagationNoticeDismissed`) with **Don't show again** on the banner and **Show propagation reminder in Chat** in the Network section. **Named sync target:** `startSync` stamps `syncTargetId`; progress line, inline error, and Sync toasts resolve it with `resolveReticulumPropagationTargetLabel`; the cascade clears it when nothing was contacted so `syncNoTarget` / `syncLocalLoading` stay unprefixed. **Attempts settle before the cascade advances:** `startSync` returns `accepted` | `deferred` | `failed` (not a boolean) — only sidecar _acceptance_ starts `awaitPropagationSyncSettled` (terminal WS frame or stall/ceiling watchdog). `failed` advances with ~15 min session-memory omit via `reticulumPropagationSyncBackoff.ts`; `deferred` (`PROPAGATION_SYNC_OUTBOUND_BUSY` — outbound deposit owns the PN link) advances **without** backoff so the next tick may retry; `cancelled` (user Cancel) stops; `success` ends the run. Remote steps are capped by `PROPAGATION_CASCADE_BUDGET_MS` (5 min) then fall through to local-prop; each remote attempt is capped by `PROPAGATION_CASCADE_ATTEMPT_TIMEOUT_MS` (~60s); local fallback refreshes nodes when local looks disabled; the cascade is single-flight (`resetPropagationSyncCascadeState` is the test seam) so overlapping 30 s ticks join one run while an explicit per-row Sync supersedes it. Auto `/api/v1/interfaces` probe **fails open** (assumes interfaces enabled) so a broken proxy still tries remotes before local. **Retrieval vs peer sync:** User Sync progress is **client `/get`-primary** (`PropagationClient` → `spawn_client_download_driver` with UI emits). Peer `/offer` inventory push runs only on the **local Host peer loop** when serving (`drive_local_host_peer_sync`). Logs: inbox retrieval is `propagation-retrieve` (`retrieve_mode=get|get_post_peer|local`); peer-offer outcome is `propagation-sync … peer_outcome=have_all|transfer` (**not** retrieval). `local-prop` Sync still uses `drain_local_inbox` (in-process). +- **Propagation mode / sync:** Network → Propagation nodes owns Off/Auto/Manual (default **Off**; persisted values including legacy App-panel `auto` are honored). Auto one-time syncs via `startPropagationSyncCascade` + sidecar `destination_hash` sync in order: **finite-hop discovered** (no Add/Preferred) → **configured remotes** → **unknown-hop discovered** → local-prop (skips remotes when no enabled interfaces); runtime hook `useReticulumPropagationAutoSync`. Sidecar `start_propagation_sync` is **client `/get`-primary** (inbox retrieval; UI progress from `PropagationClient`) — peer `/offer` inventory push stays on the local-host peer loop when serving (avoids AwaitingResponse hangs against non-peer remotes with a nonempty messagestore). Hard-fails with `PROPAGATION_PATH_UNKNOWN` when `ensure_path_for_direct` fails after announce settle (same path gate as offer probe). Manual uses Preferred, else picks the best configured remote **for that sync only** (no Preferred write), then the remaining remotes, then local-prop. Off = **no PN support**: `startPropagationSyncCascade` returns early (per-row Sync is disabled in UI), `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` are false, `ReticulumPropagationNotice` is hidden, and the sidecar disarms the outbound PN plus empties cascade candidates (`propagation_mode` in `mesh_client_stack.json`, `POST /api/v1/propagation/mode`, `candidates_for_propagation_mode`); renderer pushes the mode on change and on sidecar-ready. **Ignore for Auto:** `POST/DELETE /api/v1/propagation/auto-blacklist` persists `propagation_auto_blacklist` (32-hex, cap 256); filters Auto sync ranking **and** Auto deposit (`auto_discovered_candidates` + configured retain in Auto); Manual Prefer/Sync still allowed. Ownership: mode in renderer localStorage (+ sidecar mirror); blacklist + deposit candidates in sidecar; sync cascade orchestration in `reticulumPropagationAutoApply.ts`; `startSync` attempt stamps must be unique across same-ms supersession. `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / path-unknown / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation. Cancel mid-`/get` must call `PropagationClient::abort_transfer` (rsLXMF overlay) or the next Sync stays `PROPAGATION_RETRIEVE_BUSY`. Silent Host `/get` terminal clear must only drop the latch when `propagation_sync_target` still equals that peer. **Nothing-to-sync is not a failure:** when the cascade contacts no node it writes `syncNoTarget` / `syncLocalLoading` / `syncRetrieveBusy` (never overwriting a real error from an attempted node), the local row reports sidecar `status: "loading"` while the messagestore reads (`local_propagation_status` + `PropagationBridge::messagestore_load_pending`, per-row Sync disabled), and the 30 s tick calls `refreshFromSidecar` while `hasPropagationCascadeCandidate` is false so a fresh stack recovers on its own — `refreshFromSidecar` must **not** clear the active attempt while `sync.active`. Debug snapshot `propagationClient` exposes mode/preferred/autoTarget/resolvedSyncTargetId/autoBlacklist. **Auto also deposits on Discovered PNs:** sidecar `auto_discovered_candidates` (`pn_cascade.rs`, Auto only, cap 3, hop-sorted with `MAX_PLAUSIBLE_PROPAGATION_HOPS=32`, skips inactive / self / already-configured / Auto-blacklist / over `max_peering_cost`) appends after configured remotes and before local-prop, rebuilt from the shared `rebuild_pn_cascade_candidates` helper in `live.rs`; capacity helpers count non-blacklisted discovered rows in Auto. **Chat notice dismiss:** `chatNoticeDismissed` with **Don't show again** / Network toggle. **Named sync target:** `startSync` stamps `syncTargetId`. **Attempts settle before the cascade advances:** `startSync` returns `accepted` | `deferred` | `failed`. Soft-defer (`PROPAGATION_SYNC_OUTBOUND_BUSY` / `PROPAGATION_RETRIEVE_BUSY` / `PROPAGATION_STACK_NOT_LIVE`) advances **without** 15‑min backoff; all-remote soft-defer + local-only settle must **not** advance `lastPropagationSyncAt` as a full success. Remote budget `PROPAGATION_CASCADE_BUDGET_MS` (5 min); per-attempt ~60s; single-flight cascade. Auto `/api/v1/interfaces` probe **fails closed** (treat read/rate-limit as no interfaces → local-only settle). **Retrieval vs peer sync:** User Sync progress is **client `/get`-primary**. Peer `/offer` runs only on the **local Host peer loop**. Logs: `propagation-retrieve` (`retrieve_mode=get|get_post_peer|get_periodic|local`); peer-offer `propagation-sync … peer_outcome=*` (**not** retrieval). `local-prop` Sync uses `drain_local_inbox` and returns `PROPAGATION_STACK_NOT_LIVE` when live is absent. - **Host PN fabric → Chat (lxmd-style glue):** When local Host is enabled, mesh-client is both PN and end-client on rsLXMF (not a second lxmd). Path: outbound deposit → host peer `/offer` push (generation-gated; lxmd terminal bookkeeping: `sync_complete` / `mark_offer_generation_processed` / `take_handled_updates` + `save_peer`); inbound peer Resource accept → `request_inbox_drain` → maintenance `drain_local_inbox` → `delivery_callback` → Chat; after host peer `/offer` Completes for peer `P`, sequenced **silent** client `/get` (`retrieve_mode=get_post_peer`); while serving and quiet, **~90s** periodic silent `/get` round-robin over peered remotes (Prefer/outbound first, `retrieve_mode=get_periodic`) for inbox catch-up — not a timed empty re-`/offer`. Re-`/offer` when `offer_generation` advances, offer policy changes, or a partial sync left work; maintenance polls every ~2s but only _starts_ work when idle. Guards: coalesce drain; one internal `/get`; skip when user Sync target / outbound deposit owns the hash or `sync_active` / `client_download_active`. Do **not** re-attach peer `/offer` to the Sync button. Dual full-index exchange in one Link stays upstream rsLXMF; remote inventory for re-propagation arrives when peers `/offer` to our serve path. - **PN hosting:** Network **Advanced PN hosting** / `ReticulumPnHostingDangerZone`; shared `pnHostingPolicy.ts` + sidecar `pn_hosting_policy.rs` / `pn_hosting_apply.rs`; `POST /api/v1/propagation/hosting-policy`; rsLXMF policy-setters overlay ([ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6)). Messagestore loads in background on live attach; enabled `local-prop` serve/announce waits until load completes. - **Interface modes:** rnsd `mode` via `reticulumInterfaceMode.ts` + sidecar `normalize_interface_mode` (keep catalogs in sync — `pnpm run check:reticulum-interface-modes` in pre-commit/`release.sh`); add defaults TCP/UDP/I2P → `boundary`, RNode → `access_point`; UI in `ReticulumInterfacesPanel`; default hub presets add/repair missing mode to `boundary` (do not overwrite valid non-boundary). See [../reticulum.md#interface-modes](../reticulum.md#interface-modes). diff --git a/docs/meshcore-meshtastic-parity.md b/docs/meshcore-meshtastic-parity.md index f43604483..57881f01d 100644 --- a/docs/meshcore-meshtastic-parity.md +++ b/docs/meshcore-meshtastic-parity.md @@ -75,7 +75,7 @@ MeshCore **regions** (on repeaters) and **scopes** (on outbound flood messages) ## MeshCore: identity-scoped UI stores -Live Chat and Nodes read **identity-scoped** `nodeStore` / `messageStore` (keyed by `identityId`) via `identityStoreReads` (`getIdentityNode` / `getIdentityChatMessages`). DM/trace pubkeys live in `meshcorePubKeyRegistry` (mirrored into runtime maps for send/RPC). Hydration: [`hydrateIdentityStoresFromDb.ts`](../src/renderer/lib/hydrateIdentityStoresFromDb.ts). **Chat-driven `last_heard`** (`meshcoreIngest`, `ensureMeshcoreChatSenderInNodeStore`) updates node freshness on text traffic, not only adverts. +Live Chat and Nodes read **identity-scoped** `nodeStore` / `messageStore` (keyed by `identityId`) via `identityStoreReads` (`getIdentityNode` / `getIdentityChatMessages`). DM/trace pubkeys live in `meshcorePubKeyRegistry` (mirrored into runtime maps for send/RPC; Nodes also keep `meshcorePubKeyHexByNodeId` for offline short ids). Nodes table shows **Node health** (not a MeshCore ID column) and pubkey short id (`!` + 8 hex) with a key icon. Hydration: [`hydrateIdentityStoresFromDb.ts`](../src/renderer/lib/hydrateIdentityStoresFromDb.ts). **Chat-driven `last_heard`** (`meshcoreIngest`, `ensureMeshcoreChatSenderInNodeStore`) updates node freshness on text traffic, not only adverts. ## MeshCore: Rooms scroll UX diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index a830b1ddb..651079288 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -116,7 +116,7 @@ Routing bias between **RF** (LoRa / RNode) and **network** (TCP/UDP/I2P/gateway/ **WS `rmap.discovery`:** sidecar polls DiscoveryStore every **10s**; emits full `{ discovered: [...] }` snapshot when JSON fingerprint changes. Stub builds return `{ discovered: [] }`. | GET | `/api/v1/packets` | `?limit=500` (1–2500) | `{ packets: [] }` — recent wire tap ring buffer | | DELETE | `/api/v1/packets` | | `{ ok }` — clear wire tap buffer | -| GET | `/api/v1/propagation` | | `{ propagation, preferred_id, auto_sync_interval_sec, pn_hosting_policy }` — `local-prop` rows include `message_count`, `storage_bytes` when live | +| GET | `/api/v1/propagation` | | `{ propagation, preferred_id, auto_sync_interval_sec, propagation_mode, propagation_auto_blacklist, pn_hosting_policy, last_propagation_sync_at? }` — `local-prop` rows include `message_count`, `storage_bytes` when live | | GET | `/api/v1/propagation/discovered` | | `{ discovered: DiscoveredPropagationRow[] }` — heard `lxmf.propagation` announces (not auto-configured) | | POST | `/api/v1/propagation/add` | `{ destination_hash, name?, skip_probe? }` | `{ ok, node }` or `{ ok: false, error }` — probes `/offer` unless `skip_probe`; may return `PROPAGATION_OFFER_UNSUPPORTED`, `PROPAGATION_PEER_COST_EXCEEDS_MAX`, identity/path errors | | POST | `/api/v1/propagation/hosting-policy` | `PnHostingPolicy` | `{ ok }` — persist + apply local PN hosting / peering policy | @@ -125,8 +125,11 @@ Routing bias between **RF** (LoRa / RNode) and **network** (TCP/UDP/I2P/gateway/ | POST | `/api/v1/propagation/{id}/enable` | | `{ ok }` — for `local-prop`, starts PN serve + announce (waits for messagestore load — see below) | | POST | `/api/v1/propagation/{id}/disable` | | `{ ok }` — for `local-prop`, stops PN serve + announce | | POST | `/api/v1/propagation/{id}/preferred` | | `{ ok }` | -| POST | `/api/v1/propagation/sync` | | `{ ok }` | -| POST | `/api/v1/propagation/sync/cancel` | | `{ ok }` | +| POST | `/api/v1/propagation/mode` | `{ mode: "off"\|"auto"\|"manual" }` | `{ ok }` — gates outbound PN cascade + Auto sync eligibility | +| POST | `/api/v1/propagation/auto-blacklist` | `{ destination_hash }` | `{ ok }` — Ignore for Auto (sync + deposit); 32-hex; cap 256 | +| DELETE | `/api/v1/propagation/auto-blacklist/{hash}` | | `{ ok }` — Allow Auto again | +| POST | `/api/v1/propagation/sync` | `{ propagation_id? }` or `{ destination_hash? }` | `{ ok }` — **client `/get`-primary** for remotes; `local-prop` drains in-process. Errors include `PROPAGATION_PATH_UNKNOWN`, `PROPAGATION_RETRIEVE_BUSY`, `PROPAGATION_SYNC_OUTBOUND_BUSY`, `PROPAGATION_STACK_NOT_LIVE`, identity/non-PN/peering codes | +| POST | `/api/v1/propagation/sync/cancel` | | `{ ok }` — cancels Sync UI run and aborts in-flight client `/get` (`abort_transfer`) | | POST | `/api/v1/propagation/auto-sync-interval` | `{ interval_sec }` | `{ ok }` — `0` disables periodic sync; persists with stack | **Deferred PN messagestore:** On live attach, the local propagation messagestore loads in the background (`spawn_blocking`) so a large disk store does not gate TCP/LXMF/RRC readiness. New writes still go to `storage_dir`. If `local-prop` is enabled, serve/announce starts only after that load finishes (avoids advertising an empty PN while the scan runs). @@ -306,7 +309,7 @@ Shared `reticulum:proxy*` IPC is capped at **900/min**. `GET /api/v1/lxmf/recent `getStatus` / `onStatus` may include `interfaceIssueAlert` (TCP connect failures, TX queue drops — including BLE / bond-stale cause keys used by Diagnostics and Connection hints, link-delivery timeouts, transport saturation / slow queries, **`bleBondRemoved`** stale RNode bonds (sticky until stack stop / interface remove; not pruned solely by the 5‑minute window), **`blePairingTimedOut`** OS passkey / TX-read timeouts). Per-entry latch timestamps use a **5-minute** stale window (`RETICULUM_INTERFACE_ISSUE_ALERT_STALE_MS`) for most issues; Connection syncs **enabled** interface names via `syncInterfaceIssueScope` so disabling or removing an interface clears that name immediately and rejects re-latch from lagging log lines. Stopping the stack (or unexpected process exit) clears the tracker. -**`propagation_sync` WebSocket payload:** `{ active: boolean, progress: number, message: string | null }`. Progress uses 0–100 (Establishing ≈10, Offering ≈25, …, Complete ≈100). Sticky success after HaveAll emits `active:false, progress:100`; cancel/stall/failure emit `active:false, progress:0` (and must not emit a trailing 100). Sync `POST /api/v1/propagation/sync` may return `PROPAGATION_IDENTITY_UNKNOWN`, `PROPAGATION_TARGET_NOT_PN`, `PROPAGATION_PEERING_STAMP_FAILED`, `PROPAGATION_PEER_COST_EXCEEDS_MAX`, or `LOCAL_PROPAGATION_SYNC_UNSUPPORTED`. Add may return `PROPAGATION_OFFER_UNSUPPORTED` / probe timeout failures. +**`propagation_sync` WebSocket payload:** `{ active: boolean, progress: number, message: string | null }`. Progress uses 0–100 driven by the **client `/get` download** (Establishing ≈10 … Complete ≈100). Sticky success emits `active:false, progress:100`; cancel/stall/failure emit `active:false, progress:0` (and must not emit a trailing 100). Sync `POST /api/v1/propagation/sync` may return `PROPAGATION_PATH_UNKNOWN`, `PROPAGATION_RETRIEVE_BUSY`, `PROPAGATION_SYNC_OUTBOUND_BUSY`, `PROPAGATION_STACK_NOT_LIVE`, `PROPAGATION_IDENTITY_UNKNOWN`, `PROPAGATION_TARGET_NOT_PN`, `PROPAGATION_PEERING_STAMP_FAILED`, `PROPAGATION_PEER_COST_EXCEEDS_MAX`, or `LOCAL_PROPAGATION_SYNC_UNSUPPORTED`. Add may return `PROPAGATION_OFFER_UNSUPPORTED` / probe timeout failures. SQLite chat history uses separate `db:*` handlers (`getReticulumMessages`, `saveReticulumMessage`, `searchReticulumMessages`, `deleteReticulumMessage`, destination upserts), not sidecar HTTP. Remote saved addresses / inbound policy and RRC room history also use dedicated `db:*` handlers (not sidecar HTTP). diff --git a/docs/reticulum.md b/docs/reticulum.md index 4ef3bb2af..3db03ed62 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -38,7 +38,7 @@ After changing interfaces on a live network, **restart the stack** so RNS picks | Topology | Best-effort graph from path-table next hops (not a full multi-hop trace) | | Map | Local RMAP v4 discovery map (heard opt-in interfaces with GPS); link to rmap.world for global view | | Nomad Network | Favourites / announces list (collapsible sidebar, default Favourites sub-tab) plus **My Pages** watched-folder hosting; **lazy-mount after first visit**; Micron (.mu) browser in a **dual-axis scroll shell**; **fit-width wrap default** with open-width toggle for ASCII pages; in-page navigation, back/forward, session page cache, `/file/` downloads, source toggle, and lxmf:// DM links; page/file errors humanized via `nomadPageErrorHumanize.ts`. Local hosting uses sibling [rsNomad](https://github.com/Colorado-Mesh/rsNomad) (`nomad-core`) for static `/page` + `/file` serving and `nomadnetwork.node` announces (no CGI). Choose a site root (`pages/`) or pages directory; FS watcher reloads routes; `nomad_serving_enabled` auto-restores after stack start. | -| Propagation | **Propagation mode** (Off / Auto / Manual; default **Off**) with per-mode guidance — Off disables sync **and** the outbound PN cascade. **Sync:** Auto one-time syncs the best Discovered PN by hash (does not add it or change Preferred), then configured remotes, then hosted local-prop; Manual syncs Preferred (or closest added for that sync) then other added remotes then hosted local-prop (`stored_locally` when that step Completes). **Outbound deposit cascade** (separate from Auto’s sync-target pick): preferred / configured remotes first → in Auto up to 3 Discovered PNs → local-prop last. Preferred node, per-node **Sync messages**, rename/delete remote nodes, **Discovered on network** (Add / Add & prefer with `/offer` probe), optional **local PN hosting** (usable as the only PN), configurable **auto-sync interval**, Network **Advanced PN hosting** policy | +| Propagation | **Propagation mode** (Off / Auto / Manual; default **Off**) with per-mode guidance — Off disables sync **and** the outbound PN cascade. **Sync** is **client `/get`-primary** (inbox into Chat; UI progress from `PropagationClient`). Auto order: **finite-hop Discovered** (no Add/Preferred) → configured remotes → **unknown-hop Discovered** → local-prop; **Ignored for Auto** hashes are skipped for sync and deposit. Manual: Preferred (or closest added for that sync) → other remotes → local-prop. Hard-fails with `PROPAGATION_PATH_UNKNOWN` when no path after announce settle. Peer `/offer` inventory push stays on the **Host peer loop** when serving. **Outbound deposit cascade** (separate): preferred / configured remotes → in Auto up to 3 Discovered PNs (plausible hops ≤32) → local-prop last. Preferred, per-node Sync, rename/delete, Discovered Add/`/offer` probe, local PN hosting, auto-sync interval, Advanced PN hosting policy. | | Diagnostics | Reticulum-native interface / path / LXMF health and config audit (`reticulum/*` rows only on this tab; LoRa Hop Goblins and foreign-LoRa tables are Meshtastic/MeshCore-scoped) | | Admin | RNode firmware flasher (Web Serial), stack factory reset | | Sniffer / Stats | Reticulum packet log tab (`rawPacketLog.reticulum.*`) | @@ -275,7 +275,7 @@ When multiple enabled local RNode interfaces are connected, the interface list s - **Config validate:** Electron IPC `reticulum:validateConfig` → one-shot sidecar `validate-config --json` against `userData/reticulum/config` - **Announces:** interval (`announce_interval_sec`, 0–86400; default **3600** s / 1 h when unset; `0` = startup-only) persisted in rnsd config. The live sidecar sends an **LXMF delivery** announce shortly after stack start and on that interval (Ratspeak/lxmd parity). **Announce now** (`POST /api/v1/announces`) forces an immediate delivery announce. **Clear announces** (`DELETE /api/v1/announces`) clears the stub peer cache; the live path table may refill on the next peer refresh. Per-interface `announce_interval_min` (RMAP/discoverable interfaces) is separate. - **Inbound LXMF:** the sidecar registers `lxmf.delivery` with the transport (`RegisterDestination` + `LinkManager`) and feeds decrypted link/resource payloads into the delivery callback (WS `lxmf_message`). Without this registration, peer DMs never appear in Chat even when paths exist. -- **Propagation:** **Propagation mode** (Network → Propagation nodes; **Off** default / **Auto** / **Manual**) — **Off** means **no propagation support**: no sync (periodic, bottom **Sync**, or per-node **Sync messages**) and no outbound Direct→PN cascade, so nothing is deposited on a remote PN or the local inbox; a saved Preferred row stays on disk and is re-armed only when you pick Auto/Manual (renderer pushes the mode to the sidecar via `POST /api/v1/propagation/mode`, persisted as `propagation_mode` in `mesh_client_stack.json`). **Auto** one-time syncs the best **Discovered** PN by destination hash (does **not** add it to the configured list or change Preferred), then tries configured remotes, then local-prop (skips remotes when no enabled interfaces). Auto also **deposits** outbound LXMF on Discovered PNs: `auto_discovered_candidates` (`pn_cascade.rs`) appends up to `MAX_AUTO_DISCOVERED_PN_CANDIDATES` (3) heard nodes — hop-sorted, skipping inactive announces, the self hash, already-configured hashes, and `peering_cost` above the hosting policy `max_peering_cost` — **after** the added remotes and **before** local-prop. Nothing is persisted; the announce handler and `refresh_pn_cascade_candidates` share one rebuild helper in `live.rs`, so a newly heard PN becomes cascade-eligible without a stack restart. Because Auto really uses them, the Chat **“No propagation node is configured”** banner hides in Auto as soon as one PN is discovered; Manual only counts nodes you added. The banner also has **Don't show again**, backed by **Show propagation reminder in Chat** in Network → Propagation nodes (`chatNoticeDismissed`, persisted in `mesh-client:reticulumPropagationNoticeDismissed`). **Manual** syncs Preferred; with no Preferred it picks the closest added remote **for that sync only** (no Preferred write), then falls back to the other added remotes, then local-prop. Every cascade step **waits for that attempt to settle** before deciding what to do next: `startSync` only reports that the sidecar _accepted_ the request, so the renderer awaits the terminal `propagation_sync` frame (or the stall / ceiling watchdog) through `awaitPropagationSyncSettled` and moves to the next candidate on failure — a node that accepts and then never establishes no longer ends the cascade at its first step. A user **Cancel** stops the chain instead of advancing. The remote half of the chain is capped by `PROPAGATION_CASCADE_BUDGET_MS` (5 min), after which it goes straight to the local inbox, and a target that just failed is omitted for 15 minutes (`reticulumPropagationSyncBackoff.ts`, session memory) so a hop-closest dead PN cannot monopolize every tick; each remote attempt is capped at ~60s (`PROPAGATION_CASCADE_ATTEMPT_TIMEOUT_MS`) before the cascade advances, and local fallback re-reads sidecar nodes when local looks disabled. Because a cascade can now span several attempts, overlapping 30 s auto-sync ticks join the single in-flight run; an explicit per-row **Sync** supersedes it. **Local-only PN** is a supported setup for an always-on machine: enable local-prop with no remotes and every cascade/sync settles in the local inbox. Preferred node for offline DMs, per-node **Sync messages**, add remote propagation nodes by 32-character `lxmf.propagation` hash or from the **Discovered on network** list (heard PN announces; explicit **Add / Add & prefer** for list management), **rename** / **delete** remote nodes, optional **local PN hosting** (announce + `/offer`/`/get` + Link Resource deposit ingress with stamp validation into the local store, plus outbound peer inventory sync when hosting + autopeer/static peers are on), Network **Advanced PN hosting** policy (`peering_cost`, `max_peering_cost`, autopeer, stamps, storage), Add-time `/offer` probe, **auto-sync interval** (`auto_sync_interval_sec`; `0` disables periodic sync; interval measured from last _successful_ sync with a short failure cooldown). Local-prop messagestore load is **deferred** off the live-ready path; serve/announce waits until that load finishes so peers are not syncing an empty store — while that load runs, `list_propagation` reports the local row as `status: "loading"` (not just disabled), the row renders **loading…**, and its per-node **Sync messages** is disabled. When a sync cascade finds nothing to contact (no Discovered PN, no added remotes, local inbox off or still loading) it reports **why** — `reticulumPropagation.syncLocalLoading` or `reticulumPropagation.syncNoTarget` — instead of the generic "node may be unreachable"; a real per-node error from an attempted node is never overwritten. Every sync attempt stamps `syncTargetId`, so the progress line, the inline error, and the Sync toasts **name the node** being tried (`resolveReticulumPropagationTargetLabel` — configured row name, announce name, or hash prefix); the cascade clears it when it contacted nobody, so "nothing to sync with" is never blamed on a node. The 30 s auto-sync tick re-reads `/api/v1/propagation` while no cascade candidate exists, so a fresh stack starts syncing on its own once an announce lands or the local store finishes loading. Remote sync runs **two independent operations** against the PN: the **`/offer` peer sync** (`PropagationSyncTask` — inventory replication that drives the progress bar and the `have_all`/`transfer` outcome) and the **client `/get` download** (`PropagationClient` in `propagation_download.rs` / `propagation_bridge.rs`, driven from `live.rs::spawn_client_download_driver`) which is the half that actually **retrieves your own mail into Chat** (list → get → purge, Python `request_messages_from_propagation_node` parity). Retrieved blobs are decrypted with the local identity, unpacked, and delivered through the normal router callback (so WS `lxmf_message`, the recent ring, and catch-up fire unchanged). `local-prop` Sync has no remote link, so it replays the in-process node's own list → serve → purge (`PropagationBridge::drain_local_inbox`) to drain your inbox. Remote sync **always sends an LXMF delivery announce** then settles briefly (~2s) before Establishing so the PN has a reverse path for LRPROOF, **re-requests the forward path** (does not reuse a possibly stale hop count), pins/persists PN identity during Establishing (avoids announce-flood eviction), resolves identity+path before Establishing, rejects non-PN destinations (`PROPAGATION_TARGET_NOT_PN`), requires a peering stamp when cost > 0, treats HaveAll/Complete as success (not failure), surfaces `NoLinkProof` when establish stalls without a proof, and the renderer cancels Establishing-only stalls (~45s) plus a hard ceiling (~180s) via `reticulumPropagationSync.ts` without overwriting sidecar failure keys. After Sync Completes, the renderer runs inbound LXMF catch-up so Chat does not wait for the periodic ring poll. Correlatable logs: `propagation-deposit` (`message_hash`, `transient_id`, `pn_hash`, and on Completes `cascade_step` + `delivery_method`) for outbound; `propagation-retrieve` for the client `/get` download (`retrieve_mode=get`, `listed` / `downloaded` / `delivered` counts, `retrieve_mode=local` for local-prop) and per delivered message. The peer-offer side now logs under `propagation-sync` with `peer_outcome=have_all|transfer` — it is **not** inbox retrieval. Developer support bundles always include `reticulum/mesh_client_stack.json` and `reticulum/lxmf-outbound.log` (placeholder when absent), and `debug-snapshot.json` carries a `propagationClient` slice (`mode`, `preferredId`, `resolvedSyncTargetId`, `autoTarget`, `lastSyncError`). +- **Propagation:** **Propagation mode** (Network → Propagation nodes; **Off** default / **Auto** / **Manual**) — **Off** means **no propagation support**: no sync (periodic, bottom **Sync**, or per-node **Sync messages**) and no outbound Direct→PN cascade; a saved Preferred row stays on disk and is re-armed only when you pick Auto/Manual (renderer pushes mode via `POST /api/v1/propagation/mode`, persisted as `propagation_mode` in `mesh_client_stack.json`). **User Sync is client `/get`-primary** — inbox retrieval into Chat; UI progress comes from `PropagationClient` (`propagation_bridge.rs` / `live.rs::spawn_client_download_driver`). Peer `/offer` inventory replication stays on the **local Host peer loop** when serving (not on the Sync button), so a nonempty messagestore cannot hang Sync at AwaitingResponse against non-peer remotes. **Auto** sync cascade order: **finite-hop Discovered** by destination hash (does **not** Add or change Preferred) → configured remotes → **unknown-hop Discovered** → local-prop (skips remotes when no enabled interfaces). Hop counts above `MAX_PLAUSIBLE_PROPAGATION_HOPS` (32) rank as unknown on both sync and Auto deposit. **Ignore for Auto** (`POST/DELETE /api/v1/propagation/auto-blacklist`, persisted as `propagation_auto_blacklist`) omits those hashes from Auto sync **and** Auto deposit; Manual Prefer/Sync still works. Auto also **deposits** outbound LXMF on Discovered PNs: `auto_discovered_candidates` (`pn_cascade.rs`) appends up to `MAX_AUTO_DISCOVERED_PN_CANDIDATES` (3) heard nodes — hop-sorted, skipping inactive announces, self hash, configured hashes, Auto-blacklist, and `peering_cost` above `max_peering_cost` — after added remotes and before local-prop. Chat **“No propagation node is configured”** hides in Auto when a non-ignored Discovered PN exists; Manual only counts added nodes. Banner **Don't show again** ↔ Network **Show propagation reminder in Chat**. **Manual** syncs Preferred; with no Preferred picks the closest added remote **for that sync only**, then other remotes, then local-prop. Cascade steps await settle (`awaitPropagationSyncSettled`); Cancel stops the chain; Cancel mid-`/get` calls rsLXMF `PropagationClient::abort_transfer` (overlay) so the next Sync is not stuck `PROPAGATION_RETRIEVE_BUSY`. Soft-defer codes: `PROPAGATION_SYNC_OUTBOUND_BUSY`, `PROPAGATION_RETRIEVE_BUSY`, `PROPAGATION_STACK_NOT_LIVE`. Hard-fail `PROPAGATION_PATH_UNKNOWN` when `ensure_path_for_direct` fails after announce settle (same gate as `/offer` probe) — no 45s Establishing stall. Remote half capped by `PROPAGATION_CASCADE_BUDGET_MS` (5 min) and ~60s per attempt; 15-minute failure backoff (not for soft-defer). **Local-only PN** is supported. Preferred / Discovered Add / rename / delete / local hosting (`/offer`+`/get` + stamp-validated Resource ingress) / Advanced PN hosting / auto-sync interval as before. Local-prop messagestore load is deferred; `status: "loading"` while pending. Empty-cascade errors: `syncLocalLoading` / `syncNoTarget` / `syncRetrieveBusy`. Every attempt stamps `syncTargetId`. `local-prop` Sync drains via `drain_local_inbox` (requires live stack — else `PROPAGATION_STACK_NOT_LIVE`). Remote Sync still announces + settles before Establishing, pins PN identity, rejects `PROPAGATION_TARGET_NOT_PN`, stalls ~45s / ceiling ~180s. Logs: `propagation-deposit`, `propagation-retrieve` (`retrieve_mode=get|get_post_peer|get_periodic|local`), `propagation-sync` peer_outcome (offer side — **not** inbox retrieval). Support bundles include `propagationClient` + PATH_UNKNOWN-capable log slices. --- diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2d6fb2326..1a82beea8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1309,19 +1309,23 @@ Bond-stale **TX queue full** hints (`txQueueDropsHintBleBondStale`) point at the ### Reticulum remote propagation sync fails or never completes -**Symptoms**: **Sync messages** stays Establishing, fails with “not an LXMF propagation node”, “no link proof”, or marks Complete incorrectly after cancel. +**Symptoms**: **Sync messages** stays Establishing, fails with “not an LXMF propagation node”, “no link proof”, “no network path”, or marks Complete incorrectly after cancel. **Cause / behavior**: -- Remote sync needs a known identity (and prefers a path). Missing identity → `PROPAGATION_IDENTITY_UNKNOWN`. +- User Sync is **client `/get`-primary** (inbox into Chat). Peer `/offer` inventory push is Host peer-loop only when serving. +- **No path yet** → `PROPAGATION_PATH_UNKNOWN` (hard-fail after announce settle; UI `syncPathUnknown`) — not a 45s Establishing stall. Wait for a path / **Announce now**, retry. +- Remote sync needs a known identity. Missing identity → `PROPAGATION_IDENTITY_UNKNOWN`. - Destinations that announce as delivery/other (including TCP hubs) → `PROPAGATION_TARGET_NOT_PN`. Add a destination that announces `lxmf.propagation`. - Establishing with **no LRPROOF** often means the PN lacks a reverse path to your LXMF identity. Sync always sends an LXMF delivery announce and waits briefly before Linking; if that still stalls, use Network → **Announce now** and retry. +- Soft-defer `PROPAGATION_RETRIEVE_BUSY` means Host silent `/get` or another retrieve owns the client — wait or Cancel; Cancel must call rsLXMF `abort_transfer` or the next Sync stays busy. +- Auto keeps picking a bad Discovered PN → **Ignore for Auto** on that row (Manual Prefer/Sync still works). - HaveAll / Complete is success (not failure). Cancel or Establishing stall (~45s) must not advance “last synced”. - Transfer-phase hangs use a renderer hard ceiling (~180s) plus lxmf-core’s own timeouts. -- Auto/Manual **Sync** runs a multi-step cascade that waits for each attempt to settle (terminal WS frame or stall/ceiling). Failed remotes are omitted for ~15 minutes; the remote half of a cascade is capped (~5 min budget, ~60s per remote attempt) before falling through to local-prop. Soft defer `PROPAGATION_SYNC_OUTBOUND_BUSY` (outbound deposit owns the PN link) does **not** start that backoff — the next tick may retry the same node. -- Auto-sync interval counts from the last _successful_ sync; failed attempts only apply a short cooldown (~2 min) so they do not postpone the next scheduled sync forever. Nothing-to-sync (`syncNoTarget` / local messagestore still loading) is not treated as a failure. +- Auto/Manual **Sync** runs a multi-step cascade that waits for each attempt to settle (terminal WS frame or stall/ceiling). Failed remotes are omitted for ~15 minutes; the remote half of a cascade is capped (~5 min budget, ~60s per remote attempt) before falling through to local-prop. Soft defer `PROPAGATION_SYNC_OUTBOUND_BUSY` / `PROPAGATION_RETRIEVE_BUSY` / `PROPAGATION_STACK_NOT_LIVE` does **not** start that backoff — the next tick may retry the same node. +- Auto-sync interval counts from the last _successful_ sync; failed attempts only apply a short cooldown (~2 min). Nothing-to-sync (`syncNoTarget` / local messagestore still loading / retrieve busy) is not treated as a full remote success. -**Fix**: Prefer a discovered `lxmf.propagation` node, wait for an announce/path, retry **Sync** (or **Announce now** then Sync), and check Device logs for `[propagation-sync]` / offer errors. If Add fails with **offer unsupported**, the destination does not speak LXMF `/offer`. If Sync/Add fails with **peering cost exceeds max**, raise **Network → Advanced PN hosting → Max peering cost**. +**Fix**: Prefer a discovered `lxmf.propagation` node, wait for an announce/path, retry **Sync** (or **Announce now** then Sync), and check Device logs for `propagation-retrieve` (`retrieve_mode=get`) and path-gate `PROPAGATION_PATH_UNKNOWN`. Peer `/offer` errors under `propagation-sync` apply to Host peer loop, not the Sync button. If Add fails with **offer unsupported**, the destination does not speak LXMF `/offer`. If Sync/Add fails with **peering cost exceeds max**, raise **Network → Advanced PN hosting → Max peering cost**. ### Reticulum local PN hosting not discoverable diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index 48867c25b..bea9769e7 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -496,3 +496,26 @@ Log when `LinkManager` opportunistic inbound-raw `try_send` fails because the bo ### Sunset When upstream logs (or otherwise surfaces) inbound-raw saturation the same way, remove this patch and the apply step. + +## rsLXMF-propagation-client-abort-transfer.patch + +Adds `PropagationClient::abort_transfer` so Cancel / mid-transfer abort leaves the client **Idle**. Without it, a cancelled Sync can leave `/get` stuck busy and the next Sync returns `PROPAGATION_RETRIEVE_BUSY` forever (or Auto falsely concludes there are no PNs). + +| Field | Value | +| ----- | ----- | +| **Base commit** | floated `origin/main` (regenerate; record short SHA in PR) | +| **Upstream PR** | none yet (mesh-client-local; watch ratspeak/rsLXMF) | + +**Touches:** rsLXMF `PropagationClient` (abort in-flight list/get transfer → Idle) + +### Apply locally + +```bash +./scripts/apply-rsLXMF-propagation-client-abort-transfer.sh +``` + +Listed in `scripts/lib/ratspeak-overlay-apply-list.sh` and `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh`. Sidecar `PropagationBridge::cancel_client_download` must call `abort_transfer`. + +### Sunset + +When upstream rsLXMF exposes equivalent abort / cancel mid-transfer cleanup, remove this patch and the apply step. 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/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..1eb2ec726 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -2748,10 +2748,14 @@ impl LiveBridge { if let Some((peer_hash, retrieve_mode)) = start_silent_get { let pn_hex = hex::encode(peer_hash); let outbound_for_clear = Arc::clone(&outbound); + // Only clear the sync latch when we still own this peer — a newer + // user Sync may have claimed the target after cancel_client_download. let on_terminal: Arc = Arc::new(move || { if let Ok(mut driver) = outbound_for_clear.lock() { - driver.clear_propagation_identity_pins(); - driver.set_propagation_sync_target(None); + if driver.propagation_sync_target() == Some(peer_hash) { + driver.clear_propagation_identity_pins(); + driver.set_propagation_sync_target(None); + } } }); // Dedicated cancel; user Sync cancel_propagation_sync still @@ -2779,7 +2783,9 @@ impl LiveBridge { "silent client /get queued" ); } else if let Ok(mut driver) = outbound.lock() { - driver.set_propagation_sync_target(None); + if driver.propagation_sync_target() == Some(peer_hash) { + driver.set_propagation_sync_target(None); + } if retrieve_mode == "get_post_peer" { propagation.queue_post_peer_get(peer_hash); } @@ -2945,6 +2951,15 @@ impl LiveBridge { .ok } + /// Shared path gate for offer probe + Sync (`PROPAGATION_PATH_UNKNOWN`). + async fn ensure_propagation_path_or_unknown(&self, dest_hex: &str) -> Result<(), String> { + if self.ensure_path_for_direct(dest_hex, true).await { + Ok(()) + } else { + Err("PROPAGATION_PATH_UNKNOWN".into()) + } + } + /// Like [`Self::ensure_path_for_direct`], with a custom wait and optional /// fall-through when a forced DropPath never observes path absence (common /// on TCP hub routes that reinstall immediately). @@ -3586,9 +3601,7 @@ impl LiveBridge { self.cancel_propagation_sync().await; self.rehydrate_propagation_identities_from_persisted(); let identity_ok = self.ensure_identity_for_direct(&dest_hex).await; - if !self.ensure_path_for_direct(&dest_hex, true).await { - return Err("PROPAGATION_PATH_UNKNOWN".into()); - } + self.ensure_propagation_path_or_unknown(&dest_hex).await?; let identity_known_after = self .outbound .lock() @@ -3767,20 +3780,21 @@ impl LiveBridge { // Hard path gate after announce settle so RequestPath can benefit from the announce. // Do this before peering PoW — nonzero peering_cost stamps are expensive and useless // when there is no path (would previously burn CPU then stall into syncTimedOut). - let path_ok = self.ensure_path_for_direct(&dest_hex, true).await; let hops = self.hops_to_destination(&dest_hex).await; - if !path_ok { + if let Err(err) = self.ensure_propagation_path_or_unknown(&dest_hex).await { + // Path gate awaits; only clear if we still own this attempt's latch. if let Ok(mut driver) = self.outbound.lock() { - driver.set_propagation_sync_target(None); + if driver.propagation_sync_target() == Some(hash) { + driver.set_propagation_sync_target(None); + } } tracing::info!( target: "propagation-sync", dest = %dest_hex, - path_ok, hops = ?hops, "propagation sync aborted — no path to PN" ); - return Err("PROPAGATION_PATH_UNKNOWN".into()); + return Err(err); } // Pin PN pubkey for the duration of the client `/get` link so announce-flood // eviction cannot drop it before LRPROOF validation (see known_identities cap). @@ -3799,7 +3813,7 @@ impl LiveBridge { tracing::info!( target: "propagation-sync", dest = %dest_hex, - path_ok, + path_ok = true, hops = ?hops, pinned, local_serving, @@ -3834,8 +3848,11 @@ impl LiveBridge { let outbound = Arc::clone(&self.outbound); let on_terminal: Arc = Arc::new(move || { if let Ok(mut driver) = outbound.lock() { - driver.clear_propagation_identity_pins(); - driver.set_propagation_sync_target(None); + // Guard against clearing a superseded Sync/silent owner. + if driver.propagation_sync_target() == Some(hash) { + driver.clear_propagation_identity_pins(); + driver.set_propagation_sync_target(None); + } } }); if !self.spawn_client_download_driver( @@ -3847,9 +3864,13 @@ impl LiveBridge { Some(on_terminal), true, ) { + // Download did not start — drop our claim only if we still own this hash. + // Never wipe another owner's latch (overlapping silent `/get` / Sync). if let Ok(mut driver) = self.outbound.lock() { - driver.clear_propagation_identity_pins(); - driver.set_propagation_sync_target(None); + if driver.propagation_sync_target() == Some(hash) { + driver.clear_propagation_identity_pins(); + driver.set_propagation_sync_target(None); + } } // Client `/get` already in flight (host silent retrieve / prior Sync). return Err("PROPAGATION_RETRIEVE_BUSY".into()); @@ -5746,7 +5767,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 +5775,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 +5801,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/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index 895c49e58..c97c8c499 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -1,7 +1,7 @@ //! LXMF outbound delivery loop (Direct / Propagated) via LinkDeliveryManager. use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard, TryLockError}; use bytes::Bytes; use lxmf_core::constants::{ @@ -133,6 +133,16 @@ type PendingPnDeposit = ([u8; 16], Option<[u8; 32]>); /// Validated PN stamp entry: (transient_id, lxmf_data, stamp_u8, stamp_data). type ValidatedPnStamp = ([u8; 32], Vec, u8, [u8; 32]); +/// Result of depositing into the local hosted PN without LinkDelivery. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InProcessDepositOutcome { + Completed, + /// PropagationNode mutex contended — requeue; never self-Link. + Busy, + /// Unpack/stamp/accept failed — advance cascade; never self-Link. + Failed, +} + pub struct LxmfOutboundDriver { transport_tx: mpsc::Sender, link_delivery: LinkDeliveryManager, @@ -593,14 +603,52 @@ impl LxmfOutboundDriver { } // Hosted PN admit floor is min_stamp_cost (stamp_cost − flex). Pack at least that // when depositing in-process so accept_stamped_propagated_blob does not reject. - // try_lock: never block outbound tick on a contended PropagationNode mutex. + // try_lock: never block outbound tick on a contended PropagationNode mutex — + // defer (do not pack at cost 0 / self-Link) when the node lock is busy. let target_cost = if local_in_process { - let local_floor = self - .local_prop_node - .as_ref() - .and_then(|n| n.try_lock().ok().map(|g| g.min_stamp_cost())) - .unwrap_or(0); - local_floor.max(router.get_stamp_cost(&prop_hash).unwrap_or(0)) + let Some(node) = self.local_prop_node.clone() else { + // local_in_process requires the node; defensive. + return; + }; + let lock_outcome = try_lock_local_prop_node(&node); + match lock_outcome { + Ok(guard) => { + let local_floor = guard.min_stamp_cost(); + drop(guard); + local_floor.max(router.get_stamp_cost(&prop_hash).unwrap_or(0)) + } + Err(InProcessDepositOutcome::Busy) => { + let now = now_f64(); + message.next_delivery_attempt = now + f64::from(PATH_REQUEST_WAIT as u32); + tracing::debug!( + target: "lxmf-outbound", + prop = %prop_hex, + "DeliverPropagated: local-prop node busy — deferring in-process deposit" + ); + if let Some(hash) = message.hash.or(message.message_id) { + self.pending_pn_targets.insert(hash, prop_hash); + } + router.send(message); + return; + } + Err(InProcessDepositOutcome::Failed | InProcessDepositOutcome::Completed) => { + tracing::error!( + target: "lxmf-outbound", + prop = %prop_hex, + "DeliverPropagated: local-prop node mutex poisoned — advancing PN cascade" + ); + if let Some(hash) = message.hash.or(message.message_id) { + self.mark_pn_tried(hash, prop_hash); + } + match self.try_advance_pn_cascade(router, event_tx, message) { + Ok(()) => return, + Err(message) => { + self.emit_outbound_failed(router, event_tx, *message); + return; + } + } + } + } } else { router.get_stamp_cost(&prop_hash).unwrap_or(0) }; @@ -621,7 +669,60 @@ impl LxmfOutboundDriver { } } }; - // lxmd parity: count the attempt before packed link delivery so Failed can budget retries. + let hops = route_hops_for(&self.route_hops, prop_hash); + let message_hash_hex = message.hash.as_ref().map(hex::encode); + let transient_id_hex = message.transient_id.as_ref().map(hex::encode); + if let Some(hash) = message.hash { + self.pending_pn_deposits + .insert(hash, (prop_hash, message.transient_id)); + } + // Local-prop cascade: deposit in-process (full hosted PN store) — never self-Link. + // Do not count a delivery attempt here: Busy must requeue without burning budget. + if is_local_cascade && self.local_prop_node.is_some() { + match self.try_local_prop_in_process_deposit( + router, + event_tx, + &mut message, + prop_hash, + &packed, + ) { + InProcessDepositOutcome::Completed => return, + InProcessDepositOutcome::Busy => { + let now = now_f64(); + message.next_delivery_attempt = now + f64::from(PATH_REQUEST_WAIT as u32); + tracing::debug!( + target: "lxmf-outbound", + prop = %prop_hex, + "local-prop in-process deposit busy — deferring (no self-Link)" + ); + if let Some(hash) = message.hash.or(message.message_id) { + self.pending_pn_deposits.remove(&hash); + self.pending_pn_targets.insert(hash, prop_hash); + } + router.send(message); + return; + } + InProcessDepositOutcome::Failed => { + tracing::warn!( + target: "propagation-deposit", + pn_hash = %prop_hex, + "local-prop in-process deposit failed — advancing PN cascade (no self-Link)" + ); + if let Some(hash) = message.hash.or(message.message_id) { + self.pending_pn_deposits.remove(&hash); + self.mark_pn_tried(hash, prop_hash); + } + match self.try_advance_pn_cascade(router, event_tx, message) { + Ok(()) => return, + Err(message) => { + self.emit_outbound_failed(router, event_tx, *message); + return; + } + } + } + } + } + // lxmd parity: count the attempt immediately before packed link delivery. let attempts = mark_propagated_delivery_attempt(&mut message); if attempts >= MAX_DELIVERY_ATTEMPTS { tracing::warn!( @@ -631,6 +732,7 @@ impl LxmfOutboundDriver { "propagated delivery attempt budget reached — advancing PN cascade" ); if let Some(hash) = message.hash.or(message.message_id) { + self.pending_pn_deposits.remove(&hash); self.mark_pn_tried(hash, prop_hash); } match self.try_advance_pn_cascade(router, event_tx, message) { @@ -641,25 +743,6 @@ impl LxmfOutboundDriver { } } } - let hops = route_hops_for(&self.route_hops, prop_hash); - let message_hash_hex = message.hash.as_ref().map(hex::encode); - let transient_id_hex = message.transient_id.as_ref().map(hex::encode); - if let Some(hash) = message.hash { - self.pending_pn_deposits - .insert(hash, (prop_hash, message.transient_id)); - } - // Local-prop cascade: deposit in-process (full hosted PN store) — avoid self-Link. - if is_local_cascade - && self.try_local_prop_in_process_deposit( - router, - event_tx, - &mut message, - prop_hash, - &packed, - ) - { - return; - } tracing::info!( target: "propagation-deposit", message_hash = message_hash_hex.as_deref().unwrap_or(""), @@ -689,8 +772,6 @@ impl LxmfOutboundDriver { } /// Accept a packed propagation wrapper into the local hosted PN without LinkDelivery. - /// - /// Returns true when the deposit Completes (or failed terminal after accept miss). fn try_local_prop_in_process_deposit( &mut self, router: &mut LxmRouter, @@ -698,46 +779,59 @@ impl LxmfOutboundDriver { message: &mut LxMessage, prop_hash: [u8; 16], packed: &[u8], - ) -> bool { + ) -> InProcessDepositOutcome { let Some(node) = self.local_prop_node.clone() else { - return false; + return InProcessDepositOutcome::Failed; }; let prop_hex = hex::encode(prop_hash); let Ok((_, entries)) = LxMessage::unpack_propagation_wrapper(packed) else { tracing::warn!( target: "propagation-deposit", pn_hash = %prop_hex, - "local-prop in-process deposit: unpack wrapper failed — falling back to Link" + "local-prop in-process deposit: unpack wrapper failed" ); - return false; + return InProcessDepositOutcome::Failed; }; let Some(hash) = message.hash.or(message.message_id) else { - // Without a hash we cannot emit Completes — fall back to Link so - // requeue/status paths can run (do not accept into the store first). + // Without a hash we cannot emit Completes — advance cascade (never self-Link). tracing::warn!( target: "propagation-deposit", pn_hash = %prop_hex, "local-prop in-process deposit missing message hash" ); - return false; + return InProcessDepositOutcome::Failed; + }; + // Read admit floor without holding the lock across PoW validation. + let min_cost = match try_lock_local_prop_node(&node) { + Ok(guard) => guard.min_stamp_cost(), + Err(outcome) => return outcome, }; - // Validate outside the node mutex (PoW/stamp work must not hold PropagationNode). - // min_cost 0: stamp already generated against the PN cost at pack time. let mut validated: Vec = Vec::new(); for entry in &entries { let Some((transient_id, lxmf_data, stamp_value, stamp_data)) = - stamper::validate_pn_stamp(entry, 0) + stamper::validate_pn_stamp(entry, min_cost) else { continue; }; let stamp_u8 = u8::try_from(stamp_value).unwrap_or(u8::MAX); validated.push((transient_id, lxmf_data, stamp_u8, stamp_data)); } + if validated.is_empty() { + tracing::warn!( + target: "propagation-deposit", + pn_hash = %prop_hex, + min_cost, + entries = entries.len(), + "local-prop in-process deposit: no entries met stamp floor" + ); + return InProcessDepositOutcome::Failed; + } let mut accepted = 0usize; let mut last_tid: Option<[u8; 32]> = None; { - let Ok(mut guard) = node.try_lock() else { - return false; + let mut guard = match try_lock_local_prop_node(&node) { + Ok(g) => g, + Err(outcome) => return outcome, }; for (transient_id, lxmf_data, stamp_u8, stamp_data) in &validated { if guard.accept_stamped_propagated_blob(lxmf_data, stamp_data, *stamp_u8) { @@ -759,9 +853,9 @@ impl LxmfOutboundDriver { target: "propagation-deposit", pn_hash = %prop_hex, entries = entries.len(), - "local-prop in-process deposit accepted zero entries — falling back to Link" + "local-prop in-process deposit accepted zero entries" ); - return false; + return InProcessDepositOutcome::Failed; } self.pending_pn_deposits .insert(hash, (prop_hash, last_tid.or(message.transient_id))); @@ -773,7 +867,7 @@ impl LxmfOutboundDriver { msg_hash: Some(hash), }, ); - true + InProcessDepositOutcome::Completed } /// After a Propagated Link failure: advance cascade when other PNs remain; otherwise @@ -1508,6 +1602,23 @@ fn mark_propagated_delivery_attempt(message: &mut LxMessage) -> u32 { message.delivery_attempts } +/// Classify local-prop `try_lock`: WouldBlock → Busy (requeue); Poisoned → Failed (advance). +fn try_lock_local_prop_node( + node: &Mutex, +) -> Result, InProcessDepositOutcome> { + match node.try_lock() { + Ok(guard) => Ok(guard), + Err(TryLockError::WouldBlock) => Err(InProcessDepositOutcome::Busy), + Err(TryLockError::Poisoned(_)) => { + tracing::error!( + target: "lxmf-outbound", + "local-prop PropagationNode mutex poisoned — treating deposit as failed" + ); + Err(InProcessDepositOutcome::Failed) + } + } +} + /// Whether a Propagated link `Failed` should requeue instead of going terminal. pub(crate) fn should_retry_propagated_link_failure( method: DeliveryMethod, @@ -2731,6 +2842,111 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Busy local-node lock must requeue without consuming delivery_attempts. + #[test] + fn local_prop_in_process_busy_does_not_consume_delivery_attempt() { + use lxmf_core::constants::DeliveryMethod; + use lxmf_core::message::LxMessage; + use lxmf_core::router::{LxmRouter, RouterConfig}; + use rns_identity::destination::Destination; + use tokio::sync::broadcast; + + let dir = std::env::temp_dir().join(format!( + "mesh-prop-outbound-busy-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmpdir"); + + let sender = Identity::new(); + let recipient = Identity::new(); + let local_prop = [0xadu8; 16]; + let zero_stamp_policy = crate::stack::pn_hosting_policy::PnHostingPolicy { + propagation_stamp_cost: 0, + propagation_stamp_flex: 0, + ..Default::default() + }; + let (tx, _rx) = mpsc::channel(32); + let bridge = crate::stack::propagation_bridge::PropagationBridge::new( + tx.clone(), + local_prop, + dir.clone(), + &recipient, + &zero_stamp_policy, + ) + .expect("bridge"); + + let sender_delivery = + Destination::hash_from_name_and_identity("lxmf.delivery", Some(&sender.hash)); + let recipient_delivery = + Destination::hash_from_name_and_identity("lxmf.delivery", Some(&recipient.hash)); + let mut driver = + LxmfOutboundDriver::new(tx, &sender, hex::encode(sender_delivery), "me".into()); + driver.register_identity_key(&hex::encode(recipient_delivery), recipient.get_public_key()); + driver.set_local_prop_node(Some(bridge.local_node())); + driver.set_pn_cascade_candidates(vec![PnCascadeCandidate { + hash: local_prop, + is_local: true, + is_discovered: false, + hops: Some(0), + id: "local-prop".into(), + }]); + + let mut router = LxmRouter::new(RouterConfig::default()); + let (event_tx, mut event_rx) = broadcast::channel(16); + + let mut msg = LxMessage::new( + recipient_delivery, + sender_delivery, + "", + "busy lock must not burn attempts", + DeliveryMethod::Direct, + ); + msg.sign(&sender.get_signing_key().expect("sk")) + .expect("sign"); + let msg_hash = msg.hash.expect("hash after sign"); + assert!( + driver + .try_advance_pn_cascade(&mut router, &event_tx, msg) + .is_ok() + ); + + let local_node = bridge.local_node(); + let held = local_node.lock().expect("hold local node"); + driver.process_tick(&mut router, &event_tx); + assert_eq!(router.pending_outbound.len(), 1); + assert_eq!( + router.pending_outbound[0].delivery_attempts, 0, + "WouldBlock Busy must not mark a delivery attempt" + ); + router.pending_outbound[0].next_delivery_attempt = 0.0; + driver.process_tick(&mut router, &event_tx); + assert_eq!(router.pending_outbound.len(), 1); + assert_eq!(router.pending_outbound[0].delivery_attempts, 0); + assert_eq!(router.pending_outbound[0].hash, Some(msg_hash)); + drop(held); + + router.pending_outbound[0].next_delivery_attempt = 0.0; + driver.process_tick(&mut router, &event_tx); + let mut saw_stored = false; + while let Ok(frame) = event_rx.try_recv() { + if frame.contains("\"status\":\"stored_locally\"") { + saw_stored = true; + } + } + assert!( + saw_stored, + "deposit must succeed after the local-node lock is released" + ); + assert_eq!(bridge.local_node().lock().expect("lock").message_count(), 1); + + let _ = std::fs::remove_dir_all(&dir); + } + /// T5: deposit on local-prop appears in peer `/offer` inventory. #[test] fn local_prop_deposit_appears_in_peer_sync_offer_inventory() { diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 11d132497..892284400 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( @@ -1353,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() { @@ -1505,7 +1543,11 @@ impl StackHandle { // but still drain our own mail out of the local PN store into Chat. if is_local { #[cfg(feature = "rns-stack")] - if let Some(live) = self.live.get() { + { + let Some(live) = self.live.get() else { + // Match remotes: Auto cascade soft-defers and retries when attach lags. + return Err("PROPAGATION_STACK_NOT_LIVE".into()); + }; live.drain_local_propagation_inbox().await; } self.emit_event( diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs index 9137dcc91..8892852ed 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,45 @@ 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. + /// Trim + lowercase only — reject unless the whole string is exactly 32 ASCII hex chars + /// (do not strip arbitrary non-hex characters). + pub fn normalize_propagation_auto_blacklist_hash(raw: &str) -> Result { + let clean = raw.trim().to_lowercase(); + if clean.len() != 32 || !clean.chars().all(|c| c.is_ascii_hexdigit()) { + 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 +860,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 +877,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 +933,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 +984,20 @@ impl<'de> serde::Deserialize<'de> for PersistedState { }, auto_sync_interval_sec: raw.auto_sync_interval_sec, propagation_mode: raw.propagation_mode, + propagation_auto_blacklist: { + let mut cleaned = Vec::new(); + for raw_hash in raw.propagation_auto_blacklist { + if let Ok(hash) = Self::normalize_propagation_auto_blacklist_hash(&raw_hash) { + if !cleaned.iter().any(|h| h == &hash) { + cleaned.push(hash); + } + } + if cleaned.len() >= Self::PROPAGATION_AUTO_BLACKLIST_CAP { + break; + } + } + cleaned + }, pn_hosting_policy: raw.pn_hosting_policy, nomad_nodes: raw.nomad_nodes, rrc_hubs: raw.rrc_hubs, @@ -1113,6 +1176,59 @@ 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()] + ); + // Trim + case fold only — do not strip embedded non-hex. + state + .add_propagation_auto_blacklist(&format!(" {hash} ")) + .expect("trim whitespace"); + assert_eq!(state.propagation_auto_blacklist.len(), 1); + assert!( + state + .add_propagation_auto_blacklist(&format!("{hash}!")) + .is_err(), + "must reject hashes with non-hex junk instead of stripping" + ); + // 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 propagation_auto_blacklist_rejects_add_when_at_cap() { + let mut state = PersistedState::default_empty(); + for i in 0..PersistedState::PROPAGATION_AUTO_BLACKLIST_CAP { + let hash = format!("{i:032x}"); + state + .add_propagation_auto_blacklist(&hash) + .unwrap_or_else(|e| panic!("add {i}: {e}")); + } + assert_eq!( + state.propagation_auto_blacklist.len(), + PersistedState::PROPAGATION_AUTO_BLACKLIST_CAP + ); + let overflow = format!("{:032x}", PersistedState::PROPAGATION_AUTO_BLACKLIST_CAP); + assert_eq!( + state.add_propagation_auto_blacklist(&overflow).unwrap_err(), + "propagation Auto blacklist is full" + ); + } + #[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..04830f034 100644 --- a/reticulum-sidecar/src/stack/pn_cascade.rs +++ b/reticulum-sidecar/src/stack/pn_cascade.rs @@ -12,6 +12,18 @@ use crate::stack::propagation_mode::PropagationMode; /// `MAX_DISCOVERED_SYNC_ATTEMPTS` so both sides work the same shortlist. pub const MAX_AUTO_DISCOVERED_PN_CANDIDATES: usize = 3; +/// Path-table hop counts above this are treated as unknown for Auto deposit ranking. +/// Mirrors renderer `MAX_PLAUSIBLE_PROPAGATION_HOPS` (reticulumPropagationMode.ts). +pub const MAX_PLAUSIBLE_PROPAGATION_HOPS: u8 = 32; + +/// Rank hops for sorting: finite plausible first, absurd/unknown last (`u8::MAX`). +pub fn hops_rank(hops: Option) -> u8 { + match hops { + Some(h) if h <= MAX_PLAUSIBLE_PROPAGATION_HOPS => h, + _ => u8::MAX, + } +} + /// One PN eligible for Direct→Propagated cascade. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PnCascadeCandidate { @@ -66,8 +78,8 @@ pub fn build_pn_cascade_order( let mut remotes: Vec = candidates.iter().filter(|c| !c.is_local).cloned().collect(); remotes.sort_by(|a, b| { - let ah = a.hops.unwrap_or(u8::MAX); - let bh = b.hops.unwrap_or(u8::MAX); + let ah = hops_rank(a.hops); + let bh = hops_rank(b.hops); a.is_discovered .cmp(&b.is_discovered) .then_with(|| ah.cmp(&bh)) @@ -177,6 +189,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,20 +205,29 @@ 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; } + // Demote absurd hop counts (e.g. 100+ ghosts) to unknown so they cannot + // outrank path-known remotes — same policy as renderer Auto sync ranking. + let hops = match row.hops { + Some(h) if h <= MAX_PLAUSIBLE_PROPAGATION_HOPS => Some(h), + _ => None, + }; out.push(PnCascadeCandidate { hash, is_local: false, is_discovered: true, - hops: row.hops, + hops, id: format!("discovered-{}", &hex::encode(hash)[..8]), }); } out.sort_by(|a, b| { - let ah = a.hops.unwrap_or(u8::MAX); - let bh = b.hops.unwrap_or(u8::MAX); + let ah = hops_rank(a.hops); + let bh = hops_rank(b.hops); ah.cmp(&bh).then_with(|| a.id.cmp(&b.id)) }); out.truncate(MAX_AUTO_DISCOVERED_PN_CANDIDATES); @@ -420,6 +442,7 @@ mod tests { "", PropagationMode::Auto, u8::MAX, + &HashSet::new(), ); assert_eq!(extra.len(), 1); let mut all = configured; @@ -436,7 +459,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 +491,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 +506,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::>(), @@ -491,6 +522,27 @@ mod tests { ); } + #[test] + fn auto_discovered_demotes_absurd_hop_counts() { + let close = "11".repeat(16); + let ghost = "99".repeat(16); + let extra = auto_discovered_candidates( + &[ + discovered_row(&ghost, Some(100)), + discovered_row(&close, Some(2)), + ], + &[], + "", + PropagationMode::Auto, + u8::MAX, + &HashSet::new(), + ); + assert_eq!(extra.len(), 2); + assert_eq!(hex::encode(extra[0].hash), close); + assert_eq!(extra[0].hops, Some(2)); + assert_eq!(extra[1].hops, None, "hops>32 must rank as unknown"); + } + #[test] fn auto_discovered_only_still_reports_cascade_capacity() { let extra = auto_discovered_candidates( @@ -499,6 +551,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 +561,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_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index 4a649db4b..006862a1b 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,18 @@ 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, + /// Serializes load→merge→atomic write for [`Self::client_have_path`]. + client_have_lock: Mutex<()>, /// 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 +86,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 +109,34 @@ 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, + client_have_lock: Mutex::new(()), 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 +366,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 +536,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,14 +566,29 @@ impl PropagationBridge { let listed = client.available_messages().len(); let downloaded = client.received_count(); let blobs = client.take_received_messages(); - // Consume the terminal snapshot → Idle so the next - // start_client_download can proceed without a cancel first. - let _ = client.acknowledge_transfer(); - drop(client); + // Decode first (parity with drain_local_inbox): only successfully + // decoded mail becomes a have-id. Undecodable blobs are not persisted + // as haves so a later `/get` can retry them. let messages = blobs .iter() .filter_map(|blob| decode_downloaded_propagated_blob(&self.identity, blob)) .collect::>(); + let tids: Vec = + messages.iter().filter_map(|msg| msg.transient_id).collect(); + for tid in &tids { + client.add_local_message(*tid); + } + // Consume the terminal snapshot → Idle so the next + // start_client_download can proceed without a cancel first. + let _ = client.acknowledge_transfer(); + drop(client); + if !tids.is_empty() { + merge_persist_client_have_ids( + &self.client_have_lock, + &self.client_have_path, + &tids, + ); + } ClientDownloadPoll::Complete { messages, listed, @@ -612,6 +673,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_lock, &self.client_have_path, &tids); } (messages, listed) @@ -624,7 +695,106 @@ 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 { + tracing::warn!( + target: "propagation-retrieve", + path = %path.display(), + "corrupt client /get have-ids JSON — starting empty (will re-fetch until rewritten)" + ); + return Vec::new(); + }; + let Some(arr) = value.get("ids").and_then(|v| v.as_array()) else { + tracing::warn!( + target: "propagation-retrieve", + path = %path.display(), + "client /get have-ids missing ids array — starting empty" + ); + 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( + lock: &Mutex<()>, + path: &Path, + new_ids: &[PropagationTransientId], +) { + if new_ids.is_empty() { + return; + } + let Ok(_guard) = lock.lock() else { + tracing::warn!( + target: "propagation-retrieve", + path = %path.display(), + "client have-ids persist lock poisoned — skipping merge" + ); + 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); + } + // Atomic replace: crash mid-write must not truncate the have set to empty. + let tmp = path.with_extension("json.tmp"); + if let Err(e) = std::fs::write(&tmp, body.to_string()) { + tracing::warn!( + target: "propagation-retrieve", + error = %e, + path = %tmp.display(), + "failed to write client /get have-ids temp file" + ); + return; + } + if let Err(e) = std::fs::rename(&tmp, path) { + tracing::warn!( + target: "propagation-retrieve", + error = %e, + path = %path.display(), + "failed to atomically replace client /get have-ids" + ); + let _ = std::fs::remove_file(&tmp); + } +} + +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 +1754,157 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn cancel_client_download_allows_a_second_start() { + let dir = std::env::temp_dir().join(format!( + "mesh-prop-cancel-get-{}-{}", + 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"); + let pn = [0x44u8; 16]; + // First start may or may not enter a non-Idle state without a live Link — + // cancel must still leave the client restartable. + let _ = bridge.start_client_download(pn); + bridge.cancel_client_download(); + assert!( + !bridge.client_download_active(), + "abort_transfer must leave download inactive" + ); + assert!( + bridge.start_client_download(pn), + "second Sync after cancel must start (not permanent RETRIEVE_BUSY)" + ); + bridge.cancel_client_download(); + 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]; + let lock = Mutex::new(()); + merge_persist_client_have_ids(&lock, &path, &[tid_a]); + merge_persist_client_have_ids(&lock, &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"); @@ -1660,7 +1981,13 @@ mod tests { "live sync must drive the client `/get` download" ); // Remote sync must hard-fail when no path exists (same as offer probe) instead of - // starting Establishing and timing out in the renderer. + // starting Establishing and timing out in the renderer. Shared helper keeps probe + + // Sync on one PATH_UNKNOWN gate (no discarded `let _path_ok`). + assert!( + live.contains("async fn ensure_propagation_path_or_unknown") + && live.contains("Err(\"PROPAGATION_PATH_UNKNOWN\".into())"), + "shared path gate must return PROPAGATION_PATH_UNKNOWN" + ); let sync_fn_start = live .find("pub async fn start_propagation_sync") .expect("start_propagation_sync"); @@ -1670,16 +1997,16 @@ mod tests { .map_or(sync_fn.len(), |idx| idx + 1); let sync_body = &sync_fn[..sync_fn_end]; assert!( - sync_body.contains("PROPAGATION_PATH_UNKNOWN"), - "start_propagation_sync must return PROPAGATION_PATH_UNKNOWN when path is missing" + sync_body.contains("ensure_propagation_path_or_unknown"), + "start_propagation_sync must use shared path gate" ); assert!( !sync_body.contains("let _path_ok"), "start_propagation_sync must not discard ensure_path_for_direct" ); let path_gate_at = sync_body - .find("PROPAGATION_PATH_UNKNOWN") - .expect("PATH_UNKNOWN in start_propagation_sync"); + .find("ensure_propagation_path_or_unknown") + .expect("path gate in start_propagation_sync"); let get_at = sync_body .find("spawn_client_download_driver") .expect("client /get driver in start_propagation_sync"); @@ -1698,7 +2025,7 @@ mod tests { || sync_body.contains("peerOfferSkipped"), "user Sync must document /get-primary peer-offer skip" ); - // Offer probe still validates remotes speak `/offer`. + // Offer probe: same path gate as Sync, and still validates remotes speak `/offer`. let probe_start = live .find("pub async fn probe_propagation_offer") .expect("probe_propagation_offer"); @@ -1706,8 +2033,13 @@ mod tests { let probe_end = probe_fn[1..] .find("\n pub ") .map_or(probe_fn.len(), |idx| idx + 1); + let probe_body = &probe_fn[..probe_end]; assert!( - probe_fn[..probe_end].contains("start_sync(hash"), + probe_body.contains("ensure_propagation_path_or_unknown"), + "offer probe must use the same shared path gate as Sync" + ); + assert!( + probe_body.contains("start_sync(hash"), "offer probe must still exercise peer /offer" ); // Configured-row Sync must not accept via the persistence stub while live is None. @@ -1719,12 +2051,19 @@ mod tests { let by_id_end = by_id[1..] .find("\n pub ") .map_or(by_id.len(), |idx| idx + 1); + let by_id_body = &by_id[..by_id_end]; assert!( - by_id[..by_id_end].contains("PROPAGATION_STACK_NOT_LIVE"), + by_id_body.contains("PROPAGATION_STACK_NOT_LIVE"), "by-id Sync must hard-fail when RNS live is not attached" ); assert!( - by_id[..by_id_end].contains("#[cfg(not(feature = \"rns-stack\"))]"), + by_id_body.contains("if is_local") + && by_id_body.contains("let Some(live) = self.live.get() else") + && by_id_body.contains("PROPAGATION_STACK_NOT_LIVE"), + "local-prop Sync must return PROPAGATION_STACK_NOT_LIVE when live is None (not Ok+100%)" + ); + assert!( + by_id_body.contains("#[cfg(not(feature = \"rns-stack\"))]"), "persistence stub Sync must stay gated behind not(rns-stack)" ); } 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/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/check-i18n-quality.mjs b/scripts/check-i18n-quality.mjs index f1aaef7f3..7c6627b1a 100644 --- a/scripts/check-i18n-quality.mjs +++ b/scripts/check-i18n-quality.mjs @@ -1357,6 +1357,48 @@ function checkReticulumPropagationModeHelpIssues(ctx) { const issues = []; if (!flatKey.startsWith('reticulumPropagation.') || locale === 'en') return issues; + // Wire paths must stay English literals in hosting/sync copy. + if ( + (flatKey === 'reticulumPropagation.localHostHint' || + flatKey === 'reticulumPropagation.enableLocalHostConfirmBody') && + /\/offer/.test(enVal) && + /\/get/.test(enVal) + ) { + if (!/\/offer/.test(val) || !/\/get/.test(val)) { + issues.push( + `${flatKey} must keep wire paths /offer and /get (do not translate protocol routes)`, + ); + } + } + + // EN moved from "local inbox" → "local propagation node"; catch leftover mailbox copy. + if ( + (flatKey === 'reticulumPropagation.syncLocalSettled' || + flatKey === 'reticulumPropagation.modeHelpAuto' || + flatKey === 'reticulumPropagation.modeHelpManual') && + /local propagation node/i.test(enVal) + ) { + const inboxMarkers = [ + /Posteingang/i, + /boîte de réception/i, + /bandeja de entrada/i, + /casella di posta/i, + /收件箱/, + /受信トレイ/, + /받은편지함/, + /doručenou poštou/i, + /lokalen Posteingang/i, + ]; + for (const re of inboxMarkers) { + if (re.test(val)) { + issues.push( + `${flatKey} is stale: still says inbox/mailbox (must say local propagation node)`, + ); + break; + } + } + } + if ( flatKey === 'reticulumPropagation.modeHelpAuto' && /one-time syncs the best Discovered/i.test(enVal) @@ -4221,6 +4263,37 @@ function checkFloodRoutingUiIssues(ctx) { return issues; } +/** MeshCore Nodes: Health column + copy-public-key must not use healthcare/license MT. */ +function checkMeshcoreNodeHealthAndPubkeyIssues(ctx) { + const { locale, flatKey, val, enVal } = ctx; + const issues = []; + if (locale === 'en') return issues; + + if ( + (flatKey === 'nodeDetailModal.copyPublicKey' || + flatKey === 'nodeListPanel.hasPublicKeyTitle') && + /public key/i.test(enVal) + ) { + if (/Licenční klíč/i.test(val) || /license key/i.test(val)) { + issues.push(`${flatKey} false friend: license key instead of public key`); + } + } + + if (flatKey === 'nodeListPanel.columnHealth' && /Node health|Health/i.test(enVal)) { + const healthcare = [/Zdravotnictví/i, /Ochrona zdrowia/i, /Здравоохранение/i, /체력/, /\bHP\b/]; + for (const re of healthcare) { + if (re.test(val)) { + issues.push( + 'nodeListPanel.columnHealth false friend: healthcare/stamina wording (use node health/status)', + ); + break; + } + } + } + + return issues; +} + const LOCALE_STRING_QUALITY_CHECKS = [ checkCatEncodingAndMeshtasticIssues, checkMustTranslateAndFormFieldIssues, @@ -4237,6 +4310,7 @@ const LOCALE_STRING_QUALITY_CHECKS = [ checkMeshcoreOpenWireIssues, checkReticulumConnectionPanelIssues, checkReticulumPropagationModeHelpIssues, + checkMeshcoreNodeHealthAndPubkeyIssues, checkReticulumRemoteIssues, checkReticulumPeerAndPingIssues, checkUkrainianApostropheIssues, 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/components/NodeListPanel.test.tsx b/src/renderer/components/NodeListPanel.test.tsx index 08892fad1..e994412fd 100644 --- a/src/renderer/components/NodeListPanel.test.tsx +++ b/src/renderer/components/NodeListPanel.test.tsx @@ -684,7 +684,7 @@ describe('NodeListPanel import contacts', () => { expect(screen.queryByLabelText('Has public key')).not.toBeInTheDocument(); }); - it('labels the first column "Health" for both Meshtastic and MeshCore', () => { + it('labels the first column "Node health" for both Meshtastic and MeshCore', () => { const meshtastic = render( { mode="meshtastic" />, ); - expect(meshtastic.getByRole('columnheader', { name: /Health/ })).toBeInTheDocument(); + expect(meshtastic.getByRole('columnheader', { name: /Node health/i })).toBeInTheDocument(); meshtastic.unmount(); render( @@ -708,7 +708,7 @@ describe('NodeListPanel import contacts', () => { mode="meshcore" />, ); - expect(screen.getByRole('columnheader', { name: /Health/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Node health/i })).toBeInTheDocument(); }); }); 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.test.tsx b/src/renderer/components/ReticulumPropagationSection.test.tsx index d8cf1b34b..96cb97c67 100644 --- a/src/renderer/components/ReticulumPropagationSection.test.tsx +++ b/src/renderer/components/ReticulumPropagationSection.test.tsx @@ -489,6 +489,49 @@ describe('ReticulumPropagationSection', () => { ).toBeInTheDocument(); }); + it('Ignore for Auto posts blacklist then Allow removes it', async () => { + const user = userEvent.setup(); + const hash = 'beef'.repeat(8); + const addAutoBlacklist = vi.fn().mockResolvedValue(true); + const removeAutoBlacklist = vi.fn().mockImplementation(() => { + useReticulumPropagationStore.setState({ autoBlacklist: [] }); + return Promise.resolve(true); + }); + useReticulumPropagationStore.setState({ + discovered: [ + { + destination_hash: hash, + display_name: 'IgnoreMe', + node_state: true, + peering_cost: 0, + hops: 2, + }, + ], + autoBlacklist: [], + addAutoBlacklist, + removeAutoBlacklist, + }); + const { rerender } = render(); + + await user.click( + screen.getByRole('button', { name: 'reticulumPropagation.ignoreForAutoAria:IgnoreMe' }), + ); + await waitFor(() => { + expect(addAutoBlacklist).toHaveBeenCalledWith(hash); + }); + + useReticulumPropagationStore.setState({ autoBlacklist: [hash.toLowerCase()] }); + rerender(); + + expect(screen.getByText('reticulumPropagation.ignoredForAutoTitle')).toBeInTheDocument(); + await user.click( + screen.getByRole('button', { name: 'reticulumPropagation.allowForAutoAria:IgnoreMe' }), + ); + await waitFor(() => { + expect(removeAutoBlacklist).toHaveBeenCalledWith(hash); + }); + }); + it('persists mode to localStorage on change', async () => { const user = userEvent.setup(); render(); 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 ? ( <>