diff --git a/AGENTS.md b/AGENTS.md index 8c6bf0e5d..f1a48db0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,7 +158,7 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **RNode flasher timeouts:** `RNODE_COMMAND_TIMEOUT_MS` (30 s serial), `RNODE_BT_PAIRING_TIMEOUT_MS` (90 s BLE pairing), `ESP32_FLASH_STALL_TIMEOUT_MS` / `NRF52_DFU_STALL_TIMEOUT_MS` (60 s no-progress → `ESP32_FLASH_STALLED` / `NRF52_DFU_STALLED`); humanized via `flasherErrorHumanize.ts` - **Peer aliases / History vs Contacts:** LXMF/Nomad announce names overlay path-table peers; SQLite `reticulum_destinations.last_heard` = History, `is_contact` = Contacts (Save as contact only — inbound/outbound LXMF does **not** auto-add Contacts; sidecar `/contacts` wire rows are History hints unless SQLite `is_contact=1`); default avatars via vendored LXMFace (`lib/reticulum/lxmface.ts`); renderer refresh + `reticulumContactToNodeRecordPreservingLabel` refuse hash-prefix wipes of Chat/`nodeStore` labels; ingest stamps History via `persistReticulumHistoryFromPayload` + `stampHistoryPeer`; SQL upsert guard preserves real names over hash-prefix aliases; destination upsert requires exact 32-hex (lowercase) and omits `favorited` on icon-only patches so favorites/icons survive path/probe refresh - **Stores/lib:** `reticulumIdentityStore.ts` (session-global sidecar identity status shared by `useReticulumSidecarApi` — distinct from identity-scoped `identityStore`), `reticulumPeerStore.ts` (path-table `peers` + `history` + saved `contacts`; soft-TTL reads, forced `?refresh=1`, incremental `peers_updated` route-field patches, 50ms batching, name/appearance preservation, 30s/60s large-mesh poll), `reticulumDiscoveryMapStore.ts`, `reticulumRmapDiscovery.ts`, `reticulumDiscoveryMapLayout.ts`, `nomadNetworkStore.ts`, `rrcHubStore.ts` / `rrcSessionStore.ts` (RRC hubs + multi-hub sessions; hydrate/clear room history via `rrcRoomHistory.ts`; persist → SQLite `rrc_messages` via `rrcMessagePersist.ts` + `ipc/rrc-db-handlers.ts`; prefs in `rrcHubPrefs` / `rrcRoomPrefs` / `rrcRecentRooms`; notifications in `rrcInactiveNotifications` / `rrcMention`); **Remote (rnsh/rncp):** `rncpTransferStore.ts`, `rnshSessionStore.ts`, `reticulumInboundPolicyStore.ts`, `reticulumRemoteAddressStore.ts`, `rncpEnableRequestStore.ts` + lib `remoteSettingsStorage.ts`, `pushRncpListenerPolicy.ts`, `rncpInboundPolicyLists.ts`, `sendRncpRequestEnable.ts`, `rncpRequestEnableRateLimit.ts`, `applyRncpReceiveDestShare.ts` / `rncpReceiveDestSharePending.ts` (mark pending on request-enable; consume on ingest within TTL), `hooks/useRemotePathCapability.ts`, `components/remote/*`; WS events `rmap.discovery`, `lxmf_outbound_status`, `nomadnetwork.node`, `rrc.*`, `rnsh.*` / `rncp.*` in `useReticulumRuntime` (sidecar also emits `nomad.serving_start` / `nomad.serving_stop`; renderer polls serving status via HTTP, not those WS events) -- **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` (Direct-first; **one-shot fallback** to preferred **remote** PN on Direct fail; intermediate WS `sending` + `delivery_method: "propagated"`); renderer `applyReticulumOutboundDeliveryStatus.ts` (WS `lxmf_outbound_status` → Zustand + SQLite `delivery_status` + `delivery_method`; early-status buffer; hash/status allowlist), `reticulumOutboundFailureBridge.ts` (`shouldApplyLinkDeliveryTimeoutFailureBridge` skips the link-timeout Failed bridge when an effective remote PN target exists; also skips `propagated` rows so fallback is not killed), `markStaleReticulumOutbound.ts`. Optimistic pending rows use `reticulum-pending-*`; send-path rekey passes `replaces_message_hash` on SQLite upsert to delete the prior pending hash. Propagated Completes UI: **Stored at propagation node**. **Paper exception:** `createReticulumPaperMessage` / paper create Completes immediately (`delivery_method: paper`, `ReticulumMessageStatusBadge` **Paper**) via `lxmf_message` — no `lxmf_outbound_status`; shared `reticulumMessageTransport` / `reticulumPaperErrors` keep IPC allowlists and i18n codes aligned. +- **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` / `pn_cascade.rs` (Direct-first; after Direct exhausts **multi-PN cascade**: preferred remote → other enabled remotes hop-sorted → local-prop last; intermediate WS `sending` + `delivery_method: "propagated"` or `"stored_locally"`; terminal `delivered` at remote PN vs `stored_locally` for local inbox); renderer `applyReticulumOutboundDeliveryStatus.ts` (WS `lxmf_outbound_status` → Zustand + SQLite `delivery_status` + `delivery_method`; early-status buffer; hash/status allowlist), `reticulumOutboundFailureBridge.ts` (`shouldApplyLinkDeliveryTimeoutFailureBridge` skips the link-timeout Failed bridge when cascade capacity remains — remote **or** enabled local-prop; also skips `propagated` / `stored_locally` rows so cascade is not killed), `markStaleReticulumOutbound.ts`. Optimistic pending rows use `reticulum-pending-*`; send-path rekey passes `replaces_message_hash` on SQLite upsert to delete the prior pending hash. Remote PN Completes UI: **Stored at propagation node**; local-prop Completes: local inbox (not peer-delivered). **Paper exception:** `createReticulumPaperMessage` / paper create Completes immediately (`delivery_method: paper`, `ReticulumMessageStatusBadge` **Paper**) via `lxmf_message` — no `lxmf_outbound_status`; shared `reticulumMessageTransport` / `reticulumPaperErrors` keep IPC allowlists and i18n codes aligned. - **DM path reachability:** `useReticulumDmPathProbe.ts`, `reticulumDmPathReachability.ts`, `ReticulumDmPathReachabilityBadge.tsx` — Chat **Probe** matches Peer List (sidecar running check → `/probe` → toast → refresh); `applyProbeResult(forHash, …)` applies the settle without a second `/probe` and ignores stale completions after DM switch; manual reprobe forces Checking… even when passive hops look reachable; Peers virtualizes above 100 rows via `reticulumPeerListRows.ts`; peer refresh policy in `reticulumSidecarPeerRefreshEvents.ts` - **Inbound transport labels:** `received_via` resolves the path-table interface name against local interface config type, so a TCP hub display name still renders as TCP. - **Topology:** `via_hash` is an immediate transport id; sidecar synthesizes missing relay nodes. `ReticulumTopologyPanel` uses force layout; sidecar caps graph input at 2,000 peers and renderer caps visible peers at 800 (grid repulsion above 400). diff --git a/README.md b/README.md index 83326f3c8..8fef253fc 100644 --- a/README.md +++ b/README.md @@ -339,7 +339,7 @@ Architecture and API: [docs/reticulum.md](docs/reticulum.md). Games wire parity: - **LXST voice Call** on DM headers and Peers rows — live telephony over rsLXST (not an LXMF voice-note clip) - **RRC tab:** multi-hub relay chat (rooms, nicklists, slash commands, favourites, auto-join, reconnect; up to 8 hubs); @mentions badge Chat + the amber protocol pill - **Remote tab:** **rnsh** interactive shell sessions and **rncp** file transfer (send / receive / fetch), saved addresses and inbound-policy controls; Chat DM send-file convenience (distinct from Meshtastic remote admin) -- **Delivery:** **Direct** when the destination is in the path table (then **one-shot remote PN fallback** on Direct fail when a preferred remote PN is set); **propagated (PN)** when offline — Completes show **Stored at propagation node**, not recipient Delivered +- **Delivery:** **Direct** when the destination is in the path table; after Direct exhausts, **multi-PN cascade** (preferred remote → other enabled remotes hop-sorted → local-prop last). Remote PN Completes show **Stored at propagation node** (`delivered`); local-prop Completes as local inbox (`stored_locally`) — neither is recipient Delivered **Games (LRGP)** @@ -393,7 +393,7 @@ Architecture and API: [docs/reticulum.md](docs/reticulum.md). Games wire parity: - **Map tiles; OpenStreetMap Referer requirement**: Packaged desktop builds load the UI from the local filesystem. The main process now loads the renderer with an explicit HTTP referrer so OpenStreetMap tile requests include a valid `Referer` header and comply with the [tile usage policy](https://operations.osmfoundation.org/policies/tiles/). If you point the app at a different tile server, ensure its usage policy permits this client. - **Reticulum — no LoRa companion parity**: Reticulum does not use Meshtastic/MeshCore `ConnectionDriver`, MQTT hybrid, channel pills, Rooms BBS, or Hop Goblins diagnostics. The **Chat** tab is **DM-only**; hub room chat lives on the **RRC** tab. Interface add/edit/delete updates config on disk — **restart the stack** after changes under `rns-stack`. - **Reticulum — sidecar license**: The spawned `mesh-client-reticulum` binary is **AGPL-3.0** (separate process from the MIT Electron shell). See [docs/reticulum.md](docs/reticulum.md) and [docs/credits.md](docs/credits.md#bundled-binaries). -- **Reticulum — propagation required for offline peers**: LXMF send fails with `no_propagation_node` when the destination is not in the path table and no preferred **remote** propagation node is set. Local inbox ≠ remote store-and-forward. When a path exists, Direct is tried first; Direct fail with a remote preferred PN triggers one-shot PN deposit. +- **Reticulum — propagation required for offline peers**: LXMF send fails with `no_propagation_node` when the destination is not in the path table and no cascade candidates exist (enabled remotes or local-prop). Local inbox Completes (`stored_locally`) ≠ peer delivery at a remote PN. When a path exists, Direct is tried first; on Direct fail the sidecar cascades preferred remote → other enabled remotes (hop-sorted) → local-prop last. --- diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index ea34efef4..1ce005ac6 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -233,7 +233,7 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi | POST | `/api/v1/voice/reject` | | Reject ringing call | | POST | `/api/v1/voice/hangup` | | End active call | | POST | `/api/v1/voice/mute` | `{ muted }` | Renderer mute flag (sidecar drops PCM ingest) | -| POST | `/api/v1/voice/audio` | `{ profile?, channels, samples_b64 }` | Push one PCM frame (LE f32 base64) for Opus TX. **Only established calls transmit**; earlier frames are accepted-and-dropped as `not_established` (soft-drop — do not fatal). Renderer must defer capture/TX until `voice.update` status `established` (Answer only warms `AudioContext`). Use dedicated IPC `reticulum:voiceSendAudio` (own ~2000/min budget); generic `reticulum:proxyPost` rejects this path so realtime PCM does not starve the shared 300/min proxy ceiling. | +| POST | `/api/v1/voice/audio` | `{ profile?, channels, samples_b64 }` | Push one PCM frame (LE f32 base64) for Opus TX. **Only established calls transmit**; earlier frames are accepted-and-dropped as `not_established` (soft-drop — do not fatal). Renderer must defer capture/TX until `voice.update` status `established` (Answer only warms `AudioContext`). Use dedicated IPC `reticulum:voiceSendAudio` (own ~2000/min budget); generic `reticulum:proxyPost` rejects this path so realtime PCM does not starve the shared 900/min proxy ceiling. | | GET | `/api/v1/games/status` | | LRGP live status (`available`, `enabled`, `running`, registered apps). Use dedicated IPC `reticulum:gamesStatus` — generic `proxyGet` rejects `/api/v1/games/*` | | GET | `/api/v1/games/apps` | | Registered game manifests (ttt, chess) | | GET | `/api/v1/games/sessions` | optional `?peer=` | Session list (sidecar `LrgpStore`) | @@ -264,23 +264,25 @@ Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscri - **`rrc.disconnected`:** payload `{ hub_dest_hash, reason, will_reconnect? }`. When `will_reconnect` is `false` (or `reason` is `local_disconnect`), the renderer drops that hub session. When `true` (or omitted on older sidecars), the UI shows reconnecting and keeps volatile rooms until WELCOME. - **Outbound Direct backchannel:** On live stack start, `LinkDeliveryManager::set_inbound_packet_sender(spawn_lxmf_outbound_backchannel(...))` forwards plaintext on outbound-initiated reusable Direct links into the same unpack path as peer-initiated `lxmf.delivery`. Developer log marker: `LXMF outbound-link backchannel packet`. Without this wiring, the peer's first reply may Ack on their client but never appear in mesh-client Chat. -- **`lxmf_outbound_status`:** authoritative outbound delivery updates for **network** sends. Payload: `{ message_hash, status, delivery_method?, to_hash?, sent_via? }` where `status` is `delivered`, `failed`, or intermediate `sending` (egress upgrade or Direct→PN fallback). mesh-client maps `delivered` → UI Completes (`acked`) and persists `delivery_status` (+ `delivery_method` when present) to SQLite; Propagated Completes show **Stored at propagation node**; `failed` → Failed. Do **not** treat `/api/v1/lxmf/send` response `delivery_status` (`queued`/`sending`) as terminal. After Direct failure with a preferred remote PN, the sidecar re-queues once as Propagated and emits `sending` + `delivery_method: "propagated"` before a final `delivered`/`failed`. **Paper create/ingest does not use this event** — Completes via `lxmf_message` with `delivery_method: "paper"` / `delivery_status: "delivered"`. +- **`lxmf_outbound_status`:** authoritative outbound delivery updates for **network** sends. Payload: `{ message_hash, status, delivery_method?, to_hash?, sent_via? }` where `status` is `delivered`, `stored_locally`, `failed`, or intermediate `sending` (egress upgrade or Direct→PN cascade step). mesh-client maps `delivered` / `stored_locally` → UI Completes (`acked`) and persists `delivery_status` (+ `delivery_method` when present) to SQLite; remote PN Completes (`delivered`) show **Stored at propagation node**; local-prop Completes (`stored_locally`) are local inbox only (not peer-delivered); `failed` → Failed. Do **not** treat `/api/v1/lxmf/send` response `delivery_status` (`queued`/`sending`) as terminal. After Direct exhausts, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → local-prop last, emitting `sending` + `delivery_method: "propagated"` or `"stored_locally"` between attempts before a final `delivered` / `stored_locally` / `failed`. **Paper create/ingest does not use this event** — Completes via `lxmf_message` with `delivery_method: "paper"` / `delivery_status: "delivered"`. - **`announce.received`:** coalesced WS notify for LXMF identity announces / path responses (named or nameless). Sidecar applies identity-key + display-name cache updates immediately, but emits **at most one** WS frame per coalesce window (500ms normal / 1000ms when >256 distinct destinations are pending) so announce storms stay O(1) bus pressure on large meshes (~100k). Payload is either a single `{ destination_hash, display_name?, hops, aspect?, identity_hash? }` (legacy / one-row flush) or `{ announces: [{ destination_hash, display_name?, hops, aspect?, identity_hash? }, ...] }` (capped at 1024, named preferred; overflow dropped — slow peer poll recovers). `aspect` is set when announce `name_hash` maps to a known app name (`lxmf.delivery`, `lxmf.propagation`, `nomadnetwork.node`, `rrc.hub`, `lxst.telephony`); omitted for path responses / unknown hashes (clients must not invent `"unknown"`). `identity_hash` is the hex identity recovered from the validated announce when present. Each flush publishes pressure counters under `GET /api/v1/diagnostics` → `announce_ws` (ingress/unique/overflow + storm/flush timestamps) for the Diagnostics `reticulum/announce-bus-pressure` warning. Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. - **`peers_updated`:** also emitted when the live path table **gains** new destination hashes (maintenance tick). Payload may include `{ added: string[], patches: PeerRow[], count }` (added/patches capped at 1024). Renderer applies patches incrementally, including route-field changes. A full peer dump is used on connect, manual Refresh, restart, safety poll, or a `peers_updated` payload that cannot be applied incrementally: `cleared`, `demoted_from_contacts`, or a single-`hash` probe/path event. Hop/timestamp-only churn does not emit. `lxmf_message` payload fields include `sender_hash`, `text`, `timestamp`, `message_hash`, optional `direction` (`inbound` / `outbound`), optional `delivery_status` (`sending` on optimistic outbound rows; `delivered` on paper Completes), optional `reply_to_hash` / `reply_preview_text` (from LXMF `FIELD_REPLY_TO` / `FIELD_REPLY_QUOTE`), and transport markers `received_via` / `sent_via`. Outbound `sent_via` is **path-table / PacketTap evidence**, not “any local RNode enabled”: atomic values are `rf`, `ble`, `tcp`, `network`, or **`paper`** (offline QR handoff); multi-egress observes join with `+` (e.g. `rf+tcp`, `ble+network`). Inbound `received_via` uses the path-table interface name **matched to local interface config** (same atoms — so a TCP hub named “RNS Testnet” is `tcp`, not `network`) or `paper` for decrypted paper URIs. Never use Meshtastic-style `both` for Reticulum network egress (legacy `both` may still appear in SQLite allowlists). -`lxmf_outbound_status` payload: `message_hash`, `status` (`delivered` / `failed` / `sending`), optional `delivery_method`, optional `sent_via` (egress evidence upgrade before Completes). +`lxmf_outbound_status` payload: `message_hash`, `status` (`delivered` / `stored_locally` / `failed` / `sending`), optional `delivery_method`, optional `sent_via` (egress evidence upgrade before Completes). ## Electron bridge Renderer calls `electronAPI.reticulum.*`; main process proxies to this API (sandboxed renderer cannot reach localhost directly). Lifecycle / proxy / Remote / factory-reset handlers live in `src/main/ipc/reticulum-handlers.ts`. Reticulum destination / Remote address / inbound-policy DB handlers are in `src/main/ipc/reticulum-db-handlers.ts`; RRC room history uses `src/main/ipc/rrc-db-handlers.ts`. +Shared `reticulum:proxy*` IPC is capped at **900/min**. `GET /api/v1/lxmf/recent` uses a dedicated **120/min** bucket so WS-lag catch-up does not starve mesh control. On rate-limit errors the renderer applies exponential backoff (`reticulumProxyRateLimitBackoff.ts`). + | IPC channel | Role | | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | | `reticulum:start` / `stop` / `getStatus` | Sidecar lifecycle | | `reticulum:syncInterfaceIssueScope` | Drop TCP/TX latch entries for disabled/removed interfaces; sticky enabled-name filter for later log lines | -| `reticulum:proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete` | HTTP proxy to paths above | +| `reticulum:proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete` | HTTP proxy to paths above (shared 900/min; lxmf/recent 120/min) | | `reticulum:factoryReset` | Factory reset (generic `proxyPost` blocks `/api/v1/system/factory-reset`; UI must use this channel) | | `reticulum:validateConfig` | One-shot `validate-config --json` against `userData/reticulum/config` (read-only; safe while stack runs) | | `reticulum:readDefaultConfigFile` | Read first existing system rnsd config path | diff --git a/docs/reticulum.md b/docs/reticulum.md index b226b1d5d..fbd4c936b 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -24,25 +24,25 @@ After changing interfaces on a live network, **restart the stack** so RNS picks ## What is included -| Area | Shipped behavior | -| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Stack lifecycle | Start / stop / auto-start; disconnect & quit | -| Interfaces | TCP client, I2P (`peers`), Auto discovery, RNode (USB serial, `ble://…`, Wi‑Fi `tcp://host:7633`); default hub picker by region (Primary & Global selected by default; added disabled; syncs/repairs selected endpoints and disables remaining decommissioned testnet hubs) | -| Identity | Generate / import mnemonic; display name; encrypted export; **identity vault** passcode on Network tab | -| LXMF chat | DM-only text and reactions (outbound LXMF file/voice-note attach removed; attachment labels render; **cached raster images** display inline; use Remote rncp for peer files). **LXST live voice Call** is separate telephony (rsLXST), not an LXMF voice clip. | -| Remote | **rnsh** multi-session shell + **rncp** send/receive/fetch under one tab (Shell / Transfer / Saved / Settings); Chat DM send-file convenience; path-speed gate (TCP/network); inbound Ask/allow-list; auto-reconnect / auto-retry; LXMF “request enable receive” prompt between mesh-client peers | -| RRC | Reticulum Relay Chat — discovered/manual/favourite hubs, up to **8** concurrent sessions, hub/room auto-join, rooms, nicklists, slash commands (`/list`, `/who`, `/join`, …), @mention unread badges (also badges the **Reticulum protocol pill** with LXMF Chat), toasts when the RRC tab is inactive, automatic reconnect with backoff | -| Delivery | **Direct** when destination is in path table (outbound-initiated Direct replies need the sidecar **outbound Direct backchannel**) (then **one-shot fallback** to preferred **remote** PN on Direct fail); **Propagated (PN)** when offline and a preferred remote PN is set; **Paper** for offline encrypted QR/`lxm://` handoff (no network — Completes immediately, no `lxmf_outbound_status`). Path/transport badges (RF/BLE/TCP/NET, multi, PN, Paper) are egress evidence — network UI stays **Sending** until `lxmf_outbound_status` (`delivered` / `failed`); Propagated Completes show **Stored at propagation node**. Terminal `delivery_status` + `delivery_method` persist in SQLite. Local PN hosting ≠ remote store-and-forward. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | -| Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe; **LXST Call** and **LRGP Challenge** on rows; peer detail modal (Save as contact is manual) | -| Games | LRGP Tic-Tac-Toe + Chess via sibling [lrgp-rs](https://github.com/ratspeak/lrgp-rs); Games tab + Challenge from Peers/Chat; opponent labels via `resolveReticulumRemoteHashLabel`; deep-link `lrgp:` / `lxm://game/`; delivery chips + resend-after-restart (`games_outbound.db`); Chess promotion picker + threefold/50-move claims; wire-compatible with Ratspeak ([parity checklist](reticulum-games-parity.md)) | -| 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 | Preferred node, per-node **Sync messages**, rename/delete remote nodes, **Discovered on network** (Add / Add & prefer with `/offer` probe), optional **local PN hosting**, configurable **auto-sync interval**, Network **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.*`) | -| Coexistence | BLE on a **different** MAC from Meshtastic/MeshCore; scan mutex; **Noble BLE yield** when an enabled BLE RNode is in config (sidecar suspends Noble on macOS/Windows so btleplug can pair) | +| Area | Shipped behavior | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Stack lifecycle | Start / stop / auto-start; disconnect & quit | +| Interfaces | TCP client, I2P (`peers`), Auto discovery, RNode (USB serial, `ble://…`, Wi‑Fi `tcp://host:7633`); default hub picker by region (Primary & Global selected by default; added disabled; syncs/repairs selected endpoints and disables remaining decommissioned testnet hubs) | +| Identity | Generate / import mnemonic; display name; encrypted export; **identity vault** passcode on Network tab | +| LXMF chat | DM-only text and reactions (outbound LXMF file/voice-note attach removed; attachment labels render; **cached raster images** display inline; use Remote rncp for peer files). **LXST live voice Call** is separate telephony (rsLXST), not an LXMF voice clip. | +| Remote | **rnsh** multi-session shell + **rncp** send/receive/fetch under one tab (Shell / Transfer / Saved / Settings); Chat DM send-file convenience; path-speed gate (TCP/network); inbound Ask/allow-list; auto-reconnect / auto-retry; LXMF “request enable receive” prompt between mesh-client peers | +| RRC | Reticulum Relay Chat — discovered/manual/favourite hubs, up to **8** concurrent sessions, hub/room auto-join, rooms, nicklists, slash commands (`/list`, `/who`, `/join`, …), @mention unread badges (also badges the **Reticulum protocol pill** with LXMF Chat), toasts when the RRC tab is inactive, automatic reconnect with backoff | +| Delivery | **Direct** when destination is in path table (outbound-initiated Direct replies need the sidecar **outbound Direct backchannel**). After Direct exhausts: **multi-PN cascade** — preferred remote → other enabled remotes (hop-sorted) → **local-prop last**. Remote PN Completes as `delivered` (**Stored at propagation node**); local-prop Completes as `stored_locally` (local inbox, not peer-delivered). **Paper** for offline encrypted QR/`lxm://` handoff (no network — Completes immediately, no `lxmf_outbound_status`). Path/transport badges (RF/BLE/TCP/NET, multi, PN, Paper) are egress evidence — network UI stays **Sending** until `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`). Terminal `delivery_status` + `delivery_method` persist in SQLite. Local inbox Completes ≠ peer delivery. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | +| Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe; **LXST Call** and **LRGP Challenge** on rows; peer detail modal (Save as contact is manual) | +| Games | LRGP Tic-Tac-Toe + Chess via sibling [lrgp-rs](https://github.com/ratspeak/lrgp-rs); Games tab + Challenge from Peers/Chat; opponent labels via `resolveReticulumRemoteHashLabel`; deep-link `lrgp:` / `lxm://game/`; delivery chips + resend-after-restart (`games_outbound.db`); Chess promotion picker + threefold/50-move claims; wire-compatible with Ratspeak ([parity checklist](reticulum-games-parity.md)) | +| 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 | Preferred node, per-node **Sync messages**, rename/delete remote nodes, **Discovered on network** (Add / Add & prefer with `/offer` probe), optional **local PN hosting**, configurable **auto-sync interval**, Network **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.*`) | +| Coexistence | BLE on a **different** MAC from Meshtastic/MeshCore; scan mutex; **Noble BLE yield** when an enabled BLE RNode is in config (sidecar suspends Noble on macOS/Windows so btleplug can pair) | **Not in Reticulum mode:** Meshtastic/MeshCore-style RF channel chat, MQTT broker card, Meshtastic/MeshCore LoRa node position map, Rooms BBS, TAK, Meshtastic PKI Security tab, Hop Goblins routing diagnostics. (RRC is hub room chat over Reticulum Links — not LoRa RF channels.) @@ -281,10 +281,10 @@ When multiple enabled local RNode interfaces are connected, the interface list s - **DM-only** on the Chat tab — no RF channel pills (RRC covers hub rooms separately) - Text and emoji reactions. **Outbound LXMF file/voice attach is not offered** (removed); historic `[file:name:mime]` bubbles and inbound Sideband-style attachments render a read-only label; when the file remains in `reticulum/attachments/`, **raster images** (JPEG/PNG/GIF/WebP/AVIF/BMP — not SVG) display inline via main-process `chat:readReticulumAttachmentAsDataUrl` (magic-byte MIME check, 2 MiB cap, path jailed, IPC rate-limited). Peer file transfer is via Remote rncp. - **Replies:** outbound DMs stamp LXMF `FIELD_REPLY_TO` (0x30) and optional `FIELD_REPLY_QUOTE` (0x31, capped) before sign so peers see structured replies; ingest/Chat use `reticulum_reply_to_hash` plus quote preview (store parent when present, else wire quote) and jump-to-parent by message hash -- Outbound **Sending** until sidecar emits `lxmf_outbound_status` (`delivered` / `failed`); `/api/v1/lxmf/send` may return `delivery_status: "queued"` or `"sending"` — that is enqueue/acceptance, not delivery confirmation. On Direct failure with a preferred **remote** PN, the sidecar re-emits `sending` with `delivery_method: "propagated"` for the one-shot PN deposit. **Exception — paper:** Chat DM **Share as paper** (`createReticulumPaperMessage` → `POST /api/v1/lxmf/paper/create`) encrypts offline to a QR/`lxm://` URI with **no network send**; Completes immediately (`delivery_method: paper`, badge **Paper**) and does **not** use `lxmf_outbound_status`. Ingest via Chat **Scan paper**, Network **Scan / import**, or OS `lxm://` (`POST /api/v1/lxmf/paper/ingest` — HTTP `message` fallback-ingested when WS lags). Create needs peer pubkey (`identity_unknown` otherwise); ingest needs matching local identity (`decrypt_failed` otherwise); size-capped (`paper_too_large`). -- Terminal **Completes** / **Failed** from `lxmf_outbound_status` are persisted to SQLite (`delivery_status` + `delivery_method` on `reticulum_messages`) via `applyReticulumOutboundDeliveryStatus.ts` so restart/DB hydration keeps PN vs Direct labeling; early WS events before provisional id→hash rekey are buffered +- Outbound **Sending** until sidecar emits `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`); `/api/v1/lxmf/send` may return `delivery_status: "queued"` or `"sending"` — that is enqueue/acceptance, not delivery confirmation. After Direct exhausts, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → local-prop last, re-emitting `sending` with `delivery_method: "propagated"` (remote) or `"stored_locally"` (local inbox) between attempts. **Exception — paper:** Chat DM **Share as paper** (`createReticulumPaperMessage` → `POST /api/v1/lxmf/paper/create`) encrypts offline to a QR/`lxm://` URI with **no network send**; Completes immediately (`delivery_method: paper`, badge **Paper**) and does **not** use `lxmf_outbound_status`. Ingest via Chat **Scan paper**, Network **Scan / import**, or OS `lxm://` (`POST /api/v1/lxmf/paper/ingest` — HTTP `message` fallback-ingested when WS lags). Create needs peer pubkey (`identity_unknown` otherwise); ingest needs matching local identity (`decrypt_failed` otherwise); size-capped (`paper_too_large`). +- Terminal **Completes** / **Failed** from `lxmf_outbound_status` are persisted to SQLite (`delivery_status` + `delivery_method` on `reticulum_messages`) via `applyReticulumOutboundDeliveryStatus.ts` so restart/DB hydration keeps PN vs Direct vs local-inbox labeling; early WS events before provisional id→hash rekey are buffered - **Optimistic pending rekey:** Chat send creates a `reticulum-pending-*` row; when the sidecar returns the real `message_hash`, ingest/SQLite upsert passes `replaces_message_hash` so the pending row is deleted atomically (avoids orphan Sending duplicates) -- Propagated Completes render as **Stored at propagation node** (PN badge) — not recipient **Delivered** +- Remote PN Completes (`delivered`) render as **Stored at propagation node** (PN badge); local-prop Completes (`stored_locally`) stay in the **local propagation inbox** — neither is recipient **Delivered** - **DM path reachability:** active DM header shows a reachability badge (`ReticulumDmPathReachabilityBadge` + `useReticulumDmPathProbe`) seeded from path-table/contact hops, then settled by peer probe; when settled, **Request path** / **Probe** use the same sidecar endpoints as the Peers tab. Chat **Probe** mirrors Peer List UX: stack-running check → `/probe` → toast → peer refresh; `onProbeSettled` / `applyProbeResult(forHash, …)` applies the result without a second `/probe` (stale hashes after DM switch are ignored); manual reprobe forces Checking… even when passive hops already look reachable ## RRC (Reticulum Relay Chat) @@ -303,23 +303,24 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): ### Delivery modes -| Path table | Propagation node | Routing / UI | -| ------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Destination present | — | **Direct** link delivery; RF/BLE/TCP/NET (or explicit multi e.g. RF+TCP) badge = path-table / PacketTap egress — message stays **Sending** until `lxmf_outbound_status: delivered` | -| Destination present | Preferred **remote** PN set | Same Direct-first attempt; if Direct **fails**, sidecar **one-shot retries via preferred remote PN** (not local PN hosting). UI switches to **PN** / **Stored at propagation node** on PN Complete | -| Destination absent | Preferred PN set | **Propagated** via preferred propagation node; **PN** badge = store-and-forward — Completes as **Stored at propagation node** (not recipient-delivered) | -| Destination absent | None | Error `no_propagation_node`; set preferred **remote** node on Network tab | -| n/a (offline) | n/a | **Paper** — encrypted QR/`lxm://` handoff (`DeliveryMethod::Paper`); no path table or PN; Completes immediately; badge **Paper**; does not use `lxmf_outbound_status` | +| Path table | Propagation node | Routing / UI | +| ------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Destination present | — (no cascade candidates) | **Direct** link delivery; RF/BLE/TCP/NET (or explicit multi e.g. RF+TCP) badge = path-table / PacketTap egress — message stays **Sending** until `lxmf_outbound_status: delivered` | +| Destination present | Remotes and/or enabled local-prop | Direct-first; on Direct fail, **cascade** preferred remote → other enabled remotes (hop-sorted) → local-prop last. Remote Completes → **PN** / **Stored at propagation node** (`delivered`); local-prop → **local inbox** (`stored_locally`) | +| Destination absent | Preferred / enabled remotes | **Propagated** via cascade (preferred first); **PN** badge — Completes as **Stored at propagation node** (not recipient-delivered) | +| Destination absent | Local-prop only | Completes as `stored_locally` in the **local propagation inbox** (not peer-delivered) | +| Destination absent | None | Error `no_propagation_node`; set a preferred **remote** node (or enable local-prop for inbox-only Completes) on Network tab | +| n/a (offline) | n/a | **Paper** — encrypted QR/`lxm://` handoff (`DeliveryMethod::Paper`); no path table or PN; Completes immediately; badge **Paper**; does not use `lxmf_outbound_status` | -**Path ≠ delivered:** a path-table entry means RNS knows a route, not that LXMF completed. Reticulum is async — offline peers need a **remote** propagation node (or **paper** QR handoff). **Local PN hosting** accepts network deposits and peers inventory with other PNs when enabled — it still does **not** replace a preferred **remote** PN for _your_ unreachable outbound DMs. Propagated Completes mean the PN accepted the encrypted blob (Ratspeak envelope parity), not that the recipient opened Chat. LXMF retrieval is **any-node**: deposit on PN A and Sync from PN B is valid when the fabric peers; parties need not share the same preferred PN. +**Path ≠ delivered:** a path-table entry means RNS knows a route, not that LXMF completed. Reticulum is async — offline peers need a **remote** propagation node (or **paper** QR handoff) for peer store-and-forward. **Local-prop** is last in the Direct→PN cascade and Completes as `stored_locally` (your inbox only — not peer delivery). Remote PN Completes mean the PN accepted the encrypted blob (Ratspeak envelope parity), not that the recipient opened Chat. The renderer link-timeout Failed bridge skips while cascade capacity remains (any untried remote **or** enabled local-prop). LXMF retrieval is **any-node**: deposit on PN A and Sync from PN B is valid when the fabric peers; parties need not share the same preferred PN. --- ## Path routing -When a destination is reachable over more than one next hop, the sidecar keeps up to **three ranked path slots** (one active + backups). Failover promotes a backup (or rediscovers via another live interface) before giving up — Nomad page loads exhaust alternate paths inside one request; LXMF Direct does the same before preferred-PN fallback. See [troubleshooting](troubleshooting.md#nomad-network-pages-hang-or-almost-never-load) for triage. +When a destination is reachable over more than one next hop, the sidecar keeps up to **three ranked path slots** (one active + backups). Failover promotes a backup (or rediscovers via another live interface) before giving up — Nomad page loads exhaust alternate paths inside one request; LXMF Direct does the same before the **multi-PN cascade**. See [troubleshooting](troubleshooting.md#nomad-network-pages-hang-or-almost-never-load) for triage. -**AutoInterface vs private TCP/UDP:** Peers learned on Auto are normal 0-hop neighbors; RNS may keep Auto active even when a private LAN hub path exists (including equal-hop ties). For LXMF Direct, the sidecar **automatically** demotes Auto toward a live **private** path when Auto is unhealthy for delivery or Direct fails on Auto — then fails over private → public → preferred PN. It does **not** rewrite healthy Auto Direct, and does **not** preempt Auto to public internet hubs. See [troubleshooting — local DMs hang with AutoInterface + private TCP hub](troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub). +**AutoInterface vs private TCP/UDP:** Peers learned on Auto are normal 0-hop neighbors; RNS may keep Auto active even when a private LAN hub path exists (including equal-hop ties). For LXMF Direct, the sidecar **automatically** demotes Auto toward a live **private** path when Auto is unhealthy for delivery or Direct fails on Auto — then fails over private → public → multi-PN cascade (preferred remote → other enabled remotes hop-sorted → local-prop last). It does **not** rewrite healthy Auto Direct, and does **not** preempt Auto to public internet hubs. See [troubleshooting — local DMs hang with AutoInterface + private TCP hub](troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub). **Network → stack settings → Prefer path medium** sets the global bias: @@ -381,10 +382,10 @@ Firmware `.zip` files are selected locally (no in-app GitHub download). Disconne ### SQLite (main process) -| Table | Contents | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `reticulum_destinations` | Destination meta (canonical 32-hex hash, display name, favorited, `icon_name`, `icon_color`, `last_heard` for History, `is_contact` for saved Contacts) | -| `reticulum_messages` | LXMF history (`message_hash`, `reply_to_hash`, `delivery_status` — `sending`/`queued`/`pending`/`delivered`/`failed`; `delivery_method` — `direct`/`propagated`/`opportunistic`/`paper`; `received_via` atoms include `rf`/`ble`/`tcp`/`network`/`mqtt`/`both`/`paper`; terminal outbound status written on `lxmf_outbound_status` except paper Completes from create/ingest; stale `sending` rows marked failed on startup; optional `replaces_message_hash` on upsert deletes the prior optimistic pending hash) | +| Table | Contents | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `reticulum_destinations` | Destination meta (canonical 32-hex hash, display name, favorited, `icon_name`, `icon_color`, `last_heard` for History, `is_contact` for saved Contacts) | +| `reticulum_messages` | LXMF history (`message_hash`, `reply_to_hash`, `delivery_status` — `sending`/`queued`/`pending`/`delivered`/`failed`; `delivery_method` — `direct`/`propagated`/`opportunistic`/`paper`/`stored_locally`; wire `stored_locally` Completes map to SQLite `delivered` + `delivery_method: stored_locally`; `received_via` atoms include `rf`/`ble`/`tcp`/`network`/`mqtt`/`both`/`paper`; terminal outbound status written on `lxmf_outbound_status` except paper Completes from create/ingest; stale `sending` rows marked failed on startup; optional `replaces_message_hash` on upsert deletes the prior optimistic pending hash) | ### Sidecar `userData` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9e02bd5c4..0eb4c90f0 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1137,7 +1137,7 @@ In dev, **Start stack** now rebuilds when `reticulum-sidecar/src/**/*.rs` or `Ca Unrecognized codes pass through unchanged. -TCP/network Nomad Links use path-scaled initiator hops (`link_hops = clamp(path_hops, 3, 7)`) and a LinkClient proof wait of the **remaining overall MeshChat deadline** (~45s TCP after instant pubkey recall), matching v5.25.0. Do not cap LRPROOF at hops×6 or a 30s floor — that false-failed multi-hop hub pages that still load on release. First attempts use a cached path when present (no DropPath storm); missing paths RequestPath briefly and may return `path_timeout`. On TCP `link_timeout`, the sidecar suppresses the dead iface, drops the failed via, promotes ranked path-slot backups / other live hubs (extra RequestPath when another TCP/RF iface is up), then retries inside the same fetch. LXMF Direct chat uses the same exhaustion before the one-shot preferred-PN fallback. `force_path_ok=true` means rediscovered after absence only (cache hits log `force_path_ok=false`). Failure logs (`[nomadNetworkStore] … fetch failed` and sidecar `Nomad Link query failed`) include `path_hops`, `link_hops`, `proof_budget_secs`, `force_path_ok`, `path_ensure_kind`, `elapsed_ms`, `tried_interfaces`, `failover_rounds`, `iface`, and `raw=`. UI errors distinguish cached-path vs rediscovered-path link failures. +TCP/network Nomad Links use path-scaled initiator hops (`link_hops = clamp(path_hops, 3, 7)`) and a LinkClient proof wait of the **remaining overall MeshChat deadline** (~45s TCP after instant pubkey recall), matching v5.25.0. Do not cap LRPROOF at hops×6 or a 30s floor — that false-failed multi-hop hub pages that still load on release. First attempts use a cached path when present (no DropPath storm); missing paths RequestPath briefly and may return `path_timeout`. On TCP `link_timeout`, the sidecar suppresses the dead iface, drops the failed via, promotes ranked path-slot backups / other live hubs (extra RequestPath when another TCP/RF iface is up), then retries inside the same fetch. LXMF Direct chat uses the same path exhaustion before the **multi-PN cascade**. `force_path_ok=true` means rediscovered after absence only (cache hits log `force_path_ok=false`). Failure logs (`[nomadNetworkStore] … fetch failed` and sidecar `Nomad Link query failed`) include `path_hops`, `link_hops`, `proof_budget_secs`, `force_path_ok`, `path_ensure_kind`, `elapsed_ms`, `tried_interfaces`, `failover_rounds`, `iface`, and `raw=`. UI errors distinguish cached-path vs rediscovered-path link failures. **Cause**: Older `LinkClient` always waited for a fresh path-response announce for the destination public key, even when Nomad announces had already cached it. Successful fetches could also deregister all `nomadnetwork.node` announce handlers. Distant/high-hop nodes can still time out at the path stage (expected RF/mesh reachability limits). @@ -1346,7 +1346,7 @@ Bond-stale **TX queue full** hints (`txQueueDropsHintBleBondStale`) point at the 1. **Shared instance conflict** — `share_instance = Yes` with another Reticulum app still running (MeshChatX, Ratspeak, standalone `rnsd`) fighting the same IPC socket. mesh-client may attach as `SharedInstanceClient` and **not spawn** local TCP hubs (Connection then shows misleading “TCP hub unreachable”). 2. **Dead TCP hub still enabled** — outbound queue fills; path requests fail with _no available capacity_. -3. **No remote propagation node** — when Direct fails and no preferred **remote** PN is configured, there is no store-and-forward retry (local inbox does not count). With a remote preferred PN, the sidecar retries once via that PN (see **Stale path + Failed via TCP** below). +3. **No PN cascade capacity** — when Direct fails and there are no enabled cascade candidates (preferred/other remotes or local-prop), the row fails with no store-and-forward retry. With remotes (and/or enabled local-prop), the sidecar cascades after Direct exhausts (see **Stale path + Failed via TCP** below). Developer bundles include `reticulum/lxmf-outbound.log` (filtered LXMF outbound / PN cascade lines). **Fix**: @@ -1371,9 +1371,9 @@ Export for GitHub (`reticulum.sidecar.interfaceIssueAlert`, link-timeout counts) 1. Open **Network → Propagation** (Chat notice **Set up propagation** jumps there). 2. Add a **32-character LXMF destination hash** from whoever runs the propagation node you trust. 3. Set **Preferred** (manual mode) or leave **Auto** when multiple nodes are listed. -4. **Local propagation hosting** stores messages for peers that sync with you — it does **not** replace a remote propagation node for peers you cannot reach directly. Preferring Local shows a warning toast; Chat still treats local-only as “no remote PN.” +4. **Local propagation hosting** stores messages for peers that sync with you and is **last** in the Direct→PN cascade (`stored_locally` — local inbox, not peer-delivered). Preferring Local shows a warning toast; it does **not** replace a remote PN for peer store-and-forward. -**Stale path + Failed via TCP:** When a path exists, mesh-client tries **Direct** first. If Direct fails and a preferred **remote** PN is configured, the sidecar retries once via that PN (Ratspeak-style store-and-forward). Without a remote preferred PN, the row stays **Failed** even if Ratspeak on the same machine deposits successfully. +**Stale path + Failed via TCP:** When a path exists, mesh-client tries **Direct** first. If Direct fails, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → local-prop last. Remote deposits Complete as `delivered` (**Stored at propagation node**); local-prop Completes as `stored_locally` (inbox only). The renderer link-timeout Failed bridge skips while cascade capacity remains. Without any cascade candidates, the row stays **Failed**. Check developer-bundle `reticulum/lxmf-outbound.log` for cascade lines. Persistent `proxyGet`/`proxyPost` storms may hit the shared **900/min** proxy ceiling (LXMF recent catch-up uses a dedicated **120/min** bucket; renderer backs off on rate-limit errors). **Not the same as transport:** Ratspeak TCP hubs (e.g. `rns.ratspeak.org:4242`) and [rathole](https://github.com/ratspeak/rathole) are **connectivity / transport** tools, not LXMF propagation. mesh-client does not ship a default community propagation hash. diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 4955944be..cc5226f95 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -2,6 +2,8 @@ #[path = "lxmf_outbound.rs"] mod lxmf_outbound; +#[path = "pn_cascade.rs"] +mod pn_cascade; use std::collections::{HashMap, HashSet}; use std::io::Cursor; @@ -550,7 +552,13 @@ impl LiveBridge { if let Some(hash_hex) = preferred_prop_hash { bridge.set_outbound_propagation_node(Some(&hash_hex)).await; + } else { + tracing::warn!( + target: "lxmf-outbound", + "no preferred propagation destination_hash at stack start — Direct→PN cascade remotes may be empty" + ); } + bridge.refresh_pn_cascade_candidates().await; if let Ok(ifaces) = config::interfaces_from_config_dir(&config_dir) { let _ = bridge.sync_ble_peer_interfaces(&ifaces).await; @@ -3742,6 +3750,25 @@ impl LiveBridge { } } + /// Rebuild Direct→PN cascade candidate list from persisted propagation rows. + pub async fn refresh_pn_cascade_candidates(&self) { + use pn_cascade::candidates_from_propagation_rows; + let (rows, self_hash) = { + let state = self.persisted.read().await; + let rows: Vec<(String, bool, Option, Option)> = state + .propagation + .iter() + .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) + }; + let candidates = candidates_from_propagation_rows(&rows, &self_hash); + if let Ok(mut driver) = self.outbound.lock() { + driver.set_pn_cascade_candidates(candidates); + } + } + pub async fn fetch_interfaces(&self) -> Result, String> { let config_rows = super::config::interfaces_from_config_dir(&self.config_dir).unwrap_or_default(); diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index c97e606bb..575fd4cdd 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -29,7 +29,10 @@ use super::super::path_failover::{ }; use super::super::types::InterfaceRow; use super::super::via::classify_interface; -use super::{lxmf_payload_from_message, parse_hash16}; +use super::parse_hash16; +use super::pn_cascade::{ + PnCascadeCandidate, build_pn_cascade_order, cascade_has_capacity, pick_next_pn_cascade, +}; const PATH_REQUEST_BACKOFF_SECS: f64 = 20.0; const PATH_REQUEST_MAX_ATTEMPTS: u32 = 12; @@ -115,8 +118,12 @@ impl PathRequestGate { } } -/// Bound on Direct→PN fallback hash tracking (one entry per outbound message). -const PN_FALLBACK_ATTEMPTED_MAX: usize = 256; +/// Cap on distinct message hashes retained in `pn_cascade_tried` (memory bound under +/// announce/outbound floods — eviction prefers keys not in pending deposit/target maps). +const PN_CASCADE_TRIED_MAX: usize = 256; +/// After this many sync/pending PN-link deferrals, advance to the next cascade PN so a +/// busy preferred PN cannot storm-defer the same deposit forever. +const PN_DEPOSIT_DEFER_ADVANCE_AFTER: u32 = 8; /// Correlatable ids for an in-flight Propagated deposit (`pn_hash`, optional `transient_id`). type PendingPnDeposit = ([u8; 16], Option<[u8; 32]>); @@ -140,15 +147,28 @@ pub struct LxmfOutboundDriver { /// (Auto status may still report "up" after a Direct failure on Auto). auto_delivery_degraded_until: f64, path_request_gate: PathRequestGate, - /// Message hashes that already consumed the one-shot Direct→PN fallback. - pn_fallback_attempted: HashSet<[u8; 32]>, + /// Per-message PN hashes already tried in the Direct→Propagated cascade. + pn_cascade_tried: HashMap<[u8; 32], HashSet<[u8; 16]>>, + /// Message hashes whose current cascade step is a local-prop (offline) deposit. + pn_cascade_local: HashSet<[u8; 32]>, + /// Enabled PN candidates + preferred hash for cascade ordering. + pn_cascade_candidates: Vec, + preferred_pn_hash: Option<[u8; 16]>, + /// Consecutive DeliverPropagated deferrals while PN link busy (per message). + pn_deposit_defer_counts: HashMap<[u8; 32], u32>, /// Direct link failures still exhausting alternate path slots / ifaces. direct_path_failovers: HashMap<[u8; 32], DirectPathFailoverState>, /// When set, remote propagation sync holds a Link to this dest — do not race deposits. propagation_sync_target: Option<[u8; 16]>, /// In-flight Propagated deposits: message_hash → (pn_hash, transient_id). pending_pn_deposits: HashMap<[u8; 32], PendingPnDeposit>, + /// Per-message PN target for the current cascade step (avoids retargeting the + /// router-global `outbound_propagation_node` for concurrent sends). + pending_pn_targets: HashMap<[u8; 32], [u8; 16]>, + /// Local LXMF identity (retained for driver construction / future failed-detail payloads). + #[allow(dead_code)] self_lxmf_hash: String, + #[allow(dead_code)] self_display_name: String, } @@ -176,10 +196,15 @@ impl LxmfOutboundDriver { interfaces: Vec::new(), auto_delivery_degraded_until: 0.0, path_request_gate: PathRequestGate::new(), - pn_fallback_attempted: HashSet::new(), + pn_cascade_tried: HashMap::new(), + pn_cascade_local: HashSet::new(), + pn_cascade_candidates: Vec::new(), + preferred_pn_hash: None, + pn_deposit_defer_counts: HashMap::new(), direct_path_failovers: HashMap::new(), propagation_sync_target: None, pending_pn_deposits: HashMap::new(), + pending_pn_targets: HashMap::new(), self_lxmf_hash: self_lxmf_hash.clone(), self_display_name, }; @@ -254,9 +279,24 @@ impl LxmfOutboundDriver { #[allow(clippy::unused_self)] // method slot mirrors other LxmfOutboundDriver mutators pub fn set_propagation_node(&mut self, router: &mut LxmRouter, hash: Option<[u8; 16]>) { + self.preferred_pn_hash = hash; router.set_outbound_propagation_node(hash); } + /// Refresh enabled PN candidates used after Direct path failover exhausts. + pub fn set_pn_cascade_candidates(&mut self, candidates: Vec) { + tracing::info!( + target: "lxmf-outbound", + count = candidates.len(), + preferred = %self + .preferred_pn_hash + .map(hex::encode) + .unwrap_or_else(|| "none".into()), + "PN cascade candidates updated" + ); + self.pn_cascade_candidates = candidates; + } + /// Refresh local path cache from transport GetPathTable rows. pub fn update_path_table(&mut self, entries: &[PathTableRoute]) { self.route_hops.clear(); @@ -345,7 +385,8 @@ impl LxmfOutboundDriver { }) .collect(); - let actions = router.process_outbound_with_direct(|message, _now| { + self.ensure_router_pn_for_dispatch(router); + let mut actions = router.process_outbound_with_direct(|message, _now| { direct_inputs .get(&message.destination_hash) .cloned() @@ -355,6 +396,7 @@ impl LxmfOutboundDriver { reusable_link: DirectReusableLinkState::None, }) }); + self.apply_pending_pn_targets(&mut actions); if !actions.is_empty() { self.execute_actions(router, event_tx, actions); @@ -424,9 +466,38 @@ impl LxmfOutboundDriver { let sync_blocks = self.propagation_sync_target == Some(prop_hash); let pending_blocks = self.link_delivery.has_pending_to(&prop_hash); if should_defer_propagated_for_pn_link(sync_blocks, pending_blocks) { + if let Some(msg_hash) = message.hash.or(message.message_id) { + let defer_count = self + .pn_deposit_defer_counts + .entry(msg_hash) + .and_modify(|c| *c = c.saturating_add(1)) + .or_insert(1); + if *defer_count >= PN_DEPOSIT_DEFER_ADVANCE_AFTER { + tracing::warn!( + target: "lxmf-outbound", + prop = %prop_hex, + dest = %hex::encode(message.destination_hash), + msg = %hex::encode(msg_hash), + defer_count = *defer_count, + sync_blocks, + pending_blocks, + "DeliverPropagated: PN link busy too long — advancing PN cascade" + ); + self.pn_deposit_defer_counts.remove(&msg_hash); + self.mark_pn_tried(msg_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; + } + } + } + } let now = now_f64(); message.next_delivery_attempt = now + f64::from(PATH_REQUEST_WAIT as u32); tracing::debug!( + target: "lxmf-outbound", prop = %prop_hex, dest = %hex::encode(message.destination_hash), sync_blocks, @@ -435,19 +506,49 @@ impl LxmfOutboundDriver { "DeliverPropagated: deferring — PN link busy" ); if let Some(hash) = message.hash.or(message.message_id) { + self.pending_pn_targets.insert(hash, prop_hash); + let method = self.cascade_wire_delivery_method(hash); emit_outbound_status_with_via( event_tx, Some(serde_json::Value::String(hex::encode(hash))), None, "sending", - Some("propagated"), - None, + Some(method), + Some(prop_hex.clone()), ); } router.send(message); return; } + if let Some(hash) = message.hash.or(message.message_id) { + self.pn_deposit_defer_counts.remove(&hash); + self.pending_pn_targets.insert(hash, prop_hash); + } + // Local-prop cascade uses lxmf.propagation dest (not self LXMF). Identity should + // already be pinned via rehydrate; if missing, advance rather than path-hunt Nomad. + let is_local_cascade = message + .hash + .or(message.message_id) + .is_some_and(|h| self.pn_cascade_local.contains(&h)); if !self.known_identities.contains_key(&prop_hex.to_lowercase()) { + if is_local_cascade { + tracing::warn!( + target: "lxmf-outbound", + prop = %prop_hex, + dest = %hex::encode(message.destination_hash), + "DeliverPropagated: local-prop identity unknown — 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; + } + } + } tracing::debug!( prop = %prop_hex, dest = %hex::encode(message.destination_hash), @@ -472,10 +573,18 @@ impl LxmfOutboundDriver { tracing::warn!( prop = %prop_hex, dest = %hex::encode(message.destination_hash), - "DeliverPropagated: pack_for_propagation failed — requeue" + "DeliverPropagated: pack_for_propagation failed — advancing PN cascade" ); - router.send(message); - return; + 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; + } + } }; // lxmd parity: count the attempt before packed link delivery so Failed can budget retries. let attempts = mark_propagated_delivery_attempt(&mut message); @@ -484,10 +593,18 @@ impl LxmfOutboundDriver { prop = %prop_hex, attempts, max_attempts = MAX_DELIVERY_ATTEMPTS, - "propagated delivery attempt budget reached; deferring terminal failure" + "propagated delivery attempt budget reached — advancing PN cascade" ); - router.send(message); - return; + 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; + } + } } let hops = route_hops_for(&self.route_hops, prop_hash); let message_hash_hex = message.hash.as_ref().map(hex::encode); @@ -700,7 +817,7 @@ impl LxmfOutboundDriver { event_tx: &broadcast::Sender, message: LxMessage, ) { - match self.try_requeue_via_propagation(router, event_tx, message) { + match self.try_advance_pn_cascade(router, event_tx, message) { Ok(()) => {} Err(message) => self.emit_outbound_failed(router, event_tx, *message), } @@ -713,67 +830,173 @@ impl LxmfOutboundDriver { mut message: LxMessage, ) { message.mark_failed(); - let method = delivery_method_label(message.method); + let method = message + .hash + .or(message.message_id) + .map(|h| self.cascade_wire_delivery_method(h)) + .unwrap_or_else(|| delivery_method_label(message.method)); tracing::warn!( + target: "lxmf-outbound", dest = %hex::encode(message.destination_hash), method, attempts = message.delivery_attempts, "LXMF outbound delivery failed" ); if let Some(hash) = message.hash.or(message.message_id) { - self.pn_fallback_attempted.remove(&hash); + let attempts = message.delivery_attempts; + self.clear_pn_cascade_state(hash); self.direct_path_failovers.remove(&hash); self.pending_pn_deposits.remove(&hash); let _ = router.mark_outbound_failed(&hash); - emit_outbound_status_by_hash(event_tx, &hash, "failed", Some(method)); + emit_outbound_status_detailed_with_attempts( + event_tx, + Some(serde_json::Value::String(hex::encode(hash))), + None, + "failed", + Some(method), + None, + None, + None, + Some(attempts), + ); } - let payload = lxmf_payload_from_message( - &message, - &self.self_lxmf_hash, - &self.self_display_name, - None, - Some(method), - "outbound", - None, - ); - emit_outbound_status(event_tx, &payload, "failed", method); } - /// After Direct link failure, deposit once via preferred remote PN (Ratspeak parity). - /// Returns `Ok(())` when re-queued as Propagated; `Err(message)` when caller should fail. - fn try_requeue_via_propagation( + fn ordered_pn_cascade(&self) -> Vec { + build_pn_cascade_order(&self.pn_cascade_candidates, self.preferred_pn_hash) + } + + fn mark_pn_tried(&mut self, msg_hash: [u8; 32], pn_hash: [u8; 16]) { + if self.pn_cascade_tried.len() >= PN_CASCADE_TRIED_MAX + && !self.pn_cascade_tried.contains_key(&msg_hash) + { + let victim = self + .pn_cascade_tried + .keys() + .find(|k| { + !self.pending_pn_targets.contains_key(*k) + && !self.pending_pn_deposits.contains_key(*k) + }) + .copied() + .or_else(|| self.pn_cascade_tried.keys().next().copied()); + if let Some(oldest) = victim { + self.pn_cascade_tried.remove(&oldest); + self.pn_cascade_local.remove(&oldest); + self.pending_pn_targets.remove(&oldest); + self.pn_deposit_defer_counts.remove(&oldest); + } + } + self.pn_cascade_tried + .entry(msg_hash) + .or_default() + .insert(pn_hash); + } + + fn clear_pn_cascade_state(&mut self, msg_hash: [u8; 32]) { + self.pn_cascade_tried.remove(&msg_hash); + self.pn_cascade_local.remove(&msg_hash); + self.pn_deposit_defer_counts.remove(&msg_hash); + self.pending_pn_targets.remove(&msg_hash); + } + + fn cascade_wire_delivery_method(&self, msg_hash: [u8; 32]) -> &'static str { + if self.pn_cascade_local.contains(&msg_hash) { + "stored_locally" + } else { + "propagated" + } + } + + /// Ensure the router has *some* outbound PN so Propagated dispatch can emit actions. + /// Does not retarget an already-set global — per-message targets use `pending_pn_targets`. + fn ensure_router_pn_for_dispatch(&self, router: &mut LxmRouter) { + if router.outbound_propagation_node.is_some() { + return; + } + if let Some(preferred) = self.preferred_pn_hash { + router.set_outbound_propagation_node(Some(preferred)); + return; + } + if let Some(first) = self.ordered_pn_cascade().first().map(|c| c.hash) { + router.set_outbound_propagation_node(Some(first)); + } + } + + /// Rewrite `DeliverPropagated.prop_hash` from the per-message cascade target map. + fn apply_pending_pn_targets(&self, actions: &mut [OutboundAction]) { + for action in actions.iter_mut() { + if let OutboundAction::DeliverPropagated { message, prop_hash } = action { + if let Some(hash) = message.hash.or(message.message_id) { + if let Some(target) = self.pending_pn_targets.get(&hash) { + *prop_hash = *target; + } + } + } + } + } + + /// Advance Direct→Propagated cascade: preferred remote → other remotes → local-prop. + /// Returns `Ok(())` when re-queued; `Err(message)` when cascade is exhausted. + fn try_advance_pn_cascade( &mut self, router: &mut LxmRouter, event_tx: &broadcast::Sender, mut message: LxMessage, ) -> Result<(), Box> { - if !should_fallback_direct_to_pn( - message.method, - router.outbound_propagation_node, - &self.self_lxmf_hash, - message - .hash - .or(message.message_id) - .is_some_and(|h| self.pn_fallback_attempted.contains(&h)), - ) { + let Some(msg_hash) = message.hash.or(message.message_id) else { + return Err(Box::new(message)); + }; + // Direct may enter cascade; Propagated advances to the next PN after a deposit fail. + if message.method != DeliveryMethod::Direct && message.method != DeliveryMethod::Propagated + { return Err(Box::new(message)); } - let Some(msg_hash) = message.hash.or(message.message_id) else { + let ordered = self.ordered_pn_cascade(); + let tried = self + .pn_cascade_tried + .get(&msg_hash) + .cloned() + .unwrap_or_default(); + if !cascade_has_capacity(&ordered, &tried) { + tracing::warn!( + target: "lxmf-outbound", + dest = %hex::encode(message.destination_hash), + msg = %hex::encode(msg_hash), + tried = tried.len(), + candidates = ordered.len(), + "PN cascade exhausted — marking outbound failed" + ); + return Err(Box::new(message)); + } + let pick = pick_next_pn_cascade(&ordered, &tried); + let Some(pn_hash) = pick.hash() else { return Err(Box::new(message)); }; - // Quietly drop any leftover Direct queue entry (already removed for most Fail paths). + let method_label = pick.delivery_method_label().unwrap_or("propagated"); router .pending_outbound .retain(|m| m.hash != Some(msg_hash) && m.message_id != Some(msg_hash)); - self.remember_pn_fallback(msg_hash); + self.mark_pn_tried(msg_hash, pn_hash); self.direct_path_failovers.remove(&msg_hash); + self.pn_deposit_defer_counts.remove(&msg_hash); + if pick.is_local() { + self.pn_cascade_local.insert(msg_hash); + } else { + self.pn_cascade_local.remove(&msg_hash); + } + self.pending_pn_targets.insert(msg_hash, pn_hash); + self.ensure_router_pn_for_dispatch(router); message.method = DeliveryMethod::Propagated; message.delivery_attempts = 0; message.next_delivery_attempt = 0.0; tracing::info!( + target: "lxmf-outbound", dest = %hex::encode(message.destination_hash), msg = %hex::encode(msg_hash), - "LXMF Direct failed; falling back to preferred remote propagation node" + pn = %hex::encode(pn_hash), + cascade_step = method_label, + is_local = pick.is_local(), + "LXMF advancing PN cascade" ); router.send(message); emit_outbound_status_with_via( @@ -781,22 +1004,12 @@ impl LxmfOutboundDriver { Some(serde_json::Value::String(hex::encode(msg_hash))), None, "sending", - Some("propagated"), - None, + Some(method_label), + Some(hex::encode(pn_hash)), ); Ok(()) } - fn remember_pn_fallback(&mut self, msg_hash: [u8; 32]) { - if self.pn_fallback_attempted.len() >= PN_FALLBACK_ATTEMPTED_MAX { - // Evict an arbitrary entry so floods cannot grow unbounded. - if let Some(oldest) = self.pn_fallback_attempted.iter().next().copied() { - self.pn_fallback_attempted.remove(&oldest); - } - } - self.pn_fallback_attempted.insert(msg_hash); - } - fn pack_for_propagation( &self, message: &mut LxMessage, @@ -849,14 +1062,17 @@ impl LxmfOutboundDriver { match result { DeliveryResult::Complete { msg_hash, .. } => { if let Some(hash) = msg_hash { - let was_pn_fallback = self.pn_fallback_attempted.contains(&hash); + let was_local = self.pn_cascade_local.contains(&hash); let pending_deposit = self.pending_pn_deposits.remove(&hash); - let method = if was_pn_fallback || pending_deposit.is_some() { + let was_cascade = self.pn_cascade_tried.contains_key(&hash); + let method = if was_local { + Some("stored_locally") + } else if was_cascade || pending_deposit.is_some() { Some("propagated") } else { None }; - self.pn_fallback_attempted.remove(&hash); + self.clear_pn_cascade_state(hash); self.direct_path_failovers.remove(&hash); let _ = router.mark_outbound_delivered(&hash); if let Some((pn_hash, transient_id)) = pending_deposit { @@ -868,27 +1084,42 @@ impl LxmfOutboundDriver { .map(hex::encode) .unwrap_or_default(), pn_hash = %hex::encode(pn_hash), - pn_fallback = was_pn_fallback, + stored_locally = was_local, "outbound PN deposit Completes" ); } - emit_outbound_status_by_hash(event_tx, &hash, "delivered", method); + // Local-prop is offline inbox — not peer-delivered Complete. + let status = if was_local { + "stored_locally" + } else { + "delivered" + }; + emit_outbound_status_by_hash(event_tx, &hash, status, method); } } DeliveryResult::Rejected { message, reason, .. } => { - if let Some(hash) = message.hash { - self.pending_pn_deposits.remove(&hash); - } + let msg_hash = message.hash.or(message.message_id); + let rejected_pn = msg_hash.and_then(|h| { + self.pending_pn_deposits + .remove(&h) + .map(|(pn, _)| pn) + .or_else(|| self.pending_pn_targets.get(&h).copied()) + }); tracing::warn!( dest = %hex::encode(message.destination_hash), method = %delivery_method_label(message.method), reason = %reason, "LXMF delivery Rejected" ); - // Peer/PN rejected the resource — do not retry; only Direct→PN once. - match self.try_requeue_via_propagation(router, event_tx, message) { + // Peer/PN rejected — advance cascade (next remote or local-prop). + if message.method == DeliveryMethod::Propagated { + if let (Some(hash), Some(pn)) = (msg_hash, rejected_pn) { + self.mark_pn_tried(hash, pn); + } + } + match self.try_advance_pn_cascade(router, event_tx, message) { Ok(()) => {} Err(message) => self.emit_outbound_failed(router, event_tx, *message), } @@ -934,7 +1165,13 @@ impl LxmfOutboundDriver { } else { message }; - match self.try_requeue_via_propagation(router, event_tx, message) { + // Propagated deposit failed after retries — mark this PN tried and advance. + if message.method == DeliveryMethod::Propagated { + if let Some(hash) = message.hash.or(message.message_id) { + self.mark_pn_tried(hash, dest_hash); + } + } + match self.try_advance_pn_cascade(router, event_tx, message) { Ok(()) => {} Err(message) => self.emit_outbound_failed(router, event_tx, *message), } @@ -1063,14 +1300,15 @@ impl LxmfOutboundDriver { "re-queuing Propagated LXMF after retryable link failure" ); if let Some(hash) = msg_hash { + self.pending_pn_targets.insert(hash, prop_hash); // Keep chat UI in sending/propagated while PN rediscovery proceeds. emit_outbound_status_with_via( event_tx, Some(serde_json::Value::String(hex::encode(hash))), None, "sending", - Some("propagated"), - None, + Some(self.cascade_wire_delivery_method(hash)), + Some(hex::encode(prop_hash)), ); } router.send(message); @@ -1135,30 +1373,11 @@ pub(crate) fn choose_lxmf_send_route( } } -/// Whether a failed Direct attempt may be re-queued once via preferred remote PN. -pub(crate) fn should_fallback_direct_to_pn( - method: DeliveryMethod, - preferred_pn: Option<[u8; 16]>, - self_lxmf_hash_hex: &str, - already_fallback: bool, -) -> bool { - if already_fallback || method != DeliveryMethod::Direct { - return false; - } - let Some(pn) = preferred_pn else { - return false; - }; - let pn_hex = hex::encode(pn); - // Local / self PN is an offline inbox — not a network store for unreachable peers. - if pn_hex.eq_ignore_ascii_case(self_lxmf_hash_hex.trim()) { - return false; - } - true -} - /// Cap on retained destination public keys (announce / path flood bound). const MAX_KNOWN_IDENTITIES: usize = 4096; +/// Convenience wrapper around [`emit_outbound_status_with_via`] (hash/to/sent_via from payload). +#[allow(dead_code)] // kept for callers that already hold a full lxmf_message payload pub fn emit_outbound_status( event_tx: &broadcast::Sender, message_payload: &serde_json::Value, @@ -1208,6 +1427,31 @@ fn emit_outbound_status_detailed( sent_via: Option, tried_interfaces: Option>, failover_rounds: Option, +) { + emit_outbound_status_detailed_with_attempts( + event_tx, + message_hash, + to_hash, + status, + delivery_method, + sent_via, + tried_interfaces, + failover_rounds, + None, + ); +} + +#[allow(clippy::too_many_arguments)] // status frame fields travel together +fn emit_outbound_status_detailed_with_attempts( + event_tx: &broadcast::Sender, + message_hash: Option, + to_hash: Option, + status: &str, + delivery_method: Option<&str>, + sent_via: Option, + tried_interfaces: Option>, + failover_rounds: Option, + delivery_attempts: Option, ) { let mut payload = serde_json::Map::new(); if let Some(h) = message_hash { @@ -1232,6 +1476,9 @@ fn emit_outbound_status_detailed( if let Some(rounds) = failover_rounds { payload.insert("failover_rounds".into(), serde_json::json!(rounds)); } + if let Some(attempts) = delivery_attempts { + payload.insert("delivery_attempts".into(), serde_json::json!(attempts)); + } let frame = serde_json::json!({ "type": "lxmf_outbound_status", "payload": payload, @@ -1805,51 +2052,6 @@ mod tests { ); } - #[test] - fn should_fallback_direct_to_pn_when_remote_preferred() { - let remote = [0x47u8; 16]; - assert!(should_fallback_direct_to_pn( - DeliveryMethod::Direct, - Some(remote), - &"aa".repeat(16), - false, - )); - } - - #[test] - fn should_fallback_direct_to_pn_rejects_local_self_pn() { - let self_hash = [0x09u8; 16]; - assert!(!should_fallback_direct_to_pn( - DeliveryMethod::Direct, - Some(self_hash), - &hex::encode(self_hash), - false, - )); - } - - #[test] - fn should_fallback_direct_to_pn_rejects_propagated_and_repeat() { - let remote = [0x47u8; 16]; - assert!(!should_fallback_direct_to_pn( - DeliveryMethod::Propagated, - Some(remote), - &"aa".repeat(16), - false, - )); - assert!(!should_fallback_direct_to_pn( - DeliveryMethod::Direct, - Some(remote), - &"aa".repeat(16), - true, - )); - assert!(!should_fallback_direct_to_pn( - DeliveryMethod::Direct, - None, - &"aa".repeat(16), - false, - )); - } - #[test] fn should_retry_propagated_link_closed_while_attempts_remain() { assert!(should_retry_propagated_link_failure( @@ -1906,6 +2108,118 @@ mod tests { assert_eq!(got_link, link_id); } + #[test] + fn try_advance_pn_cascade_orders_preferred_remote_local_then_exhausts() { + use lxmf_core::constants::DeliveryMethod; + use lxmf_core::message::LxMessage; + use lxmf_core::router::{LxmRouter, RouterConfig}; + use tokio::sync::broadcast; + + let identity = Identity::new(); + let (tx, _rx) = mpsc::channel(32); + let mut driver = LxmfOutboundDriver::new(tx, &identity, "aabb".repeat(8), "me".into()); + let preferred = [0x11u8; 16]; + let next_remote = [0x22u8; 16]; + let local = [0x99u8; 16]; + let dest_hash = dest(0xcd); + let msg_hash = [0x42u8; 32]; + + let mut router = LxmRouter::new(RouterConfig::default()); + let (event_tx, _event_rx) = broadcast::channel(8); + driver.set_propagation_node(&mut router, Some(preferred)); + driver.set_pn_cascade_candidates(vec![ + PnCascadeCandidate { + hash: preferred, + is_local: false, + hops: Some(1), + id: "pn-a".into(), + }, + PnCascadeCandidate { + hash: next_remote, + is_local: false, + hops: Some(2), + id: "pn-b".into(), + }, + PnCascadeCandidate { + hash: local, + is_local: true, + hops: Some(0), + id: "local-prop".into(), + }, + ]); + + let make_direct = || { + let mut msg = LxMessage::new(dest_hash, [1u8; 16], "", "hi", DeliveryMethod::Direct); + msg.hash = Some(msg_hash); + msg + }; + + assert!( + driver + .try_advance_pn_cascade(&mut router, &event_tx, make_direct()) + .is_ok() + ); + assert_eq!(driver.pending_pn_targets.get(&msg_hash), Some(&preferred)); + assert!(!driver.pn_cascade_local.contains(&msg_hash)); + + assert!( + driver + .try_advance_pn_cascade(&mut router, &event_tx, make_direct()) + .is_ok() + ); + assert_eq!(driver.pending_pn_targets.get(&msg_hash), Some(&next_remote)); + assert!(!driver.pn_cascade_local.contains(&msg_hash)); + + assert!( + driver + .try_advance_pn_cascade(&mut router, &event_tx, make_direct()) + .is_ok() + ); + assert_eq!(driver.pending_pn_targets.get(&msg_hash), Some(&local)); + assert!(driver.pn_cascade_local.contains(&msg_hash)); + + let exhausted = driver.try_advance_pn_cascade(&mut router, &event_tx, make_direct()); + assert!(exhausted.is_err(), "cascade must exhaust after local-prop"); + } + + #[test] + fn pn_cascade_source_contract_replaces_one_shot_fallback() { + let src = include_str!("lxmf_outbound.rs"); + assert!( + src.contains("fn try_advance_pn_cascade"), + "outbound driver must advance multi-PN cascade after Direct exhaust" + ); + assert!( + src.contains("match self.try_advance_pn_cascade"), + "delivery fail paths must call try_advance_pn_cascade" + ); + assert!( + src.contains("stored_locally"), + "local-prop cascade step must emit stored_locally" + ); + assert!( + src.contains("PN_DEPOSIT_DEFER_ADVANCE_AFTER"), + "sync/pending PN-link deferral must eventually advance cascade" + ); + assert!( + src.contains("pending_pn_targets") && src.contains("apply_pending_pn_targets"), + "per-message PN targets must rewrite DeliverPropagated.prop_hash" + ); + assert!( + src.contains("pack_for_propagation failed — advancing PN cascade"), + "pack failure must advance cascade instead of bare requeue" + ); + assert!( + src.contains("propagated delivery attempt budget reached — advancing PN cascade"), + "max delivery attempts must advance cascade instead of bare requeue" + ); + let legacy_one_shot = concat!("should_fallback_", "direct_to_pn"); + assert!( + !src.contains(legacy_one_shot), + "one-shot Direct→PN helper must be removed; live path uses PN cascade" + ); + } + #[test] fn outbound_source_exposes_inbound_packet_sender_adapter() { let src = include_str!("lxmf_outbound.rs"); diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index fcffc31b4..c0feabdff 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -1237,6 +1237,14 @@ impl StackHandle { if let Some(live) = &self.live { live.set_outbound_propagation_node(prop_hash.as_deref()) .await; + live.refresh_pn_cascade_candidates().await; + if prop_hash.is_none() { + tracing::warn!( + target: "lxmf-outbound", + preferred_id = %id, + "set_preferred_propagation: preferred row has no destination_hash" + ); + } } Ok(()) } @@ -1347,9 +1355,15 @@ impl StackHandle { live.set_local_propagation_serving(enabled).await; } } - let mut inner = self.inner.write().await; - inner.set_propagation_enabled(id, enabled)?; - inner.save(&self.config_dir, &self.storage_dir)?; + { + let mut inner = self.inner.write().await; + inner.set_propagation_enabled(id, enabled)?; + inner.save(&self.config_dir, &self.storage_dir)?; + } + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + live.refresh_pn_cascade_candidates().await; + } Ok(()) } @@ -1407,6 +1421,11 @@ impl StackHandle { } } inner.save(&self.config_dir, &self.storage_dir)?; + drop(inner); + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + live.refresh_pn_cascade_candidates().await; + } Ok(serde_json::json!({ "ok": true, "node": row })) } @@ -1447,6 +1466,10 @@ impl StackHandle { live.set_outbound_propagation_node(None).await; } } + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + live.refresh_pn_cascade_candidates().await; + } Ok(()) } diff --git a/reticulum-sidecar/src/stack/pn_cascade.rs b/reticulum-sidecar/src/stack/pn_cascade.rs new file mode 100644 index 000000000..b98405714 --- /dev/null +++ b/reticulum-sidecar/src/stack/pn_cascade.rs @@ -0,0 +1,300 @@ +//! Multi-PN outbound cascade after Direct path failover exhausts. +//! +//! Order: preferred remote → other enabled remotes (hops asc) → local-prop last. + +use std::collections::HashSet; + +/// One configured PN eligible for Direct→Propagated cascade. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PnCascadeCandidate { + pub hash: [u8; 16], + /// True for local-prop / self LXMF hash (offline inbox — last resort only). + pub is_local: bool, + pub hops: Option, + pub id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PnCascadePick { + /// Deposit via a remote propagation node. + Remote([u8; 16]), + /// Deposit into local-prop (offline inbox; not peer-delivered). + Local([u8; 16]), + /// No remaining candidates. + Exhausted, +} + +impl PnCascadePick { + pub fn hash(self) -> Option<[u8; 16]> { + match self { + PnCascadePick::Remote(h) | PnCascadePick::Local(h) => Some(h), + PnCascadePick::Exhausted => None, + } + } + + pub fn is_local(self) -> bool { + matches!(self, PnCascadePick::Local(_)) + } + + pub fn delivery_method_label(self) -> Option<&'static str> { + match self { + PnCascadePick::Remote(_) => Some("propagated"), + PnCascadePick::Local(_) => Some("stored_locally"), + PnCascadePick::Exhausted => None, + } + } +} + +/// Build an ordered cascade list from persisted propagation rows. +/// +/// `preferred_hash` (when Some) is tried first among remotes; local is always last +/// when present and enabled. +pub fn build_pn_cascade_order( + candidates: &[PnCascadeCandidate], + preferred_hash: Option<[u8; 16]>, +) -> Vec { + 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); + ah.cmp(&bh).then_with(|| a.id.cmp(&b.id)) + }); + // Only reorder among enabled candidates — never synthesize a disabled/stale preferred. + if let Some(pref) = preferred_hash { + if let Some(idx) = remotes.iter().position(|c| c.hash == pref) { + let preferred = remotes.remove(idx); + remotes.insert(0, preferred); + } + } + let mut out = remotes; + if let Some(local) = candidates.iter().find(|c| c.is_local).cloned() { + out.push(local); + } + out +} + +/// Pick the next untried PN from an ordered cascade. +pub fn pick_next_pn_cascade( + ordered: &[PnCascadeCandidate], + tried: &HashSet<[u8; 16]>, +) -> PnCascadePick { + for c in ordered { + if tried.contains(&c.hash) { + continue; + } + if c.is_local { + return PnCascadePick::Local(c.hash); + } + return PnCascadePick::Remote(c.hash); + } + PnCascadePick::Exhausted +} + +/// Whether Direct failure may enter the PN cascade (any untried candidate remains). +pub fn cascade_has_capacity(ordered: &[PnCascadeCandidate], tried: &HashSet<[u8; 16]>) -> bool { + !matches!( + pick_next_pn_cascade(ordered, tried), + PnCascadePick::Exhausted + ) +} + +/// True when `hash_hex` equals self LXMF destination (case-insensitive). +pub fn is_self_lxmf_hash(hash: &[u8; 16], self_lxmf_hash_hex: &str) -> bool { + hex::encode(hash).eq_ignore_ascii_case(self_lxmf_hash_hex.trim()) +} + +/// Parse enabled propagation rows into cascade candidates. +/// +/// Local-prop eligibility uses the row `enabled` flag only (single source of truth). +/// Local-prop hash must be the lxmf.propagation destination — never fall back to self LXMF. +pub fn candidates_from_propagation_rows( + rows: &[(String, bool, Option, Option)], + self_lxmf_hash_hex: &str, +) -> Vec { + let self_norm = self_lxmf_hash_hex.trim().to_lowercase(); + let mut out = Vec::new(); + for (id, enabled, dest_hash, hops) in rows { + if id == "local-prop" { + if !*enabled { + continue; + } + // Require the real lxmf.propagation dest — self LXMF is Nomad/delivery identity. + let Some(hash) = dest_hash.as_ref().and_then(|h| parse_hash16(h)) else { + continue; + }; + out.push(PnCascadeCandidate { + hash, + is_local: true, + hops: *hops, + id: id.clone(), + }); + continue; + } + if !*enabled { + continue; + } + let Some(hash) = dest_hash.as_ref().and_then(|h| parse_hash16(h)) else { + continue; + }; + if is_self_lxmf_hash(&hash, &self_norm) { + continue; + } + out.push(PnCascadeCandidate { + hash, + is_local: false, + hops: *hops, + id: id.clone(), + }); + } + out +} + +fn parse_hash16(hex_str: &str) -> Option<[u8; 16]> { + let clean: String = hex_str.chars().filter(char::is_ascii_hexdigit).collect(); + if clean.len() != 32 { + return None; + } + let bytes = hex::decode(&clean).ok()?; + let arr: [u8; 16] = bytes.try_into().ok()?; + Some(arr) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn remote(hash_byte: u8, hops: Option, id: &str) -> PnCascadeCandidate { + PnCascadeCandidate { + hash: [hash_byte; 16], + is_local: false, + hops, + id: id.into(), + } + } + + fn local(hash_byte: u8) -> PnCascadeCandidate { + PnCascadeCandidate { + hash: [hash_byte; 16], + is_local: true, + hops: Some(0), + id: "local-prop".into(), + } + } + + #[test] + fn order_preferred_first_then_hops_then_local() { + let candidates = vec![ + remote(0x22, Some(4), "pn-far"), + remote(0x11, Some(1), "pn-near"), + local(0x99), + ]; + let preferred = [0x22; 16]; + let ordered = build_pn_cascade_order(&candidates, Some(preferred)); + assert_eq!(ordered[0].hash, preferred); + assert_eq!(ordered[1].id, "pn-near"); + assert!(ordered.last().is_some_and(|c| c.is_local)); + } + + #[test] + fn pick_skips_tried_and_ends_on_local() { + let ordered = build_pn_cascade_order( + &[ + remote(0x11, Some(1), "a"), + remote(0x22, Some(2), "b"), + local(0x99), + ], + Some([0x11; 16]), + ); + let mut tried = HashSet::new(); + assert_eq!( + pick_next_pn_cascade(&ordered, &tried), + PnCascadePick::Remote([0x11; 16]) + ); + tried.insert([0x11; 16]); + assert_eq!( + pick_next_pn_cascade(&ordered, &tried), + PnCascadePick::Remote([0x22; 16]) + ); + tried.insert([0x22; 16]); + assert_eq!( + pick_next_pn_cascade(&ordered, &tried), + PnCascadePick::Local([0x99; 16]) + ); + tried.insert([0x99; 16]); + assert_eq!( + pick_next_pn_cascade(&ordered, &tried), + PnCascadePick::Exhausted + ); + } + + #[test] + fn cascade_capacity_false_when_exhausted() { + let ordered = build_pn_cascade_order(&[remote(0x11, None, "a")], None); + let mut tried = HashSet::new(); + tried.insert([0x11; 16]); + assert!(!cascade_has_capacity(&ordered, &tried)); + assert!(cascade_has_capacity(&ordered, &HashSet::new())); + } + + #[test] + fn candidates_from_rows_skips_disabled_and_self_remote() { + let self_hex = "aa".repeat(16); + let prop_dest = "dd".repeat(16); + let rows = vec![ + ("pn-a".into(), true, Some("bb".repeat(16)), Some(1u8)), + ("pn-self".into(), true, Some(self_hex.clone()), Some(0u8)), + ("pn-off".into(), false, Some("cc".repeat(16)), None), + ("local-prop".into(), true, Some(prop_dest.clone()), Some(0)), + ]; + let c = candidates_from_propagation_rows(&rows, &self_hex); + assert_eq!(c.iter().filter(|x| !x.is_local).count(), 1); + assert_eq!(c.iter().filter(|x| x.is_local).count(), 1); + assert_eq!( + hex::encode(c.iter().find(|x| x.is_local).unwrap().hash), + prop_dest + ); + } + + #[test] + fn candidates_skip_disabled_local_and_missing_prop_dest() { + let self_hex = "aa".repeat(16); + let rows = vec![ + ("local-prop".into(), false, Some("dd".repeat(16)), Some(0)), + ("local-prop".into(), true, None, Some(0)), + ]; + let c = candidates_from_propagation_rows(&rows, &self_hex); + assert!(c.is_empty()); + } + + #[test] + fn delivery_method_labels() { + assert_eq!( + PnCascadePick::Remote([0; 16]).delivery_method_label(), + Some("propagated") + ); + assert_eq!( + PnCascadePick::Local([0; 16]).delivery_method_label(), + Some("stored_locally") + ); + assert_eq!(PnCascadePick::Exhausted.delivery_method_label(), None); + } + + #[test] + fn order_skips_preferred_not_in_enabled_list() { + let candidates = vec![remote(0x11, Some(1), "pn-a"), local(0x99)]; + let stale_preferred = [0xee; 16]; + let ordered = build_pn_cascade_order(&candidates, Some(stale_preferred)); + assert_eq!(ordered[0].hash, [0x11; 16]); + assert!(!ordered.iter().any(|c| c.hash == stale_preferred)); + } + + #[test] + fn is_self_lxmf_hash_case_insensitive() { + let hash = [0xaa; 16]; + let hex = hex::encode(hash); + assert!(is_self_lxmf_hash(&hash, &hex)); + assert!(is_self_lxmf_hash(&hash, &hex.to_uppercase())); + assert!(!is_self_lxmf_hash(&hash, &"bb".repeat(16))); + } +} diff --git a/src/main/ipc/reticulum-handlers.ts b/src/main/ipc/reticulum-handlers.ts index 2bb2c115f..62235df37 100644 --- a/src/main/ipc/reticulum-handlers.ts +++ b/src/main/ipc/reticulum-handlers.ts @@ -37,17 +37,28 @@ import { import type { ReticulumSidecarManager } from '../reticulum-sidecar-manager'; import { parseEnabledInterfaceNames } from '../reticulumInterfaceIssueScope'; import { assertIpcSender } from '../validate-ipc-sender'; +import { isLxmfRecentApiPath } from './reticulumLxmfRecentPath'; /** Shared rolling window for all reticulum proxy verbs (Get/Post/Put/Delete). */ const reticulumProxyIpcRateLimit = createIpcRateLimiter({ - max: 300, + max: 900, windowMs: MS_PER_MINUTE, label: 'reticulum:proxy', }); +/** + * Inbound LXMF catch-up (`GET /api/v1/lxmf/recent`). Own bucket so WS-lag recovery + * cannot be starved by peer/interface polls — and cannot monopolize the shared ceiling. + */ +const reticulumLxmfRecentIpcRateLimit = createIpcRateLimiter({ + max: 120, + windowMs: MS_PER_MINUTE, + label: 'reticulum:lxmfRecent', +}); + /** * Realtime LXST PCM ingest: QualityHigh is ~16.7 frames/s (~1000/min). - * Separate from the shared 300/min proxy bucket so calls do not starve mesh control IPC. + * Separate from the shared 900/min proxy bucket so calls do not starve mesh control IPC. */ const reticulumVoiceAudioIpcRateLimit = createIpcRateLimiter({ max: 2000, @@ -57,7 +68,7 @@ const reticulumVoiceAudioIpcRateLimit = createIpcRateLimiter({ /** * LRGP games control/poll traffic. Own bucket so session polls + moves do not - * starve the shared 300/min reticulum proxy ceiling. + * starve the shared 900/min reticulum proxy ceiling. */ const reticulumGamesIpcRateLimit = createIpcRateLimiter({ max: 600, @@ -213,11 +224,15 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { ipcMain.handle('reticulum:proxyGet', async (event, apiPath: unknown) => { assertIpcSender(event, 'reticulum:proxyGet'); - reticulumProxyIpcRateLimit.checkOrThrow(); const pathArg = assertProxyApiPath(apiPath); if (isGamesApiPath(pathArg)) { throw new Error('LRGP games require reticulum:games* IPC channels'); } + if (isLxmfRecentApiPath(pathArg)) { + reticulumLxmfRecentIpcRateLimit.checkOrThrow(); + } else { + reticulumProxyIpcRateLimit.checkOrThrow(); + } try { const m = ensureManager(); return await m.proxyGet(pathArg); @@ -252,7 +267,7 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { }); /** - * Realtime LXST PCM frames. Uses a dedicated rate limit (not the shared 300/min + * Realtime LXST PCM frames. Uses a dedicated rate limit (not the shared 900/min * proxy ceiling) so voice TX does not starve control-plane proxy IPC. */ ipcMain.handle('reticulum:voiceSendAudio', async (event, opts: unknown) => { diff --git a/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts b/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts index ab1c4f598..20f54aca9 100644 --- a/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts +++ b/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts @@ -3,6 +3,8 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import { describe, expect, it } from 'vitest'; +import { isLxmfRecentApiPath } from './reticulumLxmfRecentPath'; + const HANDLERS_SOURCE = readFileSync(join(__dirname, 'reticulum-handlers.ts'), 'utf-8'); const SIDECAR_STACK_SOURCE = readFileSync( join(__dirname, '../../../reticulum-sidecar/src/stack/mod.rs'), @@ -14,8 +16,8 @@ const SIDECAR_LIVE_SOURCE = readFileSync( ); describe('reticulum proxy rate limit + 100k peer ceilings (source contract)', () => { - it('caps shared proxy IPC at 300/min and treats rate-limit as expected', () => { - expect(HANDLERS_SOURCE).toMatch(/max:\s*300/); + it('caps shared proxy IPC at 900/min and treats rate-limit as expected', () => { + expect(HANDLERS_SOURCE).toMatch(/max:\s*900/); expect(HANDLERS_SOURCE).toContain("label: 'reticulum:proxy'"); expect(HANDLERS_SOURCE).toContain('isExpectedReticulumProxyError'); expect(HANDLERS_SOURCE).toContain("from '../../shared/reticulumProxyIpcError'"); @@ -26,9 +28,20 @@ describe('reticulum proxy rate limit + 100k peer ceilings (source contract)', () expect(sharedSource).toContain("lower.includes('rate limit exceeded')"); }); + it('routes LXMF recent catch-up onto a dedicated 120/min bucket', () => { + expect(HANDLERS_SOURCE).toMatch( + /const reticulumLxmfRecentIpcRateLimit = createIpcRateLimiter\(\{\s*max:\s*120,[\s\S]*?label:\s*'reticulum:lxmfRecent'/, + ); + expect(HANDLERS_SOURCE).toContain('isLxmfRecentApiPath'); + expect(HANDLERS_SOURCE).toContain('reticulumLxmfRecentIpcRateLimit.checkOrThrow()'); + expect(isLxmfRecentApiPath('/api/v1/lxmf/recent')).toBe(true); + expect(isLxmfRecentApiPath('/api/v1/lxmf/recent?since_ts=1')).toBe(true); + expect(isLxmfRecentApiPath('/api/v1/lxmf/send')).toBe(false); + }); + it('applies the shared proxy rate limit to picker-gated RNCP handlers', () => { // Dedicated rncpSend/Fetch/setRncpListener bypass generic proxyPost gating but must - // still share the 300/min ceiling so a compromised renderer cannot storm the sidecar. + // still share the 900/min ceiling so a compromised renderer cannot storm the sidecar. for (const channel of [ 'reticulum:rncpSend', 'reticulum:rncpFetch', diff --git a/src/main/ipc/reticulumLxmfRecentPath.ts b/src/main/ipc/reticulumLxmfRecentPath.ts new file mode 100644 index 000000000..6bc487856 --- /dev/null +++ b/src/main/ipc/reticulumLxmfRecentPath.ts @@ -0,0 +1,7 @@ +import { RETICULUM_LXMF_RECENT_API_PATH } from '../../shared/reticulumApiPaths'; + +/** Path-only match for LXMF recent catch-up (query string ignored). */ +export function isLxmfRecentApiPath(apiPath: string): boolean { + const pathOnly = apiPath.split('?', 1)[0] ?? apiPath; + return pathOnly === RETICULUM_LXMF_RECENT_API_PATH; +} diff --git a/src/main/reticulum-proxy-path.ts b/src/main/reticulum-proxy-path.ts index a98542f86..c82b9bab1 100644 --- a/src/main/reticulum-proxy-path.ts +++ b/src/main/reticulum-proxy-path.ts @@ -1,3 +1,4 @@ +import { RETICULUM_LXMF_RECENT_API_PATH } from '../shared/reticulumApiPaths'; import { nomadPageProxyTimeoutMsFromApiPath } from '../shared/reticulumNomadTimeouts'; /** Allowed Reticulum sidecar HTTP paths for renderer IPC proxy. */ @@ -16,7 +17,7 @@ const TRANSPORT_QUERY_GET_PATHS = [ '/api/v1/interfaces', '/api/v1/topology', '/api/v1/packets', - '/api/v1/lxmf/recent', + RETICULUM_LXMF_RECENT_API_PATH, ] as const; function isReticulumTransportQueryGetPath(normalized: string): boolean { diff --git a/src/main/support-bundle.test.ts b/src/main/support-bundle.test.ts index f0e56d008..2d133215f 100644 --- a/src/main/support-bundle.test.ts +++ b/src/main/support-bundle.test.ts @@ -42,6 +42,7 @@ import { app } from 'electron'; import { buildSupportBundleZip, defaultSupportBundleFilename, + extractLxmfOutboundLogSlice, isSupportBundleMode, readReticulumDeveloperArtifacts, redactMnemonicFromStackJson, @@ -98,6 +99,40 @@ describe('defaultSupportBundleFilename', () => { }); }); +describe('extractLxmfOutboundLogSlice', () => { + it('keeps LXMF outbound / PN cascade lines and drops unrelated noise', () => { + const chunk = Buffer.from( + [ + 'info hello world', + 'info target=lxmf-outbound LXMF advancing PN cascade', + 'warn DeliverPropagated: deferring — PN link busy', + 'debug peer refresh ok', + 'info target=propagation-deposit outbound PN deposit Completes', + 'info target=propagation-retrieve sync transfer progress', + ].join('\n'), + 'utf8', + ); + const slice = extractLxmfOutboundLogSlice(chunk).toString('utf8'); + expect(slice).toContain('LXMF advancing PN cascade'); + expect(slice).toContain('DeliverPropagated'); + expect(slice).toContain('propagation-deposit'); + expect(slice).toContain('propagation-retrieve'); + expect(slice).not.toContain('hello world'); + expect(slice).not.toContain('peer refresh ok'); + }); + + it('truncates long hex ids in kept lines', () => { + const dest = 'ab'.repeat(16); + const chunk = Buffer.from( + `info target=lxmf-outbound dest=${dest} LXMF advancing PN cascade\n`, + 'utf8', + ); + const slice = extractLxmfOutboundLogSlice(chunk).toString('utf8'); + expect(slice).toContain('dest=abababab…'); + expect(slice).not.toContain(dest); + }); +}); + describe('redactMnemonicFromStackJson', () => { it('removes identity.mnemonic from stack JSON', () => { const raw = JSON.stringify({ diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index cc018a8d4..4780fc325 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -170,6 +170,7 @@ Contents: mesh-client.db — SQLite database backup (contains secrets) reticulum/config — rnsd interface config (if present) reticulum/mesh_client_stack.json — Sidecar stack state, mnemonic redacted (if present) + reticulum/lxmf-outbound.log — Filtered LXMF outbound / PN cascade lines from app logs mesh-client.log — Application log (current session) mesh-client.log.1 — Prior session log (preserved on restart) or size-rotated backup manifest.json — App version, buildChannel, and platform metadata @@ -177,6 +178,38 @@ Contents: `; } +/** Truncate long hex ids in exported log lines (keep triage prefix only). */ +function redactLxmfOutboundLogLine(line: string): string { + return line.replace(/\b([0-9a-fA-F]{16,})\b/g, (hex) => `${hex.slice(0, 8)}…`); +} + +/** Extract LXMF outbound / PN cascade diagnostic lines for developer triage. */ +export function extractLxmfOutboundLogSlice(...logChunks: Buffer[]): Buffer { + const patterns = [ + /lxmf-outbound/i, + /propagation-deposit/i, + /propagation-retrieve/i, + /LXMF advancing PN cascade/i, + /LXMF outbound delivery failed/i, + /Direct path failover/i, + /PN cascade/i, + /DeliverPropagated/i, + ]; + const lines: string[] = []; + for (const chunk of logChunks) { + if (!chunk.length) continue; + const text = chunk.toString('utf8'); + for (const line of text.split(/\r?\n/)) { + if (patterns.some((re) => re.test(line))) { + lines.push(redactLxmfOutboundLogLine(line)); + } + } + } + // Cap slice so huge logs cannot bloat the zip. + const capped = lines.length > 4000 ? lines.slice(-4000) : lines; + return Buffer.from(capped.join('\n') + (capped.length ? '\n' : ''), 'utf8'); +} + async function readFileOrEmpty(filePath: string): Promise { try { return await fs.promises.readFile(filePath); @@ -251,14 +284,14 @@ export async function buildSupportBundleZip( const logPath = getLogPath(); const logDir = path.dirname(logPath); - zip.file('mesh-client.log', await readFileOrEmpty(logPath)); + const currentLog = await readFileOrEmpty(logPath); + zip.file('mesh-client.log', currentLog); const backupPath = path.join(logDir, LOG_BACKUP_FILENAME); + let backupLog: Buffer = Buffer.alloc(0); if (fs.existsSync(backupPath)) { - zip.file( - LOG_BACKUP_FILENAME, - await readFileTailOrEmpty(backupPath, MAX_SUPPORT_BUNDLE_LOG_BACKUP_BYTES), - ); + backupLog = await readFileTailOrEmpty(backupPath, MAX_SUPPORT_BUNDLE_LOG_BACKUP_BYTES); + zip.file(LOG_BACKUP_FILENAME, backupLog); } zip.file('manifest.json', JSON.stringify(buildManifest(mode), null, 2)); @@ -283,6 +316,10 @@ export async function buildSupportBundleZip( if (reticulumArtifacts.stackJson) { zip.file('reticulum/mesh_client_stack.json', reticulumArtifacts.stackJson); } + const lxmfSlice = extractLxmfOutboundLogSlice(backupLog, currentLog); + if (lxmfSlice.length > 0) { + zip.file('reticulum/lxmf-outbound.log', lxmfSlice); + } } const buf = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); diff --git a/src/renderer/components/ReticulumMessageStatusBadge.test.tsx b/src/renderer/components/ReticulumMessageStatusBadge.test.tsx index 7f608970b..7d08de34e 100644 --- a/src/renderer/components/ReticulumMessageStatusBadge.test.tsx +++ b/src/renderer/components/ReticulumMessageStatusBadge.test.tsx @@ -68,4 +68,41 @@ describe('ReticulumMessageStatusBadge', () => { screen.getByLabelText('chatPanel.sentViaPropagation: chatPanel.reticulumSendPropagated'), ).toBeTruthy(); }); + + it('shows PN with house icon for local-prop stored_locally (not green check)', async () => { + await renderAndAssertAxe( + , + ); + expect( + screen.getByLabelText( + 'chatPanel.sentViaLocalPropagation: chatPanel.reticulumSendStoredLocally', + ), + ).toBeTruthy(); + // Label PN + house emoji — not a delivery checkmark. + expect(screen.getByText(/reticulumPnAbbrev\s+\u{1F3E0}/u)).toBeTruthy(); + expect(screen.queryByText(/✓/)).toBeNull(); + }); + + it('shows storing-locally tooltip while sending with stored_locally', async () => { + await renderAndAssertAxe( + , + ); + expect( + screen.getByLabelText( + 'chatPanel.sentViaLocalPropagation: chatPanel.reticulumSendStoringLocally', + ), + ).toBeTruthy(); + expect(screen.getByText(/reticulumPnAbbrev\s+\u{1F3E0}/u)).toBeTruthy(); + }); + + it('shows red X (not house) for failed stored_locally', async () => { + await renderAndAssertAxe( + , + ); + expect( + screen.getByLabelText('chatPanel.sentViaLocalPropagation: chatPanel.reticulumSendFailed'), + ).toBeTruthy(); + expect(screen.getByText(/reticulumPnAbbrev\s+\u2717/)).toBeTruthy(); + expect(screen.queryByText(/\u{1F3E0}/u)).toBeNull(); + }); }); diff --git a/src/renderer/components/ReticulumMessageStatusBadge.tsx b/src/renderer/components/ReticulumMessageStatusBadge.tsx index 1133a4177..f3ff6c8c2 100644 --- a/src/renderer/components/ReticulumMessageStatusBadge.tsx +++ b/src/renderer/components/ReticulumMessageStatusBadge.tsx @@ -8,6 +8,7 @@ import { type ReticulumVia, } from '@/renderer/lib/reticulum/classifyReticulumVia'; import type { MessageRecord, MessageTransport } from '@/renderer/stores/messageStore'; +import { isPnCascadeDeliveryMethod } from '@/shared/reticulumDeliveryMethod'; export interface ReticulumMessageStatusBadgeProps { status: 'sending' | 'acked' | 'failed'; @@ -18,6 +19,9 @@ export interface ReticulumMessageStatusBadgeProps { type OutboundStatus = ReticulumMessageStatusBadgeProps['status']; +/** House mark for local-prop (own PN) offline storage — not a peer-delivery check. */ +const LOCAL_PN_HOUSE_ICON = '\u{1F3E0}'; + function tooltipKeyForVia(via: ReticulumVia | undefined): string { switch (via) { case 'rf': @@ -33,7 +37,14 @@ function tooltipKeyForVia(via: ReticulumVia | undefined): string { } } -function statusIcon(status: OutboundStatus): string { +function statusIcon( + status: OutboundStatus, + deliveryMethod: MessageRecord['reticulumDeliveryMethod'] | undefined, +): string { + // Local-prop cascade last resort: show house instead of green check / red X. + if (deliveryMethod === 'stored_locally' && status !== 'failed') { + return LOCAL_PN_HOUSE_ICON; + } switch (status) { case 'sending': return '\u23F3'; @@ -44,7 +55,13 @@ function statusIcon(status: OutboundStatus): string { } } -function statusColorClass(status: OutboundStatus): string { +function statusColorClass( + status: OutboundStatus, + deliveryMethod: MessageRecord['reticulumDeliveryMethod'] | undefined, +): string { + if (deliveryMethod === 'stored_locally' && status !== 'failed') { + return 'text-amber-400'; + } switch (status) { case 'sending': return 'text-muted'; @@ -63,11 +80,17 @@ function statusLabelText( ): string { switch (status) { case 'sending': + if (deliveryMethod === 'stored_locally') { + return t('chatPanel.reticulumSendStoringLocally'); + } if (deliveryMethod === 'propagated') { return t('chatPanel.reticulumSendPropagated'); } return t('chatPanel.reticulumSendSending'); case 'acked': + if (deliveryMethod === 'stored_locally') { + return t('chatPanel.reticulumSendStoredLocally'); + } if (deliveryMethod === 'propagated') { return t('chatPanel.reticulumSendStoredAtPn'); } @@ -86,6 +109,9 @@ function viaPrefixText( atoms: ReticulumVia[], viasLabel: string, ): string { + if (deliveryMethod === 'stored_locally') { + return t('chatPanel.sentViaLocalPropagation'); + } if (deliveryMethod === 'propagated') { return t('chatPanel.sentViaPropagation'); } @@ -107,12 +133,11 @@ export function ReticulumMessageStatusBadge({ const { t } = useTranslation(); const atoms = parseReticulumViaAtoms(via); const viasLabel = formatReticulumViaBadgeLabel(via ?? 'network'); - const label = - deliveryMethod === 'propagated' - ? t('chatPanel.reticulumPnAbbrev') - : deliveryMethod === 'paper' - ? t('chatPanel.reticulumSendPaper') - : viasLabel; + const label = isPnCascadeDeliveryMethod(deliveryMethod) + ? t('chatPanel.reticulumPnAbbrev') + : deliveryMethod === 'paper' + ? t('chatPanel.reticulumSendPaper') + : viasLabel; const statusLabel = statusLabelText(t, status, deliveryMethod, error); const viaPrefix = viaPrefixText(t, deliveryMethod, atoms, viasLabel); // Completed paper: paper-only prefix. Failed/sending paper keep status suffix (incl. error text). @@ -121,8 +146,8 @@ export function ReticulumMessageStatusBadge({ return ( ); diff --git a/src/renderer/lib/ingest/reticulumIngest.ts b/src/renderer/lib/ingest/reticulumIngest.ts index 5900f1032..97f954a52 100644 --- a/src/renderer/lib/ingest/reticulumIngest.ts +++ b/src/renderer/lib/ingest/reticulumIngest.ts @@ -398,6 +398,10 @@ export function persistReticulumOutboundRecord( ...(record.reticulumDeliveryMethod ? { delivery_method: record.reticulumDeliveryMethod } : {}), + ...(typeof record.reticulumDeliveryAttempts === 'number' && + Number.isFinite(record.reticulumDeliveryAttempts) + ? { delivery_attempts: Math.trunc(record.reticulumDeliveryAttempts) } + : {}), }) .catch((e: unknown) => { console.warn('[reticulumIngest] save outbound ' + errLikeToLogString(e)); diff --git a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts index af5b64498..8cf379216 100644 --- a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts +++ b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts @@ -27,8 +27,9 @@ describe('applyReticulumOutboundDeliveryStatus', () => { window.electronAPI = createElectronAPIMock(); }); - it('maps delivered/failed/sending; drops unknown wire statuses', () => { + it('maps delivered/failed/sending/stored_locally; drops unknown wire statuses', () => { expect(mapLxmfOutboundWireStatus('delivered')).toBe('acked'); + expect(mapLxmfOutboundWireStatus('stored_locally')).toBe('acked'); expect(mapLxmfOutboundWireStatus('failed')).toBe('failed'); expect(mapLxmfOutboundWireStatus('sending')).toBe('sending'); expect(mapLxmfOutboundWireStatus('queued')).toBeNull(); @@ -368,6 +369,110 @@ describe('applyReticulumOutboundDeliveryStatus', () => { ); }); + it('revives Failed to sending for stored_locally cascade after link-timeout bridge', () => { + const toNodeId = reticulumHashToNodeId(DEST); + const selfNodeId = reticulumHashToNodeId(SELF); + registerReticulumDestinationHash(toNodeId, DEST); + registerReticulumDestinationHash(selfNodeId, SELF); + useMessageStore.setState({ + messages: { + [identityId]: { + [messageHash]: { + id: messageHash, + from: selfNodeId, + to: toNodeId, + senderName: 'Me', + payload: 'race', + channelIndex: 0, + timestamp: Date.now(), + status: 'failed', + error: 'Failed to send', + reticulumMessageHash: messageHash, + reticulumSenderHash: SELF, + reticulumDeliveryMethod: 'direct', + }, + }, + }, + }); + + applyReticulumOutboundDeliveryStatus(identityId, messageHash, 'sending', { + deliveryMethod: 'stored_locally', + deliveryAttempts: 3, + }); + + const row = useMessageStore.getState().messages[identityId][messageHash]; + expect(row.status).toBe('sending'); + expect(row.reticulumDeliveryMethod).toBe('stored_locally'); + expect(row.reticulumDeliveryAttempts).toBe(3); + expect(row.error).toBeUndefined(); + }); + + it('buffers deliveryAttempts for pending-before-rekey flush', () => { + const pendingId = 'reticulum-pending-attempts'; + const toNodeId = reticulumHashToNodeId(DEST); + const selfNodeId = reticulumHashToNodeId(SELF); + registerReticulumDestinationHash(toNodeId, DEST); + registerReticulumDestinationHash(selfNodeId, SELF); + useMessageStore.setState({ + messages: { + [identityId]: { + [pendingId]: { + id: pendingId, + from: selfNodeId, + to: toNodeId, + payload: 'race', + channelIndex: 0, + timestamp: Date.now(), + status: 'sending', + reticulumSenderHash: SELF, + }, + }, + }, + }); + + applyReticulumOutboundDeliveryStatus(identityId, messageHash, 'sending', { + deliveryMethod: 'propagated', + deliveryAttempts: 4, + }); + renameMessageId(identityId, pendingId, messageHash); + expect(flushPendingReticulumOutboundDeliveryStatus(identityId, messageHash)).toBe(true); + expect( + useMessageStore.getState().messages[identityId][messageHash].reticulumDeliveryAttempts, + ).toBe(4); + }); + + it('clamps delivery_attempts when patching outbound status', () => { + const toNodeId = reticulumHashToNodeId(DEST); + const selfNodeId = reticulumHashToNodeId(SELF); + registerReticulumDestinationHash(toNodeId, DEST); + registerReticulumDestinationHash(selfNodeId, SELF); + useMessageStore.setState({ + messages: { + [identityId]: { + [messageHash]: { + id: messageHash, + from: selfNodeId, + to: toNodeId, + payload: 'x', + channelIndex: 0, + timestamp: Date.now(), + status: 'sending', + reticulumMessageHash: messageHash, + reticulumSenderHash: SELF, + }, + }, + }, + }); + + applyReticulumOutboundDeliveryStatus(identityId, messageHash, 'sending', { + deliveryMethod: 'direct', + deliveryAttempts: 999, + }); + expect( + useMessageStore.getState().messages[identityId][messageHash].reticulumDeliveryAttempts, + ).toBe(64); + }); + it('drops invalid message_hash and unknown wire status', () => { useMessageStore.setState({ messages: { diff --git a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts index 8a1e7cf32..92d7437cc 100644 --- a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts +++ b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts @@ -21,16 +21,26 @@ import { useMessageStore, } from '@/renderer/stores/messageStore'; import { reticulumHashForNodeId } from '@/renderer/stores/reticulumPeerStore'; -import { parseReticulumDeliveryMethod } from '@/shared/reticulumDeliveryMethod'; +import { + isPnCascadeDeliveryMethod, + parseReticulumDeliveryMethod, +} from '@/shared/reticulumDeliveryMethod'; + +/** Cap for sidecar `delivery_attempts` before store/SQLite patch. */ +export const MAX_RETICULUM_DELIVERY_ATTEMPTS = 64; /** Map sidecar `lxmf_outbound_status` wire status to UI store status. Unknown → null. */ export function mapLxmfOutboundWireStatus(wireStatus: string): MessageStatus | null { - if (wireStatus === 'delivered') return 'acked'; + if (wireStatus === 'delivered' || wireStatus === 'stored_locally') return 'acked'; if (wireStatus === 'failed') return 'failed'; if (wireStatus === 'sending') return 'sending'; return null; } +function clampDeliveryAttempts(value: number): number { + return Math.min(MAX_RETICULUM_DELIVERY_ATTEMPTS, Math.max(0, Math.trunc(value))); +} + /** Resolve LXMF peer dest hash from a chat node id (peer store, then dest registry). */ export function resolveReticulumOutboundDestHash( toNodeId: number | undefined | null, @@ -66,7 +76,13 @@ const PENDING_DELIVERY_STATUS_TTL_MS = 60_000; const PENDING_DELIVERY_STATUS_MAX = 64; const pendingDeliveryByKey = new Map< string, - { wireStatus: string; sentVia?: string; deliveryMethod?: string; receivedAt: number } + { + wireStatus: string; + sentVia?: string; + deliveryMethod?: string; + deliveryAttempts?: number; + receivedAt: number; + } >(); function pendingDeliveryKey(identityId: IdentityId, messageHash: string): string { @@ -92,12 +108,14 @@ function bufferPendingDeliveryStatus( wireStatus: string, sentVia?: string, deliveryMethod?: string, + deliveryAttempts?: number, ): void { prunePendingDeliveryStatuses(); pendingDeliveryByKey.set(pendingDeliveryKey(identityId, messageHash), { wireStatus, sentVia, deliveryMethod, + deliveryAttempts, receivedAt: Date.now(), }); } @@ -125,6 +143,7 @@ export function flushPendingReticulumOutboundDeliveryStatus( undefined, parseWireSentVia(pending.sentVia), parseReticulumDeliveryMethod(pending.deliveryMethod), + pending.deliveryAttempts, ); if (applied) pendingDeliveryByKey.delete(key); return applied; @@ -148,20 +167,28 @@ export function persistReticulumOutboundMessageStatus( errorMessage?: string, sentVia?: MessageTransport, deliveryMethod?: MessageRecord['reticulumDeliveryMethod'], + deliveryAttempts?: number, ): boolean { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Identity bucket may be absent at runtime. const before = useMessageStore.getState().messages[identityId]?.[messageId]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime guard protects external or callback-mutated state. if (!before) return false; // Link-timeout failure bridge can mark Failed before WS Direct→PN fallback arrives. - // Authoritative sending+propagated must revive so the badge is not stuck as PN ✗. - if (before.status === 'failed' && status === 'sending' && deliveryMethod === 'propagated') { + // Authoritative sending+propagated/stored_locally must revive so the badge is not stuck as PN ✗. + if ( + before.status === 'failed' && + status === 'sending' && + isPnCascadeDeliveryMethod(deliveryMethod) + ) { const revived: MessageRecord = { ...before, status: 'sending', error: undefined, - reticulumDeliveryMethod: 'propagated', + reticulumDeliveryMethod: deliveryMethod, ...(sentVia != null ? { receivedVia: sentVia } : {}), + ...(deliveryAttempts != null + ? { reticulumDeliveryAttempts: clampDeliveryAttempts(deliveryAttempts) } + : {}), }; upsertMessage(identityId, revived); const senderHash = resolveOutboundSenderHash(revived); @@ -217,6 +244,14 @@ export function persistReticulumOutboundMessageStatus( record = { ...record, reticulumDeliveryMethod: deliveryMethod }; patched = true; } + if ( + deliveryAttempts != null && + Number.isFinite(deliveryAttempts) && + clampDeliveryAttempts(deliveryAttempts) !== record.reticulumDeliveryAttempts + ) { + record = { ...record, reticulumDeliveryAttempts: clampDeliveryAttempts(deliveryAttempts) }; + patched = true; + } if (patched) { upsertMessage(identityId, record); } @@ -252,6 +287,7 @@ export function persistReticulumOutboundMessageStatus( export interface ApplyReticulumOutboundDeliveryStatusOpts { sentVia?: string | null; deliveryMethod?: string | null; + deliveryAttempts?: number | null; } /** Apply sidecar Completes/Fails (and optional egress `sent_via`): store + SQLite. */ @@ -277,6 +313,10 @@ export function applyReticulumOutboundDeliveryStatus( } const sentVia = parseWireSentVia(opts?.sentVia); const deliveryMethod = parseReticulumDeliveryMethod(opts?.deliveryMethod); + const deliveryAttempts = + opts?.deliveryAttempts != null && Number.isFinite(opts.deliveryAttempts) + ? clampDeliveryAttempts(opts.deliveryAttempts) + : undefined; const applied = persistReticulumOutboundMessageStatus( identityId, normalizedHash, @@ -284,19 +324,26 @@ export function applyReticulumOutboundDeliveryStatus( undefined, sentVia, deliveryMethod, + deliveryAttempts, ); if (applied) { pendingDeliveryByKey.delete(pendingDeliveryKey(identityId, normalizedHash)); return; } // Terminal status, or egress/method upgrade before rekey for later flush. - if (isTerminalStatus(status) || sentVia != null || deliveryMethod != null) { + if ( + isTerminalStatus(status) || + sentVia != null || + deliveryMethod != null || + deliveryAttempts != null + ) { bufferPendingDeliveryStatus( identityId, normalizedHash, wireStatus, opts?.sentVia ?? undefined, opts?.deliveryMethod ?? undefined, + deliveryAttempts, ); } } diff --git a/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts b/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts index 69dd8f81a..298558485 100644 --- a/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts @@ -8,7 +8,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ingestReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest'; import { OFFLINE_RETICULUM_IDENTITY_ID } from '@/renderer/lib/offlineProtocolIdentities'; -import { catchUpRecentInboundLxmf } from '@/renderer/lib/reticulum/catchUpRecentInboundLxmf'; +import { + catchUpRecentInboundLxmf, + resetCatchUpRecentInboundLxmfSingleFlightForTests, +} from '@/renderer/lib/reticulum/catchUpRecentInboundLxmf'; import { fetchRecentInboundLxmfDetailed } from '@/renderer/lib/reticulum/fetchRecentInboundLxmf'; import { getReticulumInboundLxmfDiagnostics, @@ -59,6 +62,7 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { useMessageStore.setState({ messages: {} }); resetReticulumManualStackStopSuppressForTests(); resetReticulumInboundLxmfDiagnosticsForTests(); + resetCatchUpRecentInboundLxmfSingleFlightForTests(); eventHandler = null; warnSpy.mockClear(); vi.mocked(fetchRecentInboundLxmfDetailed).mockReset(); diff --git a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts index 5342693d1..538bb84ef 100644 --- a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts @@ -4,7 +4,10 @@ import type { ReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest import { fetchRecentInboundLxmfDetailed } from '@/renderer/lib/reticulum/fetchRecentInboundLxmf'; import { type MessageRecord, useMessageStore } from '@/renderer/stores/messageStore'; -import { catchUpRecentInboundLxmf } from './catchUpRecentInboundLxmf'; +import { + catchUpRecentInboundLxmf, + resetCatchUpRecentInboundLxmfSingleFlightForTests, +} from './catchUpRecentInboundLxmf'; vi.mock('@/renderer/lib/reticulum/fetchRecentInboundLxmf', () => ({ fetchRecentInboundLxmfDetailed: vi.fn(), @@ -51,6 +54,7 @@ describe('catchUpRecentInboundLxmf', () => { debugSpy.mockClear(); useMessageStore.setState({ messages: {} }); vi.mocked(fetchRecentInboundLxmfDetailed).mockReset(); + resetCatchUpRecentInboundLxmfSingleFlightForTests(); }); it('returns null when identityId is empty', async () => { @@ -147,4 +151,104 @@ describe('catchUpRecentInboundLxmf', () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('count=2 reason=periodic')); expect(debugSpy).not.toHaveBeenCalledWith(expect.stringContaining('catch-up count=')); }); + + it('returns null and warns distinctly when rateLimited', async () => { + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ + messages: [], + ringLen: null, + rateLimited: true, + }); + await expect( + catchUpRecentInboundLxmf({ identityId: 'id-1', ingest: vi.fn(), reason: 'ws_reconnect' }), + ).resolves.toBeNull(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('rateLimited')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('not empty inbox')); + }); + + it('coalesces concurrent callers into one fetch plus trailing rerun', async () => { + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const ingestA = vi.fn(); + const ingestB = vi.fn(); + vi.mocked(fetchRecentInboundLxmfDetailed) + .mockImplementationOnce(async () => { + await firstGate; + return { messages: [sample('aa'.repeat(32), 1_000, 1)], ringLen: 1 }; + }) + .mockResolvedValueOnce({ messages: [sample('bb'.repeat(32), 2_000, 2)], ringLen: 2 }); + + const p1 = catchUpRecentInboundLxmf({ + identityId: 'id-1', + ingest: ingestA, + sinceTs: 100, + reason: 'connect', + }); + const p2 = catchUpRecentInboundLxmf({ + identityId: 'id-1', + ingest: ingestB, + sinceTs: 500, + sinceSeq: 3, + reason: 'ws_reconnect', + }); + + expect(fetchRecentInboundLxmfDetailed).toHaveBeenCalledTimes(1); + releaseFirst(); + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1).toEqual(r2); + expect(fetchRecentInboundLxmfDetailed).toHaveBeenCalledTimes(2); + expect(fetchRecentInboundLxmfDetailed).toHaveBeenLastCalledWith({ + limit: 200, + sinceTs: 500, + sinceSeq: 3, + }); + expect(ingestB).toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('connect+ws_reconnect')); + }); + + it('keeps independent single-flight state across two identities', async () => { + let releaseA!: () => void; + let releaseB!: () => void; + const gateA = new Promise((resolve) => { + releaseA = resolve; + }); + const gateB = new Promise((resolve) => { + releaseB = resolve; + }); + const ingestA = vi.fn(); + const ingestB = vi.fn(); + vi.mocked(fetchRecentInboundLxmfDetailed).mockImplementation(async (opts = {}) => { + if (opts.sinceTs === 1) { + await gateA; + return { messages: [sample('aa'.repeat(32), 1_000, 1)], ringLen: 1 }; + } + await gateB; + return { messages: [sample('bb'.repeat(32), 2_000, 2)], ringLen: 1 }; + }); + + const pA = catchUpRecentInboundLxmf({ + identityId: 'id-a', + ingest: ingestA, + sinceTs: 1, + reason: 'a', + }); + const pB = catchUpRecentInboundLxmf({ + identityId: 'id-b', + ingest: ingestB, + sinceTs: 2, + reason: 'b', + }); + + expect(fetchRecentInboundLxmfDetailed).toHaveBeenCalledTimes(2); + releaseB(); + const rB = await pB; + expect(rB?.count).toBe(1); + expect(ingestB).toHaveBeenCalled(); + expect(ingestA).not.toHaveBeenCalled(); + releaseA(); + const rA = await pA; + expect(rA?.count).toBe(1); + expect(ingestA).toHaveBeenCalled(); + }); }); diff --git a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts index 3eaf10d44..25298c395 100644 --- a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts +++ b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts @@ -19,6 +19,72 @@ export interface CatchUpRecentInboundLxmfOutcome { watermarkSeq: number | null; } +interface CatchUpFlight { + promise: Promise; + opts: CatchUpRecentInboundLxmfOpts; + pending: CatchUpRecentInboundLxmfOpts | null; +} + +/** Per-identity single-flight + trailing coalesce (never share across identityIds). */ +const catchUpByIdentity = new Map(); + +/** Prefer latest cursor; merge reason labels; last ingest wins (same identity). */ +function mergeCatchUpOpts( + base: CatchUpRecentInboundLxmfOpts, + next: CatchUpRecentInboundLxmfOpts, +): CatchUpRecentInboundLxmfOpts { + const reasons = [base.reason, next.reason].filter( + (r): r is string => typeof r === 'string' && r.length > 0, + ); + const uniqueReasons = [...new Set(reasons)]; + const sinceTs = + next.sinceTs != null && Number.isFinite(next.sinceTs) + ? next.sinceTs + : base.sinceTs != null && Number.isFinite(base.sinceTs) + ? base.sinceTs + : undefined; + const sinceSeq = + next.sinceSeq != null && Number.isFinite(next.sinceSeq) + ? next.sinceSeq + : base.sinceSeq != null && Number.isFinite(base.sinceSeq) + ? base.sinceSeq + : undefined; + // When both cursors present, prefer the later (ts, seq) pair from `next` if it advances. + let chosenSinceTs = sinceTs; + let chosenSinceSeq = sinceSeq; + if ( + base.sinceTs != null && + Number.isFinite(base.sinceTs) && + next.sinceTs != null && + Number.isFinite(next.sinceTs) + ) { + if (next.sinceTs > base.sinceTs) { + chosenSinceTs = next.sinceTs; + chosenSinceSeq = next.sinceSeq; + } else if (next.sinceTs < base.sinceTs) { + chosenSinceTs = base.sinceTs; + chosenSinceSeq = base.sinceSeq; + } else { + const baseSeq = base.sinceSeq ?? -1; + const nextSeq = next.sinceSeq ?? -1; + if (nextSeq >= baseSeq) { + chosenSinceTs = next.sinceTs; + chosenSinceSeq = next.sinceSeq; + } else { + chosenSinceTs = base.sinceTs; + chosenSinceSeq = base.sinceSeq; + } + } + } + return { + identityId: base.identityId, + ingest: next.ingest, + ...(chosenSinceTs != null ? { sinceTs: chosenSinceTs } : {}), + ...(chosenSinceSeq != null ? { sinceSeq: chosenSinceSeq } : {}), + ...(uniqueReasons.length > 0 ? { reason: uniqueReasons.join('+') } : {}), + }; +} + function rowAlreadyInMessageStore(identityId: string, p: ReticulumLxmfPayload): boolean { const hash = typeof p.message_hash === 'string' ? p.message_hash.trim() : ''; if (!hash) return false; @@ -46,23 +112,22 @@ function isCursorAfter( return maxSeq == null || seq > maxSeq; } -/** - * Fetch recent inbound LXMF, ingest unknown rows, and compute the catch-up watermark. - * Caller applies diagnostics (`noteReticulumInboundCatchUp` / watermark advance). - * - * Sidecar cursor is exclusive `(since_ts, since_seq)`; returned watermarks are the max - * `(timestamp, ring_seq)` among fetched rows and are safe for the next periodic fetch. - */ -export async function catchUpRecentInboundLxmf( +async function catchUpRecentInboundLxmfOnce( opts: CatchUpRecentInboundLxmfOpts, ): Promise { if (!opts.identityId) return null; - const { messages: rows } = await fetchRecentInboundLxmfDetailed({ + const { messages: rows, rateLimited } = await fetchRecentInboundLxmfDetailed({ limit: 200, ...(opts.sinceTs != null ? { sinceTs: opts.sinceTs } : {}), ...(opts.sinceSeq != null ? { sinceSeq: opts.sinceSeq } : {}), }); + if (rateLimited) { + console.warn( + `[catchUpRecentInboundLxmf] rateLimited reason=${opts.reason ?? 'catch-up'} — skipped (not empty inbox)`, + ); + return null; + } if (rows.length === 0) return null; const knownFlags = rows.map((p) => rowAlreadyInMessageStore(opts.identityId, p)); @@ -96,3 +161,54 @@ export async function catchUpRecentInboundLxmf( watermarkSeq: maxTs > 0 ? maxSeq : null, }; } + +/** + * Fetch recent inbound LXMF, ingest unknown rows, and compute the catch-up watermark. + * Caller applies diagnostics (`noteReticulumInboundCatchUp` / watermark advance). + * + * Sidecar cursor is exclusive `(since_ts, since_seq)`; returned watermarks are the max + * `(timestamp, ring_seq)` among fetched rows and are safe for the next periodic fetch. + * + * Concurrent callers for the **same** identity share one in-flight promise; later opts + * coalesce (latest cursor, merged reasons) into a trailing rerun. Different identities + * never share flight state. + */ +export async function catchUpRecentInboundLxmf( + opts: CatchUpRecentInboundLxmfOpts, +): Promise { + if (!opts.identityId) return null; + const identityId = opts.identityId; + + const existing = catchUpByIdentity.get(identityId); + if (existing) { + const base = existing.pending ?? existing.opts; + existing.pending = mergeCatchUpOpts(base, opts); + return existing.promise; + } + + const flight: CatchUpFlight = { opts, pending: null, promise: Promise.resolve(null) }; + const promise = (async () => { + try { + let current = opts; + let result = await catchUpRecentInboundLxmfOnce(current); + while (flight.pending) { + current = flight.pending; + flight.pending = null; + flight.opts = current; + result = await catchUpRecentInboundLxmfOnce(current); + } + return result; + } finally { + catchUpByIdentity.delete(identityId); + } + })(); + flight.promise = promise; + catchUpByIdentity.set(identityId, flight); + + return promise; +} + +/** Test-only reset of single-flight coalesce state. */ +export function resetCatchUpRecentInboundLxmfSingleFlightForTests(): void { + catchUpByIdentity.clear(); +} diff --git a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts index 2d3fe0fdb..8fe30a79b 100644 --- a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts @@ -15,6 +15,11 @@ import { getReticulumInboundLxmfDiagnostics, resetReticulumInboundLxmfDiagnosticsForTests, } from './reticulumInboundLxmfDiagnostics'; +import { + isReticulumProxyRateLimitBackoffActive, + noteReticulumProxyRateLimitHit, + resetReticulumProxyRateLimitBackoffForTests, +} from './reticulumProxyRateLimitBackoff'; describe('fetchRecentInboundLxmf', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -23,6 +28,7 @@ describe('fetchRecentInboundLxmf', () => { proxyGet.mockReset(); warnSpy.mockClear(); resetReticulumInboundLxmfDiagnosticsForTests(); + resetReticulumProxyRateLimitBackoffForTests(); }); it('returns inbound rows from sidecar recent API', async () => { @@ -57,6 +63,48 @@ describe('fetchRecentInboundLxmf', () => { await expect(fetchRecentInboundLxmf()).resolves.toEqual([]); expect(warnSpy).toHaveBeenCalled(); const detailed = await fetchRecentInboundLxmfDetailed(); - expect(detailed).toEqual({ messages: [], ringLen: null }); + expect(detailed).toEqual({ messages: [], ringLen: null, rateLimited: false }); + }); + + it('skips proxyGet when lxmfRecent backoff is active', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + noteReticulumProxyRateLimitHit('lxmfRecent'); + const detailed = await fetchRecentInboundLxmfDetailed(); + expect(proxyGet).not.toHaveBeenCalled(); + expect(detailed).toEqual({ messages: [], ringLen: null, rateLimited: true }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('skipped')); + }); + + it('does not skip when only shared backoff is active', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + noteReticulumProxyRateLimitHit('shared'); + proxyGet.mockResolvedValue({ messages: [], ring_len: 0 }); + await fetchRecentInboundLxmfDetailed(); + expect(proxyGet).toHaveBeenCalled(); + }); + + it('arms lxmfRecent backoff on rate-limit error', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + proxyGet.mockRejectedValue(new Error('reticulum:proxy: rate limit exceeded')); + const detailed = await fetchRecentInboundLxmfDetailed(); + expect(detailed.rateLimited).toBe(true); + // Second call should skip without hitting proxy again. + proxyGet.mockClear(); + const skipped = await fetchRecentInboundLxmfDetailed(); + expect(proxyGet).not.toHaveBeenCalled(); + expect(skipped.rateLimited).toBe(true); + }); + + it('clears lxmfRecent backoff after success', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const now = 1_000_000; + noteReticulumProxyRateLimitHit('lxmfRecent', now); + expect(isReticulumProxyRateLimitBackoffActive('lxmfRecent', now)).toBe(true); + proxyGet.mockResolvedValue({ messages: [], ring_len: 0 }); + vi.spyOn(Date, 'now').mockReturnValue(now + 120_000); + await fetchRecentInboundLxmfDetailed(); + expect(proxyGet).toHaveBeenCalled(); + expect(isReticulumProxyRateLimitBackoffActive('lxmfRecent', now)).toBe(false); }); }); diff --git a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts index 96b8cd005..e70a28111 100644 --- a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts +++ b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts @@ -1,6 +1,13 @@ import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import type { ReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest'; import { noteReticulumInboundRingLen } from '@/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics'; +import { + clearReticulumProxyRateLimitBackoff, + isReticulumProxyRateLimitBackoffActive, + noteReticulumProxyErrorIfRateLimited, + reticulumProxyRateLimitBackoffRemainingMs, +} from '@/renderer/lib/reticulum/reticulumProxyRateLimitBackoff'; +import { RETICULUM_LXMF_RECENT_API_PATH } from '@/shared/reticulumApiPaths'; export interface FetchRecentInboundLxmfOpts { /** @@ -17,6 +24,8 @@ export interface FetchRecentInboundLxmfOpts { export interface FetchRecentInboundLxmfResult { messages: ReticulumLxmfPayload[]; ringLen: number | null; + /** Set when the call was skipped or failed due to proxy rate limiting. */ + rateLimited?: boolean; } /** @@ -34,6 +43,13 @@ export async function fetchRecentInboundLxmf( export async function fetchRecentInboundLxmfDetailed( opts: FetchRecentInboundLxmfOpts = {}, ): Promise { + if (isReticulumProxyRateLimitBackoffActive('lxmfRecent')) { + const remaining = reticulumProxyRateLimitBackoffRemainingMs('lxmfRecent'); + console.warn( + `[fetchRecentInboundLxmf] skipped — proxy rate-limit backoff remaining=${remaining}ms`, + ); + return { messages: [], ringLen: null, rateLimited: true }; + } const params = new URLSearchParams(); if (opts.sinceTs != null && Number.isFinite(opts.sinceTs)) { params.set('since_ts', String(Math.floor(opts.sinceTs))); @@ -50,12 +66,13 @@ export async function fetchRecentInboundLxmfDetailed( params.set('limit', String(Math.max(1, Math.min(500, Math.floor(opts.limit))))); } const qs = params.toString(); - const path = qs ? `/api/v1/lxmf/recent?${qs}` : '/api/v1/lxmf/recent'; + const path = qs ? `${RETICULUM_LXMF_RECENT_API_PATH}?${qs}` : RETICULUM_LXMF_RECENT_API_PATH; try { const body = (await window.electronAPI.reticulum.proxyGet(path)) as { messages?: unknown; ring_len?: unknown; }; + clearReticulumProxyRateLimitBackoff('lxmfRecent'); const ringLen = typeof body.ring_len === 'number' && Number.isFinite(body.ring_len) ? Math.trunc(body.ring_len) @@ -69,8 +86,9 @@ export async function fetchRecentInboundLxmfDetailed( ringLen, }; } catch (e) { + const rateLimited = noteReticulumProxyErrorIfRateLimited(e, 'lxmfRecent'); console.warn('[fetchRecentInboundLxmf] ' + errLikeToLogString(e)); - return { messages: [], ringLen: null }; + return { messages: [], ringLen: null, rateLimited }; } } diff --git a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts index cd4fb641d..4fc80f0aa 100644 --- a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts +++ b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts @@ -119,6 +119,33 @@ describe('failReticulumSendingOutboundToDestHash', () => { expect(useMessageStore.getState().messages[identityId]['msg-hash'].status).toBe('sending'); }); + it('skips outbound rows already on stored_locally (local-prop cascade)', () => { + const toNodeId = reticulumHashToNodeId(DEST); + registerReticulumDestinationHash(toNodeId, DEST); + useMessageStore.setState({ + messages: { + [identityId]: { + 'msg-hash': { + id: 'msg-hash', + from: 1, + senderName: 'self', + payload: 'hello', + channelIndex: 0, + timestamp: Date.now(), + status: 'sending', + to: toNodeId, + reticulumSenderHash: SELF, + reticulumDeliveryMethod: 'stored_locally', + }, + }, + }, + }); + + const count = failReticulumSendingOutboundToDestHash(identityId, DEST, 'link timeout'); + expect(count).toBe(0); + expect(useMessageStore.getState().messages[identityId]['msg-hash'].status).toBe('sending'); + }); + it('requires full 32-hex equality (prefix must not fail unrelated peers)', () => { const peerA = DEST; const peerB = `${DEST.slice(0, 8)}${'ff'.repeat(12)}`; @@ -215,7 +242,7 @@ describe('shouldApplyLinkDeliveryTimeoutFailureBridge', () => { ); }); - it('returns true when only local-prop is available', () => { + it('returns false when only local-prop is enabled (cascade last resort)', () => { const localOnly: PropagationNodeRow = { id: 'local-prop', name: 'Local', @@ -224,11 +251,11 @@ describe('shouldApplyLinkDeliveryTimeoutFailureBridge', () => { preferred: true, }; expect(shouldApplyLinkDeliveryTimeoutFailureBridge([localOnly], 'local-prop', 'auto')).toBe( - true, + false, ); }); - it('returns true when no remote PN target exists', () => { + it('returns true when no remote PN and local-prop disabled', () => { expect(shouldApplyLinkDeliveryTimeoutFailureBridge([], null, 'off')).toBe(true); }); }); diff --git a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts index 8061ceac2..04f02d5bf 100644 --- a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts +++ b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts @@ -2,7 +2,7 @@ import { persistReticulumOutboundMessageStatus, resolveReticulumOutboundDestHash, } from '@/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus'; -import { hasEffectiveReticulumPropagationTarget } from '@/renderer/lib/reticulum/reticulumPropagationEffective'; +import { hasReticulumPnCascadeCapacity } from '@/renderer/lib/reticulum/reticulumPropagationEffective'; import { readReticulumPropagationMode, type ReticulumPropagationMode, @@ -10,21 +10,23 @@ import { import type { IdentityId } from '@/renderer/lib/types'; import { useMessageStore } from '@/renderer/stores/messageStore'; import type { PropagationNodeRow } from '@/renderer/stores/reticulumPropagationStore'; +import { isPnCascadeDeliveryMethod } from '@/shared/reticulumDeliveryMethod'; function normalizeDestHash(hash: string): string { return hash.replace(/[^0-9a-f]/gi, '').toLowerCase(); } /** - * When a remote preferred PN is available, sidecar owns Direct timeout via - * one-shot PN fallback + `lxmf_outbound_status`. Skip the premature Failed bridge. + * When PN cascade can still run (remote preferred/auto or enabled local-prop), + * sidecar owns Direct timeout via multi-PN fallback + `lxmf_outbound_status`. + * Skip the premature Failed bridge. */ export function shouldApplyLinkDeliveryTimeoutFailureBridge( nodes: PropagationNodeRow[], preferredId: string | null, mode: ReticulumPropagationMode = readReticulumPropagationMode(), ): boolean { - return !hasEffectiveReticulumPropagationTarget(nodes, preferredId, mode); + return !hasReticulumPnCascadeCapacity(nodes, preferredId, mode); } function destHashMatchesPeer(storedHash: string, targetNorm: string): boolean { @@ -47,8 +49,10 @@ export function failReticulumSendingOutboundToDestHash( for (const msg of Object.values(bucket)) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime guard protects external or callback-mutated state. if (msg.status !== 'sending' || msg.to == null) continue; - // Direct→PN fallback re-queues as Propagated and emits sending — do not fail those rows. - if (msg.reticulumDeliveryMethod === 'propagated') continue; + // Cascade re-queues as Propagated / stored_locally and emits sending — do not fail those. + if (isPnCascadeDeliveryMethod(msg.reticulumDeliveryMethod)) { + continue; + } const destHash = resolveReticulumOutboundDestHash(msg.to); if (!destHash || !destHashMatchesPeer(destHash, targetNorm)) continue; if (persistReticulumOutboundMessageStatus(identityId, msg.id, 'failed', errorMessage)) { diff --git a/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts b/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts index de9133311..ecb3c70fe 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'; import type { PropagationNodeRow } from '@/renderer/stores/reticulumPropagationStore'; -import { hasEffectiveReticulumPropagationTarget } from './reticulumPropagationEffective'; +import { + hasEffectiveReticulumPropagationTarget, + hasEnabledLocalPropagation, + hasReticulumPnCascadeCapacity, +} from './reticulumPropagationEffective'; const remoteNode: PropagationNodeRow = { id: 'remote-1', @@ -63,3 +67,33 @@ describe('hasEffectiveReticulumPropagationTarget', () => { expect(hasEffectiveReticulumPropagationTarget([remoteNode], 'remote-1', 'manual')).toBe(true); }); }); + +describe('hasReticulumPnCascadeCapacity', () => { + it('is true for preferred remote or enabled local-prop', () => { + const localEnabled: PropagationNodeRow = { + id: 'local-prop', + name: 'Local', + enabled: true, + status: 'active', + preferred: false, + }; + expect(hasReticulumPnCascadeCapacity([remoteNode], 'remote-1', 'off')).toBe(true); + expect(hasReticulumPnCascadeCapacity([localEnabled], 'local-prop', 'off')).toBe(true); + expect(hasEnabledLocalPropagation([localEnabled])).toBe(true); + }); + + it('is false when nothing is available', () => { + expect(hasReticulumPnCascadeCapacity([], null, 'off')).toBe(false); + }); + + it('is false when local-prop is present but disabled', () => { + const localDisabled: PropagationNodeRow = { + id: 'local-prop', + name: 'Local', + enabled: false, + status: 'inactive', + preferred: false, + }; + expect(hasReticulumPnCascadeCapacity([localDisabled], null, 'off')).toBe(false); + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumPropagationEffective.ts b/src/renderer/lib/reticulum/reticulumPropagationEffective.ts index d377136fa..e3eaef440 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationEffective.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationEffective.ts @@ -45,3 +45,21 @@ export function hasEffectiveReticulumPropagationTarget( return pickAutoPropagationNodeId(nodes) != null; } + +/** True when local-prop is enabled (cascade last resort / offline inbox). */ +export function hasEnabledLocalPropagation(nodes: PropagationNodeRow[]): boolean { + return nodes.some((n) => n.id === 'local-prop' && n.enabled); +} + +/** + * True when Direct→PN cascade can still run (remote preferred/auto OR local-prop). + * Link-timeout failure bridge must skip while this is true. + */ +export function hasReticulumPnCascadeCapacity( + nodes: PropagationNodeRow[], + preferredId: string | null, + mode: ReticulumPropagationMode = readReticulumPropagationMode(), +): boolean { + if (hasEffectiveReticulumPropagationTarget(nodes, preferredId, mode)) return true; + return hasEnabledLocalPropagation(nodes); +} diff --git a/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts b/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts index bd4f342fb..fd3962e05 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts @@ -117,6 +117,9 @@ describe('reticulumPropagationSync', () => { expect(mapPropagationSyncError('propagation offer rejected: Unknown')).toBe( 'reticulumPropagation.syncOfferUnknown', ); + expect(mapPropagationSyncError('PROPAGATION_SYNC_OUTBOUND_BUSY')).toBe( + 'reticulumPropagation.syncOutboundBusy', + ); expect(mapPropagationSyncError('other')).toBe('reticulumPropagation.syncFailed'); }); diff --git a/src/renderer/lib/reticulum/reticulumPropagationSync.ts b/src/renderer/lib/reticulum/reticulumPropagationSync.ts index ad702510d..8c98c867f 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationSync.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationSync.ts @@ -127,8 +127,10 @@ export function mapPropagationSyncError(error: string | null | undefined): strin if (error === 'PROPAGATION_OFFER_UNSUPPORTED') return SYNC_OFFER_UNSUPPORTED_KEY; if (error === 'PROPAGATION_OFFER_PROBE_TIMEOUT') return SYNC_OFFER_PROBE_TIMEOUT_KEY; if (error === 'PROPAGATION_OFFER_PROBE_FAILED') return SYNC_OFFER_PROBE_FAILED_KEY; - // Soft conflict with outbound deposit — callers should treat as non-fatal (no UI error). - if (error === 'PROPAGATION_SYNC_OUTBOUND_BUSY') return SYNC_FAILED_KEY; + // Soft conflict with outbound deposit — surface a specific key for diagnostics/UI. + if (error === 'PROPAGATION_SYNC_OUTBOUND_BUSY') { + return 'reticulumPropagation.syncOutboundBusy'; + } return mapPropagationSyncErrorBySubstring(error) ?? SYNC_FAILED_KEY; } diff --git a/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts new file mode 100644 index 000000000..95feca259 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + clearReticulumProxyRateLimitBackoff, + isReticulumProxyRateLimitBackoffActive, + noteReticulumProxyErrorIfRateLimited, + noteReticulumProxyRateLimitHit, + resetReticulumProxyRateLimitBackoffForTests, + reticulumProxyRateLimitBackoffRemainingMs, +} from '@/renderer/lib/reticulum/reticulumProxyRateLimitBackoff'; + +describe('reticulumProxyRateLimitBackoff', () => { + afterEach(() => { + resetReticulumProxyRateLimitBackoffForTests(); + vi.restoreAllMocks(); + }); + + it('arms backoff on rate-limit hit and clears on success', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(Math, 'random').mockReturnValue(0.5); // jitter factor 1.0 + const now = 1_000_000; + const delay = noteReticulumProxyRateLimitHit('shared', now); + expect(delay).toBeGreaterThan(0); + expect(isReticulumProxyRateLimitBackoffActive('shared', now)).toBe(true); + expect(reticulumProxyRateLimitBackoffRemainingMs('shared', now)).toBe(delay); + expect(isReticulumProxyRateLimitBackoffActive('shared', now + delay + 1)).toBe(false); + clearReticulumProxyRateLimitBackoff('shared'); + expect(isReticulumProxyRateLimitBackoffActive('shared', now)).toBe(false); + }); + + it('keeps shared and lxmfRecent buckets independent', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const now = 1_000_000; + noteReticulumProxyRateLimitHit('lxmfRecent', now); + expect(isReticulumProxyRateLimitBackoffActive('lxmfRecent', now)).toBe(true); + expect(isReticulumProxyRateLimitBackoffActive('shared', now)).toBe(false); + expect(isReticulumProxyRateLimitBackoffActive(undefined, now)).toBe(true); + clearReticulumProxyRateLimitBackoff('lxmfRecent'); + expect(isReticulumProxyRateLimitBackoffActive(undefined, now)).toBe(false); + }); + + it('notes rate-limit errors on the shared bucket by default', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + expect(noteReticulumProxyErrorIfRateLimited(new Error('boom'))).toBe(false); + expect( + noteReticulumProxyErrorIfRateLimited(new Error('reticulum:proxy: rate limit exceeded')), + ).toBe(true); + expect(isReticulumProxyRateLimitBackoffActive('shared')).toBe(true); + expect(isReticulumProxyRateLimitBackoffActive('lxmfRecent')).toBe(false); + }); + + it('does not tight-loop — consecutive hits increase backoff', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const first = noteReticulumProxyRateLimitHit('shared', 0); + const second = noteReticulumProxyRateLimitHit('shared', 0); + expect(second).toBeGreaterThanOrEqual(first); + }); + + it('clear without bucket resets both', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + noteReticulumProxyRateLimitHit('shared', 0); + noteReticulumProxyRateLimitHit('lxmfRecent', 0); + clearReticulumProxyRateLimitBackoff(); + expect(isReticulumProxyRateLimitBackoffActive()).toBe(false); + }); + + it('clamps jittered delay between DEFAULT and MAX backoff', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const now = 1_000_000; + // random=0 → factor 0.9; first hit base=5000 → 4500, clamped up to DEFAULT (5000) + vi.spyOn(Math, 'random').mockReturnValue(0); + const low = noteReticulumProxyRateLimitHit('shared', now); + expect(low).toBe(5_000); + resetReticulumProxyRateLimitBackoffForTests(); + // Drive hits to MAX base then jitter above MAX (factor 1.1) + vi.spyOn(Math, 'random').mockReturnValue(1); + let delay = 0; + for (let i = 0; i < 6; i++) { + delay = noteReticulumProxyRateLimitHit('shared', now); + } + expect(delay).toBe(60_000); + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts new file mode 100644 index 000000000..7ebe5117a --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts @@ -0,0 +1,108 @@ +import { isReticulumSidecarRateLimitError } from '@/renderer/lib/reticulum/reticulumSidecarReads'; +import { MS_PER_SECOND } from '@/shared/timeConstants'; + +const DEFAULT_BACKOFF_MS = 5 * MS_PER_SECOND; +const MAX_BACKOFF_MS = 60 * MS_PER_SECOND; + +/** Independent proxy IPC rate-limit backoff buckets. */ +export type ReticulumProxyRateLimitBucket = 'shared' | 'lxmfRecent'; + +interface BucketState { + backoffUntilMs: number; + consecutiveHits: number; +} + +const buckets: Record = { + shared: { backoffUntilMs: 0, consecutiveHits: 0 }, + lxmfRecent: { backoffUntilMs: 0, consecutiveHits: 0 }, +}; + +function isBucketActive(bucket: ReticulumProxyRateLimitBucket, now: number): boolean { + return now < buckets[bucket].backoffUntilMs; +} + +function remainingForBucket(bucket: ReticulumProxyRateLimitBucket, now: number): number { + return Math.max(0, buckets[bucket].backoffUntilMs - now); +} + +/** + * True while proxy rate-limit backoff is active. + * When `bucket` is omitted, true if either bucket is active (peer-store / legacy callers). + */ +export function isReticulumProxyRateLimitBackoffActive( + bucket?: ReticulumProxyRateLimitBucket, + now = Date.now(), +): boolean { + if (bucket == null) { + return isBucketActive('shared', now) || isBucketActive('lxmfRecent', now); + } + return isBucketActive(bucket, now); +} + +/** + * Remaining backoff ms (0 when clear). + * When `bucket` is omitted, returns the max remaining across both buckets. + */ +export function reticulumProxyRateLimitBackoffRemainingMs( + bucket?: ReticulumProxyRateLimitBucket, + now = Date.now(), +): number { + if (bucket == null) { + return Math.max(remainingForBucket('shared', now), remainingForBucket('lxmfRecent', now)); + } + return remainingForBucket(bucket, now); +} + +/** Optional ±10% jitter so concurrent clients do not retry in lockstep. */ +function applyJitter(delayMs: number): number { + const factor = 0.9 + Math.random() * 0.2; + return Math.round(delayMs * factor); +} + +/** + * Record a rate-limit error and arm exponential backoff so callers do not tight-loop. + * Returns the backoff duration applied (ms, after jitter, clamped to [DEFAULT, MAX]). + */ +export function noteReticulumProxyRateLimitHit( + bucket: ReticulumProxyRateLimitBucket, + now = Date.now(), +): number { + const state = buckets[bucket]; + state.consecutiveHits = Math.min(state.consecutiveHits + 1, 6); + const base = Math.min(DEFAULT_BACKOFF_MS * 2 ** (state.consecutiveHits - 1), MAX_BACKOFF_MS); + const delay = Math.min(MAX_BACKOFF_MS, Math.max(DEFAULT_BACKOFF_MS, applyJitter(base))); + state.backoffUntilMs = Math.max(state.backoffUntilMs, now + delay); + console.warn( + `[reticulumProxyRateLimit] bucket=${bucket} backoff ${delay}ms hits=${state.consecutiveHits} until=${new Date(state.backoffUntilMs).toISOString()}`, + ); + return delay; +} + +/** Clear backoff after a successful proxy call (one bucket, or both when omitted). */ +export function clearReticulumProxyRateLimitBackoff(bucket?: ReticulumProxyRateLimitBucket): void { + const clearOne = (b: ReticulumProxyRateLimitBucket): void => { + buckets[b].consecutiveHits = 0; + buckets[b].backoffUntilMs = 0; + }; + if (bucket == null) { + clearOne('shared'); + clearOne('lxmfRecent'); + return; + } + clearOne(bucket); +} + +/** If `err` is a rate-limit error, arm backoff for `bucket` (default shared) and return true. */ +export function noteReticulumProxyErrorIfRateLimited( + err: unknown, + bucket: ReticulumProxyRateLimitBucket = 'shared', +): boolean { + if (!isReticulumSidecarRateLimitError(err)) return false; + noteReticulumProxyRateLimitHit(bucket); + return true; +} + +/** Test-only reset. */ +export function resetReticulumProxyRateLimitBackoffForTests(): void { + clearReticulumProxyRateLimitBackoff(); +} diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 9af0c6ad3..011da0de3 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -677,7 +677,10 @@ "shareAsPaperMessageLabel": "Zpráva k zašifrování", "shareAsPaperGenerate": "Vytvořit papírové QR", "shareAsPaperCopyFailed": "Nelze zkopírovat papírový odkaz", - "waitingMessagesSilentFetched": "Načteno {{processed}} z rádia…" + "waitingMessagesSilentFetched": "Načteno {{processed}} z rádia…", + "reticulumSendStoringLocally": "Ukládání do místní doručené pošty šíření…", + "reticulumSendStoredLocally": "Uloženo ve vaší místní doručené poště šíření (nedoručeno peerovi)", + "sentViaLocalPropagation": "Místní doručená pošta šíření" }, "chatPayload": { "mention": "Zmínit {{label}}", @@ -4240,7 +4243,8 @@ "addProbing": "Kontrola podpory /offer…", "syncLocalNotSupported": "Místní uzel šíření hostitele nelze synchronizovat přes síť jako vzdálený uzel šíření LXMF.", "enableFailed": "Uzel šíření nelze povolit.", - "disableFailed": "Uzel šíření nelze zakázat." + "disableFailed": "Uzel šíření nelze zakázat.", + "syncOutboundBusy": "Synchronizace propagace odložena — do tohoto uzlu se ukládá odchozí zpráva." }, "reticulumPropagationHeader": { "modeLabel": "Režim propagace", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 3e90d4dbe..500180cbe 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Nachricht zum Verschlüsseln", "shareAsPaperGenerate": "Papier-QR erstellen", "shareAsPaperCopyFailed": "Papierlink konnte nicht kopiert werden", - "waitingMessagesSilentFetched": "{{processed}} vom Funkgerät abgerufen…" + "waitingMessagesSilentFetched": "{{processed}} vom Funkgerät abgerufen…", + "reticulumSendStoringLocally": "Wird in Ihrem lokalen Propagations-Posteingang gespeichert…", + "reticulumSendStoredLocally": "Wird in Ihrem lokalen Propagations-Posteingang aufbewahrt (nicht an den Peer geliefert)", + "sentViaLocalPropagation": "Lokaler Propagations-Posteingang" }, "chatPayload": { "mention": "Erwähne {{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "Prüfe /offer-Unterstützung…", "syncLocalNotSupported": "Der lokale Host-Verbreitungsknoten kann nicht wie ein entfernter LXMF-Verbreitungsknoten über das Netzwerk synchronisiert werden.", "enableFailed": "Der Ausbreitungsknoten konnte nicht aktiviert werden.", - "disableFailed": "Der Ausbreitungsknoten konnte nicht deaktiviert werden." + "disableFailed": "Der Ausbreitungsknoten konnte nicht deaktiviert werden.", + "syncOutboundBusy": "Ausbreitungssynchronisierung verschoben — eine ausgehende Nachricht wird auf diesem Knoten hinterlegt." }, "reticulumPropagationHeader": { "modeLabel": "Ausbreitungsmodus", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 858628be4..fbd67f7eb 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -494,6 +494,8 @@ "reticulumSendSending": "Sending…", "reticulumSendPropagated": "Queued at propagation node", "reticulumSendStoredAtPn": "Stored at propagation node", + "reticulumSendStoringLocally": "Saving to your local propagation inbox…", + "reticulumSendStoredLocally": "Kept in your local propagation inbox (not delivered to peer)", "reticulumPnAbbrev": "PN", "reticulumSendDelivered": "Delivered", "reticulumSendPaper": "Paper", @@ -501,6 +503,7 @@ "reticulumSendFailed": "Failed to send", "reticulumNoPropagationNode": "No propagation node configured. Set a preferred propagation node on the Reticulum Network tab.", "sentViaPropagation": "Propagation node", + "sentViaLocalPropagation": "Local propagation inbox", "reticulumImageAttachment": "Image: {{name}}", "reticulumFileAttachment": "File: {{name}}", "shareAsPaper": "Share as paper", @@ -4549,6 +4552,7 @@ "syncStatusNegotiating": "Negotiating sync with propagation node…", "syncStatusTransferring": "Transferring messages from propagation node…", "syncFailed": "Propagation sync failed — the node may be unreachable.", + "syncOutboundBusy": "Propagation sync deferred — an outbound message is depositing to this node.", "syncTimedOut": "Propagation sync timed out — the node may be unreachable.", "syncLocalNotSupported": "The local host propagation node cannot be synced over the network like a remote LXMF propagation node.", "syncIdentityUnknown": "Propagation node identity is unknown — wait for an announce or path response, then try again.", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 11569089c..d427d629a 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Mensaje a cifrar", "shareAsPaperGenerate": "Crear QR en papel", "shareAsPaperCopyFailed": "No se ha podido copiar el enlace en papel", - "waitingMessagesSilentFetched": "Obtenido {{processed}} de la radio..." + "waitingMessagesSilentFetched": "Obtenidos {{processed}} de la radio…", + "reticulumSendStoringLocally": "Guardando en su bandeja de entrada de propagación local...", + "reticulumSendStoredLocally": "Se mantiene en su bandeja de entrada de propagación local (no se entrega al peer)", + "sentViaLocalPropagation": "Bandeja de entrada de propagación local" }, "chatPayload": { "mention": "Mencionar {{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "Comprobando compatibilidad con /offer…", "syncLocalNotSupported": "El nodo de propagación del host local no se puede sincronizar a través de la red como un nodo de propagación LXMF remoto.", "enableFailed": "No se pudo habilitar el nodo de propagación.", - "disableFailed": "No se pudo deshabilitar el nodo de propagación." + "disableFailed": "No se pudo deshabilitar el nodo de propagación.", + "syncOutboundBusy": "Sincronización de propagación diferida: un mensaje saliente se está depositando en este nodo." }, "reticulumPropagationHeader": { "modeLabel": "modo de propagación", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 466e33b16..09a19ca3c 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Message à crypter", "shareAsPaperGenerate": "Créer un QR papier", "shareAsPaperCopyFailed": "Impossible de copier le lien papier", - "waitingMessagesSilentFetched": "Récupéré {{processed}} de la radio…" + "waitingMessagesSilentFetched": "{{processed}} message(s) récupéré(s) de la radio…", + "reticulumSendStoringLocally": "Enregistrement dans votre boîte de réception de propagation locale…", + "reticulumSendStoredLocally": "Conservé dans votre boîte de réception de propagation locale (non livré à l'homologue)", + "sentViaLocalPropagation": "Boîte de réception de propagation locale" }, "chatPayload": { "mention": "Mention {{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "Vérification de la prise en charge de /offer…", "syncLocalNotSupported": "Le nœud de propagation de l'hôte local ne peut pas être synchronisé sur le réseau comme un nœud de propagation LXMF distant.", "enableFailed": "Impossible d'activer le nœud de propagation.", - "disableFailed": "Impossible de désactiver le nœud de propagation." + "disableFailed": "Impossible de désactiver le nœud de propagation.", + "syncOutboundBusy": "Synchronisation de la propagation différée — un message sortant se dépose sur ce nœud." }, "reticulumPropagationHeader": { "modeLabel": "mode de propagation", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 5c314a43f..8a796585c 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Pesan untuk dienkripsi", "shareAsPaperGenerate": "Buat QR kertas", "shareAsPaperCopyFailed": "Tidak dapat menyalin tautan kertas", - "waitingMessagesSilentFetched": "Mengambil {{processed}} dari radio…" + "waitingMessagesSilentFetched": "Mengambil {{processed}} dari radio…", + "reticulumSendStoringLocally": "Menyimpan ke kotak masuk propagasi lokal Anda…", + "reticulumSendStoredLocally": "Disimpan di kotak masuk propagasi lokal Anda (tidak dikirim ke peer)", + "sentViaLocalPropagation": "Kotak masuk propagasi lokal" }, "chatPayload": { "mention": "Sebutkan {{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "Memeriksa dukungan /offer…", "syncLocalNotSupported": "Node propagasi host lokal tidak dapat disinkronkan melalui jaringan seperti node propagasi LXMF jarak jauh.", "enableFailed": "Tidak dapat mengaktifkan node propagasi.", - "disableFailed": "Tidak dapat menonaktifkan node propagasi." + "disableFailed": "Tidak dapat menonaktifkan node propagasi.", + "syncOutboundBusy": "Sinkronisasi propagasi ditangguhkan — pesan keluar disetorkan ke simpul ini." }, "reticulumPropagationHeader": { "modeLabel": "Mode propagasi", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 9b645e9ab..cdbf69172 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Messaggio da crittografare", "shareAsPaperGenerate": "Crea QR cartaceo", "shareAsPaperCopyFailed": "Impossibile copiare il link cartaceo", - "waitingMessagesSilentFetched": "Recuperato {{processed}} dalla radio..." + "waitingMessagesSilentFetched": "Recuperati {{processed}} dalla radio…", + "reticulumSendStoringLocally": "Salvataggio nella tua casella di posta di propagazione locale in corso...", + "reticulumSendStoredLocally": "Conservato nella tua casella di posta di propagazione locale (non consegnato al peer)", + "sentViaLocalPropagation": "Posta in arrivo propagazione locale" }, "chatPayload": { "mention": "Menziona {{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "Verifica del supporto /offer…", "syncLocalNotSupported": "Il nodo di propagazione dell'host locale non può essere sincronizzato sulla rete come un nodo di propagazione LXMF remoto.", "enableFailed": "Impossibile abilitare il nodo di propagazione.", - "disableFailed": "Impossibile disabilitare il nodo di propagazione." + "disableFailed": "Impossibile disabilitare il nodo di propagazione.", + "syncOutboundBusy": "Sincronizzazione di propagazione differita — un messaggio in uscita sta venendo depositato su questo nodo." }, "reticulumPropagationHeader": { "modeLabel": "Modalità di propagazione", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index f97c495ee..f7b0a0803 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "暗号化するメッセージ", "shareAsPaperGenerate": "紙のQRを作成する", "shareAsPaperCopyFailed": "用紙リンクをコピーできませんでした", - "waitingMessagesSilentFetched": "ラジオから{{processed}}を取得しました…" + "waitingMessagesSilentFetched": "ラジオから{{processed}}を取得しました…", + "reticulumSendStoringLocally": "ローカルの伝播受信トレイに保存しています…", + "reticulumSendStoredLocally": "ローカルの伝播受信トレイに保存されています(ピアには配信されません)", + "sentViaLocalPropagation": "ローカル伝播受信トレイ" }, "chatPayload": { "mention": "{{label}} について言及してください", @@ -4238,7 +4241,8 @@ "addProbing": "/offer サポートを確認中…", "syncLocalNotSupported": "ローカル ホスト伝播ノードは、リモート LXMF 伝播ノードのようにネットワーク経由で同期できません。", "enableFailed": "伝播ノードを有効にできませんでした。", - "disableFailed": "伝播ノードを無効にできませんでした。" + "disableFailed": "伝播ノードを無効にできませんでした。", + "syncOutboundBusy": "伝播同期を延期しました — 送信メッセージをこのノードに預けています。" }, "reticulumPropagationHeader": { "modeLabel": "伝播モード", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 7c8cf9d74..20ebbab96 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "암호화할 메시지", "shareAsPaperGenerate": "용지 QR 생성", "shareAsPaperCopyFailed": "용지 링크를 복사할 수 없습니다", - "waitingMessagesSilentFetched": "라디오에서 {{processed}} 을 (를) 가져왔습니다..." + "waitingMessagesSilentFetched": "라디오에서 {{processed}} 을 (를) 가져왔습니다...", + "reticulumSendStoringLocally": "로컬 전파 받은 편지함에 저장 중...", + "reticulumSendStoredLocally": "로컬 전파 받은 편지함에 보관됨 (피어에게 전달되지 않음)", + "sentViaLocalPropagation": "로컬 전파 메시지함" }, "chatPayload": { "mention": "{{label}}을(를) 언급하세요", @@ -4238,7 +4241,8 @@ "addProbing": "/offer 지원 확인 중…", "syncLocalNotSupported": "로컬 호스트 전파 노드는 원격 LXMF 전파 노드처럼 네트워크를 통해 동기화될 수 없습니다.", "enableFailed": "전파 노드를 활성화할 수 없습니다.", - "disableFailed": "전파 노드를 비활성화할 수 없습니다." + "disableFailed": "전파 노드를 비활성화할 수 없습니다.", + "syncOutboundBusy": "전파 동기화 지연 — 아웃바운드 메시지가 이 노드에 저장되는 중입니다." }, "reticulumPropagationHeader": { "modeLabel": "전파 모드", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 88404c077..ddc9e4e66 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Bericht om te versleutelen", "shareAsPaperGenerate": "Maak papieren QR", "shareAsPaperCopyFailed": "Kon papieren link niet kopiëren", - "waitingMessagesSilentFetched": "{{processed}} van de radio gehaald..." + "waitingMessagesSilentFetched": "{{processed}} van de radio gehaald...", + "reticulumSendStoringLocally": "Opslaan in uw lokale propagatie-inbox…", + "reticulumSendStoredLocally": "Bewaard in uw lokale propagatie-inbox (niet afgeleverd bij de peer)", + "sentViaLocalPropagation": "Lokale propagatie-inbox" }, "chatPayload": { "mention": "Vermeld {{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "/offer-ondersteuning controleren…", "syncLocalNotSupported": "Het lokale hostvoortplantingsknooppunt kan niet via het netwerk worden gesynchroniseerd zoals een extern LXMF-voortplantingsknooppunt.", "enableFailed": "Kan het voortplantingsknooppunt niet inschakelen.", - "disableFailed": "Kan het voortplantingsknooppunt niet uitschakelen." + "disableFailed": "Kan het voortplantingsknooppunt niet uitschakelen.", + "syncOutboundBusy": "Propagatiesynchronisatie uitgesteld — een uitgaand bericht wordt op dit knooppunt gedeponeerd." }, "reticulumPropagationHeader": { "modeLabel": "voortplantingsmodus", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index e7d626df7..31c3eec3d 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -679,7 +679,10 @@ "shareAsPaperMessageLabel": "Wiadomość do zaszyfrowania", "shareAsPaperGenerate": "Utwórz papierowy QR", "shareAsPaperCopyFailed": "Nie można skopiować papierowego linku", - "waitingMessagesSilentFetched": "Pobrano {{processed}} z radia…" + "waitingMessagesSilentFetched": "Pobrano {{processed}} z radia…", + "reticulumSendStoringLocally": "Zapisywanie w skrzynce odbiorczej lokalnej propagacji…", + "reticulumSendStoredLocally": "Przechowywane w skrzynce odbiorczej lokalnej propagacji (niedostarczone do peera)", + "sentViaLocalPropagation": "Skrzynka odbiorcza propagacji lokalnej" }, "chatPayload": { "mention": "Wspomnij o {{label}}", @@ -4242,7 +4245,8 @@ "addProbing": "Sprawdzanie obsługi /offer…", "syncLocalNotSupported": "Lokalny węzeł propagacji hosta nie może być synchronizowany przez sieć jak zdalny węzeł propagacji LXMF.", "enableFailed": "Nie można włączyć węzła propagacji.", - "disableFailed": "Nie można wyłączyć węzła propagacji." + "disableFailed": "Nie można wyłączyć węzła propagacji.", + "syncOutboundBusy": "Synchronizacja propagacji odroczona — wiadomość wychodząca jest deponowana w tym węźle." }, "reticulumPropagationHeader": { "modeLabel": "Tryb propagacji:", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 3f4ae36b7..c4423fb46 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Mensagem para encriptar", "shareAsPaperGenerate": "Criar QR de papel", "shareAsPaperCopyFailed": "Não foi possível copiar o link do papel", - "waitingMessagesSilentFetched": "Buscou {{processed}} no rádio..." + "waitingMessagesSilentFetched": "Obtidas {{processed}} mensagem(ns) do rádio…", + "reticulumSendStoringLocally": "Salvando na sua caixa de entrada de propagação local...", + "reticulumSendStoredLocally": "Mantido na sua caixa de entrada de propagação local (não entregue ao peer)", + "sentViaLocalPropagation": "Caixa de entrada de propagação local" }, "chatPayload": { "mention": "Mencionar {{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "Verificando suporte a /offer…", "syncLocalNotSupported": "O nó de propagação do host local não pode ser sincronizado pela rede como um nó de propagação LXMF remoto.", "enableFailed": "Não foi possível ativar o nó de propagação.", - "disableFailed": "Não foi possível desativar o nó de propagação." + "disableFailed": "Não foi possível desativar o nó de propagação.", + "syncOutboundBusy": "Sincronização de propagação adiada — uma mensagem de saída está sendo depositada neste nó." }, "reticulumPropagationHeader": { "modeLabel": "Modo de propagação", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 2fdeffcbb..fd635079f 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -677,7 +677,10 @@ "shareAsPaperMessageLabel": "Сообщение для шифрования", "shareAsPaperGenerate": "Создать бумажный QR-код", "shareAsPaperCopyFailed": "Не удалось скопировать ссылку на бумагу", - "waitingMessagesSilentFetched": "Получено {{processed}} из радио…" + "waitingMessagesSilentFetched": "Получено {{processed}} из радио…", + "reticulumSendStoringLocally": "Сохранение в локальный почтовый ящик распространения...", + "reticulumSendStoredLocally": "Хранится в локальном почтовом ящике распространения (не доставляется одноранговому узлу)", + "sentViaLocalPropagation": "Локальный почтовый ящик распространения" }, "chatPayload": { "mention": "Упоминание {{label}}", @@ -4240,7 +4243,8 @@ "addProbing": "Проверка поддержки /offer…", "syncLocalNotSupported": "Локальный узел распространения хоста не может быть синхронизирован по сети, как удаленный узел распространения LXMF.", "enableFailed": "Не удалось включить узел распространения.", - "disableFailed": "Не удалось отключить узел распространения." + "disableFailed": "Не удалось отключить узел распространения.", + "syncOutboundBusy": "Синхронизация распространения отложена — исходящее сообщение сохраняется на этом узле." }, "reticulumPropagationHeader": { "modeLabel": "Режим распространения", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index de2d1dfd4..af9c15e28 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Şifrelenecek mesaj", "shareAsPaperGenerate": "Kağıt QR oluştur", "shareAsPaperCopyFailed": "Kağıt bağlantısı kopyalanamadı", - "waitingMessagesSilentFetched": "Radyodan {{processed}} alındı…" + "waitingMessagesSilentFetched": "Radyodan {{processed}} alındı…", + "reticulumSendStoringLocally": "Yerel yayılım gelen kutunuza kaydediliyor…", + "reticulumSendStoredLocally": "Yerel yayılım gelen kutunuzda tutulur (akranınıza teslim edilmez)", + "sentViaLocalPropagation": "Yerel yayılım gelen kutusu" }, "chatPayload": { "mention": "{{label}}'dan bahsedin", @@ -4238,7 +4241,8 @@ "addProbing": "/offer desteği kontrol ediliyor…", "syncLocalNotSupported": "Yerel ana bilgisayar yayılım düğümü, uzak LXMF yayılım düğümü gibi ağ üzerinden senkronize edilemez.", "enableFailed": "Yayılma düğümü etkinleştirilemedi.", - "disableFailed": "Yayılma düğümü devre dışı bırakılamadı." + "disableFailed": "Yayılma düğümü devre dışı bırakılamadı.", + "syncOutboundBusy": "Yayılım senkronizasyonu ertelendi — giden bir mesaj bu düğüme bırakılıyor." }, "reticulumPropagationHeader": { "modeLabel": "Yayılma modu", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index e94c63bb7..0d1fbd9ed 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -677,7 +677,10 @@ "shareAsPaperMessageLabel": "Повідомлення для шифрування", "shareAsPaperGenerate": "Створити паперовий QR-код", "shareAsPaperCopyFailed": "Не вдалося скопіювати посилання на папір", - "waitingMessagesSilentFetched": "Отримано {{processed}} з радіо…" + "waitingMessagesSilentFetched": "Отримано {{processed}} з радіо…", + "reticulumSendStoringLocally": "Збереження до локальної скриньки поширення…", + "reticulumSendStoredLocally": "Зберігається у локальній скриньці поширення (не доставлено одноранговому вузлу)", + "sentViaLocalPropagation": "Локальна скринька поширення" }, "chatPayload": { "mention": "Згадайте {{label}}", @@ -4240,7 +4243,8 @@ "addProbing": "Перевірка підтримки /offer…", "syncLocalNotSupported": "Локальний хост-вузол поширення не можна синхронізувати через мережу, як віддалений вузол поширення LXMF.", "enableFailed": "Не вдалося ввімкнути вузол розповсюдження.", - "disableFailed": "Не вдалося вимкнути вузол розповсюдження." + "disableFailed": "Не вдалося вимкнути вузол розповсюдження.", + "syncOutboundBusy": "Синхронізація поширення відкладена — вихідне повідомлення зберігається на цьому вузлі." }, "reticulumPropagationHeader": { "modeLabel": "Режим поширення", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 583077adf..d3310b8c2 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "要加密的消息", "shareAsPaperGenerate": "创建纸质二维码", "shareAsPaperCopyFailed": "无法复制纸质链接", - "waitingMessagesSilentFetched": "已从收音机获取{{processed}} …" + "waitingMessagesSilentFetched": "已从收音机获取{{processed}} …", + "reticulumSendStoringLocally": "正在保存到本地传播收件箱…", + "reticulumSendStoredLocally": "保存在您的本地传播收件箱中(未送达对端)", + "sentViaLocalPropagation": "本地传播收件箱" }, "chatPayload": { "mention": "提及{{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "正在检查 /offer 支持…", "syncLocalNotSupported": "本地主机传播节点无法像远程 LXMF 传播节点一样通过网络同步。", "enableFailed": "无法启用传播节点。", - "disableFailed": "无法禁用传播节点。" + "disableFailed": "无法禁用传播节点。", + "syncOutboundBusy": "传播同步已延迟—出站消息正在存入此节点。" }, "reticulumPropagationHeader": { "modeLabel": "传播模式", diff --git a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts index e249cbe66..7200d3127 100644 --- a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts @@ -266,7 +266,7 @@ describe('useReticulumRuntime contact → nodeStore label preservation', () => { describe('useReticulumRuntime outbound delivery persistence', () => { it('persists Completes/Fails via applyReticulumOutboundDeliveryStatus', () => { expect(SOURCE).toMatch( - /evt\.type === 'lxmf_outbound_status'[\s\S]*?applyReticulumOutboundDeliveryStatus\(identityId, p\.message_hash, p\.status,\s*\{\s*sentVia: p\.sent_via,\s*deliveryMethod: p\.delivery_method,\s*\}\)/, + /evt\.type === 'lxmf_outbound_status'[\s\S]*?applyReticulumOutboundDeliveryStatus\(identityId, p\.message_hash, p\.status,\s*\{\s*sentVia: p\.sent_via,\s*deliveryMethod: p\.delivery_method,\s*deliveryAttempts: p\.delivery_attempts,\s*\}\)/, ); }); @@ -274,12 +274,28 @@ describe('useReticulumRuntime outbound delivery persistence', () => { expect(SOURCE).toMatch(/flushPendingReticulumOutboundDeliveryStatus\(identityId, hash\)/); }); - it('skips link-timeout failure bridge when remote PN fallback is available', () => { + it('skips link-timeout failure bridge when PN cascade is available', () => { expect(SOURCE).toContain('shouldApplyLinkDeliveryTimeoutFailureBridge'); expect(SOURCE).toMatch( /shouldApplyLinkDeliveryTimeoutFailureBridge\(\s*propState\.nodes,\s*propState\.preferredId,\s*\)/, ); - expect(SOURCE).toMatch(/if \(!applyBridge\) continue/); + expect(SOURCE).toContain('propagationHydratedForBridgeRef'); + expect(SOURCE).toContain('identityIdRef'); + expect(SOURCE).toMatch(/if \(!applyBridge\) \{/); + expect(SOURCE).toContain('cascade eligible'); + expect(SOURCE).toContain('propagation hydrate failed/uncertain'); + expect(SOURCE).toMatch( + /processedLinkTimeoutDestsRef\.current\.add\(norm\);\s*failReticulumSendingOutboundToDestHash/, + ); + }); + + it('aborts link-timeout bridge after delayed hydrate when generation is stale', () => { + expect(SOURCE).toContain('linkTimeoutBridgeGenerationRef'); + expect(SOURCE).toMatch(/const bridgeGeneration = linkTimeoutBridgeGenerationRef\.current/); + expect(SOURCE).toContain('generation stale after hydrate'); + // Generation bumps on identity change, tearDown, and disconnect. + const bumps = SOURCE.match(/linkTimeoutBridgeGenerationRef\.current \+= 1/g) ?? []; + expect(bumps.length).toBeGreaterThanOrEqual(3); }); it('wires propagation store + sidecar health into Reticulum diagnostics', () => { diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 2fbd8a665..e4a205848 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -291,6 +291,11 @@ export function useReticulumRuntime(): ProtocolRuntime { const stateRef = useRef(state); const localInterfacesRef = useRef([]); const processedLinkTimeoutDestsRef = useRef(new Set()); + /** Defer link-timeout failure bridge until first propagation store refresh completes. */ + const propagationHydratedForBridgeRef = useRef(false); + /** Bumped on identity change / tearDown / disconnect to abort stale bridge IIFEs. */ + const linkTimeoutBridgeGenerationRef = useRef(0); + const identityIdRef = useRef(identityId); const nodeStoreSlice = useNodeStore((s) => (identityId ? s.nodes[identityId] : undefined)); // Include `connecting`: main suspends Noble at sidecar start before status reaches @@ -307,6 +312,16 @@ export function useReticulumRuntime(): ProtocolRuntime { stateRef.current = state; }, [state]); + useEffect(() => { + identityIdRef.current = identityId; + }, [identityId]); + + useEffect(() => { + processedLinkTimeoutDestsRef.current.clear(); + propagationHydratedForBridgeRef.current = false; + linkTimeoutBridgeGenerationRef.current += 1; + }, [identityId]); + const selfNodeId = useMemo( () => (selfLxmfHash ? reticulumHashToNodeId(selfLxmfHash) : null), [selfLxmfHash], @@ -819,11 +834,13 @@ export function useReticulumRuntime(): ProtocolRuntime { status?: string; sent_via?: string; delivery_method?: string; + delivery_attempts?: number; }; if (identityId && p.message_hash && p.status) { applyReticulumOutboundDeliveryStatus(identityId, p.message_hash, p.status, { sentVia: p.sent_via, deliveryMethod: p.delivery_method, + deliveryAttempts: p.delivery_attempts, }); } } @@ -1461,6 +1478,8 @@ export function useReticulumRuntime(): ProtocolRuntime { setRawPackets([]); clearReticulumSessionStores(); processedLinkTimeoutDestsRef.current.clear(); + propagationHydratedForBridgeRef.current = false; + linkTimeoutBridgeGenerationRef.current += 1; setReticulumBleBondDesyncActive(false); setReticulumAnnounceBusPressureActive(false); setState(INITIAL_STATE); @@ -1483,23 +1502,87 @@ export function useReticulumRuntime(): ProtocolRuntime { void syncDiagnosticsFromSidecar(); const timeouts = status.interfaceIssueAlert?.linkDeliveryTimeouts; if (identityId && timeouts?.length) { - const propState = useReticulumPropagationStore.getState(); - const applyBridge = shouldApplyLinkDeliveryTimeoutFailureBridge( - propState.nodes, - propState.preferredId, - ); - for (const { destinationHash } of timeouts) { - const norm = destinationHash.replace(/[^0-9a-f]/gi, '').toLowerCase(); - if (!norm || processedLinkTimeoutDestsRef.current.has(norm)) continue; - processedLinkTimeoutDestsRef.current.add(norm); - // Remote preferred PN: sidecar Direct→PN fallback owns the outcome via WS. - if (!applyBridge) continue; - failReticulumSendingOutboundToDestHash( - identityId, - norm, - i18n.t('chatPanel.reticulumSendFailed'), + const bridgeIdentityId = identityId; + const bridgeGeneration = linkTimeoutBridgeGenerationRef.current; + void (async () => { + if (!propagationHydratedForBridgeRef.current) { + const stampBefore = useReticulumPropagationStore.getState().lastRefreshedAt; + try { + await useReticulumPropagationStore.getState().refreshFromSidecar(); + } catch (e: unknown) { + console.debug( + '[useReticulumRuntime] propagation hydrate for link-timeout bridge ' + + errLikeToLogString(e), + ); + } + if ( + identityIdRef.current !== bridgeIdentityId || + linkTimeoutBridgeGenerationRef.current !== bridgeGeneration + ) { + console.debug( + '[useReticulumRuntime] link-timeout bridge abort — generation stale after hydrate', + ); + return; + } + const stampAfter = useReticulumPropagationStore.getState().lastRefreshedAt; + const hydratedOk = stampAfter != null && stampAfter !== stampBefore; + if (!hydratedOk) { + console.debug( + '[useReticulumRuntime] link-timeout bridge skip — propagation hydrate failed/uncertain', + ); + return; + } + propagationHydratedForBridgeRef.current = true; + } + if ( + identityIdRef.current !== bridgeIdentityId || + linkTimeoutBridgeGenerationRef.current !== bridgeGeneration + ) { + return; + } + const propState = useReticulumPropagationStore.getState(); + // Empty + no preferred + never refreshed: cascade capacity unknown — do not fail DMs. + if ( + propState.nodes.length === 0 && + propState.preferredId == null && + propState.lastRefreshedAt == null + ) { + console.debug( + '[useReticulumRuntime] link-timeout bridge skip — propagation state uncertain', + ); + return; + } + const applyBridge = shouldApplyLinkDeliveryTimeoutFailureBridge( + propState.nodes, + propState.preferredId, ); - } + console.debug( + `[useReticulumRuntime] link-timeout bridge apply=${applyBridge} preferred=${propState.preferredId ?? 'none'} nodes=${propState.nodes.length}`, + ); + for (const { destinationHash } of timeouts) { + if ( + identityIdRef.current !== bridgeIdentityId || + linkTimeoutBridgeGenerationRef.current !== bridgeGeneration + ) { + return; + } + const norm = destinationHash.replace(/[^0-9a-f]/gi, '').toLowerCase(); + if (!norm || processedLinkTimeoutDestsRef.current.has(norm)) continue; + // PN cascade (remote or local-prop): sidecar owns outcome via WS. + if (!applyBridge) { + console.debug( + `[useReticulumRuntime] link-timeout bridge skip dest=${norm.slice(0, 8)}… (cascade eligible)`, + ); + continue; + } + processedLinkTimeoutDestsRef.current.add(norm); + failReticulumSendingOutboundToDestHash( + bridgeIdentityId, + norm, + i18n.t('chatPanel.reticulumSendFailed'), + ); + } + })(); } } if (status.running) return; @@ -1677,6 +1760,8 @@ export function useReticulumRuntime(): ProtocolRuntime { setRawPackets([]); clearReticulumSessionStores(); processedLinkTimeoutDestsRef.current.clear(); + propagationHydratedForBridgeRef.current = false; + linkTimeoutBridgeGenerationRef.current += 1; setReticulumBleBondDesyncActive(false); setReticulumAnnounceBusPressureActive(false); setState(INITIAL_STATE); diff --git a/src/renderer/stores/messageStore.ts b/src/renderer/stores/messageStore.ts index 0ac9ceac2..fb98957bf 100644 --- a/src/renderer/stores/messageStore.ts +++ b/src/renderer/stores/messageStore.ts @@ -51,8 +51,10 @@ export interface MessageRecord { reticulumSenderHash?: string; /** Reticulum reply target message hash (hex). */ reticulumReplyToHash?: string; - /** Reticulum LXMF delivery method when queued (direct / propagated / opportunistic / paper). */ + /** Reticulum LXMF delivery method when queued (direct / propagated / opportunistic / paper / stored_locally). */ reticulumDeliveryMethod?: ReticulumDeliveryMethod; + /** Sidecar outbound delivery_attempts (for triage dumps; optional). */ + reticulumDeliveryAttempts?: number; /** Saved attachment path on disk (local saves). */ reticulumAttachmentPath?: string; /** Message was replayed from a Store & Forward server (Meshtastic only). */ @@ -95,6 +97,7 @@ const MESSAGE_RECORD_KEYS: (keyof MessageRecord)[] = [ 'reticulumSenderHash', 'reticulumReplyToHash', 'reticulumDeliveryMethod', + 'reticulumDeliveryAttempts', 'reticulumAttachmentPath', 'viaStoreForward', ]; diff --git a/src/renderer/stores/reticulumPeerStore.test.ts b/src/renderer/stores/reticulumPeerStore.test.ts index 6af376dc3..87cb689d3 100644 --- a/src/renderer/stores/reticulumPeerStore.test.ts +++ b/src/renderer/stores/reticulumPeerStore.test.ts @@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ReticulumContact } from '@/shared/reticulum-types'; import { reticulumHashToNodeId } from '../lib/reticulum/destHash'; +import { + noteReticulumProxyRateLimitHit, + resetReticulumProxyRateLimitBackoffForTests, +} from '../lib/reticulum/reticulumProxyRateLimitBackoff'; import { applyReticulumAnnounceReceivedOptimistic, applyReticulumPeerPatchesNow, @@ -349,6 +353,7 @@ describe('reticulumPeerStore', () => { beforeEach(() => { resetReticulumPeerRefreshSingleFlightForTests(); resetReticulumPeerPatchBufferForTests(); + resetReticulumProxyRateLimitBackoffForTests(); useReticulumPeerStore.setState({ peers: new Map(), contacts: new Map(), @@ -966,6 +971,34 @@ describe('reticulumPeerStore', () => { debug.mockRestore(); }); + it('refreshReticulumPeersFromSidecar skips only on shared backoff (not lxmfRecent)', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'debug').mockImplementation(() => {}); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const proxyGet = vi.fn((path: string) => { + if (path.startsWith('/api/v1/peers')) return Promise.resolve({ peers: [] }); + if (path === '/api/v1/contacts') return Promise.resolve({ contacts: [] }); + if (path === '/api/v1/nomadnetwork/nodes') return Promise.resolve({ nodes: [] }); + return Promise.resolve({}); + }); + vi.stubGlobal('window', { + electronAPI: { + reticulum: { proxyGet }, + db: { getReticulumDestinations: vi.fn().mockResolvedValue([]) }, + }, + }); + + noteReticulumProxyRateLimitHit('lxmfRecent'); + await refreshReticulumPeersFromSidecar(); + expect(proxyGet).toHaveBeenCalled(); + + proxyGet.mockClear(); + resetReticulumProxyRateLimitBackoffForTests(); + noteReticulumProxyRateLimitHit('shared'); + await refreshReticulumPeersFromSidecar({ forceRefresh: true }); + expect(proxyGet).not.toHaveBeenCalled(); + }); + it('refreshReticulumPeersFromSidecar OR-accumulates forceRefresh across coalesced callers', async () => { let releaseFirst!: () => void; const firstGate = new Promise((resolve) => { diff --git a/src/renderer/stores/reticulumPeerStore.ts b/src/renderer/stores/reticulumPeerStore.ts index d22c05291..291b6f111 100644 --- a/src/renderer/stores/reticulumPeerStore.ts +++ b/src/renderer/stores/reticulumPeerStore.ts @@ -14,6 +14,12 @@ import { activeReticulumPathSlot, type ReticulumPathSlot, } from '@/renderer/lib/reticulum/reticulumPathSlots'; +import { + clearReticulumProxyRateLimitBackoff, + isReticulumProxyRateLimitBackoffActive, + noteReticulumProxyErrorIfRateLimited, + reticulumProxyRateLimitBackoffRemainingMs, +} from '@/renderer/lib/reticulum/reticulumProxyRateLimitBackoff'; import { MAX_MESH_ENTITY_CAP } from '@/renderer/lib/sessionMemoryCaps'; import { useNodeStore } from '@/renderer/stores/nodeStore'; import { @@ -1277,24 +1283,39 @@ export function refreshReticulumPeersFromSidecar( peerRefreshInFlight = (async () => { try { + if (isReticulumProxyRateLimitBackoffActive('shared')) { + // Keep coalesce flags so a force refresh is not dropped while backoff is active. + if (opts.forceRefresh) peerRefreshPendingForce = true; + if (!opts.skipNomad) peerRefreshPendingSkipNomad = false; + peerRefreshPendingRerun = true; + console.debug( + `[reticulumPeerStore] refresh skipped — proxy rate-limit backoff remaining=${reticulumProxyRateLimitBackoffRemainingMs('shared')}ms`, + ); + return [...useReticulumPeerStore.getState().contacts.values()]; + } let forceRefresh = Boolean(opts.forceRefresh) || peerRefreshPendingForce; let skipNomad = Boolean(opts.skipNomad) && peerRefreshPendingSkipNomad; peerRefreshPendingForce = false; peerRefreshPendingSkipNomad = true; peerRefreshPendingRerun = false; let result = await refreshReticulumPeersFromSidecarOnce({ forceRefresh, skipNomad }); + clearReticulumProxyRateLimitBackoff('shared'); while (peerRefreshPendingRerun) { + // Leave peerRefreshPendingRerun / force / skipNomad set so the next refresh + // after backoff still honors a coalesced force refresh. + if (isReticulumProxyRateLimitBackoffActive('shared')) break; peerRefreshPendingRerun = false; forceRefresh = peerRefreshPendingForce; skipNomad = peerRefreshPendingSkipNomad; peerRefreshPendingForce = false; peerRefreshPendingSkipNomad = true; result = await refreshReticulumPeersFromSidecarOnce({ forceRefresh, skipNomad }); + clearReticulumProxyRateLimitBackoff('shared'); } return result; } catch (e) { const msg = errLikeToLogString(e); - if (msg.toLowerCase().includes('rate limit exceeded')) { + if (noteReticulumProxyErrorIfRateLimited(e, 'shared')) { console.debug('[reticulumPeerStore] refresh ' + msg); throw e instanceof Error ? e : new Error(msg); } @@ -1302,8 +1323,11 @@ export function refreshReticulumPeersFromSidecar( return []; } finally { peerRefreshInFlight = null; - peerRefreshPendingForce = false; - peerRefreshPendingSkipNomad = true; + // Preserve coalesce intent when we broke out for shared-bucket backoff. + if (!peerRefreshPendingRerun) { + peerRefreshPendingForce = false; + peerRefreshPendingSkipNomad = true; + } } })(); diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index fa58f14e1..ad099c369 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -1160,7 +1160,7 @@ export interface ElectronAPI { }; /** * LRGP games (lrgp-rs). Dedicated IPC channels — generic `proxyGet`/`proxyPost` - * reject `/api/v1/games/*` so session polls/moves do not share the 300/min proxy bucket. + * reject `/api/v1/games/*` so session polls/moves do not share the 900/min proxy bucket. */ games: { getStatus: () => Promise; diff --git a/src/shared/reticulumApiPaths.ts b/src/shared/reticulumApiPaths.ts new file mode 100644 index 000000000..8cac1b9ee --- /dev/null +++ b/src/shared/reticulumApiPaths.ts @@ -0,0 +1,4 @@ +/** Shared sidecar HTTP path constants (main proxy allowlists + renderer fetch). */ + +/** Inbound LXMF catch-up ring (`GET` with optional since_ts / since_seq / limit). */ +export const RETICULUM_LXMF_RECENT_API_PATH = '/api/v1/lxmf/recent'; diff --git a/src/shared/reticulumDeliveryMethod.test.ts b/src/shared/reticulumDeliveryMethod.test.ts index 57766fd95..b5c2a2db5 100644 --- a/src/shared/reticulumDeliveryMethod.test.ts +++ b/src/shared/reticulumDeliveryMethod.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { parseReticulumDeliveryMethod } from '@/shared/reticulumDeliveryMethod'; +import { + isPnCascadeDeliveryMethod, + parseReticulumDeliveryMethod, +} from '@/shared/reticulumDeliveryMethod'; describe('parseReticulumDeliveryMethod', () => { it('accepts known methods case-insensitively', () => { @@ -8,6 +11,8 @@ describe('parseReticulumDeliveryMethod', () => { expect(parseReticulumDeliveryMethod('Propagated')).toBe('propagated'); expect(parseReticulumDeliveryMethod('opportunistic')).toBe('opportunistic'); expect(parseReticulumDeliveryMethod('paper')).toBe('paper'); + expect(parseReticulumDeliveryMethod('stored_locally')).toBe('stored_locally'); + expect(parseReticulumDeliveryMethod('Stored_Locally')).toBe('stored_locally'); }); it('rejects unknown or empty values', () => { @@ -17,3 +22,13 @@ describe('parseReticulumDeliveryMethod', () => { expect(parseReticulumDeliveryMethod('garbage')).toBeUndefined(); }); }); + +describe('isPnCascadeDeliveryMethod', () => { + it('is true for propagated and stored_locally only', () => { + expect(isPnCascadeDeliveryMethod('propagated')).toBe(true); + expect(isPnCascadeDeliveryMethod('stored_locally')).toBe(true); + expect(isPnCascadeDeliveryMethod('direct')).toBe(false); + expect(isPnCascadeDeliveryMethod('paper')).toBe(false); + expect(isPnCascadeDeliveryMethod(undefined)).toBe(false); + }); +}); diff --git a/src/shared/reticulumDeliveryMethod.ts b/src/shared/reticulumDeliveryMethod.ts index cf9da1562..3dd6cfc7b 100644 --- a/src/shared/reticulumDeliveryMethod.ts +++ b/src/shared/reticulumDeliveryMethod.ts @@ -4,6 +4,8 @@ export const RETICULUM_DELIVERY_METHODS = [ 'propagated', 'opportunistic', 'paper', + /** Offline local-prop inbox — not peer-delivered. */ + 'stored_locally', ] as const; export type ReticulumDeliveryMethod = (typeof RETICULUM_DELIVERY_METHODS)[number]; @@ -18,3 +20,8 @@ export function parseReticulumDeliveryMethod( const normalized = value.trim().toLowerCase(); return ALLOWED.has(normalized) ? (normalized as ReticulumDeliveryMethod) : undefined; } + +/** Direct→PN cascade in flight / stored (remote PN or local-prop inbox). */ +export function isPnCascadeDeliveryMethod(m: ReticulumDeliveryMethod | undefined): boolean { + return m === 'propagated' || m === 'stored_locally'; +}