diff --git a/AGENTS.md b/AGENTS.md index f1a48db0b..1a6b72e9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,14 +144,14 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). ### Reticulum -- **Sidecar:** `reticulum-sidecar/` (AGPL Rust binary `mesh-client-reticulum`; path deps `rsReticulum`/`rsLXMF`/`rsNomad`); dev: `pnpm run reticulum:sidecar:dev` +- **Sidecar:** `reticulum-sidecar/` (AGPL Rust binary `mesh-client-reticulum`; path deps `rsReticulum`/`rsLXMF`/`rsNomad`); dev: `pnpm run reticulum:sidecar:dev`. **Listen-first:** HTTP binds before `attach_live`; `/api/v1/status` `status: ok` = listening; `rns_ready`/`lxmf_ready` false until live. PN messagestore load deferred; local-prop serve waits for load. LXMF send/reaction fail closed with live-required errors until live. - **IPC:** `reticulum:*` main handlers — `start` / `stop` / `getStatus` / `syncInterfaceIssueScope`, `proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete`, **`factoryReset`** (blocked on generic proxy), config file read/import dialog, `showNomadContentSourceDialog`, `setNomadContentSource`, Remote `rncpSend` / `rncpFetch` / `setRncpListener` / `showRncpOpenFileDialog` / `showRncpSaveDirectoryDialog` / `revealInFolder`. Also `media:ensureCameraAccess`, `gps:exportGpx`, `db:setReticulumDestinationVerified`, Remote DB `db:listReticulumRemoteAddresses` / upsert / delete and `db:listReticulumInboundPolicy` / upsert / delete (`src/main/ipc/reticulum-db-handlers.ts`), `mesh-client:openUrl` / `electronAPI.deepLink.onOpenUrl`. Renderer uses `electronAPI.reticulum` proxy (no direct localhost). `ReticulumStackPanel` + `useReticulumInterfaceSnapshot` sync enabled interface names after hydrate so TCP/TX issue banners clear when hubs are disabled; `reticulumSidecarIssueTracker` keeps that enabled set sticky while reading sidecar logs. - **Panels:** `ReticulumStackPanel` (Connection — stack lifecycle, interfaces, issue banner), `ReticulumNetworkPanel` (Network — identity **slots** + QR share/ingest, stack/announce settings, propagation rename/delete, config import), `ChatDmPaperControls` (Chat DM **Share as paper** + **Scan paper**), `ReticulumMapPanel` (Map — RMAP v4 discovery), `ReticulumRmapDiscoveryControls` / `ReticulumRmapConnectionStatus` (RMAP publish: Network enable-all eligible interfaces; Connection **X of Y** status), `ReticulumAdminPanel` (Admin — RNode flasher, factory reset), `ReticulumPeerListPanel` (Peers — **Peers / History / Contacts / Favorites** sub-tabs; path request + probe + verified badge; LXMFace avatars; History = messaged `last_heard`, Contacts = explicit `is_contact` / Save as contact only), `NomadNetworkPanel` (Nomad — browse + **My Pages** watched-folder static host via `NomadPageServerPanel`/rsNomad; `nomad_serving_enabled` + `nomad_serving_content_source` restore hosting after live stack start; lazy-mount keep-alive, dual-axis page scroll; fit-width default and open-width toggle), `ReticulumRemotePanel` (Remote — rnsh multi-session shell + rncp send/receive/fetch; Saved addresses + inbound policy; Chat DM send-file via `ChatDmRncpControl`), `RrcPanel` (RRC — multi-hub relay chat) - **Deep links / QR:** OS scheme is **`lxm://`** (not `mesh-client://`); `MeshClientDeepLinkHost`, `meshClientDeepLink.ts` (`lxmPaperMessage` kind + `looksLikeLxmPaperBlob`; Games `lxm://game/` / Ratspeak `lrgp:` → `lxmGameSession`), `handleReticulumQrIngest.ts` (shared Network/Chat/OS paper + in-app contact ingest), `applyLxmPaperIngest` → `POST /api/v1/lxmf/paper/ingest`, `QrIngestControl` / `QrCodeImage`. OS contact / MeshCore imports confirm before upsert; **paper OS deep links ingest without confirm**; Games session links open Reticulum Games tab via `openReticulumGameSession`. - **Decommissioned hubs:** `src/shared/reticulumDecommissionedHubs.ts` (Amsterdam only) — stack-start auto-disable + **Add default backbones** disables matching enabled TCP rows; UI badge + enable-block in `ReticulumInterfacesPanel.tsx` (`isDecommissionedReticulumTcpInterfaceRow`); keep TS↔Rust synced via `pnpm run check:reticulum-decommissioned-hubs`. Default backbone picker + region-grouped interface list (Primary & Global / North America / Europe / Asia & Oceania / Specialty / User Defined) in `reticulumDefaultHubPresets.ts` + `ReticulumDefaultHubsPickerModal.tsx`; muted disabled rows + checkbox bulk delete; `countEnabledDefaultHubPresets` / >3 enable warning - **BLE RNode RSSI:** `useReticulumBleRnodeRssiMap` gates on sidecar **running** (not api-ready), burst-then-steady scans via nested `acquireReticulumBleScan`, clears sticky targets immediately when all BLE RNodes are disabled - **Propagation sync:** `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Complete on HaveAll, Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation — `refreshFromSidecar` must **not** clear the active attempt while `sync.active` -- **PN hosting:** Network **Advanced PN hosting** / `ReticulumPnHostingDangerZone`; shared `pnHostingPolicy.ts` + sidecar `pn_hosting_policy.rs` / `pn_hosting_apply.rs`; `POST /api/v1/propagation/hosting-policy`; rsLXMF policy-setters overlay ([ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6)) +- **PN hosting:** Network **Advanced PN hosting** / `ReticulumPnHostingDangerZone`; shared `pnHostingPolicy.ts` + sidecar `pn_hosting_policy.rs` / `pn_hosting_apply.rs`; `POST /api/v1/propagation/hosting-policy`; rsLXMF policy-setters overlay ([ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6)). Messagestore loads in background on live attach; enabled `local-prop` serve/announce waits until load completes. - **Interface modes:** rnsd `mode` via `reticulumInterfaceMode.ts` + sidecar `normalize_interface_mode` (keep catalogs in sync — `pnpm run check:reticulum-interface-modes` in pre-commit/`release.sh`); add defaults TCP/UDP/I2P → `boundary`, RNode → `access_point`; UI in `ReticulumInterfacesPanel`; default hub presets add/repair missing mode to `boundary` (do not overwrite valid non-boundary). See [docs/reticulum.md#interface-modes](docs/reticulum.md#interface-modes). - **Share instance defaults:** missing keys bootstrap to `share_instance = No` / `instance_name = mesh-client` (does not overwrite explicit Yes/`default`); SharedInstanceClient banner + `disable_share_instance` repair; offline lint via `reticulum:validateConfig` / Network **Check config** / `pnpm run reticulum:config:check` - **LXMF replies:** sidecar stamps `FIELD_REPLY_TO` / capped `FIELD_REPLY_QUOTE` before sign; renderer ingest/Chat use `reticulum_reply_to_hash` + quote preview + jump-by-hash @@ -169,10 +169,10 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **LRGP games:** `hasLrgpGames` gates Games tab + Challenge (Peers / Chat DM). Sidecar `games_session` + `LrgpStore`; companion `games_outbound.db` persists last envelope + `delivery_state` (LXMF outbound bridge → session chips / Resend). Dedicated IPC `electronAPI.reticulum.games.*` / `reticulum:games*` (proxy rejects `/api/v1/games/*`); WS `games.update` / `games.action_result`. Parity: [docs/reticulum-games-parity.md](docs/reticulum-games-parity.md). - **Gating:** `hasReticulumDiscoveryMap` (Map tab); `hasReticulumRemotePanel` / `hasRncpTransfer` (Remote tab + Chat DM rncp); `hasRrcPanel` (RRC tab); `hasLxstVoice` (LXST Call); `hasLrgpGames` (Games); `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities` - **rnsh/rncp:** sidecar `stack/{rnsh_session,rncp_transfer,path_speed,link_task}.rs` + HTTP `/api/v1/rnsh/*`, `/api/v1/rncp/*`, `/api/v1/remote/*`; typed `electronAPI.reticulum.rnsh|rncp|remote`; picker-gated send/fetch paths in `reticulum-remote-paths.ts`; LXMF enable-request sentinel `mesh-client:request-rncp-receive:v1` (`rncpRequestEnable.ts`); peer reply `mesh-client:rncp-receive-dest:v1:` autofills via `applyRncpReceiveDestShare` (prefer pending from `markRncpReceiveDestSharePending` / `sendRncpRequestEnable`; still apply without pending for older peers); enable-request modal + dest-share side effects deduped by LXMF `message_hash` (`rncpLxmfControlSideEffectDedup`) so catch-up cannot re-fire; already-listening auto-share is once per peer per request-enable cooldown; inbound listener config persists (`rncp_listener_*` in `mesh_client_stack.json`) and restores on live stack start -- **Runtime:** `useReticulumRuntime`, `lib/sessions/reticulumSession.ts`, `lib/ingest/reticulumIngest.ts`; connect starts sidecar, not `ConnectionDriver` RF. Sidecar RRC: `rrc_codec` / `rrc_link` / `rrc_session` / `api/rrc.rs` -- **Diagnostics:** `ReticulumDiagnosticEngine.ts` (Reticulum-native rows; no LoRa hop-goblin semantics) — includes `reticulum/sidecar-unhealthy` (60s grace), `reticulum/propagation-sync-stuck`, `reticulum/propagation-sync-failing` (1h TTL) +- **Runtime:** `useReticulumRuntime`, `lib/sessions/reticulumSession.ts`, `lib/ingest/reticulumIngest.ts`; connect starts sidecar, not `ConnectionDriver` RF — marks **configured** when HTTP + identity ready (live attach may still run); `RETICULUM_CONFIGURED_EVENT` wakes RRC. Cancel/stop is fire-and-forget vs cargo/BLE (`START_ABORTED` checkpoints; next start does not rejoin a doomed promise). LXMF/RRC proxy sends: **15 s** `RETICULUM_IPC_SEND_TIMEOUT_MS`. RRC auto-connect (`useRrcStartupAutoConnect`): ~**500 ms** while hubs pending, ~4 s steady. Sidecar RRC: `rrc_codec` / `rrc_link` / `rrc_session` / `api/rrc.rs` +- **Diagnostics:** `ReticulumDiagnosticEngine.ts` (Reticulum-native rows; no LoRa hop-goblin semantics) — includes `reticulum/sidecar-unhealthy` (60s grace; HTTP health, not listen-first ready lag), `reticulum/rns-not-ready` / `reticulum/lxmf-not-ready`, `reticulum/propagation-sync-stuck`, `reticulum/propagation-sync-failing` (1h TTL) - **No Noble/MQTT** for Reticulum's own connections (sidecar owns BLE RNode via `btleplug`); gate UI with `hasReticulumInterfaceConfig` / `hasReticulumNetworkPanel` / `ProtocolCapabilities`. On macOS/Windows, connecting a Reticulum BLE RNode may still **suspend/yield Noble** so it does not contend with the sidecar's BLE scan — see **Multi-protocol BLE** below. -- **Multi-protocol BLE:** Meshtastic, MeshCore, and Reticulum (BLE Peer + `ble://` RNode) may connect to **different** BLE devices at once on all platforms. Coexistence: `ble-coexistence-coordinator.ts` (peripheral MAC registry + scan-only mutex); Linux mesh uses Web Bluetooth + sidecar `btleplug`. Same MAC rejected; scans serialized—never disconnect unrelated GATT for scans. **Reticulum BLE RNode** on macOS/Windows may **suspend Noble** (`suspendNobleForReticulumBleConnect`, `reticulum-ble-rnode-config.ts`, `reticulumNobleBleYield.ts`, `useReticulumNobleBleYieldWatcher`); while yield holds the scan, Noble connect is rejected; post-grace yield stops re-contending (**~60s** grace aligns with OS passkey window); disconnect timeout fails closed (releases scan). Noble yield sync is **only** in `useReticulumNobleBleYieldWatcher` (always mounted from `useReticulumRuntime`, including `connecting`); **`useReticulumInterfaceSnapshot` must not release yield** (mid-pair release caused CoreBluetooth “Event receiver died”). Shared grace clock: `reticulumBleConnectGrace.ts` (watcher + snapshot). Watcher uses `AbortSignal` to avoid stale inactive release; renews grace on stack restart when main re-holds `scanOwner=reticulum` and local yield is inactive. Meshtastic/MeshCore RF autostart waits `awaitReticulumBleCoexistenceClear()` (`reticulumStartupAutostartGate.ts`, default **~65 s** = 60 s grace + 5 s buffer). Sidecar may latch **`bleBondRemoved`** (stale OS bonds) or **`blePairingTimedOut`** (passkey not entered) — Forget/re-pair; Admin Start pairing shows PIN in-panel over USB (not radio display; never Meshtastic `123456`). Release dispatches `mesh-client:nobleBleYieldReleased` for Meshtastic/MeshCore reconnect. +- **Multi-protocol BLE:** Meshtastic, MeshCore, and Reticulum (BLE Peer + `ble://` RNode) may connect to **different** BLE devices at once on all platforms. Coexistence: `ble-coexistence-coordinator.ts` (peripheral MAC registry + scan-only mutex); Linux mesh uses Web Bluetooth + sidecar `btleplug`. Same MAC rejected; scans serialized—never disconnect unrelated GATT for scans. **Reticulum BLE RNode** on macOS/Windows may **suspend Noble** (`suspendNobleForReticulumBleConnect`, `reticulum-ble-rnode-config.ts`, `reticulumNobleBleYield.ts`, `useReticulumNobleBleYieldWatcher`) — main kicks yield **after** sidecar HTTP health (not during cargo/spawn) so Cancel does not yank LoRa BLE; while yield holds the scan, Noble connect is rejected; post-grace yield stops re-contending (**~60s** grace aligns with OS passkey window); disconnect timeout fails closed (releases scan). Noble yield sync is **only** in `useReticulumNobleBleYieldWatcher` (always mounted from `useReticulumRuntime`, including `connecting`); **`useReticulumInterfaceSnapshot` must not release yield** (mid-pair release caused CoreBluetooth “Event receiver died”). Shared grace clock: `reticulumBleConnectGrace.ts` (watcher + snapshot). Watcher uses `AbortSignal` to avoid stale inactive release; renews grace on stack restart when main re-holds `scanOwner=reticulum` and local yield is inactive. Meshtastic/MeshCore RF autostart waits `awaitReticulumBleCoexistenceClear()` (`reticulumStartupAutostartGate.ts`, default **~65 s** = 60 s grace + 5 s buffer). Sidecar may latch **`bleBondRemoved`** (stale OS bonds) or **`blePairingTimedOut`** (passkey not entered) — Forget/re-pair; Admin Start pairing shows PIN in-panel over USB (not radio display; never Meshtastic `123456`). Release dispatches `mesh-client:nobleBleYieldReleased` for Meshtastic/MeshCore reconnect. - **Docs:** [docs/reticulum.md](docs/reticulum.md), [docs/reticulum-sidecar-ipc.md](docs/reticulum-sidecar-ipc.md) ### Diagnostics diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 4fe632882..957427a22 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -433,14 +433,16 @@ Runtime interface-issue rows from the sidecar latch (`interfaceIssueAlert`) are Additional runtime rows (refreshed from sidecar status + `reticulumPropagationStore`, not only the config audit poll): -| Condition | Trigger | Severity | Action | -| -------------------------------------- | --------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `reticulum/sidecar-unhealthy` | Sidecar `running && healthy === false` for ≥ 60 s (`sidecarUnhealthySince`) | error | **Restart stack** | -| `reticulum/too-many-default-backbones` | More than 3 enabled interfaces matching default backbone presets (`countEnabledDefaultHubPresets`) | warning | **Open Interfaces** — disable extras (about 2 is the sweet spot) | -| `reticulum/decommissioned-hub-enabled` | Enabled TCP client pointed at a denylisted official-testnet endpoint (Amsterdam) | warning | **Disable** | -| `reticulum/announce-bus-pressure` | Recent WS `events_lagged` (skipped ≥ 8) **or** sidecar `announce_ws` storm/overflow within 5 min | warning | Tips under issue (hot interface / boundary hubs / TX drops when known) + **Open Interfaces**; while active, unknown-aspect `reticulum_identity_activity` SQLite writes are skipped to reduce DB load | -| `reticulum/propagation-sync-stuck` | Sync active ≥ ~45 s (`RETICULUM_PROPAGATION_SYNC_STALL_MS`) with progress still Establishing (< 15) | warning | Retry sync; check PN path / announce | -| `reticulum/propagation-sync-failing` | Sync idle with `lastSyncError` (excludes user cancel) and attempt within 1 h | warning | See Network → Propagation / troubleshooting | +| Condition | Trigger | Severity | Action | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `reticulum/sidecar-unhealthy` | Sidecar `running && healthy === false` for ≥ 60 s (`sidecarUnhealthySince`) — HTTP `/status` not `ok` (process hung), **not** the brief listen-first window before live attach | error | **Restart stack** | +| `reticulum/rns-not-ready` | Sidecar snapshot `rns_ready === false` (common for a few seconds after listen-first start) | warning | Wait for live attach, or **Restart stack** if stuck | +| `reticulum/lxmf-not-ready` | Sidecar snapshot `lxmf_ready === false` (same listen-first window as RNS) | warning | Wait for live attach, or **Restart stack** if stuck | +| `reticulum/too-many-default-backbones` | More than 3 enabled interfaces matching default backbone presets (`countEnabledDefaultHubPresets`) | warning | **Open Interfaces** — disable extras (about 2 is the sweet spot) | +| `reticulum/decommissioned-hub-enabled` | Enabled TCP client pointed at a denylisted official-testnet endpoint (Amsterdam) | warning | **Disable** | +| `reticulum/announce-bus-pressure` | Recent WS `events_lagged` (skipped ≥ 8) **or** sidecar `announce_ws` storm/overflow within 5 min | warning | Tips under issue (hot interface / boundary hubs / TX drops when known) + **Open Interfaces**; while active, unknown-aspect `reticulum_identity_activity` SQLite writes are skipped to reduce DB load | +| `reticulum/propagation-sync-stuck` | Sync active ≥ ~45 s (`RETICULUM_PROPAGATION_SYNC_STALL_MS`) with progress still Establishing (< 15) | warning | Retry sync; check PN path / announce | +| `reticulum/propagation-sync-failing` | Sync idle with `lastSyncError` (excludes user cancel) and attempt within 1 h | warning | See Network → Propagation / troubleshooting | Announce-bus pressure attribution: when a single interface owns ≥50% of recent `peers_updated` path samples (min 20 in 5 min), Diagnostics names that **hot interface**. Enabled `boundary`-mode hubs (or default-preset hubs) and any `txQueueDrops` interface names are listed in tips. Path/peer tables stay in memory; announce storms mainly threatened batched identity-activity SQLite writes, which are gated under pressure. diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 1ce005ac6..72d780ff6 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -10,10 +10,12 @@ Electron main validates proxy paths: must start with `/api/v1/` (no `..` segment ### Status and app -| Method | Path | Body / notes | Response | -| ------ | ------------------ | ------------ | -------------------------------------------------- | -| GET | `/api/v1/status` | | `{ status, version, rns_ready, lxmf_ready }` | -| GET | `/api/v1/app/info` | | `{ sidecar_version, rns_version?, lxmf_version? }` | +| Method | Path | Body / notes | Response | +| ------ | ------------------ | ------------ | -------------------------------------------------------------------------- | +| GET | `/api/v1/status` | | `{ status, version, rns_ready, lxmf_ready }` — see listen-first note below | +| GET | `/api/v1/app/info` | | `{ sidecar_version, rns_version?, lxmf_version? }` | + +**Listen-first / ready flags:** `status: "ok"` means the HTTP server is accepting connections (Electron health poll succeeds). Cold start clears persisted ready bits; `rns_ready` / `lxmf_ready` stay `false` until `attach_live` finishes (RNS/LXMF bridge up). TCP/API clients can hit `/api/v1/status` and identity routes while live attach (path table, BLE Peer, PN messagestore) continues in the background. ### Identity @@ -89,10 +91,10 @@ Routing bias between **RF** (LoRa / RNode) and **network** (TCP/UDP/I2P/gateway/ | Method | Path | Body / notes | Response | | ------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| POST | `/api/v1/lxmf/send` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` | Live: stamps LXMF `FIELD_REPLY_TO` (0x30) / optional `FIELD_REPLY_QUOTE` (0x31) before sign; `{ ok, delivery_method?, delivery_status?, sent_via?, message? }` or `{ ok: false, error: "no_propagation_node" }`. **`delivery_status` on this response is initial enqueue state only** (`queued` or `sending`) — not delivery confirmation. Stub: `{ ok, sent_via?, message? }` | +| POST | `/api/v1/lxmf/send` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` | Live: stamps LXMF `FIELD_REPLY_TO` (0x30) / optional `FIELD_REPLY_QUOTE` (0x31) before sign; `{ ok, delivery_method?, delivery_status?, sent_via?, message? }` or `{ ok: false, error: "no_propagation_node" }`. **`delivery_status` on this response is initial enqueue state only** (`queued` or `sending`) — not delivery confirmation. Before live attach (`rns-stack` without bridge): fail-closed `{ ok: false, error: "lxmf send requires live rns-stack sidecar" }`. Stub build: `{ ok, sent_via?, message? }` | | POST | `/api/v1/lxmf/paper/create` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` (Chat UI currently sends `destination_hash` + `text` only) | Live: signed `DeliveryMethod::Paper` + `to_paper_uri` (encrypt to peer pubkey in `known_identities`); **no** network send. `{ ok, uri, message_hash, delivery_method: "paper", message? }` or `{ ok: false, error: "identity_unknown" \| "paper_too_large" \| "identity_not_configured" \| "invalid_hash" \| "internal_error" }`. Emits WS `lxmf_message` outbound with `delivery_method`/`sent_via`/`received_via`=`paper` and `delivery_status: "delivered"` — **does not** emit `lxmf_outbound_status`. Stub: `{ ok: false, error: "identity_not_configured" }` | | POST | `/api/v1/lxmf/paper/ingest` | `{ uri }` (`lxm://` base64url paper blob; URI length capped before decrypt) | Live: `from_paper_uri` + local identity decrypt + `ingest_lxm_uri` → delivery callback / WS `lxmf_message` inbound (`delivery_method`/`received_via`=`paper`). `{ ok, message? }` or `{ ok: false, error: "invalid_uri" \| "decrypt_failed" \| "identity_not_configured" \| "paper_too_large" \| "internal_error" }`. Stub: `{ ok: false, error: "identity_not_configured" }` | -| POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | `{ ok, message? }` | +| POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | Live: `{ ok, message? }`. Before live attach: fail-closed `{ ok: false, error: "lxmf reaction requires live rns-stack sidecar" }` | | GET | `/api/v1/lxmf/recent` | `?since_ts=` (ms, optional), `?since_seq=` (opaque `ring_seq`, optional), `?limit=` (default 200, max 500) | `{ messages: [], ring_len }` — ring buffer of recent **inbound** LXMF payloads for WS lag/reconnect catch-up (not durable across sidecar restart; capped at 200). Rows are chronological (oldest→newest) and each accepted row is stamped with monotonic `ring_seq`. Cursor: `since_ts` alone keeps `timestamp > since_ts`; with `since_seq`, keep rows after the complete `(since_ts, since_seq)` cursor (`timestamp > since_ts` **or** same-ms with `ring_seq > since_seq`) so same-ms twins remain recoverable without reprocessing the boundary; `ring_len` is current buffer occupancy | | DELETE | `/api/v1/lxmf/messages/{hash}` | | `{ ok }` | | GET | `/api/v1/contacts` | | `{ contacts: [] }` — overlays announce/peer/Nomad labels onto nameless or hash-prefix contact `display_name` values (does not overwrite a real name) and may persist fills | @@ -120,13 +122,15 @@ Routing bias between **RF** (LoRa / RNode) and **network** (TCP/UDP/I2P/gateway/ | POST | `/api/v1/propagation/hosting-policy` | `PnHostingPolicy` | `{ ok }` — persist + apply local PN hosting / peering policy | | PUT | `/api/v1/propagation/{id}` | `{ name }` | `{ ok }` — rename a remote node (`local-prop` rejected) | | DELETE | `/api/v1/propagation/{id}` | | `{ ok }` — remove a remote node (`local-prop` rejected; clears preferred if that id) | -| POST | `/api/v1/propagation/{id}/enable` | | `{ ok }` — for `local-prop`, starts PN serve + announce | +| POST | `/api/v1/propagation/{id}/enable` | | `{ ok }` — for `local-prop`, starts PN serve + announce (waits for messagestore load — see below) | | POST | `/api/v1/propagation/{id}/disable` | | `{ ok }` — for `local-prop`, stops PN serve + announce | | POST | `/api/v1/propagation/{id}/preferred` | | `{ ok }` | | POST | `/api/v1/propagation/sync` | | `{ ok }` | | POST | `/api/v1/propagation/sync/cancel` | | `{ ok }` | | POST | `/api/v1/propagation/auto-sync-interval` | `{ interval_sec }` | `{ ok }` — `0` disables periodic sync; persists with stack | +**Deferred PN messagestore:** On live attach, the local propagation messagestore loads in the background (`spawn_blocking`) so a large disk store does not gate TCP/LXMF/RRC readiness. New writes still go to `storage_dir`. If `local-prop` is enabled, serve/announce starts only after that load finishes (avoids advertising an empty PN while the scan runs). + **`PnHostingPolicy`** (mirrored in `src/shared/pnHostingPolicy.ts` / sidecar `pn_hosting_policy.rs`): | Field | Default | Notes | diff --git a/docs/reticulum.md b/docs/reticulum.md index fbd4c936b..03a3e89c9 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -26,7 +26,7 @@ After changing interfaces on a live network, **restart the stack** so RNS picks | Area | Shipped behavior | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Stack lifecycle | Start / stop / auto-start; disconnect & quit | +| Stack lifecycle | Start / stop / auto-start; disconnect & quit. Sidecar **listen-first**: HTTP binds before live RNS/LXMF attach; Connect marks **configured** when HTTP + identity are ready (live attach / BLE may still be in progress) | | 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. | @@ -174,6 +174,8 @@ flowchart LR The renderer **must not** call the sidecar URL directly (sandbox). All HTTP/WS goes through main-process `reticulum:proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete`. Paths must start with `/api/v1/`. Full route list: [reticulum-sidecar-ipc.md](reticulum-sidecar-ipc.md). +**Listen-first connect:** The sidecar binds HTTP first, then `attach_live` brings up RNS/LXMF (path table, BLE Peer, deferred PN messagestore). Electron health is `GET /api/v1/status` with `status: "ok"` — not `rns_ready` / `lxmf_ready`. `useReticulumRuntime` marks connection **configured** once start succeeds and identity is known, then hydrates peers/DB in the background and dispatches `RETICULUM_CONFIGURED_EVENT`. TCP hubs and RRC can proceed after live attach; Chat LXMF send/reaction fail closed with `requires live rns-stack sidecar` until the bridge is up. **Cancel** / stop does not wait on cargo or BLE; Noble yield for an enabled BLE RNode starts only after health (fire-and-forget). Renderer LXMF/RRC proxy sends use a **15 s** IPC deadline (`RETICULUM_IPC_SEND_TIMEOUT_MS`). + --- ## Interface management (Connection tab) @@ -240,7 +242,7 @@ When multiple enabled local RNode interfaces are connected, the interface list s **Bluetooth coexistence:** Meshtastic, MeshCore, and Reticulum may each use Bluetooth on **different devices** at once. Same MAC is rejected. Only **active scans** are serialized; connected GATT links are not torn down for another protocol’s scan. On Linux, LoRa stacks use Web Bluetooth in the renderer; Reticulum uses the sidecar `btleplug` stack. -**Noble BLE yield (macOS/Windows):** When the Reticulum config includes an **enabled BLE RNode** (`ble://…`), sidecar start calls `bleCoexistence:suspendNobleForReticulumBleConnect` — Noble disconnects GATT sessions and holds the scan mutex until the RNode connects or a grace window expires. While `scanOwner === 'reticulum'`, Meshtastic/MeshCore Noble **connect is rejected** (`BleScanBusyError`). After the post-connect grace, yield **stops re-contending** (prepare backoff) so an offline RNode cannot thrash LoRa BLE. mesh-client then dispatches `mesh-client:nobleBleYieldReleased` so Meshtastic/MeshCore can reconnect. If Noble disconnect times out during suspend, the yield is **released** (fail closed) rather than leaving a half-held mutex. Yield lifecycle is owned exclusively by `useReticulumNobleBleYieldWatcher` (not `useReticulumInterfaceSnapshot`) with a shared grace clock (`reticulumBleConnectGrace.ts`) so sidecar pairing is not interrupted. On stack restart, grace may renew when main re-acquires the scan mutex. Meshtastic/MeshCore autostart waits up to ~**65 s** (`awaitReticulumBleCoexistenceClear`) when Reticulum auto-starts with a BLE RNode. +**Noble BLE yield (macOS/Windows):** When the Reticulum config includes an **enabled BLE RNode** (`ble://…`), sidecar start kicks `bleCoexistence:suspendNobleForReticulumBleConnect` **after** HTTP health succeeds (not during cargo/spawn) — Noble disconnects GATT sessions and holds the scan mutex until the RNode connects or a grace window expires. Cancel during cargo never suspends Meshtastic/MeshCore. While `scanOwner === 'reticulum'`, Meshtastic/MeshCore Noble **connect is rejected** (`BleScanBusyError`). After the post-connect grace, yield **stops re-contending** (prepare backoff) so an offline RNode cannot thrash LoRa BLE. mesh-client then dispatches `mesh-client:nobleBleYieldReleased` so Meshtastic/MeshCore can reconnect. If Noble disconnect times out during suspend, the yield is **released** (fail closed) rather than leaving a half-held mutex. Yield lifecycle is owned exclusively by `useReticulumNobleBleYieldWatcher` (not `useReticulumInterfaceSnapshot`) with a shared grace clock (`reticulumBleConnectGrace.ts`) so sidecar pairing is not interrupted. On stack restart, grace may renew when main re-acquires the scan mutex. Meshtastic/MeshCore autostart waits up to ~**65 s** (`awaitReticulumBleCoexistenceClear`) when Reticulum auto-starts with a BLE RNode. **Stale BLE bond:** Sidecar may latch `bleBondRemoved` when the peer dropped pairing information while the OS still shows Paired (bond-desync overlay also **halts** BLE reconnect until stack restart). Connection / Diagnostics / TX-drop bond-stale hints surface Forget-and-re-pair copy — forget the RNode in System Settings → Bluetooth first, prefer USB/`tcp://` when possible, use Admin → Bluetooth → **Clear paired devices** (USB `CMD_BT_UNPAIR`, ESP32) then **Start pairing**, **remove and re-add** the BLE interface (**Pick device**) so the saved `ble://` id refreshes, restart the stack, enter the new PIN. Connection banners link to Admin Bluetooth. @@ -272,7 +274,7 @@ When multiple enabled local RNode interfaces are connected, the interface list s - **Config validate:** Electron IPC `reticulum:validateConfig` → one-shot sidecar `validate-config --json` against `userData/reticulum/config` - **Announces:** interval (`announce_interval_sec`, 0–86400; default **3600** s / 1 h when unset; `0` = startup-only) persisted in rnsd config. The live sidecar sends an **LXMF delivery** announce shortly after stack start and on that interval (Ratspeak/lxmd parity). **Announce now** (`POST /api/v1/announces`) forces an immediate delivery announce. **Clear announces** (`DELETE /api/v1/announces`) clears the stub peer cache; the live path table may refill on the next peer refresh. Per-interface `announce_interval_min` (RMAP/discoverable interfaces) is separate. - **Inbound LXMF:** the sidecar registers `lxmf.delivery` with the transport (`RegisterDestination` + `LinkManager`) and feeds decrypted link/resource payloads into the delivery callback (WS `lxmf_message`). Without this registration, peer DMs never appear in Chat even when paths exist. -- **Propagation:** preferred node for offline DMs, per-node **Sync messages**, add remote propagation nodes by 32-character `lxmf.propagation` hash or from the **Discovered on network** list (heard PN announces; Add / Add & prefer — never silent auto-add), **rename** / **delete** remote nodes, optional **local PN hosting** (announce + `/offer`/`/get` + Link Resource deposit ingress with stamp validation into the local store, plus outbound peer inventory sync when hosting + autopeer/static peers are on), Network **Advanced PN hosting** policy (`peering_cost`, `max_peering_cost`, autopeer, stamps, storage), Add-time `/offer` probe, **auto-sync interval** (`auto_sync_interval_sec`; `0` disables periodic sync; interval measured from last _successful_ sync with a short failure cooldown). Remote sync **always sends an LXMF delivery announce** then settles briefly (~2s) before Establishing so the PN has a reverse path for LRPROOF, **re-requests the forward path** (does not reuse a possibly stale hop count), pins/persists PN identity during Establishing (avoids announce-flood eviction), resolves identity+path before Establishing, rejects non-PN destinations (`PROPAGATION_TARGET_NOT_PN`), requires a peering stamp when cost > 0, treats HaveAll/Complete as success (not failure), surfaces `NoLinkProof` when establish stalls without a proof, and the renderer cancels Establishing-only stalls (~45s) plus a hard ceiling (~180s) via `reticulumPropagationSync.ts` without overwriting sidecar failure keys. After Sync Completes, the renderer runs inbound LXMF catch-up so Chat does not wait for the periodic ring poll. Correlatable deposit/retrieve logs use targets `propagation-deposit` / `propagation-retrieve` (`message_hash`, `transient_id`, `pn_hash`). +- **Propagation:** preferred node for offline DMs, per-node **Sync messages**, add remote propagation nodes by 32-character `lxmf.propagation` hash or from the **Discovered on network** list (heard PN announces; Add / Add & prefer — never silent auto-add), **rename** / **delete** remote nodes, optional **local PN hosting** (announce + `/offer`/`/get` + Link Resource deposit ingress with stamp validation into the local store, plus outbound peer inventory sync when hosting + autopeer/static peers are on), Network **Advanced PN hosting** policy (`peering_cost`, `max_peering_cost`, autopeer, stamps, storage), Add-time `/offer` probe, **auto-sync interval** (`auto_sync_interval_sec`; `0` disables periodic sync; interval measured from last _successful_ sync with a short failure cooldown). Local-prop messagestore load is **deferred** off the live-ready path; serve/announce waits until that load finishes so peers are not syncing an empty store. Remote sync **always sends an LXMF delivery announce** then settles briefly (~2s) before Establishing so the PN has a reverse path for LRPROOF, **re-requests the forward path** (does not reuse a possibly stale hop count), pins/persists PN identity during Establishing (avoids announce-flood eviction), resolves identity+path before Establishing, rejects non-PN destinations (`PROPAGATION_TARGET_NOT_PN`), requires a peering stamp when cost > 0, treats HaveAll/Complete as success (not failure), surfaces `NoLinkProof` when establish stalls without a proof, and the renderer cancels Establishing-only stalls (~45s) plus a hard ceiling (~180s) via `reticulumPropagationSync.ts` without overwriting sidecar failure keys. After Sync Completes, the renderer runs inbound LXMF catch-up so Chat does not wait for the periodic ring poll. Correlatable deposit/retrieve logs use targets `propagation-deposit` / `propagation-retrieve` (`message_hash`, `transient_id`, `pn_hash`). --- @@ -292,6 +294,7 @@ When multiple enabled local RNode interfaces are connected, the interface list s IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): - Discover hubs from announces, connect by hash, or favourite hubs (Nomad-style). Soft cap **8** concurrent hub sessions. +- Headless hub **auto-connect** (`useRrcStartupAutoConnect`, mounted from App): polls ~**500 ms** while any preferred hub is still pending (waiting for live attach / first link), then ~**4 s** steady; also wakes immediately on `RETICULUM_CONFIGURED_EVENT`. - Per-hub rooms, nicklists (`/who`), topics, slash commands (`/help`, `/join`, `/part`, `/list`, `/msg`, …). Hub and room **auto-join** prefs in localStorage. - **Per-peer DMs (`@`):** `/msg NICK text` opens an IRC-style query tab for that peer. Wire delivery is a direct NOTICE with `K_DST` and no `K_ROOM` / room JOIN — this requires the hub to advertise **`CAP_DIRECT_NOTICE`** (`capabilities.direct_notice`). When the hub does not advertise that capability, `/msg` and plain replies in a DM tab show `rrc.directNoticeUnsupported` and do not send. Sidebar/header show the nick. Leave closes that DM locally; open DMs persist in localStorage until left. Legacy `[whispers]` inbox is migrated best-effort into per-peer rooms. - Chat virtualization pins to the bottom while reading live traffic; **Jump to latest** appears when scrolled up; leaving/re-entering RRC restores the prior scroll pin when possible (`RrcChatView` + TanStack Virtual). @@ -489,6 +492,8 @@ Transfers require a **high-speed** path (TCP/network); LoRa/BLE-only destination | Symptom | Doc | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Sidecar won't start / health timeout | [troubleshooting.md#reticulum-sidecar-wont-start-or-health-poll-times-out](troubleshooting.md#reticulum-sidecar-wont-start-or-health-poll-times-out) | +| RRC/LXMF requires live… just after start | [troubleshooting.md#reticulum-rrclxmf-requires-live-rns-stack-right-after-start](troubleshooting.md#reticulum-rrclxmf-requires-live-rns-stack-right-after-start) | +| Cancel then Connect stuck on START_ABORTED | [troubleshooting.md#reticulum-cancel-then-connect-stuck-on-start_aborted](troubleshooting.md#reticulum-cancel-then-connect-stuck-on-start_aborted) | | `register_packet_tap` / cargo build failed | [troubleshooting.md#reticulum-sidecar-cargo-build-fails](troubleshooting.md#reticulum-sidecar-cargo-build-fails-register_packet_tap--reticulum_cargo_build_failed) | | AutoInterface utun log spam (macOS VPN) | [troubleshooting.md#reticulum-autointerface-log-spam-on-macos](troubleshooting.md#reticulum-autointerface-log-spam-on-macos-vpn-utun--enobufs) | | Local DMs hang (Auto + private TCP hub) | [troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub](troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub) | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0eb4c90f0..508528483 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1016,11 +1016,13 @@ AGPL Rust sidecar (`mesh-client-reticulum`), interfaces, LXMF, RRC, and RNode Wi **Symptoms**: Connection tab **Start stack** fails; logs show `[ReticulumSidecar]` health poll timeout; `reticulum:getStatus` reports `lastError`. Identity **Generate** / **Import** errors with `Reticulum sidecar is not running`. +**Health vs live ready:** Electron health only requires `GET /api/v1/status` → `status: "ok"` (HTTP listening). That is **not** the same as `rns_ready` / `lxmf_ready` (true only after live RNS/LXMF attach). A green/configured Connection after Start means the API is up + identity known; Chat LXMF and RRC may still need a few seconds for live attach. Diagnostics may show `reticulum/rns-not-ready` / `reticulum/lxmf-not-ready` briefly — see [requires live right after start](#reticulum-rrclxmf-requires-live-rns-stack-right-after-start). + **Checks**: 0. **Identity wizard**: click **Start stack** at the top of the Reticulum Connection panel before generating or importing a mnemonic. The sidecar must be running for `reticulum:proxyGet` / `proxyPost` identity routes. 1. **Dev — binary missing**: build once from repo root: `pnpm run reticulum:sidecar:build` (requires [Rust](https://rustup.rs/); see [development-environment.md](development-environment.md#reticulum-sidecar-optional)). Electron **Start stack** can auto-run `cargo build` on first click, but you need `cargo` on `PATH`. Error text `sidecar binary not found` means `reticulum-sidecar/target/debug/mesh-client-reticulum` does not exist yet. -2. **Dev — run / health**: `pnpm run reticulum:sidecar:dev` or confirm `curl http://127.0.0.1:19437/api/v1/status` after **Start stack**. +2. **Dev — run / health**: `pnpm run reticulum:sidecar:dev` or confirm `curl http://127.0.0.1:19437/api/v1/status` after **Start stack** (`status` should be `ok`; `rns_ready`/`lxmf_ready` may still be `false` for a short window). 3. **Packaged app — sidecar missing from installer**: older Electron releases (before CI bundled the sidecar) ship without `mesh-client-reticulum` under `resources/reticulum-sidecar/`; the UI shows a message about a missing bundled sidecar — **upgrade to a newer release** (or use Flatpak on Linux). WoA needs the **arm64** installer (`Mesh-client Setup {version}-arm64.exe`) with an **arm64** sidecar inside, not the x64 binary. 4. **Packaged app — verify install**: confirm `mesh-client-reticulum` (or `.exe` on Windows) exists under the app resources (`reticulum-sidecar/` beside the executable). 5. **macOS Gatekeeper**: unsigned local sidecar builds may need `xattr -cr` on the binary or ad-hoc signing for dev. @@ -1028,6 +1030,22 @@ AGPL Rust sidecar (`mesh-client-reticulum`), interfaces, LXMF, RRC, and RNode Wi Keep Rust current with `pnpm run update` (runs `rustup update` and rebuilds the sidecar when `cargo` is available). +### Reticulum RRC/LXMF requires live rns-stack right after start + +**Symptoms**: For the first few seconds after **Start stack**, Chat DM send/reaction or RRC hub connect fails with `lxmf send requires live rns-stack sidecar`, `lxmf reaction requires live rns-stack sidecar`, or `rrc connect requires live rns-stack sidecar` (humanized toasts). Connection may already show **configured**. + +**Cause**: Listen-first startup — HTTP is up (`status: ok`) and the UI marks configured when identity is known, but `attach_live` has not finished. LXMF/RRC fail closed until the live bridge is ready. RRC auto-connect retries about every **500 ms** while hubs are pending and wakes on the configured event. + +**What to do**: Wait a few seconds and retry (or let RRC auto-join settle). If errors persist after `rns_ready`/`lxmf_ready` are true in `/api/v1/status`, treat as a real stack failure (restart stack; check logs). + +### Reticulum Cancel then Connect stuck on START_ABORTED + +**Symptoms**: Click **Cancel** during **Start stack** (especially while cargo is building), then **Connect** / **Start** again; UI or logs show `RETICULUM_SIDECAR_START_ABORTED` and the stack never comes up. + +**Cause (fixed):** Older builds rejoined the aborted start promise. Current builds set an abort flag and return from **Cancel** without waiting on cargo/BLE; the next **start** waits for the doomed promise to clear, then starts fresh. Noble yield for BLE RNode runs only after health, so Cancel during cargo does not suspend Meshtastic/MeshCore. + +**What to do**: Upgrade to a build with listen-first Cancel fix. If you still see `START_ABORTED` after Cancel+Connect on a current build, quit the app fully and **Start stack** once. + ### Reticulum sidecar cargo build fails (`register_packet_tap` / `RETICULUM_CARGO_BUILD_FAILED`) **Symptoms**: **Start stack** fails; logs show `RETICULUM_CARGO_BUILD_FAILED` or Rust errors such as `method not found in ReticulumHandle`, `register_packet_tap`, or `PacketTapEvent`. Electron may surface `RETICULUM_RNS_PATCH_MISSING` after upgrading mesh-client. diff --git a/reticulum-sidecar/README.md b/reticulum-sidecar/README.md index 68e59a508..558ee9e56 100644 --- a/reticulum-sidecar/README.md +++ b/reticulum-sidecar/README.md @@ -44,6 +44,7 @@ Prefer `./scripts/clone-ratspeak-stack.sh` (or `./scripts/ensure-rsReticulum-pat ./scripts/apply-rsReticulum-discovery-announce-egress.sh ./scripts/apply-rsLXMF-propagation-sync-peering.sh ./scripts/apply-rsLXMF-propagation-node-policy-setters.sh +./scripts/apply-rsLXMF-propagation-node-deferred-messagestore-load.sh ``` See [patches/README.md](patches/README.md) for overlay regen against floated `origin/main` (record the short SHA in the PR). @@ -64,6 +65,8 @@ curl -s http://127.0.0.1:19437/api/v1/status Or **Reticulum tab → Connection → Start stack** (sidecar must be running before identity or Network configuration). +**Startup order (listen-first):** bootstrap persist → bind HTTP → accept `/api/v1/status` (`status: ok`) → `attach_live` (RNS/LXMF; sets `rns_ready` / `lxmf_ready`). PN messagestore loads in the background; local-prop serve waits for that load. Electron health polls `status: ok` only — not the ready flags. + ## Lint and coverage Toolchain components (`clippy`, `rustfmt`, `llvm-tools-preview`) come from [`rust-toolchain.toml`](rust-toolchain.toml). diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index 955f7b76a..58d10ac6f 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -391,6 +391,33 @@ git diff -- crates/lxmf-core/src/propagation_node.rs \ When [ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6) merges and floated `origin/main` includes it, remove this patch and drop the apply step from `clone-ratspeak-stack.sh` / `ensure-rsReticulum-patches.sh`. +## rsLXMF-propagation-node-deferred-messagestore-load.patch + +`PropagationNode::with_storage_unloaded` + `load_messagestore_from_disk` so mesh-client can mark live/RRC ready before scanning a large on-disk PN messagestore (tens of thousands of files). `with_storage` remains eager (loads immediately) for callers that need a full store up front. + +| Field | Value | +| ----- | ----- | +| **Base commit** | tip of `ratspeak/rsLXMF` `main` + `rsLXMF-propagation-node-policy-setters` overlay | +| **Upstream PR** | (none yet — mesh-client local API) | + +**Modifies (1 file):** + +- `crates/lxmf-core/src/propagation_node.rs` — deferred messagestore open/load APIs + +### Apply locally + +From mesh-client repo root (sibling `../rsLXMF` required; apply policy-setters first): + +```bash +./scripts/apply-rsLXMF-propagation-node-deferred-messagestore-load.sh +``` + +`clone-ratspeak-stack.sh` and `ensure-rsReticulum-patches.sh` invoke this automatically after policy-setters. + +### Sunset + +When upstream grows an equivalent deferred-load API, remove this patch and drop the apply step. + ## rsLXMF-link-delivery-has-pending-to.patch Expose `LinkDeliveryManager::has_pending_to` so the sidecar can serialize packed Propagated deposits (and propagation sync) against an in-flight Link to the same PN. Floated rsLXMF tip only has `delivery_link_available` (reusable idle link), which is the wrong predicate for one-shot packed sessions. diff --git a/reticulum-sidecar/patches/rsLXMF-propagation-node-deferred-messagestore-load.patch b/reticulum-sidecar/patches/rsLXMF-propagation-node-deferred-messagestore-load.patch new file mode 100644 index 000000000..91f2c2b16 --- /dev/null +++ b/reticulum-sidecar/patches/rsLXMF-propagation-node-deferred-messagestore-load.patch @@ -0,0 +1,42 @@ +--- a/crates/lxmf-core/src/propagation_node.rs ++++ b/crates/lxmf-core/src/propagation_node.rs +@@ -333,8 +333,21 @@ + dest_hash: [u8; 16], + storage_path: PathBuf, + ) -> std::io::Result { ++ let mut node = Self::with_storage_unloaded(config, dest_hash, storage_path)?; ++ node.load_messagestore_from_disk()?; ++ Ok(node) ++ } ++ ++ /// Disk-backed node that keeps `storage_path` for new writes but does **not** ++ /// scan/load the existing messagestore yet. Call [`load_messagestore_from_disk`] ++ /// (ideally off the HTTP-ready path) before relying on historical PN messages. ++ pub fn with_storage_unloaded( ++ config: PropagationNodeConfig, ++ dest_hash: [u8; 16], ++ storage_path: PathBuf, ++ ) -> std::io::Result { + std::fs::create_dir_all(&storage_path)?; +- let mut node = Self { ++ Ok(Self { + config, + store: PropagationStore::new(), + sync_sessions: HashMap::new(), +@@ -342,9 +355,13 @@ + storage_path: Some(storage_path), + last_offer_times: HashMap::new(), + offer_generation: 0, +- }; +- node.load_from_disk()?; +- Ok(node) ++ }) ++ } ++ ++ /// Load messagestore entries from `storage_path`. Intended after ++ /// [`with_storage_unloaded`]; safe no-op when storage is unset. ++ pub fn load_messagestore_from_disk(&mut self) -> std::io::Result<()> { ++ self.load_from_disk() + } + + /// Returns `true` if the message was stored, `false` on duplicate, overflow, diff --git a/reticulum-sidecar/src/main.rs b/reticulum-sidecar/src/main.rs index 1ac6b2443..14ba3630e 100644 --- a/reticulum-sidecar/src/main.rs +++ b/reticulum-sidecar/src/main.rs @@ -160,9 +160,10 @@ async fn main() -> ExitCode { info!(config_dir = %config_dir.display(), storage_dir = %storage_dir.display(), "data dirs"); let (event_tx, _) = broadcast::channel::(256); + // Persist + HTTP shell first — do not await live RNS/BLE before binding. let stack = Arc::new(Box::pin(StackHandle::bootstrap(config_dir, storage_dir, event_tx)).await); - let app = api::router(stack); + let app = api::router(stack.clone()); let addr: SocketAddr = match format!("{}:{}", cli.host, cli.port).parse() { Ok(addr) => addr, @@ -179,9 +180,15 @@ async fn main() -> ExitCode { return ExitCode::from(1); } }; - if let Err(e) = axum::serve(listener, app).await { - error!(error = %e, "HTTP server exited with error"); - return ExitCode::from(1); - } + + // Accept /api/v1/status (and other routes) while live RNS attach continues. + let serve = tokio::spawn(async move { + if let Err(e) = axum::serve(listener, app).await { + error!(error = %e, "HTTP server exited with error"); + } + }); + // attach_live's future is large (PropagationBridge / LXMF setup); pin to satisfy clippy. + Box::pin(stack.attach_live()).await; + let _ = serve.await; ExitCode::SUCCESS } diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index cc5226f95..5ee288d62 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -463,13 +463,17 @@ impl LiveBridge { path_peer_cache_fetched_at, display_name_cache, outbound, - propagation: Arc::new(PropagationBridge::new( - handle.transport_tx.clone(), - lxmf_propagation_dest_hash, - storage_dir.join("propagation"), - &identity, - &pn_hosting_policy, - )?), + propagation: { + let prop = Arc::new(PropagationBridge::new( + handle.transport_tx.clone(), + lxmf_propagation_dest_hash, + storage_dir.join("propagation"), + &identity, + &pn_hosting_policy, + )?); + prop.spawn_messagestore_load(); + prop + }, prop_serve: Arc::new(PropagationServeHandle::new()), prop_announce: Arc::new(PropagationAnnounceLoop::new()), pn_hosting_policy: Arc::new(Mutex::new(pn_hosting_policy)), @@ -515,31 +519,21 @@ impl LiveBridge { })), }; - let (preferred_prop_hash, local_prop_enabled) = { + let preferred_prop_hash = { let state = inner.read().await; - let preferred = state.preferred_propagation_id.as_ref().and_then(|id| { + state.preferred_propagation_id.as_ref().and_then(|id| { state .propagation .iter() .find(|p| p.id == *id) .and_then(|p| p.destination_hash.clone()) - }); - let local_enabled = state - .propagation - .iter() - .find(|p| p.id == "local-prop") - .map(|p| p.enabled) - .unwrap_or(false); - (preferred, local_enabled) + }) }; bridge.spawn_maintenance(event_tx); - // Persisted local-prop.enabled must drive live serving; otherwise UI always - // shows disabled until the user toggles Enable (AtomicBool defaults false). - if local_prop_enabled { - bridge.set_local_propagation_serving(true).await; - } + // Local-prop serve/announce is deferred until messagestore load finishes + // (see StackHandle::attach_live) so we do not advertise an empty PN. bridge.rehydrate_propagation_identities_from_persisted(); // Keep persisted local-prop hash on lxmf.propagation (legacy rows stored delivery). { @@ -560,9 +554,7 @@ impl LiveBridge { } 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; - } + // BLE Peer sync is started from StackHandle::attach_live after HTTP is up. { let mut state = inner.write().await; @@ -3722,6 +3714,10 @@ impl LiveBridge { self.propagation.is_local_serving() } + pub async fn wait_propagation_messagestore_loaded(&self) -> Result<(), String> { + self.propagation.wait_messagestore_loaded().await + } + #[allow(clippy::unused_async)] // async matches StackHandle propagation cancel API pub async fn cancel_propagation_sync(&self) { // Invalidate in-flight emitters before flipping cancel / clearing pins. @@ -4363,7 +4359,10 @@ impl LiveBridge { } #[cfg(feature = "rns-ble")] - async fn sync_ble_peer_interfaces(&self, interfaces: &[InterfaceRow]) -> Result<(), String> { + pub(crate) async fn sync_ble_peer_interfaces( + &self, + interfaces: &[InterfaceRow], + ) -> Result<(), String> { let desired: HashMap = interfaces .iter() .filter(|i| i.iface_type == "ble_peer" && i.enabled) diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index c0feabdff..6daa40558 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -143,7 +143,11 @@ pub struct StackHandle { /// Serializes path-medium preference/pin persist → live-apply → rollback sequences. path_medium_op_lock: Mutex<()>, #[cfg(feature = "rns-stack")] - live: Option>, + /// Set once after HTTP is already listening (TCP usable before BLE finishes). + live: std::sync::OnceLock>, + /// Serializes attach_live so concurrent callers cannot spawn duplicate live bridges. + #[cfg(feature = "rns-stack")] + attach_live_lock: Mutex<()>, /// Test-only: next preference/pin apply returns this error after persist (exercises rollback). #[cfg(test)] test_path_medium_apply_error: Mutex>, @@ -221,53 +225,31 @@ impl StackHandle { } let inner = Arc::new(RwLock::new(persisted)); + // Cold start: do not advertise RNS/LXMF ready from a prior session's persisted flags + // until attach_live finishes (HTTP listen-first path). + { + let mut guard = inner.write().await; + guard.rns_ready = false; + guard.lxmf_ready = false; + } let inbound_lxmf = Arc::new(LxmfInboundBuffer::new(MAX_LXMF_INBOUND_LOG)); #[cfg(feature = "rns-stack")] let packet_log = Arc::new(PacketLogBuffer::new(MAX_WIRE_PACKET_LOG)); - #[cfg(feature = "rns-stack")] - let live = match live::LiveBridge::spawn( - config_dir.clone(), - storage_dir.clone(), - event_tx.clone(), - packet_log.clone(), - inbound_lxmf.clone(), - inner.clone(), - ) - .await - { - Ok(bridge) => { - let bridge = Arc::new(bridge); - { - let mut inner_guard = inner.write().await; - if let Err(e) = identity_apply::reconcile_persisted_identity_from_file( - &mut inner_guard, - &config_dir, - &storage_dir, - ) { - tracing::warn!("identity reconcile after live spawn failed: {e}"); - } - } - Some(bridge) - } - Err(e) => { - tracing::warn!("live RNS bridge unavailable, using local stack: {e}"); - None - } - }; #[cfg(feature = "rns-stack")] let handle = Self { config_dir, storage_dir, inner, - event_tx: event_tx.clone(), + event_tx, packet_log, inbound_lxmf, contact_name_persist_dirty: std::sync::atomic::AtomicBool::new(false), identity_op_lock: Mutex::new(()), path_medium_op_lock: Mutex::new(()), - live, + live: std::sync::OnceLock::new(), + attach_live_lock: Mutex::new(()), #[cfg(test)] test_path_medium_apply_error: Mutex::new(None), }; @@ -285,26 +267,138 @@ impl StackHandle { #[cfg(test)] test_path_medium_apply_error: Mutex::new(None), }; - #[cfg(feature = "rns-stack")] - if let Some(live) = &handle.live { - live.register_nomad_announce_handler( - handle.inner.clone(), - handle.config_dir.clone(), - handle.storage_dir.clone(), - ); - live.register_rrc_announce_handler( - handle.inner.clone(), - handle.config_dir.clone(), - handle.storage_dir.clone(), - ); - live.register_propagation_announce_handler(); - live.register_lxmf_identity_announce_handler(); - live.register_rmap_discovery_watcher(event_tx.clone()); - } + // Live RNS attach happens after HTTP listen (see main + attach_live) so TCP/LXMF/RRC + // clients can reach /api/v1/status while BLE RNode / large path tables finish loading. handle.emit_stats().await; handle } + /// Finish live RNS/LXMF bring-up after the HTTP server is already accepting connections. + #[cfg(feature = "rns-stack")] + pub async fn attach_live(self: &Arc) { + let _attach_guard = self.attach_live_lock.lock().await; + if self.live.get().is_some() { + return; + } + let started = std::time::Instant::now(); + match live::LiveBridge::spawn( + self.config_dir.clone(), + self.storage_dir.clone(), + self.event_tx.clone(), + self.packet_log.clone(), + self.inbound_lxmf.clone(), + self.inner.clone(), + ) + .await + { + Ok(bridge) => { + let bridge = Arc::new(bridge); + { + let mut inner_guard = self.inner.write().await; + if let Err(e) = identity_apply::reconcile_persisted_identity_from_file( + &mut inner_guard, + &self.config_dir, + &self.storage_dir, + ) { + tracing::warn!("identity reconcile after live spawn failed: {e}"); + } + } + bridge.register_nomad_announce_handler( + self.inner.clone(), + self.config_dir.clone(), + self.storage_dir.clone(), + ); + bridge.register_rrc_announce_handler( + self.inner.clone(), + self.config_dir.clone(), + self.storage_dir.clone(), + ); + bridge.register_propagation_announce_handler(); + bridge.register_lxmf_identity_announce_handler(); + bridge.register_rmap_discovery_watcher(self.event_tx.clone()); + if self.live.set(bridge).is_err() { + tracing::warn!("live bridge already attached"); + } else { + // Restore local PN serving only after messagestore load so peers do not + // sync against an empty store while the background scan runs. + { + let live = self.live.get().expect("live just set").clone(); + let inner = self.inner.clone(); + let local_prop_enabled = { + let state = inner.read().await; + state + .propagation + .iter() + .find(|p| p.id == "local-prop") + .map(|p| p.enabled) + .unwrap_or(false) + }; + if local_prop_enabled { + tokio::spawn(async move { + if let Err(e) = live.wait_propagation_messagestore_loaded().await { + tracing::warn!( + error = %e, + "skipping local-prop serve restore: messagestore load failed" + ); + return; + } + let still_enabled = { + let state = inner.read().await; + state + .propagation + .iter() + .find(|p| p.id == "local-prop") + .map(|p| p.enabled) + .unwrap_or(false) + }; + if still_enabled { + live.set_local_propagation_serving(true).await; + } + }); + } + } + // BLE Peer bring-up is slow (adapter/scan); keep it off the HTTP-ready path. + #[cfg(feature = "rns-ble")] + { + let live = self.live.get().expect("live just set").clone(); + let config_dir = self.config_dir.clone(); + tokio::spawn(async move { + match config::interfaces_from_config_dir(&config_dir) { + Ok(ifaces) => { + if let Err(e) = live.sync_ble_peer_interfaces(&ifaces).await { + tracing::warn!( + error = %e, + "background BLE Peer sync failed" + ); + } + } + Err(e) => { + tracing::warn!( + error = %e, + "background BLE Peer sync: config read failed" + ); + } + } + }); + } + } + tracing::info!( + elapsed_ms = started.elapsed().as_millis() as u64, + "live RNS bridge attached (HTTP was already listening)" + ); + self.emit_stats().await; + } + Err(e) => { + tracing::warn!("live RNS bridge unavailable, using local stack: {e}"); + } + } + } + + #[cfg(not(feature = "rns-stack"))] + pub async fn attach_live(self: &Arc) { + let _ = self; + } + #[allow(clippy::needless_pass_by_value)] // payload is moved into the broadcast frame fn emit_event(&self, event_type: &str, payload: serde_json::Value) { let msg = serde_json::json!({ "type": event_type, "payload": payload }); @@ -318,7 +412,7 @@ impl StackHandle { /// High-rate `voice.audio` PCM frames (dedicated `/ws/voice` bus, not shared `/ws`). pub fn subscribe_voice_audio(&self) -> broadcast::Receiver { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.subscribe_voice_audio(); } // No live stack: closed channel so `/ws/voice` clients exit cleanly. @@ -526,7 +620,7 @@ impl StackHandle { return Some(hex::encode(id.get_public_key())); } } - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { if live .identity_hash_hex() .eq_ignore_ascii_case(&status.identity_hash) @@ -776,7 +870,7 @@ impl StackHandle { }; #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { if let Ok(rows) = live.fetch_interfaces().await { if !rows.is_empty() { return rows; @@ -797,7 +891,7 @@ impl StackHandle { self.sync_interfaces_from_config().await; self.emit_event("interface.state", serde_json::json!({ "action": "added" })); #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let _ = live.apply_interfaces(self).await; } Ok(row) @@ -815,7 +909,7 @@ impl StackHandle { serde_json::json!({ "id": id, "action": "updated" }), ); #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let _ = live.apply_interfaces(self).await; } Ok(row) @@ -830,7 +924,7 @@ impl StackHandle { serde_json::json!({ "id": id, "action": "deleted" }), ); #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let _ = live.apply_interfaces(self).await; } Ok(()) @@ -845,7 +939,7 @@ impl StackHandle { serde_json::json!({ "id": id, "enabled": enabled }), ); #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let _ = live.apply_interfaces(self).await; } Ok(()) @@ -855,7 +949,7 @@ impl StackHandle { config::write_config(&self.config_dir, content)?; self.sync_interfaces_from_config().await; #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let _ = live.apply_interfaces(self).await; } Ok(()) @@ -869,7 +963,7 @@ impl StackHandle { let result = config::import_config(&self.config_dir, content, mode)?; self.sync_interfaces_from_config().await; #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let _ = live.apply_interfaces(self).await; } Ok(result) @@ -884,7 +978,7 @@ impl StackHandle { #[cfg(feature = "rns-stack")] let announce_labels = self .live - .as_ref() + .get() .map(|live| live.display_name_snapshot()) .unwrap_or_default(); #[cfg(not(feature = "rns-stack"))] @@ -945,7 +1039,7 @@ impl StackHandle { pub async fn list_peers_with_refresh(&self, force_refresh: bool) -> Vec { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let announce_labels = live.display_name_snapshot(); let fetched = live.fetch_peers(force_refresh).await; let mut inner = self.inner.write().await; @@ -970,7 +1064,7 @@ impl StackHandle { pub async fn request_peer_path(&self, hash: &str) -> Result<(), String> { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let res = live.request_path(hash).await; if res.is_ok() { self.emit_event("peers_updated", serde_json::json!({ "hash": hash })); @@ -983,7 +1077,7 @@ impl StackHandle { pub async fn probe_peer(&self, hash: &str) -> Result { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let res = live.probe_peer(hash).await; if res.is_ok() { self.emit_event("peers_updated", serde_json::json!({ "hash": hash })); @@ -1030,7 +1124,7 @@ impl StackHandle { return Err(err); } #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { if let Err(e) = live.apply_path_medium_preference(preference).await { self.rollback_path_medium_preference(snapshot).await; return Err(e); @@ -1074,7 +1168,7 @@ impl StackHandle { return Err(err); } #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { if let Err(e) = live.apply_peer_medium_pin(&canonical, pin).await { self.rollback_peer_medium_pins(pin_snapshot).await; return Err(e); @@ -1121,7 +1215,7 @@ impl StackHandle { ) }; #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let (paths, effective_preference) = live.path_slots(&canonical).await?; return Ok(peer_path_slots_json( &canonical, @@ -1153,7 +1247,7 @@ impl StackHandle { let auto_sync_interval_sec = inner.auto_sync_interval_sec; let pn_hosting_policy = inner.pn_hosting_policy.clone(); #[cfg(feature = "rns-stack")] - let local_stats = if let Some(live) = &self.live { + let local_stats = if let Some(live) = self.live.get() { let (count, bytes) = live.propagation_local_stats(); let serving = live.propagation_is_local_serving(); Some((count, bytes, serving, live.propagation_local_hash())) @@ -1215,7 +1309,7 @@ impl StackHandle { pub fn list_discovered_propagation(&self) -> Vec { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.list_discovered_propagation(); } Vec::new() @@ -1234,7 +1328,7 @@ impl StackHandle { hash }; #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { live.set_outbound_propagation_node(prop_hash.as_deref()) .await; live.refresh_pn_cascade_candidates().await; @@ -1270,7 +1364,7 @@ impl StackHandle { policy }; #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { live.apply_pn_hosting_policy(&policy).await?; } Ok(()) @@ -1295,7 +1389,7 @@ impl StackHandle { #[cfg(feature = "rns-stack")] { self.live - .as_ref() + .get() .map(|live| live.propagation_local_hash()) .unwrap_or_default() } @@ -1324,7 +1418,7 @@ impl StackHandle { return Err("LOCAL_PROPAGATION_SYNC_UNSUPPORTED".into()); } #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { live.start_propagation_sync(&prop_hash).await?; return Ok(()); } @@ -1337,7 +1431,7 @@ impl StackHandle { pub async fn cancel_propagation_sync(&self) -> Result<(), String> { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { live.cancel_propagation_sync().await; return Ok(()); } @@ -1351,7 +1445,7 @@ impl StackHandle { pub async fn set_propagation_enabled(&self, id: &str, enabled: bool) -> Result<(), String> { if id == "local-prop" { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { live.set_local_propagation_serving(enabled).await; } } @@ -1361,7 +1455,7 @@ impl StackHandle { inner.save(&self.config_dir, &self.storage_dir)?; } #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { live.refresh_pn_cascade_candidates().await; } Ok(()) @@ -1377,7 +1471,7 @@ impl StackHandle { // Prefer live known key / discovered announce metadata before persist. let (pub_hex, id_hex) = { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let discovered = live .list_discovered_propagation() .into_iter() @@ -1403,7 +1497,7 @@ impl StackHandle { }; #[cfg(feature = "rns-stack")] if !skip_probe { - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { live.probe_propagation_offer(&hash).await?; } } @@ -1423,7 +1517,7 @@ impl StackHandle { inner.save(&self.config_dir, &self.storage_dir)?; drop(inner); #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { live.refresh_pn_cascade_candidates().await; } Ok(serde_json::json!({ "ok": true, "node": row })) @@ -1433,7 +1527,7 @@ impl StackHandle { // Live sync tracks progress in PropagationBridge, not persisted flags — always // cancel before mutating so RF/`/offer` work cannot outlive a deleted node. #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { live.cancel_propagation_sync().await; self.emit_event( "propagation_sync", @@ -1462,12 +1556,12 @@ impl StackHandle { }; if cleared_preferred { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { live.set_outbound_propagation_node(None).await; } } #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { live.refresh_pn_cascade_candidates().await; } Ok(()) @@ -1504,7 +1598,7 @@ impl StackHandle { pub async fn list_rmap_discovered(&self) -> Vec { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.fetch_rmap_discovered().await; } #[cfg(not(feature = "rns-stack"))] @@ -1522,7 +1616,7 @@ impl StackHandle { let mut name_by_hash = topology::build_topology_name_map(&inner.peers, &inner.contacts, &inner.nomad_nodes); #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { topology::extend_name_map_with_announce_labels( &mut name_by_hash, &live.display_name_snapshot(), @@ -1549,7 +1643,7 @@ impl StackHandle { /// Send an LXMF delivery announce immediately (live stack only). pub async fn announce_now(&self) -> Result<(), String> { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.announce_lxmf_now().await; } #[cfg(feature = "rns-stack")] @@ -1566,7 +1660,7 @@ impl StackHandle { let mut nodes = self.inner.read().await.nomad_nodes.clone(); // Own Nomad announces often sit in the path table as multi-hop echoes. #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let our_id = live.identity_hash_hex(); for node in &mut nodes { if node @@ -1591,13 +1685,13 @@ impl StackHandle { #[cfg(feature = "rns-stack")] fn require_live(&self) -> Result<&Arc, String> { self.live - .as_ref() + .get() .ok_or_else(|| "Nomad serving requires a live RNS stack".into()) } pub async fn nomad_serving_status(&self) -> NomadServingStatus { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.nomad_serving_status().await; } let inner = self.inner.read().await; @@ -1984,7 +2078,7 @@ impl StackHandle { let _ = inner.save(&self.config_dir, &self.storage_dir); } #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rrc_connect(bytes, clean, hops, nick).await; } let _ = (bytes, nick, hops); @@ -1996,7 +2090,7 @@ impl StackHandle { pub async fn rrc_disconnect(&self, dest_hash_hex: Option<&str>) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rrc_disconnect(dest_hash_hex).await; } let _ = dest_hash_hex; @@ -2005,7 +2099,7 @@ impl StackHandle { pub async fn rrc_status(&self) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rrc_status().await; } serde_json::json!({ @@ -2021,7 +2115,7 @@ impl StackHandle { key: Option<&str>, ) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rrc_join(hub_dest_hash, room, key).await; } let _ = (hub_dest_hash, room, key); @@ -2030,7 +2124,7 @@ impl StackHandle { pub async fn rrc_part(&self, hub_dest_hash: &str, room: &str) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rrc_part(hub_dest_hash, room).await; } let _ = (hub_dest_hash, room); @@ -2046,7 +2140,7 @@ impl StackHandle { dst_hash: Option<&str>, ) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live .rrc_send(hub_dest_hash, room, body, kind.unwrap_or("msg"), dst_hash) .await; @@ -2061,7 +2155,7 @@ impl StackHandle { nickname: &str, ) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rrc_set_nick(hub_dest_hash, nickname).await; } let _ = (hub_dest_hash, nickname); @@ -2070,7 +2164,7 @@ impl StackHandle { pub async fn rrc_rooms(&self, hub_dest_hash: Option<&str>) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rrc_rooms(hub_dest_hash).await; } let _ = hub_dest_hash; @@ -2079,7 +2173,7 @@ impl StackHandle { pub async fn rnsh_connect(&self, destination_hash: &str) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rnsh_connect(destination_hash).await; } let _ = destination_hash; @@ -2088,7 +2182,7 @@ impl StackHandle { pub async fn rnsh_input(&self, session_id: &str, data: Vec) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rnsh_input(session_id, data).await; } let _ = (session_id, data); @@ -2102,7 +2196,7 @@ impl StackHandle { cols: Option, ) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rnsh_resize(session_id, rows, cols).await; } let _ = (session_id, rows, cols); @@ -2111,7 +2205,7 @@ impl StackHandle { pub async fn rnsh_disconnect(&self, session_id: &str) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rnsh_disconnect(session_id).await; } let _ = session_id; @@ -2120,7 +2214,7 @@ impl StackHandle { pub async fn rnsh_status(&self) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rnsh_status().await; } serde_json::json!({ "sessions": [] }) @@ -2128,7 +2222,7 @@ impl StackHandle { pub async fn rncp_send(&self, destination_hash: &str, path: &str) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rncp_send(destination_hash, path).await; } let _ = (destination_hash, path); @@ -2142,7 +2236,7 @@ impl StackHandle { save_path: Option, ) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let save_dir = save_path .map(PathBuf::from) .unwrap_or_else(|| self.storage_dir.join("rncp_fetched")); @@ -2156,7 +2250,7 @@ impl StackHandle { pub async fn rncp_cancel(&self, transfer_id: &str) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rncp_cancel(transfer_id).await; } let _ = transfer_id; @@ -2165,7 +2259,7 @@ impl StackHandle { pub async fn rncp_accept(&self, transfer_id: &str) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rncp_accept(transfer_id).await; } let _ = transfer_id; @@ -2174,7 +2268,7 @@ impl StackHandle { pub async fn rncp_reject(&self, transfer_id: &str) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rncp_reject(transfer_id).await; } let _ = transfer_id; @@ -2183,7 +2277,7 @@ impl StackHandle { pub async fn rncp_status(&self) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rncp_status().await; } serde_json::json!({ "transfers": [], "pending_offers": [] }) @@ -2192,7 +2286,7 @@ impl StackHandle { /// Force one `rncp.receive` announce while the inbound listener is enabled. pub async fn rncp_announce_now(&self) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rncp_announce_now().await; } serde_json::json!({ "ok": false, "error": "rncp requires live rns-stack sidecar" }) @@ -2215,7 +2309,7 @@ impl StackHandle { blocked: Vec, ) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { if !enabled { live.rncp_stop_listener().await; let _ = live @@ -2287,7 +2381,7 @@ impl StackHandle { pub async fn rncp_listener_status(&self) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.rncp_listener_status().await; } serde_json::json!({ @@ -2301,7 +2395,7 @@ impl StackHandle { pub fn path_capability(&self, destination_hash: &str) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.path_capability(destination_hash); } let clean = destination_hash.trim().to_lowercase(); @@ -2319,7 +2413,7 @@ impl StackHandle { pub async fn remote_identity(&self) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return serde_json::json!({ "identity_hash": live.identity_hash_hex(), "rncp_receive_hash": live.rncp_receive_destination_hash().await, @@ -2349,7 +2443,7 @@ impl StackHandle { request_id: Option<&str>, ) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let interfaces = self.inner.read().await.interfaces.clone(); let identity_hash = self.nomad_identity_hash_for(hash).await; return live @@ -2378,7 +2472,7 @@ impl StackHandle { force_path_refresh: bool, ) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let interfaces = self.inner.read().await.interfaces.clone(); let identity_hash = self.nomad_identity_hash_for(hash).await; return live @@ -2400,7 +2494,7 @@ impl StackHandle { pub async fn lxmf_send(&self, req: LxmfSendRequest) -> Result { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let res = live.send_lxmf(&req).await?; let payload = res.get("message").cloned().unwrap_or(res.clone()); if payload.get("text").is_some() { @@ -2412,13 +2506,23 @@ impl StackHandle { "sent_via": res.get("sent_via"), })); } - let mut inner = self.inner.write().await; - let res = inner.send_lxmf_local(&req)?; - inner.save(&self.config_dir, &self.storage_dir)?; - let payload = res.clone(); - drop(inner); - self.emit_event("lxmf_message", payload); - Ok(res) + // Fail closed while listen-first HTTP is up but live attach has not finished. + // Local persistence fallback would report ok without a wire send. + #[cfg(feature = "rns-stack")] + { + let _ = req; + Err("lxmf send requires live rns-stack sidecar".into()) + } + #[cfg(not(feature = "rns-stack"))] + { + let mut inner = self.inner.write().await; + let res = inner.send_lxmf_local(&req)?; + inner.save(&self.config_dir, &self.storage_dir)?; + let payload = res.clone(); + drop(inner); + self.emit_event("lxmf_message", payload); + Ok(res) + } } pub async fn lxmf_paper_create( @@ -2426,7 +2530,7 @@ impl StackHandle { req: LxmfSendRequest, ) -> Result { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let res = live.create_lxmf_paper(&req).await?; if res.get("ok") == Some(&serde_json::Value::Bool(true)) { if let Some(payload) = res.get("message").cloned() { @@ -2443,7 +2547,7 @@ impl StackHandle { pub async fn lxmf_paper_ingest(&self, uri: String) -> Result { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { // ingest_lxm_uri fires the delivery callback (WS lxmf_message); return HTTP body only. return live.ingest_lxmf_paper(&uri).await; } @@ -2455,7 +2559,7 @@ impl StackHandle { fn maybe_emit_identity_restart(&self) { #[cfg(feature = "rns-stack")] - if self.live.is_some() { + if self.live.get().is_some() { self.emit_event("stack_restart_requested", serde_json::json!({ "ok": true })); } } @@ -2465,17 +2569,25 @@ impl StackHandle { req: LxmfReactionRequest, ) -> Result { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { let res = live.send_reaction(&req).await?; self.emit_event("lxmf_message", res.clone()); return Ok(res); } - let mut inner = self.inner.write().await; - let res = inner.send_reaction(&req)?; - inner.save(&self.config_dir, &self.storage_dir)?; - drop(inner); - self.emit_event("lxmf_message", res.clone()); - Ok(res) + #[cfg(feature = "rns-stack")] + { + let _ = req; + Err("lxmf reaction requires live rns-stack sidecar".into()) + } + #[cfg(not(feature = "rns-stack"))] + { + let mut inner = self.inner.write().await; + let res = inner.send_reaction(&req)?; + inner.save(&self.config_dir, &self.storage_dir)?; + drop(inner); + self.emit_event("lxmf_message", res.clone()); + Ok(res) + } } #[allow(clippy::unused_async)] // async matches StackHandle admin API awaited by HTTP handlers @@ -2516,7 +2628,7 @@ impl StackHandle { /// Graceful RNS shutdown (BLE RNode detach) before the process is SIGTERM'd. pub async fn prepare_stop(&self) -> Result<(), String> { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { live.prepare_stop().await; return Ok(()); } @@ -2588,7 +2700,7 @@ impl StackHandle { pub async fn voice_status(&self) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.voice_status().await; } serde_json::json!({ @@ -2608,7 +2720,7 @@ impl StackHandle { pub async fn voice_call(&self, identity_hash: &str) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.voice_call(identity_hash).await; } let _ = identity_hash; @@ -2617,7 +2729,7 @@ impl StackHandle { pub async fn voice_answer(&self) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.voice_answer().await; } serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) @@ -2625,7 +2737,7 @@ impl StackHandle { pub async fn voice_reject(&self) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.voice_reject().await; } serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) @@ -2633,7 +2745,7 @@ impl StackHandle { pub async fn voice_hangup(&self) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.voice_hangup().await; } serde_json::json!({ "ok": false, "error": "voice requires live rns-stack sidecar" }) @@ -2641,7 +2753,7 @@ impl StackHandle { pub async fn voice_mute(&self, muted: bool) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.voice_mute(muted).await; } let _ = muted; @@ -2655,7 +2767,7 @@ impl StackHandle { samples_b64: &str, ) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.voice_audio(profile, channels, samples_b64).await; } let _ = (profile, channels, samples_b64); @@ -2664,7 +2776,7 @@ impl StackHandle { pub async fn games_status(&self) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.games_status().await; } serde_json::json!({ @@ -2676,7 +2788,7 @@ impl StackHandle { pub async fn games_apps(&self) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.games_apps().await; } serde_json::json!({ "apps": [] }) @@ -2684,7 +2796,7 @@ impl StackHandle { pub async fn games_sessions(&self, peer: Option<&str>) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.games_sessions(peer).await; } let _ = peer; @@ -2693,7 +2805,7 @@ impl StackHandle { pub async fn games_session_detail(&self, session_id: &str) -> serde_json::Value { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.games_session_detail(session_id).await; } let _ = session_id; @@ -2709,7 +2821,7 @@ impl StackHandle { payload: Option<&serde_json::Value>, ) -> Result { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live .send_game_action(dest_hash, app_id, command, session_id, payload) .await; @@ -2720,7 +2832,7 @@ impl StackHandle { pub async fn games_resend_action(&self, session_id: &str) -> Result { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.resend_last_game_action(session_id).await; } let _ = session_id; @@ -2729,7 +2841,7 @@ impl StackHandle { pub async fn games_mark_read(&self, session_id: &str) -> Result<(), String> { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.games_mark_read(session_id).await; } let _ = session_id; @@ -2738,7 +2850,7 @@ impl StackHandle { pub async fn games_delete_session(&self, session_id: &str) -> Result<(), String> { #[cfg(feature = "rns-stack")] - if let Some(live) = &self.live { + if let Some(live) = self.live.get() { return live.games_delete_session(session_id).await; } let _ = session_id; @@ -2924,10 +3036,24 @@ impl StackHandle { } pub async fn rns_ready(&self) -> bool { + #[cfg(feature = "rns-stack")] + { + // Persisted flags can be true from a prior session before live attach finishes. + // HTTP may already be up — only report ready once the live bridge is attached. + if self.live.get().is_none() { + return false; + } + } self.inner.read().await.rns_ready } pub async fn lxmf_ready(&self) -> bool { + #[cfg(feature = "rns-stack")] + { + if self.live.get().is_none() { + return false; + } + } self.inner.read().await.lxmf_ready } @@ -3331,6 +3457,38 @@ mod tests { let _ = std::fs::remove_dir_all(storage_dir); } + #[tokio::test] + async fn bootstrap_clears_persisted_rns_and_lxmf_ready_until_attach_live() { + let (config_dir, storage_dir) = temp_stack_dirs(); + let mut stale = PersistedState::load(&config_dir, &storage_dir); + stale.rns_ready = true; + stale.lxmf_ready = true; + stale + .save(&config_dir, &storage_dir) + .expect("save stale ready flags"); + let reloaded = PersistedState::load(&config_dir, &storage_dir); + assert!(reloaded.rns_ready); + assert!(reloaded.lxmf_ready); + + let (tx, _) = broadcast::channel(8); + let handle = Box::pin(StackHandle::bootstrap( + config_dir.clone(), + storage_dir.clone(), + tx, + )) + .await; + + // diagnostics_snapshot reads inner flags (not the live-gate on rns_ready()). + let snap = handle.diagnostics_snapshot().await; + assert_eq!(snap["rns_ready"], false); + assert_eq!(snap["lxmf_ready"], false); + assert!(!handle.rns_ready().await); + assert!(!handle.lxmf_ready().await); + + let _ = std::fs::remove_dir_all(config_dir); + let _ = std::fs::remove_dir_all(storage_dir); + } + #[test] fn sync_live_peer_cache_keeps_demoted_peers_not_in_path_table() { let now = now_unix_secs(); diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs index dbd75dab1..65260ce44 100644 --- a/reticulum-sidecar/src/stack/persistence.rs +++ b/reticulum-sidecar/src/stack/persistence.rs @@ -714,6 +714,8 @@ impl PersistedState { self.upsert_contact(&hash, resolved); } + /// Offline/stub LXMF send used when the `rns-stack` feature is off. + #[cfg_attr(feature = "rns-stack", allow(dead_code))] pub fn send_lxmf_local(&mut self, req: &LxmfSendRequest) -> Result { if !self.identity.configured { return Err("identity not configured".into()); @@ -751,6 +753,7 @@ impl PersistedState { } #[allow(clippy::unnecessary_wraps)] // Result matches other LXMF send helpers for uniform ? handling + #[cfg_attr(feature = "rns-stack", allow(dead_code))] pub fn send_reaction( &mut self, req: &LxmfReactionRequest, @@ -785,6 +788,7 @@ impl PersistedState { } } +#[cfg_attr(feature = "rns-stack", allow(dead_code))] pub(crate) fn stable_hash(s: &str) -> u128 { let mut h: u128 = 0xcbf2_9ce4_8422_2325; for b in s.bytes() { diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index 6de1573c9..3ff138aa6 100644 --- a/reticulum-sidecar/src/stack/propagation_bridge.rs +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -12,7 +12,7 @@ use lxmf_core::propagation_sync::{PeerSyncTerminalState, PropagationSyncTask, Sy use lxmf_core::router::LxmRouter; use rns_identity::identity::Identity; use rns_transport::messages::TransportMessage; -use tokio::sync::{broadcast, mpsc}; +use tokio::sync::{Notify, broadcast, mpsc}; /// Completed host-peer peering PoW (stamp, value) awaiting apply onto `LxmPeer`. type PeeringKeyResult = ([u8; 16], [u8; 32], u32); @@ -25,6 +25,9 @@ pub struct PropagationBridge { local_node: Arc>, sync_task: Mutex, local_serving: AtomicBool, + /// Terminal result of background `load_messagestore_from_disk` (`None` while in flight). + messagestore_result: Mutex>>, + messagestore_notify: Notify, /// Serializes sync-run generation changes with emitter cancel / pin / event side effects. sync_lifecycle: Mutex<()>, /// Sticky offer failure label (rsLXMF tip keeps terminal state on the task, not these fields). @@ -57,8 +60,10 @@ impl PropagationBridge { max_message_size: policy.propagation_limit_kb.saturating_mul(1024), max_offer_size: policy.sync_limit_kb.saturating_mul(1000), }; + // Defer messagestore scan — large local PN stores can take many seconds and must + // not gate TCP/LXMF/RRC live attach. New writes still go to `storage_dir`. let local_node = Arc::new(Mutex::new( - PropagationNode::with_storage(node_config, local_dest_hash, storage_dir) + PropagationNode::with_storage_unloaded(node_config, local_dest_hash, storage_dir) .map_err(|e| format!("propagation storage init: {e}"))?, )); let mut sync_task = PropagationSyncTask::with_shared_node(transport_tx, local_node.clone()); @@ -71,6 +76,8 @@ impl PropagationBridge { local_node, sync_task: Mutex::new(sync_task), local_serving: AtomicBool::new(false), + messagestore_result: Mutex::new(None), + messagestore_notify: Notify::new(), sync_lifecycle: Mutex::new(()), last_offer_error: Mutex::new(None), last_establish_error: Mutex::new(None), @@ -81,6 +88,65 @@ impl PropagationBridge { }) } + /// Load historical PN messages off the live-ready path (spawn_blocking). + pub fn spawn_messagestore_load(self: &Arc) { + let this = Arc::clone(self); + let node = Arc::clone(&self.local_node); + tokio::spawn(async move { + let load_started = Instant::now(); + let result = tokio::task::spawn_blocking(move || { + let mut guard = node + .lock() + .map_err(|e| format!("propagation node lock poisoned: {e}"))?; + guard + .load_messagestore_from_disk() + .map_err(|e| e.to_string())?; + Ok::<(), String>(()) + }) + .await; + let terminal = match result { + Ok(Ok(())) => { + tracing::info!( + elapsed_ms = load_started.elapsed().as_millis() as u64, + "propagation messagestore loaded in background" + ); + Ok(()) + } + Ok(Err(e)) => { + tracing::warn!(error = %e, "background propagation messagestore load failed"); + Err(e) + } + Err(e) => { + let msg = format!("background propagation messagestore load join failed: {e}"); + tracing::warn!(error = %e, "background propagation messagestore load join failed"); + Err(msg) + } + }; + if let Ok(mut slot) = this.messagestore_result.lock() { + *slot = Some(terminal); + } + this.messagestore_notify.notify_waiters(); + }); + } + + /// Wait until background messagestore load has finished; returns the stored terminal result. + pub async fn wait_messagestore_loaded(&self) -> Result<(), String> { + loop { + if let Ok(guard) = self.messagestore_result.lock() { + if let Some(result) = guard.as_ref() { + return result.clone(); + } + } + let notified = self.messagestore_notify.notified(); + if let Ok(guard) = self.messagestore_result.lock() { + if let Some(result) = guard.as_ref() { + return result.clone(); + } + } + notified.await; + } + } + pub fn peering_key_job_inflight(&self, peer_hash: &[u8; 16]) -> bool { self.peering_key_jobs .lock() diff --git a/scripts/apply-rsLXMF-propagation-node-deferred-messagestore-load.sh b/scripts/apply-rsLXMF-propagation-node-deferred-messagestore-load.sh new file mode 100755 index 000000000..0cb631b24 --- /dev/null +++ b/scripts/apply-rsLXMF-propagation-node-deferred-messagestore-load.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Apply mesh-client rsLXMF deferred messagestore load for rns-stack builds. +# Adds PropagationNode::with_storage_unloaded + load_messagestore_from_disk so +# live attach / RRC can become ready before scanning a large PN store. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# shellcheck source=lib/apply-ratspeak-overlay.sh +source "${SCRIPT_DIR}/lib/apply-ratspeak-overlay.sh" +PATCH_FILE="${REPO_ROOT}/reticulum-sidecar/patches/rsLXMF-propagation-node-deferred-messagestore-load.patch" +LXMF_DIR="${RS_LXMF_DIR:-$(cd "${REPO_ROOT}/.." && pwd)/rsLXMF}" +NODE_RS="${LXMF_DIR}/crates/lxmf-core/src/propagation_node.rs" + +if [[ ! -d "${LXMF_DIR}/.git" ]]; then + echo "error: rsLXMF not found at ${LXMF_DIR}" >&2 + echo "Clone: git clone https://github.com/ratspeak/rsLXMF.git ${LXMF_DIR}" >&2 + exit 1 +fi + +if [[ ! -f "${PATCH_FILE}" ]]; then + echo "error: patch not found at ${PATCH_FILE}" >&2 + exit 1 +fi + +overlay_already_present() { + [[ -f "${NODE_RS}" ]] || return 1 + grep -qE 'fn with_storage_unloaded\(' "${NODE_RS}" \ + && grep -qE 'fn load_messagestore_from_disk\(' "${NODE_RS}" +} + +if overlay_already_present; then + echo "propagation-node deferred messagestore load overlay already present on rsLXMF @ $(git -C "${LXMF_DIR}" rev-parse --short HEAD)" + exit 0 +fi + +if apply_ratspeak_overlay_or_die "${LXMF_DIR}" "${PATCH_FILE}" "propagation-node-deferred-messagestore-load"; then + exit 0 +fi +exit 1 diff --git a/scripts/clone-ratspeak-stack.test.mjs b/scripts/clone-ratspeak-stack.test.mjs index 2c3a6584e..4cfc9da3c 100644 --- a/scripts/clone-ratspeak-stack.test.mjs +++ b/scripts/clone-ratspeak-stack.test.mjs @@ -175,5 +175,6 @@ describe('clone-ratspeak-stack.sh float policy', () => { const listScript = readFileSync(listPath, 'utf8'); expect(listScript).toContain('apply-rsReticulum-packet-tap.sh'); expect(listScript).toContain('apply-rsLXMF-propagation-node-policy-setters.sh'); + expect(listScript).toContain('apply-rsLXMF-propagation-node-deferred-messagestore-load.sh'); }); }); diff --git a/scripts/lib/ratspeak-overlay-apply-list.sh b/scripts/lib/ratspeak-overlay-apply-list.sh index 10639e8e2..f0ce424f4 100644 --- a/scripts/lib/ratspeak-overlay-apply-list.sh +++ b/scripts/lib/ratspeak-overlay-apply-list.sh @@ -18,6 +18,7 @@ RS_RETICULUM_APPLY_SCRIPTS=( RS_LXMF_APPLY_SCRIPTS=( apply-rsLXMF-propagation-sync-peering.sh apply-rsLXMF-propagation-node-policy-setters.sh + apply-rsLXMF-propagation-node-deferred-messagestore-load.sh apply-rsLXMF-link-delivery-has-pending-to.sh ) diff --git a/scripts/update.sh b/scripts/update.sh index 06de96821..06f3c519d 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -221,6 +221,7 @@ check_ratspeak_patches() { 'rsReticulum-inbound-raw-saturation-log.patch|ratspeak/rsReticulum||rsReticulum inbound-raw saturation log|' 'rsLXMF-propagation-sync-peering.patch|ratspeak/rsLXMF|4|rsLXMF propagation sync peering|https://github.com/ratspeak/rsLXMF/pull/4' 'rsLXMF-propagation-node-policy-setters.patch|ratspeak/rsLXMF|6|rsLXMF PropagationNode policy setters|https://github.com/ratspeak/rsLXMF/pull/6' + 'rsLXMF-propagation-node-deferred-messagestore-load.patch|ratspeak/rsLXMF||rsLXMF PropagationNode deferred messagestore load|' 'rsLXMF-link-delivery-has-pending-to.patch|ratspeak/rsLXMF||rsLXMF LinkDeliveryManager has_pending_to|' ) local patches_dir='reticulum-sidecar/patches' diff --git a/src/main/reticulum-sidecar-manager.test.ts b/src/main/reticulum-sidecar-manager.test.ts index 65dc63ff0..bbb736cdf 100644 --- a/src/main/reticulum-sidecar-manager.test.ts +++ b/src/main/reticulum-sidecar-manager.test.ts @@ -578,11 +578,17 @@ describe('ReticulumSidecarManager', () => { expect(wsInstance.handlers.has('error')).toBe(true); }); - it('yields Noble BLE when config has enabled ble RNode before spawn', async () => { + it('starts Noble BLE yield after health succeeds (does not block start on yield)', async () => { const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); vi.mocked(reticulumConfigDirHasEnabledBleRnode).mockReturnValue(true); + let resolveYield!: () => void; + const yieldGate = new Promise((resolve) => { + resolveYield = resolve; + }); + suspendNobleMock.mockImplementationOnce(() => yieldGate); + const proc = mockSidecarProc(); proc.kill.mockImplementation(() => { proc.emit('exit', 0, null); @@ -590,17 +596,27 @@ describe('ReticulumSidecarManager', () => { spawnMock.mockReturnValue(proc); const manager = new ReticulumSidecarManager(); - await manager.start(); - - expect(suspendNobleMock).toHaveBeenCalledTimes(1); + const started = await manager.start(); + expect(started.running).toBe(true); expect(spawnMock).toHaveBeenCalledTimes(1); + // Yield kicks after health poll succeeds — start has already returned success. + expect(suspendNobleMock).toHaveBeenCalledTimes(1); + expect(spawnMock.mock.invocationCallOrder[0]).toBeLessThan( + suspendNobleMock.mock.invocationCallOrder[0], + ); + + resolveYield(); + await yieldGate; + await vi.waitFor(() => { + expect(suspendNobleMock).toHaveBeenCalledTimes(1); + }); await manager.stop(); existsSpy.mockRestore(); mkdirSpy.mockRestore(); }); - it('releases Noble scan lock when sidecar binary ensure fails after yield', async () => { + it('does not yield Noble when sidecar binary ensure fails before spawn', async () => { const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); vi.mocked(reticulumConfigDirHasEnabledBleRnode).mockReturnValue(true); @@ -608,8 +624,128 @@ describe('ReticulumSidecarManager', () => { const manager = new ReticulumSidecarManager(); await expect(manager.start()).rejects.toThrow('missing rust toolchain'); + expect(suspendNobleMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + + existsSpy.mockRestore(); + mkdirSpy.mockRestore(); + }); + + it('stop during pre-spawn cargo does not await the startPromise', async () => { + const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); + const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + vi.mocked(reticulumConfigDirHasEnabledBleRnode).mockReturnValue(true); + let resolveCargo!: () => void; + const cargoGate = new Promise((resolve) => { + resolveCargo = () => { + resolve(); + }; + }); + vi.mocked(ensureDevSidecarBinary).mockImplementationOnce(() => cargoGate); + + const manager = new ReticulumSidecarManager(); + const startP = manager.start(); + await vi.waitFor(() => { + expect(ensureDevSidecarBinary).toHaveBeenCalled(); + }); + + const stopT0 = Date.now(); + await manager.stop(); + expect(Date.now() - stopT0).toBeLessThan(500); + // Cancel during cargo must never yank Meshtastic/MeshCore Noble. + expect(suspendNobleMock).not.toHaveBeenCalled(); + + resolveCargo(); + await expect(startP).rejects.toThrow(/START_ABORTED|aborted/i); + + existsSpy.mockRestore(); + mkdirSpy.mockRestore(); + }); + + it('after stop aborts cargo, a subsequent start does not rejoin the aborted promise', async () => { + const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); + const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + let resolveCargo!: () => void; + const cargoGate = new Promise((resolve) => { + resolveCargo = () => { + resolve(); + }; + }); + vi.mocked(ensureDevSidecarBinary).mockClear(); + vi.mocked(ensureDevSidecarBinary).mockImplementationOnce(() => cargoGate); + + const manager = new ReticulumSidecarManager(); + const abortedStart = manager.start(); + await vi.waitFor(() => { + expect(ensureDevSidecarBinary).toHaveBeenCalledTimes(1); + }); + expect(spawnMock).not.toHaveBeenCalled(); + + await manager.stop(); + resolveCargo(); + await expect(abortedStart).rejects.toThrow(/START_ABORTED|aborted/i); + + const proc = mockSidecarProc(); + proc.kill.mockImplementation(() => { + proc.emit('exit', 0, null); + }); + spawnMock.mockReturnValue(proc); + vi.mocked(ensureDevSidecarBinary).mockResolvedValue(undefined); + + const started = await manager.start(); + expect(started.running).toBe(true); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(ensureDevSidecarBinary).toHaveBeenCalledTimes(2); + + await manager.stop(); + existsSpy.mockRestore(); + mkdirSpy.mockRestore(); + }); + + it('releases Noble when an aborted yield resumes after a newer start clears abort', async () => { + const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true); + const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + vi.mocked(reticulumConfigDirHasEnabledBleRnode).mockReturnValue(true); + + let resolveFirstYield!: () => void; + const firstYieldGate = new Promise((resolve) => { + resolveFirstYield = resolve; + }); + suspendNobleMock.mockImplementationOnce(() => { + getStateMock.mockReturnValue({ connections: [], scanOwner: 'reticulum' }); + return firstYieldGate; + }); + + const proc1 = mockSidecarProc(); + proc1.kill.mockImplementation(() => { + proc1.emit('exit', 0, null); + }); + spawnMock.mockReturnValue(proc1); + + const manager = new ReticulumSidecarManager(); + await manager.start(); expect(suspendNobleMock).toHaveBeenCalledTimes(1); - expect(releaseScanMock).toHaveBeenCalledWith('reticulum'); + + await manager.stop(); + releaseScanMock.mockClear(); + // Aborted yield still holds the coordinator ownership until it resumes. + getStateMock.mockReturnValue({ connections: [], scanOwner: 'reticulum' }); + + const proc2 = mockSidecarProc(); + proc2.kill.mockImplementation(() => { + proc2.emit('exit', 0, null); + }); + spawnMock.mockReturnValue(proc2); + suspendNobleMock.mockResolvedValue(undefined); + + const started = manager.start(); + resolveFirstYield(); + await firstYieldGate; + await vi.waitFor(() => { + expect(releaseScanMock).toHaveBeenCalledWith('reticulum'); + }); + await started; + await manager.stop(); existsSpy.mockRestore(); mkdirSpy.mockRestore(); diff --git a/src/main/reticulum-sidecar-manager.ts b/src/main/reticulum-sidecar-manager.ts index 957efd1d0..b4275a326 100644 --- a/src/main/reticulum-sidecar-manager.ts +++ b/src/main/reticulum-sidecar-manager.ts @@ -40,7 +40,7 @@ import { startSidecarWatchdog } from './reticulumSidecarWatchdog'; const HEALTH_POLL_INTERVAL_MS = 250; const HEALTH_POLL_TIMEOUT_MS = 30 * MS_PER_SECOND; /** Wait for BLE RNode detach via POST /api/v1/stack/prepare-stop before SIGTERM. */ -const PREPARE_STOP_TIMEOUT_MS = 8 * MS_PER_SECOND; +const PREPARE_STOP_TIMEOUT_MS = 1 * MS_PER_SECOND; const STOP_GRACE_MS = 5 * MS_PER_SECOND; /** After yielding Noble BLE, allow CoreBluetooth/btleplug to settle before sidecar connect. */ const RETICULUM_BLE_RNODE_NOBLE_SETTLE_MS = 500; @@ -149,7 +149,9 @@ async function pollSidecarHealth(port: number): Promise lastError = `status ${res.status}`; } else { const body = (await res.json()) as ReticulumStatusResponse; - if (body.status === 'ok') return body; + if (body.status === 'ok') { + return body; + } lastError = `unexpected status field: ${body.status}`; } } catch (err) { @@ -175,6 +177,16 @@ export class ReticulumSidecarManager extends EventEmitter { private startPromise: Promise | null = null; /** In-flight stop — start must await so a fresh spawn does not race SIGTERM exit. */ private stopPromise: Promise | null = null; + /** + * Set by stop() so an in-flight startOnce exits at the next checkpoint instead of + * spawning after cargo/BLE yield. Lets Cancel/Disconnect return without waiting on cargo. + */ + private startAbortRequested = false; + /** + * Bumped on stop and each new start so a late Noble yield from an aborted attempt + * cannot observe a cleared startAbortRequested from a newer start. + */ + private startAttemptGeneration = 0; private readonly stderrDedupe = new ReticulumSidecarStderrDedupe(); private readonly autoBeaconTracker = new ReticulumSidecarAutoBeaconTracker(); private readonly interfaceIssueTracker = new ReticulumSidecarInterfaceIssueTracker(); @@ -256,21 +268,40 @@ export class ReticulumSidecarManager extends EventEmitter { } async start(opts: ReticulumSidecarStartOptions = {}): Promise { + // After Cancel/stop aborts an in-flight start, do not rejoin that doomed promise — + // wait for it to clear, then start fresh. + if (this.startPromise && this.startAbortRequested) { + try { + await this.startPromise; + } catch { + // catch-no-log-ok: previous start aborted or failed; continue with a new start + } + } if (this.startPromise) { return this.startPromise; } + this.startAbortRequested = false; + this.startAttemptGeneration += 1; this.startPromise = this.startOnce(opts).finally(() => { this.startPromise = null; }); return this.startPromise; } + /** Abort in-flight start at await checkpoints (cargo / BLE yield / pre-spawn). */ + private throwIfStartAborted(releaseNobleYield?: () => void): void { + if (!this.startAbortRequested) return; + releaseNobleYield?.(); + throw new Error('RETICULUM_SIDECAR_START_ABORTED: stop requested during start'); + } + private async startOnce( opts: ReticulumSidecarStartOptions = {}, ): Promise { if (this.stopPromise) { await this.stopPromise; } + this.throwIfStartAborted(); if (opts.reuseIfRunning && this._status.running && this.proc) { try { await pollSidecarHealth(this._status.port); @@ -299,31 +330,18 @@ export class ReticulumSidecarManager extends EventEmitter { } const needsBleRnodeNobleYield = reticulumConfigDirHasEnabledBleRnode(configDir); - let nobleYieldHeldForStart = false; - if (needsBleRnodeNobleYield) { - await bleCoexistenceCoordinator.suspendNobleForReticulumBleConnect(); - nobleYieldHeldForStart = true; - await new Promise((r) => setTimeout(r, RETICULUM_BLE_RNODE_NOBLE_SETTLE_MS)); - } - - const releaseNobleYieldOnStartFailure = (): void => { - if (!nobleYieldHeldForStart) return; - nobleYieldHeldForStart = false; - bleCoexistenceCoordinator.releaseScan('reticulum'); - }; const port = await findFreePort(); const binary = this.resolveBinaryPath(); try { await ensureDevSidecarBinary(binary); + this.throwIfStartAborted(); } catch (err) { - releaseNobleYieldOnStartFailure(); const msg = err instanceof Error ? err.message : String(err); this._status = { running: false, port: 0, pid: null, lastError: msg }; throw new Error(msg); } if (!fs.existsSync(binary)) { - releaseNobleYieldOnStartFailure(); const msg = app.isPackaged ? `RETICULUM_SIDECAR_BUNDLED_MISSING: packaged sidecar binary not found at ${binary}` : `Reticulum sidecar binary not found: ${binary}. Run \`pnpm run reticulum:sidecar:build\` from the repo root (requires Rust).`; @@ -331,6 +349,10 @@ export class ReticulumSidecarManager extends EventEmitter { throw new Error(msg); } + this.throwIfStartAborted(); + // Kick Noble yield only after health succeeds (below) so Cancel during cargo/spawn + // never suspends Meshtastic/MeshCore, while TCP/API readiness still does not await BLE. + const needsBleYieldAfterHealth = needsBleRnodeNobleYield; const args = [ '--headless', '--host', @@ -401,15 +423,12 @@ export class ReticulumSidecarManager extends EventEmitter { try { await pollSidecarHealth(port); } catch (err) { - releaseNobleYieldOnStartFailure(); const msg = err instanceof Error ? err.message : String(err); await this.stopProc(); this._status = { running: false, port: 0, pid: null, lastError: msg }; throw new Error(msg); } - nobleYieldHeldForStart = false; - this._status = { running: true, port, @@ -420,9 +439,36 @@ export class ReticulumSidecarManager extends EventEmitter { this.connectWs(port); this.startWatchdog(); this.emit('status', this.getStatus()); + // Do not await BLE yield — TCP/LXMF/RRC/Nomad are already usable. Start yield only + // after health so Cancel during cargo never yanks Meshtastic/MeshCore. + if (needsBleYieldAfterHealth) { + void this.yieldNobleForEnabledBleRnode().catch((e: unknown) => { + console.warn( + '[ReticulumSidecar] background Noble yield for BLE RNode failed:', + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); + }); + } return this.getStatus(); } + /** + * Yield CoreBluetooth/Noble to the sidecar for BLE RNode (macOS/Windows). + * Runs after health so stack TCP features are not gated on BLE. + */ + private async yieldNobleForEnabledBleRnode(): Promise { + const attemptGeneration = this.startAttemptGeneration; + if (this.startAbortRequested) return; + await bleCoexistenceCoordinator.suspendNobleForReticulumBleConnect(); + if (this.startAbortRequested || attemptGeneration !== this.startAttemptGeneration) { + if (bleCoexistenceCoordinator.getState().scanOwner === 'reticulum') { + bleCoexistenceCoordinator.releaseScan('reticulum'); + } + return; + } + await new Promise((r) => setTimeout(r, RETICULUM_BLE_RNODE_NOBLE_SETTLE_MS)); + } + private startWatchdog(): void { this.stopWatchdog(); this.watchdogStop = startSidecarWatchdog({ @@ -453,9 +499,18 @@ export class ReticulumSidecarManager extends EventEmitter { } async stop(): Promise { - if (this.startPromise) { - await this.startPromise.catch(() => { - // catch-no-log-ok: in-flight start may fail; explicit stop still runs afterward + // Abort in-flight start at checkpoints (cargo/BLE) so Cancel does not wait on build. + this.startAbortRequested = true; + this.startAttemptGeneration += 1; + if (this.startPromise && !this.proc) { + // Pre-spawn: do not await cargo — startOnce throws at next checkpoint. + void this.startPromise.catch(() => { + // catch-no-log-ok: aborted/failed start; stop continues without blocking UI + }); + } else if (this.startPromise) { + // Process already spawned (e.g. health poll): kill via stopProc; do not wait on health. + void this.startPromise.catch(() => { + // catch-no-log-ok: start fails when proc is killed mid-health-poll }); } if (this.stopPromise) { diff --git a/src/main/reticulum-sidecar-path.test.ts b/src/main/reticulum-sidecar-path.test.ts index 011dd8435..954e30ea5 100644 --- a/src/main/reticulum-sidecar-path.test.ts +++ b/src/main/reticulum-sidecar-path.test.ts @@ -156,6 +156,34 @@ describe('reticulum-sidecar-path', () => { expect(msg).toContain('pnpm run reticulum:sidecar:build'); }); + it('resolveDevSidecarEnsureAction backgrounds mtime-stale full builds', async () => { + const { resolveDevSidecarEnsureAction } = await import('./reticulum-sidecar-path'); + expect( + resolveDevSidecarEnsureAction({ + missing: false, + stale: true, + lacksRnsStack: false, + lacksRnsBle: false, + }), + ).toBe('background-build'); + expect( + resolveDevSidecarEnsureAction({ + missing: true, + stale: false, + lacksRnsStack: false, + lacksRnsBle: false, + }), + ).toBe('await-build'); + expect( + resolveDevSidecarEnsureAction({ + missing: false, + stale: false, + lacksRnsStack: false, + lacksRnsBle: false, + }), + ).toBe('noop'); + }); + it('formatReticulumCargoBuildError keeps generic RETICULUM_CARGO_BUILD_FAILED for other errors', () => { const msg = formatReticulumCargoBuildError(101, 'error: linker command failed'); expect(msg).toContain('RETICULUM_CARGO_BUILD_FAILED'); diff --git a/src/main/reticulum-sidecar-path.ts b/src/main/reticulum-sidecar-path.ts index 92ed9674e..78233a95b 100644 --- a/src/main/reticulum-sidecar-path.ts +++ b/src/main/reticulum-sidecar-path.ts @@ -230,6 +230,23 @@ export function sidecarBinaryIsStale(binaryPath: string, projectDir: string): bo return newestReticulumSidecarSourceMtimeMs(projectDir) > binaryMtime; } +export type DevSidecarEnsureAction = 'await-build' | 'background-build' | 'noop'; + +/** + * Decide whether connect must wait on cargo, can refresh in the background, or skip. + * Missing / featureless binaries block; mtime-stale full-feature binaries do not. + */ +export function resolveDevSidecarEnsureAction(opts: { + missing: boolean; + stale: boolean; + lacksRnsStack: boolean; + lacksRnsBle: boolean; +}): DevSidecarEnsureAction { + if (opts.missing || opts.lacksRnsStack || opts.lacksRnsBle) return 'await-build'; + if (opts.stale) return 'background-build'; + return 'noop'; +} + async function runDevSidecarCargoBuild(projectDir: string, reason: string): Promise { if (!devBuildInFlight) { console.debug(`[ReticulumSidecar] ${reason}; running cargo build…`); @@ -245,7 +262,7 @@ async function runDevSidecarCargoBuild(projectDir: string, reason: string): Prom await devBuildInFlight; } -/** Dev-only: compile the sidecar when the debug binary is missing or stale. */ +/** Dev-only: compile the sidecar when the debug binary is missing or unusable. */ export async function ensureDevSidecarBinary(binaryPath: string): Promise { if (app.isPackaged) return; @@ -262,10 +279,28 @@ export async function ensureDevSidecarBinary(binaryPath: string): Promise !missing && hasRnsStackSiblings(projectDir) && sidecarBinaryLacksRnsStack(binaryPath); const lacksRnsBle = !missing && hasRnsStackSiblings(projectDir) && sidecarBinaryLacksRnsBle(binaryPath); + const action = resolveDevSidecarEnsureAction({ missing, stale, lacksRnsStack, lacksRnsBle }); + + if (action === 'noop') { + return; + } + if (action === 'background-build') { + // Prefer availability: start TCP/LXMF/RRC on the existing binary while cargo + // refreshes in the background for the next start. + void runDevSidecarCargoBuild( + projectDir, + 'sidecar sources newer than binary (background refresh)', + ).catch((e: unknown) => { + console.warn( + '[ReticulumSidecar] background cargo rebuild failed:', + sanitizeLogMessage(e instanceof Error ? e.message : String(e)), + ); + }); + return; + } + if (missing) { await runDevSidecarCargoBuild(projectDir, 'debug binary missing'); - } else if (stale) { - await runDevSidecarCargoBuild(projectDir, 'sidecar sources newer than binary'); } else if (lacksRnsStack) { await runDevSidecarCargoBuild( projectDir, @@ -276,8 +311,6 @@ export async function ensureDevSidecarBinary(binaryPath: string): Promise projectDir, 'debug binary lacks rns-ble; rebuilding with BLE interface support', ); - } else { - return; } if (!fs.existsSync(binaryPath)) { diff --git a/src/renderer/components/ConnectionPanel.test.tsx b/src/renderer/components/ConnectionPanel.test.tsx index 6add6b7ae..936e09960 100644 --- a/src/renderer/components/ConnectionPanel.test.tsx +++ b/src/renderer/components/ConnectionPanel.test.tsx @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { act } from 'react'; @@ -14,6 +17,8 @@ import type { DeviceState } from '../lib/types'; import { mockConsoleWarn, withMockedConsoleWarn } from '../lib/vitestConsoleMock'; import ConnectionPanel from './ConnectionPanel'; +const CONNECTION_PANEL_SOURCE = readFileSync(join(__dirname, 'ConnectionPanel.tsx'), 'utf-8'); + const disconnectedState: DeviceState = { status: 'disconnected', myNodeNum: 0, @@ -2257,4 +2262,60 @@ describe('ConnectionPanel Reticulum', () => { localStorage.removeItem(lastConnKey); } }); + + it('Cancel fire-and-forgets onDisconnect and does not stopNobleBleScanning for reticulum', async () => { + // handleCancelConnection is shared by Cancel + Disconnect&Quit-while-connecting. + expect(CONNECTION_PANEL_SOURCE).toMatch(/void onDisconnect\(\)\.catch\(\(e: unknown\) => \{/); + expect(CONNECTION_PANEL_SOURCE).toMatch( + /else if \(capabilities\.hasNobleBleScanning\) \{\s*void window\.electronAPI\.stopNobleBleScanning\(protocol\)/, + ); + + const lastConnKey = 'mesh-client:lastConnection:reticulum'; + localStorage.setItem( + lastConnKey, + JSON.stringify({ type: 'ble', bleDeviceId: 'saved-reticulum-ble' }), + ); + let resolveDisconnect!: () => void; + const onDisconnect = vi.fn( + () => + new Promise((resolve) => { + resolveDisconnect = resolve; + }), + ); + vi.mocked(window.electronAPI.stopNobleBleScanning).mockClear(); + vi.mocked(window.electronAPI.quitApp).mockClear(); + + try { + const user = userEvent.setup(); + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Reticulum stack')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /Disconnect & Quit/i })); + + await waitFor(() => { + expect(onDisconnect).toHaveBeenCalledTimes(1); + }); + // Fire-and-forget: hung onDisconnect must not block quitApp. + await waitFor(() => { + expect(window.electronAPI.quitApp).toHaveBeenCalled(); + }); + expect(window.electronAPI.stopNobleBleScanning).not.toHaveBeenCalled(); + } finally { + resolveDisconnect?.(); + localStorage.removeItem(lastConnKey); + } + }); }); diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index 30ca0ee8b..b6b09320e 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -1300,7 +1300,7 @@ export default function ConnectionPanel({ } }, [connectionType, activeHostAddress, onConnect, protocol, isLinux, t]); - const handleCancelConnection = useCallback(async () => { + const handleCancelConnection = useCallback(() => { autoConnectCancelRef.current = true; cancelProtocolRfAutoConnect(protocol); isAutoConnectingRef.current = false; @@ -1327,7 +1327,7 @@ export default function ConnectionPanel({ if (webBluetoothDevice) { setWebBluetoothDevice(null); } - } else { + } else if (capabilities.hasNobleBleScanning) { void window.electronAPI.stopNobleBleScanning(protocol).catch((e: unknown) => { console.debug('[ConnectionPanel] stopNobleBleScanning failed ' + errLikeToLogString(e)); }); @@ -1341,13 +1341,11 @@ export default function ConnectionPanel({ bleLinuxPickerSelectionResolvedRef.current = false; setConnecting(false); setConnectionStage(''); - // Ensure the underlying connection attempt is properly torn down - try { - console.debug('[ConnectionPanel] handleCancelConnection onDisconnect'); - await onDisconnect(); - } catch (e) { + // Tear down connection without blocking Cancel UI on sidecar cargo/BLE start. + console.debug('[ConnectionPanel] handleCancelConnection onDisconnect'); + void onDisconnect().catch((e: unknown) => { console.debug('[ConnectionPanel] onDisconnect best-effort cleanup ' + errLikeToLogString(e)); - } + }); }, [ showBlePicker, showSerialPicker, @@ -1356,6 +1354,7 @@ export default function ConnectionPanel({ protocol, isLinux, webBluetoothDevice, + capabilities.hasNobleBleScanning, ]); const handleSelectBleDevice = useCallback( @@ -1407,9 +1406,11 @@ export default function ConnectionPanel({ // Don't call onConnect again - the original onConnect will continue from requestDevice() // and proceed to connect(), which triggers the pairing handler. } else { - void window.electronAPI.stopNobleBleScanning(protocol).catch((e: unknown) => { - console.debug('[ConnectionPanel] stopNobleBleScanning failed ' + errLikeToLogString(e)); - }); + if (capabilities.hasNobleBleScanning) { + void window.electronAPI.stopNobleBleScanning(protocol).catch((e: unknown) => { + console.debug('[ConnectionPanel] stopNobleBleScanning failed ' + errLikeToLogString(e)); + }); + } // Trigger the actual connection with the peripheral ID onConnect('ble', undefined, deviceId).catch((err: unknown) => { const errMsg = err instanceof Error ? err.message : String(err); @@ -1421,7 +1422,7 @@ export default function ConnectionPanel({ }); } }, - [bleDevices, isLinux, onConnect, protocol, t], + [bleDevices, isLinux, onConnect, protocol, t, capabilities.hasNobleBleScanning], ); const handleSelectSerialPort = useCallback((portId: string) => { @@ -1927,7 +1928,7 @@ export default function ConnectionPanel({ async (variant: 'connected' | 'idle' | 'connecting') => { try { if (variant === 'connecting') { - await handleCancelConnection(); + handleCancelConnection(); } else if (isConnected) { await Promise.race([ onDisconnect(), diff --git a/src/renderer/components/RrcPanel.tsx b/src/renderer/components/RrcPanel.tsx index 369e1773b..dacadec86 100644 --- a/src/renderer/components/RrcPanel.tsx +++ b/src/renderer/components/RrcPanel.tsx @@ -11,6 +11,7 @@ import { RrcTopicBar } from '@/renderer/components/rrc/RrcTopicBar'; import { runRrcHubAutoConnectBatch } from '@/renderer/hooks/useRrcStartupAutoConnect'; import { loadMutedViews, saveMutedViews } from '@/renderer/lib/chatPanelProtocolStorage'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { withReticulumIpcSendDeadline } from '@/renderer/lib/reticulum/reticulumIpcDeadline'; import { isReticulumSidecarRunning } from '@/renderer/lib/reticulum/reticulumSidecarReads'; import { isRrcDmRoom, @@ -80,6 +81,14 @@ function persistCollapsed(key: string, next: boolean) { } } +type RrcSendArgs = Parameters[0]; +type RrcSendResult = Awaited>; + +/** Bound RRC send so a stuck proxy cannot hang the composer. */ +function rrcSendBounded(args: RrcSendArgs): Promise { + return withReticulumIpcSendDeadline(window.electronAPI.reticulum.rrc.send(args)); +} + export interface RrcPanelProps { isActive: boolean; /** Keep RRC per-message copy visible (same App Appearance setting as Chat). */ @@ -230,14 +239,20 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: activeRoom && !activeRoom.startsWith('[') && !isRrcDmRoom(activeRoom) ? activeRoom : undefined; - await window.electronAPI.reticulum.rrc.send({ - hub_dest_hash: hubDestHash, - room: hubRoom, - body, - type: 'msg', - }); + try { + await rrcSendBounded({ + hub_dest_hash: hubDestHash, + room: hubRoom, + body, + type: 'msg', + }); + } catch (e: unknown) { + const msg = errLikeToLogString(e); + console.debug('[RrcPanel] sendHubCommand failed ' + msg); + setError(formatRrcErrorMessage(msg, t), hubDestHash); + } }, - [activeRoom, hubDestHash, status], + [activeRoom, hubDestHash, setError, status, t], ); useEffect(() => { @@ -710,7 +725,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: useRrcSessionStore.getState().setError(t('rrc.joinRoomPrompt')); return; } - const res = await window.electronAPI.reticulum.rrc.send({ + const res = await rrcSendBounded({ hub_dest_hash: hubDestHash, room: activeRoom, body: parsed.action, @@ -739,7 +754,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: useRrcSessionStore.getState().setError(t('rrc.slash.msgTargetNotFound')); return; } - const res = await window.electronAPI.reticulum.rrc.send({ + const res = await rrcSendBounded({ hub_dest_hash: hubDestHash, body: parsed.text, type: 'notice', @@ -788,7 +803,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: useRrcSessionStore.getState().setError(t('rrc.sendFailed')); return; } - const res = await window.electronAPI.reticulum.rrc.send({ + const res = await rrcSendBounded({ hub_dest_hash: hubDestHash, room: activeRoom && !activeRoom.startsWith('[') && !isRrcDmRoom(activeRoom) @@ -816,7 +831,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: useRrcSessionStore.getState().setError(t('rrc.directNoticeUnsupported')); return; } - const res = await window.electronAPI.reticulum.rrc.send({ + const res = await rrcSendBounded({ hub_dest_hash: hubDestHash, body: parsed.body, type: 'notice', @@ -847,7 +862,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: useRrcSessionStore.getState().setError(t('rrc.sendFailed')); return; } - const res = await window.electronAPI.reticulum.rrc.send({ + const res = await rrcSendBounded({ hub_dest_hash: hubDestHash, room: activeRoom, body: parsed.body, diff --git a/src/renderer/hooks/useRrcStartupAutoConnect.test.ts b/src/renderer/hooks/useRrcStartupAutoConnect.test.ts index c640c6b46..e849ee41e 100644 --- a/src/renderer/hooks/useRrcStartupAutoConnect.test.ts +++ b/src/renderer/hooks/useRrcStartupAutoConnect.test.ts @@ -1,5 +1,8 @@ +import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RETICULUM_CONFIGURED_EVENT } from '@/renderer/lib/reticulum/reticulumConfiguredEvent'; +import * as sidecarReads from '@/renderer/lib/reticulum/reticulumSidecarReads'; import { resetRrcHubDisconnectSuppressForTests, setRrcHubDisconnectSuppressed, @@ -7,7 +10,19 @@ import { import { saveRrcHubAutoJoin } from '@/renderer/lib/rrcHubPrefs'; import { useRrcSessionStore } from '@/renderer/stores/rrcSessionStore'; -import { runRrcHubAutoConnectBatch } from './useRrcStartupAutoConnect'; +import { + RRC_AUTO_CONNECT_FAST_MS, + RRC_AUTO_CONNECT_STEADY_MS, + runRrcHubAutoConnectBatch, + useRrcStartupAutoConnect, +} from './useRrcStartupAutoConnect'; + +async function flushMicrotasks(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} describe('runRrcHubAutoConnectBatch', () => { beforeEach(() => { @@ -45,4 +60,109 @@ describe('runRrcHubAutoConnectBatch', () => { await runRrcHubAutoConnectBatch('tester'); expect(window.electronAPI.reticulum.rrc.connect).not.toHaveBeenCalled(); }); + + it('skips hubs that are already linked', async () => { + const linked = '11112222333344445555666677778888'; + const pending = 'aabbccddeeff00112233445566778899'; + saveRrcHubAutoJoin([linked, pending]); + useRrcSessionStore.getState().applyStatus('active', linked, null); + await runRrcHubAutoConnectBatch('tester'); + + expect(window.electronAPI.reticulum.rrc.connect).toHaveBeenCalledTimes(1); + expect(window.electronAPI.reticulum.rrc.connect).toHaveBeenCalledWith({ + dest_hash: pending, + nickname: 'tester', + }); + }); +}); + +describe('useRrcStartupAutoConnect poll timing', () => { + beforeEach(() => { + localStorage.clear(); + resetRrcHubDisconnectSuppressForTests(); + useRrcSessionStore.setState({ + sessionsByHub: new Map(), + focusedHubHash: null, + }); + vi.useFakeTimers(); + vi.spyOn(sidecarReads, 'isReticulumSidecarRunning').mockResolvedValue(true); + vi.mocked(window.electronAPI.reticulum.rrc.connect).mockResolvedValue({ ok: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.mocked(window.electronAPI.reticulum.rrc.connect).mockClear(); + }); + + it('derives fast/steady intervals from MS_PER_SECOND', () => { + expect(RRC_AUTO_CONNECT_FAST_MS).toBe(500); + expect(RRC_AUTO_CONNECT_STEADY_MS).toBe(4000); + }); + + it('polls at the fast interval while hubs are pending', async () => { + // Keep hubs pending: failed connect rolls status back so linked-check stays false. + vi.mocked(window.electronAPI.reticulum.rrc.connect).mockResolvedValue({ + ok: false, + error: 'rrc connect requires live rns-stack sidecar', + }); + saveRrcHubAutoJoin(['aabbccddeeff00112233445566778899']); + renderHook(() => { + useRrcStartupAutoConnect(); + }); + await flushMicrotasks(); + expect(window.electronAPI.reticulum.rrc.connect).toHaveBeenCalledTimes(1); + vi.mocked(window.electronAPI.reticulum.rrc.connect).mockClear(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(RRC_AUTO_CONNECT_FAST_MS); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(window.electronAPI.reticulum.rrc.connect).toHaveBeenCalled(); + }); + + it('wakes immediately on RETICULUM_CONFIGURED_EVENT', async () => { + vi.mocked(window.electronAPI.reticulum.rrc.connect).mockResolvedValue({ + ok: false, + error: 'rrc connect requires live rns-stack sidecar', + }); + saveRrcHubAutoJoin(['aabbccddeeff00112233445566778899']); + renderHook(() => { + useRrcStartupAutoConnect(); + }); + await flushMicrotasks(); + expect(window.electronAPI.reticulum.rrc.connect).toHaveBeenCalledTimes(1); + vi.mocked(window.electronAPI.reticulum.rrc.connect).mockClear(); + + await act(async () => { + window.dispatchEvent(new CustomEvent(RETICULUM_CONFIGURED_EVENT)); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(window.electronAPI.reticulum.rrc.connect).toHaveBeenCalled(); + }); + + it('does not start a batch when unmounted while status await is pending', async () => { + let resolveStatus!: (v: boolean) => void; + vi.spyOn(sidecarReads, 'isReticulumSidecarRunning').mockImplementation( + () => + new Promise((resolve) => { + resolveStatus = resolve; + }), + ); + saveRrcHubAutoJoin(['aabbccddeeff00112233445566778899']); + const { unmount } = renderHook(() => { + useRrcStartupAutoConnect(); + }); + await flushMicrotasks(); + expect(sidecarReads.isReticulumSidecarRunning).toHaveBeenCalled(); + unmount(); + await act(async () => { + resolveStatus(true); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(window.electronAPI.reticulum.rrc.connect).not.toHaveBeenCalled(); + }); }); diff --git a/src/renderer/hooks/useRrcStartupAutoConnect.ts b/src/renderer/hooks/useRrcStartupAutoConnect.ts index 0f24a6eeb..f37f844f9 100644 --- a/src/renderer/hooks/useRrcStartupAutoConnect.ts +++ b/src/renderer/hooks/useRrcStartupAutoConnect.ts @@ -5,6 +5,7 @@ import { useEffect, useRef } from 'react'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { RETICULUM_CONFIGURED_EVENT } from '@/renderer/lib/reticulum/reticulumConfiguredEvent'; import { isReticulumSidecarRunning } from '@/renderer/lib/reticulum/reticulumSidecarReads'; import { isRrcHubDisconnectSuppressed } from '@/renderer/lib/rrcHubDisconnectSuppress'; import { loadRrcHubAutoJoin } from '@/renderer/lib/rrcHubPrefs'; @@ -16,7 +17,14 @@ import { RRC_NICKNAME_STORAGE_KEY, useRrcSessionStore, } from '@/renderer/stores/rrcSessionStore'; +import { MS_PER_SECOND } from '@/shared/timeConstants'; +/** While hubs still need linking, poll quickly so we do not miss the HTTP-ready window by 4s. */ +export const RRC_AUTO_CONNECT_FAST_MS = MS_PER_SECOND / 2; +/** Steady poll once hubs are linked (or none configured). */ +export const RRC_AUTO_CONNECT_STEADY_MS = 4 * MS_PER_SECOND; + +export { RETICULUM_CONFIGURED_EVENT }; let hubAutoConnectBusy = false; function isRrcHubLinkedNow(hub: string): boolean { @@ -24,6 +32,12 @@ function isRrcHubLinkedNow(hub: string): boolean { return !!s && isRrcHubLinked(s.status); } +function pendingRrcAutoJoinHubs(): string[] { + return loadRrcHubAutoJoin().filter( + (hub) => !isRrcHubLinkedNow(hub) && !isRrcHubDisconnectSuppressed(hub), + ); +} + /** Roll a hub session back to idle when a connect attempt fails mid-handshake. */ function clearRrcHubIfStillConnecting(hub: string, err: string): void { useRrcSessionStore.getState().setError(err, hub); @@ -68,12 +82,7 @@ async function connectRrcHubForAutoJoin(hub: string, nickname: string): Promise< /** Connect hubs marked for auto-join (no focus steal). Safe to call from panel + App. */ export async function runRrcHubAutoConnectBatch(nickname: string): Promise { if (hubAutoConnectBusy) return; - const wanted = loadRrcHubAutoJoin(); - if (wanted.length === 0) return; - - const pending = wanted.filter( - (hub) => !isRrcHubLinkedNow(hub) && !isRrcHubDisconnectSuppressed(hub), - ); + const pending = pendingRrcAutoJoinHubs(); if (pending.length === 0) return; hubAutoConnectBusy = true; @@ -107,21 +116,50 @@ export function useRrcStartupAutoConnect(): void { useEffect(() => { let cancelled = false; - const tick = (): void => { - void isReticulumSidecarRunning() - .then((running) => { - if (cancelled || !running) return; - return runRrcHubAutoConnectBatch(readRrcNickname()); - }) - .catch((e: unknown) => { - console.debug('[useRrcStartupAutoConnect] ' + errLikeToLogString(e)); - }); + let timer: ReturnType | null = null; + + const clearTimer = (): void => { + if (timer != null) { + clearTimeout(timer); + timer = null; + } + }; + + const schedule = (ms: number): void => { + clearTimer(); + timer = setTimeout(() => { + void tick(); + }, ms); }; - tick(); - const id = setInterval(tick, 4000); + + const tick = async (): Promise => { + if (cancelled) return; + try { + const running = await isReticulumSidecarRunning(); + if (cancelled) return; + if (running) { + await runRrcHubAutoConnectBatch(readRrcNickname()); + } + } catch (e: unknown) { + console.debug('[useRrcStartupAutoConnect] ' + errLikeToLogString(e)); + } + if (cancelled) return; + const pending = pendingRrcAutoJoinHubs(); + // Fast retry while waiting for live attach / first successful hub link. + schedule(pending.length > 0 ? RRC_AUTO_CONNECT_FAST_MS : RRC_AUTO_CONNECT_STEADY_MS); + }; + + const onConfigured = (): void => { + // Stack just became usable — do not wait for the next poll slot. + void tick(); + }; + + void tick(); + window.addEventListener(RETICULUM_CONFIGURED_EVENT, onConfigured); return () => { cancelled = true; - clearInterval(id); + clearTimer(); + window.removeEventListener(RETICULUM_CONFIGURED_EVENT, onConfigured); }; }, []); diff --git a/src/renderer/lib/radio/BaseRadioProvider.ts b/src/renderer/lib/radio/BaseRadioProvider.ts index 8e80ae093..7e76b2e54 100644 --- a/src/renderer/lib/radio/BaseRadioProvider.ts +++ b/src/renderer/lib/radio/BaseRadioProvider.ts @@ -157,6 +157,8 @@ export interface ProtocolCapabilities { hasLxstVoice: boolean; /** Reticulum: LRGP games (Games tab, Peers / Chat DM Challenge) */ hasLrgpGames: boolean; + /** Whether Cancel/disconnect should stop Noble BLE scanning (Meshtastic/MeshCore on macOS/Windows). */ + hasNobleBleScanning: boolean; /** Reticulum: LXMF encrypted paper message share/scan (Chat DM) */ hasLxmfPaper: boolean; /** DM composer payload limit (Reticulum LXMF only) */ @@ -237,6 +239,7 @@ export const MESHTASTIC_CAPABILITIES: ProtocolCapabilities = { hasRncpTransfer: false, hasLxstVoice: false, hasLrgpGames: false, + hasNobleBleScanning: true, hasLxmfPaper: false, }; @@ -315,6 +318,7 @@ export const MESHCORE_CAPABILITIES: ProtocolCapabilities = { hasRncpTransfer: false, hasLxstVoice: false, hasLrgpGames: false, + hasNobleBleScanning: true, hasLxmfPaper: false, }; @@ -392,6 +396,7 @@ export const RETICULUM_CAPABILITIES: ProtocolCapabilities = { hasRncpTransfer: true, hasLxstVoice: true, hasLrgpGames: true, + hasNobleBleScanning: false, hasLxmfPaper: true, lxmfPayloadLimit: RETICULUM_LXMF_PAYLOAD_LIMIT, }; diff --git a/src/renderer/lib/radio/protocol-capabilities.test.ts b/src/renderer/lib/radio/protocol-capabilities.test.ts index 3b3370176..abeee785d 100644 --- a/src/renderer/lib/radio/protocol-capabilities.test.ts +++ b/src/renderer/lib/radio/protocol-capabilities.test.ts @@ -89,6 +89,7 @@ const REQUIRED_CAPABILITY_KEYS: (keyof ProtocolCapabilities)[] = [ 'hasRncpTransfer', 'hasLxstVoice', 'hasLrgpGames', + 'hasNobleBleScanning', 'hasLxmfPaper', 'hasDiagnosticsPanel', 'nodeStaleThresholdMs', @@ -151,6 +152,7 @@ describe('ProtocolCapabilities contract', () => { "hasMqttConnectionPanel": true, "hasMqttHybrid": true, "hasNeighborInfo": true, + "hasNobleBleScanning": true, "hasNodeDbReset": true, "hasNomadNetworkPanel": false, "hasOnDemandNodeStatus": false, @@ -235,6 +237,7 @@ describe('ProtocolCapabilities contract', () => { "hasMqttConnectionPanel": true, "hasMqttHybrid": false, "hasNeighborInfo": false, + "hasNobleBleScanning": true, "hasNodeDbReset": false, "hasNomadNetworkPanel": false, "hasOnDemandNodeStatus": true, @@ -325,6 +328,7 @@ describe('ProtocolCapabilities contract', () => { "hasMqttConnectionPanel": false, "hasMqttHybrid": false, "hasNeighborInfo": false, + "hasNobleBleScanning": false, "hasNodeDbReset": false, "hasNomadNetworkPanel": true, "hasOnDemandNodeStatus": false, diff --git a/src/renderer/lib/reticulum/reticulumConfiguredEvent.ts b/src/renderer/lib/reticulum/reticulumConfiguredEvent.ts new file mode 100644 index 000000000..d5e878a05 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumConfiguredEvent.ts @@ -0,0 +1,2 @@ +/** Dispatched from useReticulumRuntime when the Reticulum stack reaches configured. */ +export const RETICULUM_CONFIGURED_EVENT = 'mesh-client:reticulumConfigured'; diff --git a/src/renderer/lib/reticulum/reticulumIpcDeadline.test.ts b/src/renderer/lib/reticulum/reticulumIpcDeadline.test.ts new file mode 100644 index 000000000..e85c248da --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumIpcDeadline.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { raceWithDeadline } from '@/renderer/lib/bleReconnectHelper'; +import { + isReticulumIpcSendTimeout, + RETICULUM_IPC_SEND_TIMEOUT_TAG, + withReticulumIpcSendDeadline, +} from '@/renderer/lib/reticulum/reticulumIpcDeadline'; +import { RETICULUM_IPC_SEND_TIMEOUT_MS } from '@/renderer/lib/timeConstants'; + +vi.mock('@/renderer/lib/bleReconnectHelper', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + raceWithDeadline: vi.fn( + (actual as { raceWithDeadline: typeof raceWithDeadline }).raceWithDeadline, + ), + }; +}); + +describe('reticulumIpcDeadline', () => { + it('delegates to raceWithDeadline with the shared send budget', async () => { + const work = Promise.resolve('ok'); + await expect(withReticulumIpcSendDeadline(work)).resolves.toBe('ok'); + expect(raceWithDeadline).toHaveBeenCalledWith( + work, + RETICULUM_IPC_SEND_TIMEOUT_MS, + RETICULUM_IPC_SEND_TIMEOUT_TAG, + ); + }); + + it('detects timeout tag errors', () => { + expect(isReticulumIpcSendTimeout(new Error(RETICULUM_IPC_SEND_TIMEOUT_TAG))).toBe(true); + expect(isReticulumIpcSendTimeout(new Error('other'))).toBe(false); + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumIpcDeadline.ts b/src/renderer/lib/reticulum/reticulumIpcDeadline.ts new file mode 100644 index 000000000..f21a5d304 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumIpcDeadline.ts @@ -0,0 +1,17 @@ +import { raceWithDeadline } from '@/renderer/lib/bleReconnectHelper'; +import { RETICULUM_IPC_SEND_TIMEOUT_MS } from '@/renderer/lib/timeConstants'; + +/** Stable tag for timeout errors — UI maps via i18n (RRC / Chat). */ +export const RETICULUM_IPC_SEND_TIMEOUT_TAG = 'RETICULUM_IPC_SEND_TIMEOUT'; + +/** + * Bound a Reticulum IPC send (LXMF / RRC) so a stuck proxy cannot hang the UI forever. + * Does not cancel the underlying request; callers treat timeout as failure. + */ +export function withReticulumIpcSendDeadline(work: Promise): Promise { + return raceWithDeadline(work, RETICULUM_IPC_SEND_TIMEOUT_MS, RETICULUM_IPC_SEND_TIMEOUT_TAG); +} + +export function isReticulumIpcSendTimeout(err: unknown): boolean { + return String(err instanceof Error ? err.message : err).includes(RETICULUM_IPC_SEND_TIMEOUT_TAG); +} diff --git a/src/renderer/lib/reticulum/reticulumProxyErrorHumanize.test.ts b/src/renderer/lib/reticulum/reticulumProxyErrorHumanize.test.ts new file mode 100644 index 000000000..17ac5e820 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumProxyErrorHumanize.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatReticulumProxyErrorMessage, + reticulumProxyErrorToI18nKey, +} from './reticulumProxyErrorHumanize'; + +describe('reticulumProxyErrorHumanize', () => { + it('maps IPC send timeout to shared chat timeout key', () => { + expect(reticulumProxyErrorToI18nKey('RETICULUM_IPC_SEND_TIMEOUT')).toBe( + 'chatPanel.reticulumSendTimeout', + ); + }); + + it('splits sidecar stopped vs still attaching', () => { + expect(reticulumProxyErrorToI18nKey('sidecar_not_running')).toBe('rrc.sidecarNotRunning'); + expect(reticulumProxyErrorToI18nKey('stack_not_ready')).toBe('rrc.stackNotReady'); + expect(reticulumProxyErrorToI18nKey('rrc connect requires live rns-stack sidecar')).toBe( + 'rrc.stackNotReady', + ); + expect(reticulumProxyErrorToI18nKey('lxmf send requires live rns-stack sidecar')).toBe( + 'rrc.stackNotReady', + ); + }); + + it('formats known keys via t()', () => { + expect(formatReticulumProxyErrorMessage('sidecar_not_running', (k) => `T:${k}`)).toBe( + 'T:rrc.sidecarNotRunning', + ); + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumProxyErrorHumanize.ts b/src/renderer/lib/reticulum/reticulumProxyErrorHumanize.ts new file mode 100644 index 000000000..927f31441 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumProxyErrorHumanize.ts @@ -0,0 +1,29 @@ +/** + * Map sidecar / IPC proxy errors shared by Chat LXMF and RRC to i18n keys. + */ + +export function reticulumProxyErrorToI18nKey(message: string): string | null { + const lower = message.toLowerCase(); + if (lower.includes('reticulum_ipc_send_timeout') || lower.includes('rrc_send_timeout')) { + return 'chatPanel.reticulumSendTimeout'; + } + if (lower.includes('sidecar_not_running')) { + return 'rrc.sidecarNotRunning'; + } + if ( + lower.includes('stack_not_ready') || + lower.includes('requires live rns-stack sidecar') || + lower.includes('requires live rns-stack') + ) { + return 'rrc.stackNotReady'; + } + return null; +} + +export function formatReticulumProxyErrorMessage( + message: string, + t: (key: string) => string, +): string { + const key = reticulumProxyErrorToI18nKey(message); + return key ? t(key) : message; +} diff --git a/src/renderer/lib/rrcErrorHumanize.test.ts b/src/renderer/lib/rrcErrorHumanize.test.ts index 2fe8f2bcc..cbd0f2959 100644 --- a/src/renderer/lib/rrcErrorHumanize.test.ts +++ b/src/renderer/lib/rrcErrorHumanize.test.ts @@ -16,4 +16,13 @@ describe('rrcErrorHumanize', () => { expect(rrcErrorToI18nKey('rate limit exceeded')).toBeNull(); expect(formatRrcErrorMessage('rate limit exceeded', (k) => k)).toBe('rate limit exceeded'); }); + + it('maps IPC send timeout and stack readiness tags', () => { + expect(rrcErrorToI18nKey('RETICULUM_IPC_SEND_TIMEOUT')).toBe('chatPanel.reticulumSendTimeout'); + expect(rrcErrorToI18nKey('sidecar_not_running')).toBe('rrc.sidecarNotRunning'); + expect(rrcErrorToI18nKey('stack_not_ready')).toBe('rrc.stackNotReady'); + expect(rrcErrorToI18nKey('rrc connect requires live rns-stack sidecar')).toBe( + 'rrc.stackNotReady', + ); + }); }); diff --git a/src/renderer/lib/rrcErrorHumanize.ts b/src/renderer/lib/rrcErrorHumanize.ts index 1c77af5cf..4334dc1a0 100644 --- a/src/renderer/lib/rrcErrorHumanize.ts +++ b/src/renderer/lib/rrcErrorHumanize.ts @@ -1,5 +1,10 @@ /** Map sidecar/hub RRC errors to i18n keys when we recognize them. */ +import { + formatReticulumProxyErrorMessage, + reticulumProxyErrorToI18nKey, +} from '@/renderer/lib/reticulum/reticulumProxyErrorHumanize'; + export function rrcErrorToI18nKey(message: string): string | null { const lower = message.toLowerCase(); if (lower.includes('link proof') || lower.includes('timed out waiting for link')) { @@ -11,10 +16,13 @@ export function rrcErrorToI18nKey(message: string): string | null { if (lower.includes('timed out waiting for welcome')) { return 'rrc.errors.welcomeTimeout'; } + const proxyKey = reticulumProxyErrorToI18nKey(message); + if (proxyKey) return proxyKey; return null; } export function formatRrcErrorMessage(message: string, t: (key: string) => string): string { const key = rrcErrorToI18nKey(message); - return key ? t(key) : message; + if (key) return t(key); + return formatReticulumProxyErrorMessage(message, t); } diff --git a/src/renderer/lib/timeConstants.ts b/src/renderer/lib/timeConstants.ts index cd317cff8..baa898428 100644 --- a/src/renderer/lib/timeConstants.ts +++ b/src/renderer/lib/timeConstants.ts @@ -308,3 +308,9 @@ export const MESHTASTIC_TEXT_CHUNK_SEND_INTERVAL_MS = 2.5 * MS_PER_SECOND; * Slightly above rsLXST `outgoing_call_timeout` (70s). */ export const RETICULUM_VOICE_OUTGOING_SAFETY_HANGUP_MS = 75 * MS_PER_SECOND; + +/** + * Bound LXMF / RRC proxy sends so a stuck sidecar IPC cannot hang Chat/RRC UI forever. + * Stack TCP features should already be up; this is a safety net during connect races. + */ +export const RETICULUM_IPC_SEND_TIMEOUT_MS = 15 * MS_PER_SECOND; diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 011da0de3..1fa45156e 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -680,7 +680,8 @@ "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í" + "sentViaLocalPropagation": "Místní doručená pošta šíření", + "reticulumSendTimeout": "Odeslání vypršelo. Zásobník Reticulum se možná spouští nebo je zaneprázdněný — zkuste to znovu." }, "chatPayload": { "mention": "Zmínit {{label}}", @@ -4879,7 +4880,9 @@ "clearHistoryConfirm": "Smazat veškerou uloženou historii zpráv pro {{room}}? Toto nelze vrátit zpět.", "clearHistoryConfirmAction": "Smazat historii", "whisperReplyPlaceholder": "Odpověz uživateli {{name}} nebo /msg…", - "jumpToLatest": "Přejít na nejnovější" + "jumpToLatest": "Přejít na nejnovější", + "stackNotReady": "Zásobník Reticulum ještě není připraven. Počkejte, až bude připojen, a zkuste to znovu.", + "sidecarNotRunning": "Reticulum sidecar neběží. Spusťte zásobník na Připojení a zkuste to znovu." }, "signalMeter": { "rssi": "\"RSSI\"", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 500180cbe..baed41b89 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -678,7 +678,8 @@ "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" + "sentViaLocalPropagation": "Lokaler Propagations-Posteingang", + "reticulumSendTimeout": "Senden abgelaufen. Der Reticulum-Stack startet möglicherweise oder ist beschäftigt — bitte erneut versuchen." }, "chatPayload": { "mention": "Erwähne {{label}}", @@ -4877,7 +4878,9 @@ "clearHistoryConfirm": "Gesamten gespeicherten Nachrichtenverlauf für {{room}} löschen? Dies kann nicht rückgängig gemacht werden.", "clearHistoryConfirmAction": "Verlauf löschen", "whisperReplyPlaceholder": "Antworte auf {{name}} oder /msg…", - "jumpToLatest": "Zum neuesten Stand springen" + "jumpToLatest": "Zum neuesten Stand springen", + "stackNotReady": "Der Reticulum-Stack ist noch nicht bereit. Warten Sie, bis die Verbindung hergestellt ist, und versuchen Sie es erneut.", + "sidecarNotRunning": "Reticulum-Sidecar läuft nicht. Starten Sie den Stack unter Verbindung und versuchen Sie es erneut." }, "signalMeter": { "rssi": "RSSI", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index fbd67f7eb..4eeb8f377 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -502,6 +502,7 @@ "reticulumSendPaperTooltip": "Shared as an encrypted LXMF paper message (QR / lxm:// link)", "reticulumSendFailed": "Failed to send", "reticulumNoPropagationNode": "No propagation node configured. Set a preferred propagation node on the Reticulum Network tab.", + "reticulumSendTimeout": "Send timed out. The Reticulum stack may be starting or busy — try again.", "sentViaPropagation": "Propagation node", "sentViaLocalPropagation": "Local propagation inbox", "reticulumImageAttachment": "Image: {{name}}", @@ -3249,6 +3250,8 @@ "joinFailed": "Could not join room.", "partFailed": "Could not leave room.", "sendFailed": "Could not send message.", + "stackNotReady": "Reticulum stack is not ready yet. Wait until connected, then try again.", + "sidecarNotRunning": "Reticulum sidecar is not running. Start the stack on Connection, then try again.", "selectHub": "Select hub {{name}}", "selectHubUnread": "Select hub {{name}}, {{marker}}, {{count}} unread", "selectHubPrompt": "Select an RRC hub to connect.", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index d427d629a..d7286c9ca 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -678,7 +678,8 @@ "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" + "sentViaLocalPropagation": "Bandeja de entrada de propagación local", + "reticulumSendTimeout": "El envío ha caducado. La pila Reticulum puede estar iniciándose o ocupada; inténtelo de nuevo." }, "chatPayload": { "mention": "Mencionar {{label}}", @@ -4877,7 +4878,9 @@ "clearHistoryConfirm": "¿Eliminar todo el historial de mensajes guardados para {{room}}? Esto no se puede deshacer.", "clearHistoryConfirmAction": "Eliminar historial", "whisperReplyPlaceholder": "Responder a {{name}} o /msg...", - "jumpToLatest": "Saltar a lo último" + "jumpToLatest": "Saltar a lo último", + "stackNotReady": "La pila Reticulum aún no está lista. Espere a que esté conectada e inténtelo de nuevo.", + "sidecarNotRunning": "El sidecar de Reticulum no está en ejecución. Inicie la pila en Conexión e inténtelo de nuevo." }, "signalMeter": { "rssi": "RSSI", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 09a19ca3c..d5a25d114 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -678,7 +678,8 @@ "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" + "sentViaLocalPropagation": "Boîte de réception de propagation locale", + "reticulumSendTimeout": "Envoi expiré. La pile Reticulum est peut-être en cours de démarrage ou occupée — réessayez." }, "chatPayload": { "mention": "Mention {{label}}", @@ -4877,7 +4878,9 @@ "clearHistoryConfirm": "Supprimer tout l'historique des messages enregistrés pour {{room}} ? Cela ne peut pas être annulé.", "clearHistoryConfirmAction": "Supprimer l'historique", "whisperReplyPlaceholder": "Répondre à {{name}} ou /msg…", - "jumpToLatest": "Aller au dernier" + "jumpToLatest": "Aller au dernier", + "stackNotReady": "La pile Reticulum n'est pas encore prête. Attendez d'être connecté, puis réessayez.", + "sidecarNotRunning": "Le sidecar Reticulum n'est pas en cours d'exécution. Démarrez la pile sur Connexion, puis réessayez." }, "signalMeter": { "rssi": "RSSI", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 8a796585c..25c9cc224 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -678,7 +678,8 @@ "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" + "sentViaLocalPropagation": "Kotak masuk propagasi lokal", + "reticulumSendTimeout": "Waktu pengiriman habis. Stack Reticulum mungkin sedang mulai atau sibuk — coba lagi." }, "chatPayload": { "mention": "Sebutkan {{label}}", @@ -4877,7 +4878,9 @@ "clearHistoryConfirm": "Hapus semua riwayat pesan tersimpan untuk {{room}}? Hal ini tidak dapat dibatalkan.", "clearHistoryConfirmAction": "Hapus riwayat", "whisperReplyPlaceholder": "Balas {{name}} atau /msg…", - "jumpToLatest": "Langsung ke Terbaru" + "jumpToLatest": "Langsung ke Terbaru", + "stackNotReady": "Stack Reticulum belum siap. Tunggu hingga terhubung, lalu coba lagi.", + "sidecarNotRunning": "Sidecar Reticulum tidak berjalan. Mulai stack di Connection, lalu coba lagi." }, "signalMeter": { "rssi": "RSSI", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index cdbf69172..a0fd332d4 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -678,7 +678,8 @@ "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" + "sentViaLocalPropagation": "Posta in arrivo propagazione locale", + "reticulumSendTimeout": "Timeout dell'invio. Lo stack Reticulum potrebbe essere in avvio o occupato. Riprova." }, "chatPayload": { "mention": "Menziona {{label}}", @@ -4877,7 +4878,9 @@ "clearHistoryConfirm": "Eliminare tutta la cronologia dei messaggi salvati per {{room}}? Questa operazione non può essere annullata.", "clearHistoryConfirmAction": "Elimina la cronologia", "whisperReplyPlaceholder": "Rispondi a {{name}} o /msg...", - "jumpToLatest": "Passa all'ultimo" + "jumpToLatest": "Passa all'ultimo", + "stackNotReady": "Lo stack Reticulum non è ancora pronto. Attendere fino alla connessione, quindi riprovare.", + "sidecarNotRunning": "Il sidecar Reticulum non è in esecuzione. Avviare lo stack su Connessione, quindi riprovare." }, "signalMeter": { "rssi": "RSSI", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index f7b0a0803..11fac451f 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -678,7 +678,8 @@ "waitingMessagesSilentFetched": "ラジオから{{processed}}を取得しました…", "reticulumSendStoringLocally": "ローカルの伝播受信トレイに保存しています…", "reticulumSendStoredLocally": "ローカルの伝播受信トレイに保存されています(ピアには配信されません)", - "sentViaLocalPropagation": "ローカル伝播受信トレイ" + "sentViaLocalPropagation": "ローカル伝播受信トレイ", + "reticulumSendTimeout": "送信がタイムアウトしました。Reticulum スタックが起動中またはビジーの可能性があります — 再試行してください。" }, "chatPayload": { "mention": "{{label}} について言及してください", @@ -4877,7 +4878,9 @@ "clearHistoryConfirm": "{{room}} の保存されているメッセージ履歴をすべて削除しますか?これを元に戻すことはできません。", "clearHistoryConfirmAction": "履歴の削除", "whisperReplyPlaceholder": "{{name}}または/msgに返信…", - "jumpToLatest": "最新へジャンプ" + "jumpToLatest": "最新へジャンプ", + "stackNotReady": "Reticulum スタックはまだ準備ができていません。接続されるまで待ってから再試行してください。", + "sidecarNotRunning": "Reticulum サイドカーが実行されていません。接続でスタックを開始してから、もう一度お試しください。" }, "signalMeter": { "rssi": "RSSI", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 20ebbab96..a4fa7e575 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -678,7 +678,8 @@ "waitingMessagesSilentFetched": "라디오에서 {{processed}} 을 (를) 가져왔습니다...", "reticulumSendStoringLocally": "로컬 전파 받은 편지함에 저장 중...", "reticulumSendStoredLocally": "로컬 전파 받은 편지함에 보관됨 (피어에게 전달되지 않음)", - "sentViaLocalPropagation": "로컬 전파 메시지함" + "sentViaLocalPropagation": "로컬 전파 메시지함", + "reticulumSendTimeout": "전송 시간이 초과되었습니다. Reticulum 스택이 시작 중이거나 사용 중일 수 있습니다 — 다시 시도하세요." }, "chatPayload": { "mention": "{{label}}을(를) 언급하세요", @@ -4877,7 +4878,9 @@ "clearHistoryConfirm": "{{room}}에 대해 저장된 메시지 기록을 모두 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", "clearHistoryConfirmAction": "기록 삭제", "whisperReplyPlaceholder": "{{name}} 또는/msg에 회신…", - "jumpToLatest": "최신으로 이동" + "jumpToLatest": "최신으로 이동", + "stackNotReady": "Reticulum 스택이 아직 준비되지 않았습니다. 연결될 때까지 기다린 다음 다시 시도하세요.", + "sidecarNotRunning": "Reticulum 사이드카가 실행되고 있지 않습니다. Connection에서 스택을 시작한 다음 다시 시도하십시오." }, "signalMeter": { "rssi": "RSSI", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index ddc9e4e66..ee874a413 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -678,7 +678,8 @@ "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" + "sentViaLocalPropagation": "Lokale propagatie-inbox", + "reticulumSendTimeout": "Time-out voor verzenden. De Reticulum-stack is mogelijk aan het starten of bezet — probeer het opnieuw." }, "chatPayload": { "mention": "Vermeld {{label}}", @@ -4877,7 +4878,9 @@ "clearHistoryConfirm": "Alle opgeslagen berichtgeschiedenis voor {{room}} verwijderen? Dit kan niet ongedaan worden gemaakt.", "clearHistoryConfirmAction": "Geschiedenis verwijderen", "whisperReplyPlaceholder": "Reageer op {{name}} of /msg...", - "jumpToLatest": "Spring naar de laatste" + "jumpToLatest": "Spring naar de laatste", + "stackNotReady": "De Reticulum-stack is nog niet klaar. Wacht tot deze verbonden is en probeer het opnieuw.", + "sidecarNotRunning": "Reticulum-sidecar draait niet. Start de stack op Verbinding en probeer het opnieuw." }, "signalMeter": { "rssi": "RSSI", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 31c3eec3d..5e632ec79 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -682,7 +682,8 @@ "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" + "sentViaLocalPropagation": "Skrzynka odbiorcza propagacji lokalnej", + "reticulumSendTimeout": "Przekroczono limit czasu wysyłania. Stos Reticulum może się uruchamiać lub być zajęty — spróbuj ponownie." }, "chatPayload": { "mention": "Wspomnij o {{label}}", @@ -4881,7 +4882,9 @@ "clearHistoryConfirm": "Usunąć całą zapisaną historię wiadomości dla {{room}}? Tego nie można cofnąć.", "clearHistoryConfirmAction": "Usuń historię", "whisperReplyPlaceholder": "Odpowiedz na {{name}} lub /msg…", - "jumpToLatest": "Przejdź do najnowszych" + "jumpToLatest": "Przejdź do najnowszych", + "stackNotReady": "Stos Reticulum nie jest jeszcze gotowy. Poczekaj, aż połączenie zostanie nawiązane, a następnie spróbuj ponownie.", + "sidecarNotRunning": "Sidecar Reticulum nie działa. Uruchom stos w Połączeniu, a następnie spróbuj ponownie." }, "signalMeter": { "rssi": "RSSI", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index c4423fb46..516a9bc65 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -678,7 +678,8 @@ "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" + "sentViaLocalPropagation": "Caixa de entrada de propagação local", + "reticulumSendTimeout": "O envio expirou. A pilha Reticulum pode estar iniciando ou ocupada — tente novamente." }, "chatPayload": { "mention": "Mencionar {{label}}", @@ -4877,7 +4878,9 @@ "clearHistoryConfirm": "Excluir todo o histórico de mensagens salvas para {{room}}? Isto não pode ser desfeito.", "clearHistoryConfirmAction": "Excluir histórico", "whisperReplyPlaceholder": "Responder a {{name}} ou /msg…", - "jumpToLatest": "Ir para as últimas" + "jumpToLatest": "Ir para as últimas", + "stackNotReady": "A pilha Reticulum ainda não está pronta. Aguarde até conectar e tente novamente.", + "sidecarNotRunning": "O sidecar Reticulum não está em execução. Inicie a pilha em Conexão e tente novamente." }, "signalMeter": { "rssi": "RSSI", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index fd635079f..9337948d2 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -680,7 +680,8 @@ "waitingMessagesSilentFetched": "Получено {{processed}} из радио…", "reticulumSendStoringLocally": "Сохранение в локальный почтовый ящик распространения...", "reticulumSendStoredLocally": "Хранится в локальном почтовом ящике распространения (не доставляется одноранговому узлу)", - "sentViaLocalPropagation": "Локальный почтовый ящик распространения" + "sentViaLocalPropagation": "Локальный почтовый ящик распространения", + "reticulumSendTimeout": "Время ожидания отправки истекло. Стек Reticulum, возможно, запускается или занят — повторите попытку." }, "chatPayload": { "mention": "Упоминание {{label}}", @@ -4879,7 +4880,9 @@ "clearHistoryConfirm": "Удалить всю сохраненную историю сообщений для {{room}}? Это невозможно отменить.", "clearHistoryConfirmAction": "Удалить историю", "whisperReplyPlaceholder": "Ответить на {{name}} или /msg…", - "jumpToLatest": "Перейти к последнему" + "jumpToLatest": "Перейти к последнему", + "stackNotReady": "Стек Reticulum ещё не готов. Дождитесь, пока будет установлено подключение, и повторите попытку.", + "sidecarNotRunning": "Sidecar Reticulum не запущен. Запустите стек в разделе Connection и повторите попытку." }, "signalMeter": { "rssi": "РССИ", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index af9c15e28..4f12faea0 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -678,7 +678,8 @@ "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" + "sentViaLocalPropagation": "Yerel yayılım gelen kutusu", + "reticulumSendTimeout": "Gönderim zaman aşımına uğradı. Reticulum yığını başlıyor veya meşgul olabilir — tekrar deneyin." }, "chatPayload": { "mention": "{{label}}'dan bahsedin", @@ -4877,7 +4878,9 @@ "clearHistoryConfirm": "{{room}} için kayıtlı tüm mesaj geçmişi silinsin mi? Bu geri alınamaz.", "clearHistoryConfirmAction": "Geçmişi sil", "whisperReplyPlaceholder": "{{name}} veya /msg'ye yanıt verin…", - "jumpToLatest": "En Yeniye Atla" + "jumpToLatest": "En Yeniye Atla", + "stackNotReady": "Reticulum yığını henüz hazır değil. Bağlanana kadar bekleyin, ardından tekrar deneyin.", + "sidecarNotRunning": "Reticulum sidecar çalışmıyor. Bağlantı bölümünde yığını başlatın, sonra tekrar deneyin." }, "signalMeter": { "rssi": "RSSI", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 0d1fbd9ed..dcd8d68b2 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -680,7 +680,8 @@ "waitingMessagesSilentFetched": "Отримано {{processed}} з радіо…", "reticulumSendStoringLocally": "Збереження до локальної скриньки поширення…", "reticulumSendStoredLocally": "Зберігається у локальній скриньці поширення (не доставлено одноранговому вузлу)", - "sentViaLocalPropagation": "Локальна скринька поширення" + "sentViaLocalPropagation": "Локальна скринька поширення", + "reticulumSendTimeout": "Тайм-аут надсилання. Стек Reticulum може запускатися або бути зайнятий — спробуйте ще раз." }, "chatPayload": { "mention": "Згадайте {{label}}", @@ -4879,7 +4880,9 @@ "clearHistoryConfirm": "Видалити всю збережену історію повідомлень для {{room}}? Це неможливо скасувати.", "clearHistoryConfirmAction": "Видалити історію", "whisperReplyPlaceholder": "Відповісти на {{name}} або /msg…", - "jumpToLatest": "Перейти до останнього" + "jumpToLatest": "Перейти до останнього", + "stackNotReady": "Стек Reticulum ще не готовий. Зачекайте, доки буде підключено, а потім повторіть спробу.", + "sidecarNotRunning": "Sidecar Reticulum не працює. Запустіть стек на Підключенні, а потім спробуйте ще раз." }, "signalMeter": { "rssi": "RSSI (індикатор потужності прийнятого сигналу)", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index d3310b8c2..701073a13 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -678,7 +678,8 @@ "waitingMessagesSilentFetched": "已从收音机获取{{processed}} …", "reticulumSendStoringLocally": "正在保存到本地传播收件箱…", "reticulumSendStoredLocally": "保存在您的本地传播收件箱中(未送达对端)", - "sentViaLocalPropagation": "本地传播收件箱" + "sentViaLocalPropagation": "本地传播收件箱", + "reticulumSendTimeout": "发送超时。Reticulum 堆栈可能正在启动或忙碌—请重试。" }, "chatPayload": { "mention": "提及{{label}}", @@ -4877,7 +4878,9 @@ "clearHistoryConfirm": "删除 {{room}} 的所有已保存消息历史记录?此操作无法撤消。", "clearHistoryConfirmAction": "删除历史记录", "whisperReplyPlaceholder": "回复{{name}}或/msg…", - "jumpToLatest": "跳到最新" + "jumpToLatest": "跳到最新", + "stackNotReady": "Reticulum 堆栈尚未就绪。请等到已连接后再重试。", + "sidecarNotRunning": "Reticulum sidecar 未运行。请在 Connection 启动协议栈,然后重试。" }, "signalMeter": { "rssi": "RSSI", diff --git a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts index 7200d3127..56c262ff3 100644 --- a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts @@ -263,6 +263,23 @@ describe('useReticulumRuntime contact → nodeStore label preservation', () => { }); }); +describe('useReticulumRuntime chat LXMF send timeout wiring', () => { + it('maps IPC send timeout and proxy readiness errors via shared humanize', () => { + expect(SOURCE).toContain('withReticulumIpcSendDeadline'); + expect(SOURCE).toContain('isReticulumIpcSendTimeout'); + expect(SOURCE).toContain('reticulumProxyErrorToI18nKey'); + expect(SOURCE).toContain("i18n.t('chatPanel.reticulumSendTimeout')"); + expect(SOURCE).toMatch(/withReticulumIpcSendDeadline\(\s*[\s\S]*?proxyPost\(/); + }); + + it('bounds LXMF reaction sends with the same IPC deadline', () => { + expect(SOURCE).toMatch( + /withReticulumIpcSendDeadline\(\s*[\s\S]*?proxyPost\('\/api\/v1\/lxmf\/reaction'/, + ); + expect(SOURCE).toMatch(/res\?\.ok === false/); + }); +}); + describe('useReticulumRuntime outbound delivery persistence', () => { it('persists Completes/Fails via applyReticulumOutboundDeliveryStatus', () => { expect(SOURCE).toMatch( diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index e4a205848..d2399bfec 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -54,6 +54,7 @@ import { isReticulumBleRnodeInterfaceRow } from '@/renderer/lib/reticulum/reticu import { releaseReticulumBleRnodeConnect } from '@/renderer/lib/reticulum/reticulumBleAdapterLease'; import { setReticulumBleBondDesyncActive } from '@/renderer/lib/reticulum/reticulumBleBondDesync'; import { fetchReticulumConfigAudit } from '@/renderer/lib/reticulum/reticulumConfigAudit'; +import { RETICULUM_CONFIGURED_EVENT } from '@/renderer/lib/reticulum/reticulumConfiguredEvent'; import { maybeNotifyInboundGamesChallenge } from '@/renderer/lib/reticulum/reticulumGamesNotifications'; import { refreshGamesSessions } from '@/renderer/lib/reticulum/reticulumGamesSession'; import { @@ -62,6 +63,10 @@ import { noteReticulumEventsLagged, noteReticulumInboundCatchUp, } from '@/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics'; +import { + isReticulumIpcSendTimeout, + withReticulumIpcSendDeadline, +} from '@/renderer/lib/reticulum/reticulumIpcDeadline'; import { logReticulumInterfaceStateEvent, logReticulumLocalInterfaceHealthChanges, @@ -85,6 +90,7 @@ import { normalizePropagationSyncProgress, RETICULUM_PROPAGATION_SYNC_STALL_MS, } from '@/renderer/lib/reticulum/reticulumPropagationSync'; +import { reticulumProxyErrorToI18nKey } from '@/renderer/lib/reticulum/reticulumProxyErrorHumanize'; import { reticulumWireRowToEntry } from '@/renderer/lib/reticulum/reticulumRawPacketLog'; import { resolveReticulumSelfFullLabel, @@ -1673,22 +1679,10 @@ export function useReticulumRuntime(): ProtocolRuntime { if (connectedNodeId > 0) { syncConnectionStore({ myNodeNum: connectedNodeId }); } - await refreshContactsFromSidecar(); - await refreshLocalInterfacesFromSidecar(); - await syncDiagnosticsFromSidecar(); - await hydrateRawPackets(); - if (identityId) { - await markStaleReticulumOutboundMessages(identityId, RETICULUM_STALE_OUTBOUND_MS); - markStaleReticulumOutboundInStore(identityId, RETICULUM_STALE_OUTBOUND_MS); - // Merge — do not wipe live WS ingest whose fire-and-forget DB persist is still in flight. - await loadMessagesFromDb('merge'); - } - // Catch up any inbound LXMF that arrived while WS was lagging or before subscribe. - await catchUpRecentInboundLxmf({ reason: 'connect' }); + // Mark usable as soon as the sidecar HTTP API is up + identity is known. + // Peer/DB hydration and live RNS/BLE attach continue in the background so + // Chat/RRC/Nomad are not gated on a large path table or BLE RNode. if (resumeGenerationRef.current !== generation) { - // A later power-suspend fired while this connect attempt was still in flight — the - // sidecar keeps running (no RF link to go stale), but a fresher resume/suspend cycle - // now owns the UI state; applying this stale "configured" result could clobber it. console.debug( '[useReticulumRuntime] connect superseded by newer power-suspend generation — skip applying stale configured state', ); @@ -1704,6 +1698,31 @@ export function useReticulumRuntime(): ProtocolRuntime { void reconcileRncpListenerFromSidecar().catch((e: unknown) => { console.debug('[useReticulumRuntime] rncp reconcile ' + errLikeToLogString(e)); }); + window.dispatchEvent(new CustomEvent(RETICULUM_CONFIGURED_EVENT)); + void (async () => { + try { + await refreshContactsFromSidecar(); + if (resumeGenerationRef.current !== generation) return; + await refreshLocalInterfacesFromSidecar(); + if (resumeGenerationRef.current !== generation) return; + await syncDiagnosticsFromSidecar(); + if (resumeGenerationRef.current !== generation) return; + await hydrateRawPackets(); + if (resumeGenerationRef.current !== generation) return; + if (identityId) { + await markStaleReticulumOutboundMessages(identityId, RETICULUM_STALE_OUTBOUND_MS); + if (resumeGenerationRef.current !== generation) return; + markStaleReticulumOutboundInStore(identityId, RETICULUM_STALE_OUTBOUND_MS); + await loadMessagesFromDb('merge'); + if (resumeGenerationRef.current !== generation) return; + } + await catchUpRecentInboundLxmf({ reason: 'connect' }); + } catch (e: unknown) { + console.debug( + '[useReticulumRuntime] background connect hydrate ' + errLikeToLogString(e), + ); + } + })(); })(); connectInFlightDoneRef.current = flight; try { @@ -1735,6 +1754,8 @@ export function useReticulumRuntime(): ProtocolRuntime { const disconnect = useCallback(async () => { suppressReconnectRef.current = true; + // Invalidate in-flight connect hydrate / configured apply across stop paths. + resumeGenerationRef.current += 1; // Same latch as Stop button — covers any disconnect path (panel, protocol facade, etc.). setReticulumManualStackStopSuppress(true); if (peerRefreshDebounceRef.current) { @@ -2055,7 +2076,9 @@ export function useReticulumRuntime(): ProtocolRuntime { body.reply_preview_text = quote; } try { - const res = (await window.electronAPI.reticulum.proxyPost('/api/v1/lxmf/send', body)) as { + const res = (await withReticulumIpcSendDeadline( + window.electronAPI.reticulum.proxyPost('/api/v1/lxmf/send', body), + )) as { ok?: boolean; error?: string; message?: ReticulumLxmfPayload; @@ -2114,9 +2137,14 @@ export function useReticulumRuntime(): ProtocolRuntime { } catch (e) { if (pendingId) { const errStr = errLikeToLogString(e); + const proxyKey = reticulumProxyErrorToI18nKey(errStr); const userMessage = errStr.includes('no_propagation_node') ? i18n.t('chatPanel.reticulumNoPropagationNode') - : i18n.t('chatPanel.reticulumSendFailed'); + : proxyKey + ? i18n.t(proxyKey) + : isReticulumIpcSendTimeout(e) + ? i18n.t('chatPanel.reticulumSendTimeout') + : i18n.t('chatPanel.reticulumSendFailed'); updateMessageStatus(identityId, pendingId, 'failed', userMessage); } throw e; @@ -2139,11 +2167,16 @@ export function useReticulumRuntime(): ProtocolRuntime { ? resolveReticulumDestinationHash(targetMsg.to) : targetMsg.reticulumSenderHash; if (!peerHash) return; - const res = (await window.electronAPI.reticulum.proxyPost('/api/v1/lxmf/reaction', { - destination_hash: peerHash, - target_hash: targetMsg.reticulumMessageHash, - emoji: glyph, - })) as { ok?: boolean; message?: ReticulumLxmfPayload }; + const res = (await withReticulumIpcSendDeadline( + window.electronAPI.reticulum.proxyPost('/api/v1/lxmf/reaction', { + destination_hash: peerHash, + target_hash: targetMsg.reticulumMessageHash, + emoji: glyph, + }), + )) as { ok?: boolean; message?: ReticulumLxmfPayload; error?: string }; + if (res?.ok === false) { + throw new Error(res.error ?? 'LXMF reaction failed'); + } if (res?.message) { const payload = extractLxmfPayloadFromSendResponse(res) ?? res.message; if (payload) ingestLxmfPayload(payload);