From 74d39e23db3f3de83261ad20ee43015f264eed48 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Fri, 7 Aug 2026 09:07:23 -0600 Subject: [PATCH 1/5] fix(meshcore): speed waiting-message drain and rename SoftAP to OpenHop Prefer bulk getWaitingMessages for silent event-131 drains (BLE/serial/TCP) with header X/Y progress, falling back to syncNextMessage on timeout without disconnecting. Erase incorrect SoftAP jargon in favor of OpenHop naming. --- AGENTS.md | 2 +- docs/meshcore-meshtastic-parity.md | 2 +- docs/troubleshooting.md | 18 +- src/main/index.ts | 4 +- src/renderer/App.tsx | 10 +- .../meshcore/meshcoreConnSideEffects.test.ts | 171 ++++++++++- .../hooks/meshcore/meshcoreConnSideEffects.ts | 144 +++++++-- src/renderer/hooks/openMeshCoreTransport.ts | 2 +- ...oreRuntime.waiting-messages-drain.test.tsx | 19 +- src/renderer/hooks/useProtocolConnection.ts | 2 +- src/renderer/hooks/useSendMessage.test.ts | 32 +- src/renderer/hooks/useSendMessage.ts | 22 +- .../lib/drivers/ConnectionDriver.test.ts | 2 +- src/renderer/lib/drivers/ConnectionDriver.ts | 2 +- src/renderer/lib/hostLinkQuality.ts | 2 +- .../lib/meshcore/meshcoreDiscoverSelfCache.ts | 2 +- .../lib/meshcore/meshcoreTcpInitBurst.test.ts | 102 +++---- .../lib/meshcore/meshcoreTcpInitBurst.ts | 94 +++--- .../lib/meshcoreWaitingMessagesDrain.test.ts | 39 +++ .../lib/meshcoreWaitingMessagesDrain.ts | 53 ++++ .../meshcoreWaitingMessagesStatusText.test.ts | 32 ++ .../lib/meshcoreWaitingMessagesStatusText.ts | 32 +- src/renderer/lib/protocolTransportParams.ts | 2 +- .../protocols/meshcore/MeshCoreTransport.ts | 8 +- src/renderer/lib/sessions/meshcoreSession.ts | 6 +- src/renderer/locales/cs/translation.json | 3 +- src/renderer/locales/de/translation.json | 3 +- src/renderer/locales/en/translation.json | 1 + src/renderer/locales/es/translation.json | 3 +- src/renderer/locales/fr/translation.json | 3 +- src/renderer/locales/id/translation.json | 3 +- src/renderer/locales/it/translation.json | 3 +- src/renderer/locales/ja/translation.json | 3 +- src/renderer/locales/ko/translation.json | 3 +- src/renderer/locales/nl/translation.json | 3 +- src/renderer/locales/pl/translation.json | 3 +- src/renderer/locales/pt-BR/translation.json | 3 +- src/renderer/locales/ru/translation.json | 3 +- src/renderer/locales/tr/translation.json | 3 +- src/renderer/locales/uk/translation.json | 3 +- src/renderer/locales/zh/translation.json | 3 +- .../useMeshcoreRuntime.reconnect.test.ts | 123 ++++---- src/renderer/runtime/useMeshcoreRuntime.ts | 288 ++++++++++-------- src/shared/withTimeout.ts | 2 +- 44 files changed, 870 insertions(+), 393 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 34ba42276..8c6bf0e5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -368,7 +368,7 @@ Do not change behavior guarded by `meshcoreZeroHopRepeaterWorkingState.test.ts` | Repeater CLI danger / auto-ping | `meshcoreRepeaterCliDanger.ts`, `RepeatersPanel.tsx` (`ensureCliRoutePrimed`); `repeatersPanel.cliMultiHopHint` | | Room vs repeater LoginFail | `meshcoreRoomLoginRpc.ts` (fail fast) vs `meshcoreRepeaterLoginRpc.ts` + `meshcoreRepeaterPrefixPushRpc.ts` (wait for LoginSuccess) | | Renderer hung after wake | `rendererHeartbeatWatchdog.ts`, `useRendererHeartbeat`; visible stall + export `mainLiveness`; [troubleshooting](docs/troubleshooting.md#macos-sleep--wake-and-auto-reconnect) — quit fully if no `[usePowerRecovery]` after resume watchdog | -| MeshCore TCP mid-init peer FIN | `useMeshcoreRuntime` initConn / `meshcore:tcp-*`; [troubleshooting](docs/troubleshooting.md#meshcore-tcp-connect-stuck-or-reconnect-loop-on-softapopenhop) | +| MeshCore TCP mid-init peer FIN | `useMeshcoreRuntime` initConn / `meshcore:tcp-*`; [troubleshooting](docs/troubleshooting.md#meshcore-tcp-connect-stuck-or-reconnect-loop-on-openhop) | | Chat hop pills missing | MeshCore: `meshcoreCompanionRxPathLenToHopCount` / `MeshCoreProtocol` / `meshcoreRawPacketCorrelate` / `meshcoreIngest`; Meshtastic: `meshtasticRfHops.ts` (`viaMqtt` / `hopStart===0` omit by design) | | Meshtastic SDK routing console noise | `meshtasticSdkRoutingErrorConsoleHook.ts`, `meshtasticSdkRoutingErrorLog.ts` | diff --git a/docs/meshcore-meshtastic-parity.md b/docs/meshcore-meshtastic-parity.md index 2250512a4..e2ffa84e9 100644 --- a/docs/meshcore-meshtastic-parity.md +++ b/docs/meshcore-meshtastic-parity.md @@ -46,7 +46,7 @@ Shared UI gates use `ProtocolCapabilities` in [`src/renderer/lib/radio/BaseRadio | Log analyzer | `LogPanel` → **Analyze** (`logAnalyzer.ts`, protocol-aware) | Same shared UI | **App** (implemented) | | Room servers (BBS) | Not applicable | **Rooms** tab: login/post/admin CLI; optional **Remember password** (`app_settings`); **Auto-sync** periodic re-login while radio connected ([`meshcoreRoomSyncScheduler.ts`](../src/renderer/lib/meshcoreRoomSyncScheduler.ts), [`useMeshcoreRuntime.ts`](../src/renderer/runtime/useMeshcoreRuntime.ts)); RF-only (not MQTT) | **App** (MeshCore-only) | | Repeater admin passwords | Not applicable | Per-repeater **Remember** (`meshcoreRepeaterCredential:` in `app_settings`); shared factory [`meshcorePerNodeCredentialStorage.ts`](../src/renderer/lib/meshcorePerNodeCredentialStorage.ts) with [`meshcoreRepeaterCredentialStorage.ts`](../src/renderer/lib/meshcoreRepeaterCredentialStorage.ts) / [`meshcoreRoomCredentialStorage.ts`](../src/renderer/lib/meshcoreRoomCredentialStorage.ts); [`useMeshcoreRepeaterRemoteAuth.tsx`](../src/renderer/hooks/useMeshcoreRepeaterRemoteAuth.tsx), [`MeshcoreRepeaterPasswordControls.tsx`](../src/renderer/components/MeshcoreRepeaterPasswordControls.tsx); Repeaters sidebar **Saved repeater passwords** + Forget | **App** (MeshCore-only) | -| MsgWaiting background drain | Not applicable | Event 131 silent drain ([`meshcoreWaitingMessagesDrain.ts`](../src/renderer/lib/meshcoreWaitingMessagesDrain.ts)); **header status indicator** (queued backlog and active sync on any protocol tab; **paused/deferred** only on MeshCore tab); manual **Sync now** with determinate progress in the header indicator | **App** (MeshCore-only) | +| MsgWaiting background drain | Not applicable | Event 131 silent drain ([`meshcoreWaitingMessagesDrain.ts`](../src/renderer/lib/meshcoreWaitingMessagesDrain.ts)): bulk `getWaitingMessages` first (header **X / Y**), `syncNextMessage` fallback on timeout without disconnect (**Fetched N…**); **header status indicator** (queued backlog and active sync on any protocol tab; **paused/deferred** only on MeshCore tab); manual **Sync now** with determinate progress | **App** (MeshCore-only) | ## MeshCore: Room servers diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0ceeebe09..9e02bd5c4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -71,7 +71,7 @@ The top-level **`legend`** explains that ids like `offline-meshcore` are **inter - `uiStoreIdentityId` — bucket Chat and Nodes read from. - `identitySplit: true` while transport is connected — **suspicious** (live ingress and UI may disagree). - `ui.chatPanelFrozen` + `frozenMessageCount` lagging `liveResolvedMessageCount` — **legacy snapshots only** (current builds always emit `chatPanelFrozen: false`; the freeze path was removed). Ignore unless analyzing an older export. -- `ui.waitingMessagesSilentDrainActive` / `ui.waitingMessagesDrainDeferred` — MeshCore incremental drain in progress or paused behind admin/trace (serial may show small batches). UI: **header status indicator** (queued backlog visible on any protocol tab; **active sync spinner and paused/deferred** state only on the MeshCore tab), not Chat/Rooms panel strips. +- `ui.waitingMessagesSilentDrainActive` / `ui.waitingMessagesDrainDeferred` — MeshCore waiting-message drain in progress or paused behind admin/trace. Auto-drain prefers bulk `getWaitingMessages` (header shows **X / Y** when the radio returns a queue); on bulk timeout it falls back to one-at-a-time `syncNextMessage` (header shows **Fetched N…**, no fake total). Serial may still feel batchy. UI: **header status indicator** (queued backlog visible on any protocol tab; **active sync spinner and paused/deferred** state only on the MeshCore tab), not Chat/Rooms panel strips. - `meshcoreContactPathDiagnostics` — redacted MeshCore contact rows with `pubKeyPrefixHex` (12 hex chars), `hopsAway`, and best known `bestPathBytes` / `bestPathHopCount` from SQLite path history (useful for ping/no-route reports). **Meshtastic-only extension** (under `meshtastic` bucket): @@ -607,7 +607,7 @@ IPv6 addresses work for Meshtastic Wi‑Fi, MeshCore TCP, and Reticulum RNode Wi ### Connection panel Link quality (TCP) shows "—" or unexpected latency -**Cause:** For **Meshtastic WiFi/TCP** and **MeshCore TCP/IP SoftAP**, the Connection panel signal bars reflect **live-session responsiveness** — an EWMA of write→first-data delay on the already-open TCP socket — not a separate connect probe. Bars may show **"—"** until traffic has produced a sample, or after ~2 minutes without a completed sample (covers idle heartbeat gaps). Meshtastic **WiFi/HTTP** still uses a `/json/report` RTT probe (separate from the TCP session). Reticulum hub/RMAP rows still use a short-lived TCP connect probe (different risk profile). +**Cause:** For **Meshtastic WiFi/TCP** and **MeshCore TCP/IP OpenHop**, the Connection panel signal bars reflect **live-session responsiveness** — an EWMA of write→first-data delay on the already-open TCP socket — not a separate connect probe. Bars may show **"—"** until traffic has produced a sample, or after ~2 minutes without a completed sample (covers idle heartbeat gaps). Meshtastic **WiFi/HTTP** still uses a `/json/report` RTT probe (separate from the TCP session). Reticulum hub/RMAP rows still use a short-lived TCP connect probe (different risk profile). **Why not a second TCP connect?** Probing the same `host:port` as the live session every few seconds can RST ESP32/lwIP-class devices (see PR discussion around competing connections). @@ -788,9 +788,9 @@ When the Meshtastic SDK logs a routing / queue failure, mesh-client intercepts m ## MeshCore -### MeshCore TCP connect stuck or reconnect loop on SoftAP/OpenHop +### MeshCore TCP connect stuck or reconnect loop on OpenHop -**Symptoms**: TCP connect stuck on **Connecting**; empty/stale nodes; reconnect thrash on SoftAP / OpenHop / pyMC companions; log lines like `[IPC] meshcore:tcp socket closed … readableEnded=true` during `[useMeshcoreRuntime] initConn getContacts`. +**Symptoms**: TCP connect stuck on **Connecting**; empty/stale nodes; reconnect thrash on OpenHop / pyMC companions; log lines like `[IPC] meshcore:tcp socket closed … readableEnded=true` during `[useMeshcoreRuntime] initConn getContacts`. **Cause**: Companion closes TCP mid-handshake or after the contacts dump. Older builds thrashed reconnect before contacts were latched. @@ -824,7 +824,7 @@ Startup maintenance can delete stale MeshCore contacts by age. Important details **Common causes**: - **Large contact/repeater lists (1,000+)** — list tabs virtualize rows, but USB serial still serializes companion RPCs; prefer **Nodes → search** for one repeater instead of scrolling the full Repeaters table. -- **Queued public messages (Sync now)** — MsgWaiting backlog is drained **incrementally in the background** after connect and when the radio pushes event 131 (including after you send). The **header status indicator** (queued backlog and active sync on any protocol tab; **paused/deferred** state only on the MeshCore tab) shows silent auto-drain or deferred drain behind repeater admin/trace work. The determinate progress state appears when you click **Sync now** and the radio confirms a non-empty queue. Large backlogs may take a minute on manual sync; wait for the indicator to finish before switching tabs during heavy sync. +- **Queued public messages (Sync now)** — MsgWaiting backlog is drained in the background after connect and when the radio pushes event 131 (including after you send). Auto-drain prefers a bulk `getWaitingMessages` pull (header shows **Syncing X / Y…**); if that times out it falls back to one-at-a-time `syncNextMessage` without disconnecting (header shows **Fetched N…**). The **header status indicator** (queued backlog and active sync on any protocol tab; **paused/deferred** state only on the MeshCore tab) shows silent auto-drain or deferred drain behind repeater admin/trace work. Manual **Sync now** still uses bulk with determinate progress. Wait for the indicator to finish before switching tabs during heavy sync. - **Multi-hop repeater RPCs** (Neighbors, Status, telemetry) share one serialized USB serial queue. Retrying rapidly or querying distant repeaters (8+ hops) can block the link for up to **120 seconds** per request; queued pings up to **180s** each. **Load more** on a neighbor list is another full Neighbors RPC (~120s) — prefer it over re-clicking **Neighbors** (which replaces the first page). Page request size is 50, but firmware reply buffers often return fewer rows. - **Concurrent Ping + Status** — MeshCore allows only **one traceroute at a time** on the RF link; multiple pings are queued serially. Status/Neighbors/Sensors wait for an in-progress ping to finish before using the companion queue (see [Serialized traceroutes](meshcore-meshtastic-parity.md#serialized-traceroutes-protocol-requirement)). @@ -1619,18 +1619,18 @@ Chat used a freeze-on-leave snapshot: `messagesForUnread` stayed live for badges **Cause** -The companion radio queues public messages behind a **single serialized USB serial lane** shared with repeater admin, init RPCs, and MsgWaiting drains. Older builds bulk-fetched the whole queue before updating the UI. +The companion radio queues public messages behind a **single serialized USB serial lane** shared with repeater admin, init RPCs, and MsgWaiting drains. Auto-drain tries bulk `getWaitingMessages` first (shorter silent timeout on serial); on timeout it falls back to `syncNextMessage` without tearing down the link. **In-app status** -The **header status indicator** (queued backlog and active sync on any protocol tab; **paused/deferred** state only on the MeshCore tab) shows silent auto-drain or deferred drain behind admin/trace work. On serial, messages may arrive in small batches without a Chat/Rooms panel banner. +The **header status indicator** (queued backlog and active sync on any protocol tab; **paused/deferred** state only on the MeshCore tab) shows silent auto-drain (**X / Y** on bulk success, **Fetched N…** on fallback) or deferred drain behind admin/trace work. On serial, messages may still arrive in small batches without a Chat/Rooms panel banner. **Fix / workaround** 1. Pause repeater **Status / Neighbors / ping** while monitoring live chat on serial. -2. Prefer **BLE** or **TCP** when available for lower-latency chat. +2. Prefer **BLE** or **TCP** (including OpenHop) when available for lower-latency chat. 3. If drains stall, **Disconnect → Connect** or quit and reopen after repeated timeouts in the log. -4. Use **Sync now** from the **header waiting-messages indicator** for a large backlog (determinate progress in the header tooltip/status). +4. Use **Sync now** from the **header waiting-messages indicator** for a large backlog (determinate **X / Y** progress in the header tooltip/status). Auto-drain now also shows progress when bulk succeeds. ### Chat or Rooms: scroll jumps when switching tabs diff --git a/src/main/index.ts b/src/main/index.ts index 460890de0..d07428e28 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -325,7 +325,7 @@ async function ensureTakServerManager(): Promise { /** Max bytes per MeshCore TCP IPC write (DoS guard). */ const MESHCORE_TCP_WRITE_MAX_BYTES = 256 * 1024; -/** Cap per-chunk IPC fan-out from SoftAP/companion TCP reads (align with write max). */ +/** Cap per-chunk IPC fan-out from OpenHop/companion TCP reads (align with write max). */ const MESHCORE_TCP_DATA_MAX_BYTES = MESHCORE_TCP_WRITE_MAX_BYTES; /** Min node ID for MeshCore chat stub nodes (derived from meshcoreUtils). */ const MESHCORE_CHAT_STUB_ID_MIN = 0xa0000000 >>> 0; @@ -6148,7 +6148,7 @@ ipcMain.handle('meshcore:tcp-connect', (event, host: string, port: number) => { const socketHost = formatHostForSocket(host); const socket = new net.Socket(); // MeshCore Open / official companion TCP clients use TCP_NODELAY; Node defaults can - // Nagle-batch small companion RPCs and SoftAP/OpenHop peers often FIN mid-init. + // Nagle-batch small companion RPCs and OpenHop peers often FIN mid-init. socket.setNoDelay(true); socket.setKeepAlive(true, MESHCORE_TCP_KEEPALIVE_INITIAL_DELAY_MS); meshcoreTcpSocket = socket; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0f9585e34..71e798510 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -52,7 +52,7 @@ import { ConnectIcon } from '@/renderer/lib/icons/connectIcon'; import { MqttGlobeIcon } from '@/renderer/lib/icons/connectionIcons'; import { ICON_MD } from '@/renderer/lib/icons/iconClass'; import { useIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; -import { isMeshcoreTcpSoftApDeadAccepted } from '@/renderer/lib/meshcore/meshcoreTcpInitBurst'; +import { isMeshcoreTcpOpenHopDeadAccepted } from '@/renderer/lib/meshcore/meshcoreTcpInitBurst'; import { meshcoreConfiguredChannelIndexSet, meshcoreConfiguredChatChannels, @@ -2510,11 +2510,11 @@ function AppContent() { if (!meshcoreIdentityId || !connectionDriver.getHandle(meshcoreIdentityId)) return; const sendScheduledAdvert = () => { - // SoftAP/OpenHop: configured session may have a dead TCP bridge after contacts FIN. + // OpenHop: configured session may have a dead TCP bridge after contacts FIN. // Flood advert would tcp-write-fail and thrash reconnect. - const softAp = isMeshcoreTcpSoftApDeadAccepted(); - if (softAp) { - console.debug('[App] auto flood advert skipped (SoftAP dead bridge)'); + const openHop = isMeshcoreTcpOpenHopDeadAccepted(); + if (openHop) { + console.debug('[App] auto flood advert skipped (OpenHop dead bridge)'); return; } const action = diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts index 84958bcdd..63d116609 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts @@ -17,6 +17,11 @@ import { useNodeStore } from '@/renderer/stores/nodeStore'; import { attachMeshcoreConnSideEffects } from './meshcoreConnSideEffects'; import type { MeshcoreConnSideEffectsCtx } from './meshcoreConnSideEffectsCtx'; import type { PendingDmAckEntry } from './meshcoreHookPreamble'; +import { + clearMeshcoreWaitingMessagesFollowUp, + resetMeshcoreWaitingMessagesSilentFollowUpChain, + setMeshcoreProcessWaitingMessagesInFlight, +} from './meshcoreWaitingMessagesSyncState'; const ID = 'meshcore-conn-side-effects-test'; @@ -151,6 +156,9 @@ describe('attachMeshcoreConnSideEffects', () => { beforeEach(() => { resetMeshcoreWaitingMessagesDrainState(0); + setMeshcoreProcessWaitingMessagesInFlight(null); + clearMeshcoreWaitingMessagesFollowUp(); + resetMeshcoreWaitingMessagesSilentFollowUpChain(); useNodeStore.setState({ nodes: {} }); useMessageStore.setState({ messages: {} }); }); @@ -337,17 +345,168 @@ describe('attachMeshcoreConnSideEffects', () => { expect(publish.mock.calls.length).toBeGreaterThan(0); }); - it('schedules a silent drain on the message-waiting signal', async () => { + it.each(['ble', 'serial', 'tcp'] as const)( + 'silent drain prefers bulk getWaitingMessages on %s', + async (connectionType) => { + vi.useFakeTimers(); + const h = makeHarness(); + h.ctx.meshcoreConnectTypeRef.current = connectionType; + vi.mocked(h.conn.getWaitingMessages).mockResolvedValue([ + { + channelMessage: { + channelIdx: 0, + text: 'BulkPeer: queued', + senderTimestamp: 1_700_000_000, + }, + }, + ]); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + dispatch({ type: 'meshcore_waiting_messages', payload: {} }); + await vi.advanceTimersByTimeAsync(MESHCORE_WAITING_MESSAGES_DRAIN_DEBOUNCE_MS + 50); + await vi.runAllTimersAsync(); + + expect(h.conn.getWaitingMessages).toHaveBeenCalled(); + expect(h.syncNextMessage).not.toHaveBeenCalled(); + expect(h.ctx.setWaitingMessagesSyncProgress).toHaveBeenCalledWith( + expect.objectContaining({ processed: expect.any(Number), total: 1 }), + ); + expect(h.ctx.addMessagesBatch).toHaveBeenCalled(); + expect(h.handleConnectionLost).not.toHaveBeenCalled(); + }, + ); + + it.each(['ble', 'serial', 'tcp'] as const)( + 'silent bulk timeout falls back to syncNextMessage on %s without disconnect', + async (connectionType) => { + vi.useFakeTimers(); + const h = makeHarness(); + h.ctx.meshcoreConnectTypeRef.current = connectionType; + vi.mocked(h.conn.getWaitingMessages).mockImplementation( + () => new Promise(() => undefined), // hang until withTimeout + ); + h.syncNextMessage + .mockResolvedValueOnce({ + channelMessage: { + channelIdx: 0, + text: 'FallbackPeer: one', + senderTimestamp: 1_700_000_001, + }, + }) + .mockResolvedValueOnce(null); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + const drainPromise = h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: false }); + await vi.advanceTimersByTimeAsync( + connectionType === 'serial' + ? 15_000 // MESHCORE_WAITING_MESSAGES_SERIAL_SILENT_TIMEOUT_MS + : 45_000, + ); + await vi.runAllTimersAsync(); + await drainPromise; + + expect(h.conn.getWaitingMessages).toHaveBeenCalled(); + expect(h.syncNextMessage).toHaveBeenCalled(); + expect(h.handleConnectionLost).not.toHaveBeenCalled(); + expect(h.teardownConn).not.toHaveBeenCalled(); + expect(h.ctx.connRef.current).toBe(h.conn); + expect(h.ctx.addMessagesBatch).toHaveBeenCalled(); + }, + ); + + it('ignores late bulk resolve after timeout fallback has started', async () => { vi.useFakeTimers(); const h = makeHarness(); + let resolveBulk: (value: unknown[]) => void = () => undefined; + vi.mocked(h.conn.getWaitingMessages).mockImplementation( + () => + new Promise((resolve) => { + resolveBulk = resolve; + }), + ); + h.syncNextMessage.mockResolvedValue(null); detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); - expect(h.ctx.processWaitingMessagesRef.current).toBeTypeOf('function'); - dispatch({ type: 'meshcore_waiting_messages', payload: {} }); - await vi.advanceTimersByTimeAsync(MESHCORE_WAITING_MESSAGES_DRAIN_DEBOUNCE_MS + 50); + const drainPromise = h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: false }); + await vi.advanceTimersByTimeAsync(45_000); + await Promise.resolve(); + // Late bulk payload — must not be ingested (withTimeout already abandoned; attempt id bumped). + resolveBulk([ + { + channelMessage: { + channelIdx: 0, + text: 'LatePeer: should not ingest', + senderTimestamp: 1_700_000_999, + }, + }, + ]); + await vi.runAllTimersAsync(); + await drainPromise; expect(h.syncNextMessage).toHaveBeenCalled(); - expect(h.conn.getWaitingMessages).not.toHaveBeenCalled(); + expect(h.ctx.addMessagesBatch).not.toHaveBeenCalled(); + expect(h.handleConnectionLost).not.toHaveBeenCalled(); + }); + + it('does not fallback or disconnect when silent bulk hits transport-dead', async () => { + const h = makeHarness(); + vi.mocked(h.conn.getWaitingMessages).mockRejectedValue( + new Error('meshcore:tcp-write: no active socket'), + ); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + await h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: false }); + + expect(h.syncNextMessage).not.toHaveBeenCalled(); + expect(h.handleConnectionLost).not.toHaveBeenCalled(); + expect(h.teardownConn).not.toHaveBeenCalled(); + }); + + it('manual Sync now still uses bulk getWaitingMessages with banner progress', async () => { + const h = makeHarness(); + vi.mocked(h.conn.getWaitingMessages).mockResolvedValue([ + { + channelMessage: { + channelIdx: 0, + text: 'ManualPeer: queued', + senderTimestamp: 1_700_000_000, + }, + }, + ]); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + await h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: true }); + + expect(h.conn.getWaitingMessages).toHaveBeenCalled(); + expect(h.syncNextMessage).not.toHaveBeenCalled(); + expect(h.ctx.setWaitingMessagesSyncActive).toHaveBeenCalledWith(true); + expect(h.ctx.setWaitingMessagesSyncProgress).toHaveBeenCalledWith( + expect.objectContaining({ total: 1 }), + ); + }); + + it('skips a second silent drain while one is in flight', async () => { + vi.useFakeTimers(); + const h = makeHarness(); + let releaseBulk: () => void = () => undefined; + vi.mocked(h.conn.getWaitingMessages).mockImplementation( + () => + new Promise((resolve) => { + releaseBulk = () => { + resolve([]); + }; + }), + ); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + const first = h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: false }); + await Promise.resolve(); + const second = h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: false }); + expect(h.conn.getWaitingMessages).toHaveBeenCalledTimes(1); + releaseBulk(); + await vi.runAllTimersAsync(); + await Promise.all([first, second]); + expect(h.handleConnectionLost).not.toHaveBeenCalled(); }); it('flushes waiting-message node changes to nodeStore without updating the runtime node mirror', async () => { @@ -442,7 +601,7 @@ describe('attachMeshcoreConnSideEffects', () => { dispatch({ type: 'device_status', payload: { status: 'disconnected' } }); - // SoftAP FIN must not strip the ConnectionDriver handle — write-dead / tcp.onDisconnected + // OpenHop FIN must not strip the ConnectionDriver handle — write-dead / tcp.onDisconnected // own recovery after "accepting dead bridge". expect(h.state.status).toBe('configured'); expect(h.teardownConn).not.toHaveBeenCalled(); diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts index 581cc575f..100e1a2c7 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts @@ -28,8 +28,14 @@ import { normalizeMeshcoreWaitingMessageItem, } from '../../lib/meshcoreWaitingMessageItem'; import { + abandonMeshcoreSilentBulkAttempt, + beginMeshcoreSilentBulkAttempt, isMeshcoreCompanionDrainDeferred, + isMeshcoreGetWaitingMessagesTimeoutError, + isMeshcoreSilentBulkAttemptCurrent, isMeshcoreSyncNextMessageTimeoutError, + isMeshcoreWaitingMessagesBulkFallbackError, + isMeshcoreWaitingMessagesTransportDeadError, logMeshcoreWaitingMessagesDrainError, markMeshcoreMsgWaitingEvent, resetMeshcoreWaitingMessagesDrainSchedule, @@ -93,6 +99,9 @@ interface MeshcoreWaitingMessagesDrainDeps { interface MeshcoreWaitingMessagesDrainState { processed: number; bannerActive: boolean; + /** Silent bulk X/Y or Sync-now banner; fallback uses processed-only (syncTotal 0). */ + progressActive: boolean; + /** When progressActive and syncTotal > 0 → X/Y; when syncTotal === 0 → processed-only. */ syncTotal: number; /** Mutated in place (pushed/cleared) rather than reassigned so helpers can share the reference. */ pendingMessages: ChatMessage[]; @@ -197,8 +206,11 @@ async function ingestMeshcoreWaitingMessageItem( state.pendingMessages.push(...result.pendingMessages); } state.processed += 1; - if (state.bannerActive) { - deps.setWaitingMessagesSyncProgress({ processed: state.processed, total: state.syncTotal }); + if (state.progressActive) { + deps.setWaitingMessagesSyncProgress({ + processed: state.processed, + total: state.syncTotal, + }); } } catch (e: unknown) { console.warn( @@ -225,6 +237,7 @@ async function drainWaitingMessagesManual( state.syncTotal = total; if (shouldActivateWaitingMessagesBanner(true, total)) { state.bannerActive = true; + state.progressActive = true; deps.setWaitingMessagesSyncActive(true); deps.setWaitingMessagesSyncProgress(null); deps.setWaitingMessagesCount(total); @@ -233,10 +246,15 @@ async function drainWaitingMessagesManual( console.debug('[meshcoreConnSideEffects] processWaitingMessages empty queue (manual sync)'); return; } - console.debug('[meshcoreConnSideEffects] processWaitingMessages start', { - count: total, - showSyncBanner: true, - }); + console.debug( + '[meshcoreConnSideEffects] processWaitingMessages start ' + + JSON.stringify({ + count: total, + showSyncBanner: true, + connectionType: deps.connectionType, + mode: 'manual', + }), + ); for (const m of arr) { if (!deps.meshcoreHookMountedRef.current) break; await ingestMeshcoreWaitingMessageItem(m, state, deps); @@ -250,15 +268,12 @@ async function drainWaitingMessagesManual( flushMeshcoreWaitingState(state, deps); } -/** Silent/incremental drain (message-waiting push 131) — no banner, capped per-drain. */ -async function drainWaitingMessagesSilent( +/** Pull queued messages via syncNextMessage (fallback / empty-queue end). */ +async function drainWaitingMessagesIncremental( conn: MeshCoreConnection, state: MeshcoreWaitingMessagesDrainState, deps: MeshcoreWaitingMessagesDrainDeps, ): Promise { - console.debug('[meshcoreConnSideEffects] processWaitingMessages start (incremental)', { - showSyncBanner: false, - }); let silentDrainExhaustedCap = false; for (let i = 0; i < MESHCORE_SYNC_NEXT_MESSAGE_MAX_PER_DRAIN; i += 1) { if (!deps.meshcoreHookMountedRef.current) break; @@ -289,6 +304,87 @@ async function drainWaitingMessagesSilent( flushMeshcoreWaitingState(state, deps); } +/** + * Silent auto-drain (event 131): prefer bulk getWaitingMessages for speed; on timeout/transient + * fall back to syncNextMessage. Never tears down the connection from this path. + */ +async function drainWaitingMessagesSilent( + conn: MeshCoreConnection, + state: MeshcoreWaitingMessagesDrainState, + deps: MeshcoreWaitingMessagesDrainDeps, +): Promise { + const attemptId = beginMeshcoreSilentBulkAttempt(); + console.debug( + '[meshcoreConnSideEffects] processWaitingMessages start ' + + JSON.stringify({ + showSyncBanner: false, + connectionType: deps.connectionType, + mode: 'silent-bulk', + }), + ); + + try { + const msgs = await withTimeout( + conn.getWaitingMessages(), + waitingMessagesDrainTimeoutMs(false, deps.connectionType), + 'MeshCore getWaitingMessages', + ); + if (!isMeshcoreSilentBulkAttemptCurrent(attemptId)) { + // catch-no-log-ok late bulk after abandon — ignore without disconnect + return; + } + if (!deps.meshcoreHookMountedRef.current) return; + const arr = normalizeMeshcoreWaitingMessageBatch(msgs); + if (arr.length === 0) { + console.debug('[meshcoreConnSideEffects] processWaitingMessages empty queue (silent bulk)'); + return; + } + state.syncTotal = arr.length; + state.progressActive = true; + deps.setWaitingMessagesSyncProgress({ processed: 0, total: arr.length }); + for (const m of arr) { + if (!deps.meshcoreHookMountedRef.current) break; + if (!isMeshcoreSilentBulkAttemptCurrent(attemptId)) break; + await ingestMeshcoreWaitingMessageItem(m, state, deps); + if ( + state.processed % MESHCORE_WAITING_MESSAGES_BATCH_YIELD === 0 || + state.pendingMessages.length >= MESHCORE_WAITING_MESSAGES_BATCH_YIELD + ) { + flushMeshcoreWaitingState(state, deps); + } + } + flushMeshcoreWaitingState(state, deps); + return; + } catch (e: unknown) { + if (isMeshcoreWaitingMessagesTransportDeadError(e)) { + // catch-no-log-ok transport dead — reconnect owns link; do not fallback or disconnect here + logMeshcoreWaitingMessagesDrainError('silent bulk transport dead', e, false); + return; + } + if ( + isMeshcoreWaitingMessagesBulkFallbackError(e) || + isMeshcoreGetWaitingMessagesTimeoutError(e) + ) { + abandonMeshcoreSilentBulkAttempt(attemptId); + logMeshcoreWaitingMessagesDrainError('silent bulk fallback to syncNextMessage', e, false); + state.syncTotal = 0; + state.progressActive = true; + deps.setWaitingMessagesSyncProgress({ processed: 0, total: 0 }); + console.debug( + '[meshcoreConnSideEffects] processWaitingMessages start ' + + JSON.stringify({ + showSyncBanner: false, + connectionType: deps.connectionType, + mode: 'silent-fallback', + }), + ); + await drainWaitingMessagesIncremental(conn, state, deps); + return; + } + throw e; + } +} + /** * Runs one waiting-messages drain (manual full sync or silent incremental) and manages the * sync-progress banner / silent-drain UI flag around it. Mirrors the original inline async IIFE @@ -303,6 +399,7 @@ async function runMeshcoreWaitingMessagesDrain( const state: MeshcoreWaitingMessagesDrainState = { processed: 0, bannerActive: false, + progressActive: false, syncTotal: 0, pendingMessages: [], dirtyNodeIds: new Set(), @@ -319,11 +416,22 @@ async function runMeshcoreWaitingMessagesDrain( } else { await drainWaitingMessagesSilent(conn, state, deps); } - console.debug('[meshcoreConnSideEffects] processWaitingMessages done', { - count: state.processed, - durationMs: Date.now() - startedAt, - showSyncBanner: options.showSyncBanner, - }); + console.debug( + '[meshcoreConnSideEffects] processWaitingMessages done ' + + JSON.stringify({ + count: state.processed, + durationMs: Date.now() - startedAt, + showSyncBanner: options.showSyncBanner, + connectionType: deps.connectionType, + mode: options.showSyncBanner + ? 'manual' + : state.syncTotal > 0 + ? 'silent-bulk' + : state.processed > 0 || state.progressActive + ? 'silent-fallback' + : 'silent', + }), + ); } finally { if (silentDrainUiActive) { deps.setWaitingMessagesSilentDrainActive(false); @@ -332,6 +440,8 @@ async function runMeshcoreWaitingMessagesDrain( deps.setWaitingMessagesCount(0); deps.setWaitingMessagesSyncActive(false); deps.setWaitingMessagesSyncProgress(null); + } else if (state.progressActive) { + deps.setWaitingMessagesSyncProgress(null); } } } @@ -644,7 +754,7 @@ export function attachMeshcoreConnSideEffects( const handleDisconnected = () => { // TCP: runtime meshcore.tcp.onDisconnected / write-dead own bridge-dead + reconnect (#792). - // SoftAP/OpenHop often FINs after contacts; TcpOverIpc still emits device_status disconnected. + // OpenHop often FINs after contacts; TcpOverIpc still emits device_status disconnected. // Tearing down the driver here left "accepting dead bridge" with no ConnectionDriver handle // and no scheduled reconnect (deferred flag cleared without schedule) — send then logs // "no handle for offline-meshcore" and never reaches write-dead recovery. diff --git a/src/renderer/hooks/openMeshCoreTransport.ts b/src/renderer/hooks/openMeshCoreTransport.ts index d7faf42f5..ed561e2aa 100644 --- a/src/renderer/hooks/openMeshCoreTransport.ts +++ b/src/renderer/hooks/openMeshCoreTransport.ts @@ -19,7 +19,7 @@ export async function openMeshCoreTransport( blePeripheralId?: string; host?: string; portSignature?: string | null; - /** SoftAP user-TX reopen: skip ConnectionDriver discoverSelf so user RPC is first. */ + /** OpenHop user-TX reopen: skip ConnectionDriver discoverSelf so user RPC is first. */ skipDiscoverSelf?: boolean; }, ): Promise { diff --git a/src/renderer/hooks/useMeshcoreRuntime.waiting-messages-drain.test.tsx b/src/renderer/hooks/useMeshcoreRuntime.waiting-messages-drain.test.tsx index bec7006a7..8958e075e 100644 --- a/src/renderer/hooks/useMeshcoreRuntime.waiting-messages-drain.test.tsx +++ b/src/renderer/hooks/useMeshcoreRuntime.waiting-messages-drain.test.tsx @@ -237,11 +237,10 @@ describe('useMeshcoreRuntime waiting messages drain', () => { expect(syncNextMessageMock).not.toHaveBeenCalled(); }); - it('event 131 schedules silent syncNextMessage drain', async () => { - syncNextMessageMock.mockResolvedValueOnce({ - channelMessage: { channelIdx: 0, senderTimestamp: 1, text: 'queued' }, - }); - syncNextMessageMock.mockResolvedValue(null); + it('event 131 schedules silent bulk getWaitingMessages drain', async () => { + getWaitingMessagesMock.mockResolvedValueOnce([ + { channelMessage: { channelIdx: 0, senderTimestamp: 1, text: 'queued' } }, + ]); await connectSerialConfigured(); const conn = lastMeshSerialMock.current; @@ -253,14 +252,17 @@ describe('useMeshcoreRuntime waiting messages drain', () => { await waitFor( () => { - expect(syncNextMessageMock).toHaveBeenCalled(); + expect(getWaitingMessagesMock).toHaveBeenCalled(); }, { timeout: 8_000 }, ); - expect(getWaitingMessagesMock).not.toHaveBeenCalled(); + expect(syncNextMessageMock).not.toHaveBeenCalled(); }, 15_000); - it('event 131 silent drain treats syncNextMessage timeout as empty queue', async () => { + it('event 131 silent drain falls back on bulk timeout then ends on syncNextMessage timeout', async () => { + getWaitingMessagesMock.mockRejectedValue( + new Error('MeshCore getWaitingMessages timed out after 15000ms'), + ); syncNextMessageMock.mockRejectedValue( new Error('MeshCore syncNextMessage timed out after 12000ms'), ); @@ -275,6 +277,7 @@ describe('useMeshcoreRuntime waiting messages drain', () => { await waitFor( () => { + expect(getWaitingMessagesMock).toHaveBeenCalled(); expect(syncNextMessageMock).toHaveBeenCalled(); }, { timeout: 8_000 }, diff --git a/src/renderer/hooks/useProtocolConnection.ts b/src/renderer/hooks/useProtocolConnection.ts index 359288ab4..28883a260 100644 --- a/src/renderer/hooks/useProtocolConnection.ts +++ b/src/renderer/hooks/useProtocolConnection.ts @@ -62,7 +62,7 @@ export function useProtocolConnect(): ( blePeripheralId?: string, ) => { if (protocol === 'meshcore') { - // Delegate to runtime connect — do not reassemble prepare/driver/attach here (Neal SoftAP: + // Delegate to runtime connect — do not reassemble prepare/driver/attach here (Neal OpenHop: // that skipped session params + TCP deferred-reconnect after #792 / burst-complete). const mcType = meshcoreConnectionType(type); await getMeshcoreSession().connect(mcType, httpAddress, blePeripheralId); diff --git a/src/renderer/hooks/useSendMessage.test.ts b/src/renderer/hooks/useSendMessage.test.ts index 33e53a50b..8b5c6a5b7 100644 --- a/src/renderer/hooks/useSendMessage.test.ts +++ b/src/renderer/hooks/useSendMessage.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mergeAppSetting } from '../lib/appSettingsStorage'; import { connectionDriver } from '../lib/drivers/ConnectionDriver'; -import { setMeshcoreTcpSoftApDeadAccepted } from '../lib/meshcore/meshcoreTcpInitBurst'; +import { setMeshcoreTcpOpenHopDeadAccepted } from '../lib/meshcore/meshcoreTcpInitBurst'; import { meshcoreProtocol } from '../lib/protocols/MeshCoreProtocol'; import { meshtasticProtocol } from '../lib/protocols/MeshtasticProtocol'; import { reticulumProtocol } from '../lib/protocols/ReticulumProtocol'; @@ -68,7 +68,7 @@ describe('useSendMessage', () => { registerMeshtasticSession(null); registerMeshcoreSession(null); registerReticulumSession(null); - setMeshcoreTcpSoftApDeadAccepted(false); + setMeshcoreTcpOpenHopDeadAccepted(false); useIdentityStore.setState({ identities: {}, activeIdentityId: null }); useMessageStore.setState({ messages: {} }); vi.mocked(connectionDriver.getHandle).mockReturnValue(null); @@ -293,9 +293,9 @@ describe('useSendMessage', () => { sendSpy.mockRestore(); }); - it('SoftAP dead-accepted: sends via runMeshcoreUserTxWithLiveTcp without RF handle', async () => { - setMeshcoreTcpSoftApDeadAccepted(true); - const liveHandle = { kind: 'softap-live' }; + it('OpenHop dead-accepted: sends via runMeshcoreUserTxWithLiveTcp without RF handle', async () => { + setMeshcoreTcpOpenHopDeadAccepted(true); + const liveHandle = { kind: 'openhop-live' }; let runTxCalls = 0; const runTx: NonNullable = async (op) => { runTxCalls += 1; @@ -322,13 +322,13 @@ describe('useSendMessage', () => { setConnection(ID_MC, { status: 'configured', myNodeNum: 7 }); const { result } = renderHook(() => useSendMessage(ID_MC)); - result.current('softap hi', 1); + result.current('openhop hi', 1); await vi.waitFor(() => { expect(runTxCalls).toBe(1); expect(sendSpy).toHaveBeenCalledWith( liveHandle, - expect.objectContaining({ text: 'softap hi', channelIndex: 1 }), + expect.objectContaining({ text: 'openhop hi', channelIndex: 1 }), ); const rows = Object.values(useMessageStore.getState().messages[ID_MC] ?? {}); expect(rows).toHaveLength(1); @@ -338,9 +338,9 @@ describe('useSendMessage', () => { sendSpy.mockRestore(); }); - it('SoftAP dead-accepted: falls back to ensureTcpLiveForUserTx when runTx missing', async () => { - setMeshcoreTcpSoftApDeadAccepted(true); - const liveHandle = { kind: 'softap-ensure' }; + it('OpenHop dead-accepted: falls back to ensureTcpLiveForUserTx when runTx missing', async () => { + setMeshcoreTcpOpenHopDeadAccepted(true); + const liveHandle = { kind: 'openhop-ensure' }; const ensureTcpLiveForUserTx = vi.fn(() => { vi.mocked(connectionDriver.getHandle).mockReturnValue(liveHandle); return Promise.resolve(); @@ -365,13 +365,13 @@ describe('useSendMessage', () => { setConnection(ID_MC, { status: 'configured', myNodeNum: 7 }); const { result } = renderHook(() => useSendMessage(ID_MC)); - result.current('softap ensure', 2); + result.current('openhop ensure', 2); await vi.waitFor(() => { expect(ensureTcpLiveForUserTx).toHaveBeenCalledTimes(1); expect(sendSpy).toHaveBeenCalledWith( liveHandle, - expect.objectContaining({ text: 'softap ensure', channelIndex: 2 }), + expect.objectContaining({ text: 'openhop ensure', channelIndex: 2 }), ); const rows = Object.values(useMessageStore.getState().messages[ID_MC] ?? {}); expect(rows[0]?.status).toBe('acked'); @@ -379,8 +379,8 @@ describe('useSendMessage', () => { sendSpy.mockRestore(); }); - it('SoftAP dead-accepted: marks failed when live reopen yields no handle', async () => { - setMeshcoreTcpSoftApDeadAccepted(true); + it('OpenHop dead-accepted: marks failed when live reopen yields no handle', async () => { + setMeshcoreTcpOpenHopDeadAccepted(true); const { spy: warn, restore } = mockConsoleWarn(); try { registerMeshcoreSession( @@ -400,14 +400,14 @@ describe('useSendMessage', () => { setConnection(ID_MC, { status: 'configured', myNodeNum: 7 }); const { result } = renderHook(() => useSendMessage(ID_MC)); - result.current('softap fail', 1); + result.current('openhop fail', 1); await vi.waitFor(() => { const rows = Object.values(useMessageStore.getState().messages[ID_MC] ?? {}); expect(rows).toHaveLength(1); expect(rows[0]?.status).toBe('failed'); }); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('SoftAP live reopen failed')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('OpenHop live reopen failed')); } finally { restore(); } diff --git a/src/renderer/hooks/useSendMessage.ts b/src/renderer/hooks/useSendMessage.ts index 5ca83400b..5a8dff5c3 100644 --- a/src/renderer/hooks/useSendMessage.ts +++ b/src/renderer/hooks/useSendMessage.ts @@ -7,7 +7,7 @@ import { isMeshcoreOpenWireCompatEnabled } from '../lib/appSettingsStorage'; import { connectionDriver } from '../lib/drivers/ConnectionDriver'; import { errLikeToLogString } from '../lib/errLikeToLogString'; import { - isMeshcoreTcpSoftApDeadAccepted, + isMeshcoreTcpOpenHopDeadAccepted, trackMeshcoreTcpUserTxSend, } from '../lib/meshcore/meshcoreTcpInitBurst'; import { resolveMeshcoreOutboundWireText } from '../lib/meshcoreChannelText'; @@ -148,8 +148,8 @@ export function useSendMessage( } if (!handle) { - // SoftAP dead bridge may still send via quiet reopen (handle recreated on open). - if (!(identity.protocol.type === 'meshcore' && isMeshcoreTcpSoftApDeadAccepted())) { + // OpenHop dead bridge may still send via quiet reopen (handle recreated on open). + if (!(identity.protocol.type === 'meshcore' && isMeshcoreTcpOpenHopDeadAccepted())) { console.warn('[useSendMessage] no handle for', identityId); return; } @@ -207,10 +207,10 @@ export function useSendMessage( const wireText = resolvedOutbound.wireText; - if (isMeshcore && isMeshcoreTcpSoftApDeadAccepted()) { + if (isMeshcore && isMeshcoreTcpOpenHopDeadAccepted()) { void (async () => { try { - const applySoftApSendResult = (res: { packetId?: number }): void => { + const applyOpenHopSendResult = (res: { packetId?: number }): void => { const resolvedId = res.packetId != null ? String(res.packetId >>> 0) : provisionalId; if (res.packetId != null && resolvedId !== provisionalId) { renameMessageId(identityId, provisionalId, resolvedId); @@ -239,7 +239,7 @@ export function useSendMessage( replyTo, }); trackMeshcoreTcpUserTxSend(sendPromise); - applySoftApSendResult(await sendPromise); + applyOpenHopSendResult(await sendPromise); return; } const res = await runTx(async () => { @@ -255,12 +255,12 @@ export function useSendMessage( replyTo, }); }); - // Only after SoftAP retry loop resolves — not inside the parked op (latch-retry + // Only after OpenHop retry loop resolves — not inside the parked op (latch-retry // may re-run the send; premature acked would stick if attempt 2 failed). - applySoftApSendResult(res); + applyOpenHopSendResult(res); } catch (e: unknown) { const errMsg = errLikeToLogString(e); - console.warn('[useSendMessage] SoftAP live reopen failed ' + errMsg); + console.warn('[useSendMessage] OpenHop live reopen failed ' + errMsg); updateMessageStatus(identityId, provisionalId, 'failed', errMsg); persistMeshcoreOutboundRow(record, myNodeNum, meshcoreSenderName, 'failed'); } @@ -275,7 +275,7 @@ export function useSendMessage( const finishSend = ( sendHandle: NonNullable, - opts?: { trackForSoftApLiveWindow?: boolean }, + opts?: { trackForOpenHopLiveWindow?: boolean }, ): void => { const sendPromise = identity.protocol.sendMessage(sendHandle, { text: wireText, @@ -284,7 +284,7 @@ export function useSendMessage( destinationPubKey, replyTo, }); - if (opts?.trackForSoftApLiveWindow) { + if (opts?.trackForOpenHopLiveWindow) { trackMeshcoreTcpUserTxSend(sendPromise); } void sendPromise.then( diff --git a/src/renderer/lib/drivers/ConnectionDriver.test.ts b/src/renderer/lib/drivers/ConnectionDriver.test.ts index 53eb3167b..09e9d2074 100644 --- a/src/renderer/lib/drivers/ConnectionDriver.test.ts +++ b/src/renderer/lib/drivers/ConnectionDriver.test.ts @@ -88,7 +88,7 @@ describe('ConnectionDriver', () => { }); it('connect with skipDiscoverSelf skips protocol.discoverSelf', async () => { - const host = `softap-skip-${Date.now()}`; + const host = `openhop-skip-${Date.now()}`; const params: TransportParams = { type: 'tcp', host }; const fakeHandle = { kind: 'mock-meshcore-tcp' } as unknown as Connection; diff --git a/src/renderer/lib/drivers/ConnectionDriver.ts b/src/renderer/lib/drivers/ConnectionDriver.ts index 3fa9593b9..7f5d1762f 100644 --- a/src/renderer/lib/drivers/ConnectionDriver.ts +++ b/src/renderer/lib/drivers/ConnectionDriver.ts @@ -199,7 +199,7 @@ export class ConnectionDriver { } let info: DiscoveryInfo | undefined; - // SoftAP user-TX reopen: skip getSelfInfo so the parked user command is the first RPC. + // OpenHop user-TX reopen: skip getSelfInfo so the parked user command is the first RPC. if (protocol.discoverSelf && !opts?.skipDiscoverSelf) { try { info = await protocol.discoverSelf(handle); diff --git a/src/renderer/lib/hostLinkQuality.ts b/src/renderer/lib/hostLinkQuality.ts index 60304213c..7321d0f49 100644 --- a/src/renderer/lib/hostLinkQuality.ts +++ b/src/renderer/lib/hostLinkQuality.ts @@ -76,7 +76,7 @@ export function parseTcpProbeTarget( /** * True when the Connection panel transport is a live TCP session socket in main - * (`meshtastic:tcp-*` / `meshcore:tcp-*`). MeshCore SoftAP is stored as `http` + * (`meshtastic:tcp-*` / `meshcore:tcp-*`). MeshCore OpenHop is stored as `http` * (legacy enum) but is TCP on the wire. */ export function isLiveTcpSession( diff --git a/src/renderer/lib/meshcore/meshcoreDiscoverSelfCache.ts b/src/renderer/lib/meshcore/meshcoreDiscoverSelfCache.ts index 89d4e7e84..1b972e61a 100644 --- a/src/renderer/lib/meshcore/meshcoreDiscoverSelfCache.ts +++ b/src/renderer/lib/meshcore/meshcoreDiscoverSelfCache.ts @@ -2,7 +2,7 @@ import type { MeshCoreSelfInfoWire } from '../meshcoreTelemetryPrivacy'; /** * ConnectionDriver calls {@link MeshCoreProtocol.discoverSelf} (getSelfInfo) before - * `initConn`. TCP SoftAP/OpenHop companions often FIN under duplicate self-info RPCs — + * `initConn`. TCP OpenHop companions often FIN under duplicate self-info RPCs — * stash the wire payload so sequential TCP init can skip a second getSelfInfo. */ const cache = new WeakMap(); diff --git a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts index ff0a3a39c..70395048d 100644 --- a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts +++ b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.test.ts @@ -2,23 +2,23 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { isMeshcoreTcpTransportDeadError } from '../bleConnectErrors'; import { - clearMeshcoreSoftApPendingUserTx, - decideSoftApUserTxAfterEnsureFailure, - hasMeshcoreSoftApPendingUserTx, + clearMeshcoreOpenHopPendingUserTx, + decideOpenHopUserTxAfterEnsureFailure, + hasMeshcoreOpenHopPendingUserTx, isMeshcoreTcpBurstDeadBridge, - isMeshcoreTcpSoftApDeadAccepted, - MESHCORE_TCP_SOFTAP_BRIDGE_DIED_DURING_OP, + isMeshcoreTcpOpenHopDeadAccepted, + MESHCORE_TCP_OPENHOP_BRIDGE_DIED_DURING_OP, notifyMeshcoreTcpLiveForUserTx, notifyMeshcoreTcpWriteDead, rejectMeshcoreTcpLiveForUserTx, - runMeshcoreSoftApPendingUserTx, + runMeshcoreOpenHopPendingUserTx, runWithMeshcoreTcpDeadWriteRetry, - setMeshcoreSoftApPendingUserTx, - setMeshcoreTcpSoftApDeadAccepted, + setMeshcoreOpenHopPendingUserTx, + setMeshcoreTcpOpenHopDeadAccepted, setMeshcoreTcpWriteDeadListener, - settleSoftApPendingResult, + settleOpenHopPendingResult, shouldDeferMeshcoreTcpReconnectAfterBurst, - throwIfMeshcoreTcpBridgeDiedDuringSoftApOp, + throwIfMeshcoreTcpBridgeDiedDuringOpenHopOp, trackMeshcoreTcpUserTxSend, waitForMeshcoreTcpLiveForUserTx, yieldToMeshcoreTcpUserTxSends, @@ -124,23 +124,23 @@ describe('shouldDeferMeshcoreTcpReconnectAfterBurst', () => { }); }); -describe('meshcoreTcpSoftApDeadAccepted', () => { +describe('meshcoreTcpOpenHopDeadAccepted', () => { afterEach(() => { - setMeshcoreTcpSoftApDeadAccepted(false); + setMeshcoreTcpOpenHopDeadAccepted(false); }); it('defaults false and toggles', () => { - expect(isMeshcoreTcpSoftApDeadAccepted()).toBe(false); - setMeshcoreTcpSoftApDeadAccepted(true); - expect(isMeshcoreTcpSoftApDeadAccepted()).toBe(true); - setMeshcoreTcpSoftApDeadAccepted(false); - expect(isMeshcoreTcpSoftApDeadAccepted()).toBe(false); + expect(isMeshcoreTcpOpenHopDeadAccepted()).toBe(false); + setMeshcoreTcpOpenHopDeadAccepted(true); + expect(isMeshcoreTcpOpenHopDeadAccepted()).toBe(true); + setMeshcoreTcpOpenHopDeadAccepted(false); + expect(isMeshcoreTcpOpenHopDeadAccepted()).toBe(false); }); }); -describe('SoftAP user-TX live window', () => { +describe('OpenHop user-TX live window', () => { afterEach(() => { - setMeshcoreTcpSoftApDeadAccepted(false); + setMeshcoreTcpOpenHopDeadAccepted(false); rejectMeshcoreTcpLiveForUserTx(new Error('test cleanup')); }); @@ -162,7 +162,7 @@ describe('SoftAP user-TX live window', () => { expect(order).toEqual(['live', 'sent', 'after-yield']); }); - it('waits for nested ensureTcpLive→send track (SoftAP chat reopen race)', async () => { + it('waits for nested ensureTcpLive→send track (OpenHop chat reopen race)', async () => { const order: string[] = []; // Mirrors useSendMessage: await ensureTcpLive (wait), then another async hop, then track. const ensureTcpLive = waitForMeshcoreTcpLiveForUserTx(5_000); @@ -222,61 +222,61 @@ describe('runWithMeshcoreTcpDeadWriteRetry', () => { }); }); -describe('SoftAP pending user TX slot', () => { +describe('OpenHop pending user TX slot', () => { afterEach(() => { - clearMeshcoreSoftApPendingUserTx(); + clearMeshcoreOpenHopPendingUserTx(); }); - it('runs parked op as first SoftAP RPC and settles the result promise', async () => { + it('runs parked op as first OpenHop RPC and settles the result promise', async () => { const order: string[] = []; - const resultPromise = setMeshcoreSoftApPendingUserTx(() => { + const resultPromise = setMeshcoreOpenHopPendingUserTx(() => { order.push('op'); return Promise.resolve(42); }); - expect(hasMeshcoreSoftApPendingUserTx()).toBe(true); - const ran = await runMeshcoreSoftApPendingUserTx(); + expect(hasMeshcoreOpenHopPendingUserTx()).toBe(true); + const ran = await runMeshcoreOpenHopPendingUserTx(); expect(ran).toBe(true); await expect(resultPromise).resolves.toBe(42); expect(order).toEqual(['op']); - expect(hasMeshcoreSoftApPendingUserTx()).toBe(false); + expect(hasMeshcoreOpenHopPendingUserTx()).toBe(false); }); it('runs concurrent parked ops in FIFO order', async () => { const order: string[] = []; - const first = setMeshcoreSoftApPendingUserTx(() => { + const first = setMeshcoreOpenHopPendingUserTx(() => { order.push('a'); return Promise.resolve(1); }); - const second = setMeshcoreSoftApPendingUserTx(() => { + const second = setMeshcoreOpenHopPendingUserTx(() => { order.push('b'); return Promise.resolve(2); }); - expect(hasMeshcoreSoftApPendingUserTx()).toBe(true); - const ran = await runMeshcoreSoftApPendingUserTx(); + expect(hasMeshcoreOpenHopPendingUserTx()).toBe(true); + const ran = await runMeshcoreOpenHopPendingUserTx(); expect(ran).toBe(true); await expect(first).resolves.toBe(1); await expect(second).resolves.toBe(2); expect(order).toEqual(['a', 'b']); - expect(hasMeshcoreSoftApPendingUserTx()).toBe(false); + expect(hasMeshcoreOpenHopPendingUserTx()).toBe(false); }); it('clear rejects all parked TX that never ran', async () => { - const first = setMeshcoreSoftApPendingUserTx(() => Promise.resolve('never-a')); - const second = setMeshcoreSoftApPendingUserTx(() => Promise.resolve('never-b')); - clearMeshcoreSoftApPendingUserTx(new Error('aborted')); + const first = setMeshcoreOpenHopPendingUserTx(() => Promise.resolve('never-a')); + const second = setMeshcoreOpenHopPendingUserTx(() => Promise.resolve('never-b')); + clearMeshcoreOpenHopPendingUserTx(new Error('aborted')); await expect(first).rejects.toThrow('aborted'); await expect(second).rejects.toThrow('aborted'); - expect(hasMeshcoreSoftApPendingUserTx()).toBe(false); + expect(hasMeshcoreOpenHopPendingUserTx()).toBe(false); }); }); -describe('throwIfMeshcoreTcpBridgeDiedDuringSoftApOp', () => { +describe('throwIfMeshcoreTcpBridgeDiedDuringOpenHopOp', () => { it('throws a transport-dead error when the latch flips during the parked op', () => { expect(() => { - throwIfMeshcoreTcpBridgeDiedDuringSoftApOp(false, true); - }).toThrow(MESHCORE_TCP_SOFTAP_BRIDGE_DIED_DURING_OP); + throwIfMeshcoreTcpBridgeDiedDuringOpenHopOp(false, true); + }).toThrow(MESHCORE_TCP_OPENHOP_BRIDGE_DIED_DURING_OP); try { - throwIfMeshcoreTcpBridgeDiedDuringSoftApOp(false, true); + throwIfMeshcoreTcpBridgeDiedDuringOpenHopOp(false, true); } catch (e: unknown) { expect(isMeshcoreTcpTransportDeadError(e)).toBe(true); } @@ -284,21 +284,21 @@ describe('throwIfMeshcoreTcpBridgeDiedDuringSoftApOp', () => { it('is a no-op when the latch was already dead or stayed live', () => { expect(() => { - throwIfMeshcoreTcpBridgeDiedDuringSoftApOp(true, true); + throwIfMeshcoreTcpBridgeDiedDuringOpenHopOp(true, true); }).not.toThrow(); expect(() => { - throwIfMeshcoreTcpBridgeDiedDuringSoftApOp(false, false); + throwIfMeshcoreTcpBridgeDiedDuringOpenHopOp(false, false); }).not.toThrow(); expect(() => { - throwIfMeshcoreTcpBridgeDiedDuringSoftApOp(true, false); + throwIfMeshcoreTcpBridgeDiedDuringOpenHopOp(true, false); }).not.toThrow(); }); }); -describe('decideSoftApUserTxAfterEnsureFailure', () => { +describe('decideOpenHopUserTxAfterEnsureFailure', () => { it('returns the parked value when the op already fulfilled (late latch — no double-send)', () => { expect( - decideSoftApUserTxAfterEnsureFailure({ + decideOpenHopUserTxAfterEnsureFailure({ opSettlement: { status: 'fulfilled', value: 42 }, }), ).toEqual({ action: 'return', value: 42 }); @@ -306,10 +306,10 @@ describe('decideSoftApUserTxAfterEnsureFailure', () => { it('retries only when the parked op rejected with transport-dead', () => { expect( - decideSoftApUserTxAfterEnsureFailure({ + decideOpenHopUserTxAfterEnsureFailure({ opSettlement: { status: 'rejected', - reason: new Error(MESHCORE_TCP_SOFTAP_BRIDGE_DIED_DURING_OP), + reason: new Error(MESHCORE_TCP_OPENHOP_BRIDGE_DIED_DURING_OP), }, }), ).toEqual({ action: 'retry' }); @@ -318,16 +318,16 @@ describe('decideSoftApUserTxAfterEnsureFailure', () => { it('rethrows non-transport parked-op failures without retry', () => { const err = new Error('channel name too long'); expect( - decideSoftApUserTxAfterEnsureFailure({ + decideOpenHopUserTxAfterEnsureFailure({ opSettlement: { status: 'rejected', reason: err }, }), ).toEqual({ action: 'throw', error: err }); }); }); -describe('settleSoftApPendingResult', () => { +describe('settleOpenHopPendingResult', () => { it('reports fulfilled and rejected settlements', async () => { - await expect(settleSoftApPendingResult(Promise.resolve('ok'))).resolves.toEqual({ + await expect(settleOpenHopPendingResult(Promise.resolve('ok'))).resolves.toEqual({ status: 'fulfilled', value: 'ok', }); @@ -335,7 +335,7 @@ describe('settleSoftApPendingResult', () => { const rejected = Promise.reject(boom); // Attach early so vitest does not flag an unhandled rejection before settle. void rejected.catch(() => undefined); - await expect(settleSoftApPendingResult(rejected)).resolves.toEqual({ + await expect(settleOpenHopPendingResult(rejected)).resolves.toEqual({ status: 'rejected', reason: boom, }); diff --git a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts index 53cc612fe..25de6d47f 100644 --- a/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts +++ b/src/renderer/lib/meshcore/meshcoreTcpInitBurst.ts @@ -48,28 +48,28 @@ type MeshcoreTcpWriteDeadListener = () => void; let meshcoreTcpWriteDeadListener: MeshcoreTcpWriteDeadListener | null = null; /** - * SoftAP/OpenHop: peer FIN after contacts dump left a configured session with a dead bridge. + * OpenHop: peer FIN after contacts dump left a configured session with a dead bridge. * Background writes (flood advert, outbox) must not call handleConnectionLost — that reconnects, * companion FINs again after contacts, and loops forever. */ -let meshcoreTcpSoftApDeadAccepted = false; +let meshcoreTcpOpenHopDeadAccepted = false; -export function setMeshcoreTcpSoftApDeadAccepted(accepted: boolean): void { - meshcoreTcpSoftApDeadAccepted = accepted; +export function setMeshcoreTcpOpenHopDeadAccepted(accepted: boolean): void { + meshcoreTcpOpenHopDeadAccepted = accepted; } -export function isMeshcoreTcpSoftApDeadAccepted(): boolean { - return meshcoreTcpSoftApDeadAccepted; +export function isMeshcoreTcpOpenHopDeadAccepted(): boolean { + return meshcoreTcpOpenHopDeadAccepted; } -/** SoftAP user TX: wait for getSelfInfo live window before getContacts / peer FIN. */ +/** OpenHop user TX: wait for getSelfInfo live window before getContacts / peer FIN. */ export const MESHCORE_TCP_USER_TX_LIVE_TIMEOUT_MS = 20 * MS_PER_SECOND; /** - * SoftAP/OpenHop often FINs a reconnect that starts immediately after the prior session. + * OpenHop often FINs a reconnect that starts immediately after the prior session. * Match reconnect attempt-1 backoff so the companion accepts a new TCP live window for chat TX. */ -export const MESHCORE_TCP_SOFTAP_USER_TX_REOPEN_DELAY_MS = 2 * MS_PER_SECOND; +export const MESHCORE_TCP_OPENHOP_USER_TX_REOPEN_DELAY_MS = 2 * MS_PER_SECOND; interface TcpLiveWaiter { resolve: () => void; @@ -80,7 +80,7 @@ interface TcpLiveWaiter { let tcpLiveWaiters: TcpLiveWaiter[] = []; let inFlightUserTxSends: Promise[] = []; -/** Chat send waits here until initConn releases the SoftAP live window (post-getSelfInfo). */ +/** Chat send waits here until initConn releases the OpenHop live window (post-getSelfInfo). */ export function waitForMeshcoreTcpLiveForUserTx( timeoutMs: number = MESHCORE_TCP_USER_TX_LIVE_TIMEOUT_MS, ): Promise { @@ -105,7 +105,7 @@ export function waitForMeshcoreTcpLiveForUserTx( }); } -/** initConn: unblock SoftAP user-TX waiters while the TCP socket is still live. */ +/** initConn: unblock OpenHop user-TX waiters while the TCP socket is still live. */ export function notifyMeshcoreTcpLiveForUserTx(): void { const waiters = tcpLiveWaiters; tcpLiveWaiters = []; @@ -123,7 +123,7 @@ export function rejectMeshcoreTcpLiveForUserTx(err: Error): void { } } -/** Track an in-flight SoftAP user send so initConn can await it before getContacts. */ +/** Track an in-flight OpenHop user send so initConn can await it before getContacts. */ export function trackMeshcoreTcpUserTxSend(sendPromise: Promise): void { // Attach immediately so mockRejectedValue / sync rejects are not unhandled before await. void sendPromise.then( @@ -138,18 +138,18 @@ export function trackMeshcoreTcpUserTxSend(sendPromise: Promise): void /** * After notifying live waiters, yield microtasks then await any tracked user sends. - * SoftAP companions often FIN immediately after getContacts — send must finish first. + * OpenHop companions often FIN immediately after getContacts — send must finish first. * * Ordering vs `ensureTcpLiveForUserTx` / `useSendMessage`: * 1. initConn calls `notifyMeshcoreTcpLiveForUserTx()` (resolves waiters), * 2. then `yieldToMeshcoreTcpUserTxSends()`. * Waiters resume in `ensureTcpLiveForUserTx`, which returns into a nested `useSendMessage` * async IIFE that only then calls `trackMeshcoreTcpUserTxSend`. That is **three** microtask - * hops (notify → ensureTcpLive → useSendMessage), not two — SoftAP reopen used to snapshot + * hops (notify → ensureTcpLive → useSendMessage), not two — OpenHop reopen used to snapshot * an empty send list and start getContacts before track registered. */ export async function yieldToMeshcoreTcpUserTxSends(opts?: { - /** SoftAP user-TX reopen: wait briefly for a late-tracked send after the microtask hops. */ + /** OpenHop user-TX reopen: wait briefly for a late-tracked send after the microtask hops. */ waitForFirstSendMs?: number; }): Promise { await Promise.resolve(); @@ -186,7 +186,7 @@ export function notifyMeshcoreTcpWriteDead(): void { } /** - * SoftAP user TX: ensure live TCP, run op, retry once on dead-bridge write errors. + * OpenHop user TX: ensure live TCP, run op, retry once on dead-bridge write errors. * Non-transport failures are not retried. */ export async function runWithMeshcoreTcpDeadWriteRetry( @@ -206,19 +206,19 @@ export async function runWithMeshcoreTcpDeadWriteRetry( throw lastErr; } -interface SoftApPendingUserTx { +interface OpenHopPendingUserTx { run: () => Promise; reject: (reason?: unknown) => void; } -/** FIFO parked SoftAP user commands (concurrent sends share one quiet reopen). */ -const softApPendingUserTxQueue: SoftApPendingUserTx[] = []; +/** FIFO parked OpenHop user commands (concurrent sends share one quiet reopen). */ +const openHopPendingUserTxQueue: OpenHopPendingUserTx[] = []; /** - * Park a SoftAP user command so SoftAP `initConn` can run it (FIFO) as companion RPC(s) + * Park a OpenHop user command so OpenHop `initConn` can run it (FIFO) as companion RPC(s) * before getSelfInfo / contacts. Returns a promise that settles when that run completes. */ -export function setMeshcoreSoftApPendingUserTx(op: () => Promise): Promise { +export function setMeshcoreOpenHopPendingUserTx(op: () => Promise): Promise { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const resultPromise = new Promise((res, rej) => { @@ -230,7 +230,7 @@ export function setMeshcoreSoftApPendingUserTx(op: () => Promise): Promise () => undefined, () => undefined, ); - softApPendingUserTxQueue.push({ + openHopPendingUserTxQueue.push({ reject, run: async () => { try { @@ -245,11 +245,11 @@ export function setMeshcoreSoftApPendingUserTx(op: () => Promise): Promise return resultPromise; } -/** SoftAP initConn: run and clear all parked user TX in FIFO order (if any). */ -export async function runMeshcoreSoftApPendingUserTx(): Promise { +/** OpenHop initConn: run and clear all parked user TX in FIFO order (if any). */ +export async function runMeshcoreOpenHopPendingUserTx(): Promise { let ran = false; - while (softApPendingUserTxQueue.length > 0) { - const pending = softApPendingUserTxQueue.shift(); + while (openHopPendingUserTxQueue.length > 0) { + const pending = openHopPendingUserTxQueue.shift(); if (!pending) break; await pending.run(); ran = true; @@ -257,64 +257,64 @@ export async function runMeshcoreSoftApPendingUserTx(): Promise { return ran; } -/** Clear parked SoftAP TX that will never run (open aborted / ensure failed). */ -export function clearMeshcoreSoftApPendingUserTx(err?: Error): void { - const batch = softApPendingUserTxQueue.splice(0); +/** Clear parked OpenHop TX that will never run (open aborted / ensure failed). */ +export function clearMeshcoreOpenHopPendingUserTx(err?: Error): void { + const batch = openHopPendingUserTxQueue.splice(0); if (batch.length === 0) return; - const reason = err ?? new Error('MeshCore SoftAP pending TX cleared'); + const reason = err ?? new Error('MeshCore OpenHop pending TX cleared'); for (const pending of batch) { pending.reject(reason); } } -export function hasMeshcoreSoftApPendingUserTx(): boolean { - return softApPendingUserTxQueue.length > 0; +export function hasMeshcoreOpenHopPendingUserTx(): boolean { + return openHopPendingUserTxQueue.length > 0; } -/** Error message matching {@link isMeshcoreTcpTransportDeadError} for SoftAP latch-retry. */ -export const MESHCORE_TCP_SOFTAP_BRIDGE_DIED_DURING_OP = 'meshcore:tcp-write: no active socket'; +/** Error message matching {@link isMeshcoreTcpTransportDeadError} for OpenHop latch-retry. */ +export const MESHCORE_TCP_OPENHOP_BRIDGE_DIED_DURING_OP = 'meshcore:tcp-write: no active socket'; /** - * SoftAP first-RPC: if the write-dead latch flipped during the parked user op, throw a + * OpenHop first-RPC: if the write-dead latch flipped during the parked user op, throw a * transport-dead error so ensure's live wait rejects. {@link runMeshcoreUserTxWithLiveTcp} * must still return a fulfilled parked result (no re-run) — late latch after Ok must not * double-send chat. */ -export function throwIfMeshcoreTcpBridgeDiedDuringSoftApOp( +export function throwIfMeshcoreTcpBridgeDiedDuringOpenHopOp( bridgeDeadBefore: boolean, bridgeDeadAfter: boolean, ): void { if (bridgeDeadAfter && !bridgeDeadBefore) { - throw new Error(MESHCORE_TCP_SOFTAP_BRIDGE_DIED_DURING_OP); + throw new Error(MESHCORE_TCP_OPENHOP_BRIDGE_DIED_DURING_OP); } } -export type SoftApOpSettlement = +export type OpenHopOpSettlement = { status: 'fulfilled'; value: T } | { status: 'rejected'; reason: unknown }; -/** Settle a parked SoftAP result without throwing (for ensure-failure decision). */ -export async function settleSoftApPendingResult( +/** Settle a parked OpenHop result without throwing (for ensure-failure decision). */ +export async function settleOpenHopPendingResult( resultPromise: Promise, -): Promise> { +): Promise> { try { return { status: 'fulfilled', value: await resultPromise }; } catch (reason: unknown) { - // catch-no-log-ok settle helper returns rejected status to caller for SoftAP retry decision + // catch-no-log-ok settle helper returns rejected status to caller for OpenHop retry decision return { status: 'rejected', reason }; } } -export type SoftApEnsureFailureDecision = +export type OpenHopEnsureFailureDecision = { action: 'return'; value: T } | { action: 'retry' } | { action: 'throw'; error: unknown }; /** - * After SoftAP `ensureTcpLiveForUserTx` fails: never re-run a parked op that already + * After OpenHop `ensureTcpLiveForUserTx` fails: never re-run a parked op that already * completed (would double-send). Retry only when the op never succeeded and rejected * with a transport-dead error (including clear-with-ensure when ensure was transport-dead). */ -export function decideSoftApUserTxAfterEnsureFailure(opts: { - opSettlement: SoftApOpSettlement; -}): SoftApEnsureFailureDecision { +export function decideOpenHopUserTxAfterEnsureFailure(opts: { + opSettlement: OpenHopOpSettlement; +}): OpenHopEnsureFailureDecision { if (opts.opSettlement.status === 'fulfilled') { return { action: 'return', value: opts.opSettlement.value }; } diff --git a/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts b/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts index d4e57c73c..29afa9ace 100644 --- a/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts +++ b/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts @@ -3,8 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as meshcoreRepeaterRpcInFlight from './meshcoreRepeaterRpcInFlight'; import * as meshcoreTracePathMultiplex from './meshcoreTracePathMultiplex'; import { + abandonMeshcoreSilentBulkAttempt, + beginMeshcoreSilentBulkAttempt, isMeshcoreCompanionDrainDeferred, + isMeshcoreSilentBulkAttemptCurrent, isMeshcoreSyncNextMessageTimeoutError, + isMeshcoreWaitingMessagesBulkFallbackError, + isMeshcoreWaitingMessagesTransportDeadError, logMeshcoreWaitingMessagesDrainError, markMeshcoreCompanionTx, markMeshcoreMsgWaitingEvent, @@ -219,6 +224,40 @@ describe('isMeshcoreSyncNextMessageTimeoutError', () => { }); }); +describe('silent bulk error classifiers', () => { + it('treats tcp-write dead as transport-dead (no fallback)', () => { + expect( + isMeshcoreWaitingMessagesTransportDeadError( + new Error('meshcore:tcp-write: no active socket'), + ), + ).toBe(true); + expect( + isMeshcoreWaitingMessagesBulkFallbackError(new Error('meshcore:tcp-write: no active socket')), + ).toBe(false); + }); + + it('treats getWaitingMessages timeout as fallback-safe', () => { + expect( + isMeshcoreWaitingMessagesBulkFallbackError( + new Error('MeshCore getWaitingMessages timed out after 45000ms'), + ), + ).toBe(true); + expect( + isMeshcoreWaitingMessagesTransportDeadError( + new Error('MeshCore getWaitingMessages timed out after 45000ms'), + ), + ).toBe(false); + }); + + it('bumps silent bulk attempt id on abandon so late results are stale', () => { + resetMeshcoreWaitingMessagesDrainState(0); + const id = beginMeshcoreSilentBulkAttempt(); + expect(isMeshcoreSilentBulkAttemptCurrent(id)).toBe(true); + abandonMeshcoreSilentBulkAttempt(id); + expect(isMeshcoreSilentBulkAttemptCurrent(id)).toBe(false); + }); +}); + describe('shouldRunMeshcoreWaitingMessagesPeriodicPoll', () => { beforeEach(() => { resetMeshcoreWaitingMessagesDrainState(0); diff --git a/src/renderer/lib/meshcoreWaitingMessagesDrain.ts b/src/renderer/lib/meshcoreWaitingMessagesDrain.ts index 42b46722d..7eee3d0f2 100644 --- a/src/renderer/lib/meshcoreWaitingMessagesDrain.ts +++ b/src/renderer/lib/meshcoreWaitingMessagesDrain.ts @@ -1,3 +1,4 @@ +import { isMeshcoreTcpTransportDeadError } from '@/renderer/lib/bleConnectErrors'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { isMeshcoreFloodScopeOverrideActive } from './meshcoreFloodScopeSend'; @@ -16,6 +17,8 @@ import { let debounceTimer: ReturnType | null = null; let lastCompanionTxAt = 0; let lastMsgWaitingEventAt = 0; +/** Bumped when silent bulk is abandoned so a late getWaitingMessages resolve is ignored. */ +let silentBulkAttemptId = 0; /** Record outbound companion RF TX so auto-drains can defer until the radio settles. */ export function markMeshcoreCompanionTx(): void { @@ -30,6 +33,25 @@ export function resetMeshcoreWaitingMessagesDrainState(now = 0): void { } lastCompanionTxAt = now; lastMsgWaitingEventAt = now; + silentBulkAttemptId = 0; +} + +/** Start a silent bulk getWaitingMessages attempt; return id used by {@link isMeshcoreSilentBulkAttemptCurrent}. */ +export function beginMeshcoreSilentBulkAttempt(): number { + silentBulkAttemptId += 1; + return silentBulkAttemptId; +} + +/** Abandon the current silent bulk attempt (timeout/fallback) so late results are ignored. */ +export function abandonMeshcoreSilentBulkAttempt(attemptId: number): void { + if (attemptId === silentBulkAttemptId) { + silentBulkAttemptId += 1; + } +} + +/** True when `attemptId` is still the active silent bulk attempt. */ +export function isMeshcoreSilentBulkAttemptCurrent(attemptId: number): boolean { + return attemptId === silentBulkAttemptId; } /** Record MsgWaiting (event 131) so periodic safety-net polls can skip idle queues. */ @@ -52,6 +74,37 @@ export function isMeshcoreSyncNextMessageTimeoutError(error: unknown): boolean { return errMsg.includes('syncnextmessage') && errMsg.includes('timed out'); } +/** + * True when the companion link is already dead — silent drain must not start syncNextMessage + * fallback (reconnect / OpenHop dead-bridge paths own recovery). Never disconnects from here. + */ +export function isMeshcoreWaitingMessagesTransportDeadError(error: unknown): boolean { + if (isMeshcoreTcpTransportDeadError(error)) return true; + const msg = errLikeToLogString(error).toLowerCase(); + return ( + msg.includes('no active socket') || + msg.includes('gatt server is disconnected') || + msg.includes('device disconnected') || + msg.includes('not connected') + ); +} + +/** + * True when silent bulk getWaitingMessages failed in a way that is safe to fall back to + * syncNextMessage (timeout / transient). Transport-dead is never a fallback candidate. + */ +export function isMeshcoreWaitingMessagesBulkFallbackError(error: unknown): boolean { + if (isMeshcoreWaitingMessagesTransportDeadError(error)) return false; + const msg = errLikeToLogString(error).toLowerCase(); + return msg.includes('timed out') || msg.includes('timeout') || msg.includes('busy'); +} + +/** getWaitingMessages timeout label used by silent bulk withTimeout. */ +export function isMeshcoreGetWaitingMessagesTimeoutError(error: unknown): boolean { + const errMsg = errLikeToLogString(error).toLowerCase(); + return errMsg.includes('getwaitingmessages') && errMsg.includes('timed out'); +} + export function resetMeshcoreWaitingMessagesDrainSchedule(): void { if (debounceTimer) { clearTimeout(debounceTimer); diff --git a/src/renderer/lib/meshcoreWaitingMessagesStatusText.test.ts b/src/renderer/lib/meshcoreWaitingMessagesStatusText.test.ts index 8dead8d37..b04973bda 100644 --- a/src/renderer/lib/meshcoreWaitingMessagesStatusText.test.ts +++ b/src/renderer/lib/meshcoreWaitingMessagesStatusText.test.ts @@ -25,6 +25,9 @@ const t = ((key: string, opts?: Record) => { if (key === 'chatPanel.waitingMessagesQueued') { return `${opts?.count} queued`; } + if (key === 'chatPanel.waitingMessagesSilentFetched') { + return `Fetched ${opts?.processed}`; + } const labels: Record = { 'chatPanel.waitingMessagesSyncProgressIndeterminate': 'Syncing…', 'chatPanel.waitingMessagesSilentDrain': 'Silent drain', @@ -59,6 +62,35 @@ describe('meshcoreWaitingMessagesStatusText', () => { expect(meshcoreWaitingMessagesStatusText(t, input)).toBe('Sync 2/5'); }); + it('reports X/Y during silent bulk when progress has a total', () => { + const input: MeshcoreWaitingMessagesStatusInput = { + ...baseInput, + waitingMessagesSilentDrainActive: true, + waitingMessagesSyncProgress: { processed: 3, total: 10 }, + }; + expect(meshcoreWaitingMessagesStatusText(t, input)).toBe('Sync 3/10'); + }); + + it('reports processed-only during silent fallback', () => { + const input: MeshcoreWaitingMessagesStatusInput = { + ...baseInput, + waitingMessagesSilentDrainActive: true, + waitingMessagesSyncProgress: { processed: 7, total: 0 }, + connectionType: 'ble', + }; + expect(meshcoreWaitingMessagesStatusText(t, input)).toBe('Fetched 7'); + }); + + it('appends serial hint on silent fallback', () => { + const input: MeshcoreWaitingMessagesStatusInput = { + ...baseInput, + waitingMessagesSilentDrainActive: true, + waitingMessagesSyncProgress: { processed: 2, total: 0 }, + connectionType: 'serial', + }; + expect(meshcoreWaitingMessagesStatusText(t, input)).toBe('Fetched 2 (serial hint)'); + }); + it('appends serial hint during silent drain on serial transport', () => { const input: MeshcoreWaitingMessagesStatusInput = { ...baseInput, diff --git a/src/renderer/lib/meshcoreWaitingMessagesStatusText.ts b/src/renderer/lib/meshcoreWaitingMessagesStatusText.ts index cb139c963..cb7f0701e 100644 --- a/src/renderer/lib/meshcoreWaitingMessagesStatusText.ts +++ b/src/renderer/lib/meshcoreWaitingMessagesStatusText.ts @@ -43,13 +43,33 @@ export function meshcoreWaitingMessagesStatusText( const syncBusy = waitingMessagesSyncActive || waitingMessagesSilentDrainActive; if (syncBusy) { + if (waitingMessagesSyncProgress && waitingMessagesSyncProgress.total > 0) { + return appendSerialHint( + t, + t('chatPanel.waitingMessagesSyncProgress', { + processed: waitingMessagesSyncProgress.processed, + total: waitingMessagesSyncProgress.total, + }), + connectionType, + waitingMessagesSilentDrainActive && !waitingMessagesSyncActive, + ); + } + if ( + waitingMessagesSilentDrainActive && + !waitingMessagesSyncActive && + waitingMessagesSyncProgress?.total === 0 + ) { + return appendSerialHint( + t, + t('chatPanel.waitingMessagesSilentFetched', { + processed: waitingMessagesSyncProgress.processed, + }), + connectionType, + true, + ); + } const primary = waitingMessagesSyncActive - ? waitingMessagesSyncProgress && waitingMessagesSyncProgress.total > 0 - ? t('chatPanel.waitingMessagesSyncProgress', { - processed: waitingMessagesSyncProgress.processed, - total: waitingMessagesSyncProgress.total, - }) - : t('chatPanel.waitingMessagesSyncProgressIndeterminate') + ? t('chatPanel.waitingMessagesSyncProgressIndeterminate') : t('chatPanel.waitingMessagesSilentDrain'); return appendSerialHint( t, diff --git a/src/renderer/lib/protocolTransportParams.ts b/src/renderer/lib/protocolTransportParams.ts index e2ff32a32..11fda4e7f 100644 --- a/src/renderer/lib/protocolTransportParams.ts +++ b/src/renderer/lib/protocolTransportParams.ts @@ -17,7 +17,7 @@ export function protocolTransportParams( opts.type === 'http' || opts.type === 'tcp' ? (opts.httpAddress ?? 'localhost') : undefined; return meshcoreTransportParams(mcType, { peripheralId: opts.type === 'ble' ? opts.blePeripheralId : undefined, - // http and tcp UI types both carry SoftAP/companion host in httpAddress. + // http and tcp UI types both carry OpenHop/companion host in httpAddress. host: mcType === 'tcp' ? tcpHost : undefined, portSignature: opts.type === 'serial' ? (opts.lastSerialPortId ?? undefined) : undefined, }); diff --git a/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ts b/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ts index c6ba1c708..3869cbd25 100644 --- a/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ts +++ b/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ts @@ -13,7 +13,7 @@ import { isMeshcoreRetryableBleErrorMessage } from '../../bleConnectErrors'; import { connectNobleBleWithScanBusyRetry } from '../../bleReconnectHelper'; import { closeSerialPortIfOpen } from '../../connection'; import { - isMeshcoreTcpSoftApDeadAccepted, + isMeshcoreTcpOpenHopDeadAccepted, notifyMeshcoreTcpWriteDead, } from '../../meshcore/meshcoreTcpInitBurst'; import { patchMeshcoreCompanionTxEchoFilter } from '../../meshcoreCompanionTxEchoFilter'; @@ -158,9 +158,9 @@ class IpcTcpConnection { try { await window.electronAPI.meshcore.tcp.write(Array.from(bytes)); } catch (e) { - // SoftAP-accepted dead bridge: expected; keep noise at debug (stats/outbox thrash). - if (isMeshcoreTcpSoftApDeadAccepted()) { - console.debug('[IpcTcpConnection] write on SoftAP dead bridge', e); + // OpenHop-accepted dead bridge: expected; keep noise at debug (stats/outbox thrash). + if (isMeshcoreTcpOpenHopDeadAccepted()) { + console.debug('[IpcTcpConnection] write on OpenHop dead bridge', e); } else { console.error('[IpcTcpConnection] write error', e); } diff --git a/src/renderer/lib/sessions/meshcoreSession.ts b/src/renderer/lib/sessions/meshcoreSession.ts index 6e0ac8682..0b9c2acf4 100644 --- a/src/renderer/lib/sessions/meshcoreSession.ts +++ b/src/renderer/lib/sessions/meshcoreSession.ts @@ -28,12 +28,12 @@ export interface MeshcoreSessionApi { /** RF contact pubkey for DM send when nodeStore has not been hydrated yet. */ getDestinationPubKey?: (nodeId: number) => Uint8Array | undefined; /** - * SoftAP/OpenHop: when the TCP bridge was accepted dead after contacts FIN, reopen a live - * socket and resolve once the SoftAP user TX live window is ready (first-RPC path). + * OpenHop: when the TCP bridge was accepted dead after contacts FIN, reopen a live + * socket and resolve once the OpenHop user TX live window is ready (first-RPC path). */ ensureTcpLiveForUserTx?: () => Promise; /** - * SoftAP/dead-bridge user TX helper: SoftAP parks the op as the first companion RPC on + * OpenHop/dead-bridge user TX helper: OpenHop parks the op as the first companion RPC on * quiet reopen; mid-session dead bridge reconnects then runs the op. */ runMeshcoreUserTxWithLiveTcp?: (op: () => Promise) => Promise; diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 24ef7d8db..9af0c6ad3 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -676,7 +676,8 @@ "scanPaperHint": "Vložte nebo naskenujte papírový QR kód pro dešifrování do chatu.", "shareAsPaperMessageLabel": "Zpráva k zašifrování", "shareAsPaperGenerate": "Vytvořit papírové QR", - "shareAsPaperCopyFailed": "Nelze zkopírovat papírový odkaz" + "shareAsPaperCopyFailed": "Nelze zkopírovat papírový odkaz", + "waitingMessagesSilentFetched": "Načteno {{processed}} z rádia…" }, "chatPayload": { "mention": "Zmínit {{label}}", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 4217cc377..3e90d4dbe 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -674,7 +674,8 @@ "scanPaperHint": "Fügen Sie einen Papier-QR ein oder scannen Sie ihn, um ihn in den Chat zu entschlüsseln.", "shareAsPaperMessageLabel": "Nachricht zum Verschlüsseln", "shareAsPaperGenerate": "Papier-QR erstellen", - "shareAsPaperCopyFailed": "Papierlink konnte nicht kopiert werden" + "shareAsPaperCopyFailed": "Papierlink konnte nicht kopiert werden", + "waitingMessagesSilentFetched": "{{processed}} vom Funkgerät abgerufen…" }, "chatPayload": { "mention": "Erwähne {{label}}", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 60c06b1a6..858628be4 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -473,6 +473,7 @@ "waitingMessagesSyncProgressIndeterminate": "Syncing queued messages from radio…", "waitingMessagesSyncNow": "Sync now", "waitingMessagesSilentDrain": "Fetching messages queued on the radio…", + "waitingMessagesSilentFetched": "Fetched {{processed}} from radio…", "waitingMessagesDrainDeferred": "Message sync paused while the radio is busy (admin/trace)…", "waitingMessagesSerialHint": "USB serial handles one command at a time; messages may arrive in small batches.", "waitingMessagesSyncFailed": "Failed to sync queued messages: {{message}}", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 513da7b75..11569089c 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -674,7 +674,8 @@ "scanPaperHint": "Pegue o escanee un QR en papel para descifrar en el chat.", "shareAsPaperMessageLabel": "Mensaje a cifrar", "shareAsPaperGenerate": "Crear QR en papel", - "shareAsPaperCopyFailed": "No se ha podido copiar el enlace en papel" + "shareAsPaperCopyFailed": "No se ha podido copiar el enlace en papel", + "waitingMessagesSilentFetched": "Obtenido {{processed}} de la radio..." }, "chatPayload": { "mention": "Mencionar {{label}}", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index cce5d24c7..466e33b16 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -674,7 +674,8 @@ "scanPaperHint": "Collez ou numérisez un QR papier pour le décrypter dans le Chat.", "shareAsPaperMessageLabel": "Message à crypter", "shareAsPaperGenerate": "Créer un QR papier", - "shareAsPaperCopyFailed": "Impossible de copier le lien papier" + "shareAsPaperCopyFailed": "Impossible de copier le lien papier", + "waitingMessagesSilentFetched": "Récupéré {{processed}} de la radio…" }, "chatPayload": { "mention": "Mention {{label}}", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index db50cb743..5c314a43f 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -674,7 +674,8 @@ "scanPaperHint": "Tempel atau pindai QR kertas untuk mendekripsi ke Chat.", "shareAsPaperMessageLabel": "Pesan untuk dienkripsi", "shareAsPaperGenerate": "Buat QR kertas", - "shareAsPaperCopyFailed": "Tidak dapat menyalin tautan kertas" + "shareAsPaperCopyFailed": "Tidak dapat menyalin tautan kertas", + "waitingMessagesSilentFetched": "Mengambil {{processed}} dari radio…" }, "chatPayload": { "mention": "Sebutkan {{label}}", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 1bd13dcce..9b645e9ab 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -674,7 +674,8 @@ "scanPaperHint": "Incolla o scansiona un QR cartaceo per decifrarlo in Chat.", "shareAsPaperMessageLabel": "Messaggio da crittografare", "shareAsPaperGenerate": "Crea QR cartaceo", - "shareAsPaperCopyFailed": "Impossibile copiare il link cartaceo" + "shareAsPaperCopyFailed": "Impossibile copiare il link cartaceo", + "waitingMessagesSilentFetched": "Recuperato {{processed}} dalla radio..." }, "chatPayload": { "mention": "Menziona {{label}}", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index b2fdde6fc..f97c495ee 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -674,7 +674,8 @@ "scanPaperHint": "紙のQRを貼り付けるかスキャンして、チャットに復号します。", "shareAsPaperMessageLabel": "暗号化するメッセージ", "shareAsPaperGenerate": "紙のQRを作成する", - "shareAsPaperCopyFailed": "用紙リンクをコピーできませんでした" + "shareAsPaperCopyFailed": "用紙リンクをコピーできませんでした", + "waitingMessagesSilentFetched": "ラジオから{{processed}}を取得しました…" }, "chatPayload": { "mention": "{{label}} について言及してください", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index c7a4f4cb0..7c8cf9d74 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -674,7 +674,8 @@ "scanPaperHint": "용지 QR을 붙여넣거나 스캔하여 채팅에 암호를 해독합니다.", "shareAsPaperMessageLabel": "암호화할 메시지", "shareAsPaperGenerate": "용지 QR 생성", - "shareAsPaperCopyFailed": "용지 링크를 복사할 수 없습니다" + "shareAsPaperCopyFailed": "용지 링크를 복사할 수 없습니다", + "waitingMessagesSilentFetched": "라디오에서 {{processed}} 을 (를) 가져왔습니다..." }, "chatPayload": { "mention": "{{label}}을(를) 언급하세요", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 62d89dff8..88404c077 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -674,7 +674,8 @@ "scanPaperHint": "Plak of scan een papieren QR-code om te ontsleutelen in Chat.", "shareAsPaperMessageLabel": "Bericht om te versleutelen", "shareAsPaperGenerate": "Maak papieren QR", - "shareAsPaperCopyFailed": "Kon papieren link niet kopiëren" + "shareAsPaperCopyFailed": "Kon papieren link niet kopiëren", + "waitingMessagesSilentFetched": "{{processed}} van de radio gehaald..." }, "chatPayload": { "mention": "Vermeld {{label}}", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 3f5e99973..e7d626df7 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -678,7 +678,8 @@ "scanPaperHint": "Wklej lub zeskanuj papierowy kod QR, aby odszyfrować go na czacie.", "shareAsPaperMessageLabel": "Wiadomość do zaszyfrowania", "shareAsPaperGenerate": "Utwórz papierowy QR", - "shareAsPaperCopyFailed": "Nie można skopiować papierowego linku" + "shareAsPaperCopyFailed": "Nie można skopiować papierowego linku", + "waitingMessagesSilentFetched": "Pobrano {{processed}} z radia…" }, "chatPayload": { "mention": "Wspomnij o {{label}}", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 2f7cd68cd..3f4ae36b7 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -674,7 +674,8 @@ "scanPaperHint": "Cole ou digitalize um QR de papel para descriptografar no Chat.", "shareAsPaperMessageLabel": "Mensagem para encriptar", "shareAsPaperGenerate": "Criar QR de papel", - "shareAsPaperCopyFailed": "Não foi possível copiar o link do papel" + "shareAsPaperCopyFailed": "Não foi possível copiar o link do papel", + "waitingMessagesSilentFetched": "Buscou {{processed}} no rádio..." }, "chatPayload": { "mention": "Mencionar {{label}}", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 986a3e12e..2fdeffcbb 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -676,7 +676,8 @@ "scanPaperHint": "Вставьте или отсканируйте бумажный QR-код, чтобы расшифровать его в чате.", "shareAsPaperMessageLabel": "Сообщение для шифрования", "shareAsPaperGenerate": "Создать бумажный QR-код", - "shareAsPaperCopyFailed": "Не удалось скопировать ссылку на бумагу" + "shareAsPaperCopyFailed": "Не удалось скопировать ссылку на бумагу", + "waitingMessagesSilentFetched": "Получено {{processed}} из радио…" }, "chatPayload": { "mention": "Упоминание {{label}}", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index b84a71d3e..de2d1dfd4 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -674,7 +674,8 @@ "scanPaperHint": "Sohbetin şifresini çözmek için basılı bir QR'yi yapıştırın veya tarayın.", "shareAsPaperMessageLabel": "Şifrelenecek mesaj", "shareAsPaperGenerate": "Kağıt QR oluştur", - "shareAsPaperCopyFailed": "Kağıt bağlantısı kopyalanamadı" + "shareAsPaperCopyFailed": "Kağıt bağlantısı kopyalanamadı", + "waitingMessagesSilentFetched": "Radyodan {{processed}} alındı…" }, "chatPayload": { "mention": "{{label}}'dan bahsedin", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 591d6f8cb..e94c63bb7 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -676,7 +676,8 @@ "scanPaperHint": "Вставте або відскануйте паперовий QR-код, щоб розшифрувати його в чаті.", "shareAsPaperMessageLabel": "Повідомлення для шифрування", "shareAsPaperGenerate": "Створити паперовий QR-код", - "shareAsPaperCopyFailed": "Не вдалося скопіювати посилання на папір" + "shareAsPaperCopyFailed": "Не вдалося скопіювати посилання на папір", + "waitingMessagesSilentFetched": "Отримано {{processed}} з радіо…" }, "chatPayload": { "mention": "Згадайте {{label}}", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index e2e5be3ee..583077adf 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -674,7 +674,8 @@ "scanPaperHint": "粘贴或扫描纸质二维码以解密到聊天中。", "shareAsPaperMessageLabel": "要加密的消息", "shareAsPaperGenerate": "创建纸质二维码", - "shareAsPaperCopyFailed": "无法复制纸质链接" + "shareAsPaperCopyFailed": "无法复制纸质链接", + "waitingMessagesSilentFetched": "已从收音机获取{{processed}} …" }, "chatPayload": { "mention": "提及{{label}}", diff --git a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts index 13956bf75..0c04bfd53 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts @@ -299,31 +299,33 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { 'TCP write-dead after init burst — latch bridge dead, defer reconnect', ); expect(RUNTIME_SOURCE).toContain('setMeshcoreTcpWriteDeadListener'); - expect(RUNTIME_SOURCE).toContain('setMeshcoreTcpSoftApDeadAccepted'); + expect(RUNTIME_SOURCE).toContain('setMeshcoreTcpOpenHopDeadAccepted'); expect(RUNTIME_SOURCE).toContain( - 'TCP write-dead on SoftAP-accepted dead bridge — keep configured', + 'TCP write-dead on OpenHop-accepted dead bridge — keep configured', ); expect(RUNTIME_SOURCE).toContain('ensureTcpLiveForUserTx'); expect(RUNTIME_SOURCE).toContain('notifyMeshcoreTcpLiveForUserTx'); expect(RUNTIME_SOURCE).toContain('yieldToMeshcoreTcpUserTxSends'); - // SoftAP chat send must quiet-reopen via connect() — not connection-lost (disconnect UI). - expect(RUNTIME_SOURCE).toContain('SoftAP user TX — quiet TCP reopen (no connection-lost)'); - expect(RUNTIME_SOURCE).toContain('meshcoreSoftApUserTxReopenInFlightRef'); - expect(RUNTIME_SOURCE).toContain('meshcoreConnectForSoftApTxRef'); + // OpenHop chat send must quiet-reopen via connect() — not connection-lost (disconnect UI). + expect(RUNTIME_SOURCE).toContain('OpenHop user TX — quiet TCP reopen (no connection-lost)'); + expect(RUNTIME_SOURCE).toContain('meshcoreOpenHopUserTxReopenInFlightRef'); + expect(RUNTIME_SOURCE).toContain('meshcoreConnectForOpenHopTxRef'); expect(RUNTIME_SOURCE).toMatch( - /useLayoutEffect\(\(\) => \{\s*meshcoreConnectForSoftApTxRef\.current = connect;/, + /useLayoutEffect\(\(\) => \{\s*meshcoreConnectForOpenHopTxRef\.current = connect;/, ); - expect(RUNTIME_SOURCE).toContain('MESHCORE_TCP_SOFTAP_USER_TX_REOPEN_DELAY_MS'); + expect(RUNTIME_SOURCE).toContain('MESHCORE_TCP_OPENHOP_USER_TX_REOPEN_DELAY_MS'); expect(RUNTIME_SOURCE).toContain( - 'SoftAP user-TX reopen failed — restore SoftAP-accepted configured', + 'OpenHop user-TX reopen failed — restore OpenHop-accepted configured', + ); + expect(RUNTIME_SOURCE).toContain('OpenHop user-TX reopen — skip contacts dump after live send'); + expect(RUNTIME_SOURCE).toContain( + 'TCP closed on OpenHop-accepted dead bridge — keep configured', ); - expect(RUNTIME_SOURCE).toContain('SoftAP user-TX reopen — skip contacts dump after live send'); - expect(RUNTIME_SOURCE).toContain('TCP closed on SoftAP-accepted dead bridge — keep configured'); expect(RUNTIME_SOURCE).toMatch( /shouldDeferMeshcoreTcpReconnectAfterBurst\(\{[\s\S]*?burstCaptured:[\s\S]*?everConfigured:[\s\S]*?deviceConfigured:[\s\S]*?\}\)[\s\S]*?meshcoreDeferredReconnectRef\.current = true;[\s\S]*?return;[\s\S]*?handleMeshcoreConnectionLostRef\.current\(\)/, ); - // SoftAP: accept dead bridge without immediate reconnect (avoid FIN-after-contacts loop). - // SoftAP-accepted suppresses background write-dead → lost; mid-session death still reconnects. + // OpenHop: accept dead bridge without immediate reconnect (avoid FIN-after-contacts loop). + // OpenHop-accepted suppresses background write-dead → lost; mid-session death still reconnects. expect(RUNTIME_SOURCE).toContain('TCP burst-complete configure — accepting dead bridge'); expect(RUNTIME_SOURCE).toContain( 'TCP burst-complete reconnect attach — accepting dead bridge (configured)', @@ -337,7 +339,7 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { ); }); - it('registers runtime connect on MeshcoreSessionApi for UI Connect path (Neal SoftAP)', () => { + it('registers runtime connect on MeshcoreSessionApi for UI Connect path (Neal OpenHop)', () => { // Manual Connect must use session.connect → runtime connect so TCP burst-complete // deferred reconnect and connectionParams latch run (useProtocolConnect must not // reassemble prepare/driver/attach alone — #792 params gate + burst-complete). @@ -383,60 +385,63 @@ describe('useMeshcoreRuntime auto-reconnect (regression)', () => { expect(RUNTIME_SOURCE).toMatch( /takeMeshcoreDiscoverSelfCache\(conn\)[\s\S]*?conn\.getSelfInfo\(5000\)/, ); - // SoftAP user-TX reopen: first companion RPC is the parked user command (skip getSelfInfo). - expect(RUNTIME_SOURCE).toContain('SoftAP user-TX reopen — first-RPC path (skip getSelfInfo)'); - expect(RUNTIME_SOURCE).toContain('runMeshcoreSoftApPendingUserTx'); - expect(RUNTIME_SOURCE).toContain('skipDiscoverSelf: meshcoreSoftApUserTxReopenInFlightRef'); - expect(RUNTIME_SOURCE).not.toContain('SoftAP user-TX fresh'); - expect(RUNTIME_SOURCE).not.toContain('SoftAP user-TX reopen — bridge dead before live window'); - // Late SoftAP post-TX getChannels latched write-dead after Ok and skipped SoftAP retry. - expect(RUNTIME_SOURCE).not.toContain('SoftAP post-TX getChannels'); - expect(RUNTIME_SOURCE).toContain('throwIfMeshcoreTcpBridgeDiedDuringSoftApOp'); - expect(RUNTIME_SOURCE).toContain('setMeshcoreSoftApPendingUserTx'); + // OpenHop user-TX reopen: first companion RPC is the parked user command (skip getSelfInfo). + expect(RUNTIME_SOURCE).toContain('OpenHop user-TX reopen — first-RPC path (skip getSelfInfo)'); + expect(RUNTIME_SOURCE).toContain('runMeshcoreOpenHopPendingUserTx'); + expect(RUNTIME_SOURCE).toContain('skipDiscoverSelf: meshcoreOpenHopUserTxReopenInFlightRef'); + expect(RUNTIME_SOURCE).not.toContain('OpenHop user-TX fresh'); + expect(RUNTIME_SOURCE).not.toContain('OpenHop user-TX reopen — bridge dead before live window'); + // Late OpenHop post-TX getChannels latched write-dead after Ok and skipped OpenHop retry. + expect(RUNTIME_SOURCE).not.toContain('OpenHop post-TX getChannels'); + expect(RUNTIME_SOURCE).toContain('throwIfMeshcoreTcpBridgeDiedDuringOpenHopOp'); + expect(RUNTIME_SOURCE).toContain('setMeshcoreOpenHopPendingUserTx'); expect(RUNTIME_SOURCE).toContain('runMeshcoreUserTxWithLiveTcp'); expect(RUNTIME_SOURCE).toMatch( /runMeshcoreUserTxWithLiveTcp\(async \(\) => \{[\s\S]*?sendChannelTextMessage/, ); - // SoftAP retry must re-latch accepted so attempt 2 stays on quiet reopen. - expect(RUNTIME_SOURCE).toContain('setMeshcoreTcpSoftApDeadAccepted(true)'); + // OpenHop retry must re-latch accepted so attempt 2 stays on quiet reopen. + expect(RUNTIME_SOURCE).toContain('setMeshcoreTcpOpenHopDeadAccepted(true)'); // Late latch after Ok: return fulfilled parked result — do not re-park (double-send). - expect(RUNTIME_SOURCE).toContain('decideSoftApUserTxAfterEnsureFailure'); - expect(RUNTIME_SOURCE).toContain('settleSoftApPendingResult'); + expect(RUNTIME_SOURCE).toContain('decideOpenHopUserTxAfterEnsureFailure'); + expect(RUNTIME_SOURCE).toContain('settleOpenHopPendingResult'); expect(RUNTIME_SOURCE).toMatch( - /decideSoftApUserTxAfterEnsureFailure\([\s\S]*?decision\.action === 'return'[\s\S]*?return decision\.value/, + /decideOpenHopUserTxAfterEnsureFailure\([\s\S]*?decision\.action === 'return'[\s\S]*?return decision\.value/, ); - const softApFirstRpcIdx = RUNTIME_SOURCE.indexOf( - 'SoftAP user-TX reopen — first-RPC path (skip getSelfInfo)', + const openHopFirstRpcIdx = RUNTIME_SOURCE.indexOf( + 'OpenHop user-TX reopen — first-RPC path (skip getSelfInfo)', ); - expect(softApFirstRpcIdx).toBeGreaterThan(-1); + expect(openHopFirstRpcIdx).toBeGreaterThan(-1); const pendingIdx = RUNTIME_SOURCE.indexOf( - 'runMeshcoreSoftApPendingUserTx()', - softApFirstRpcIdx, + 'runMeshcoreOpenHopPendingUserTx()', + openHopFirstRpcIdx, ); const latchIdx = RUNTIME_SOURCE.indexOf( - 'throwIfMeshcoreTcpBridgeDiedDuringSoftApOp', - softApFirstRpcIdx, + 'throwIfMeshcoreTcpBridgeDiedDuringOpenHopOp', + openHopFirstRpcIdx, + ); + const notifyIdx = RUNTIME_SOURCE.indexOf( + 'notifyMeshcoreTcpLiveForUserTx()', + openHopFirstRpcIdx, ); - const notifyIdx = RUNTIME_SOURCE.indexOf('notifyMeshcoreTcpLiveForUserTx()', softApFirstRpcIdx); - const softApAcceptIdx = RUNTIME_SOURCE.indexOf( - 'setMeshcoreTcpSoftApDeadAccepted(true)', - softApFirstRpcIdx, + const openHopAcceptIdx = RUNTIME_SOURCE.indexOf( + 'setMeshcoreTcpOpenHopDeadAccepted(true)', + openHopFirstRpcIdx, ); - const disconnectIdx = RUNTIME_SOURCE.indexOf('meshcore.tcp.disconnect()', softApFirstRpcIdx); - expect(pendingIdx).toBeGreaterThan(softApFirstRpcIdx); + const disconnectIdx = RUNTIME_SOURCE.indexOf('meshcore.tcp.disconnect()', openHopFirstRpcIdx); + expect(pendingIdx).toBeGreaterThan(openHopFirstRpcIdx); expect(latchIdx).toBeGreaterThan(pendingIdx); expect(notifyIdx).toBeGreaterThan(latchIdx); - // SoftAP-accept + configured before intentional disconnect (quiet teardown). - expect(softApAcceptIdx).toBeGreaterThan(notifyIdx); - expect(disconnectIdx).toBeGreaterThan(softApAcceptIdx); - expect(RUNTIME_SOURCE).toContain('TCP closed during SoftAP user-TX reopen — keep configured'); - // SoftAP first-RPC must not dip status to connected (header flicker). - const softApBlock = RUNTIME_SOURCE.slice( - softApFirstRpcIdx, - RUNTIME_SOURCE.indexOf('// Show persisted contacts immediately', softApFirstRpcIdx), - ); - expect(softApBlock).not.toMatch(/status:\s*'connected'/); - expect(softApBlock).toMatch(/status:\s*'configured'/); + // OpenHop-accept + configured before intentional disconnect (quiet teardown). + expect(openHopAcceptIdx).toBeGreaterThan(notifyIdx); + expect(disconnectIdx).toBeGreaterThan(openHopAcceptIdx); + expect(RUNTIME_SOURCE).toContain('TCP closed during OpenHop user-TX reopen — keep configured'); + // OpenHop first-RPC must not dip status to connected (header flicker). + const openHopBlock = RUNTIME_SOURCE.slice( + openHopFirstRpcIdx, + RUNTIME_SOURCE.indexOf('// Show persisted contacts immediately', openHopFirstRpcIdx), + ); + expect(openHopBlock).not.toMatch(/status:\s*'connected'/); + expect(openHopBlock).toMatch(/status:\s*'configured'/); }); }); @@ -638,8 +643,8 @@ describe('meshcoreConnSideEffects disconnected handler (regression)', () => { ); }); - it('skips TCP device_status disconnect teardown (runtime owns SoftAP bridge recovery)', () => { - // SoftAP FIN emits device_status via TcpOverIpc; tearing down the driver here left + it('skips TCP device_status disconnect teardown (runtime owns OpenHop bridge recovery)', () => { + // OpenHop FIN emits device_status via TcpOverIpc; tearing down the driver here left // "accepting dead bridge" with no handle and no scheduled reconnect. expect(CONN_EVENTS_SOURCE).toMatch(/meshcoreConnectTypeRef\.current === 'tcp'[\s\S]*?return;/); }); @@ -691,15 +696,15 @@ describe('useMeshcoreRuntime prepareRfConnect driver teardown (regression)', () expect(prepareBody).toContain('meshcorePendingDriverIdentityRef.current'); }); - it('clears SoftAP dead-bridge latch on every prepareRfConnect (not TCP-only)', () => { + it('clears OpenHop dead-bridge latch on every prepareRfConnect (not TCP-only)', () => { const prepareBody = extractUseCallbackBody(RUNTIME_SOURCE, 'prepareRfConnect'); - const softApClearIdx = prepareBody.indexOf('setMeshcoreTcpSoftApDeadAccepted(false)'); + const openHopClearIdx = prepareBody.indexOf('setMeshcoreTcpOpenHopDeadAccepted(false)'); const bridgeClearIdx = prepareBody.indexOf('meshcoreTcpBridgeDeadRef.current = false'); const tcpGuardIdx = prepareBody.indexOf("if (type === 'tcp')"); - expect(softApClearIdx).toBeGreaterThan(-1); + expect(openHopClearIdx).toBeGreaterThan(-1); expect(bridgeClearIdx).toBeGreaterThan(-1); expect(tcpGuardIdx).toBeGreaterThan(-1); - expect(softApClearIdx).toBeLessThan(tcpGuardIdx); + expect(openHopClearIdx).toBeLessThan(tcpGuardIdx); expect(bridgeClearIdx).toBeLessThan(tcpGuardIdx); }); diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index a956ac844..717236dad 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -174,21 +174,21 @@ import { } from '../lib/meshcore/meshcorePubKeyRegistry'; import { attachMeshcoreSerialTransportLossWatch } from '../lib/meshcore/meshcoreSerialTransportLoss'; import { - clearMeshcoreSoftApPendingUserTx, - decideSoftApUserTxAfterEnsureFailure, + clearMeshcoreOpenHopPendingUserTx, + decideOpenHopUserTxAfterEnsureFailure, isMeshcoreTcpBurstDeadBridge, - isMeshcoreTcpSoftApDeadAccepted, - MESHCORE_TCP_SOFTAP_USER_TX_REOPEN_DELAY_MS, + isMeshcoreTcpOpenHopDeadAccepted, + MESHCORE_TCP_OPENHOP_USER_TX_REOPEN_DELAY_MS, notifyMeshcoreTcpLiveForUserTx, rejectMeshcoreTcpLiveForUserTx, - runMeshcoreSoftApPendingUserTx, + runMeshcoreOpenHopPendingUserTx, runWithMeshcoreTcpDeadWriteRetry, - setMeshcoreSoftApPendingUserTx, - setMeshcoreTcpSoftApDeadAccepted, + setMeshcoreOpenHopPendingUserTx, + setMeshcoreTcpOpenHopDeadAccepted, setMeshcoreTcpWriteDeadListener, - settleSoftApPendingResult, + settleOpenHopPendingResult, shouldDeferMeshcoreTcpReconnectAfterBurst, - throwIfMeshcoreTcpBridgeDiedDuringSoftApOp, + throwIfMeshcoreTcpBridgeDiedDuringOpenHopOp, trackMeshcoreTcpUserTxSend, waitForMeshcoreTcpLiveForUserTx, yieldToMeshcoreTcpUserTxSends, @@ -435,6 +435,8 @@ import { MESHCORE_ROOM_SYNC_TICK_MS, MESHCORE_STATS_POLL_MS, MESHCORE_TRACE_PING_TOTAL_TIMEOUT_MS, + MESHCORE_WAITING_MESSAGES_AFTER_TX_DEFER_MS, + MESHCORE_WAITING_MESSAGES_DRAIN_DEBOUNCE_MS, MESHCORE_WAITING_MESSAGES_POLL_MS, NOBLE_BLE_RECONNECT_ATTEMPT_BUDGET_MS, POWER_RESUME_MESHCORE_MESHTASTIC_SETTLE_MS, @@ -589,6 +591,8 @@ export function useMeshcoreRuntime() { } | null>(null); const [waitingMessagesSilentDrainActive, setWaitingMessagesSilentDrainActive] = useState(false); const [waitingMessagesDrainDeferred, setWaitingMessagesDrainDeferred] = useState(false); + /** True while silent or manual waiting-message drain holds the companion RPC lane. */ + const waitingMessagesDrainBusyRef = useRef(false); const mqttStatusRef = useRef('disconnected'); const connRef = useRef(null); @@ -623,7 +627,7 @@ export function useMeshcoreRuntime() { /** * Set when main emits meshcore:tcp-disconnected (or write fail-closed). Cleared on prepareRfConnect * for a new TCP open. Lets initConn abort before contacts→UI / getChannels even if the IPC - * event arrives a tick before setup-generation bump is observed (Fuzzy SoftAP write storms). + * event arrives a tick before setup-generation bump is observed (Fuzzy OpenHop write storms). */ const meshcoreTcpBridgeDeadRef = useRef(false); /** @@ -638,10 +642,10 @@ export function useMeshcoreRuntime() { */ const meshcoreTcpContactsDumpInFlightRef = useRef(false); /** - * SoftAP/OpenHop user TX: true while `ensureTcpLiveForUserTx` has started a quiet `connect()` + * OpenHop user TX: true while `ensureTcpLiveForUserTx` has started a quiet `connect()` * reopen (not handleMeshcoreConnectionLost). Concurrent sends await the same live window. */ - const meshcoreSoftApUserTxReopenInFlightRef = useRef(false); + const meshcoreOpenHopUserTxReopenInFlightRef = useRef(false); /** * True for the duration of `initConn`. After configure-before-dump, peer FIN once the contacts * burst is held must defer reconnect (not bump setup gen) until init finishes. @@ -780,12 +784,12 @@ export function useMeshcoreRuntime() { /** Fetch and update local radio stats (core, radio, packet). Called by requestRefresh and on connect. */ const fetchAndUpdateLocalStats = useCallback(async () => { - // SoftAP/OpenHop accepted dead bridge — companion RPCs only reopen on user TX. - if (isMeshcoreTcpSoftApDeadAccepted()) return; + // OpenHop accepted dead bridge — companion RPCs only reopen on user TX. + if (isMeshcoreTcpOpenHopDeadAccepted()) return; const conn = connRef.current; if (!conn) return; - // SoftAP/OpenHop: peer FIN left a dead bridge — stats RPCs only spam tcp-write errors. - if (isMeshcoreTcpSoftApDeadAccepted() || meshcoreTcpBridgeDeadRef.current) { + // OpenHop: peer FIN left a dead bridge — stats RPCs only spam tcp-write errors. + if (isMeshcoreTcpOpenHopDeadAccepted() || meshcoreTcpBridgeDeadRef.current) { return; } let coreStats: Awaited>; @@ -987,6 +991,11 @@ export function useMeshcoreRuntime() { waitingMessagesCountRef.current = waitingMessagesCount; }, [waitingMessagesCount]); + useEffect(() => { + waitingMessagesDrainBusyRef.current = + waitingMessagesSyncActive || waitingMessagesSilentDrainActive; + }, [waitingMessagesSyncActive, waitingMessagesSilentDrainActive]); + useEffect(() => { rawPacketsRef.current = rawPackets; }, [rawPackets]); @@ -1002,7 +1011,7 @@ export function useMeshcoreRuntime() { meshcoreStatsPollRef.current = setInterval(() => { if (!meshcoreHookMountedRef.current) return; if (meshcoreInitConnInFlightRef.current) return; - if (isMeshcoreTcpSoftApDeadAccepted()) return; + if (isMeshcoreTcpOpenHopDeadAccepted()) return; void fetchAndUpdateLocalStats().catch((e: unknown) => { console.warn('[useMeshcoreRuntime] periodic stats poll failed ' + errLikeToLogString(e)); }); @@ -2202,17 +2211,17 @@ export function useMeshcoreRuntime() { })(); } - // SoftAP user-TX reopen: skip getSelfInfo / contacts — run parked user command as the + // OpenHop user-TX reopen: skip getSelfInfo / contacts — run parked user command as the // first companion RPC (peer FINs ~160ms after self-info; notify→setChannel always loses). - const softApUserTxReopen = meshcoreSoftApUserTxReopenInFlightRef.current; - if (softApUserTxReopen) { + const openHopUserTxReopen = meshcoreOpenHopUserTxReopenInFlightRef.current; + if (openHopUserTxReopen) { console.debug( - '[useMeshcoreRuntime] SoftAP user-TX reopen — first-RPC path (skip getSelfInfo)', + '[useMeshcoreRuntime] OpenHop user-TX reopen — first-RPC path (skip getSelfInfo)', ); const transportType = meshcoreConnectTypeRef.current; const myNodeId = myNodeNumRef.current; const priorSelf = selfInfoRef.current; - // Stay configured for the whole SoftAP reopen (no connected→configured header flicker). + // Stay configured for the whole OpenHop reopen (no connected→configured header flicker). setState((prev) => ({ ...prev, myNodeNum: myNodeId || prev.myNodeNum, @@ -2236,7 +2245,7 @@ export function useMeshcoreRuntime() { myNodeNum: myNodeId, }); } - const promoteConfiguredSoftAp = (): void => { + const promoteConfiguredOpenHop = (): void => { setState((prev) => ({ ...prev, myNodeNum: myNodeId || prev.myNodeNum, @@ -2256,10 +2265,10 @@ export function useMeshcoreRuntime() { }; try { const bridgeDeadBefore = meshcoreTcpBridgeDeadRef.current; - await runMeshcoreSoftApPendingUserTx(); - // meshcore.js may resolve Ok while peer FIN latches write-dead — reject so SoftAP + await runMeshcoreOpenHopPendingUserTx(); + // meshcore.js may resolve Ok while peer FIN latches write-dead — reject so OpenHop // retry runs (do not notify live on this path). - throwIfMeshcoreTcpBridgeDiedDuringSoftApOp( + throwIfMeshcoreTcpBridgeDiedDuringOpenHopOp( bridgeDeadBefore, meshcoreTcpBridgeDeadRef.current, ); @@ -2268,23 +2277,23 @@ export function useMeshcoreRuntime() { const err = e instanceof Error ? e - : new Error(errLikeToLogString(e) || 'SoftAP pending user TX failed'); + : new Error(errLikeToLogString(e) || 'OpenHop pending user TX failed'); console.warn( - '[useMeshcoreRuntime] SoftAP user-TX first-RPC failed ' + errLikeToLogString(err), + '[useMeshcoreRuntime] OpenHop user-TX first-RPC failed ' + errLikeToLogString(err), ); rejectMeshcoreTcpLiveForUserTx(err); } console.debug( - '[useMeshcoreRuntime] SoftAP user-TX reopen — skip contacts dump after live send', + '[useMeshcoreRuntime] OpenHop user-TX reopen — skip contacts dump after live send', ); - // SoftAP-accept + configured before intentional disconnect (quiet teardown). + // OpenHop-accept + configured before intentional disconnect (quiet teardown). meshcoreTcpInitBurstCapturedRef.current = true; meshcoreTcpBridgeDeadRef.current = true; - setMeshcoreTcpSoftApDeadAccepted(true); - promoteConfiguredSoftAp(); + setMeshcoreTcpOpenHopDeadAccepted(true); + promoteConfiguredOpenHop(); void window.electronAPI.meshcore.tcp.disconnect().catch((e: unknown) => { console.debug( - '[useMeshcoreRuntime] SoftAP user-TX reopen tcp.disconnect ' + errLikeToLogString(e), + '[useMeshcoreRuntime] OpenHop user-TX reopen tcp.disconnect ' + errLikeToLogString(e), ); }); return; @@ -2325,7 +2334,7 @@ export function useMeshcoreRuntime() { ); // TCP: ConnectionDriver.discoverSelf already ran getSelfInfo — reuse to avoid a second - // companion RPC that SoftAP/OpenHop often FINs after (Neal/Fuzzy). + // companion RPC that OpenHop often FINs after (Neal/Fuzzy). const reusedDiscoverSelf = sequentialRadioInit && meshcoreConnectTypeRef.current === 'tcp' ? takeMeshcoreDiscoverSelfCache(conn) @@ -2352,7 +2361,7 @@ export function useMeshcoreRuntime() { // Latch session readiness after self-info (reconnect / FIN races), but keep UI status at // `connected` until the contacts dump settles. Promoting `configured` early starts App // flood-advert, stats poll, and static-GPS writes that interleave with getContacts — - // SoftAP then FINs mid-dump and meshcore.js Ok/Err listeners race (first-connect hang). + // OpenHop then FINs mid-dump and meshcore.js Ok/Err listeners race (first-connect hang). const configureBeforeContactsDump = true; setState((prev) => ({ ...prev, @@ -2432,9 +2441,9 @@ export function useMeshcoreRuntime() { } }; - // SoftAP user TX: release waiters while the socket is still live — companions often + // OpenHop user TX: release waiters while the socket is still live — companions often // FIN immediately after getContacts. Await tracked sends before starting the dump. - // (SoftAP user-TX reopen returns earlier via first-RPC path above.) + // (OpenHop user-TX reopen returns earlier via first-RPC path above.) if (transportType === 'tcp' && !meshcoreTcpBridgeDeadRef.current) { notifyMeshcoreTcpLiveForUserTx(); await yieldToMeshcoreTcpUserTxSends(); @@ -2475,7 +2484,7 @@ export function useMeshcoreRuntime() { : await parallelContactsPromise!; contactsDumpOk = true; } catch (e) { - // Soft-fail is TCP SoftAP/OpenHop only — BLE/serial getContacts failures must abort. + // Soft-fail is TCP OpenHop only — BLE/serial getContacts failures must abort. if ( transportType === 'tcp' && configureBeforeContactsDump && @@ -2503,7 +2512,7 @@ export function useMeshcoreRuntime() { if (transportType === 'tcp') { meshcoreTcpInitBurstCapturedRef.current = true; } - // Fuzzy SoftAP: peer FIN often lands between getContacts resolve and contacts→UI — + // Fuzzy OpenHop: peer FIN often lands between getContacts resolve and contacts→UI — // abort before DB/UI work when burst was not yet captured (pre-TCP path above). assertInitConnStillLive(); // Do not mark-all-off-radio + apply an empty dump on soft-fail — that would wipe the @@ -2689,13 +2698,32 @@ export function useMeshcoreRuntime() { '[useMeshcoreRuntime] post-connect refreshOurPosition ' + errLikeToLogString(e), ); }); - void requestTelemetryMeshCoreRef.current(myNodeId).catch((e: unknown) => { - if (isMeshcoreTcpTransportDeadError(e) || isMeshcoreSetupAbortError(e)) return; + if (waitingMessagesDrainBusyRef.current) { console.debug( - '[useMeshcoreRuntime] post-connect self telemetry (altitude) ' + - errLikeToLogString(e), + '[useMeshcoreRuntime] post-connect self telemetry deferred (waiting-message drain busy)', ); - }); + } else { + // Give proactive MsgWaiting drain a head start so telemetry does not seize the + // companion RPC lane during a large backlog (Neil: 120s telemetry vs sync). + window.setTimeout(() => { + if (meshcoreSetupGenerationRef.current !== setupGen || connRef.current !== conn) { + return; + } + if (waitingMessagesDrainBusyRef.current) { + console.debug( + '[useMeshcoreRuntime] post-connect self telemetry skipped (waiting-message drain busy)', + ); + return; + } + void requestTelemetryMeshCoreRef.current(myNodeId).catch((e: unknown) => { + if (isMeshcoreTcpTransportDeadError(e) || isMeshcoreSetupAbortError(e)) return; + console.debug( + '[useMeshcoreRuntime] post-connect self telemetry (altitude) ' + + errLikeToLogString(e), + ); + }); + }, MESHCORE_WAITING_MESSAGES_DRAIN_DEBOUNCE_MS + MESHCORE_WAITING_MESSAGES_AFTER_TX_DEFER_MS); + } }); }); @@ -2981,10 +3009,10 @@ export function useMeshcoreRuntime() { // and reconnect deferral cannot stick after BLE/serial prepare aborts a prior open. meshcoreInitConnInFlightRef.current = false; meshcoreInitConnInFlightSetupGenRef.current = null; - // SoftAP dead-bridge latch can outlive a TCP session — clear on every prepare so + // OpenHop dead-bridge latch can outlive a TCP session — clear on every prepare so // BLE/serial opens do not inherit a stale "accepted dead bridge" TX path. meshcoreTcpBridgeDeadRef.current = false; - setMeshcoreTcpSoftApDeadAccepted(false); + setMeshcoreTcpOpenHopDeadAccepted(false); if (type === 'tcp') { meshcoreTcpInitBurstCapturedRef.current = false; meshcoreTcpContactsDumpInFlightRef.current = false; @@ -2997,9 +3025,9 @@ export function useMeshcoreRuntime() { serialRediscoveryStopRef.current?.(); serialRediscoveryStopRef.current = null; } - // SoftAP chat reopen: keep configured UI (do not flash connecting / wipe myNodeNum). + // OpenHop chat reopen: keep configured UI (do not flash connecting / wipe myNodeNum). // Full connect/reconnect still uses status=connecting below. - if (meshcoreSoftApUserTxReopenInFlightRef.current && type === 'tcp') { + if (meshcoreOpenHopUserTxReopenInFlightRef.current && type === 'tcp') { setState((s) => ({ ...s, connectionType: 'http', @@ -3086,11 +3114,11 @@ export function useMeshcoreRuntime() { const handleRfConnectFailure = useCallback( (type: 'ble' | 'serial' | 'tcp', driverIdentityId?: string): Promise => { - // SoftAP chat reopen failed mid-handshake: restore accepted dead-bridge session. - // Leaving disconnected here stranded SoftAP TX with no reconnect owner (post-fix logs). - if (type === 'tcp' && meshcoreSoftApUserTxReopenInFlightRef.current) { + // OpenHop chat reopen failed mid-handshake: restore accepted dead-bridge session. + // Leaving disconnected here stranded OpenHop TX with no reconnect owner (post-fix logs). + if (type === 'tcp' && meshcoreOpenHopUserTxReopenInFlightRef.current) { meshcoreTcpBridgeDeadRef.current = true; - setMeshcoreTcpSoftApDeadAccepted(true); + setMeshcoreTcpOpenHopDeadAccepted(true); meshcoreDeferredReconnectRef.current = false; meshcoreDeviceConfiguredRef.current = true; meshcoreEverConfiguredRef.current = true; @@ -3103,9 +3131,9 @@ export function useMeshcoreRuntime() { myNodeNum: myNodeNum || s.myNodeNum, })); console.debug( - '[useMeshcoreRuntime] SoftAP user-TX reopen failed — restore SoftAP-accepted configured', + '[useMeshcoreRuntime] OpenHop user-TX reopen failed — restore OpenHop-accepted configured', ); - clearMeshcoreSoftApPendingUserTx(new Error('MeshCore SoftAP user-TX reopen failed')); + clearMeshcoreOpenHopPendingUserTx(new Error('MeshCore OpenHop user-TX reopen failed')); teardownMeshcoreConnEventListeners({ driverDisconnect: true, driverIdentityId, @@ -3462,11 +3490,11 @@ export function useMeshcoreRuntime() { } // Burst-complete attach left a dead bridge (OpenHop FIN after contacts). UI is configured // from the contacts burst — accept that session. Forcing an immediate live-socket retry - // loops forever on companions that FIN after every contacts dump (WAN :5054 / SoftAP). - // SoftAP-accepted: keep configured; background write-dead must not reconnect-loop. + // loops forever on companions that FIN after every contacts dump (WAN :5054 / OpenHop). + // OpenHop-accepted: keep configured; background write-dead must not reconnect-loop. if (params.rfType === 'tcp' && meshcoreDeferredReconnectRef.current) { meshcoreDeferredReconnectRef.current = false; - setMeshcoreTcpSoftApDeadAccepted(true); + setMeshcoreTcpOpenHopDeadAccepted(true); console.debug( '[useMeshcoreRuntime] TCP burst-complete reconnect attach — accepting dead bridge (configured)', ); @@ -3480,7 +3508,7 @@ export function useMeshcoreRuntime() { serialNeedsReselect: false, connectionLoss: false, })); - // SoftAP dead bridge: outbox drain would tcp-write-fail → reconnect thrash. + // OpenHop dead bridge: outbox drain would tcp-write-fail → reconnect thrash. if (!(params.rfType === 'tcp' && meshcoreTcpBridgeDeadRef.current)) { requestChatOutboxDrain('meshcore'); } @@ -3685,8 +3713,8 @@ export function useMeshcoreRuntime() { handleMeshcoreConnectionLostRef.current = handleMeshcoreConnectionLost; - /** Set after `connect` is defined — SoftAP user TX reopen must not use connection-lost. */ - const meshcoreConnectForSoftApTxRef = useRef< + /** Set after `connect` is defined — OpenHop user TX reopen must not use connection-lost. */ + const meshcoreConnectForOpenHopTxRef = useRef< | (( type: 'ble' | 'serial' | 'tcp', tcpHost?: string, @@ -3697,95 +3725,95 @@ export function useMeshcoreRuntime() { const ensureTcpLiveForUserTx = useCallback(async (): Promise => { const bridgeDead = meshcoreTcpBridgeDeadRef.current; - const softAp = isMeshcoreTcpSoftApDeadAccepted(); - if (!softAp && !bridgeDead) { + const openHop = isMeshcoreTcpOpenHopDeadAccepted(); + if (!openHop && !bridgeDead) { return; } - // Already opening — wait for the post-getSelfInfo live window (SoftAP quiet reopen or - // reconnect). SoftAP reopen clears bridgeDead before connect settles; do not require live. + // Already opening — wait for the post-getSelfInfo live window (OpenHop quiet reopen or + // reconnect). OpenHop reopen clears bridgeDead before connect settles; do not require live. if ( meshcoreConnectTypeRef.current === 'tcp' && (meshcoreInitConnInFlightRef.current || meshcoreIsReconnectingRef.current || - meshcoreSoftApUserTxReopenInFlightRef.current) + meshcoreOpenHopUserTxReopenInFlightRef.current) ) { await waitForMeshcoreTcpLiveForUserTx(); return; } - // SoftAP-accepted dead bridge: reopen via connect() — not handleMeshcoreConnectionLost. + // OpenHop-accepted dead bridge: reopen via connect() — not handleMeshcoreConnectionLost. // Connection-lost sets connectionLoss + 2s backoff and looks like a drop on every chat send. - if (softAp) { + if (openHop) { const host = meshcoreConnectionParamsRef.current?.httpAddress?.trim(); - const connectFn = meshcoreConnectForSoftApTxRef.current; + const connectFn = meshcoreConnectForOpenHopTxRef.current; if (!host || !connectFn) { - throw new Error('MeshCore SoftAP user-TX reopen missing TCP host or connect'); + throw new Error('MeshCore OpenHop user-TX reopen missing TCP host or connect'); } - // Keep SoftAP latch during settle so background writes stay suppressed. Immediate + // Keep OpenHop latch during settle so background writes stay suppressed. Immediate // reconnect FINs in <200ms (post-fix); match reconnect attempt-1 backoff. - meshcoreSoftApUserTxReopenInFlightRef.current = true; + meshcoreOpenHopUserTxReopenInFlightRef.current = true; console.debug( - `[useMeshcoreRuntime] SoftAP user TX — settle ${MESHCORE_TCP_SOFTAP_USER_TX_REOPEN_DELAY_MS}ms before quiet reopen`, + `[useMeshcoreRuntime] OpenHop user TX — settle ${MESHCORE_TCP_OPENHOP_USER_TX_REOPEN_DELAY_MS}ms before quiet reopen`, ); await new Promise((resolve) => { - setTimeout(resolve, MESHCORE_TCP_SOFTAP_USER_TX_REOPEN_DELAY_MS); + setTimeout(resolve, MESHCORE_TCP_OPENHOP_USER_TX_REOPEN_DELAY_MS); }); if (meshcoreExplicitDisconnectRef.current) { - meshcoreSoftApUserTxReopenInFlightRef.current = false; - throw new Error('MeshCore SoftAP user-TX reopen aborted (user disconnect)'); + meshcoreOpenHopUserTxReopenInFlightRef.current = false; + throw new Error('MeshCore OpenHop user-TX reopen aborted (user disconnect)'); } - setMeshcoreTcpSoftApDeadAccepted(false); + setMeshcoreTcpOpenHopDeadAccepted(false); meshcoreTcpBridgeDeadRef.current = false; - console.debug('[useMeshcoreRuntime] SoftAP user TX — quiet TCP reopen (no connection-lost)'); + console.debug('[useMeshcoreRuntime] OpenHop user TX — quiet TCP reopen (no connection-lost)'); void connectFn('tcp', host) .catch((e: unknown) => { console.warn( - '[useMeshcoreRuntime] SoftAP user-TX reopen failed ' + errLikeToLogString(e), + '[useMeshcoreRuntime] OpenHop user-TX reopen failed ' + errLikeToLogString(e), ); rejectMeshcoreTcpLiveForUserTx( - e instanceof Error ? e : new Error(errLikeToLogString(e) || 'SoftAP reopen failed'), + e instanceof Error ? e : new Error(errLikeToLogString(e) || 'OpenHop reopen failed'), ); }) .finally(() => { - meshcoreSoftApUserTxReopenInFlightRef.current = false; + meshcoreOpenHopUserTxReopenInFlightRef.current = false; }); await waitForMeshcoreTcpLiveForUserTx(); return; } - // Mid-session dead bridge (not SoftAP-accepted): normal reconnect recovery. - setMeshcoreTcpSoftApDeadAccepted(false); + // Mid-session dead bridge (not OpenHop-accepted): normal reconnect recovery. + setMeshcoreTcpOpenHopDeadAccepted(false); handleMeshcoreConnectionLostRef.current(); await waitForMeshcoreTcpLiveForUserTx(); }, []); - /** SoftAP / dead-bridge user TX: park op for SoftAP first-RPC reopen; retry once on dead write. */ + /** OpenHop / dead-bridge user TX: park op for OpenHop first-RPC reopen; retry once on dead write. */ const runMeshcoreUserTxWithLiveTcp = useCallback( async (op: () => Promise): Promise => { - const softApIntent = isMeshcoreTcpSoftApDeadAccepted(); - if (!softApIntent && !meshcoreTcpBridgeDeadRef.current) { + const openHopIntent = isMeshcoreTcpOpenHopDeadAccepted(); + if (!openHopIntent && !meshcoreTcpBridgeDeadRef.current) { return op(); } - // Mid-session dead bridge (not SoftAP): reconnect then run op after live window. - if (!softApIntent) { + // Mid-session dead bridge (not OpenHop): reconnect then run op after live window. + if (!openHopIntent) { return runWithMeshcoreTcpDeadWriteRetry(ensureTcpLiveForUserTx, op); } let lastErr: unknown; for (let attempt = 0; attempt < 2; attempt++) { - // SoftAP quiet reopen must stay SoftAP across retries (clearing accepted mid-open + // OpenHop quiet reopen must stay OpenHop across retries (clearing accepted mid-open // sent attempt 2 into handleMeshcoreConnectionLost + discoverSelf reuse). - setMeshcoreTcpSoftApDeadAccepted(true); - const resultPromise = setMeshcoreSoftApPendingUserTx(op); + setMeshcoreTcpOpenHopDeadAccepted(true); + const resultPromise = setMeshcoreOpenHopPendingUserTx(op); try { await ensureTcpLiveForUserTx(); return await resultPromise; } catch (e: unknown) { - clearMeshcoreSoftApPendingUserTx( - e instanceof Error ? e : new Error(errLikeToLogString(e) || 'SoftAP TX failed'), + clearMeshcoreOpenHopPendingUserTx( + e instanceof Error ? e : new Error(errLikeToLogString(e) || 'OpenHop TX failed'), ); // Late write-dead latch after meshcore.js Ok: resultPromise is already fulfilled — // return that value. Re-parking would double-send chat. - const opSettlement = await settleSoftApPendingResult(resultPromise); - const decision = decideSoftApUserTxAfterEnsureFailure({ opSettlement }); + const opSettlement = await settleOpenHopPendingResult(resultPromise); + const decision = decideOpenHopUserTxAfterEnsureFailure({ opSettlement }); if (decision.action === 'return') return decision.value; if (decision.action === 'throw') throw decision.error; lastErr = opSettlement.status === 'rejected' ? opSettlement.reason : e; @@ -3869,7 +3897,7 @@ export function useMeshcoreRuntime() { openMeshCoreTransport(type, { blePeripheralId, host: type === 'tcp' ? (tcpHost ?? 'localhost') : undefined, - skipDiscoverSelf: meshcoreSoftApUserTxReopenInFlightRef.current, + skipDiscoverSelf: meshcoreOpenHopUserTxReopenInFlightRef.current, }); opened = type === 'ble' && isRendererNobleBlePlatform() @@ -3924,11 +3952,11 @@ export function useMeshcoreRuntime() { meshcoreEverConfiguredRef.current = true; // Neal OpenHop: peer FIN after contacts — initConn completed configured from the burst // with a dead bridge. Do not force an immediate live-socket reconnect (companions that - // FIN after every contacts dump would loop forever). SoftAP-accepted suppresses + // FIN after every contacts dump would loop forever). OpenHop-accepted suppresses // background write-dead → reconnect (flood advert / outbox thrash). if (type === 'tcp' && meshcoreDeferredReconnectRef.current) { meshcoreDeferredReconnectRef.current = false; - setMeshcoreTcpSoftApDeadAccepted(true); + setMeshcoreTcpOpenHopDeadAccepted(true); console.debug( '[useMeshcoreRuntime] TCP burst-complete configure — accepting dead bridge', ); @@ -4014,7 +4042,7 @@ export function useMeshcoreRuntime() { [prepareRfConnect, attachRfSession, handleRfConnectFailure], ); useLayoutEffect(() => { - meshcoreConnectForSoftApTxRef.current = connect; + meshcoreConnectForOpenHopTxRef.current = connect; }, [connect]); /** @@ -4300,7 +4328,7 @@ export function useMeshcoreRuntime() { try { const hadRadioConn = connRef.current != null || - isMeshcoreTcpSoftApDeadAccepted() || + isMeshcoreTcpOpenHopDeadAccepted() || meshcoreTcpBridgeDeadRef.current; if (hadRadioConn) { await runMeshcoreUserTxWithLiveTcp(async () => { @@ -4308,8 +4336,8 @@ export function useMeshcoreRuntime() { if (!liveConn) throw new Error('Not connected to radio'); const work = liveConn.sendChannelTextMessage(channelIdx, textToSend); if ( - isMeshcoreTcpSoftApDeadAccepted() || - meshcoreSoftApUserTxReopenInFlightRef.current + isMeshcoreTcpOpenHopDeadAccepted() || + meshcoreOpenHopUserTxReopenInFlightRef.current ) { trackMeshcoreTcpUserTxSend(work); } @@ -6707,7 +6735,7 @@ export function useMeshcoreRuntime() { const setMeshcoreChannel = useCallback( async (idx: number, name: string, secret: Uint8Array) => { - // Validate parameters before SoftAP reopen (avoid pointless TCP churn). + // Validate parameters before OpenHop reopen (avoid pointless TCP churn). if (!Number.isInteger(idx) || idx < 0 || idx > 39) { console.warn('[useMeshcoreRuntime] setMeshcoreChannel: invalid channel index', idx); throw new Error(`Invalid channel index: ${idx}. Must be 0-39.`); @@ -6740,7 +6768,10 @@ export function useMeshcoreRuntime() { throw new Error('Not connected to radio'); } const work = withTimeout(liveConn.setChannel(idx, name, secret), 10_000, 'setChannel'); - if (isMeshcoreTcpSoftApDeadAccepted() || meshcoreSoftApUserTxReopenInFlightRef.current) { + if ( + isMeshcoreTcpOpenHopDeadAccepted() || + meshcoreOpenHopUserTxReopenInFlightRef.current + ) { trackMeshcoreTcpUserTxSend(work); } await work; @@ -6775,7 +6806,10 @@ export function useMeshcoreRuntime() { throw new Error('Not connected to radio'); } const work = liveConn.deleteChannel(idx); - if (isMeshcoreTcpSoftApDeadAccepted() || meshcoreSoftApUserTxReopenInFlightRef.current) { + if ( + isMeshcoreTcpOpenHopDeadAccepted() || + meshcoreOpenHopUserTxReopenInFlightRef.current + ) { trackMeshcoreTcpUserTxSend(work); } await work; @@ -7041,7 +7075,7 @@ export function useMeshcoreRuntime() { async (glyph: string, replyId: number, channel: number) => { if ( !connRef.current && - !isMeshcoreTcpSoftApDeadAccepted() && + !isMeshcoreTcpOpenHopDeadAccepted() && !meshcoreTcpBridgeDeadRef.current ) { throw new Error('Not connected to radio'); @@ -7084,7 +7118,10 @@ export function useMeshcoreRuntime() { const liveConn = connRef.current; if (!liveConn) throw new Error('Not connected to radio'); const work = liveConn.sendTextMessage(pubKey, tapbackText); - if (isMeshcoreTcpSoftApDeadAccepted() || meshcoreSoftApUserTxReopenInFlightRef.current) { + if ( + isMeshcoreTcpOpenHopDeadAccepted() || + meshcoreOpenHopUserTxReopenInFlightRef.current + ) { trackMeshcoreTcpUserTxSend(work); } await work; @@ -7114,7 +7151,10 @@ export function useMeshcoreRuntime() { const liveConn = connRef.current; if (!liveConn) throw new Error('Not connected to radio'); const work = liveConn.sendChannelTextMessage(outboundChannel, tapbackText); - if (isMeshcoreTcpSoftApDeadAccepted() || meshcoreSoftApUserTxReopenInFlightRef.current) { + if ( + isMeshcoreTcpOpenHopDeadAccepted() || + meshcoreOpenHopUserTxReopenInFlightRef.current + ) { trackMeshcoreTcpUserTxSend(work); } await work; @@ -7480,7 +7520,7 @@ export function useMeshcoreRuntime() { } if (pos.source === 'static' && connRef.current) { - // Do not write SetAdvertLatLon during initConn contacts dump — SoftAP FINs mid-dump when + // Do not write SetAdvertLatLon during initConn contacts dump — OpenHop FINs mid-dump when // GPS/stats/advert RPCs interleave with getContacts (meshcore.js shared Ok/Err). if (meshcoreInitConnInFlightRef.current) { return pos; @@ -7742,7 +7782,7 @@ export function useMeshcoreRuntime() { meshcoreTcpBridgeDeadRef.current = true; // Post-configure contacts dump: keep the configured session; do not reconnect-loop. if (meshcoreTcpContactsDumpInFlightRef.current) { - setMeshcoreTcpSoftApDeadAccepted(true); + setMeshcoreTcpOpenHopDeadAccepted(true); console.debug( source === 'write' ? '[useMeshcoreRuntime] TCP write-dead during post-configure contacts dump — keep configured' @@ -7758,17 +7798,17 @@ export function useMeshcoreRuntime() { deviceConfigured: meshcoreDeviceConfiguredRef.current, initConnInFlight: meshcoreInitConnInFlightRef.current, }); - // SoftAP-accepted dead bridge: flood advert / outbox / intentional SoftAP reopen - // tcp.disconnect must not reconnect-loop (ipc or write). SoftAP user-TX reopen-in-flight + // OpenHop-accepted dead bridge: flood advert / outbox / intentional OpenHop reopen + // tcp.disconnect must not reconnect-loop (ipc or write). OpenHop user-TX reopen-in-flight // must also stay quiet (accepted cleared mid-open; FIN must not flash connectionLoss). - const softApAccepted = isMeshcoreTcpSoftApDeadAccepted(); - const softApUserTxReopen = meshcoreSoftApUserTxReopenInFlightRef.current; + const openHopAccepted = isMeshcoreTcpOpenHopDeadAccepted(); + const openHopUserTxReopen = meshcoreOpenHopUserTxReopenInFlightRef.current; if (defer) { meshcoreDeferredReconnectRef.current = true; - // Latch SoftAP-accepted as soon as configured+deferred so flood advert cannot + // Latch OpenHop-accepted as soon as configured+deferred so flood advert cannot // write-dead→lost in the gap before connect() clears deferredReconnect. if (meshcoreDeviceConfiguredRef.current && meshcoreEverConfiguredRef.current) { - setMeshcoreTcpSoftApDeadAccepted(true); + setMeshcoreTcpOpenHopDeadAccepted(true); } console.debug( source === 'write' @@ -7777,22 +7817,22 @@ export function useMeshcoreRuntime() { ); return; } - if (softApAccepted || softApUserTxReopen) { - if (softApUserTxReopen) { - setMeshcoreTcpSoftApDeadAccepted(true); + if (openHopAccepted || openHopUserTxReopen) { + if (openHopUserTxReopen) { + setMeshcoreTcpOpenHopDeadAccepted(true); } console.debug( - softApUserTxReopen + openHopUserTxReopen ? source === 'write' - ? '[useMeshcoreRuntime] TCP write-dead during SoftAP user-TX reopen — keep configured' - : '[useMeshcoreRuntime] TCP closed during SoftAP user-TX reopen — keep configured' + ? '[useMeshcoreRuntime] TCP write-dead during OpenHop user-TX reopen — keep configured' + : '[useMeshcoreRuntime] TCP closed during OpenHop user-TX reopen — keep configured' : source === 'write' - ? '[useMeshcoreRuntime] TCP write-dead on SoftAP-accepted dead bridge — keep configured' - : '[useMeshcoreRuntime] TCP closed on SoftAP-accepted dead bridge — keep configured', + ? '[useMeshcoreRuntime] TCP write-dead on OpenHop-accepted dead bridge — keep configured' + : '[useMeshcoreRuntime] TCP closed on OpenHop-accepted dead bridge — keep configured', ); return; } - // Mid-session TCP death (not SoftAP-accepted): ipc/write own recovery. SoftAP accept + // Mid-session TCP death (not OpenHop-accepted): ipc/write own recovery. OpenHop accept // intentionally leaves a dead bridge — do not immediate-reconnect on accept. handleMeshcoreConnectionLostRef.current(); }; diff --git a/src/shared/withTimeout.ts b/src/shared/withTimeout.ts index fa83e61f9..e8f195fbe 100644 --- a/src/shared/withTimeout.ts +++ b/src/shared/withTimeout.ts @@ -7,7 +7,7 @@ export function withTimeout(promise: Promise, ms: number, label: string): }, ms); }); // Swallow late rejects from the loser of the race so they cannot surface as - // Unhandled rejection (SoftAP: tcp-write fails after timeout already won). + // Unhandled rejection (OpenHop: tcp-write fails after timeout already won). void promise.then( () => undefined, () => undefined, From 7ca93eb7813939d601f50706b752535f572faa3c Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Fri, 7 Aug 2026 11:43:16 -0600 Subject: [PATCH 2/5] fix(meshcore): address waiting-drain review nits Align telemetry skip wording, use silent-timeout constants in tests, drop contradictory catch-no-log-ok, cover stale bulk-attempt abandon, and remove redundant OpenHop dead-bridge check in stats fetch. --- .../hooks/meshcore/meshcoreConnSideEffects.test.ts | 10 +++++++--- src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts | 2 +- src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts | 10 ++++++++++ src/renderer/runtime/useMeshcoreRuntime.ts | 4 ++-- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts index 63d116609..ca3121637 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts @@ -9,7 +9,11 @@ import type { } from '@/renderer/lib/meshcore/meshcoreHookTypes'; import { resetMeshcoreWaitingMessagesDrainState } from '@/renderer/lib/meshcoreWaitingMessagesDrain'; import type { DomainEvent } from '@/renderer/lib/protocols/Protocol'; -import { MESHCORE_WAITING_MESSAGES_DRAIN_DEBOUNCE_MS } from '@/renderer/lib/timeConstants'; +import { + MESHCORE_WAITING_MESSAGES_DRAIN_DEBOUNCE_MS, + MESHCORE_WAITING_MESSAGES_SERIAL_SILENT_TIMEOUT_MS, + MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS, +} from '@/renderer/lib/timeConstants'; import type { ChatMessage, DeviceState, TelemetryPoint } from '@/renderer/lib/types'; import { useMessageStore } from '@/renderer/stores/messageStore'; import { useNodeStore } from '@/renderer/stores/nodeStore'; @@ -399,8 +403,8 @@ describe('attachMeshcoreConnSideEffects', () => { const drainPromise = h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: false }); await vi.advanceTimersByTimeAsync( connectionType === 'serial' - ? 15_000 // MESHCORE_WAITING_MESSAGES_SERIAL_SILENT_TIMEOUT_MS - : 45_000, + ? MESHCORE_WAITING_MESSAGES_SERIAL_SILENT_TIMEOUT_MS + : MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS, ); await vi.runAllTimersAsync(); await drainPromise; diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts index 100e1a2c7..24e4358b4 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts @@ -357,7 +357,7 @@ async function drainWaitingMessagesSilent( return; } catch (e: unknown) { if (isMeshcoreWaitingMessagesTransportDeadError(e)) { - // catch-no-log-ok transport dead — reconnect owns link; do not fallback or disconnect here + // Transport dead — reconnect owns link; do not fallback or disconnect here. logMeshcoreWaitingMessagesDrainError('silent bulk transport dead', e, false); return; } diff --git a/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts b/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts index 29afa9ace..84aa51ae5 100644 --- a/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts +++ b/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts @@ -256,6 +256,16 @@ describe('silent bulk error classifiers', () => { abandonMeshcoreSilentBulkAttempt(id); expect(isMeshcoreSilentBulkAttemptCurrent(id)).toBe(false); }); + + it('ignores abandon of a stale attempt id so the newer attempt stays current', () => { + resetMeshcoreWaitingMessagesDrainState(0); + const staleId = beginMeshcoreSilentBulkAttempt(); + const currentId = beginMeshcoreSilentBulkAttempt(); + expect(isMeshcoreSilentBulkAttemptCurrent(staleId)).toBe(false); + expect(isMeshcoreSilentBulkAttemptCurrent(currentId)).toBe(true); + abandonMeshcoreSilentBulkAttempt(staleId); + expect(isMeshcoreSilentBulkAttemptCurrent(currentId)).toBe(true); + }); }); describe('shouldRunMeshcoreWaitingMessagesPeriodicPoll', () => { diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 717236dad..f5142af50 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -789,7 +789,7 @@ export function useMeshcoreRuntime() { const conn = connRef.current; if (!conn) return; // OpenHop: peer FIN left a dead bridge — stats RPCs only spam tcp-write errors. - if (isMeshcoreTcpOpenHopDeadAccepted() || meshcoreTcpBridgeDeadRef.current) { + if (meshcoreTcpBridgeDeadRef.current) { return; } let coreStats: Awaited>; @@ -2700,7 +2700,7 @@ export function useMeshcoreRuntime() { }); if (waitingMessagesDrainBusyRef.current) { console.debug( - '[useMeshcoreRuntime] post-connect self telemetry deferred (waiting-message drain busy)', + '[useMeshcoreRuntime] post-connect self telemetry skipped (waiting-message drain busy)', ); } else { // Give proactive MsgWaiting drain a head start so telemetry does not seize the From e102a98e5ebefaa580ebbf7b151ee3ee2ded0de1 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Fri, 7 Aug 2026 12:22:20 -0600 Subject: [PATCH 3/5] fix(reticulum): multi-PN DM cascade and proxy rate-limit hardening (#817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct LXMF timeouts to third-party clients often failed without propagation fallback, and inbound catch-up was starved by the shared 300/min proxy ceiling. Cascade preferred → other remotes → local-prop (PN 🏠 badge), raise/split proxy budgets with backoff, and improve outbound logging in developer bundles. --- docs/reticulum-sidecar-ipc.md | 2 +- reticulum-sidecar/src/stack/live.rs | 33 ++ reticulum-sidecar/src/stack/lxmf_outbound.rs | 299 +++++++++++++++--- reticulum-sidecar/src/stack/mod.rs | 29 +- reticulum-sidecar/src/stack/pn_cascade.rs | 279 ++++++++++++++++ src/main/ipc/reticulum-handlers.ts | 25 +- ...eticulum-proxy-rate-limit.contract.test.ts | 19 +- src/main/ipc/reticulumLxmfRecentPath.ts | 5 + src/main/support-bundle.test.ts | 22 ++ src/main/support-bundle.ts | 41 ++- .../ReticulumMessageStatusBadge.test.tsx | 14 + .../ReticulumMessageStatusBadge.tsx | 35 +- src/renderer/lib/ingest/reticulumIngest.ts | 4 + .../applyReticulumOutboundDeliveryStatus.ts | 25 +- .../reticulum/fetchRecentInboundLxmf.test.ts | 2 +- .../lib/reticulum/fetchRecentInboundLxmf.ts | 19 +- .../reticulumOutboundFailureBridge.test.ts | 6 +- .../reticulumOutboundFailureBridge.ts | 18 +- .../reticulumPropagationEffective.test.ts | 25 +- .../reticulumPropagationEffective.ts | 18 ++ .../reticulumPropagationSync.test.ts | 3 + .../lib/reticulum/reticulumPropagationSync.ts | 6 +- .../reticulumProxyRateLimitBackoff.test.ts | 45 +++ .../reticulumProxyRateLimitBackoff.ts | 51 +++ src/renderer/locales/cs/translation.json | 8 +- src/renderer/locales/de/translation.json | 8 +- src/renderer/locales/en/translation.json | 4 + src/renderer/locales/es/translation.json | 8 +- src/renderer/locales/fr/translation.json | 8 +- src/renderer/locales/id/translation.json | 8 +- src/renderer/locales/it/translation.json | 8 +- src/renderer/locales/ja/translation.json | 8 +- src/renderer/locales/ko/translation.json | 8 +- src/renderer/locales/nl/translation.json | 8 +- src/renderer/locales/pl/translation.json | 8 +- src/renderer/locales/pt-BR/translation.json | 8 +- src/renderer/locales/ru/translation.json | 8 +- src/renderer/locales/tr/translation.json | 8 +- src/renderer/locales/uk/translation.json | 8 +- src/renderer/locales/zh/translation.json | 8 +- ...ticulumRuntime.reconnect-hardening.test.ts | 8 +- src/renderer/runtime/useReticulumRuntime.ts | 59 +++- src/renderer/stores/messageStore.ts | 5 +- .../stores/reticulumPeerStore.test.ts | 2 + src/renderer/stores/reticulumPeerStore.ts | 17 +- src/shared/electron-api.types.ts | 2 +- src/shared/reticulumDeliveryMethod.ts | 2 + 47 files changed, 1106 insertions(+), 138 deletions(-) create mode 100644 reticulum-sidecar/src/stack/pn_cascade.rs create mode 100644 src/main/ipc/reticulumLxmfRecentPath.ts create mode 100644 src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts create mode 100644 src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index ea34efef4..e6dbfebfe 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -233,7 +233,7 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi | POST | `/api/v1/voice/reject` | | Reject ringing call | | POST | `/api/v1/voice/hangup` | | End active call | | POST | `/api/v1/voice/mute` | `{ muted }` | Renderer mute flag (sidecar drops PCM ingest) | -| POST | `/api/v1/voice/audio` | `{ profile?, channels, samples_b64 }` | Push one PCM frame (LE f32 base64) for Opus TX. **Only established calls transmit**; earlier frames are accepted-and-dropped as `not_established` (soft-drop — do not fatal). Renderer must defer capture/TX until `voice.update` status `established` (Answer only warms `AudioContext`). Use dedicated IPC `reticulum:voiceSendAudio` (own ~2000/min budget); generic `reticulum:proxyPost` rejects this path so realtime PCM does not starve the shared 300/min proxy ceiling. | +| POST | `/api/v1/voice/audio` | `{ profile?, channels, samples_b64 }` | Push one PCM frame (LE f32 base64) for Opus TX. **Only established calls transmit**; earlier frames are accepted-and-dropped as `not_established` (soft-drop — do not fatal). Renderer must defer capture/TX until `voice.update` status `established` (Answer only warms `AudioContext`). Use dedicated IPC `reticulum:voiceSendAudio` (own ~2000/min budget); generic `reticulum:proxyPost` rejects this path so realtime PCM does not starve the shared 900/min proxy ceiling. | | GET | `/api/v1/games/status` | | LRGP live status (`available`, `enabled`, `running`, registered apps). Use dedicated IPC `reticulum:gamesStatus` — generic `proxyGet` rejects `/api/v1/games/*` | | GET | `/api/v1/games/apps` | | Registered game manifests (ttt, chess) | | GET | `/api/v1/games/sessions` | optional `?peer=` | Session list (sidecar `LrgpStore`) | diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 4955944be..1159e0452 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -2,6 +2,8 @@ #[path = "lxmf_outbound.rs"] mod lxmf_outbound; +#[path = "pn_cascade.rs"] +mod pn_cascade; use std::collections::{HashMap, HashSet}; use std::io::Cursor; @@ -550,7 +552,13 @@ impl LiveBridge { if let Some(hash_hex) = preferred_prop_hash { bridge.set_outbound_propagation_node(Some(&hash_hex)).await; + } else { + tracing::warn!( + target: "lxmf-outbound", + "no preferred propagation destination_hash at stack start — Direct→PN cascade remotes may be empty" + ); } + bridge.refresh_pn_cascade_candidates().await; if let Ok(ifaces) = config::interfaces_from_config_dir(&config_dir) { let _ = bridge.sync_ble_peer_interfaces(&ifaces).await; @@ -3742,6 +3750,31 @@ impl LiveBridge { } } + /// Rebuild Direct→PN cascade candidate list from persisted propagation rows. + pub async fn refresh_pn_cascade_candidates(&self) { + use pn_cascade::candidates_from_propagation_rows; + let (rows, self_hash, local_enabled) = { + let state = self.persisted.read().await; + let rows: Vec<(String, bool, Option, Option)> = state + .propagation + .iter() + .map(|p| (p.id.clone(), p.enabled, p.destination_hash.clone(), p.hops)) + .collect(); + let self_hash = state.identity.lxmf_hash.clone(); + let local_enabled = state + .propagation + .iter() + .find(|p| p.id == "local-prop") + .map(|p| p.enabled) + .unwrap_or(false); + (rows, self_hash, local_enabled) + }; + let candidates = candidates_from_propagation_rows(&rows, &self_hash, local_enabled); + if let Ok(mut driver) = self.outbound.lock() { + driver.set_pn_cascade_candidates(candidates); + } + } + pub async fn fetch_interfaces(&self) -> Result, String> { let config_rows = super::config::interfaces_from_config_dir(&self.config_dir).unwrap_or_default(); diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index c97e606bb..0e4188779 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -29,6 +29,9 @@ use super::super::path_failover::{ }; use super::super::types::InterfaceRow; use super::super::via::classify_interface; +use super::pn_cascade::{ + PnCascadeCandidate, build_pn_cascade_order, cascade_has_capacity, pick_next_pn_cascade, +}; use super::{lxmf_payload_from_message, parse_hash16}; const PATH_REQUEST_BACKOFF_SECS: f64 = 20.0; @@ -115,8 +118,10 @@ impl PathRequestGate { } } -/// Bound on Direct→PN fallback hash tracking (one entry per outbound message). -const PN_FALLBACK_ATTEMPTED_MAX: usize = 256; +/// Bound on per-message PN cascade tried-set tracking. +const PN_CASCADE_TRIED_MAX: usize = 256; +/// After this many sync/pending PN-link deferrals, advance to the next cascade PN. +const PN_DEPOSIT_DEFER_ADVANCE_AFTER: u32 = 8; /// Correlatable ids for an in-flight Propagated deposit (`pn_hash`, optional `transient_id`). type PendingPnDeposit = ([u8; 16], Option<[u8; 32]>); @@ -140,8 +145,15 @@ pub struct LxmfOutboundDriver { /// (Auto status may still report "up" after a Direct failure on Auto). auto_delivery_degraded_until: f64, path_request_gate: PathRequestGate, - /// Message hashes that already consumed the one-shot Direct→PN fallback. - pn_fallback_attempted: HashSet<[u8; 32]>, + /// Per-message PN hashes already tried in the Direct→Propagated cascade. + pn_cascade_tried: HashMap<[u8; 32], HashSet<[u8; 16]>>, + /// Message hashes whose current cascade step is a local-prop (offline) deposit. + pn_cascade_local: HashSet<[u8; 32]>, + /// Enabled PN candidates + preferred hash for cascade ordering. + pn_cascade_candidates: Vec, + preferred_pn_hash: Option<[u8; 16]>, + /// Consecutive DeliverPropagated deferrals while PN link busy (per message). + pn_deposit_defer_counts: HashMap<[u8; 32], u32>, /// Direct link failures still exhausting alternate path slots / ifaces. direct_path_failovers: HashMap<[u8; 32], DirectPathFailoverState>, /// When set, remote propagation sync holds a Link to this dest — do not race deposits. @@ -176,7 +188,11 @@ impl LxmfOutboundDriver { interfaces: Vec::new(), auto_delivery_degraded_until: 0.0, path_request_gate: PathRequestGate::new(), - pn_fallback_attempted: HashSet::new(), + pn_cascade_tried: HashMap::new(), + pn_cascade_local: HashSet::new(), + pn_cascade_candidates: Vec::new(), + preferred_pn_hash: None, + pn_deposit_defer_counts: HashMap::new(), direct_path_failovers: HashMap::new(), propagation_sync_target: None, pending_pn_deposits: HashMap::new(), @@ -254,9 +270,24 @@ impl LxmfOutboundDriver { #[allow(clippy::unused_self)] // method slot mirrors other LxmfOutboundDriver mutators pub fn set_propagation_node(&mut self, router: &mut LxmRouter, hash: Option<[u8; 16]>) { + self.preferred_pn_hash = hash; router.set_outbound_propagation_node(hash); } + /// Refresh enabled PN candidates used after Direct path failover exhausts. + pub fn set_pn_cascade_candidates(&mut self, candidates: Vec) { + tracing::info!( + target: "lxmf-outbound", + count = candidates.len(), + preferred = %self + .preferred_pn_hash + .map(hex::encode) + .unwrap_or_else(|| "none".into()), + "PN cascade candidates updated" + ); + self.pn_cascade_candidates = candidates; + } + /// Refresh local path cache from transport GetPathTable rows. pub fn update_path_table(&mut self, entries: &[PathTableRoute]) { self.route_hops.clear(); @@ -424,9 +455,38 @@ impl LxmfOutboundDriver { let sync_blocks = self.propagation_sync_target == Some(prop_hash); let pending_blocks = self.link_delivery.has_pending_to(&prop_hash); if should_defer_propagated_for_pn_link(sync_blocks, pending_blocks) { + if let Some(msg_hash) = message.hash.or(message.message_id) { + let defer_count = self + .pn_deposit_defer_counts + .entry(msg_hash) + .and_modify(|c| *c = c.saturating_add(1)) + .or_insert(1); + if *defer_count >= PN_DEPOSIT_DEFER_ADVANCE_AFTER { + tracing::warn!( + target: "lxmf-outbound", + prop = %prop_hex, + dest = %hex::encode(message.destination_hash), + msg = %hex::encode(msg_hash), + defer_count = *defer_count, + sync_blocks, + pending_blocks, + "DeliverPropagated: PN link busy too long — advancing PN cascade" + ); + self.pn_deposit_defer_counts.remove(&msg_hash); + self.mark_pn_tried(msg_hash, prop_hash); + match self.try_advance_pn_cascade(router, event_tx, message) { + Ok(()) => return, + Err(message) => { + self.emit_outbound_failed(router, event_tx, *message); + return; + } + } + } + } let now = now_f64(); message.next_delivery_attempt = now + f64::from(PATH_REQUEST_WAIT as u32); tracing::debug!( + target: "lxmf-outbound", prop = %prop_hex, dest = %hex::encode(message.destination_hash), sync_blocks, @@ -435,18 +495,26 @@ impl LxmfOutboundDriver { "DeliverPropagated: deferring — PN link busy" ); if let Some(hash) = message.hash.or(message.message_id) { + let method = if self.pn_cascade_local.contains(&hash) { + "stored_locally" + } else { + "propagated" + }; emit_outbound_status_with_via( event_tx, Some(serde_json::Value::String(hex::encode(hash))), None, "sending", - Some("propagated"), - None, + Some(method), + Some(prop_hex.clone()), ); } router.send(message); return; } + if let Some(hash) = message.hash.or(message.message_id) { + self.pn_deposit_defer_counts.remove(&hash); + } if !self.known_identities.contains_key(&prop_hex.to_lowercase()) { tracing::debug!( prop = %prop_hex, @@ -700,7 +768,7 @@ impl LxmfOutboundDriver { event_tx: &broadcast::Sender, message: LxMessage, ) { - match self.try_requeue_via_propagation(router, event_tx, message) { + match self.try_advance_pn_cascade(router, event_tx, message) { Ok(()) => {} Err(message) => self.emit_outbound_failed(router, event_tx, *message), } @@ -713,19 +781,40 @@ impl LxmfOutboundDriver { mut message: LxMessage, ) { message.mark_failed(); - let method = delivery_method_label(message.method); + let method = if message + .hash + .or(message.message_id) + .is_some_and(|h| self.pn_cascade_local.contains(&h)) + { + "stored_locally" + } else { + delivery_method_label(message.method) + }; tracing::warn!( + target: "lxmf-outbound", dest = %hex::encode(message.destination_hash), method, attempts = message.delivery_attempts, "LXMF outbound delivery failed" ); if let Some(hash) = message.hash.or(message.message_id) { - self.pn_fallback_attempted.remove(&hash); + let attempts = message.delivery_attempts; + self.clear_pn_cascade_state(hash); self.direct_path_failovers.remove(&hash); self.pending_pn_deposits.remove(&hash); + self.pn_deposit_defer_counts.remove(&hash); let _ = router.mark_outbound_failed(&hash); - emit_outbound_status_by_hash(event_tx, &hash, "failed", Some(method)); + emit_outbound_status_detailed_with_attempts( + event_tx, + Some(serde_json::Value::String(hex::encode(hash))), + None, + "failed", + Some(method), + None, + None, + None, + Some(attempts), + ); } let payload = lxmf_payload_from_message( &message, @@ -739,41 +828,92 @@ impl LxmfOutboundDriver { emit_outbound_status(event_tx, &payload, "failed", method); } - /// After Direct link failure, deposit once via preferred remote PN (Ratspeak parity). - /// Returns `Ok(())` when re-queued as Propagated; `Err(message)` when caller should fail. - fn try_requeue_via_propagation( + fn ordered_pn_cascade(&self) -> Vec { + build_pn_cascade_order(&self.pn_cascade_candidates, self.preferred_pn_hash) + } + + fn mark_pn_tried(&mut self, msg_hash: [u8; 32], pn_hash: [u8; 16]) { + if self.pn_cascade_tried.len() >= PN_CASCADE_TRIED_MAX + && !self.pn_cascade_tried.contains_key(&msg_hash) + { + if let Some(oldest) = self.pn_cascade_tried.keys().next().copied() { + self.pn_cascade_tried.remove(&oldest); + self.pn_cascade_local.remove(&oldest); + } + } + self.pn_cascade_tried + .entry(msg_hash) + .or_default() + .insert(pn_hash); + } + + fn clear_pn_cascade_state(&mut self, msg_hash: [u8; 32]) { + self.pn_cascade_tried.remove(&msg_hash); + self.pn_cascade_local.remove(&msg_hash); + self.pn_deposit_defer_counts.remove(&msg_hash); + } + + /// Advance Direct→Propagated cascade: preferred remote → other remotes → local-prop. + /// Returns `Ok(())` when re-queued; `Err(message)` when cascade is exhausted. + fn try_advance_pn_cascade( &mut self, router: &mut LxmRouter, event_tx: &broadcast::Sender, mut message: LxMessage, ) -> Result<(), Box> { - if !should_fallback_direct_to_pn( - message.method, - router.outbound_propagation_node, - &self.self_lxmf_hash, - message - .hash - .or(message.message_id) - .is_some_and(|h| self.pn_fallback_attempted.contains(&h)), - ) { + let Some(msg_hash) = message.hash.or(message.message_id) else { + return Err(Box::new(message)); + }; + // Direct may enter cascade; Propagated advances to the next PN after a deposit fail. + if message.method != DeliveryMethod::Direct && message.method != DeliveryMethod::Propagated + { return Err(Box::new(message)); } - let Some(msg_hash) = message.hash.or(message.message_id) else { + let ordered = self.ordered_pn_cascade(); + let tried = self + .pn_cascade_tried + .get(&msg_hash) + .cloned() + .unwrap_or_default(); + if !cascade_has_capacity(&ordered, &tried) { + tracing::warn!( + target: "lxmf-outbound", + dest = %hex::encode(message.destination_hash), + msg = %hex::encode(msg_hash), + tried = tried.len(), + candidates = ordered.len(), + "PN cascade exhausted — marking outbound failed" + ); + return Err(Box::new(message)); + } + let pick = pick_next_pn_cascade(&ordered, &tried); + let Some(pn_hash) = pick.hash() else { return Err(Box::new(message)); }; - // Quietly drop any leftover Direct queue entry (already removed for most Fail paths). + let method_label = pick.delivery_method_label().unwrap_or("propagated"); router .pending_outbound .retain(|m| m.hash != Some(msg_hash) && m.message_id != Some(msg_hash)); - self.remember_pn_fallback(msg_hash); + self.mark_pn_tried(msg_hash, pn_hash); self.direct_path_failovers.remove(&msg_hash); + self.pn_deposit_defer_counts.remove(&msg_hash); + if pick.is_local() { + self.pn_cascade_local.insert(msg_hash); + } else { + self.pn_cascade_local.remove(&msg_hash); + } + router.set_outbound_propagation_node(Some(pn_hash)); message.method = DeliveryMethod::Propagated; message.delivery_attempts = 0; message.next_delivery_attempt = 0.0; tracing::info!( + target: "lxmf-outbound", dest = %hex::encode(message.destination_hash), msg = %hex::encode(msg_hash), - "LXMF Direct failed; falling back to preferred remote propagation node" + pn = %hex::encode(pn_hash), + cascade_step = method_label, + is_local = pick.is_local(), + "LXMF advancing PN cascade" ); router.send(message); emit_outbound_status_with_via( @@ -781,22 +921,12 @@ impl LxmfOutboundDriver { Some(serde_json::Value::String(hex::encode(msg_hash))), None, "sending", - Some("propagated"), - None, + Some(method_label), + Some(hex::encode(pn_hash)), ); Ok(()) } - fn remember_pn_fallback(&mut self, msg_hash: [u8; 32]) { - if self.pn_fallback_attempted.len() >= PN_FALLBACK_ATTEMPTED_MAX { - // Evict an arbitrary entry so floods cannot grow unbounded. - if let Some(oldest) = self.pn_fallback_attempted.iter().next().copied() { - self.pn_fallback_attempted.remove(&oldest); - } - } - self.pn_fallback_attempted.insert(msg_hash); - } - fn pack_for_propagation( &self, message: &mut LxMessage, @@ -849,14 +979,17 @@ impl LxmfOutboundDriver { match result { DeliveryResult::Complete { msg_hash, .. } => { if let Some(hash) = msg_hash { - let was_pn_fallback = self.pn_fallback_attempted.contains(&hash); + let was_local = self.pn_cascade_local.contains(&hash); let pending_deposit = self.pending_pn_deposits.remove(&hash); - let method = if was_pn_fallback || pending_deposit.is_some() { + let was_cascade = self.pn_cascade_tried.contains_key(&hash); + let method = if was_local { + Some("stored_locally") + } else if was_cascade || pending_deposit.is_some() { Some("propagated") } else { None }; - self.pn_fallback_attempted.remove(&hash); + self.clear_pn_cascade_state(hash); self.direct_path_failovers.remove(&hash); let _ = router.mark_outbound_delivered(&hash); if let Some((pn_hash, transient_id)) = pending_deposit { @@ -868,11 +1001,17 @@ impl LxmfOutboundDriver { .map(hex::encode) .unwrap_or_default(), pn_hash = %hex::encode(pn_hash), - pn_fallback = was_pn_fallback, + stored_locally = was_local, "outbound PN deposit Completes" ); } - emit_outbound_status_by_hash(event_tx, &hash, "delivered", method); + // Local-prop is offline inbox — not peer-delivered Complete. + let status = if was_local { + "stored_locally" + } else { + "delivered" + }; + emit_outbound_status_by_hash(event_tx, &hash, status, method); } } DeliveryResult::Rejected { @@ -887,8 +1026,17 @@ impl LxmfOutboundDriver { reason = %reason, "LXMF delivery Rejected" ); - // Peer/PN rejected the resource — do not retry; only Direct→PN once. - match self.try_requeue_via_propagation(router, event_tx, message) { + // Peer/PN rejected — advance cascade (next remote or local-prop). + if message.method == DeliveryMethod::Propagated { + if let Some(hash) = message.hash.or(message.message_id) { + // Mark the PN that rejected if we know it from pending deposit clear above. + // dest is not in Rejected; mark current outbound PN if set. + if let Some(pn) = router.outbound_propagation_node { + self.mark_pn_tried(hash, pn); + } + } + } + match self.try_advance_pn_cascade(router, event_tx, message) { Ok(()) => {} Err(message) => self.emit_outbound_failed(router, event_tx, *message), } @@ -934,7 +1082,13 @@ impl LxmfOutboundDriver { } else { message }; - match self.try_requeue_via_propagation(router, event_tx, message) { + // Propagated deposit failed after retries — mark this PN tried and advance. + if message.method == DeliveryMethod::Propagated { + if let Some(hash) = message.hash.or(message.message_id) { + self.mark_pn_tried(hash, dest_hash); + } + } + match self.try_advance_pn_cascade(router, event_tx, message) { Ok(()) => {} Err(message) => self.emit_outbound_failed(router, event_tx, *message), } @@ -1136,6 +1290,8 @@ pub(crate) fn choose_lxmf_send_route( } /// Whether a failed Direct attempt may be re-queued once via preferred remote PN. +/// Retained for unit coverage of the preferred-remote gate; live path uses PN cascade. +#[cfg(test)] pub(crate) fn should_fallback_direct_to_pn( method: DeliveryMethod, preferred_pn: Option<[u8; 16]>, @@ -1208,6 +1364,31 @@ fn emit_outbound_status_detailed( sent_via: Option, tried_interfaces: Option>, failover_rounds: Option, +) { + emit_outbound_status_detailed_with_attempts( + event_tx, + message_hash, + to_hash, + status, + delivery_method, + sent_via, + tried_interfaces, + failover_rounds, + None, + ); +} + +#[allow(clippy::too_many_arguments)] // status frame fields travel together +fn emit_outbound_status_detailed_with_attempts( + event_tx: &broadcast::Sender, + message_hash: Option, + to_hash: Option, + status: &str, + delivery_method: Option<&str>, + sent_via: Option, + tried_interfaces: Option>, + failover_rounds: Option, + delivery_attempts: Option, ) { let mut payload = serde_json::Map::new(); if let Some(h) = message_hash { @@ -1232,6 +1413,9 @@ fn emit_outbound_status_detailed( if let Some(rounds) = failover_rounds { payload.insert("failover_rounds".into(), serde_json::json!(rounds)); } + if let Some(attempts) = delivery_attempts { + payload.insert("delivery_attempts".into(), serde_json::json!(attempts)); + } let frame = serde_json::json!({ "type": "lxmf_outbound_status", "payload": payload, @@ -1906,6 +2090,27 @@ mod tests { assert_eq!(got_link, link_id); } + #[test] + fn pn_cascade_source_contract_replaces_one_shot_fallback() { + let src = include_str!("lxmf_outbound.rs"); + assert!( + src.contains("fn try_advance_pn_cascade"), + "outbound driver must advance multi-PN cascade after Direct exhaust" + ); + assert!( + src.contains("match self.try_advance_pn_cascade"), + "delivery fail paths must call try_advance_pn_cascade" + ); + assert!( + src.contains("stored_locally"), + "local-prop cascade step must emit stored_locally" + ); + assert!( + src.contains("PN_DEPOSIT_DEFER_ADVANCE_AFTER"), + "sync/pending PN-link deferral must eventually advance cascade" + ); + } + #[test] fn outbound_source_exposes_inbound_packet_sender_adapter() { let src = include_str!("lxmf_outbound.rs"); diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index fcffc31b4..c0feabdff 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -1237,6 +1237,14 @@ impl StackHandle { if let Some(live) = &self.live { live.set_outbound_propagation_node(prop_hash.as_deref()) .await; + live.refresh_pn_cascade_candidates().await; + if prop_hash.is_none() { + tracing::warn!( + target: "lxmf-outbound", + preferred_id = %id, + "set_preferred_propagation: preferred row has no destination_hash" + ); + } } Ok(()) } @@ -1347,9 +1355,15 @@ impl StackHandle { live.set_local_propagation_serving(enabled).await; } } - let mut inner = self.inner.write().await; - inner.set_propagation_enabled(id, enabled)?; - inner.save(&self.config_dir, &self.storage_dir)?; + { + let mut inner = self.inner.write().await; + inner.set_propagation_enabled(id, enabled)?; + inner.save(&self.config_dir, &self.storage_dir)?; + } + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + live.refresh_pn_cascade_candidates().await; + } Ok(()) } @@ -1407,6 +1421,11 @@ impl StackHandle { } } inner.save(&self.config_dir, &self.storage_dir)?; + drop(inner); + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + live.refresh_pn_cascade_candidates().await; + } Ok(serde_json::json!({ "ok": true, "node": row })) } @@ -1447,6 +1466,10 @@ impl StackHandle { live.set_outbound_propagation_node(None).await; } } + #[cfg(feature = "rns-stack")] + if let Some(live) = &self.live { + live.refresh_pn_cascade_candidates().await; + } Ok(()) } diff --git a/reticulum-sidecar/src/stack/pn_cascade.rs b/reticulum-sidecar/src/stack/pn_cascade.rs new file mode 100644 index 000000000..61535a9c6 --- /dev/null +++ b/reticulum-sidecar/src/stack/pn_cascade.rs @@ -0,0 +1,279 @@ +//! Multi-PN outbound cascade after Direct path failover exhausts. +//! +//! Order: preferred remote → other enabled remotes (hops asc) → local-prop last. + +use std::collections::HashSet; + +/// One configured PN eligible for Direct→Propagated cascade. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PnCascadeCandidate { + pub hash: [u8; 16], + /// True for local-prop / self LXMF hash (offline inbox — last resort only). + pub is_local: bool, + pub hops: Option, + pub id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PnCascadePick { + /// Deposit via a remote propagation node. + Remote([u8; 16]), + /// Deposit into local-prop (offline inbox; not peer-delivered). + Local([u8; 16]), + /// No remaining candidates. + Exhausted, +} + +impl PnCascadePick { + pub fn hash(self) -> Option<[u8; 16]> { + match self { + PnCascadePick::Remote(h) | PnCascadePick::Local(h) => Some(h), + PnCascadePick::Exhausted => None, + } + } + + pub fn is_local(self) -> bool { + matches!(self, PnCascadePick::Local(_)) + } + + pub fn delivery_method_label(self) -> Option<&'static str> { + match self { + PnCascadePick::Remote(_) => Some("propagated"), + PnCascadePick::Local(_) => Some("stored_locally"), + PnCascadePick::Exhausted => None, + } + } +} + +/// Build an ordered cascade list from persisted propagation rows. +/// +/// `preferred_hash` (when Some) is tried first among remotes; local is always last +/// when present and enabled. +pub fn build_pn_cascade_order( + candidates: &[PnCascadeCandidate], + preferred_hash: Option<[u8; 16]>, +) -> Vec { + let mut remotes: Vec = + candidates.iter().filter(|c| !c.is_local).cloned().collect(); + remotes.sort_by(|a, b| { + let ah = a.hops.unwrap_or(u8::MAX); + let bh = b.hops.unwrap_or(u8::MAX); + ah.cmp(&bh).then_with(|| a.id.cmp(&b.id)) + }); + if let Some(pref) = preferred_hash { + if let Some(idx) = remotes.iter().position(|c| c.hash == pref) { + let preferred = remotes.remove(idx); + remotes.insert(0, preferred); + } else { + // Preferred hash not in enabled list — still try it first if we know the hash. + remotes.insert( + 0, + PnCascadeCandidate { + hash: pref, + is_local: false, + hops: None, + id: format!("pn-{}", hex::encode(&pref[..4])), + }, + ); + } + } + let mut out = remotes; + if let Some(local) = candidates.iter().find(|c| c.is_local).cloned() { + out.push(local); + } + out +} + +/// Pick the next untried PN from an ordered cascade. +pub fn pick_next_pn_cascade( + ordered: &[PnCascadeCandidate], + tried: &HashSet<[u8; 16]>, +) -> PnCascadePick { + for c in ordered { + if tried.contains(&c.hash) { + continue; + } + if c.is_local { + return PnCascadePick::Local(c.hash); + } + return PnCascadePick::Remote(c.hash); + } + PnCascadePick::Exhausted +} + +/// Whether Direct failure may enter the PN cascade (any untried candidate remains). +pub fn cascade_has_capacity(ordered: &[PnCascadeCandidate], tried: &HashSet<[u8; 16]>) -> bool { + !matches!( + pick_next_pn_cascade(ordered, tried), + PnCascadePick::Exhausted + ) +} + +/// True when `hash_hex` equals self LXMF destination (case-insensitive). +pub fn is_self_lxmf_hash(hash: &[u8; 16], self_lxmf_hash_hex: &str) -> bool { + hex::encode(hash).eq_ignore_ascii_case(self_lxmf_hash_hex.trim()) +} + +/// Parse enabled propagation rows into cascade candidates. +pub fn candidates_from_propagation_rows( + rows: &[(String, bool, Option, Option)], + self_lxmf_hash_hex: &str, + local_prop_enabled: bool, +) -> Vec { + let self_norm = self_lxmf_hash_hex.trim().to_lowercase(); + let mut out = Vec::new(); + for (id, enabled, dest_hash, hops) in rows { + if id == "local-prop" { + if !local_prop_enabled && !*enabled { + continue; + } + let enabled_local = local_prop_enabled || *enabled; + if !enabled_local { + continue; + } + let hash = dest_hash + .as_ref() + .and_then(|h| parse_hash16(h)) + .or_else(|| parse_hash16(&self_norm)); + let Some(hash) = hash else { continue }; + out.push(PnCascadeCandidate { + hash, + is_local: true, + hops: *hops, + id: id.clone(), + }); + continue; + } + if !*enabled { + continue; + } + let Some(hash) = dest_hash.as_ref().and_then(|h| parse_hash16(h)) else { + continue; + }; + if is_self_lxmf_hash(&hash, &self_norm) { + continue; + } + out.push(PnCascadeCandidate { + hash, + is_local: false, + hops: *hops, + id: id.clone(), + }); + } + out +} + +fn parse_hash16(hex_str: &str) -> Option<[u8; 16]> { + let clean: String = hex_str.chars().filter(char::is_ascii_hexdigit).collect(); + if clean.len() != 32 { + return None; + } + let bytes = hex::decode(&clean).ok()?; + let arr: [u8; 16] = bytes.try_into().ok()?; + Some(arr) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn remote(hash_byte: u8, hops: Option, id: &str) -> PnCascadeCandidate { + PnCascadeCandidate { + hash: [hash_byte; 16], + is_local: false, + hops, + id: id.into(), + } + } + + fn local(hash_byte: u8) -> PnCascadeCandidate { + PnCascadeCandidate { + hash: [hash_byte; 16], + is_local: true, + hops: Some(0), + id: "local-prop".into(), + } + } + + #[test] + fn order_preferred_first_then_hops_then_local() { + let candidates = vec![ + remote(0x22, Some(4), "pn-far"), + remote(0x11, Some(1), "pn-near"), + local(0x99), + ]; + let preferred = [0x22; 16]; + let ordered = build_pn_cascade_order(&candidates, Some(preferred)); + assert_eq!(ordered[0].hash, preferred); + assert_eq!(ordered[1].id, "pn-near"); + assert!(ordered.last().is_some_and(|c| c.is_local)); + } + + #[test] + fn pick_skips_tried_and_ends_on_local() { + let ordered = build_pn_cascade_order( + &[ + remote(0x11, Some(1), "a"), + remote(0x22, Some(2), "b"), + local(0x99), + ], + Some([0x11; 16]), + ); + let mut tried = HashSet::new(); + assert_eq!( + pick_next_pn_cascade(&ordered, &tried), + PnCascadePick::Remote([0x11; 16]) + ); + tried.insert([0x11; 16]); + assert_eq!( + pick_next_pn_cascade(&ordered, &tried), + PnCascadePick::Remote([0x22; 16]) + ); + tried.insert([0x22; 16]); + assert_eq!( + pick_next_pn_cascade(&ordered, &tried), + PnCascadePick::Local([0x99; 16]) + ); + tried.insert([0x99; 16]); + assert_eq!( + pick_next_pn_cascade(&ordered, &tried), + PnCascadePick::Exhausted + ); + } + + #[test] + fn cascade_capacity_false_when_exhausted() { + let ordered = build_pn_cascade_order(&[remote(0x11, None, "a")], None); + let mut tried = HashSet::new(); + tried.insert([0x11; 16]); + assert!(!cascade_has_capacity(&ordered, &tried)); + assert!(cascade_has_capacity(&ordered, &HashSet::new())); + } + + #[test] + fn candidates_from_rows_skips_disabled_and_self_remote() { + let self_hex = "aa".repeat(16); + let rows = vec![ + ("pn-a".into(), true, Some("bb".repeat(16)), Some(1u8)), + ("pn-self".into(), true, Some(self_hex.clone()), Some(0u8)), + ("pn-off".into(), false, Some("cc".repeat(16)), None), + ("local-prop".into(), true, Some(self_hex.clone()), Some(0)), + ]; + let c = candidates_from_propagation_rows(&rows, &self_hex, true); + assert_eq!(c.iter().filter(|x| !x.is_local).count(), 1); + assert_eq!(c.iter().filter(|x| x.is_local).count(), 1); + } + + #[test] + fn delivery_method_labels() { + assert_eq!( + PnCascadePick::Remote([0; 16]).delivery_method_label(), + Some("propagated") + ); + assert_eq!( + PnCascadePick::Local([0; 16]).delivery_method_label(), + Some("stored_locally") + ); + assert_eq!(PnCascadePick::Exhausted.delivery_method_label(), None); + } +} diff --git a/src/main/ipc/reticulum-handlers.ts b/src/main/ipc/reticulum-handlers.ts index 2bb2c115f..62235df37 100644 --- a/src/main/ipc/reticulum-handlers.ts +++ b/src/main/ipc/reticulum-handlers.ts @@ -37,17 +37,28 @@ import { import type { ReticulumSidecarManager } from '../reticulum-sidecar-manager'; import { parseEnabledInterfaceNames } from '../reticulumInterfaceIssueScope'; import { assertIpcSender } from '../validate-ipc-sender'; +import { isLxmfRecentApiPath } from './reticulumLxmfRecentPath'; /** Shared rolling window for all reticulum proxy verbs (Get/Post/Put/Delete). */ const reticulumProxyIpcRateLimit = createIpcRateLimiter({ - max: 300, + max: 900, windowMs: MS_PER_MINUTE, label: 'reticulum:proxy', }); +/** + * Inbound LXMF catch-up (`GET /api/v1/lxmf/recent`). Own bucket so WS-lag recovery + * cannot be starved by peer/interface polls — and cannot monopolize the shared ceiling. + */ +const reticulumLxmfRecentIpcRateLimit = createIpcRateLimiter({ + max: 120, + windowMs: MS_PER_MINUTE, + label: 'reticulum:lxmfRecent', +}); + /** * Realtime LXST PCM ingest: QualityHigh is ~16.7 frames/s (~1000/min). - * Separate from the shared 300/min proxy bucket so calls do not starve mesh control IPC. + * Separate from the shared 900/min proxy bucket so calls do not starve mesh control IPC. */ const reticulumVoiceAudioIpcRateLimit = createIpcRateLimiter({ max: 2000, @@ -57,7 +68,7 @@ const reticulumVoiceAudioIpcRateLimit = createIpcRateLimiter({ /** * LRGP games control/poll traffic. Own bucket so session polls + moves do not - * starve the shared 300/min reticulum proxy ceiling. + * starve the shared 900/min reticulum proxy ceiling. */ const reticulumGamesIpcRateLimit = createIpcRateLimiter({ max: 600, @@ -213,11 +224,15 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { ipcMain.handle('reticulum:proxyGet', async (event, apiPath: unknown) => { assertIpcSender(event, 'reticulum:proxyGet'); - reticulumProxyIpcRateLimit.checkOrThrow(); const pathArg = assertProxyApiPath(apiPath); if (isGamesApiPath(pathArg)) { throw new Error('LRGP games require reticulum:games* IPC channels'); } + if (isLxmfRecentApiPath(pathArg)) { + reticulumLxmfRecentIpcRateLimit.checkOrThrow(); + } else { + reticulumProxyIpcRateLimit.checkOrThrow(); + } try { const m = ensureManager(); return await m.proxyGet(pathArg); @@ -252,7 +267,7 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { }); /** - * Realtime LXST PCM frames. Uses a dedicated rate limit (not the shared 300/min + * Realtime LXST PCM frames. Uses a dedicated rate limit (not the shared 900/min * proxy ceiling) so voice TX does not starve control-plane proxy IPC. */ ipcMain.handle('reticulum:voiceSendAudio', async (event, opts: unknown) => { diff --git a/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts b/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts index ab1c4f598..20f54aca9 100644 --- a/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts +++ b/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts @@ -3,6 +3,8 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import { describe, expect, it } from 'vitest'; +import { isLxmfRecentApiPath } from './reticulumLxmfRecentPath'; + const HANDLERS_SOURCE = readFileSync(join(__dirname, 'reticulum-handlers.ts'), 'utf-8'); const SIDECAR_STACK_SOURCE = readFileSync( join(__dirname, '../../../reticulum-sidecar/src/stack/mod.rs'), @@ -14,8 +16,8 @@ const SIDECAR_LIVE_SOURCE = readFileSync( ); describe('reticulum proxy rate limit + 100k peer ceilings (source contract)', () => { - it('caps shared proxy IPC at 300/min and treats rate-limit as expected', () => { - expect(HANDLERS_SOURCE).toMatch(/max:\s*300/); + it('caps shared proxy IPC at 900/min and treats rate-limit as expected', () => { + expect(HANDLERS_SOURCE).toMatch(/max:\s*900/); expect(HANDLERS_SOURCE).toContain("label: 'reticulum:proxy'"); expect(HANDLERS_SOURCE).toContain('isExpectedReticulumProxyError'); expect(HANDLERS_SOURCE).toContain("from '../../shared/reticulumProxyIpcError'"); @@ -26,9 +28,20 @@ describe('reticulum proxy rate limit + 100k peer ceilings (source contract)', () expect(sharedSource).toContain("lower.includes('rate limit exceeded')"); }); + it('routes LXMF recent catch-up onto a dedicated 120/min bucket', () => { + expect(HANDLERS_SOURCE).toMatch( + /const reticulumLxmfRecentIpcRateLimit = createIpcRateLimiter\(\{\s*max:\s*120,[\s\S]*?label:\s*'reticulum:lxmfRecent'/, + ); + expect(HANDLERS_SOURCE).toContain('isLxmfRecentApiPath'); + expect(HANDLERS_SOURCE).toContain('reticulumLxmfRecentIpcRateLimit.checkOrThrow()'); + expect(isLxmfRecentApiPath('/api/v1/lxmf/recent')).toBe(true); + expect(isLxmfRecentApiPath('/api/v1/lxmf/recent?since_ts=1')).toBe(true); + expect(isLxmfRecentApiPath('/api/v1/lxmf/send')).toBe(false); + }); + it('applies the shared proxy rate limit to picker-gated RNCP handlers', () => { // Dedicated rncpSend/Fetch/setRncpListener bypass generic proxyPost gating but must - // still share the 300/min ceiling so a compromised renderer cannot storm the sidecar. + // still share the 900/min ceiling so a compromised renderer cannot storm the sidecar. for (const channel of [ 'reticulum:rncpSend', 'reticulum:rncpFetch', diff --git a/src/main/ipc/reticulumLxmfRecentPath.ts b/src/main/ipc/reticulumLxmfRecentPath.ts new file mode 100644 index 000000000..56fbd083d --- /dev/null +++ b/src/main/ipc/reticulumLxmfRecentPath.ts @@ -0,0 +1,5 @@ +/** Path-only match for LXMF recent catch-up (query string ignored). */ +export function isLxmfRecentApiPath(apiPath: string): boolean { + const pathOnly = apiPath.split('?', 1)[0] ?? apiPath; + return pathOnly === '/api/v1/lxmf/recent'; +} diff --git a/src/main/support-bundle.test.ts b/src/main/support-bundle.test.ts index f0e56d008..64d16a8fc 100644 --- a/src/main/support-bundle.test.ts +++ b/src/main/support-bundle.test.ts @@ -42,6 +42,7 @@ import { app } from 'electron'; import { buildSupportBundleZip, defaultSupportBundleFilename, + extractLxmfOutboundLogSlice, isSupportBundleMode, readReticulumDeveloperArtifacts, redactMnemonicFromStackJson, @@ -98,6 +99,27 @@ describe('defaultSupportBundleFilename', () => { }); }); +describe('extractLxmfOutboundLogSlice', () => { + it('keeps LXMF outbound / PN cascade lines and drops unrelated noise', () => { + const chunk = Buffer.from( + [ + 'info hello world', + 'info target=lxmf-outbound LXMF advancing PN cascade', + 'warn DeliverPropagated: deferring — PN link busy', + 'debug peer refresh ok', + 'info target=propagation-deposit outbound PN deposit Completes', + ].join('\n'), + 'utf8', + ); + const slice = extractLxmfOutboundLogSlice(chunk).toString('utf8'); + expect(slice).toContain('LXMF advancing PN cascade'); + expect(slice).toContain('DeliverPropagated'); + expect(slice).toContain('propagation-deposit'); + expect(slice).not.toContain('hello world'); + expect(slice).not.toContain('peer refresh ok'); + }); +}); + describe('redactMnemonicFromStackJson', () => { it('removes identity.mnemonic from stack JSON', () => { const raw = JSON.stringify({ diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index cc018a8d4..2aede9453 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -170,6 +170,7 @@ Contents: mesh-client.db — SQLite database backup (contains secrets) reticulum/config — rnsd interface config (if present) reticulum/mesh_client_stack.json — Sidecar stack state, mnemonic redacted (if present) + reticulum/lxmf-outbound.log — Filtered LXMF outbound / PN cascade lines from app logs mesh-client.log — Application log (current session) mesh-client.log.1 — Prior session log (preserved on restart) or size-rotated backup manifest.json — App version, buildChannel, and platform metadata @@ -177,6 +178,32 @@ Contents: `; } +/** Extract LXMF outbound / PN cascade diagnostic lines for developer triage. */ +export function extractLxmfOutboundLogSlice(...logChunks: Buffer[]): Buffer { + const patterns = [ + /lxmf-outbound/i, + /propagation-deposit/i, + /LXMF advancing PN cascade/i, + /LXMF outbound delivery failed/i, + /Direct path failover/i, + /PN cascade/i, + /DeliverPropagated/i, + ]; + const lines: string[] = []; + for (const chunk of logChunks) { + if (!chunk.length) continue; + const text = chunk.toString('utf8'); + for (const line of text.split(/\r?\n/)) { + if (patterns.some((re) => re.test(line))) { + lines.push(line); + } + } + } + // Cap slice so huge logs cannot bloat the zip. + const capped = lines.length > 4000 ? lines.slice(-4000) : lines; + return Buffer.from(capped.join('\n') + (capped.length ? '\n' : ''), 'utf8'); +} + async function readFileOrEmpty(filePath: string): Promise { try { return await fs.promises.readFile(filePath); @@ -251,14 +278,14 @@ export async function buildSupportBundleZip( const logPath = getLogPath(); const logDir = path.dirname(logPath); - zip.file('mesh-client.log', await readFileOrEmpty(logPath)); + const currentLog = await readFileOrEmpty(logPath); + zip.file('mesh-client.log', currentLog); const backupPath = path.join(logDir, LOG_BACKUP_FILENAME); + let backupLog: Buffer = Buffer.alloc(0); if (fs.existsSync(backupPath)) { - zip.file( - LOG_BACKUP_FILENAME, - await readFileTailOrEmpty(backupPath, MAX_SUPPORT_BUNDLE_LOG_BACKUP_BYTES), - ); + backupLog = await readFileTailOrEmpty(backupPath, MAX_SUPPORT_BUNDLE_LOG_BACKUP_BYTES); + zip.file(LOG_BACKUP_FILENAME, backupLog); } zip.file('manifest.json', JSON.stringify(buildManifest(mode), null, 2)); @@ -283,6 +310,10 @@ export async function buildSupportBundleZip( if (reticulumArtifacts.stackJson) { zip.file('reticulum/mesh_client_stack.json', reticulumArtifacts.stackJson); } + const lxmfSlice = extractLxmfOutboundLogSlice(backupLog, currentLog); + if (lxmfSlice.length > 0) { + zip.file('reticulum/lxmf-outbound.log', lxmfSlice); + } } const buf = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); diff --git a/src/renderer/components/ReticulumMessageStatusBadge.test.tsx b/src/renderer/components/ReticulumMessageStatusBadge.test.tsx index 7f608970b..cec14e5ba 100644 --- a/src/renderer/components/ReticulumMessageStatusBadge.test.tsx +++ b/src/renderer/components/ReticulumMessageStatusBadge.test.tsx @@ -68,4 +68,18 @@ describe('ReticulumMessageStatusBadge', () => { screen.getByLabelText('chatPanel.sentViaPropagation: chatPanel.reticulumSendPropagated'), ).toBeTruthy(); }); + + it('shows PN with house icon for local-prop stored_locally (not green check)', async () => { + await renderAndAssertAxe( + , + ); + expect( + screen.getByLabelText( + 'chatPanel.sentViaLocalPropagation: chatPanel.reticulumSendStoredLocally', + ), + ).toBeTruthy(); + // Label PN + house emoji — not a delivery checkmark. + expect(screen.getByText(/reticulumPnAbbrev\s+\u{1F3E0}/u)).toBeTruthy(); + expect(screen.queryByText(/✓/)).toBeNull(); + }); }); diff --git a/src/renderer/components/ReticulumMessageStatusBadge.tsx b/src/renderer/components/ReticulumMessageStatusBadge.tsx index 1133a4177..4cdb73778 100644 --- a/src/renderer/components/ReticulumMessageStatusBadge.tsx +++ b/src/renderer/components/ReticulumMessageStatusBadge.tsx @@ -18,6 +18,9 @@ export interface ReticulumMessageStatusBadgeProps { type OutboundStatus = ReticulumMessageStatusBadgeProps['status']; +/** House mark for local-prop (own PN) offline storage — not a peer-delivery check. */ +const LOCAL_PN_HOUSE_ICON = '\u{1F3E0}'; + function tooltipKeyForVia(via: ReticulumVia | undefined): string { switch (via) { case 'rf': @@ -33,7 +36,14 @@ function tooltipKeyForVia(via: ReticulumVia | undefined): string { } } -function statusIcon(status: OutboundStatus): string { +function statusIcon( + status: OutboundStatus, + deliveryMethod: MessageRecord['reticulumDeliveryMethod'] | undefined, +): string { + // Local-prop cascade last resort: show house instead of green check / red X. + if (deliveryMethod === 'stored_locally' && status !== 'failed') { + return LOCAL_PN_HOUSE_ICON; + } switch (status) { case 'sending': return '\u23F3'; @@ -44,7 +54,13 @@ function statusIcon(status: OutboundStatus): string { } } -function statusColorClass(status: OutboundStatus): string { +function statusColorClass( + status: OutboundStatus, + deliveryMethod: MessageRecord['reticulumDeliveryMethod'] | undefined, +): string { + if (deliveryMethod === 'stored_locally' && status !== 'failed') { + return 'text-amber-400'; + } switch (status) { case 'sending': return 'text-muted'; @@ -63,11 +79,17 @@ function statusLabelText( ): string { switch (status) { case 'sending': + if (deliveryMethod === 'stored_locally') { + return t('chatPanel.reticulumSendStoringLocally'); + } if (deliveryMethod === 'propagated') { return t('chatPanel.reticulumSendPropagated'); } return t('chatPanel.reticulumSendSending'); case 'acked': + if (deliveryMethod === 'stored_locally') { + return t('chatPanel.reticulumSendStoredLocally'); + } if (deliveryMethod === 'propagated') { return t('chatPanel.reticulumSendStoredAtPn'); } @@ -86,6 +108,9 @@ function viaPrefixText( atoms: ReticulumVia[], viasLabel: string, ): string { + if (deliveryMethod === 'stored_locally') { + return t('chatPanel.sentViaLocalPropagation'); + } if (deliveryMethod === 'propagated') { return t('chatPanel.sentViaPropagation'); } @@ -108,7 +133,7 @@ export function ReticulumMessageStatusBadge({ const atoms = parseReticulumViaAtoms(via); const viasLabel = formatReticulumViaBadgeLabel(via ?? 'network'); const label = - deliveryMethod === 'propagated' + deliveryMethod === 'propagated' || deliveryMethod === 'stored_locally' ? t('chatPanel.reticulumPnAbbrev') : deliveryMethod === 'paper' ? t('chatPanel.reticulumSendPaper') @@ -121,8 +146,8 @@ export function ReticulumMessageStatusBadge({ return ( ); diff --git a/src/renderer/lib/ingest/reticulumIngest.ts b/src/renderer/lib/ingest/reticulumIngest.ts index 5900f1032..97f954a52 100644 --- a/src/renderer/lib/ingest/reticulumIngest.ts +++ b/src/renderer/lib/ingest/reticulumIngest.ts @@ -398,6 +398,10 @@ export function persistReticulumOutboundRecord( ...(record.reticulumDeliveryMethod ? { delivery_method: record.reticulumDeliveryMethod } : {}), + ...(typeof record.reticulumDeliveryAttempts === 'number' && + Number.isFinite(record.reticulumDeliveryAttempts) + ? { delivery_attempts: Math.trunc(record.reticulumDeliveryAttempts) } + : {}), }) .catch((e: unknown) => { console.warn('[reticulumIngest] save outbound ' + errLikeToLogString(e)); diff --git a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts index 8a1e7cf32..cbebf5b56 100644 --- a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts +++ b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts @@ -25,7 +25,7 @@ import { parseReticulumDeliveryMethod } from '@/shared/reticulumDeliveryMethod'; /** Map sidecar `lxmf_outbound_status` wire status to UI store status. Unknown → null. */ export function mapLxmfOutboundWireStatus(wireStatus: string): MessageStatus | null { - if (wireStatus === 'delivered') return 'acked'; + if (wireStatus === 'delivered' || wireStatus === 'stored_locally') return 'acked'; if (wireStatus === 'failed') return 'failed'; if (wireStatus === 'sending') return 'sending'; return null; @@ -148,6 +148,7 @@ export function persistReticulumOutboundMessageStatus( errorMessage?: string, sentVia?: MessageTransport, deliveryMethod?: MessageRecord['reticulumDeliveryMethod'], + deliveryAttempts?: number, ): boolean { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Identity bucket may be absent at runtime. const before = useMessageStore.getState().messages[identityId]?.[messageId]; @@ -155,12 +156,16 @@ export function persistReticulumOutboundMessageStatus( if (!before) return false; // Link-timeout failure bridge can mark Failed before WS Direct→PN fallback arrives. // Authoritative sending+propagated must revive so the badge is not stuck as PN ✗. - if (before.status === 'failed' && status === 'sending' && deliveryMethod === 'propagated') { + if ( + before.status === 'failed' && + status === 'sending' && + (deliveryMethod === 'propagated' || deliveryMethod === 'stored_locally') + ) { const revived: MessageRecord = { ...before, status: 'sending', error: undefined, - reticulumDeliveryMethod: 'propagated', + reticulumDeliveryMethod: deliveryMethod, ...(sentVia != null ? { receivedVia: sentVia } : {}), }; upsertMessage(identityId, revived); @@ -217,6 +222,14 @@ export function persistReticulumOutboundMessageStatus( record = { ...record, reticulumDeliveryMethod: deliveryMethod }; patched = true; } + if ( + deliveryAttempts != null && + Number.isFinite(deliveryAttempts) && + deliveryAttempts !== record.reticulumDeliveryAttempts + ) { + record = { ...record, reticulumDeliveryAttempts: Math.trunc(deliveryAttempts) }; + patched = true; + } if (patched) { upsertMessage(identityId, record); } @@ -252,6 +265,7 @@ export function persistReticulumOutboundMessageStatus( export interface ApplyReticulumOutboundDeliveryStatusOpts { sentVia?: string | null; deliveryMethod?: string | null; + deliveryAttempts?: number | null; } /** Apply sidecar Completes/Fails (and optional egress `sent_via`): store + SQLite. */ @@ -277,6 +291,10 @@ export function applyReticulumOutboundDeliveryStatus( } const sentVia = parseWireSentVia(opts?.sentVia); const deliveryMethod = parseReticulumDeliveryMethod(opts?.deliveryMethod); + const deliveryAttempts = + opts?.deliveryAttempts != null && Number.isFinite(opts.deliveryAttempts) + ? Math.trunc(opts.deliveryAttempts) + : undefined; const applied = persistReticulumOutboundMessageStatus( identityId, normalizedHash, @@ -284,6 +302,7 @@ export function applyReticulumOutboundDeliveryStatus( undefined, sentVia, deliveryMethod, + deliveryAttempts, ); if (applied) { pendingDeliveryByKey.delete(pendingDeliveryKey(identityId, normalizedHash)); diff --git a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts index 2d3fe0fdb..2042c9f13 100644 --- a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts @@ -57,6 +57,6 @@ describe('fetchRecentInboundLxmf', () => { await expect(fetchRecentInboundLxmf()).resolves.toEqual([]); expect(warnSpy).toHaveBeenCalled(); const detailed = await fetchRecentInboundLxmfDetailed(); - expect(detailed).toEqual({ messages: [], ringLen: null }); + expect(detailed).toEqual({ messages: [], ringLen: null, rateLimited: false }); }); }); diff --git a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts index 96b8cd005..51fbad468 100644 --- a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts +++ b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts @@ -1,6 +1,12 @@ import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import type { ReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest'; import { noteReticulumInboundRingLen } from '@/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics'; +import { + clearReticulumProxyRateLimitBackoff, + isReticulumProxyRateLimitBackoffActive, + noteReticulumProxyErrorIfRateLimited, + reticulumProxyRateLimitBackoffRemainingMs, +} from '@/renderer/lib/reticulum/reticulumProxyRateLimitBackoff'; export interface FetchRecentInboundLxmfOpts { /** @@ -17,6 +23,8 @@ export interface FetchRecentInboundLxmfOpts { export interface FetchRecentInboundLxmfResult { messages: ReticulumLxmfPayload[]; ringLen: number | null; + /** Set when the call was skipped or failed due to proxy rate limiting. */ + rateLimited?: boolean; } /** @@ -34,6 +42,13 @@ export async function fetchRecentInboundLxmf( export async function fetchRecentInboundLxmfDetailed( opts: FetchRecentInboundLxmfOpts = {}, ): Promise { + if (isReticulumProxyRateLimitBackoffActive()) { + const remaining = reticulumProxyRateLimitBackoffRemainingMs(); + console.warn( + `[fetchRecentInboundLxmf] skipped — proxy rate-limit backoff remaining=${remaining}ms`, + ); + return { messages: [], ringLen: null, rateLimited: true }; + } const params = new URLSearchParams(); if (opts.sinceTs != null && Number.isFinite(opts.sinceTs)) { params.set('since_ts', String(Math.floor(opts.sinceTs))); @@ -56,6 +71,7 @@ export async function fetchRecentInboundLxmfDetailed( messages?: unknown; ring_len?: unknown; }; + clearReticulumProxyRateLimitBackoff(); const ringLen = typeof body.ring_len === 'number' && Number.isFinite(body.ring_len) ? Math.trunc(body.ring_len) @@ -69,8 +85,9 @@ export async function fetchRecentInboundLxmfDetailed( ringLen, }; } catch (e) { + const rateLimited = noteReticulumProxyErrorIfRateLimited(e); console.warn('[fetchRecentInboundLxmf] ' + errLikeToLogString(e)); - return { messages: [], ringLen: null }; + return { messages: [], ringLen: null, rateLimited }; } } diff --git a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts index cd4fb641d..c7930e336 100644 --- a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts +++ b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts @@ -215,7 +215,7 @@ describe('shouldApplyLinkDeliveryTimeoutFailureBridge', () => { ); }); - it('returns true when only local-prop is available', () => { + it('returns false when only local-prop is enabled (cascade last resort)', () => { const localOnly: PropagationNodeRow = { id: 'local-prop', name: 'Local', @@ -224,11 +224,11 @@ describe('shouldApplyLinkDeliveryTimeoutFailureBridge', () => { preferred: true, }; expect(shouldApplyLinkDeliveryTimeoutFailureBridge([localOnly], 'local-prop', 'auto')).toBe( - true, + false, ); }); - it('returns true when no remote PN target exists', () => { + it('returns true when no remote PN and local-prop disabled', () => { expect(shouldApplyLinkDeliveryTimeoutFailureBridge([], null, 'off')).toBe(true); }); }); diff --git a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts index 8061ceac2..08460385b 100644 --- a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts +++ b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts @@ -2,7 +2,7 @@ import { persistReticulumOutboundMessageStatus, resolveReticulumOutboundDestHash, } from '@/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus'; -import { hasEffectiveReticulumPropagationTarget } from '@/renderer/lib/reticulum/reticulumPropagationEffective'; +import { hasReticulumPnCascadeCapacity } from '@/renderer/lib/reticulum/reticulumPropagationEffective'; import { readReticulumPropagationMode, type ReticulumPropagationMode, @@ -16,15 +16,16 @@ function normalizeDestHash(hash: string): string { } /** - * When a remote preferred PN is available, sidecar owns Direct timeout via - * one-shot PN fallback + `lxmf_outbound_status`. Skip the premature Failed bridge. + * When PN cascade can still run (remote preferred/auto or enabled local-prop), + * sidecar owns Direct timeout via multi-PN fallback + `lxmf_outbound_status`. + * Skip the premature Failed bridge. */ export function shouldApplyLinkDeliveryTimeoutFailureBridge( nodes: PropagationNodeRow[], preferredId: string | null, mode: ReticulumPropagationMode = readReticulumPropagationMode(), ): boolean { - return !hasEffectiveReticulumPropagationTarget(nodes, preferredId, mode); + return !hasReticulumPnCascadeCapacity(nodes, preferredId, mode); } function destHashMatchesPeer(storedHash: string, targetNorm: string): boolean { @@ -47,8 +48,13 @@ export function failReticulumSendingOutboundToDestHash( for (const msg of Object.values(bucket)) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime guard protects external or callback-mutated state. if (msg.status !== 'sending' || msg.to == null) continue; - // Direct→PN fallback re-queues as Propagated and emits sending — do not fail those rows. - if (msg.reticulumDeliveryMethod === 'propagated') continue; + // Cascade re-queues as Propagated / stored_locally and emits sending — do not fail those. + if ( + msg.reticulumDeliveryMethod === 'propagated' || + msg.reticulumDeliveryMethod === 'stored_locally' + ) { + continue; + } const destHash = resolveReticulumOutboundDestHash(msg.to); if (!destHash || !destHashMatchesPeer(destHash, targetNorm)) continue; if (persistReticulumOutboundMessageStatus(identityId, msg.id, 'failed', errorMessage)) { diff --git a/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts b/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts index de9133311..24ed8a307 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'; import type { PropagationNodeRow } from '@/renderer/stores/reticulumPropagationStore'; -import { hasEffectiveReticulumPropagationTarget } from './reticulumPropagationEffective'; +import { + hasEffectiveReticulumPropagationTarget, + hasEnabledLocalPropagation, + hasReticulumPnCascadeCapacity, +} from './reticulumPropagationEffective'; const remoteNode: PropagationNodeRow = { id: 'remote-1', @@ -63,3 +67,22 @@ describe('hasEffectiveReticulumPropagationTarget', () => { expect(hasEffectiveReticulumPropagationTarget([remoteNode], 'remote-1', 'manual')).toBe(true); }); }); + +describe('hasReticulumPnCascadeCapacity', () => { + it('is true for preferred remote or enabled local-prop', () => { + const localEnabled: PropagationNodeRow = { + id: 'local-prop', + name: 'Local', + enabled: true, + status: 'active', + preferred: false, + }; + expect(hasReticulumPnCascadeCapacity([remoteNode], 'remote-1', 'off')).toBe(true); + expect(hasReticulumPnCascadeCapacity([localEnabled], 'local-prop', 'off')).toBe(true); + expect(hasEnabledLocalPropagation([localEnabled])).toBe(true); + }); + + it('is false when nothing is available', () => { + expect(hasReticulumPnCascadeCapacity([], null, 'off')).toBe(false); + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumPropagationEffective.ts b/src/renderer/lib/reticulum/reticulumPropagationEffective.ts index d377136fa..e3eaef440 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationEffective.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationEffective.ts @@ -45,3 +45,21 @@ export function hasEffectiveReticulumPropagationTarget( return pickAutoPropagationNodeId(nodes) != null; } + +/** True when local-prop is enabled (cascade last resort / offline inbox). */ +export function hasEnabledLocalPropagation(nodes: PropagationNodeRow[]): boolean { + return nodes.some((n) => n.id === 'local-prop' && n.enabled); +} + +/** + * True when Direct→PN cascade can still run (remote preferred/auto OR local-prop). + * Link-timeout failure bridge must skip while this is true. + */ +export function hasReticulumPnCascadeCapacity( + nodes: PropagationNodeRow[], + preferredId: string | null, + mode: ReticulumPropagationMode = readReticulumPropagationMode(), +): boolean { + if (hasEffectiveReticulumPropagationTarget(nodes, preferredId, mode)) return true; + return hasEnabledLocalPropagation(nodes); +} diff --git a/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts b/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts index bd4f342fb..fd3962e05 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationSync.test.ts @@ -117,6 +117,9 @@ describe('reticulumPropagationSync', () => { expect(mapPropagationSyncError('propagation offer rejected: Unknown')).toBe( 'reticulumPropagation.syncOfferUnknown', ); + expect(mapPropagationSyncError('PROPAGATION_SYNC_OUTBOUND_BUSY')).toBe( + 'reticulumPropagation.syncOutboundBusy', + ); expect(mapPropagationSyncError('other')).toBe('reticulumPropagation.syncFailed'); }); diff --git a/src/renderer/lib/reticulum/reticulumPropagationSync.ts b/src/renderer/lib/reticulum/reticulumPropagationSync.ts index ad702510d..8c98c867f 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationSync.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationSync.ts @@ -127,8 +127,10 @@ export function mapPropagationSyncError(error: string | null | undefined): strin if (error === 'PROPAGATION_OFFER_UNSUPPORTED') return SYNC_OFFER_UNSUPPORTED_KEY; if (error === 'PROPAGATION_OFFER_PROBE_TIMEOUT') return SYNC_OFFER_PROBE_TIMEOUT_KEY; if (error === 'PROPAGATION_OFFER_PROBE_FAILED') return SYNC_OFFER_PROBE_FAILED_KEY; - // Soft conflict with outbound deposit — callers should treat as non-fatal (no UI error). - if (error === 'PROPAGATION_SYNC_OUTBOUND_BUSY') return SYNC_FAILED_KEY; + // Soft conflict with outbound deposit — surface a specific key for diagnostics/UI. + if (error === 'PROPAGATION_SYNC_OUTBOUND_BUSY') { + return 'reticulumPropagation.syncOutboundBusy'; + } return mapPropagationSyncErrorBySubstring(error) ?? SYNC_FAILED_KEY; } diff --git a/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts new file mode 100644 index 000000000..68c99ab95 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + clearReticulumProxyRateLimitBackoff, + isReticulumProxyRateLimitBackoffActive, + noteReticulumProxyErrorIfRateLimited, + noteReticulumProxyRateLimitHit, + resetReticulumProxyRateLimitBackoffForTests, + reticulumProxyRateLimitBackoffRemainingMs, +} from '@/renderer/lib/reticulum/reticulumProxyRateLimitBackoff'; + +describe('reticulumProxyRateLimitBackoff', () => { + afterEach(() => { + resetReticulumProxyRateLimitBackoffForTests(); + vi.restoreAllMocks(); + }); + + it('arms backoff on rate-limit hit and clears on success', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const now = 1_000_000; + const delay = noteReticulumProxyRateLimitHit(now); + expect(delay).toBeGreaterThan(0); + expect(isReticulumProxyRateLimitBackoffActive(now)).toBe(true); + expect(reticulumProxyRateLimitBackoffRemainingMs(now)).toBe(delay); + expect(isReticulumProxyRateLimitBackoffActive(now + delay + 1)).toBe(false); + clearReticulumProxyRateLimitBackoff(); + expect(isReticulumProxyRateLimitBackoffActive(now)).toBe(false); + }); + + it('notes rate-limit errors and ignores other errors', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(noteReticulumProxyErrorIfRateLimited(new Error('boom'))).toBe(false); + expect( + noteReticulumProxyErrorIfRateLimited(new Error('reticulum:proxy: rate limit exceeded')), + ).toBe(true); + expect(isReticulumProxyRateLimitBackoffActive()).toBe(true); + }); + + it('does not tight-loop — consecutive hits increase backoff', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const first = noteReticulumProxyRateLimitHit(0); + const second = noteReticulumProxyRateLimitHit(0); + expect(second).toBeGreaterThanOrEqual(first); + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts new file mode 100644 index 000000000..3a0f86cb8 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts @@ -0,0 +1,51 @@ +import { isReticulumSidecarRateLimitError } from '@/renderer/lib/reticulum/reticulumSidecarReads'; +import { MS_PER_SECOND } from '@/shared/timeConstants'; + +const DEFAULT_BACKOFF_MS = 5 * MS_PER_SECOND; +const MAX_BACKOFF_MS = 60 * MS_PER_SECOND; + +let backoffUntilMs = 0; +let consecutiveHits = 0; + +/** True while shared/dedicated proxy rate-limit backoff is active. */ +export function isReticulumProxyRateLimitBackoffActive(now = Date.now()): boolean { + return now < backoffUntilMs; +} + +/** Remaining backoff ms (0 when clear). */ +export function reticulumProxyRateLimitBackoffRemainingMs(now = Date.now()): number { + return Math.max(0, backoffUntilMs - now); +} + +/** + * Record a rate-limit error and arm exponential backoff so callers do not tight-loop. + * Returns the backoff duration applied (ms). + */ +export function noteReticulumProxyRateLimitHit(now = Date.now()): number { + consecutiveHits = Math.min(consecutiveHits + 1, 6); + const delay = Math.min(DEFAULT_BACKOFF_MS * 2 ** (consecutiveHits - 1), MAX_BACKOFF_MS); + backoffUntilMs = Math.max(backoffUntilMs, now + delay); + console.warn( + `[reticulumProxyRateLimit] backoff ${delay}ms hits=${consecutiveHits} until=${new Date(backoffUntilMs).toISOString()}`, + ); + return delay; +} + +/** Clear backoff after a successful proxy call. */ +export function clearReticulumProxyRateLimitBackoff(): void { + consecutiveHits = 0; + backoffUntilMs = 0; +} + +/** If `err` is a rate-limit error, arm backoff and return true. */ +export function noteReticulumProxyErrorIfRateLimited(err: unknown): boolean { + if (!isReticulumSidecarRateLimitError(err)) return false; + noteReticulumProxyRateLimitHit(); + return true; +} + +/** Test-only reset. */ +export function resetReticulumProxyRateLimitBackoffForTests(): void { + consecutiveHits = 0; + backoffUntilMs = 0; +} diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 9af0c6ad3..29fd56a28 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -677,7 +677,10 @@ "shareAsPaperMessageLabel": "Zpráva k zašifrování", "shareAsPaperGenerate": "Vytvořit papírové QR", "shareAsPaperCopyFailed": "Nelze zkopírovat papírový odkaz", - "waitingMessagesSilentFetched": "Načteno {{processed}} z rádia…" + "waitingMessagesSilentFetched": "Načteno {{processed}} z rádia…", + "reticulumSendStoringLocally": "Ukládání do doručené pošty místní propagace...", + "reticulumSendStoredLocally": "Uchovává se ve vaší místní propagační doručené poště (nedoručuje se kolegovi)", + "sentViaLocalPropagation": "Doručená pošta pro místní propagaci" }, "chatPayload": { "mention": "Zmínit {{label}}", @@ -4240,7 +4243,8 @@ "addProbing": "Kontrola podpory /offer…", "syncLocalNotSupported": "Místní uzel šíření hostitele nelze synchronizovat přes síť jako vzdálený uzel šíření LXMF.", "enableFailed": "Uzel šíření nelze povolit.", - "disableFailed": "Uzel šíření nelze zakázat." + "disableFailed": "Uzel šíření nelze zakázat.", + "syncOutboundBusy": "Synchronizace propagace odložena — do tohoto uzlu se ukládá odchozí zpráva." }, "reticulumPropagationHeader": { "modeLabel": "Režim propagace", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 3e90d4dbe..03198cedb 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Nachricht zum Verschlüsseln", "shareAsPaperGenerate": "Papier-QR erstellen", "shareAsPaperCopyFailed": "Papierlink konnte nicht kopiert werden", - "waitingMessagesSilentFetched": "{{processed}} vom Funkgerät abgerufen…" + "waitingMessagesSilentFetched": "{{processed}} vom Funkgerät abgerufen…", + "reticulumSendStoringLocally": "Wird in Ihrem lokalen Ausbreitungs-Posteingang gespeichert...", + "reticulumSendStoredLocally": "Wird in Ihrem lokalen Propagationsposteingang aufbewahrt (nicht an Kollegen geliefert)", + "sentViaLocalPropagation": "Lokaler Ausbreitungseingang" }, "chatPayload": { "mention": "Erwähne {{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "Prüfe /offer-Unterstützung…", "syncLocalNotSupported": "Der lokale Host-Verbreitungsknoten kann nicht wie ein entfernter LXMF-Verbreitungsknoten über das Netzwerk synchronisiert werden.", "enableFailed": "Der Ausbreitungsknoten konnte nicht aktiviert werden.", - "disableFailed": "Der Ausbreitungsknoten konnte nicht deaktiviert werden." + "disableFailed": "Der Ausbreitungsknoten konnte nicht deaktiviert werden.", + "syncOutboundBusy": "Ausbreitungssynchronisierung verschoben — eine ausgehende Nachricht wird auf diesem Knoten hinterlegt." }, "reticulumPropagationHeader": { "modeLabel": "Ausbreitungsmodus", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 858628be4..fbd67f7eb 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -494,6 +494,8 @@ "reticulumSendSending": "Sending…", "reticulumSendPropagated": "Queued at propagation node", "reticulumSendStoredAtPn": "Stored at propagation node", + "reticulumSendStoringLocally": "Saving to your local propagation inbox…", + "reticulumSendStoredLocally": "Kept in your local propagation inbox (not delivered to peer)", "reticulumPnAbbrev": "PN", "reticulumSendDelivered": "Delivered", "reticulumSendPaper": "Paper", @@ -501,6 +503,7 @@ "reticulumSendFailed": "Failed to send", "reticulumNoPropagationNode": "No propagation node configured. Set a preferred propagation node on the Reticulum Network tab.", "sentViaPropagation": "Propagation node", + "sentViaLocalPropagation": "Local propagation inbox", "reticulumImageAttachment": "Image: {{name}}", "reticulumFileAttachment": "File: {{name}}", "shareAsPaper": "Share as paper", @@ -4549,6 +4552,7 @@ "syncStatusNegotiating": "Negotiating sync with propagation node…", "syncStatusTransferring": "Transferring messages from propagation node…", "syncFailed": "Propagation sync failed — the node may be unreachable.", + "syncOutboundBusy": "Propagation sync deferred — an outbound message is depositing to this node.", "syncTimedOut": "Propagation sync timed out — the node may be unreachable.", "syncLocalNotSupported": "The local host propagation node cannot be synced over the network like a remote LXMF propagation node.", "syncIdentityUnknown": "Propagation node identity is unknown — wait for an announce or path response, then try again.", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 11569089c..cda17c23c 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Mensaje a cifrar", "shareAsPaperGenerate": "Crear QR en papel", "shareAsPaperCopyFailed": "No se ha podido copiar el enlace en papel", - "waitingMessagesSilentFetched": "Obtenido {{processed}} de la radio..." + "waitingMessagesSilentFetched": "Obtenido {{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 a los compañeros)", + "sentViaLocalPropagation": "Bandeja de entrada de propagación local" }, "chatPayload": { "mention": "Mencionar {{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "Comprobando compatibilidad con /offer…", "syncLocalNotSupported": "El nodo de propagación del host local no se puede sincronizar a través de la red como un nodo de propagación LXMF remoto.", "enableFailed": "No se pudo habilitar el nodo de propagación.", - "disableFailed": "No se pudo deshabilitar el nodo de propagación." + "disableFailed": "No se pudo deshabilitar el nodo de propagación.", + "syncOutboundBusy": "Sincronización de propagación diferida: un mensaje saliente se está depositando en este nodo." }, "reticulumPropagationHeader": { "modeLabel": "modo de propagación", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 466e33b16..c7d524889 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Message à crypter", "shareAsPaperGenerate": "Créer un QR papier", "shareAsPaperCopyFailed": "Impossible de copier le lien papier", - "waitingMessagesSilentFetched": "Récupéré {{processed}} de la radio…" + "waitingMessagesSilentFetched": "Récupéré {{processed}} de la radio…", + "reticulumSendStoringLocally": "Enregistrement dans votre boîte de réception de propagation locale…", + "reticulumSendStoredLocally": "Conservé dans votre boîte de réception de propagation locale (non livré à l'homologue)", + "sentViaLocalPropagation": "Boîte de réception de propagation locale" }, "chatPayload": { "mention": "Mention {{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "Vérification de la prise en charge de /offer…", "syncLocalNotSupported": "Le nœud de propagation de l'hôte local ne peut pas être synchronisé sur le réseau comme un nœud de propagation LXMF distant.", "enableFailed": "Impossible d'activer le nœud de propagation.", - "disableFailed": "Impossible de désactiver le nœud de propagation." + "disableFailed": "Impossible de désactiver le nœud de propagation.", + "syncOutboundBusy": "Synchronisation de la propagation différée — un message sortant se dépose sur ce nœud." }, "reticulumPropagationHeader": { "modeLabel": "mode de propagation", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 5c314a43f..8a796585c 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Pesan untuk dienkripsi", "shareAsPaperGenerate": "Buat QR kertas", "shareAsPaperCopyFailed": "Tidak dapat menyalin tautan kertas", - "waitingMessagesSilentFetched": "Mengambil {{processed}} dari radio…" + "waitingMessagesSilentFetched": "Mengambil {{processed}} dari radio…", + "reticulumSendStoringLocally": "Menyimpan ke kotak masuk propagasi lokal Anda…", + "reticulumSendStoredLocally": "Disimpan di kotak masuk propagasi lokal Anda (tidak dikirim ke peer)", + "sentViaLocalPropagation": "Kotak masuk propagasi lokal" }, "chatPayload": { "mention": "Sebutkan {{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "Memeriksa dukungan /offer…", "syncLocalNotSupported": "Node propagasi host lokal tidak dapat disinkronkan melalui jaringan seperti node propagasi LXMF jarak jauh.", "enableFailed": "Tidak dapat mengaktifkan node propagasi.", - "disableFailed": "Tidak dapat menonaktifkan node propagasi." + "disableFailed": "Tidak dapat menonaktifkan node propagasi.", + "syncOutboundBusy": "Sinkronisasi propagasi ditangguhkan — pesan keluar disetorkan ke simpul ini." }, "reticulumPropagationHeader": { "modeLabel": "Mode propagasi", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 9b645e9ab..272e2445a 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Messaggio da crittografare", "shareAsPaperGenerate": "Crea QR cartaceo", "shareAsPaperCopyFailed": "Impossibile copiare il link cartaceo", - "waitingMessagesSilentFetched": "Recuperato {{processed}} dalla radio..." + "waitingMessagesSilentFetched": "Recuperato {{processed}} dalla radio...", + "reticulumSendStoringLocally": "Salvataggio nella tua casella di posta di propagazione locale in corso...", + "reticulumSendStoredLocally": "Conservato nella tua casella di posta di propagazione locale (non consegnato al peer)", + "sentViaLocalPropagation": "Posta in arrivo propagazione locale" }, "chatPayload": { "mention": "Menziona {{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "Verifica del supporto /offer…", "syncLocalNotSupported": "Il nodo di propagazione dell'host locale non può essere sincronizzato sulla rete come un nodo di propagazione LXMF remoto.", "enableFailed": "Impossibile abilitare il nodo di propagazione.", - "disableFailed": "Impossibile disabilitare il nodo di propagazione." + "disableFailed": "Impossibile disabilitare il nodo di propagazione.", + "syncOutboundBusy": "Sincronizzazione propagazione differita — un messaggio in uscita sta depositando su questo nodo." }, "reticulumPropagationHeader": { "modeLabel": "Modalità di propagazione", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index f97c495ee..d91d80998 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "暗号化するメッセージ", "shareAsPaperGenerate": "紙のQRを作成する", "shareAsPaperCopyFailed": "用紙リンクをコピーできませんでした", - "waitingMessagesSilentFetched": "ラジオから{{processed}}を取得しました…" + "waitingMessagesSilentFetched": "ラジオから{{processed}}を取得しました…", + "reticulumSendStoringLocally": "ローカルの伝播受信トレイに保存しています…", + "reticulumSendStoredLocally": "ローカルの伝播受信トレイに保存されています(ピアには配信されません)", + "sentViaLocalPropagation": "ローカル伝播受信トレイ" }, "chatPayload": { "mention": "{{label}} について言及してください", @@ -4238,7 +4241,8 @@ "addProbing": "/offer サポートを確認中…", "syncLocalNotSupported": "ローカル ホスト伝播ノードは、リモート LXMF 伝播ノードのようにネットワーク経由で同期できません。", "enableFailed": "伝播ノードを有効にできませんでした。", - "disableFailed": "伝播ノードを無効にできませんでした。" + "disableFailed": "伝播ノードを無効にできませんでした。", + "syncOutboundBusy": "伝播同期が延期されました—アウトバウンドメッセージがこのノードにデポジットされています。" }, "reticulumPropagationHeader": { "modeLabel": "伝播モード", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 7c8cf9d74..9593d1a19 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "암호화할 메시지", "shareAsPaperGenerate": "용지 QR 생성", "shareAsPaperCopyFailed": "용지 링크를 복사할 수 없습니다", - "waitingMessagesSilentFetched": "라디오에서 {{processed}} 을 (를) 가져왔습니다..." + "waitingMessagesSilentFetched": "라디오에서 {{processed}} 을 (를) 가져왔습니다...", + "reticulumSendStoringLocally": "로컬 전파 받은 편지함에 저장 중...", + "reticulumSendStoredLocally": "로컬 전파 받은 편지함에 보관 (동료에게 전달되지 않음)", + "sentViaLocalPropagation": "로컬 전파 메시지함" }, "chatPayload": { "mention": "{{label}}을(를) 언급하세요", @@ -4238,7 +4241,8 @@ "addProbing": "/offer 지원 확인 중…", "syncLocalNotSupported": "로컬 호스트 전파 노드는 원격 LXMF 전파 노드처럼 네트워크를 통해 동기화될 수 없습니다.", "enableFailed": "전파 노드를 활성화할 수 없습니다.", - "disableFailed": "전파 노드를 비활성화할 수 없습니다." + "disableFailed": "전파 노드를 비활성화할 수 없습니다.", + "syncOutboundBusy": "전파 동기화 지연 — 아웃바운드 메시지가 이 노드에 입금됩니다." }, "reticulumPropagationHeader": { "modeLabel": "전파 모드", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 88404c077..ecc7c267b 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Bericht om te versleutelen", "shareAsPaperGenerate": "Maak papieren QR", "shareAsPaperCopyFailed": "Kon papieren link niet kopiëren", - "waitingMessagesSilentFetched": "{{processed}} van de radio gehaald..." + "waitingMessagesSilentFetched": "{{processed}} van de radio gehaald...", + "reticulumSendStoringLocally": "Opslaan in uw lokale propagatie-inbox...", + "reticulumSendStoredLocally": "Bewaard in uw lokale propagatie-inbox (niet afgeleverd bij collega)", + "sentViaLocalPropagation": "Postvak IN voor lokale propagatie" }, "chatPayload": { "mention": "Vermeld {{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "/offer-ondersteuning controleren…", "syncLocalNotSupported": "Het lokale hostvoortplantingsknooppunt kan niet via het netwerk worden gesynchroniseerd zoals een extern LXMF-voortplantingsknooppunt.", "enableFailed": "Kan het voortplantingsknooppunt niet inschakelen.", - "disableFailed": "Kan het voortplantingsknooppunt niet uitschakelen." + "disableFailed": "Kan het voortplantingsknooppunt niet uitschakelen.", + "syncOutboundBusy": "Voortplantingssynchronisatie uitgesteld — een uitgaand bericht wordt op dit knooppunt gedeponeerd." }, "reticulumPropagationHeader": { "modeLabel": "voortplantingsmodus", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index e7d626df7..c225588d8 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -679,7 +679,10 @@ "shareAsPaperMessageLabel": "Wiadomość do zaszyfrowania", "shareAsPaperGenerate": "Utwórz papierowy QR", "shareAsPaperCopyFailed": "Nie można skopiować papierowego linku", - "waitingMessagesSilentFetched": "Pobrano {{processed}} z radia…" + "waitingMessagesSilentFetched": "Pobrano {{processed}} z radia…", + "reticulumSendStoringLocally": "Zapisywanie w skrzynce odbiorczej lokalnej propagacji…", + "reticulumSendStoredLocally": "Przechowywane w skrzynce odbiorczej lokalnej propagacji (niedostarczone do partnera)", + "sentViaLocalPropagation": "Skrzynka odbiorcza propagacji lokalnej" }, "chatPayload": { "mention": "Wspomnij o {{label}}", @@ -4242,7 +4245,8 @@ "addProbing": "Sprawdzanie obsługi /offer…", "syncLocalNotSupported": "Lokalny węzeł propagacji hosta nie może być synchronizowany przez sieć jak zdalny węzeł propagacji LXMF.", "enableFailed": "Nie można włączyć węzła propagacji.", - "disableFailed": "Nie można wyłączyć węzła propagacji." + "disableFailed": "Nie można wyłączyć węzła propagacji.", + "syncOutboundBusy": "Propagation sync deferred — wiadomość wychodząca jest deponowana w tym węźle." }, "reticulumPropagationHeader": { "modeLabel": "Tryb propagacji:", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 3f4ae36b7..123facf07 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Mensagem para encriptar", "shareAsPaperGenerate": "Criar QR de papel", "shareAsPaperCopyFailed": "Não foi possível copiar o link do papel", - "waitingMessagesSilentFetched": "Buscou {{processed}} no rádio..." + "waitingMessagesSilentFetched": "Buscou {{processed}} no rádio...", + "reticulumSendStoringLocally": "Salvando na sua caixa de entrada de propagação local...", + "reticulumSendStoredLocally": "Mantido em sua caixa de entrada de propagação local (não entregue ao colega)", + "sentViaLocalPropagation": "Caixa de entrada de propagação local" }, "chatPayload": { "mention": "Mencionar {{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "Verificando suporte a /offer…", "syncLocalNotSupported": "O nó de propagação do host local não pode ser sincronizado pela rede como um nó de propagação LXMF remoto.", "enableFailed": "Não foi possível ativar o nó de propagação.", - "disableFailed": "Não foi possível desativar o nó de propagação." + "disableFailed": "Não foi possível desativar o nó de propagação.", + "syncOutboundBusy": "Sincronização de propagação adiada — uma mensagem de saída está sendo depositada neste nó." }, "reticulumPropagationHeader": { "modeLabel": "Modo de propagação", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 2fdeffcbb..87b2cc68d 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -677,7 +677,10 @@ "shareAsPaperMessageLabel": "Сообщение для шифрования", "shareAsPaperGenerate": "Создать бумажный QR-код", "shareAsPaperCopyFailed": "Не удалось скопировать ссылку на бумагу", - "waitingMessagesSilentFetched": "Получено {{processed}} из радио…" + "waitingMessagesSilentFetched": "Получено {{processed}} из радио…", + "reticulumSendStoringLocally": "Сохранение в локальный почтовый ящик распространения...", + "reticulumSendStoredLocally": "Хранится в локальном почтовом ящике распространения (не доставляется одноранговому узлу)", + "sentViaLocalPropagation": "Локальный почтовый ящик распространения" }, "chatPayload": { "mention": "Упоминание {{label}}", @@ -4240,7 +4243,8 @@ "addProbing": "Проверка поддержки /offer…", "syncLocalNotSupported": "Локальный узел распространения хоста не может быть синхронизирован по сети, как удаленный узел распространения LXMF.", "enableFailed": "Не удалось включить узел распространения.", - "disableFailed": "Не удалось отключить узел распространения." + "disableFailed": "Не удалось отключить узел распространения.", + "syncOutboundBusy": "Синхронизация распространения отложена — исходящее сообщение передается на этот узел." }, "reticulumPropagationHeader": { "modeLabel": "Режим распространения", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index de2d1dfd4..13519c86e 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "Şifrelenecek mesaj", "shareAsPaperGenerate": "Kağıt QR oluştur", "shareAsPaperCopyFailed": "Kağıt bağlantısı kopyalanamadı", - "waitingMessagesSilentFetched": "Radyodan {{processed}} alındı…" + "waitingMessagesSilentFetched": "Radyodan {{processed}} alındı…", + "reticulumSendStoringLocally": "Yerel yayılım gelen kutunuza kaydediliyor…", + "reticulumSendStoredLocally": "Yerel yayılım gelen kutunuzda tutulur (akranınıza teslim edilmez)", + "sentViaLocalPropagation": "Yerel yayılım gelen kutusu" }, "chatPayload": { "mention": "{{label}}'dan bahsedin", @@ -4238,7 +4241,8 @@ "addProbing": "/offer desteği kontrol ediliyor…", "syncLocalNotSupported": "Yerel ana bilgisayar yayılım düğümü, uzak LXMF yayılım düğümü gibi ağ üzerinden senkronize edilemez.", "enableFailed": "Yayılma düğümü etkinleştirilemedi.", - "disableFailed": "Yayılma düğümü devre dışı bırakılamadı." + "disableFailed": "Yayılma düğümü devre dışı bırakılamadı.", + "syncOutboundBusy": "Yayılım senkronizasyonu ertelendi — bu düğüme giden bir mesaj gönderiliyor." }, "reticulumPropagationHeader": { "modeLabel": "Yayılma modu", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index e94c63bb7..7da95eb22 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -677,7 +677,10 @@ "shareAsPaperMessageLabel": "Повідомлення для шифрування", "shareAsPaperGenerate": "Створити паперовий QR-код", "shareAsPaperCopyFailed": "Не вдалося скопіювати посилання на папір", - "waitingMessagesSilentFetched": "Отримано {{processed}} з радіо…" + "waitingMessagesSilentFetched": "Отримано {{processed}} з радіо…", + "reticulumSendStoringLocally": "Збереження до вашої локальної папки «Вхідні» …", + "reticulumSendStoredLocally": "Зберігається у вашій локальній папці «Вхідні» (не доставляється одноранговому користувачеві)", + "sentViaLocalPropagation": "Вхідні повідомлення про локальне поширення" }, "chatPayload": { "mention": "Згадайте {{label}}", @@ -4240,7 +4243,8 @@ "addProbing": "Перевірка підтримки /offer…", "syncLocalNotSupported": "Локальний хост-вузол поширення не можна синхронізувати через мережу, як віддалений вузол поширення LXMF.", "enableFailed": "Не вдалося ввімкнути вузол розповсюдження.", - "disableFailed": "Не вдалося вимкнути вузол розповсюдження." + "disableFailed": "Не вдалося вимкнути вузол розповсюдження.", + "syncOutboundBusy": "Синхронізація поширення відкладена — вихідне повідомлення передається на цей вузол." }, "reticulumPropagationHeader": { "modeLabel": "Режим поширення", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 583077adf..0332c3706 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -675,7 +675,10 @@ "shareAsPaperMessageLabel": "要加密的消息", "shareAsPaperGenerate": "创建纸质二维码", "shareAsPaperCopyFailed": "无法复制纸质链接", - "waitingMessagesSilentFetched": "已从收音机获取{{processed}} …" + "waitingMessagesSilentFetched": "已从收音机获取{{processed}} …", + "reticulumSendStoringLocally": "正在保存到本地传播收件箱…", + "reticulumSendStoredLocally": "保存在您的本地传播收件箱中(未发送给同行)", + "sentViaLocalPropagation": "本地传播收件箱" }, "chatPayload": { "mention": "提及{{label}}", @@ -4238,7 +4241,8 @@ "addProbing": "正在检查 /offer 支持…", "syncLocalNotSupported": "本地主机传播节点无法像远程 LXMF 传播节点一样通过网络同步。", "enableFailed": "无法启用传播节点。", - "disableFailed": "无法禁用传播节点。" + "disableFailed": "无法禁用传播节点。", + "syncOutboundBusy": "传播同步已延迟—出站消息正在存入此节点。" }, "reticulumPropagationHeader": { "modeLabel": "传播模式", diff --git a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts index e249cbe66..e68ce8644 100644 --- a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts @@ -266,7 +266,7 @@ describe('useReticulumRuntime contact → nodeStore label preservation', () => { describe('useReticulumRuntime outbound delivery persistence', () => { it('persists Completes/Fails via applyReticulumOutboundDeliveryStatus', () => { expect(SOURCE).toMatch( - /evt\.type === 'lxmf_outbound_status'[\s\S]*?applyReticulumOutboundDeliveryStatus\(identityId, p\.message_hash, p\.status,\s*\{\s*sentVia: p\.sent_via,\s*deliveryMethod: p\.delivery_method,\s*\}\)/, + /evt\.type === 'lxmf_outbound_status'[\s\S]*?applyReticulumOutboundDeliveryStatus\(identityId, p\.message_hash, p\.status,\s*\{\s*sentVia: p\.sent_via,\s*deliveryMethod: p\.delivery_method,\s*deliveryAttempts: p\.delivery_attempts,\s*\}\)/, ); }); @@ -274,12 +274,14 @@ describe('useReticulumRuntime outbound delivery persistence', () => { expect(SOURCE).toMatch(/flushPendingReticulumOutboundDeliveryStatus\(identityId, hash\)/); }); - it('skips link-timeout failure bridge when remote PN fallback is available', () => { + it('skips link-timeout failure bridge when PN cascade is available', () => { expect(SOURCE).toContain('shouldApplyLinkDeliveryTimeoutFailureBridge'); expect(SOURCE).toMatch( /shouldApplyLinkDeliveryTimeoutFailureBridge\(\s*propState\.nodes,\s*propState\.preferredId,\s*\)/, ); - expect(SOURCE).toMatch(/if \(!applyBridge\) continue/); + expect(SOURCE).toContain('propagationHydratedForBridgeRef'); + expect(SOURCE).toMatch(/if \(!applyBridge\) \{/); + expect(SOURCE).toContain('cascade eligible'); }); it('wires propagation store + sidecar health into Reticulum diagnostics', () => { diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 2fbd8a665..67f7f3a9e 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -291,6 +291,8 @@ export function useReticulumRuntime(): ProtocolRuntime { const stateRef = useRef(state); const localInterfacesRef = useRef([]); const processedLinkTimeoutDestsRef = useRef(new Set()); + /** Defer link-timeout failure bridge until first propagation store refresh completes. */ + const propagationHydratedForBridgeRef = useRef(false); const nodeStoreSlice = useNodeStore((s) => (identityId ? s.nodes[identityId] : undefined)); // Include `connecting`: main suspends Noble at sidecar start before status reaches @@ -819,11 +821,13 @@ export function useReticulumRuntime(): ProtocolRuntime { status?: string; sent_via?: string; delivery_method?: string; + delivery_attempts?: number; }; if (identityId && p.message_hash && p.status) { applyReticulumOutboundDeliveryStatus(identityId, p.message_hash, p.status, { sentVia: p.sent_via, deliveryMethod: p.delivery_method, + deliveryAttempts: p.delivery_attempts, }); } } @@ -1461,6 +1465,7 @@ export function useReticulumRuntime(): ProtocolRuntime { setRawPackets([]); clearReticulumSessionStores(); processedLinkTimeoutDestsRef.current.clear(); + propagationHydratedForBridgeRef.current = false; setReticulumBleBondDesyncActive(false); setReticulumAnnounceBusPressureActive(false); setState(INITIAL_STATE); @@ -1483,23 +1488,44 @@ export function useReticulumRuntime(): ProtocolRuntime { void syncDiagnosticsFromSidecar(); const timeouts = status.interfaceIssueAlert?.linkDeliveryTimeouts; if (identityId && timeouts?.length) { - const propState = useReticulumPropagationStore.getState(); - const applyBridge = shouldApplyLinkDeliveryTimeoutFailureBridge( - propState.nodes, - propState.preferredId, - ); - for (const { destinationHash } of timeouts) { - const norm = destinationHash.replace(/[^0-9a-f]/gi, '').toLowerCase(); - if (!norm || processedLinkTimeoutDestsRef.current.has(norm)) continue; - processedLinkTimeoutDestsRef.current.add(norm); - // Remote preferred PN: sidecar Direct→PN fallback owns the outcome via WS. - if (!applyBridge) continue; - failReticulumSendingOutboundToDestHash( - identityId, - norm, - i18n.t('chatPanel.reticulumSendFailed'), + void (async () => { + if (!propagationHydratedForBridgeRef.current) { + try { + await useReticulumPropagationStore.getState().refreshFromSidecar(); + } catch (e: unknown) { + console.debug( + '[useReticulumRuntime] propagation hydrate for link-timeout bridge ' + + errLikeToLogString(e), + ); + } + propagationHydratedForBridgeRef.current = true; + } + const propState = useReticulumPropagationStore.getState(); + const applyBridge = shouldApplyLinkDeliveryTimeoutFailureBridge( + propState.nodes, + propState.preferredId, ); - } + console.debug( + `[useReticulumRuntime] link-timeout bridge apply=${applyBridge} preferred=${propState.preferredId ?? 'none'} nodes=${propState.nodes.length}`, + ); + for (const { destinationHash } of timeouts) { + const norm = destinationHash.replace(/[^0-9a-f]/gi, '').toLowerCase(); + if (!norm || processedLinkTimeoutDestsRef.current.has(norm)) continue; + processedLinkTimeoutDestsRef.current.add(norm); + // PN cascade (remote or local-prop): sidecar owns outcome via WS. + if (!applyBridge) { + console.debug( + `[useReticulumRuntime] link-timeout bridge skip dest=${norm.slice(0, 8)}… (cascade eligible)`, + ); + continue; + } + failReticulumSendingOutboundToDestHash( + identityId, + norm, + i18n.t('chatPanel.reticulumSendFailed'), + ); + } + })(); } } if (status.running) return; @@ -1677,6 +1703,7 @@ export function useReticulumRuntime(): ProtocolRuntime { setRawPackets([]); clearReticulumSessionStores(); processedLinkTimeoutDestsRef.current.clear(); + propagationHydratedForBridgeRef.current = false; setReticulumBleBondDesyncActive(false); setReticulumAnnounceBusPressureActive(false); setState(INITIAL_STATE); diff --git a/src/renderer/stores/messageStore.ts b/src/renderer/stores/messageStore.ts index 0ac9ceac2..fb98957bf 100644 --- a/src/renderer/stores/messageStore.ts +++ b/src/renderer/stores/messageStore.ts @@ -51,8 +51,10 @@ export interface MessageRecord { reticulumSenderHash?: string; /** Reticulum reply target message hash (hex). */ reticulumReplyToHash?: string; - /** Reticulum LXMF delivery method when queued (direct / propagated / opportunistic / paper). */ + /** Reticulum LXMF delivery method when queued (direct / propagated / opportunistic / paper / stored_locally). */ reticulumDeliveryMethod?: ReticulumDeliveryMethod; + /** Sidecar outbound delivery_attempts (for triage dumps; optional). */ + reticulumDeliveryAttempts?: number; /** Saved attachment path on disk (local saves). */ reticulumAttachmentPath?: string; /** Message was replayed from a Store & Forward server (Meshtastic only). */ @@ -95,6 +97,7 @@ const MESSAGE_RECORD_KEYS: (keyof MessageRecord)[] = [ 'reticulumSenderHash', 'reticulumReplyToHash', 'reticulumDeliveryMethod', + 'reticulumDeliveryAttempts', 'reticulumAttachmentPath', 'viaStoreForward', ]; diff --git a/src/renderer/stores/reticulumPeerStore.test.ts b/src/renderer/stores/reticulumPeerStore.test.ts index 6af376dc3..63662cadf 100644 --- a/src/renderer/stores/reticulumPeerStore.test.ts +++ b/src/renderer/stores/reticulumPeerStore.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ReticulumContact } from '@/shared/reticulum-types'; import { reticulumHashToNodeId } from '../lib/reticulum/destHash'; +import { resetReticulumProxyRateLimitBackoffForTests } from '../lib/reticulum/reticulumProxyRateLimitBackoff'; import { applyReticulumAnnounceReceivedOptimistic, applyReticulumPeerPatchesNow, @@ -349,6 +350,7 @@ describe('reticulumPeerStore', () => { beforeEach(() => { resetReticulumPeerRefreshSingleFlightForTests(); resetReticulumPeerPatchBufferForTests(); + resetReticulumProxyRateLimitBackoffForTests(); useReticulumPeerStore.setState({ peers: new Map(), contacts: new Map(), diff --git a/src/renderer/stores/reticulumPeerStore.ts b/src/renderer/stores/reticulumPeerStore.ts index d22c05291..fa463d816 100644 --- a/src/renderer/stores/reticulumPeerStore.ts +++ b/src/renderer/stores/reticulumPeerStore.ts @@ -14,6 +14,11 @@ import { activeReticulumPathSlot, type ReticulumPathSlot, } from '@/renderer/lib/reticulum/reticulumPathSlots'; +import { + isReticulumProxyRateLimitBackoffActive, + noteReticulumProxyErrorIfRateLimited, + reticulumProxyRateLimitBackoffRemainingMs, +} from '@/renderer/lib/reticulum/reticulumProxyRateLimitBackoff'; import { MAX_MESH_ENTITY_CAP } from '@/renderer/lib/sessionMemoryCaps'; import { useNodeStore } from '@/renderer/stores/nodeStore'; import { @@ -1277,6 +1282,12 @@ export function refreshReticulumPeersFromSidecar( peerRefreshInFlight = (async () => { try { + if (isReticulumProxyRateLimitBackoffActive()) { + console.debug( + `[reticulumPeerStore] refresh skipped — proxy rate-limit backoff remaining=${reticulumProxyRateLimitBackoffRemainingMs()}ms`, + ); + return [...useReticulumPeerStore.getState().contacts.values()]; + } let forceRefresh = Boolean(opts.forceRefresh) || peerRefreshPendingForce; let skipNomad = Boolean(opts.skipNomad) && peerRefreshPendingSkipNomad; peerRefreshPendingForce = false; @@ -1284,6 +1295,7 @@ export function refreshReticulumPeersFromSidecar( peerRefreshPendingRerun = false; let result = await refreshReticulumPeersFromSidecarOnce({ forceRefresh, skipNomad }); while (peerRefreshPendingRerun) { + if (isReticulumProxyRateLimitBackoffActive()) break; peerRefreshPendingRerun = false; forceRefresh = peerRefreshPendingForce; skipNomad = peerRefreshPendingSkipNomad; @@ -1294,7 +1306,10 @@ export function refreshReticulumPeersFromSidecar( return result; } catch (e) { const msg = errLikeToLogString(e); - if (msg.toLowerCase().includes('rate limit exceeded')) { + if ( + noteReticulumProxyErrorIfRateLimited(e) || + msg.toLowerCase().includes('rate limit exceeded') + ) { console.debug('[reticulumPeerStore] refresh ' + msg); throw e instanceof Error ? e : new Error(msg); } diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index fa58f14e1..ad099c369 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -1160,7 +1160,7 @@ export interface ElectronAPI { }; /** * LRGP games (lrgp-rs). Dedicated IPC channels — generic `proxyGet`/`proxyPost` - * reject `/api/v1/games/*` so session polls/moves do not share the 300/min proxy bucket. + * reject `/api/v1/games/*` so session polls/moves do not share the 900/min proxy bucket. */ games: { getStatus: () => Promise; diff --git a/src/shared/reticulumDeliveryMethod.ts b/src/shared/reticulumDeliveryMethod.ts index cf9da1562..c2a92a90a 100644 --- a/src/shared/reticulumDeliveryMethod.ts +++ b/src/shared/reticulumDeliveryMethod.ts @@ -4,6 +4,8 @@ export const RETICULUM_DELIVERY_METHODS = [ 'propagated', 'opportunistic', 'paper', + /** Offline local-prop inbox — not peer-delivered. */ + 'stored_locally', ] as const; export type ReticulumDeliveryMethod = (typeof RETICULUM_DELIVERY_METHODS)[number]; From 03f4cf5aa5f7caeb07fdf9e62081d8408462b85c Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Fri, 7 Aug 2026 12:56:40 -0600 Subject: [PATCH 4/5] fix(reticulum): harden PN cascade, bridge, and proxy backoff (#817) Address review findings: per-message cascade PN targets, advance on pack/max-attempt failure, safer link-timeout bridge hydration, catch-up single-flight, per-bucket rate-limit backoff, locale/doc accuracy for multi-PN + stored_locally. --- AGENTS.md | 2 +- README.md | 4 +- docs/reticulum-sidecar-ipc.md | 8 +- docs/reticulum.md | 73 +++--- docs/troubleshooting.md | 8 +- reticulum-sidecar/src/stack/lxmf_outbound.rs | 238 +++++++++--------- reticulum-sidecar/src/stack/pn_cascade.rs | 30 ++- src/main/ipc/reticulumLxmfRecentPath.ts | 4 +- src/main/reticulum-proxy-path.ts | 3 +- src/main/support-bundle.test.ts | 11 + src/main/support-bundle.ts | 7 +- .../ReticulumMessageStatusBadge.test.tsx | 23 ++ .../ReticulumMessageStatusBadge.tsx | 12 +- ...plyReticulumOutboundDeliveryStatus.test.ts | 71 +++++- .../applyReticulumOutboundDeliveryStatus.ts | 22 +- .../lib/reticulum/catchUpInboundLxmf.test.ts | 6 +- .../catchUpRecentInboundLxmf.test.ts | 61 ++++- .../lib/reticulum/catchUpRecentInboundLxmf.ts | 128 +++++++++- .../reticulum/fetchRecentInboundLxmf.test.ts | 48 ++++ .../lib/reticulum/fetchRecentInboundLxmf.ts | 11 +- .../reticulumOutboundFailureBridge.test.ts | 27 ++ .../reticulumOutboundFailureBridge.ts | 6 +- .../reticulumProxyRateLimitBackoff.test.ts | 45 +++- .../reticulumProxyRateLimitBackoff.ts | 103 ++++++-- src/renderer/locales/cs/translation.json | 6 +- src/renderer/locales/de/translation.json | 6 +- src/renderer/locales/es/translation.json | 4 +- src/renderer/locales/fr/translation.json | 2 +- src/renderer/locales/it/translation.json | 4 +- src/renderer/locales/ja/translation.json | 2 +- src/renderer/locales/ko/translation.json | 4 +- src/renderer/locales/nl/translation.json | 8 +- src/renderer/locales/pl/translation.json | 4 +- src/renderer/locales/pt-BR/translation.json | 4 +- src/renderer/locales/ru/translation.json | 2 +- src/renderer/locales/tr/translation.json | 2 +- src/renderer/locales/uk/translation.json | 8 +- src/renderer/locales/zh/translation.json | 2 +- ...ticulumRuntime.reconnect-hardening.test.ts | 5 + src/renderer/runtime/useReticulumRuntime.ts | 38 ++- .../stores/reticulumPeerStore.test.ts | 33 ++- src/renderer/stores/reticulumPeerStore.ts | 27 +- src/shared/reticulumApiPaths.ts | 4 + src/shared/reticulumDeliveryMethod.test.ts | 17 +- src/shared/reticulumDeliveryMethod.ts | 5 + 45 files changed, 856 insertions(+), 282 deletions(-) create mode 100644 src/shared/reticulumApiPaths.ts diff --git a/AGENTS.md b/AGENTS.md index 8c6bf0e5d..f1a48db0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,7 +158,7 @@ Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`). - **RNode flasher timeouts:** `RNODE_COMMAND_TIMEOUT_MS` (30 s serial), `RNODE_BT_PAIRING_TIMEOUT_MS` (90 s BLE pairing), `ESP32_FLASH_STALL_TIMEOUT_MS` / `NRF52_DFU_STALL_TIMEOUT_MS` (60 s no-progress → `ESP32_FLASH_STALLED` / `NRF52_DFU_STALLED`); humanized via `flasherErrorHumanize.ts` - **Peer aliases / History vs Contacts:** LXMF/Nomad announce names overlay path-table peers; SQLite `reticulum_destinations.last_heard` = History, `is_contact` = Contacts (Save as contact only — inbound/outbound LXMF does **not** auto-add Contacts; sidecar `/contacts` wire rows are History hints unless SQLite `is_contact=1`); default avatars via vendored LXMFace (`lib/reticulum/lxmface.ts`); renderer refresh + `reticulumContactToNodeRecordPreservingLabel` refuse hash-prefix wipes of Chat/`nodeStore` labels; ingest stamps History via `persistReticulumHistoryFromPayload` + `stampHistoryPeer`; SQL upsert guard preserves real names over hash-prefix aliases; destination upsert requires exact 32-hex (lowercase) and omits `favorited` on icon-only patches so favorites/icons survive path/probe refresh - **Stores/lib:** `reticulumIdentityStore.ts` (session-global sidecar identity status shared by `useReticulumSidecarApi` — distinct from identity-scoped `identityStore`), `reticulumPeerStore.ts` (path-table `peers` + `history` + saved `contacts`; soft-TTL reads, forced `?refresh=1`, incremental `peers_updated` route-field patches, 50ms batching, name/appearance preservation, 30s/60s large-mesh poll), `reticulumDiscoveryMapStore.ts`, `reticulumRmapDiscovery.ts`, `reticulumDiscoveryMapLayout.ts`, `nomadNetworkStore.ts`, `rrcHubStore.ts` / `rrcSessionStore.ts` (RRC hubs + multi-hub sessions; hydrate/clear room history via `rrcRoomHistory.ts`; persist → SQLite `rrc_messages` via `rrcMessagePersist.ts` + `ipc/rrc-db-handlers.ts`; prefs in `rrcHubPrefs` / `rrcRoomPrefs` / `rrcRecentRooms`; notifications in `rrcInactiveNotifications` / `rrcMention`); **Remote (rnsh/rncp):** `rncpTransferStore.ts`, `rnshSessionStore.ts`, `reticulumInboundPolicyStore.ts`, `reticulumRemoteAddressStore.ts`, `rncpEnableRequestStore.ts` + lib `remoteSettingsStorage.ts`, `pushRncpListenerPolicy.ts`, `rncpInboundPolicyLists.ts`, `sendRncpRequestEnable.ts`, `rncpRequestEnableRateLimit.ts`, `applyRncpReceiveDestShare.ts` / `rncpReceiveDestSharePending.ts` (mark pending on request-enable; consume on ingest within TTL), `hooks/useRemotePathCapability.ts`, `components/remote/*`; WS events `rmap.discovery`, `lxmf_outbound_status`, `nomadnetwork.node`, `rrc.*`, `rnsh.*` / `rncp.*` in `useReticulumRuntime` (sidecar also emits `nomad.serving_start` / `nomad.serving_stop`; renderer polls serving status via HTTP, not those WS events) -- **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` (Direct-first; **one-shot fallback** to preferred **remote** PN on Direct fail; intermediate WS `sending` + `delivery_method: "propagated"`); renderer `applyReticulumOutboundDeliveryStatus.ts` (WS `lxmf_outbound_status` → Zustand + SQLite `delivery_status` + `delivery_method`; early-status buffer; hash/status allowlist), `reticulumOutboundFailureBridge.ts` (`shouldApplyLinkDeliveryTimeoutFailureBridge` skips the link-timeout Failed bridge when an effective remote PN target exists; also skips `propagated` rows so fallback is not killed), `markStaleReticulumOutbound.ts`. Optimistic pending rows use `reticulum-pending-*`; send-path rekey passes `replaces_message_hash` on SQLite upsert to delete the prior pending hash. Propagated Completes UI: **Stored at propagation node**. **Paper exception:** `createReticulumPaperMessage` / paper create Completes immediately (`delivery_method: paper`, `ReticulumMessageStatusBadge` **Paper**) via `lxmf_message` — no `lxmf_outbound_status`; shared `reticulumMessageTransport` / `reticulumPaperErrors` keep IPC allowlists and i18n codes aligned. +- **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` / `pn_cascade.rs` (Direct-first; after Direct exhausts **multi-PN cascade**: preferred remote → other enabled remotes hop-sorted → local-prop last; intermediate WS `sending` + `delivery_method: "propagated"` or `"stored_locally"`; terminal `delivered` at remote PN vs `stored_locally` for local inbox); renderer `applyReticulumOutboundDeliveryStatus.ts` (WS `lxmf_outbound_status` → Zustand + SQLite `delivery_status` + `delivery_method`; early-status buffer; hash/status allowlist), `reticulumOutboundFailureBridge.ts` (`shouldApplyLinkDeliveryTimeoutFailureBridge` skips the link-timeout Failed bridge when cascade capacity remains — remote **or** enabled local-prop; also skips `propagated` / `stored_locally` rows so cascade is not killed), `markStaleReticulumOutbound.ts`. Optimistic pending rows use `reticulum-pending-*`; send-path rekey passes `replaces_message_hash` on SQLite upsert to delete the prior pending hash. Remote PN Completes UI: **Stored at propagation node**; local-prop Completes: local inbox (not peer-delivered). **Paper exception:** `createReticulumPaperMessage` / paper create Completes immediately (`delivery_method: paper`, `ReticulumMessageStatusBadge` **Paper**) via `lxmf_message` — no `lxmf_outbound_status`; shared `reticulumMessageTransport` / `reticulumPaperErrors` keep IPC allowlists and i18n codes aligned. - **DM path reachability:** `useReticulumDmPathProbe.ts`, `reticulumDmPathReachability.ts`, `ReticulumDmPathReachabilityBadge.tsx` — Chat **Probe** matches Peer List (sidecar running check → `/probe` → toast → refresh); `applyProbeResult(forHash, …)` applies the settle without a second `/probe` and ignores stale completions after DM switch; manual reprobe forces Checking… even when passive hops look reachable; Peers virtualizes above 100 rows via `reticulumPeerListRows.ts`; peer refresh policy in `reticulumSidecarPeerRefreshEvents.ts` - **Inbound transport labels:** `received_via` resolves the path-table interface name against local interface config type, so a TCP hub display name still renders as TCP. - **Topology:** `via_hash` is an immediate transport id; sidecar synthesizes missing relay nodes. `ReticulumTopologyPanel` uses force layout; sidecar caps graph input at 2,000 peers and renderer caps visible peers at 800 (grid repulsion above 400). diff --git a/README.md b/README.md index 83326f3c8..8fef253fc 100644 --- a/README.md +++ b/README.md @@ -339,7 +339,7 @@ Architecture and API: [docs/reticulum.md](docs/reticulum.md). Games wire parity: - **LXST voice Call** on DM headers and Peers rows — live telephony over rsLXST (not an LXMF voice-note clip) - **RRC tab:** multi-hub relay chat (rooms, nicklists, slash commands, favourites, auto-join, reconnect; up to 8 hubs); @mentions badge Chat + the amber protocol pill - **Remote tab:** **rnsh** interactive shell sessions and **rncp** file transfer (send / receive / fetch), saved addresses and inbound-policy controls; Chat DM send-file convenience (distinct from Meshtastic remote admin) -- **Delivery:** **Direct** when the destination is in the path table (then **one-shot remote PN fallback** on Direct fail when a preferred remote PN is set); **propagated (PN)** when offline — Completes show **Stored at propagation node**, not recipient Delivered +- **Delivery:** **Direct** when the destination is in the path table; after Direct exhausts, **multi-PN cascade** (preferred remote → other enabled remotes hop-sorted → local-prop last). Remote PN Completes show **Stored at propagation node** (`delivered`); local-prop Completes as local inbox (`stored_locally`) — neither is recipient Delivered **Games (LRGP)** @@ -393,7 +393,7 @@ Architecture and API: [docs/reticulum.md](docs/reticulum.md). Games wire parity: - **Map tiles; OpenStreetMap Referer requirement**: Packaged desktop builds load the UI from the local filesystem. The main process now loads the renderer with an explicit HTTP referrer so OpenStreetMap tile requests include a valid `Referer` header and comply with the [tile usage policy](https://operations.osmfoundation.org/policies/tiles/). If you point the app at a different tile server, ensure its usage policy permits this client. - **Reticulum — no LoRa companion parity**: Reticulum does not use Meshtastic/MeshCore `ConnectionDriver`, MQTT hybrid, channel pills, Rooms BBS, or Hop Goblins diagnostics. The **Chat** tab is **DM-only**; hub room chat lives on the **RRC** tab. Interface add/edit/delete updates config on disk — **restart the stack** after changes under `rns-stack`. - **Reticulum — sidecar license**: The spawned `mesh-client-reticulum` binary is **AGPL-3.0** (separate process from the MIT Electron shell). See [docs/reticulum.md](docs/reticulum.md) and [docs/credits.md](docs/credits.md#bundled-binaries). -- **Reticulum — propagation required for offline peers**: LXMF send fails with `no_propagation_node` when the destination is not in the path table and no preferred **remote** propagation node is set. Local inbox ≠ remote store-and-forward. When a path exists, Direct is tried first; Direct fail with a remote preferred PN triggers one-shot PN deposit. +- **Reticulum — propagation required for offline peers**: LXMF send fails with `no_propagation_node` when the destination is not in the path table and no cascade candidates exist (enabled remotes or local-prop). Local inbox Completes (`stored_locally`) ≠ peer delivery at a remote PN. When a path exists, Direct is tried first; on Direct fail the sidecar cascades preferred remote → other enabled remotes (hop-sorted) → local-prop last. --- diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index e6dbfebfe..1ce005ac6 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -264,23 +264,25 @@ Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscri - **`rrc.disconnected`:** payload `{ hub_dest_hash, reason, will_reconnect? }`. When `will_reconnect` is `false` (or `reason` is `local_disconnect`), the renderer drops that hub session. When `true` (or omitted on older sidecars), the UI shows reconnecting and keeps volatile rooms until WELCOME. - **Outbound Direct backchannel:** On live stack start, `LinkDeliveryManager::set_inbound_packet_sender(spawn_lxmf_outbound_backchannel(...))` forwards plaintext on outbound-initiated reusable Direct links into the same unpack path as peer-initiated `lxmf.delivery`. Developer log marker: `LXMF outbound-link backchannel packet`. Without this wiring, the peer's first reply may Ack on their client but never appear in mesh-client Chat. -- **`lxmf_outbound_status`:** authoritative outbound delivery updates for **network** sends. Payload: `{ message_hash, status, delivery_method?, to_hash?, sent_via? }` where `status` is `delivered`, `failed`, or intermediate `sending` (egress upgrade or Direct→PN fallback). mesh-client maps `delivered` → UI Completes (`acked`) and persists `delivery_status` (+ `delivery_method` when present) to SQLite; Propagated Completes show **Stored at propagation node**; `failed` → Failed. Do **not** treat `/api/v1/lxmf/send` response `delivery_status` (`queued`/`sending`) as terminal. After Direct failure with a preferred remote PN, the sidecar re-queues once as Propagated and emits `sending` + `delivery_method: "propagated"` before a final `delivered`/`failed`. **Paper create/ingest does not use this event** — Completes via `lxmf_message` with `delivery_method: "paper"` / `delivery_status: "delivered"`. +- **`lxmf_outbound_status`:** authoritative outbound delivery updates for **network** sends. Payload: `{ message_hash, status, delivery_method?, to_hash?, sent_via? }` where `status` is `delivered`, `stored_locally`, `failed`, or intermediate `sending` (egress upgrade or Direct→PN cascade step). mesh-client maps `delivered` / `stored_locally` → UI Completes (`acked`) and persists `delivery_status` (+ `delivery_method` when present) to SQLite; remote PN Completes (`delivered`) show **Stored at propagation node**; local-prop Completes (`stored_locally`) are local inbox only (not peer-delivered); `failed` → Failed. Do **not** treat `/api/v1/lxmf/send` response `delivery_status` (`queued`/`sending`) as terminal. After Direct exhausts, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → local-prop last, emitting `sending` + `delivery_method: "propagated"` or `"stored_locally"` between attempts before a final `delivered` / `stored_locally` / `failed`. **Paper create/ingest does not use this event** — Completes via `lxmf_message` with `delivery_method: "paper"` / `delivery_status: "delivered"`. - **`announce.received`:** coalesced WS notify for LXMF identity announces / path responses (named or nameless). Sidecar applies identity-key + display-name cache updates immediately, but emits **at most one** WS frame per coalesce window (500ms normal / 1000ms when >256 distinct destinations are pending) so announce storms stay O(1) bus pressure on large meshes (~100k). Payload is either a single `{ destination_hash, display_name?, hops, aspect?, identity_hash? }` (legacy / one-row flush) or `{ announces: [{ destination_hash, display_name?, hops, aspect?, identity_hash? }, ...] }` (capped at 1024, named preferred; overflow dropped — slow peer poll recovers). `aspect` is set when announce `name_hash` maps to a known app name (`lxmf.delivery`, `lxmf.propagation`, `nomadnetwork.node`, `rrc.hub`, `lxst.telephony`); omitted for path responses / unknown hashes (clients must not invent `"unknown"`). `identity_hash` is the hex identity recovered from the validated announce when present. Each flush publishes pressure counters under `GET /api/v1/diagnostics` → `announce_ws` (ingress/unique/overflow + storm/flush timestamps) for the Diagnostics `reticulum/announce-bus-pressure` warning. Display names update the peer-label cache only — announces do **not** auto-create LXMF contacts. That cache is overlayed onto `GET /api/v1/peers` / topology rows **and** onto nameless/hash-prefix rows from `GET /api/v1/contacts` (`list_contacts` may persist those fills) so path-table and contact refreshes keep announce aliases. - **`peers_updated`:** also emitted when the live path table **gains** new destination hashes (maintenance tick). Payload may include `{ added: string[], patches: PeerRow[], count }` (added/patches capped at 1024). Renderer applies patches incrementally, including route-field changes. A full peer dump is used on connect, manual Refresh, restart, safety poll, or a `peers_updated` payload that cannot be applied incrementally: `cleared`, `demoted_from_contacts`, or a single-`hash` probe/path event. Hop/timestamp-only churn does not emit. `lxmf_message` payload fields include `sender_hash`, `text`, `timestamp`, `message_hash`, optional `direction` (`inbound` / `outbound`), optional `delivery_status` (`sending` on optimistic outbound rows; `delivered` on paper Completes), optional `reply_to_hash` / `reply_preview_text` (from LXMF `FIELD_REPLY_TO` / `FIELD_REPLY_QUOTE`), and transport markers `received_via` / `sent_via`. Outbound `sent_via` is **path-table / PacketTap evidence**, not “any local RNode enabled”: atomic values are `rf`, `ble`, `tcp`, `network`, or **`paper`** (offline QR handoff); multi-egress observes join with `+` (e.g. `rf+tcp`, `ble+network`). Inbound `received_via` uses the path-table interface name **matched to local interface config** (same atoms — so a TCP hub named “RNS Testnet” is `tcp`, not `network`) or `paper` for decrypted paper URIs. Never use Meshtastic-style `both` for Reticulum network egress (legacy `both` may still appear in SQLite allowlists). -`lxmf_outbound_status` payload: `message_hash`, `status` (`delivered` / `failed` / `sending`), optional `delivery_method`, optional `sent_via` (egress evidence upgrade before Completes). +`lxmf_outbound_status` payload: `message_hash`, `status` (`delivered` / `stored_locally` / `failed` / `sending`), optional `delivery_method`, optional `sent_via` (egress evidence upgrade before Completes). ## Electron bridge Renderer calls `electronAPI.reticulum.*`; main process proxies to this API (sandboxed renderer cannot reach localhost directly). Lifecycle / proxy / Remote / factory-reset handlers live in `src/main/ipc/reticulum-handlers.ts`. Reticulum destination / Remote address / inbound-policy DB handlers are in `src/main/ipc/reticulum-db-handlers.ts`; RRC room history uses `src/main/ipc/rrc-db-handlers.ts`. +Shared `reticulum:proxy*` IPC is capped at **900/min**. `GET /api/v1/lxmf/recent` uses a dedicated **120/min** bucket so WS-lag catch-up does not starve mesh control. On rate-limit errors the renderer applies exponential backoff (`reticulumProxyRateLimitBackoff.ts`). + | IPC channel | Role | | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | | `reticulum:start` / `stop` / `getStatus` | Sidecar lifecycle | | `reticulum:syncInterfaceIssueScope` | Drop TCP/TX latch entries for disabled/removed interfaces; sticky enabled-name filter for later log lines | -| `reticulum:proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete` | HTTP proxy to paths above | +| `reticulum:proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete` | HTTP proxy to paths above (shared 900/min; lxmf/recent 120/min) | | `reticulum:factoryReset` | Factory reset (generic `proxyPost` blocks `/api/v1/system/factory-reset`; UI must use this channel) | | `reticulum:validateConfig` | One-shot `validate-config --json` against `userData/reticulum/config` (read-only; safe while stack runs) | | `reticulum:readDefaultConfigFile` | Read first existing system rnsd config path | diff --git a/docs/reticulum.md b/docs/reticulum.md index b226b1d5d..fbd4c936b 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -24,25 +24,25 @@ After changing interfaces on a live network, **restart the stack** so RNS picks ## What is included -| Area | Shipped behavior | -| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Stack lifecycle | Start / stop / auto-start; disconnect & quit | -| Interfaces | TCP client, I2P (`peers`), Auto discovery, RNode (USB serial, `ble://…`, Wi‑Fi `tcp://host:7633`); default hub picker by region (Primary & Global selected by default; added disabled; syncs/repairs selected endpoints and disables remaining decommissioned testnet hubs) | -| Identity | Generate / import mnemonic; display name; encrypted export; **identity vault** passcode on Network tab | -| LXMF chat | DM-only text and reactions (outbound LXMF file/voice-note attach removed; attachment labels render; **cached raster images** display inline; use Remote rncp for peer files). **LXST live voice Call** is separate telephony (rsLXST), not an LXMF voice clip. | -| Remote | **rnsh** multi-session shell + **rncp** send/receive/fetch under one tab (Shell / Transfer / Saved / Settings); Chat DM send-file convenience; path-speed gate (TCP/network); inbound Ask/allow-list; auto-reconnect / auto-retry; LXMF “request enable receive” prompt between mesh-client peers | -| RRC | Reticulum Relay Chat — discovered/manual/favourite hubs, up to **8** concurrent sessions, hub/room auto-join, rooms, nicklists, slash commands (`/list`, `/who`, `/join`, …), @mention unread badges (also badges the **Reticulum protocol pill** with LXMF Chat), toasts when the RRC tab is inactive, automatic reconnect with backoff | -| Delivery | **Direct** when destination is in path table (outbound-initiated Direct replies need the sidecar **outbound Direct backchannel**) (then **one-shot fallback** to preferred **remote** PN on Direct fail); **Propagated (PN)** when offline and a preferred remote PN is set; **Paper** for offline encrypted QR/`lxm://` handoff (no network — Completes immediately, no `lxmf_outbound_status`). Path/transport badges (RF/BLE/TCP/NET, multi, PN, Paper) are egress evidence — network UI stays **Sending** until `lxmf_outbound_status` (`delivered` / `failed`); Propagated Completes show **Stored at propagation node**. Terminal `delivery_status` + `delivery_method` persist in SQLite. Local PN hosting ≠ remote store-and-forward. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | -| Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe; **LXST Call** and **LRGP Challenge** on rows; peer detail modal (Save as contact is manual) | -| Games | LRGP Tic-Tac-Toe + Chess via sibling [lrgp-rs](https://github.com/ratspeak/lrgp-rs); Games tab + Challenge from Peers/Chat; opponent labels via `resolveReticulumRemoteHashLabel`; deep-link `lrgp:` / `lxm://game/`; delivery chips + resend-after-restart (`games_outbound.db`); Chess promotion picker + threefold/50-move claims; wire-compatible with Ratspeak ([parity checklist](reticulum-games-parity.md)) | -| Topology | Best-effort graph from path-table next hops (not a full multi-hop trace) | -| Map | Local RMAP v4 discovery map (heard opt-in interfaces with GPS); link to rmap.world for global view | -| Nomad Network | Favourites / announces list (collapsible sidebar, default Favourites sub-tab) plus **My Pages** watched-folder hosting; **lazy-mount after first visit**; Micron (.mu) browser in a **dual-axis scroll shell**; **fit-width wrap default** with open-width toggle for ASCII pages; in-page navigation, back/forward, session page cache, `/file/` downloads, source toggle, and lxmf:// DM links; page/file errors humanized via `nomadPageErrorHumanize.ts`. Local hosting uses sibling [rsNomad](https://github.com/Colorado-Mesh/rsNomad) (`nomad-core`) for static `/page` + `/file` serving and `nomadnetwork.node` announces (no CGI). Choose a site root (`pages/`) or pages directory; FS watcher reloads routes; `nomad_serving_enabled` auto-restores after stack start. | -| Propagation | Preferred node, per-node **Sync messages**, rename/delete remote nodes, **Discovered on network** (Add / Add & prefer with `/offer` probe), optional **local PN hosting**, configurable **auto-sync interval**, Network **Advanced PN hosting** policy | -| Diagnostics | Reticulum-native interface / path / LXMF health and config audit (`reticulum/*` rows only on this tab; LoRa Hop Goblins and foreign-LoRa tables are Meshtastic/MeshCore-scoped) | -| Admin | RNode firmware flasher (Web Serial), stack factory reset | -| Sniffer / Stats | Reticulum packet log tab (`rawPacketLog.reticulum.*`) | -| Coexistence | BLE on a **different** MAC from Meshtastic/MeshCore; scan mutex; **Noble BLE yield** when an enabled BLE RNode is in config (sidecar suspends Noble on macOS/Windows so btleplug can pair) | +| Area | Shipped behavior | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Stack lifecycle | Start / stop / auto-start; disconnect & quit | +| Interfaces | TCP client, I2P (`peers`), Auto discovery, RNode (USB serial, `ble://…`, Wi‑Fi `tcp://host:7633`); default hub picker by region (Primary & Global selected by default; added disabled; syncs/repairs selected endpoints and disables remaining decommissioned testnet hubs) | +| Identity | Generate / import mnemonic; display name; encrypted export; **identity vault** passcode on Network tab | +| LXMF chat | DM-only text and reactions (outbound LXMF file/voice-note attach removed; attachment labels render; **cached raster images** display inline; use Remote rncp for peer files). **LXST live voice Call** is separate telephony (rsLXST), not an LXMF voice clip. | +| Remote | **rnsh** multi-session shell + **rncp** send/receive/fetch under one tab (Shell / Transfer / Saved / Settings); Chat DM send-file convenience; path-speed gate (TCP/network); inbound Ask/allow-list; auto-reconnect / auto-retry; LXMF “request enable receive” prompt between mesh-client peers | +| RRC | Reticulum Relay Chat — discovered/manual/favourite hubs, up to **8** concurrent sessions, hub/room auto-join, rooms, nicklists, slash commands (`/list`, `/who`, `/join`, …), @mention unread badges (also badges the **Reticulum protocol pill** with LXMF Chat), toasts when the RRC tab is inactive, automatic reconnect with backoff | +| Delivery | **Direct** when destination is in path table (outbound-initiated Direct replies need the sidecar **outbound Direct backchannel**). After Direct exhausts: **multi-PN cascade** — preferred remote → other enabled remotes (hop-sorted) → **local-prop last**. Remote PN Completes as `delivered` (**Stored at propagation node**); local-prop Completes as `stored_locally` (local inbox, not peer-delivered). **Paper** for offline encrypted QR/`lxm://` handoff (no network — Completes immediately, no `lxmf_outbound_status`). Path/transport badges (RF/BLE/TCP/NET, multi, PN, Paper) are egress evidence — network UI stays **Sending** until `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`). Terminal `delivery_status` + `delivery_method` persist in SQLite. Local inbox Completes ≠ peer delivery. Inbound `received_via` / TCP badges use local interface **config type**, not display name. | +| Peers | RNS path table + messaged History + saved Contacts + Favorites (Peers tab sub-tabs); LXMFace avatars; probe; **LXST Call** and **LRGP Challenge** on rows; peer detail modal (Save as contact is manual) | +| Games | LRGP Tic-Tac-Toe + Chess via sibling [lrgp-rs](https://github.com/ratspeak/lrgp-rs); Games tab + Challenge from Peers/Chat; opponent labels via `resolveReticulumRemoteHashLabel`; deep-link `lrgp:` / `lxm://game/`; delivery chips + resend-after-restart (`games_outbound.db`); Chess promotion picker + threefold/50-move claims; wire-compatible with Ratspeak ([parity checklist](reticulum-games-parity.md)) | +| Topology | Best-effort graph from path-table next hops (not a full multi-hop trace) | +| Map | Local RMAP v4 discovery map (heard opt-in interfaces with GPS); link to rmap.world for global view | +| Nomad Network | Favourites / announces list (collapsible sidebar, default Favourites sub-tab) plus **My Pages** watched-folder hosting; **lazy-mount after first visit**; Micron (.mu) browser in a **dual-axis scroll shell**; **fit-width wrap default** with open-width toggle for ASCII pages; in-page navigation, back/forward, session page cache, `/file/` downloads, source toggle, and lxmf:// DM links; page/file errors humanized via `nomadPageErrorHumanize.ts`. Local hosting uses sibling [rsNomad](https://github.com/Colorado-Mesh/rsNomad) (`nomad-core`) for static `/page` + `/file` serving and `nomadnetwork.node` announces (no CGI). Choose a site root (`pages/`) or pages directory; FS watcher reloads routes; `nomad_serving_enabled` auto-restores after stack start. | +| Propagation | Preferred node, per-node **Sync messages**, rename/delete remote nodes, **Discovered on network** (Add / Add & prefer with `/offer` probe), optional **local PN hosting**, configurable **auto-sync interval**, Network **Advanced PN hosting** policy | +| Diagnostics | Reticulum-native interface / path / LXMF health and config audit (`reticulum/*` rows only on this tab; LoRa Hop Goblins and foreign-LoRa tables are Meshtastic/MeshCore-scoped) | +| Admin | RNode firmware flasher (Web Serial), stack factory reset | +| Sniffer / Stats | Reticulum packet log tab (`rawPacketLog.reticulum.*`) | +| Coexistence | BLE on a **different** MAC from Meshtastic/MeshCore; scan mutex; **Noble BLE yield** when an enabled BLE RNode is in config (sidecar suspends Noble on macOS/Windows so btleplug can pair) | **Not in Reticulum mode:** Meshtastic/MeshCore-style RF channel chat, MQTT broker card, Meshtastic/MeshCore LoRa node position map, Rooms BBS, TAK, Meshtastic PKI Security tab, Hop Goblins routing diagnostics. (RRC is hub room chat over Reticulum Links — not LoRa RF channels.) @@ -281,10 +281,10 @@ When multiple enabled local RNode interfaces are connected, the interface list s - **DM-only** on the Chat tab — no RF channel pills (RRC covers hub rooms separately) - Text and emoji reactions. **Outbound LXMF file/voice attach is not offered** (removed); historic `[file:name:mime]` bubbles and inbound Sideband-style attachments render a read-only label; when the file remains in `reticulum/attachments/`, **raster images** (JPEG/PNG/GIF/WebP/AVIF/BMP — not SVG) display inline via main-process `chat:readReticulumAttachmentAsDataUrl` (magic-byte MIME check, 2 MiB cap, path jailed, IPC rate-limited). Peer file transfer is via Remote rncp. - **Replies:** outbound DMs stamp LXMF `FIELD_REPLY_TO` (0x30) and optional `FIELD_REPLY_QUOTE` (0x31, capped) before sign so peers see structured replies; ingest/Chat use `reticulum_reply_to_hash` plus quote preview (store parent when present, else wire quote) and jump-to-parent by message hash -- Outbound **Sending** until sidecar emits `lxmf_outbound_status` (`delivered` / `failed`); `/api/v1/lxmf/send` may return `delivery_status: "queued"` or `"sending"` — that is enqueue/acceptance, not delivery confirmation. On Direct failure with a preferred **remote** PN, the sidecar re-emits `sending` with `delivery_method: "propagated"` for the one-shot PN deposit. **Exception — paper:** Chat DM **Share as paper** (`createReticulumPaperMessage` → `POST /api/v1/lxmf/paper/create`) encrypts offline to a QR/`lxm://` URI with **no network send**; Completes immediately (`delivery_method: paper`, badge **Paper**) and does **not** use `lxmf_outbound_status`. Ingest via Chat **Scan paper**, Network **Scan / import**, or OS `lxm://` (`POST /api/v1/lxmf/paper/ingest` — HTTP `message` fallback-ingested when WS lags). Create needs peer pubkey (`identity_unknown` otherwise); ingest needs matching local identity (`decrypt_failed` otherwise); size-capped (`paper_too_large`). -- Terminal **Completes** / **Failed** from `lxmf_outbound_status` are persisted to SQLite (`delivery_status` + `delivery_method` on `reticulum_messages`) via `applyReticulumOutboundDeliveryStatus.ts` so restart/DB hydration keeps PN vs Direct labeling; early WS events before provisional id→hash rekey are buffered +- Outbound **Sending** until sidecar emits `lxmf_outbound_status` (`delivered` / `stored_locally` / `failed`); `/api/v1/lxmf/send` may return `delivery_status: "queued"` or `"sending"` — that is enqueue/acceptance, not delivery confirmation. After Direct exhausts, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → local-prop last, re-emitting `sending` with `delivery_method: "propagated"` (remote) or `"stored_locally"` (local inbox) between attempts. **Exception — paper:** Chat DM **Share as paper** (`createReticulumPaperMessage` → `POST /api/v1/lxmf/paper/create`) encrypts offline to a QR/`lxm://` URI with **no network send**; Completes immediately (`delivery_method: paper`, badge **Paper**) and does **not** use `lxmf_outbound_status`. Ingest via Chat **Scan paper**, Network **Scan / import**, or OS `lxm://` (`POST /api/v1/lxmf/paper/ingest` — HTTP `message` fallback-ingested when WS lags). Create needs peer pubkey (`identity_unknown` otherwise); ingest needs matching local identity (`decrypt_failed` otherwise); size-capped (`paper_too_large`). +- Terminal **Completes** / **Failed** from `lxmf_outbound_status` are persisted to SQLite (`delivery_status` + `delivery_method` on `reticulum_messages`) via `applyReticulumOutboundDeliveryStatus.ts` so restart/DB hydration keeps PN vs Direct vs local-inbox labeling; early WS events before provisional id→hash rekey are buffered - **Optimistic pending rekey:** Chat send creates a `reticulum-pending-*` row; when the sidecar returns the real `message_hash`, ingest/SQLite upsert passes `replaces_message_hash` so the pending row is deleted atomically (avoids orphan Sending duplicates) -- Propagated Completes render as **Stored at propagation node** (PN badge) — not recipient **Delivered** +- Remote PN Completes (`delivered`) render as **Stored at propagation node** (PN badge); local-prop Completes (`stored_locally`) stay in the **local propagation inbox** — neither is recipient **Delivered** - **DM path reachability:** active DM header shows a reachability badge (`ReticulumDmPathReachabilityBadge` + `useReticulumDmPathProbe`) seeded from path-table/contact hops, then settled by peer probe; when settled, **Request path** / **Probe** use the same sidecar endpoints as the Peers tab. Chat **Probe** mirrors Peer List UX: stack-running check → `/probe` → toast → peer refresh; `onProbeSettled` / `applyProbeResult(forHash, …)` applies the result without a second `/probe` (stale hashes after DM switch are ignored); manual reprobe forces Checking… even when passive hops already look reachable ## RRC (Reticulum Relay Chat) @@ -303,23 +303,24 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): ### Delivery modes -| Path table | Propagation node | Routing / UI | -| ------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Destination present | — | **Direct** link delivery; RF/BLE/TCP/NET (or explicit multi e.g. RF+TCP) badge = path-table / PacketTap egress — message stays **Sending** until `lxmf_outbound_status: delivered` | -| Destination present | Preferred **remote** PN set | Same Direct-first attempt; if Direct **fails**, sidecar **one-shot retries via preferred remote PN** (not local PN hosting). UI switches to **PN** / **Stored at propagation node** on PN Complete | -| Destination absent | Preferred PN set | **Propagated** via preferred propagation node; **PN** badge = store-and-forward — Completes as **Stored at propagation node** (not recipient-delivered) | -| Destination absent | None | Error `no_propagation_node`; set preferred **remote** node on Network tab | -| n/a (offline) | n/a | **Paper** — encrypted QR/`lxm://` handoff (`DeliveryMethod::Paper`); no path table or PN; Completes immediately; badge **Paper**; does not use `lxmf_outbound_status` | +| Path table | Propagation node | Routing / UI | +| ------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Destination present | — (no cascade candidates) | **Direct** link delivery; RF/BLE/TCP/NET (or explicit multi e.g. RF+TCP) badge = path-table / PacketTap egress — message stays **Sending** until `lxmf_outbound_status: delivered` | +| Destination present | Remotes and/or enabled local-prop | Direct-first; on Direct fail, **cascade** preferred remote → other enabled remotes (hop-sorted) → local-prop last. Remote Completes → **PN** / **Stored at propagation node** (`delivered`); local-prop → **local inbox** (`stored_locally`) | +| Destination absent | Preferred / enabled remotes | **Propagated** via cascade (preferred first); **PN** badge — Completes as **Stored at propagation node** (not recipient-delivered) | +| Destination absent | Local-prop only | Completes as `stored_locally` in the **local propagation inbox** (not peer-delivered) | +| Destination absent | None | Error `no_propagation_node`; set a preferred **remote** node (or enable local-prop for inbox-only Completes) on Network tab | +| n/a (offline) | n/a | **Paper** — encrypted QR/`lxm://` handoff (`DeliveryMethod::Paper`); no path table or PN; Completes immediately; badge **Paper**; does not use `lxmf_outbound_status` | -**Path ≠ delivered:** a path-table entry means RNS knows a route, not that LXMF completed. Reticulum is async — offline peers need a **remote** propagation node (or **paper** QR handoff). **Local PN hosting** accepts network deposits and peers inventory with other PNs when enabled — it still does **not** replace a preferred **remote** PN for _your_ unreachable outbound DMs. Propagated Completes mean the PN accepted the encrypted blob (Ratspeak envelope parity), not that the recipient opened Chat. LXMF retrieval is **any-node**: deposit on PN A and Sync from PN B is valid when the fabric peers; parties need not share the same preferred PN. +**Path ≠ delivered:** a path-table entry means RNS knows a route, not that LXMF completed. Reticulum is async — offline peers need a **remote** propagation node (or **paper** QR handoff) for peer store-and-forward. **Local-prop** is last in the Direct→PN cascade and Completes as `stored_locally` (your inbox only — not peer delivery). Remote PN Completes mean the PN accepted the encrypted blob (Ratspeak envelope parity), not that the recipient opened Chat. The renderer link-timeout Failed bridge skips while cascade capacity remains (any untried remote **or** enabled local-prop). LXMF retrieval is **any-node**: deposit on PN A and Sync from PN B is valid when the fabric peers; parties need not share the same preferred PN. --- ## Path routing -When a destination is reachable over more than one next hop, the sidecar keeps up to **three ranked path slots** (one active + backups). Failover promotes a backup (or rediscovers via another live interface) before giving up — Nomad page loads exhaust alternate paths inside one request; LXMF Direct does the same before preferred-PN fallback. See [troubleshooting](troubleshooting.md#nomad-network-pages-hang-or-almost-never-load) for triage. +When a destination is reachable over more than one next hop, the sidecar keeps up to **three ranked path slots** (one active + backups). Failover promotes a backup (or rediscovers via another live interface) before giving up — Nomad page loads exhaust alternate paths inside one request; LXMF Direct does the same before the **multi-PN cascade**. See [troubleshooting](troubleshooting.md#nomad-network-pages-hang-or-almost-never-load) for triage. -**AutoInterface vs private TCP/UDP:** Peers learned on Auto are normal 0-hop neighbors; RNS may keep Auto active even when a private LAN hub path exists (including equal-hop ties). For LXMF Direct, the sidecar **automatically** demotes Auto toward a live **private** path when Auto is unhealthy for delivery or Direct fails on Auto — then fails over private → public → preferred PN. It does **not** rewrite healthy Auto Direct, and does **not** preempt Auto to public internet hubs. See [troubleshooting — local DMs hang with AutoInterface + private TCP hub](troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub). +**AutoInterface vs private TCP/UDP:** Peers learned on Auto are normal 0-hop neighbors; RNS may keep Auto active even when a private LAN hub path exists (including equal-hop ties). For LXMF Direct, the sidecar **automatically** demotes Auto toward a live **private** path when Auto is unhealthy for delivery or Direct fails on Auto — then fails over private → public → multi-PN cascade (preferred remote → other enabled remotes hop-sorted → local-prop last). It does **not** rewrite healthy Auto Direct, and does **not** preempt Auto to public internet hubs. See [troubleshooting — local DMs hang with AutoInterface + private TCP hub](troubleshooting.md#reticulum-local-dms-hang-with-autointerface--private-tcp-hub). **Network → stack settings → Prefer path medium** sets the global bias: @@ -381,10 +382,10 @@ Firmware `.zip` files are selected locally (no in-app GitHub download). Disconne ### SQLite (main process) -| Table | Contents | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `reticulum_destinations` | Destination meta (canonical 32-hex hash, display name, favorited, `icon_name`, `icon_color`, `last_heard` for History, `is_contact` for saved Contacts) | -| `reticulum_messages` | LXMF history (`message_hash`, `reply_to_hash`, `delivery_status` — `sending`/`queued`/`pending`/`delivered`/`failed`; `delivery_method` — `direct`/`propagated`/`opportunistic`/`paper`; `received_via` atoms include `rf`/`ble`/`tcp`/`network`/`mqtt`/`both`/`paper`; terminal outbound status written on `lxmf_outbound_status` except paper Completes from create/ingest; stale `sending` rows marked failed on startup; optional `replaces_message_hash` on upsert deletes the prior optimistic pending hash) | +| Table | Contents | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `reticulum_destinations` | Destination meta (canonical 32-hex hash, display name, favorited, `icon_name`, `icon_color`, `last_heard` for History, `is_contact` for saved Contacts) | +| `reticulum_messages` | LXMF history (`message_hash`, `reply_to_hash`, `delivery_status` — `sending`/`queued`/`pending`/`delivered`/`failed`; `delivery_method` — `direct`/`propagated`/`opportunistic`/`paper`/`stored_locally`; wire `stored_locally` Completes map to SQLite `delivered` + `delivery_method: stored_locally`; `received_via` atoms include `rf`/`ble`/`tcp`/`network`/`mqtt`/`both`/`paper`; terminal outbound status written on `lxmf_outbound_status` except paper Completes from create/ingest; stale `sending` rows marked failed on startup; optional `replaces_message_hash` on upsert deletes the prior optimistic pending hash) | ### Sidecar `userData` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9e02bd5c4..0eb4c90f0 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1137,7 +1137,7 @@ In dev, **Start stack** now rebuilds when `reticulum-sidecar/src/**/*.rs` or `Ca Unrecognized codes pass through unchanged. -TCP/network Nomad Links use path-scaled initiator hops (`link_hops = clamp(path_hops, 3, 7)`) and a LinkClient proof wait of the **remaining overall MeshChat deadline** (~45s TCP after instant pubkey recall), matching v5.25.0. Do not cap LRPROOF at hops×6 or a 30s floor — that false-failed multi-hop hub pages that still load on release. First attempts use a cached path when present (no DropPath storm); missing paths RequestPath briefly and may return `path_timeout`. On TCP `link_timeout`, the sidecar suppresses the dead iface, drops the failed via, promotes ranked path-slot backups / other live hubs (extra RequestPath when another TCP/RF iface is up), then retries inside the same fetch. LXMF Direct chat uses the same exhaustion before the one-shot preferred-PN fallback. `force_path_ok=true` means rediscovered after absence only (cache hits log `force_path_ok=false`). Failure logs (`[nomadNetworkStore] … fetch failed` and sidecar `Nomad Link query failed`) include `path_hops`, `link_hops`, `proof_budget_secs`, `force_path_ok`, `path_ensure_kind`, `elapsed_ms`, `tried_interfaces`, `failover_rounds`, `iface`, and `raw=`. UI errors distinguish cached-path vs rediscovered-path link failures. +TCP/network Nomad Links use path-scaled initiator hops (`link_hops = clamp(path_hops, 3, 7)`) and a LinkClient proof wait of the **remaining overall MeshChat deadline** (~45s TCP after instant pubkey recall), matching v5.25.0. Do not cap LRPROOF at hops×6 or a 30s floor — that false-failed multi-hop hub pages that still load on release. First attempts use a cached path when present (no DropPath storm); missing paths RequestPath briefly and may return `path_timeout`. On TCP `link_timeout`, the sidecar suppresses the dead iface, drops the failed via, promotes ranked path-slot backups / other live hubs (extra RequestPath when another TCP/RF iface is up), then retries inside the same fetch. LXMF Direct chat uses the same path exhaustion before the **multi-PN cascade**. `force_path_ok=true` means rediscovered after absence only (cache hits log `force_path_ok=false`). Failure logs (`[nomadNetworkStore] … fetch failed` and sidecar `Nomad Link query failed`) include `path_hops`, `link_hops`, `proof_budget_secs`, `force_path_ok`, `path_ensure_kind`, `elapsed_ms`, `tried_interfaces`, `failover_rounds`, `iface`, and `raw=`. UI errors distinguish cached-path vs rediscovered-path link failures. **Cause**: Older `LinkClient` always waited for a fresh path-response announce for the destination public key, even when Nomad announces had already cached it. Successful fetches could also deregister all `nomadnetwork.node` announce handlers. Distant/high-hop nodes can still time out at the path stage (expected RF/mesh reachability limits). @@ -1346,7 +1346,7 @@ Bond-stale **TX queue full** hints (`txQueueDropsHintBleBondStale`) point at the 1. **Shared instance conflict** — `share_instance = Yes` with another Reticulum app still running (MeshChatX, Ratspeak, standalone `rnsd`) fighting the same IPC socket. mesh-client may attach as `SharedInstanceClient` and **not spawn** local TCP hubs (Connection then shows misleading “TCP hub unreachable”). 2. **Dead TCP hub still enabled** — outbound queue fills; path requests fail with _no available capacity_. -3. **No remote propagation node** — when Direct fails and no preferred **remote** PN is configured, there is no store-and-forward retry (local inbox does not count). With a remote preferred PN, the sidecar retries once via that PN (see **Stale path + Failed via TCP** below). +3. **No PN cascade capacity** — when Direct fails and there are no enabled cascade candidates (preferred/other remotes or local-prop), the row fails with no store-and-forward retry. With remotes (and/or enabled local-prop), the sidecar cascades after Direct exhausts (see **Stale path + Failed via TCP** below). Developer bundles include `reticulum/lxmf-outbound.log` (filtered LXMF outbound / PN cascade lines). **Fix**: @@ -1371,9 +1371,9 @@ Export for GitHub (`reticulum.sidecar.interfaceIssueAlert`, link-timeout counts) 1. Open **Network → Propagation** (Chat notice **Set up propagation** jumps there). 2. Add a **32-character LXMF destination hash** from whoever runs the propagation node you trust. 3. Set **Preferred** (manual mode) or leave **Auto** when multiple nodes are listed. -4. **Local propagation hosting** stores messages for peers that sync with you — it does **not** replace a remote propagation node for peers you cannot reach directly. Preferring Local shows a warning toast; Chat still treats local-only as “no remote PN.” +4. **Local propagation hosting** stores messages for peers that sync with you and is **last** in the Direct→PN cascade (`stored_locally` — local inbox, not peer-delivered). Preferring Local shows a warning toast; it does **not** replace a remote PN for peer store-and-forward. -**Stale path + Failed via TCP:** When a path exists, mesh-client tries **Direct** first. If Direct fails and a preferred **remote** PN is configured, the sidecar retries once via that PN (Ratspeak-style store-and-forward). Without a remote preferred PN, the row stays **Failed** even if Ratspeak on the same machine deposits successfully. +**Stale path + Failed via TCP:** When a path exists, mesh-client tries **Direct** first. If Direct fails, the sidecar **cascades** preferred remote → other enabled remotes (hop-sorted) → local-prop last. Remote deposits Complete as `delivered` (**Stored at propagation node**); local-prop Completes as `stored_locally` (inbox only). The renderer link-timeout Failed bridge skips while cascade capacity remains. Without any cascade candidates, the row stays **Failed**. Check developer-bundle `reticulum/lxmf-outbound.log` for cascade lines. Persistent `proxyGet`/`proxyPost` storms may hit the shared **900/min** proxy ceiling (LXMF recent catch-up uses a dedicated **120/min** bucket; renderer backs off on rate-limit errors). **Not the same as transport:** Ratspeak TCP hubs (e.g. `rns.ratspeak.org:4242`) and [rathole](https://github.com/ratspeak/rathole) are **connectivity / transport** tools, not LXMF propagation. mesh-client does not ship a default community propagation hash. diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index 0e4188779..a4b621a48 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -29,10 +29,10 @@ use super::super::path_failover::{ }; use super::super::types::InterfaceRow; use super::super::via::classify_interface; +use super::parse_hash16; use super::pn_cascade::{ PnCascadeCandidate, build_pn_cascade_order, cascade_has_capacity, pick_next_pn_cascade, }; -use super::{lxmf_payload_from_message, parse_hash16}; const PATH_REQUEST_BACKOFF_SECS: f64 = 20.0; const PATH_REQUEST_MAX_ATTEMPTS: u32 = 12; @@ -118,9 +118,11 @@ impl PathRequestGate { } } -/// Bound on per-message PN cascade tried-set tracking. +/// Cap on distinct message hashes retained in `pn_cascade_tried` (memory bound under +/// announce/outbound floods — eviction prefers keys not in pending deposit/target maps). const PN_CASCADE_TRIED_MAX: usize = 256; -/// After this many sync/pending PN-link deferrals, advance to the next cascade PN. +/// After this many sync/pending PN-link deferrals, advance to the next cascade PN so a +/// busy preferred PN cannot storm-defer the same deposit forever. const PN_DEPOSIT_DEFER_ADVANCE_AFTER: u32 = 8; /// Correlatable ids for an in-flight Propagated deposit (`pn_hash`, optional `transient_id`). @@ -160,7 +162,13 @@ pub struct LxmfOutboundDriver { propagation_sync_target: Option<[u8; 16]>, /// In-flight Propagated deposits: message_hash → (pn_hash, transient_id). pending_pn_deposits: HashMap<[u8; 32], PendingPnDeposit>, + /// Per-message PN target for the current cascade step (avoids retargeting the + /// router-global `outbound_propagation_node` for concurrent sends). + pending_pn_targets: HashMap<[u8; 32], [u8; 16]>, + /// Local LXMF identity (retained for driver construction / future failed-detail payloads). + #[allow(dead_code)] self_lxmf_hash: String, + #[allow(dead_code)] self_display_name: String, } @@ -196,6 +204,7 @@ impl LxmfOutboundDriver { direct_path_failovers: HashMap::new(), propagation_sync_target: None, pending_pn_deposits: HashMap::new(), + pending_pn_targets: HashMap::new(), self_lxmf_hash: self_lxmf_hash.clone(), self_display_name, }; @@ -376,7 +385,8 @@ impl LxmfOutboundDriver { }) .collect(); - let actions = router.process_outbound_with_direct(|message, _now| { + self.ensure_router_pn_for_dispatch(router); + let mut actions = router.process_outbound_with_direct(|message, _now| { direct_inputs .get(&message.destination_hash) .cloned() @@ -386,6 +396,7 @@ impl LxmfOutboundDriver { reusable_link: DirectReusableLinkState::None, }) }); + self.apply_pending_pn_targets(&mut actions); if !actions.is_empty() { self.execute_actions(router, event_tx, actions); @@ -495,11 +506,8 @@ impl LxmfOutboundDriver { "DeliverPropagated: deferring — PN link busy" ); if let Some(hash) = message.hash.or(message.message_id) { - let method = if self.pn_cascade_local.contains(&hash) { - "stored_locally" - } else { - "propagated" - }; + self.pending_pn_targets.insert(hash, prop_hash); + let method = self.cascade_wire_delivery_method(hash); emit_outbound_status_with_via( event_tx, Some(serde_json::Value::String(hex::encode(hash))), @@ -514,6 +522,7 @@ impl LxmfOutboundDriver { } if let Some(hash) = message.hash.or(message.message_id) { self.pn_deposit_defer_counts.remove(&hash); + self.pending_pn_targets.insert(hash, prop_hash); } if !self.known_identities.contains_key(&prop_hex.to_lowercase()) { tracing::debug!( @@ -540,10 +549,18 @@ impl LxmfOutboundDriver { tracing::warn!( prop = %prop_hex, dest = %hex::encode(message.destination_hash), - "DeliverPropagated: pack_for_propagation failed — requeue" + "DeliverPropagated: pack_for_propagation failed — advancing PN cascade" ); - router.send(message); - return; + if let Some(hash) = message.hash.or(message.message_id) { + self.mark_pn_tried(hash, prop_hash); + } + match self.try_advance_pn_cascade(router, event_tx, message) { + Ok(()) => return, + Err(message) => { + self.emit_outbound_failed(router, event_tx, *message); + return; + } + } }; // lxmd parity: count the attempt before packed link delivery so Failed can budget retries. let attempts = mark_propagated_delivery_attempt(&mut message); @@ -552,10 +569,18 @@ impl LxmfOutboundDriver { prop = %prop_hex, attempts, max_attempts = MAX_DELIVERY_ATTEMPTS, - "propagated delivery attempt budget reached; deferring terminal failure" + "propagated delivery attempt budget reached — advancing PN cascade" ); - router.send(message); - return; + if let Some(hash) = message.hash.or(message.message_id) { + self.mark_pn_tried(hash, prop_hash); + } + match self.try_advance_pn_cascade(router, event_tx, message) { + Ok(()) => return, + Err(message) => { + self.emit_outbound_failed(router, event_tx, *message); + return; + } + } } let hops = route_hops_for(&self.route_hops, prop_hash); let message_hash_hex = message.hash.as_ref().map(hex::encode); @@ -781,15 +806,11 @@ impl LxmfOutboundDriver { mut message: LxMessage, ) { message.mark_failed(); - let method = if message + let method = message .hash .or(message.message_id) - .is_some_and(|h| self.pn_cascade_local.contains(&h)) - { - "stored_locally" - } else { - delivery_method_label(message.method) - }; + .map(|h| self.cascade_wire_delivery_method(h)) + .unwrap_or_else(|| delivery_method_label(message.method)); tracing::warn!( target: "lxmf-outbound", dest = %hex::encode(message.destination_hash), @@ -802,7 +823,6 @@ impl LxmfOutboundDriver { self.clear_pn_cascade_state(hash); self.direct_path_failovers.remove(&hash); self.pending_pn_deposits.remove(&hash); - self.pn_deposit_defer_counts.remove(&hash); let _ = router.mark_outbound_failed(&hash); emit_outbound_status_detailed_with_attempts( event_tx, @@ -816,16 +836,6 @@ impl LxmfOutboundDriver { Some(attempts), ); } - let payload = lxmf_payload_from_message( - &message, - &self.self_lxmf_hash, - &self.self_display_name, - None, - Some(method), - "outbound", - None, - ); - emit_outbound_status(event_tx, &payload, "failed", method); } fn ordered_pn_cascade(&self) -> Vec { @@ -836,9 +846,19 @@ impl LxmfOutboundDriver { if self.pn_cascade_tried.len() >= PN_CASCADE_TRIED_MAX && !self.pn_cascade_tried.contains_key(&msg_hash) { - if let Some(oldest) = self.pn_cascade_tried.keys().next().copied() { + let victim = self + .pn_cascade_tried + .keys() + .find(|k| { + !self.pending_pn_targets.contains_key(*k) + && !self.pending_pn_deposits.contains_key(*k) + }) + .copied() + .or_else(|| self.pn_cascade_tried.keys().next().copied()); + if let Some(oldest) = victim { self.pn_cascade_tried.remove(&oldest); self.pn_cascade_local.remove(&oldest); + self.pending_pn_targets.remove(&oldest); } } self.pn_cascade_tried @@ -851,6 +871,43 @@ impl LxmfOutboundDriver { self.pn_cascade_tried.remove(&msg_hash); self.pn_cascade_local.remove(&msg_hash); self.pn_deposit_defer_counts.remove(&msg_hash); + self.pending_pn_targets.remove(&msg_hash); + } + + fn cascade_wire_delivery_method(&self, msg_hash: [u8; 32]) -> &'static str { + if self.pn_cascade_local.contains(&msg_hash) { + "stored_locally" + } else { + "propagated" + } + } + + /// Ensure the router has *some* outbound PN so Propagated dispatch can emit actions. + /// Does not retarget an already-set global — per-message targets use `pending_pn_targets`. + fn ensure_router_pn_for_dispatch(&self, router: &mut LxmRouter) { + if router.outbound_propagation_node.is_some() { + return; + } + if let Some(preferred) = self.preferred_pn_hash { + router.set_outbound_propagation_node(Some(preferred)); + return; + } + if let Some(first) = self.ordered_pn_cascade().first().map(|c| c.hash) { + router.set_outbound_propagation_node(Some(first)); + } + } + + /// Rewrite `DeliverPropagated.prop_hash` from the per-message cascade target map. + fn apply_pending_pn_targets(&self, actions: &mut [OutboundAction]) { + for action in actions.iter_mut() { + if let OutboundAction::DeliverPropagated { message, prop_hash } = action { + if let Some(hash) = message.hash.or(message.message_id) { + if let Some(target) = self.pending_pn_targets.get(&hash) { + *prop_hash = *target; + } + } + } + } } /// Advance Direct→Propagated cascade: preferred remote → other remotes → local-prop. @@ -902,7 +959,8 @@ impl LxmfOutboundDriver { } else { self.pn_cascade_local.remove(&msg_hash); } - router.set_outbound_propagation_node(Some(pn_hash)); + self.pending_pn_targets.insert(msg_hash, pn_hash); + self.ensure_router_pn_for_dispatch(router); message.method = DeliveryMethod::Propagated; message.delivery_attempts = 0; message.next_delivery_attempt = 0.0; @@ -1017,9 +1075,13 @@ impl LxmfOutboundDriver { DeliveryResult::Rejected { message, reason, .. } => { - if let Some(hash) = message.hash { - self.pending_pn_deposits.remove(&hash); - } + let msg_hash = message.hash.or(message.message_id); + let rejected_pn = msg_hash.and_then(|h| { + self.pending_pn_deposits + .remove(&h) + .map(|(pn, _)| pn) + .or_else(|| self.pending_pn_targets.get(&h).copied()) + }); tracing::warn!( dest = %hex::encode(message.destination_hash), method = %delivery_method_label(message.method), @@ -1028,12 +1090,8 @@ impl LxmfOutboundDriver { ); // Peer/PN rejected — advance cascade (next remote or local-prop). if message.method == DeliveryMethod::Propagated { - if let Some(hash) = message.hash.or(message.message_id) { - // Mark the PN that rejected if we know it from pending deposit clear above. - // dest is not in Rejected; mark current outbound PN if set. - if let Some(pn) = router.outbound_propagation_node { - self.mark_pn_tried(hash, pn); - } + if let (Some(hash), Some(pn)) = (msg_hash, rejected_pn) { + self.mark_pn_tried(hash, pn); } } match self.try_advance_pn_cascade(router, event_tx, message) { @@ -1217,14 +1275,15 @@ impl LxmfOutboundDriver { "re-queuing Propagated LXMF after retryable link failure" ); if let Some(hash) = msg_hash { + self.pending_pn_targets.insert(hash, prop_hash); // Keep chat UI in sending/propagated while PN rediscovery proceeds. emit_outbound_status_with_via( event_tx, Some(serde_json::Value::String(hex::encode(hash))), None, "sending", - Some("propagated"), - None, + Some(self.cascade_wire_delivery_method(hash)), + Some(hex::encode(prop_hash)), ); } router.send(message); @@ -1289,32 +1348,11 @@ pub(crate) fn choose_lxmf_send_route( } } -/// Whether a failed Direct attempt may be re-queued once via preferred remote PN. -/// Retained for unit coverage of the preferred-remote gate; live path uses PN cascade. -#[cfg(test)] -pub(crate) fn should_fallback_direct_to_pn( - method: DeliveryMethod, - preferred_pn: Option<[u8; 16]>, - self_lxmf_hash_hex: &str, - already_fallback: bool, -) -> bool { - if already_fallback || method != DeliveryMethod::Direct { - return false; - } - let Some(pn) = preferred_pn else { - return false; - }; - let pn_hex = hex::encode(pn); - // Local / self PN is an offline inbox — not a network store for unreachable peers. - if pn_hex.eq_ignore_ascii_case(self_lxmf_hash_hex.trim()) { - return false; - } - true -} - /// Cap on retained destination public keys (announce / path flood bound). const MAX_KNOWN_IDENTITIES: usize = 4096; +/// Convenience wrapper around [`emit_outbound_status_with_via`] (hash/to/sent_via from payload). +#[allow(dead_code)] // kept for callers that already hold a full lxmf_message payload pub fn emit_outbound_status( event_tx: &broadcast::Sender, message_payload: &serde_json::Value, @@ -1989,51 +2027,6 @@ mod tests { ); } - #[test] - fn should_fallback_direct_to_pn_when_remote_preferred() { - let remote = [0x47u8; 16]; - assert!(should_fallback_direct_to_pn( - DeliveryMethod::Direct, - Some(remote), - &"aa".repeat(16), - false, - )); - } - - #[test] - fn should_fallback_direct_to_pn_rejects_local_self_pn() { - let self_hash = [0x09u8; 16]; - assert!(!should_fallback_direct_to_pn( - DeliveryMethod::Direct, - Some(self_hash), - &hex::encode(self_hash), - false, - )); - } - - #[test] - fn should_fallback_direct_to_pn_rejects_propagated_and_repeat() { - let remote = [0x47u8; 16]; - assert!(!should_fallback_direct_to_pn( - DeliveryMethod::Propagated, - Some(remote), - &"aa".repeat(16), - false, - )); - assert!(!should_fallback_direct_to_pn( - DeliveryMethod::Direct, - Some(remote), - &"aa".repeat(16), - true, - )); - assert!(!should_fallback_direct_to_pn( - DeliveryMethod::Direct, - None, - &"aa".repeat(16), - false, - )); - } - #[test] fn should_retry_propagated_link_closed_while_attempts_remain() { assert!(should_retry_propagated_link_failure( @@ -2109,6 +2102,23 @@ mod tests { src.contains("PN_DEPOSIT_DEFER_ADVANCE_AFTER"), "sync/pending PN-link deferral must eventually advance cascade" ); + assert!( + src.contains("pending_pn_targets") && src.contains("apply_pending_pn_targets"), + "per-message PN targets must rewrite DeliverPropagated.prop_hash" + ); + assert!( + src.contains("pack_for_propagation failed — advancing PN cascade"), + "pack failure must advance cascade instead of bare requeue" + ); + assert!( + src.contains("propagated delivery attempt budget reached — advancing PN cascade"), + "max delivery attempts must advance cascade instead of bare requeue" + ); + let legacy_one_shot = concat!("should_fallback_", "direct_to_pn"); + assert!( + !src.contains(legacy_one_shot), + "one-shot Direct→PN helper must be removed; live path uses PN cascade" + ); } #[test] diff --git a/reticulum-sidecar/src/stack/pn_cascade.rs b/reticulum-sidecar/src/stack/pn_cascade.rs index 61535a9c6..275adddf6 100644 --- a/reticulum-sidecar/src/stack/pn_cascade.rs +++ b/reticulum-sidecar/src/stack/pn_cascade.rs @@ -60,21 +60,11 @@ pub fn build_pn_cascade_order( let bh = b.hops.unwrap_or(u8::MAX); ah.cmp(&bh).then_with(|| a.id.cmp(&b.id)) }); + // Only reorder among enabled candidates — never synthesize a disabled/stale preferred. if let Some(pref) = preferred_hash { if let Some(idx) = remotes.iter().position(|c| c.hash == pref) { let preferred = remotes.remove(idx); remotes.insert(0, preferred); - } else { - // Preferred hash not in enabled list — still try it first if we know the hash. - remotes.insert( - 0, - PnCascadeCandidate { - hash: pref, - is_local: false, - hops: None, - id: format!("pn-{}", hex::encode(&pref[..4])), - }, - ); } } let mut out = remotes; @@ -276,4 +266,22 @@ mod tests { ); assert_eq!(PnCascadePick::Exhausted.delivery_method_label(), None); } + + #[test] + fn order_skips_preferred_not_in_enabled_list() { + let candidates = vec![remote(0x11, Some(1), "pn-a"), local(0x99)]; + let stale_preferred = [0xee; 16]; + let ordered = build_pn_cascade_order(&candidates, Some(stale_preferred)); + assert_eq!(ordered[0].hash, [0x11; 16]); + assert!(!ordered.iter().any(|c| c.hash == stale_preferred)); + } + + #[test] + fn is_self_lxmf_hash_case_insensitive() { + let hash = [0xaa; 16]; + let hex = hex::encode(hash); + assert!(is_self_lxmf_hash(&hash, &hex)); + assert!(is_self_lxmf_hash(&hash, &hex.to_uppercase())); + assert!(!is_self_lxmf_hash(&hash, &"bb".repeat(16))); + } } diff --git a/src/main/ipc/reticulumLxmfRecentPath.ts b/src/main/ipc/reticulumLxmfRecentPath.ts index 56fbd083d..6bc487856 100644 --- a/src/main/ipc/reticulumLxmfRecentPath.ts +++ b/src/main/ipc/reticulumLxmfRecentPath.ts @@ -1,5 +1,7 @@ +import { RETICULUM_LXMF_RECENT_API_PATH } from '../../shared/reticulumApiPaths'; + /** Path-only match for LXMF recent catch-up (query string ignored). */ export function isLxmfRecentApiPath(apiPath: string): boolean { const pathOnly = apiPath.split('?', 1)[0] ?? apiPath; - return pathOnly === '/api/v1/lxmf/recent'; + return pathOnly === RETICULUM_LXMF_RECENT_API_PATH; } diff --git a/src/main/reticulum-proxy-path.ts b/src/main/reticulum-proxy-path.ts index a98542f86..c82b9bab1 100644 --- a/src/main/reticulum-proxy-path.ts +++ b/src/main/reticulum-proxy-path.ts @@ -1,3 +1,4 @@ +import { RETICULUM_LXMF_RECENT_API_PATH } from '../shared/reticulumApiPaths'; import { nomadPageProxyTimeoutMsFromApiPath } from '../shared/reticulumNomadTimeouts'; /** Allowed Reticulum sidecar HTTP paths for renderer IPC proxy. */ @@ -16,7 +17,7 @@ const TRANSPORT_QUERY_GET_PATHS = [ '/api/v1/interfaces', '/api/v1/topology', '/api/v1/packets', - '/api/v1/lxmf/recent', + RETICULUM_LXMF_RECENT_API_PATH, ] as const; function isReticulumTransportQueryGetPath(normalized: string): boolean { diff --git a/src/main/support-bundle.test.ts b/src/main/support-bundle.test.ts index 64d16a8fc..bd27ac859 100644 --- a/src/main/support-bundle.test.ts +++ b/src/main/support-bundle.test.ts @@ -118,6 +118,17 @@ describe('extractLxmfOutboundLogSlice', () => { expect(slice).not.toContain('hello world'); expect(slice).not.toContain('peer refresh ok'); }); + + it('truncates long hex ids in kept lines', () => { + const dest = 'ab'.repeat(16); + const chunk = Buffer.from( + `info target=lxmf-outbound dest=${dest} LXMF advancing PN cascade\n`, + 'utf8', + ); + const slice = extractLxmfOutboundLogSlice(chunk).toString('utf8'); + expect(slice).toContain('dest=abababab…'); + expect(slice).not.toContain(dest); + }); }); describe('redactMnemonicFromStackJson', () => { diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index 2aede9453..af0e930d6 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -178,6 +178,11 @@ Contents: `; } +/** Truncate long hex ids in exported log lines (keep triage prefix only). */ +function redactLxmfOutboundLogLine(line: string): string { + return line.replace(/\b([0-9a-fA-F]{16,})\b/g, (hex) => `${hex.slice(0, 8)}…`); +} + /** Extract LXMF outbound / PN cascade diagnostic lines for developer triage. */ export function extractLxmfOutboundLogSlice(...logChunks: Buffer[]): Buffer { const patterns = [ @@ -195,7 +200,7 @@ export function extractLxmfOutboundLogSlice(...logChunks: Buffer[]): Buffer { const text = chunk.toString('utf8'); for (const line of text.split(/\r?\n/)) { if (patterns.some((re) => re.test(line))) { - lines.push(line); + lines.push(redactLxmfOutboundLogLine(line)); } } } diff --git a/src/renderer/components/ReticulumMessageStatusBadge.test.tsx b/src/renderer/components/ReticulumMessageStatusBadge.test.tsx index cec14e5ba..7d08de34e 100644 --- a/src/renderer/components/ReticulumMessageStatusBadge.test.tsx +++ b/src/renderer/components/ReticulumMessageStatusBadge.test.tsx @@ -82,4 +82,27 @@ describe('ReticulumMessageStatusBadge', () => { expect(screen.getByText(/reticulumPnAbbrev\s+\u{1F3E0}/u)).toBeTruthy(); expect(screen.queryByText(/✓/)).toBeNull(); }); + + it('shows storing-locally tooltip while sending with stored_locally', async () => { + await renderAndAssertAxe( + , + ); + expect( + screen.getByLabelText( + 'chatPanel.sentViaLocalPropagation: chatPanel.reticulumSendStoringLocally', + ), + ).toBeTruthy(); + expect(screen.getByText(/reticulumPnAbbrev\s+\u{1F3E0}/u)).toBeTruthy(); + }); + + it('shows red X (not house) for failed stored_locally', async () => { + await renderAndAssertAxe( + , + ); + expect( + screen.getByLabelText('chatPanel.sentViaLocalPropagation: chatPanel.reticulumSendFailed'), + ).toBeTruthy(); + expect(screen.getByText(/reticulumPnAbbrev\s+\u2717/)).toBeTruthy(); + expect(screen.queryByText(/\u{1F3E0}/u)).toBeNull(); + }); }); diff --git a/src/renderer/components/ReticulumMessageStatusBadge.tsx b/src/renderer/components/ReticulumMessageStatusBadge.tsx index 4cdb73778..f3ff6c8c2 100644 --- a/src/renderer/components/ReticulumMessageStatusBadge.tsx +++ b/src/renderer/components/ReticulumMessageStatusBadge.tsx @@ -8,6 +8,7 @@ import { type ReticulumVia, } from '@/renderer/lib/reticulum/classifyReticulumVia'; import type { MessageRecord, MessageTransport } from '@/renderer/stores/messageStore'; +import { isPnCascadeDeliveryMethod } from '@/shared/reticulumDeliveryMethod'; export interface ReticulumMessageStatusBadgeProps { status: 'sending' | 'acked' | 'failed'; @@ -132,12 +133,11 @@ export function ReticulumMessageStatusBadge({ const { t } = useTranslation(); const atoms = parseReticulumViaAtoms(via); const viasLabel = formatReticulumViaBadgeLabel(via ?? 'network'); - const label = - deliveryMethod === 'propagated' || deliveryMethod === 'stored_locally' - ? t('chatPanel.reticulumPnAbbrev') - : deliveryMethod === 'paper' - ? t('chatPanel.reticulumSendPaper') - : viasLabel; + const label = isPnCascadeDeliveryMethod(deliveryMethod) + ? t('chatPanel.reticulumPnAbbrev') + : deliveryMethod === 'paper' + ? t('chatPanel.reticulumSendPaper') + : viasLabel; const statusLabel = statusLabelText(t, status, deliveryMethod, error); const viaPrefix = viaPrefixText(t, deliveryMethod, atoms, viasLabel); // Completed paper: paper-only prefix. Failed/sending paper keep status suffix (incl. error text). diff --git a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts index af5b64498..8ffdb08c4 100644 --- a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts +++ b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts @@ -27,8 +27,9 @@ describe('applyReticulumOutboundDeliveryStatus', () => { window.electronAPI = createElectronAPIMock(); }); - it('maps delivered/failed/sending; drops unknown wire statuses', () => { + it('maps delivered/failed/sending/stored_locally; drops unknown wire statuses', () => { expect(mapLxmfOutboundWireStatus('delivered')).toBe('acked'); + expect(mapLxmfOutboundWireStatus('stored_locally')).toBe('acked'); expect(mapLxmfOutboundWireStatus('failed')).toBe('failed'); expect(mapLxmfOutboundWireStatus('sending')).toBe('sending'); expect(mapLxmfOutboundWireStatus('queued')).toBeNull(); @@ -368,6 +369,74 @@ describe('applyReticulumOutboundDeliveryStatus', () => { ); }); + it('revives Failed to sending for stored_locally cascade after link-timeout bridge', () => { + const toNodeId = reticulumHashToNodeId(DEST); + const selfNodeId = reticulumHashToNodeId(SELF); + registerReticulumDestinationHash(toNodeId, DEST); + registerReticulumDestinationHash(selfNodeId, SELF); + useMessageStore.setState({ + messages: { + [identityId]: { + [messageHash]: { + id: messageHash, + from: selfNodeId, + to: toNodeId, + senderName: 'Me', + payload: 'race', + channelIndex: 0, + timestamp: Date.now(), + status: 'failed', + error: 'Failed to send', + reticulumMessageHash: messageHash, + reticulumSenderHash: SELF, + reticulumDeliveryMethod: 'direct', + }, + }, + }, + }); + + applyReticulumOutboundDeliveryStatus(identityId, messageHash, 'sending', { + deliveryMethod: 'stored_locally', + }); + + const row = useMessageStore.getState().messages[identityId][messageHash]; + expect(row.status).toBe('sending'); + expect(row.reticulumDeliveryMethod).toBe('stored_locally'); + expect(row.error).toBeUndefined(); + }); + + it('clamps delivery_attempts when patching outbound status', () => { + const toNodeId = reticulumHashToNodeId(DEST); + const selfNodeId = reticulumHashToNodeId(SELF); + registerReticulumDestinationHash(toNodeId, DEST); + registerReticulumDestinationHash(selfNodeId, SELF); + useMessageStore.setState({ + messages: { + [identityId]: { + [messageHash]: { + id: messageHash, + from: selfNodeId, + to: toNodeId, + payload: 'x', + channelIndex: 0, + timestamp: Date.now(), + status: 'sending', + reticulumMessageHash: messageHash, + reticulumSenderHash: SELF, + }, + }, + }, + }); + + applyReticulumOutboundDeliveryStatus(identityId, messageHash, 'sending', { + deliveryMethod: 'direct', + deliveryAttempts: 999, + }); + expect( + useMessageStore.getState().messages[identityId][messageHash].reticulumDeliveryAttempts, + ).toBe(64); + }); + it('drops invalid message_hash and unknown wire status', () => { useMessageStore.setState({ messages: { diff --git a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts index cbebf5b56..5998ac854 100644 --- a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts +++ b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts @@ -21,7 +21,13 @@ import { useMessageStore, } from '@/renderer/stores/messageStore'; import { reticulumHashForNodeId } from '@/renderer/stores/reticulumPeerStore'; -import { parseReticulumDeliveryMethod } from '@/shared/reticulumDeliveryMethod'; +import { + isPnCascadeDeliveryMethod, + parseReticulumDeliveryMethod, +} from '@/shared/reticulumDeliveryMethod'; + +/** Cap for sidecar `delivery_attempts` before store/SQLite patch. */ +export const MAX_RETICULUM_DELIVERY_ATTEMPTS = 64; /** Map sidecar `lxmf_outbound_status` wire status to UI store status. Unknown → null. */ export function mapLxmfOutboundWireStatus(wireStatus: string): MessageStatus | null { @@ -31,6 +37,10 @@ export function mapLxmfOutboundWireStatus(wireStatus: string): MessageStatus | n return null; } +function clampDeliveryAttempts(value: number): number { + return Math.min(MAX_RETICULUM_DELIVERY_ATTEMPTS, Math.max(0, Math.trunc(value))); +} + /** Resolve LXMF peer dest hash from a chat node id (peer store, then dest registry). */ export function resolveReticulumOutboundDestHash( toNodeId: number | undefined | null, @@ -155,11 +165,11 @@ export function persistReticulumOutboundMessageStatus( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime guard protects external or callback-mutated state. if (!before) return false; // Link-timeout failure bridge can mark Failed before WS Direct→PN fallback arrives. - // Authoritative sending+propagated must revive so the badge is not stuck as PN ✗. + // Authoritative sending+propagated/stored_locally must revive so the badge is not stuck as PN ✗. if ( before.status === 'failed' && status === 'sending' && - (deliveryMethod === 'propagated' || deliveryMethod === 'stored_locally') + isPnCascadeDeliveryMethod(deliveryMethod) ) { const revived: MessageRecord = { ...before, @@ -225,9 +235,9 @@ export function persistReticulumOutboundMessageStatus( if ( deliveryAttempts != null && Number.isFinite(deliveryAttempts) && - deliveryAttempts !== record.reticulumDeliveryAttempts + clampDeliveryAttempts(deliveryAttempts) !== record.reticulumDeliveryAttempts ) { - record = { ...record, reticulumDeliveryAttempts: Math.trunc(deliveryAttempts) }; + record = { ...record, reticulumDeliveryAttempts: clampDeliveryAttempts(deliveryAttempts) }; patched = true; } if (patched) { @@ -293,7 +303,7 @@ export function applyReticulumOutboundDeliveryStatus( const deliveryMethod = parseReticulumDeliveryMethod(opts?.deliveryMethod); const deliveryAttempts = opts?.deliveryAttempts != null && Number.isFinite(opts.deliveryAttempts) - ? Math.trunc(opts.deliveryAttempts) + ? clampDeliveryAttempts(opts.deliveryAttempts) : undefined; const applied = persistReticulumOutboundMessageStatus( identityId, diff --git a/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts b/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts index 69dd8f81a..298558485 100644 --- a/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts @@ -8,7 +8,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ingestReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest'; import { OFFLINE_RETICULUM_IDENTITY_ID } from '@/renderer/lib/offlineProtocolIdentities'; -import { catchUpRecentInboundLxmf } from '@/renderer/lib/reticulum/catchUpRecentInboundLxmf'; +import { + catchUpRecentInboundLxmf, + resetCatchUpRecentInboundLxmfSingleFlightForTests, +} from '@/renderer/lib/reticulum/catchUpRecentInboundLxmf'; import { fetchRecentInboundLxmfDetailed } from '@/renderer/lib/reticulum/fetchRecentInboundLxmf'; import { getReticulumInboundLxmfDiagnostics, @@ -59,6 +62,7 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { useMessageStore.setState({ messages: {} }); resetReticulumManualStackStopSuppressForTests(); resetReticulumInboundLxmfDiagnosticsForTests(); + resetCatchUpRecentInboundLxmfSingleFlightForTests(); eventHandler = null; warnSpy.mockClear(); vi.mocked(fetchRecentInboundLxmfDetailed).mockReset(); diff --git a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts index 5342693d1..a98961b29 100644 --- a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts @@ -4,7 +4,10 @@ import type { ReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest import { fetchRecentInboundLxmfDetailed } from '@/renderer/lib/reticulum/fetchRecentInboundLxmf'; import { type MessageRecord, useMessageStore } from '@/renderer/stores/messageStore'; -import { catchUpRecentInboundLxmf } from './catchUpRecentInboundLxmf'; +import { + catchUpRecentInboundLxmf, + resetCatchUpRecentInboundLxmfSingleFlightForTests, +} from './catchUpRecentInboundLxmf'; vi.mock('@/renderer/lib/reticulum/fetchRecentInboundLxmf', () => ({ fetchRecentInboundLxmfDetailed: vi.fn(), @@ -51,6 +54,7 @@ describe('catchUpRecentInboundLxmf', () => { debugSpy.mockClear(); useMessageStore.setState({ messages: {} }); vi.mocked(fetchRecentInboundLxmfDetailed).mockReset(); + resetCatchUpRecentInboundLxmfSingleFlightForTests(); }); it('returns null when identityId is empty', async () => { @@ -147,4 +151,59 @@ describe('catchUpRecentInboundLxmf', () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('count=2 reason=periodic')); expect(debugSpy).not.toHaveBeenCalledWith(expect.stringContaining('catch-up count=')); }); + + it('returns null and warns distinctly when rateLimited', async () => { + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ + messages: [], + ringLen: null, + rateLimited: true, + }); + await expect( + catchUpRecentInboundLxmf({ identityId: 'id-1', ingest: vi.fn(), reason: 'ws_reconnect' }), + ).resolves.toBeNull(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('rateLimited')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('not empty inbox')); + }); + + it('coalesces concurrent callers into one fetch plus trailing rerun', async () => { + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const ingestA = vi.fn(); + const ingestB = vi.fn(); + vi.mocked(fetchRecentInboundLxmfDetailed) + .mockImplementationOnce(async () => { + await firstGate; + return { messages: [sample('aa'.repeat(32), 1_000, 1)], ringLen: 1 }; + }) + .mockResolvedValueOnce({ messages: [sample('bb'.repeat(32), 2_000, 2)], ringLen: 2 }); + + const p1 = catchUpRecentInboundLxmf({ + identityId: 'id-1', + ingest: ingestA, + sinceTs: 100, + reason: 'connect', + }); + const p2 = catchUpRecentInboundLxmf({ + identityId: 'id-1', + ingest: ingestB, + sinceTs: 500, + sinceSeq: 3, + reason: 'ws_reconnect', + }); + + expect(fetchRecentInboundLxmfDetailed).toHaveBeenCalledTimes(1); + releaseFirst(); + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1).toEqual(r2); + expect(fetchRecentInboundLxmfDetailed).toHaveBeenCalledTimes(2); + expect(fetchRecentInboundLxmfDetailed).toHaveBeenLastCalledWith({ + limit: 200, + sinceTs: 500, + sinceSeq: 3, + }); + expect(ingestB).toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('connect+ws_reconnect')); + }); }); diff --git a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts index 3eaf10d44..17bafa11c 100644 --- a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts +++ b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts @@ -19,6 +19,68 @@ export interface CatchUpRecentInboundLxmfOutcome { watermarkSeq: number | null; } +/** Single-flight + trailing coalesce for concurrent catch-up callers. */ +let catchUpInFlight: Promise | null = null; +let catchUpInFlightOpts: CatchUpRecentInboundLxmfOpts | null = null; +let catchUpPending: CatchUpRecentInboundLxmfOpts | null = null; + +/** Prefer latest cursor; merge reason labels; last ingest/identity wins. */ +function mergeCatchUpOpts( + base: CatchUpRecentInboundLxmfOpts, + next: CatchUpRecentInboundLxmfOpts, +): CatchUpRecentInboundLxmfOpts { + const reasons = [base.reason, next.reason].filter( + (r): r is string => typeof r === 'string' && r.length > 0, + ); + const uniqueReasons = [...new Set(reasons)]; + const sinceTs = + next.sinceTs != null && Number.isFinite(next.sinceTs) + ? next.sinceTs + : base.sinceTs != null && Number.isFinite(base.sinceTs) + ? base.sinceTs + : undefined; + const sinceSeq = + next.sinceSeq != null && Number.isFinite(next.sinceSeq) + ? next.sinceSeq + : base.sinceSeq != null && Number.isFinite(base.sinceSeq) + ? base.sinceSeq + : undefined; + // When both cursors present, prefer the later (ts, seq) pair from `next` if it advances. + let chosenSinceTs = sinceTs; + let chosenSinceSeq = sinceSeq; + if ( + base.sinceTs != null && + Number.isFinite(base.sinceTs) && + next.sinceTs != null && + Number.isFinite(next.sinceTs) + ) { + if (next.sinceTs > base.sinceTs) { + chosenSinceTs = next.sinceTs; + chosenSinceSeq = next.sinceSeq; + } else if (next.sinceTs < base.sinceTs) { + chosenSinceTs = base.sinceTs; + chosenSinceSeq = base.sinceSeq; + } else { + const baseSeq = base.sinceSeq ?? -1; + const nextSeq = next.sinceSeq ?? -1; + if (nextSeq >= baseSeq) { + chosenSinceTs = next.sinceTs; + chosenSinceSeq = next.sinceSeq; + } else { + chosenSinceTs = base.sinceTs; + chosenSinceSeq = base.sinceSeq; + } + } + } + return { + identityId: next.identityId || base.identityId, + ingest: next.ingest, + ...(chosenSinceTs != null ? { sinceTs: chosenSinceTs } : {}), + ...(chosenSinceSeq != null ? { sinceSeq: chosenSinceSeq } : {}), + ...(uniqueReasons.length > 0 ? { reason: uniqueReasons.join('+') } : {}), + }; +} + function rowAlreadyInMessageStore(identityId: string, p: ReticulumLxmfPayload): boolean { const hash = typeof p.message_hash === 'string' ? p.message_hash.trim() : ''; if (!hash) return false; @@ -46,23 +108,22 @@ function isCursorAfter( return maxSeq == null || seq > maxSeq; } -/** - * Fetch recent inbound LXMF, ingest unknown rows, and compute the catch-up watermark. - * Caller applies diagnostics (`noteReticulumInboundCatchUp` / watermark advance). - * - * Sidecar cursor is exclusive `(since_ts, since_seq)`; returned watermarks are the max - * `(timestamp, ring_seq)` among fetched rows and are safe for the next periodic fetch. - */ -export async function catchUpRecentInboundLxmf( +async function catchUpRecentInboundLxmfOnce( opts: CatchUpRecentInboundLxmfOpts, ): Promise { if (!opts.identityId) return null; - const { messages: rows } = await fetchRecentInboundLxmfDetailed({ + const { messages: rows, rateLimited } = await fetchRecentInboundLxmfDetailed({ limit: 200, ...(opts.sinceTs != null ? { sinceTs: opts.sinceTs } : {}), ...(opts.sinceSeq != null ? { sinceSeq: opts.sinceSeq } : {}), }); + if (rateLimited) { + console.warn( + `[catchUpRecentInboundLxmf] rateLimited reason=${opts.reason ?? 'catch-up'} — skipped (not empty inbox)`, + ); + return null; + } if (rows.length === 0) return null; const knownFlags = rows.map((p) => rowAlreadyInMessageStore(opts.identityId, p)); @@ -96,3 +157,52 @@ export async function catchUpRecentInboundLxmf( watermarkSeq: maxTs > 0 ? maxSeq : null, }; } + +/** + * Fetch recent inbound LXMF, ingest unknown rows, and compute the catch-up watermark. + * Caller applies diagnostics (`noteReticulumInboundCatchUp` / watermark advance). + * + * Sidecar cursor is exclusive `(since_ts, since_seq)`; returned watermarks are the max + * `(timestamp, ring_seq)` among fetched rows and are safe for the next periodic fetch. + * + * Concurrent callers share one in-flight promise; later opts coalesce (latest cursor, merged reasons) + * into a trailing rerun when needed. + */ +export async function catchUpRecentInboundLxmf( + opts: CatchUpRecentInboundLxmfOpts, +): Promise { + if (!opts.identityId) return null; + + if (catchUpInFlight) { + const base = catchUpPending ?? catchUpInFlightOpts ?? opts; + catchUpPending = mergeCatchUpOpts(base, opts); + return catchUpInFlight; + } + + catchUpInFlightOpts = opts; + catchUpInFlight = (async () => { + try { + let current = opts; + let result = await catchUpRecentInboundLxmfOnce(current); + while (catchUpPending) { + current = catchUpPending; + catchUpPending = null; + catchUpInFlightOpts = current; + result = await catchUpRecentInboundLxmfOnce(current); + } + return result; + } finally { + catchUpInFlight = null; + catchUpInFlightOpts = null; + } + })(); + + return catchUpInFlight; +} + +/** Test-only reset of single-flight coalesce state. */ +export function resetCatchUpRecentInboundLxmfSingleFlightForTests(): void { + catchUpInFlight = null; + catchUpInFlightOpts = null; + catchUpPending = null; +} diff --git a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts index 2042c9f13..8fe30a79b 100644 --- a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts @@ -15,6 +15,11 @@ import { getReticulumInboundLxmfDiagnostics, resetReticulumInboundLxmfDiagnosticsForTests, } from './reticulumInboundLxmfDiagnostics'; +import { + isReticulumProxyRateLimitBackoffActive, + noteReticulumProxyRateLimitHit, + resetReticulumProxyRateLimitBackoffForTests, +} from './reticulumProxyRateLimitBackoff'; describe('fetchRecentInboundLxmf', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -23,6 +28,7 @@ describe('fetchRecentInboundLxmf', () => { proxyGet.mockReset(); warnSpy.mockClear(); resetReticulumInboundLxmfDiagnosticsForTests(); + resetReticulumProxyRateLimitBackoffForTests(); }); it('returns inbound rows from sidecar recent API', async () => { @@ -59,4 +65,46 @@ describe('fetchRecentInboundLxmf', () => { const detailed = await fetchRecentInboundLxmfDetailed(); expect(detailed).toEqual({ messages: [], ringLen: null, rateLimited: false }); }); + + it('skips proxyGet when lxmfRecent backoff is active', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + noteReticulumProxyRateLimitHit('lxmfRecent'); + const detailed = await fetchRecentInboundLxmfDetailed(); + expect(proxyGet).not.toHaveBeenCalled(); + expect(detailed).toEqual({ messages: [], ringLen: null, rateLimited: true }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('skipped')); + }); + + it('does not skip when only shared backoff is active', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + noteReticulumProxyRateLimitHit('shared'); + proxyGet.mockResolvedValue({ messages: [], ring_len: 0 }); + await fetchRecentInboundLxmfDetailed(); + expect(proxyGet).toHaveBeenCalled(); + }); + + it('arms lxmfRecent backoff on rate-limit error', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.5); + proxyGet.mockRejectedValue(new Error('reticulum:proxy: rate limit exceeded')); + const detailed = await fetchRecentInboundLxmfDetailed(); + expect(detailed.rateLimited).toBe(true); + // Second call should skip without hitting proxy again. + proxyGet.mockClear(); + const skipped = await fetchRecentInboundLxmfDetailed(); + expect(proxyGet).not.toHaveBeenCalled(); + expect(skipped.rateLimited).toBe(true); + }); + + it('clears lxmfRecent backoff after success', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const now = 1_000_000; + noteReticulumProxyRateLimitHit('lxmfRecent', now); + expect(isReticulumProxyRateLimitBackoffActive('lxmfRecent', now)).toBe(true); + proxyGet.mockResolvedValue({ messages: [], ring_len: 0 }); + vi.spyOn(Date, 'now').mockReturnValue(now + 120_000); + await fetchRecentInboundLxmfDetailed(); + expect(proxyGet).toHaveBeenCalled(); + expect(isReticulumProxyRateLimitBackoffActive('lxmfRecent', now)).toBe(false); + }); }); diff --git a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts index 51fbad468..e70a28111 100644 --- a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts +++ b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts @@ -7,6 +7,7 @@ import { noteReticulumProxyErrorIfRateLimited, reticulumProxyRateLimitBackoffRemainingMs, } from '@/renderer/lib/reticulum/reticulumProxyRateLimitBackoff'; +import { RETICULUM_LXMF_RECENT_API_PATH } from '@/shared/reticulumApiPaths'; export interface FetchRecentInboundLxmfOpts { /** @@ -42,8 +43,8 @@ export async function fetchRecentInboundLxmf( export async function fetchRecentInboundLxmfDetailed( opts: FetchRecentInboundLxmfOpts = {}, ): Promise { - if (isReticulumProxyRateLimitBackoffActive()) { - const remaining = reticulumProxyRateLimitBackoffRemainingMs(); + if (isReticulumProxyRateLimitBackoffActive('lxmfRecent')) { + const remaining = reticulumProxyRateLimitBackoffRemainingMs('lxmfRecent'); console.warn( `[fetchRecentInboundLxmf] skipped — proxy rate-limit backoff remaining=${remaining}ms`, ); @@ -65,13 +66,13 @@ export async function fetchRecentInboundLxmfDetailed( params.set('limit', String(Math.max(1, Math.min(500, Math.floor(opts.limit))))); } const qs = params.toString(); - const path = qs ? `/api/v1/lxmf/recent?${qs}` : '/api/v1/lxmf/recent'; + const path = qs ? `${RETICULUM_LXMF_RECENT_API_PATH}?${qs}` : RETICULUM_LXMF_RECENT_API_PATH; try { const body = (await window.electronAPI.reticulum.proxyGet(path)) as { messages?: unknown; ring_len?: unknown; }; - clearReticulumProxyRateLimitBackoff(); + clearReticulumProxyRateLimitBackoff('lxmfRecent'); const ringLen = typeof body.ring_len === 'number' && Number.isFinite(body.ring_len) ? Math.trunc(body.ring_len) @@ -85,7 +86,7 @@ export async function fetchRecentInboundLxmfDetailed( ringLen, }; } catch (e) { - const rateLimited = noteReticulumProxyErrorIfRateLimited(e); + const rateLimited = noteReticulumProxyErrorIfRateLimited(e, 'lxmfRecent'); console.warn('[fetchRecentInboundLxmf] ' + errLikeToLogString(e)); return { messages: [], ringLen: null, rateLimited }; } diff --git a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts index c7930e336..4fc80f0aa 100644 --- a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts +++ b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.test.ts @@ -119,6 +119,33 @@ describe('failReticulumSendingOutboundToDestHash', () => { expect(useMessageStore.getState().messages[identityId]['msg-hash'].status).toBe('sending'); }); + it('skips outbound rows already on stored_locally (local-prop cascade)', () => { + const toNodeId = reticulumHashToNodeId(DEST); + registerReticulumDestinationHash(toNodeId, DEST); + useMessageStore.setState({ + messages: { + [identityId]: { + 'msg-hash': { + id: 'msg-hash', + from: 1, + senderName: 'self', + payload: 'hello', + channelIndex: 0, + timestamp: Date.now(), + status: 'sending', + to: toNodeId, + reticulumSenderHash: SELF, + reticulumDeliveryMethod: 'stored_locally', + }, + }, + }, + }); + + const count = failReticulumSendingOutboundToDestHash(identityId, DEST, 'link timeout'); + expect(count).toBe(0); + expect(useMessageStore.getState().messages[identityId]['msg-hash'].status).toBe('sending'); + }); + it('requires full 32-hex equality (prefix must not fail unrelated peers)', () => { const peerA = DEST; const peerB = `${DEST.slice(0, 8)}${'ff'.repeat(12)}`; diff --git a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts index 08460385b..04f02d5bf 100644 --- a/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts +++ b/src/renderer/lib/reticulum/reticulumOutboundFailureBridge.ts @@ -10,6 +10,7 @@ import { import type { IdentityId } from '@/renderer/lib/types'; import { useMessageStore } from '@/renderer/stores/messageStore'; import type { PropagationNodeRow } from '@/renderer/stores/reticulumPropagationStore'; +import { isPnCascadeDeliveryMethod } from '@/shared/reticulumDeliveryMethod'; function normalizeDestHash(hash: string): string { return hash.replace(/[^0-9a-f]/gi, '').toLowerCase(); @@ -49,10 +50,7 @@ export function failReticulumSendingOutboundToDestHash( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime guard protects external or callback-mutated state. if (msg.status !== 'sending' || msg.to == null) continue; // Cascade re-queues as Propagated / stored_locally and emits sending — do not fail those. - if ( - msg.reticulumDeliveryMethod === 'propagated' || - msg.reticulumDeliveryMethod === 'stored_locally' - ) { + if (isPnCascadeDeliveryMethod(msg.reticulumDeliveryMethod)) { continue; } const destHash = resolveReticulumOutboundDestHash(msg.to); diff --git a/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts index 68c99ab95..b3495b3e9 100644 --- a/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts +++ b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts @@ -17,29 +17,54 @@ describe('reticulumProxyRateLimitBackoff', () => { it('arms backoff on rate-limit hit and clears on success', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(Math, 'random').mockReturnValue(0.5); // jitter factor 1.0 const now = 1_000_000; - const delay = noteReticulumProxyRateLimitHit(now); + const delay = noteReticulumProxyRateLimitHit('shared', now); expect(delay).toBeGreaterThan(0); - expect(isReticulumProxyRateLimitBackoffActive(now)).toBe(true); - expect(reticulumProxyRateLimitBackoffRemainingMs(now)).toBe(delay); - expect(isReticulumProxyRateLimitBackoffActive(now + delay + 1)).toBe(false); - clearReticulumProxyRateLimitBackoff(); - expect(isReticulumProxyRateLimitBackoffActive(now)).toBe(false); + expect(isReticulumProxyRateLimitBackoffActive('shared', now)).toBe(true); + expect(reticulumProxyRateLimitBackoffRemainingMs('shared', now)).toBe(delay); + expect(isReticulumProxyRateLimitBackoffActive('shared', now + delay + 1)).toBe(false); + clearReticulumProxyRateLimitBackoff('shared'); + expect(isReticulumProxyRateLimitBackoffActive('shared', now)).toBe(false); + }); + + it('keeps shared and lxmfRecent buckets independent', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const now = 1_000_000; + noteReticulumProxyRateLimitHit('lxmfRecent', now); + expect(isReticulumProxyRateLimitBackoffActive('lxmfRecent', now)).toBe(true); + expect(isReticulumProxyRateLimitBackoffActive('shared', now)).toBe(false); + expect(isReticulumProxyRateLimitBackoffActive(undefined, now)).toBe(true); + clearReticulumProxyRateLimitBackoff('lxmfRecent'); + expect(isReticulumProxyRateLimitBackoffActive(undefined, now)).toBe(false); }); - it('notes rate-limit errors and ignores other errors', () => { + it('notes rate-limit errors on the shared bucket by default', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(Math, 'random').mockReturnValue(0.5); expect(noteReticulumProxyErrorIfRateLimited(new Error('boom'))).toBe(false); expect( noteReticulumProxyErrorIfRateLimited(new Error('reticulum:proxy: rate limit exceeded')), ).toBe(true); - expect(isReticulumProxyRateLimitBackoffActive()).toBe(true); + expect(isReticulumProxyRateLimitBackoffActive('shared')).toBe(true); + expect(isReticulumProxyRateLimitBackoffActive('lxmfRecent')).toBe(false); }); it('does not tight-loop — consecutive hits increase backoff', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); - const first = noteReticulumProxyRateLimitHit(0); - const second = noteReticulumProxyRateLimitHit(0); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const first = noteReticulumProxyRateLimitHit('shared', 0); + const second = noteReticulumProxyRateLimitHit('shared', 0); expect(second).toBeGreaterThanOrEqual(first); }); + + it('clear without bucket resets both', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + noteReticulumProxyRateLimitHit('shared', 0); + noteReticulumProxyRateLimitHit('lxmfRecent', 0); + clearReticulumProxyRateLimitBackoff(); + expect(isReticulumProxyRateLimitBackoffActive()).toBe(false); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts index 3a0f86cb8..b1a073d82 100644 --- a/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts +++ b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts @@ -4,48 +4,105 @@ import { MS_PER_SECOND } from '@/shared/timeConstants'; const DEFAULT_BACKOFF_MS = 5 * MS_PER_SECOND; const MAX_BACKOFF_MS = 60 * MS_PER_SECOND; -let backoffUntilMs = 0; -let consecutiveHits = 0; +/** Independent proxy IPC rate-limit backoff buckets. */ +export type ReticulumProxyRateLimitBucket = 'shared' | 'lxmfRecent'; -/** True while shared/dedicated proxy rate-limit backoff is active. */ -export function isReticulumProxyRateLimitBackoffActive(now = Date.now()): boolean { - return now < backoffUntilMs; +interface BucketState { + backoffUntilMs: number; + consecutiveHits: number; } -/** Remaining backoff ms (0 when clear). */ -export function reticulumProxyRateLimitBackoffRemainingMs(now = Date.now()): number { - return Math.max(0, backoffUntilMs - now); +const buckets: Record = { + shared: { backoffUntilMs: 0, consecutiveHits: 0 }, + lxmfRecent: { backoffUntilMs: 0, consecutiveHits: 0 }, +}; + +function isBucketActive(bucket: ReticulumProxyRateLimitBucket, now: number): boolean { + return now < buckets[bucket].backoffUntilMs; +} + +function remainingForBucket(bucket: ReticulumProxyRateLimitBucket, now: number): number { + return Math.max(0, buckets[bucket].backoffUntilMs - now); +} + +/** + * True while proxy rate-limit backoff is active. + * When `bucket` is omitted, true if either bucket is active (peer-store / legacy callers). + */ +export function isReticulumProxyRateLimitBackoffActive( + bucket?: ReticulumProxyRateLimitBucket, + now = Date.now(), +): boolean { + if (bucket == null) { + return isBucketActive('shared', now) || isBucketActive('lxmfRecent', now); + } + return isBucketActive(bucket, now); +} + +/** + * Remaining backoff ms (0 when clear). + * When `bucket` is omitted, returns the max remaining across both buckets. + */ +export function reticulumProxyRateLimitBackoffRemainingMs( + bucket?: ReticulumProxyRateLimitBucket, + now = Date.now(), +): number { + if (bucket == null) { + return Math.max(remainingForBucket('shared', now), remainingForBucket('lxmfRecent', now)); + } + return remainingForBucket(bucket, now); +} + +/** Optional ±10% jitter so concurrent clients do not retry in lockstep. */ +function applyJitter(delayMs: number): number { + const factor = 0.9 + Math.random() * 0.2; + return Math.max(1, Math.round(delayMs * factor)); } /** * Record a rate-limit error and arm exponential backoff so callers do not tight-loop. - * Returns the backoff duration applied (ms). + * Returns the backoff duration applied (ms, after jitter). */ -export function noteReticulumProxyRateLimitHit(now = Date.now()): number { - consecutiveHits = Math.min(consecutiveHits + 1, 6); - const delay = Math.min(DEFAULT_BACKOFF_MS * 2 ** (consecutiveHits - 1), MAX_BACKOFF_MS); - backoffUntilMs = Math.max(backoffUntilMs, now + delay); +export function noteReticulumProxyRateLimitHit( + bucket: ReticulumProxyRateLimitBucket, + now = Date.now(), +): number { + const state = buckets[bucket]; + state.consecutiveHits = Math.min(state.consecutiveHits + 1, 6); + const base = Math.min(DEFAULT_BACKOFF_MS * 2 ** (state.consecutiveHits - 1), MAX_BACKOFF_MS); + const delay = applyJitter(base); + state.backoffUntilMs = Math.max(state.backoffUntilMs, now + delay); console.warn( - `[reticulumProxyRateLimit] backoff ${delay}ms hits=${consecutiveHits} until=${new Date(backoffUntilMs).toISOString()}`, + `[reticulumProxyRateLimit] bucket=${bucket} backoff ${delay}ms hits=${state.consecutiveHits} until=${new Date(state.backoffUntilMs).toISOString()}`, ); return delay; } -/** Clear backoff after a successful proxy call. */ -export function clearReticulumProxyRateLimitBackoff(): void { - consecutiveHits = 0; - backoffUntilMs = 0; +/** Clear backoff after a successful proxy call (one bucket, or both when omitted). */ +export function clearReticulumProxyRateLimitBackoff(bucket?: ReticulumProxyRateLimitBucket): void { + const clearOne = (b: ReticulumProxyRateLimitBucket): void => { + buckets[b].consecutiveHits = 0; + buckets[b].backoffUntilMs = 0; + }; + if (bucket == null) { + clearOne('shared'); + clearOne('lxmfRecent'); + return; + } + clearOne(bucket); } -/** If `err` is a rate-limit error, arm backoff and return true. */ -export function noteReticulumProxyErrorIfRateLimited(err: unknown): boolean { +/** If `err` is a rate-limit error, arm backoff for `bucket` (default shared) and return true. */ +export function noteReticulumProxyErrorIfRateLimited( + err: unknown, + bucket: ReticulumProxyRateLimitBucket = 'shared', +): boolean { if (!isReticulumSidecarRateLimitError(err)) return false; - noteReticulumProxyRateLimitHit(); + noteReticulumProxyRateLimitHit(bucket); return true; } /** Test-only reset. */ export function resetReticulumProxyRateLimitBackoffForTests(): void { - consecutiveHits = 0; - backoffUntilMs = 0; + clearReticulumProxyRateLimitBackoff(); } diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 29fd56a28..011da0de3 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -678,9 +678,9 @@ "shareAsPaperGenerate": "Vytvořit papírové QR", "shareAsPaperCopyFailed": "Nelze zkopírovat papírový odkaz", "waitingMessagesSilentFetched": "Načteno {{processed}} z rádia…", - "reticulumSendStoringLocally": "Ukládání do doručené pošty místní propagace...", - "reticulumSendStoredLocally": "Uchovává se ve vaší místní propagační doručené poště (nedoručuje se kolegovi)", - "sentViaLocalPropagation": "Doručená pošta pro místní propagaci" + "reticulumSendStoringLocally": "Ukládání do místní doručené pošty šíření…", + "reticulumSendStoredLocally": "Uloženo ve vaší místní doručené poště šíření (nedoručeno peerovi)", + "sentViaLocalPropagation": "Místní doručená pošta šíření" }, "chatPayload": { "mention": "Zmínit {{label}}", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 03198cedb..500180cbe 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -676,9 +676,9 @@ "shareAsPaperGenerate": "Papier-QR erstellen", "shareAsPaperCopyFailed": "Papierlink konnte nicht kopiert werden", "waitingMessagesSilentFetched": "{{processed}} vom Funkgerät abgerufen…", - "reticulumSendStoringLocally": "Wird in Ihrem lokalen Ausbreitungs-Posteingang gespeichert...", - "reticulumSendStoredLocally": "Wird in Ihrem lokalen Propagationsposteingang aufbewahrt (nicht an Kollegen geliefert)", - "sentViaLocalPropagation": "Lokaler Ausbreitungseingang" + "reticulumSendStoringLocally": "Wird in Ihrem lokalen Propagations-Posteingang gespeichert…", + "reticulumSendStoredLocally": "Wird in Ihrem lokalen Propagations-Posteingang aufbewahrt (nicht an den Peer geliefert)", + "sentViaLocalPropagation": "Lokaler Propagations-Posteingang" }, "chatPayload": { "mention": "Erwähne {{label}}", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index cda17c23c..d427d629a 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -675,9 +675,9 @@ "shareAsPaperMessageLabel": "Mensaje a cifrar", "shareAsPaperGenerate": "Crear QR en papel", "shareAsPaperCopyFailed": "No se ha podido copiar el enlace en papel", - "waitingMessagesSilentFetched": "Obtenido {{processed}} de la radio...", + "waitingMessagesSilentFetched": "Obtenidos {{processed}} de la radio…", "reticulumSendStoringLocally": "Guardando en su bandeja de entrada de propagación local...", - "reticulumSendStoredLocally": "Se mantiene en su bandeja de entrada de propagación local (no se entrega a los compañeros)", + "reticulumSendStoredLocally": "Se mantiene en su bandeja de entrada de propagación local (no se entrega al peer)", "sentViaLocalPropagation": "Bandeja de entrada de propagación local" }, "chatPayload": { diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index c7d524889..09a19ca3c 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -675,7 +675,7 @@ "shareAsPaperMessageLabel": "Message à crypter", "shareAsPaperGenerate": "Créer un QR papier", "shareAsPaperCopyFailed": "Impossible de copier le lien papier", - "waitingMessagesSilentFetched": "Récupéré {{processed}} de la radio…", + "waitingMessagesSilentFetched": "{{processed}} message(s) récupéré(s) de la radio…", "reticulumSendStoringLocally": "Enregistrement dans votre boîte de réception de propagation locale…", "reticulumSendStoredLocally": "Conservé dans votre boîte de réception de propagation locale (non livré à l'homologue)", "sentViaLocalPropagation": "Boîte de réception de propagation locale" diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 272e2445a..cdbf69172 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -675,7 +675,7 @@ "shareAsPaperMessageLabel": "Messaggio da crittografare", "shareAsPaperGenerate": "Crea QR cartaceo", "shareAsPaperCopyFailed": "Impossibile copiare il link cartaceo", - "waitingMessagesSilentFetched": "Recuperato {{processed}} dalla radio...", + "waitingMessagesSilentFetched": "Recuperati {{processed}} dalla radio…", "reticulumSendStoringLocally": "Salvataggio nella tua casella di posta di propagazione locale in corso...", "reticulumSendStoredLocally": "Conservato nella tua casella di posta di propagazione locale (non consegnato al peer)", "sentViaLocalPropagation": "Posta in arrivo propagazione locale" @@ -4242,7 +4242,7 @@ "syncLocalNotSupported": "Il nodo di propagazione dell'host locale non può essere sincronizzato sulla rete come un nodo di propagazione LXMF remoto.", "enableFailed": "Impossibile abilitare il nodo di propagazione.", "disableFailed": "Impossibile disabilitare il nodo di propagazione.", - "syncOutboundBusy": "Sincronizzazione propagazione differita — un messaggio in uscita sta depositando su questo nodo." + "syncOutboundBusy": "Sincronizzazione di propagazione differita — un messaggio in uscita sta venendo depositato su questo nodo." }, "reticulumPropagationHeader": { "modeLabel": "Modalità di propagazione", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index d91d80998..f7b0a0803 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -4242,7 +4242,7 @@ "syncLocalNotSupported": "ローカル ホスト伝播ノードは、リモート LXMF 伝播ノードのようにネットワーク経由で同期できません。", "enableFailed": "伝播ノードを有効にできませんでした。", "disableFailed": "伝播ノードを無効にできませんでした。", - "syncOutboundBusy": "伝播同期が延期されました—アウトバウンドメッセージがこのノードにデポジットされています。" + "syncOutboundBusy": "伝播同期を延期しました — 送信メッセージをこのノードに預けています。" }, "reticulumPropagationHeader": { "modeLabel": "伝播モード", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 9593d1a19..20ebbab96 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -677,7 +677,7 @@ "shareAsPaperCopyFailed": "용지 링크를 복사할 수 없습니다", "waitingMessagesSilentFetched": "라디오에서 {{processed}} 을 (를) 가져왔습니다...", "reticulumSendStoringLocally": "로컬 전파 받은 편지함에 저장 중...", - "reticulumSendStoredLocally": "로컬 전파 받은 편지함에 보관 (동료에게 전달되지 않음)", + "reticulumSendStoredLocally": "로컬 전파 받은 편지함에 보관됨 (피어에게 전달되지 않음)", "sentViaLocalPropagation": "로컬 전파 메시지함" }, "chatPayload": { @@ -4242,7 +4242,7 @@ "syncLocalNotSupported": "로컬 호스트 전파 노드는 원격 LXMF 전파 노드처럼 네트워크를 통해 동기화될 수 없습니다.", "enableFailed": "전파 노드를 활성화할 수 없습니다.", "disableFailed": "전파 노드를 비활성화할 수 없습니다.", - "syncOutboundBusy": "전파 동기화 지연 — 아웃바운드 메시지가 이 노드에 입금됩니다." + "syncOutboundBusy": "전파 동기화 지연 — 아웃바운드 메시지가 이 노드에 저장되는 중입니다." }, "reticulumPropagationHeader": { "modeLabel": "전파 모드", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index ecc7c267b..ddc9e4e66 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -676,9 +676,9 @@ "shareAsPaperGenerate": "Maak papieren QR", "shareAsPaperCopyFailed": "Kon papieren link niet kopiëren", "waitingMessagesSilentFetched": "{{processed}} van de radio gehaald...", - "reticulumSendStoringLocally": "Opslaan in uw lokale propagatie-inbox...", - "reticulumSendStoredLocally": "Bewaard in uw lokale propagatie-inbox (niet afgeleverd bij collega)", - "sentViaLocalPropagation": "Postvak IN voor lokale propagatie" + "reticulumSendStoringLocally": "Opslaan in uw lokale propagatie-inbox…", + "reticulumSendStoredLocally": "Bewaard in uw lokale propagatie-inbox (niet afgeleverd bij de peer)", + "sentViaLocalPropagation": "Lokale propagatie-inbox" }, "chatPayload": { "mention": "Vermeld {{label}}", @@ -4242,7 +4242,7 @@ "syncLocalNotSupported": "Het lokale hostvoortplantingsknooppunt kan niet via het netwerk worden gesynchroniseerd zoals een extern LXMF-voortplantingsknooppunt.", "enableFailed": "Kan het voortplantingsknooppunt niet inschakelen.", "disableFailed": "Kan het voortplantingsknooppunt niet uitschakelen.", - "syncOutboundBusy": "Voortplantingssynchronisatie uitgesteld — een uitgaand bericht wordt op dit knooppunt gedeponeerd." + "syncOutboundBusy": "Propagatiesynchronisatie uitgesteld — een uitgaand bericht wordt op dit knooppunt gedeponeerd." }, "reticulumPropagationHeader": { "modeLabel": "voortplantingsmodus", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index c225588d8..31c3eec3d 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -681,7 +681,7 @@ "shareAsPaperCopyFailed": "Nie można skopiować papierowego linku", "waitingMessagesSilentFetched": "Pobrano {{processed}} z radia…", "reticulumSendStoringLocally": "Zapisywanie w skrzynce odbiorczej lokalnej propagacji…", - "reticulumSendStoredLocally": "Przechowywane w skrzynce odbiorczej lokalnej propagacji (niedostarczone do partnera)", + "reticulumSendStoredLocally": "Przechowywane w skrzynce odbiorczej lokalnej propagacji (niedostarczone do peera)", "sentViaLocalPropagation": "Skrzynka odbiorcza propagacji lokalnej" }, "chatPayload": { @@ -4246,7 +4246,7 @@ "syncLocalNotSupported": "Lokalny węzeł propagacji hosta nie może być synchronizowany przez sieć jak zdalny węzeł propagacji LXMF.", "enableFailed": "Nie można włączyć węzła propagacji.", "disableFailed": "Nie można wyłączyć węzła propagacji.", - "syncOutboundBusy": "Propagation sync deferred — wiadomość wychodząca jest deponowana w tym węźle." + "syncOutboundBusy": "Synchronizacja propagacji odroczona — wiadomość wychodząca jest deponowana w tym węźle." }, "reticulumPropagationHeader": { "modeLabel": "Tryb propagacji:", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 123facf07..c4423fb46 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -675,9 +675,9 @@ "shareAsPaperMessageLabel": "Mensagem para encriptar", "shareAsPaperGenerate": "Criar QR de papel", "shareAsPaperCopyFailed": "Não foi possível copiar o link do papel", - "waitingMessagesSilentFetched": "Buscou {{processed}} no rádio...", + "waitingMessagesSilentFetched": "Obtidas {{processed}} mensagem(ns) do rádio…", "reticulumSendStoringLocally": "Salvando na sua caixa de entrada de propagação local...", - "reticulumSendStoredLocally": "Mantido em sua caixa de entrada de propagação local (não entregue ao colega)", + "reticulumSendStoredLocally": "Mantido na sua caixa de entrada de propagação local (não entregue ao peer)", "sentViaLocalPropagation": "Caixa de entrada de propagação local" }, "chatPayload": { diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 87b2cc68d..fd635079f 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -4244,7 +4244,7 @@ "syncLocalNotSupported": "Локальный узел распространения хоста не может быть синхронизирован по сети, как удаленный узел распространения LXMF.", "enableFailed": "Не удалось включить узел распространения.", "disableFailed": "Не удалось отключить узел распространения.", - "syncOutboundBusy": "Синхронизация распространения отложена — исходящее сообщение передается на этот узел." + "syncOutboundBusy": "Синхронизация распространения отложена — исходящее сообщение сохраняется на этом узле." }, "reticulumPropagationHeader": { "modeLabel": "Режим распространения", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 13519c86e..af9c15e28 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -4242,7 +4242,7 @@ "syncLocalNotSupported": "Yerel ana bilgisayar yayılım düğümü, uzak LXMF yayılım düğümü gibi ağ üzerinden senkronize edilemez.", "enableFailed": "Yayılma düğümü etkinleştirilemedi.", "disableFailed": "Yayılma düğümü devre dışı bırakılamadı.", - "syncOutboundBusy": "Yayılım senkronizasyonu ertelendi — bu düğüme giden bir mesaj gönderiliyor." + "syncOutboundBusy": "Yayılım senkronizasyonu ertelendi — giden bir mesaj bu düğüme bırakılıyor." }, "reticulumPropagationHeader": { "modeLabel": "Yayılma modu", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 7da95eb22..0d1fbd9ed 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -678,9 +678,9 @@ "shareAsPaperGenerate": "Створити паперовий QR-код", "shareAsPaperCopyFailed": "Не вдалося скопіювати посилання на папір", "waitingMessagesSilentFetched": "Отримано {{processed}} з радіо…", - "reticulumSendStoringLocally": "Збереження до вашої локальної папки «Вхідні» …", - "reticulumSendStoredLocally": "Зберігається у вашій локальній папці «Вхідні» (не доставляється одноранговому користувачеві)", - "sentViaLocalPropagation": "Вхідні повідомлення про локальне поширення" + "reticulumSendStoringLocally": "Збереження до локальної скриньки поширення…", + "reticulumSendStoredLocally": "Зберігається у локальній скриньці поширення (не доставлено одноранговому вузлу)", + "sentViaLocalPropagation": "Локальна скринька поширення" }, "chatPayload": { "mention": "Згадайте {{label}}", @@ -4244,7 +4244,7 @@ "syncLocalNotSupported": "Локальний хост-вузол поширення не можна синхронізувати через мережу, як віддалений вузол поширення LXMF.", "enableFailed": "Не вдалося ввімкнути вузол розповсюдження.", "disableFailed": "Не вдалося вимкнути вузол розповсюдження.", - "syncOutboundBusy": "Синхронізація поширення відкладена — вихідне повідомлення передається на цей вузол." + "syncOutboundBusy": "Синхронізація поширення відкладена — вихідне повідомлення зберігається на цьому вузлі." }, "reticulumPropagationHeader": { "modeLabel": "Режим поширення", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 0332c3706..d3310b8c2 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -677,7 +677,7 @@ "shareAsPaperCopyFailed": "无法复制纸质链接", "waitingMessagesSilentFetched": "已从收音机获取{{processed}} …", "reticulumSendStoringLocally": "正在保存到本地传播收件箱…", - "reticulumSendStoredLocally": "保存在您的本地传播收件箱中(未发送给同行)", + "reticulumSendStoredLocally": "保存在您的本地传播收件箱中(未送达对端)", "sentViaLocalPropagation": "本地传播收件箱" }, "chatPayload": { diff --git a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts index e68ce8644..fc06c10e2 100644 --- a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts @@ -280,8 +280,13 @@ describe('useReticulumRuntime outbound delivery persistence', () => { /shouldApplyLinkDeliveryTimeoutFailureBridge\(\s*propState\.nodes,\s*propState\.preferredId,\s*\)/, ); expect(SOURCE).toContain('propagationHydratedForBridgeRef'); + expect(SOURCE).toContain('identityIdRef'); expect(SOURCE).toMatch(/if \(!applyBridge\) \{/); expect(SOURCE).toContain('cascade eligible'); + expect(SOURCE).toContain('propagation hydrate failed/uncertain'); + expect(SOURCE).toMatch( + /processedLinkTimeoutDestsRef\.current\.add\(norm\);\s*failReticulumSendingOutboundToDestHash/, + ); }); it('wires propagation store + sidecar health into Reticulum diagnostics', () => { diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 67f7f3a9e..063679f6f 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -293,6 +293,7 @@ export function useReticulumRuntime(): ProtocolRuntime { const processedLinkTimeoutDestsRef = useRef(new Set()); /** Defer link-timeout failure bridge until first propagation store refresh completes. */ const propagationHydratedForBridgeRef = useRef(false); + const identityIdRef = useRef(identityId); const nodeStoreSlice = useNodeStore((s) => (identityId ? s.nodes[identityId] : undefined)); // Include `connecting`: main suspends Noble at sidecar start before status reaches @@ -309,6 +310,15 @@ export function useReticulumRuntime(): ProtocolRuntime { stateRef.current = state; }, [state]); + useEffect(() => { + identityIdRef.current = identityId; + }, [identityId]); + + useEffect(() => { + processedLinkTimeoutDestsRef.current.clear(); + propagationHydratedForBridgeRef.current = false; + }, [identityId]); + const selfNodeId = useMemo( () => (selfLxmfHash ? reticulumHashToNodeId(selfLxmfHash) : null), [selfLxmfHash], @@ -1488,8 +1498,10 @@ export function useReticulumRuntime(): ProtocolRuntime { void syncDiagnosticsFromSidecar(); const timeouts = status.interfaceIssueAlert?.linkDeliveryTimeouts; if (identityId && timeouts?.length) { + const bridgeIdentityId = identityId; void (async () => { if (!propagationHydratedForBridgeRef.current) { + const stampBefore = useReticulumPropagationStore.getState().lastRefreshedAt; try { await useReticulumPropagationStore.getState().refreshFromSidecar(); } catch (e: unknown) { @@ -1498,9 +1510,30 @@ export function useReticulumRuntime(): ProtocolRuntime { errLikeToLogString(e), ); } + if (identityIdRef.current !== bridgeIdentityId) return; + const stampAfter = useReticulumPropagationStore.getState().lastRefreshedAt; + const hydratedOk = stampAfter != null && stampAfter !== stampBefore; + if (!hydratedOk) { + console.debug( + '[useReticulumRuntime] link-timeout bridge skip — propagation hydrate failed/uncertain', + ); + return; + } propagationHydratedForBridgeRef.current = true; } + if (identityIdRef.current !== bridgeIdentityId) return; const propState = useReticulumPropagationStore.getState(); + // Empty + no preferred + never refreshed: cascade capacity unknown — do not fail DMs. + if ( + propState.nodes.length === 0 && + propState.preferredId == null && + propState.lastRefreshedAt == null + ) { + console.debug( + '[useReticulumRuntime] link-timeout bridge skip — propagation state uncertain', + ); + return; + } const applyBridge = shouldApplyLinkDeliveryTimeoutFailureBridge( propState.nodes, propState.preferredId, @@ -1509,9 +1542,9 @@ export function useReticulumRuntime(): ProtocolRuntime { `[useReticulumRuntime] link-timeout bridge apply=${applyBridge} preferred=${propState.preferredId ?? 'none'} nodes=${propState.nodes.length}`, ); for (const { destinationHash } of timeouts) { + if (identityIdRef.current !== bridgeIdentityId) return; const norm = destinationHash.replace(/[^0-9a-f]/gi, '').toLowerCase(); if (!norm || processedLinkTimeoutDestsRef.current.has(norm)) continue; - processedLinkTimeoutDestsRef.current.add(norm); // PN cascade (remote or local-prop): sidecar owns outcome via WS. if (!applyBridge) { console.debug( @@ -1519,8 +1552,9 @@ export function useReticulumRuntime(): ProtocolRuntime { ); continue; } + processedLinkTimeoutDestsRef.current.add(norm); failReticulumSendingOutboundToDestHash( - identityId, + bridgeIdentityId, norm, i18n.t('chatPanel.reticulumSendFailed'), ); diff --git a/src/renderer/stores/reticulumPeerStore.test.ts b/src/renderer/stores/reticulumPeerStore.test.ts index 63662cadf..87cb689d3 100644 --- a/src/renderer/stores/reticulumPeerStore.test.ts +++ b/src/renderer/stores/reticulumPeerStore.test.ts @@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ReticulumContact } from '@/shared/reticulum-types'; import { reticulumHashToNodeId } from '../lib/reticulum/destHash'; -import { resetReticulumProxyRateLimitBackoffForTests } from '../lib/reticulum/reticulumProxyRateLimitBackoff'; +import { + noteReticulumProxyRateLimitHit, + resetReticulumProxyRateLimitBackoffForTests, +} from '../lib/reticulum/reticulumProxyRateLimitBackoff'; import { applyReticulumAnnounceReceivedOptimistic, applyReticulumPeerPatchesNow, @@ -968,6 +971,34 @@ describe('reticulumPeerStore', () => { debug.mockRestore(); }); + it('refreshReticulumPeersFromSidecar skips only on shared backoff (not lxmfRecent)', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'debug').mockImplementation(() => {}); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const proxyGet = vi.fn((path: string) => { + if (path.startsWith('/api/v1/peers')) return Promise.resolve({ peers: [] }); + if (path === '/api/v1/contacts') return Promise.resolve({ contacts: [] }); + if (path === '/api/v1/nomadnetwork/nodes') return Promise.resolve({ nodes: [] }); + return Promise.resolve({}); + }); + vi.stubGlobal('window', { + electronAPI: { + reticulum: { proxyGet }, + db: { getReticulumDestinations: vi.fn().mockResolvedValue([]) }, + }, + }); + + noteReticulumProxyRateLimitHit('lxmfRecent'); + await refreshReticulumPeersFromSidecar(); + expect(proxyGet).toHaveBeenCalled(); + + proxyGet.mockClear(); + resetReticulumProxyRateLimitBackoffForTests(); + noteReticulumProxyRateLimitHit('shared'); + await refreshReticulumPeersFromSidecar({ forceRefresh: true }); + expect(proxyGet).not.toHaveBeenCalled(); + }); + it('refreshReticulumPeersFromSidecar OR-accumulates forceRefresh across coalesced callers', async () => { let releaseFirst!: () => void; const firstGate = new Promise((resolve) => { diff --git a/src/renderer/stores/reticulumPeerStore.ts b/src/renderer/stores/reticulumPeerStore.ts index fa463d816..291b6f111 100644 --- a/src/renderer/stores/reticulumPeerStore.ts +++ b/src/renderer/stores/reticulumPeerStore.ts @@ -15,6 +15,7 @@ import { type ReticulumPathSlot, } from '@/renderer/lib/reticulum/reticulumPathSlots'; import { + clearReticulumProxyRateLimitBackoff, isReticulumProxyRateLimitBackoffActive, noteReticulumProxyErrorIfRateLimited, reticulumProxyRateLimitBackoffRemainingMs, @@ -1282,9 +1283,13 @@ export function refreshReticulumPeersFromSidecar( peerRefreshInFlight = (async () => { try { - if (isReticulumProxyRateLimitBackoffActive()) { + if (isReticulumProxyRateLimitBackoffActive('shared')) { + // Keep coalesce flags so a force refresh is not dropped while backoff is active. + if (opts.forceRefresh) peerRefreshPendingForce = true; + if (!opts.skipNomad) peerRefreshPendingSkipNomad = false; + peerRefreshPendingRerun = true; console.debug( - `[reticulumPeerStore] refresh skipped — proxy rate-limit backoff remaining=${reticulumProxyRateLimitBackoffRemainingMs()}ms`, + `[reticulumPeerStore] refresh skipped — proxy rate-limit backoff remaining=${reticulumProxyRateLimitBackoffRemainingMs('shared')}ms`, ); return [...useReticulumPeerStore.getState().contacts.values()]; } @@ -1294,22 +1299,23 @@ export function refreshReticulumPeersFromSidecar( peerRefreshPendingSkipNomad = true; peerRefreshPendingRerun = false; let result = await refreshReticulumPeersFromSidecarOnce({ forceRefresh, skipNomad }); + clearReticulumProxyRateLimitBackoff('shared'); while (peerRefreshPendingRerun) { - if (isReticulumProxyRateLimitBackoffActive()) break; + // Leave peerRefreshPendingRerun / force / skipNomad set so the next refresh + // after backoff still honors a coalesced force refresh. + if (isReticulumProxyRateLimitBackoffActive('shared')) break; peerRefreshPendingRerun = false; forceRefresh = peerRefreshPendingForce; skipNomad = peerRefreshPendingSkipNomad; peerRefreshPendingForce = false; peerRefreshPendingSkipNomad = true; result = await refreshReticulumPeersFromSidecarOnce({ forceRefresh, skipNomad }); + clearReticulumProxyRateLimitBackoff('shared'); } return result; } catch (e) { const msg = errLikeToLogString(e); - if ( - noteReticulumProxyErrorIfRateLimited(e) || - msg.toLowerCase().includes('rate limit exceeded') - ) { + if (noteReticulumProxyErrorIfRateLimited(e, 'shared')) { console.debug('[reticulumPeerStore] refresh ' + msg); throw e instanceof Error ? e : new Error(msg); } @@ -1317,8 +1323,11 @@ export function refreshReticulumPeersFromSidecar( return []; } finally { peerRefreshInFlight = null; - peerRefreshPendingForce = false; - peerRefreshPendingSkipNomad = true; + // Preserve coalesce intent when we broke out for shared-bucket backoff. + if (!peerRefreshPendingRerun) { + peerRefreshPendingForce = false; + peerRefreshPendingSkipNomad = true; + } } })(); diff --git a/src/shared/reticulumApiPaths.ts b/src/shared/reticulumApiPaths.ts new file mode 100644 index 000000000..8cac1b9ee --- /dev/null +++ b/src/shared/reticulumApiPaths.ts @@ -0,0 +1,4 @@ +/** Shared sidecar HTTP path constants (main proxy allowlists + renderer fetch). */ + +/** Inbound LXMF catch-up ring (`GET` with optional since_ts / since_seq / limit). */ +export const RETICULUM_LXMF_RECENT_API_PATH = '/api/v1/lxmf/recent'; diff --git a/src/shared/reticulumDeliveryMethod.test.ts b/src/shared/reticulumDeliveryMethod.test.ts index 57766fd95..b5c2a2db5 100644 --- a/src/shared/reticulumDeliveryMethod.test.ts +++ b/src/shared/reticulumDeliveryMethod.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { parseReticulumDeliveryMethod } from '@/shared/reticulumDeliveryMethod'; +import { + isPnCascadeDeliveryMethod, + parseReticulumDeliveryMethod, +} from '@/shared/reticulumDeliveryMethod'; describe('parseReticulumDeliveryMethod', () => { it('accepts known methods case-insensitively', () => { @@ -8,6 +11,8 @@ describe('parseReticulumDeliveryMethod', () => { expect(parseReticulumDeliveryMethod('Propagated')).toBe('propagated'); expect(parseReticulumDeliveryMethod('opportunistic')).toBe('opportunistic'); expect(parseReticulumDeliveryMethod('paper')).toBe('paper'); + expect(parseReticulumDeliveryMethod('stored_locally')).toBe('stored_locally'); + expect(parseReticulumDeliveryMethod('Stored_Locally')).toBe('stored_locally'); }); it('rejects unknown or empty values', () => { @@ -17,3 +22,13 @@ describe('parseReticulumDeliveryMethod', () => { expect(parseReticulumDeliveryMethod('garbage')).toBeUndefined(); }); }); + +describe('isPnCascadeDeliveryMethod', () => { + it('is true for propagated and stored_locally only', () => { + expect(isPnCascadeDeliveryMethod('propagated')).toBe(true); + expect(isPnCascadeDeliveryMethod('stored_locally')).toBe(true); + expect(isPnCascadeDeliveryMethod('direct')).toBe(false); + expect(isPnCascadeDeliveryMethod('paper')).toBe(false); + expect(isPnCascadeDeliveryMethod(undefined)).toBe(false); + }); +}); diff --git a/src/shared/reticulumDeliveryMethod.ts b/src/shared/reticulumDeliveryMethod.ts index c2a92a90a..3dd6cfc7b 100644 --- a/src/shared/reticulumDeliveryMethod.ts +++ b/src/shared/reticulumDeliveryMethod.ts @@ -20,3 +20,8 @@ export function parseReticulumDeliveryMethod( const normalized = value.trim().toLowerCase(); return ALLOWED.has(normalized) ? (normalized as ReticulumDeliveryMethod) : undefined; } + +/** Direct→PN cascade in flight / stored (remote PN or local-prop inbox). */ +export function isPnCascadeDeliveryMethod(m: ReticulumDeliveryMethod | undefined): boolean { + return m === 'propagated' || m === 'stored_locally'; +} From 64a2e8b7f83ad4235e807c9cf6052a0675ee1083 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Fri, 7 Aug 2026 13:17:10 -0600 Subject: [PATCH 5/5] fix(reticulum): address #817 PR review findings Verify-and-fix: cascade eviction/local-prop dest, try_advance behavioral coverage, per-identity catch-up single-flight, bridge generation abort, deliveryAttempts persistence, jitter clamp, retrieve log slice, and cascade-capacity negative test. --- reticulum-sidecar/src/stack/live.rs | 12 +-- reticulum-sidecar/src/stack/lxmf_outbound.rs | 99 +++++++++++++++++++ reticulum-sidecar/src/stack/pn_cascade.rs | 37 ++++--- src/main/support-bundle.test.ts | 2 + src/main/support-bundle.ts | 1 + ...plyReticulumOutboundDeliveryStatus.test.ts | 36 +++++++ .../applyReticulumOutboundDeliveryStatus.ts | 22 ++++- .../catchUpRecentInboundLxmf.test.ts | 45 +++++++++ .../lib/reticulum/catchUpRecentInboundLxmf.ts | 54 +++++----- .../reticulumPropagationEffective.test.ts | 11 +++ .../reticulumProxyRateLimitBackoff.test.ts | 17 ++++ .../reticulumProxyRateLimitBackoff.ts | 6 +- ...ticulumRuntime.reconnect-hardening.test.ts | 9 ++ src/renderer/runtime/useReticulumRuntime.ts | 30 +++++- 14 files changed, 328 insertions(+), 53 deletions(-) diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 1159e0452..cc5226f95 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -3753,7 +3753,7 @@ impl LiveBridge { /// Rebuild Direct→PN cascade candidate list from persisted propagation rows. pub async fn refresh_pn_cascade_candidates(&self) { use pn_cascade::candidates_from_propagation_rows; - let (rows, self_hash, local_enabled) = { + let (rows, self_hash) = { let state = self.persisted.read().await; let rows: Vec<(String, bool, Option, Option)> = state .propagation @@ -3761,15 +3761,9 @@ impl LiveBridge { .map(|p| (p.id.clone(), p.enabled, p.destination_hash.clone(), p.hops)) .collect(); let self_hash = state.identity.lxmf_hash.clone(); - let local_enabled = state - .propagation - .iter() - .find(|p| p.id == "local-prop") - .map(|p| p.enabled) - .unwrap_or(false); - (rows, self_hash, local_enabled) + (rows, self_hash) }; - let candidates = candidates_from_propagation_rows(&rows, &self_hash, local_enabled); + let candidates = candidates_from_propagation_rows(&rows, &self_hash); if let Ok(mut driver) = self.outbound.lock() { driver.set_pn_cascade_candidates(candidates); } diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index a4b621a48..575fd4cdd 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -524,7 +524,31 @@ impl LxmfOutboundDriver { self.pn_deposit_defer_counts.remove(&hash); self.pending_pn_targets.insert(hash, prop_hash); } + // Local-prop cascade uses lxmf.propagation dest (not self LXMF). Identity should + // already be pinned via rehydrate; if missing, advance rather than path-hunt Nomad. + let is_local_cascade = message + .hash + .or(message.message_id) + .is_some_and(|h| self.pn_cascade_local.contains(&h)); if !self.known_identities.contains_key(&prop_hex.to_lowercase()) { + if is_local_cascade { + tracing::warn!( + target: "lxmf-outbound", + prop = %prop_hex, + dest = %hex::encode(message.destination_hash), + "DeliverPropagated: local-prop identity unknown — advancing PN cascade" + ); + if let Some(hash) = message.hash.or(message.message_id) { + self.mark_pn_tried(hash, prop_hash); + } + match self.try_advance_pn_cascade(router, event_tx, message) { + Ok(()) => return, + Err(message) => { + self.emit_outbound_failed(router, event_tx, *message); + return; + } + } + } tracing::debug!( prop = %prop_hex, dest = %hex::encode(message.destination_hash), @@ -859,6 +883,7 @@ impl LxmfOutboundDriver { self.pn_cascade_tried.remove(&oldest); self.pn_cascade_local.remove(&oldest); self.pending_pn_targets.remove(&oldest); + self.pn_deposit_defer_counts.remove(&oldest); } } self.pn_cascade_tried @@ -2083,6 +2108,80 @@ mod tests { assert_eq!(got_link, link_id); } + #[test] + fn try_advance_pn_cascade_orders_preferred_remote_local_then_exhausts() { + use lxmf_core::constants::DeliveryMethod; + use lxmf_core::message::LxMessage; + use lxmf_core::router::{LxmRouter, RouterConfig}; + use tokio::sync::broadcast; + + let identity = Identity::new(); + let (tx, _rx) = mpsc::channel(32); + let mut driver = LxmfOutboundDriver::new(tx, &identity, "aabb".repeat(8), "me".into()); + let preferred = [0x11u8; 16]; + let next_remote = [0x22u8; 16]; + let local = [0x99u8; 16]; + let dest_hash = dest(0xcd); + let msg_hash = [0x42u8; 32]; + + let mut router = LxmRouter::new(RouterConfig::default()); + let (event_tx, _event_rx) = broadcast::channel(8); + driver.set_propagation_node(&mut router, Some(preferred)); + driver.set_pn_cascade_candidates(vec![ + PnCascadeCandidate { + hash: preferred, + is_local: false, + hops: Some(1), + id: "pn-a".into(), + }, + PnCascadeCandidate { + hash: next_remote, + is_local: false, + hops: Some(2), + id: "pn-b".into(), + }, + PnCascadeCandidate { + hash: local, + is_local: true, + hops: Some(0), + id: "local-prop".into(), + }, + ]); + + let make_direct = || { + let mut msg = LxMessage::new(dest_hash, [1u8; 16], "", "hi", DeliveryMethod::Direct); + msg.hash = Some(msg_hash); + msg + }; + + assert!( + driver + .try_advance_pn_cascade(&mut router, &event_tx, make_direct()) + .is_ok() + ); + assert_eq!(driver.pending_pn_targets.get(&msg_hash), Some(&preferred)); + assert!(!driver.pn_cascade_local.contains(&msg_hash)); + + assert!( + driver + .try_advance_pn_cascade(&mut router, &event_tx, make_direct()) + .is_ok() + ); + assert_eq!(driver.pending_pn_targets.get(&msg_hash), Some(&next_remote)); + assert!(!driver.pn_cascade_local.contains(&msg_hash)); + + assert!( + driver + .try_advance_pn_cascade(&mut router, &event_tx, make_direct()) + .is_ok() + ); + assert_eq!(driver.pending_pn_targets.get(&msg_hash), Some(&local)); + assert!(driver.pn_cascade_local.contains(&msg_hash)); + + let exhausted = driver.try_advance_pn_cascade(&mut router, &event_tx, make_direct()); + assert!(exhausted.is_err(), "cascade must exhaust after local-prop"); + } + #[test] fn pn_cascade_source_contract_replaces_one_shot_fallback() { let src = include_str!("lxmf_outbound.rs"); diff --git a/reticulum-sidecar/src/stack/pn_cascade.rs b/reticulum-sidecar/src/stack/pn_cascade.rs index 275adddf6..b98405714 100644 --- a/reticulum-sidecar/src/stack/pn_cascade.rs +++ b/reticulum-sidecar/src/stack/pn_cascade.rs @@ -105,27 +105,24 @@ pub fn is_self_lxmf_hash(hash: &[u8; 16], self_lxmf_hash_hex: &str) -> bool { } /// Parse enabled propagation rows into cascade candidates. +/// +/// Local-prop eligibility uses the row `enabled` flag only (single source of truth). +/// Local-prop hash must be the lxmf.propagation destination — never fall back to self LXMF. pub fn candidates_from_propagation_rows( rows: &[(String, bool, Option, Option)], self_lxmf_hash_hex: &str, - local_prop_enabled: bool, ) -> Vec { let self_norm = self_lxmf_hash_hex.trim().to_lowercase(); let mut out = Vec::new(); for (id, enabled, dest_hash, hops) in rows { if id == "local-prop" { - if !local_prop_enabled && !*enabled { + if !*enabled { continue; } - let enabled_local = local_prop_enabled || *enabled; - if !enabled_local { + // Require the real lxmf.propagation dest — self LXMF is Nomad/delivery identity. + let Some(hash) = dest_hash.as_ref().and_then(|h| parse_hash16(h)) else { continue; - } - let hash = dest_hash - .as_ref() - .and_then(|h| parse_hash16(h)) - .or_else(|| parse_hash16(&self_norm)); - let Some(hash) = hash else { continue }; + }; out.push(PnCascadeCandidate { hash, is_local: true, @@ -243,15 +240,31 @@ mod tests { #[test] fn candidates_from_rows_skips_disabled_and_self_remote() { let self_hex = "aa".repeat(16); + let prop_dest = "dd".repeat(16); let rows = vec![ ("pn-a".into(), true, Some("bb".repeat(16)), Some(1u8)), ("pn-self".into(), true, Some(self_hex.clone()), Some(0u8)), ("pn-off".into(), false, Some("cc".repeat(16)), None), - ("local-prop".into(), true, Some(self_hex.clone()), Some(0)), + ("local-prop".into(), true, Some(prop_dest.clone()), Some(0)), ]; - let c = candidates_from_propagation_rows(&rows, &self_hex, true); + let c = candidates_from_propagation_rows(&rows, &self_hex); assert_eq!(c.iter().filter(|x| !x.is_local).count(), 1); assert_eq!(c.iter().filter(|x| x.is_local).count(), 1); + assert_eq!( + hex::encode(c.iter().find(|x| x.is_local).unwrap().hash), + prop_dest + ); + } + + #[test] + fn candidates_skip_disabled_local_and_missing_prop_dest() { + let self_hex = "aa".repeat(16); + let rows = vec![ + ("local-prop".into(), false, Some("dd".repeat(16)), Some(0)), + ("local-prop".into(), true, None, Some(0)), + ]; + let c = candidates_from_propagation_rows(&rows, &self_hex); + assert!(c.is_empty()); } #[test] diff --git a/src/main/support-bundle.test.ts b/src/main/support-bundle.test.ts index bd27ac859..2d133215f 100644 --- a/src/main/support-bundle.test.ts +++ b/src/main/support-bundle.test.ts @@ -108,6 +108,7 @@ describe('extractLxmfOutboundLogSlice', () => { 'warn DeliverPropagated: deferring — PN link busy', 'debug peer refresh ok', 'info target=propagation-deposit outbound PN deposit Completes', + 'info target=propagation-retrieve sync transfer progress', ].join('\n'), 'utf8', ); @@ -115,6 +116,7 @@ describe('extractLxmfOutboundLogSlice', () => { expect(slice).toContain('LXMF advancing PN cascade'); expect(slice).toContain('DeliverPropagated'); expect(slice).toContain('propagation-deposit'); + expect(slice).toContain('propagation-retrieve'); expect(slice).not.toContain('hello world'); expect(slice).not.toContain('peer refresh ok'); }); diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index af0e930d6..4780fc325 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -188,6 +188,7 @@ export function extractLxmfOutboundLogSlice(...logChunks: Buffer[]): Buffer { const patterns = [ /lxmf-outbound/i, /propagation-deposit/i, + /propagation-retrieve/i, /LXMF advancing PN cascade/i, /LXMF outbound delivery failed/i, /Direct path failover/i, diff --git a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts index 8ffdb08c4..8cf379216 100644 --- a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts +++ b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.test.ts @@ -397,14 +397,50 @@ describe('applyReticulumOutboundDeliveryStatus', () => { applyReticulumOutboundDeliveryStatus(identityId, messageHash, 'sending', { deliveryMethod: 'stored_locally', + deliveryAttempts: 3, }); const row = useMessageStore.getState().messages[identityId][messageHash]; expect(row.status).toBe('sending'); expect(row.reticulumDeliveryMethod).toBe('stored_locally'); + expect(row.reticulumDeliveryAttempts).toBe(3); expect(row.error).toBeUndefined(); }); + it('buffers deliveryAttempts for pending-before-rekey flush', () => { + const pendingId = 'reticulum-pending-attempts'; + const toNodeId = reticulumHashToNodeId(DEST); + const selfNodeId = reticulumHashToNodeId(SELF); + registerReticulumDestinationHash(toNodeId, DEST); + registerReticulumDestinationHash(selfNodeId, SELF); + useMessageStore.setState({ + messages: { + [identityId]: { + [pendingId]: { + id: pendingId, + from: selfNodeId, + to: toNodeId, + payload: 'race', + channelIndex: 0, + timestamp: Date.now(), + status: 'sending', + reticulumSenderHash: SELF, + }, + }, + }, + }); + + applyReticulumOutboundDeliveryStatus(identityId, messageHash, 'sending', { + deliveryMethod: 'propagated', + deliveryAttempts: 4, + }); + renameMessageId(identityId, pendingId, messageHash); + expect(flushPendingReticulumOutboundDeliveryStatus(identityId, messageHash)).toBe(true); + expect( + useMessageStore.getState().messages[identityId][messageHash].reticulumDeliveryAttempts, + ).toBe(4); + }); + it('clamps delivery_attempts when patching outbound status', () => { const toNodeId = reticulumHashToNodeId(DEST); const selfNodeId = reticulumHashToNodeId(SELF); diff --git a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts index 5998ac854..92d7437cc 100644 --- a/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts +++ b/src/renderer/lib/reticulum/applyReticulumOutboundDeliveryStatus.ts @@ -76,7 +76,13 @@ const PENDING_DELIVERY_STATUS_TTL_MS = 60_000; const PENDING_DELIVERY_STATUS_MAX = 64; const pendingDeliveryByKey = new Map< string, - { wireStatus: string; sentVia?: string; deliveryMethod?: string; receivedAt: number } + { + wireStatus: string; + sentVia?: string; + deliveryMethod?: string; + deliveryAttempts?: number; + receivedAt: number; + } >(); function pendingDeliveryKey(identityId: IdentityId, messageHash: string): string { @@ -102,12 +108,14 @@ function bufferPendingDeliveryStatus( wireStatus: string, sentVia?: string, deliveryMethod?: string, + deliveryAttempts?: number, ): void { prunePendingDeliveryStatuses(); pendingDeliveryByKey.set(pendingDeliveryKey(identityId, messageHash), { wireStatus, sentVia, deliveryMethod, + deliveryAttempts, receivedAt: Date.now(), }); } @@ -135,6 +143,7 @@ export function flushPendingReticulumOutboundDeliveryStatus( undefined, parseWireSentVia(pending.sentVia), parseReticulumDeliveryMethod(pending.deliveryMethod), + pending.deliveryAttempts, ); if (applied) pendingDeliveryByKey.delete(key); return applied; @@ -177,6 +186,9 @@ export function persistReticulumOutboundMessageStatus( error: undefined, reticulumDeliveryMethod: deliveryMethod, ...(sentVia != null ? { receivedVia: sentVia } : {}), + ...(deliveryAttempts != null + ? { reticulumDeliveryAttempts: clampDeliveryAttempts(deliveryAttempts) } + : {}), }; upsertMessage(identityId, revived); const senderHash = resolveOutboundSenderHash(revived); @@ -319,13 +331,19 @@ export function applyReticulumOutboundDeliveryStatus( return; } // Terminal status, or egress/method upgrade before rekey for later flush. - if (isTerminalStatus(status) || sentVia != null || deliveryMethod != null) { + if ( + isTerminalStatus(status) || + sentVia != null || + deliveryMethod != null || + deliveryAttempts != null + ) { bufferPendingDeliveryStatus( identityId, normalizedHash, wireStatus, opts?.sentVia ?? undefined, opts?.deliveryMethod ?? undefined, + deliveryAttempts, ); } } diff --git a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts index a98961b29..538bb84ef 100644 --- a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts @@ -206,4 +206,49 @@ describe('catchUpRecentInboundLxmf', () => { expect(ingestB).toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('connect+ws_reconnect')); }); + + it('keeps independent single-flight state across two identities', async () => { + let releaseA!: () => void; + let releaseB!: () => void; + const gateA = new Promise((resolve) => { + releaseA = resolve; + }); + const gateB = new Promise((resolve) => { + releaseB = resolve; + }); + const ingestA = vi.fn(); + const ingestB = vi.fn(); + vi.mocked(fetchRecentInboundLxmfDetailed).mockImplementation(async (opts = {}) => { + if (opts.sinceTs === 1) { + await gateA; + return { messages: [sample('aa'.repeat(32), 1_000, 1)], ringLen: 1 }; + } + await gateB; + return { messages: [sample('bb'.repeat(32), 2_000, 2)], ringLen: 1 }; + }); + + const pA = catchUpRecentInboundLxmf({ + identityId: 'id-a', + ingest: ingestA, + sinceTs: 1, + reason: 'a', + }); + const pB = catchUpRecentInboundLxmf({ + identityId: 'id-b', + ingest: ingestB, + sinceTs: 2, + reason: 'b', + }); + + expect(fetchRecentInboundLxmfDetailed).toHaveBeenCalledTimes(2); + releaseB(); + const rB = await pB; + expect(rB?.count).toBe(1); + expect(ingestB).toHaveBeenCalled(); + expect(ingestA).not.toHaveBeenCalled(); + releaseA(); + const rA = await pA; + expect(rA?.count).toBe(1); + expect(ingestA).toHaveBeenCalled(); + }); }); diff --git a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts index 17bafa11c..25298c395 100644 --- a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts +++ b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts @@ -19,12 +19,16 @@ export interface CatchUpRecentInboundLxmfOutcome { watermarkSeq: number | null; } -/** Single-flight + trailing coalesce for concurrent catch-up callers. */ -let catchUpInFlight: Promise | null = null; -let catchUpInFlightOpts: CatchUpRecentInboundLxmfOpts | null = null; -let catchUpPending: CatchUpRecentInboundLxmfOpts | null = null; +interface CatchUpFlight { + promise: Promise; + opts: CatchUpRecentInboundLxmfOpts; + pending: CatchUpRecentInboundLxmfOpts | null; +} + +/** Per-identity single-flight + trailing coalesce (never share across identityIds). */ +const catchUpByIdentity = new Map(); -/** Prefer latest cursor; merge reason labels; last ingest/identity wins. */ +/** Prefer latest cursor; merge reason labels; last ingest wins (same identity). */ function mergeCatchUpOpts( base: CatchUpRecentInboundLxmfOpts, next: CatchUpRecentInboundLxmfOpts, @@ -73,7 +77,7 @@ function mergeCatchUpOpts( } } return { - identityId: next.identityId || base.identityId, + identityId: base.identityId, ingest: next.ingest, ...(chosenSinceTs != null ? { sinceTs: chosenSinceTs } : {}), ...(chosenSinceSeq != null ? { sinceSeq: chosenSinceSeq } : {}), @@ -165,44 +169,46 @@ async function catchUpRecentInboundLxmfOnce( * Sidecar cursor is exclusive `(since_ts, since_seq)`; returned watermarks are the max * `(timestamp, ring_seq)` among fetched rows and are safe for the next periodic fetch. * - * Concurrent callers share one in-flight promise; later opts coalesce (latest cursor, merged reasons) - * into a trailing rerun when needed. + * Concurrent callers for the **same** identity share one in-flight promise; later opts + * coalesce (latest cursor, merged reasons) into a trailing rerun. Different identities + * never share flight state. */ export async function catchUpRecentInboundLxmf( opts: CatchUpRecentInboundLxmfOpts, ): Promise { if (!opts.identityId) return null; + const identityId = opts.identityId; - if (catchUpInFlight) { - const base = catchUpPending ?? catchUpInFlightOpts ?? opts; - catchUpPending = mergeCatchUpOpts(base, opts); - return catchUpInFlight; + const existing = catchUpByIdentity.get(identityId); + if (existing) { + const base = existing.pending ?? existing.opts; + existing.pending = mergeCatchUpOpts(base, opts); + return existing.promise; } - catchUpInFlightOpts = opts; - catchUpInFlight = (async () => { + const flight: CatchUpFlight = { opts, pending: null, promise: Promise.resolve(null) }; + const promise = (async () => { try { let current = opts; let result = await catchUpRecentInboundLxmfOnce(current); - while (catchUpPending) { - current = catchUpPending; - catchUpPending = null; - catchUpInFlightOpts = current; + while (flight.pending) { + current = flight.pending; + flight.pending = null; + flight.opts = current; result = await catchUpRecentInboundLxmfOnce(current); } return result; } finally { - catchUpInFlight = null; - catchUpInFlightOpts = null; + catchUpByIdentity.delete(identityId); } })(); + flight.promise = promise; + catchUpByIdentity.set(identityId, flight); - return catchUpInFlight; + return promise; } /** Test-only reset of single-flight coalesce state. */ export function resetCatchUpRecentInboundLxmfSingleFlightForTests(): void { - catchUpInFlight = null; - catchUpInFlightOpts = null; - catchUpPending = null; + catchUpByIdentity.clear(); } diff --git a/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts b/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts index 24ed8a307..ecb3c70fe 100644 --- a/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts +++ b/src/renderer/lib/reticulum/reticulumPropagationEffective.test.ts @@ -85,4 +85,15 @@ describe('hasReticulumPnCascadeCapacity', () => { it('is false when nothing is available', () => { expect(hasReticulumPnCascadeCapacity([], null, 'off')).toBe(false); }); + + it('is false when local-prop is present but disabled', () => { + const localDisabled: PropagationNodeRow = { + id: 'local-prop', + name: 'Local', + enabled: false, + status: 'inactive', + preferred: false, + }; + expect(hasReticulumPnCascadeCapacity([localDisabled], null, 'off')).toBe(false); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts index b3495b3e9..95feca259 100644 --- a/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts +++ b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.test.ts @@ -67,4 +67,21 @@ describe('reticulumProxyRateLimitBackoff', () => { clearReticulumProxyRateLimitBackoff(); expect(isReticulumProxyRateLimitBackoffActive()).toBe(false); }); + + it('clamps jittered delay between DEFAULT and MAX backoff', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const now = 1_000_000; + // random=0 → factor 0.9; first hit base=5000 → 4500, clamped up to DEFAULT (5000) + vi.spyOn(Math, 'random').mockReturnValue(0); + const low = noteReticulumProxyRateLimitHit('shared', now); + expect(low).toBe(5_000); + resetReticulumProxyRateLimitBackoffForTests(); + // Drive hits to MAX base then jitter above MAX (factor 1.1) + vi.spyOn(Math, 'random').mockReturnValue(1); + let delay = 0; + for (let i = 0; i < 6; i++) { + delay = noteReticulumProxyRateLimitHit('shared', now); + } + expect(delay).toBe(60_000); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts index b1a073d82..7ebe5117a 100644 --- a/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts +++ b/src/renderer/lib/reticulum/reticulumProxyRateLimitBackoff.ts @@ -56,12 +56,12 @@ export function reticulumProxyRateLimitBackoffRemainingMs( /** Optional ±10% jitter so concurrent clients do not retry in lockstep. */ function applyJitter(delayMs: number): number { const factor = 0.9 + Math.random() * 0.2; - return Math.max(1, Math.round(delayMs * factor)); + return Math.round(delayMs * factor); } /** * Record a rate-limit error and arm exponential backoff so callers do not tight-loop. - * Returns the backoff duration applied (ms, after jitter). + * Returns the backoff duration applied (ms, after jitter, clamped to [DEFAULT, MAX]). */ export function noteReticulumProxyRateLimitHit( bucket: ReticulumProxyRateLimitBucket, @@ -70,7 +70,7 @@ export function noteReticulumProxyRateLimitHit( const state = buckets[bucket]; state.consecutiveHits = Math.min(state.consecutiveHits + 1, 6); const base = Math.min(DEFAULT_BACKOFF_MS * 2 ** (state.consecutiveHits - 1), MAX_BACKOFF_MS); - const delay = applyJitter(base); + const delay = Math.min(MAX_BACKOFF_MS, Math.max(DEFAULT_BACKOFF_MS, applyJitter(base))); state.backoffUntilMs = Math.max(state.backoffUntilMs, now + delay); console.warn( `[reticulumProxyRateLimit] bucket=${bucket} backoff ${delay}ms hits=${state.consecutiveHits} until=${new Date(state.backoffUntilMs).toISOString()}`, diff --git a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts index fc06c10e2..7200d3127 100644 --- a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts @@ -289,6 +289,15 @@ describe('useReticulumRuntime outbound delivery persistence', () => { ); }); + it('aborts link-timeout bridge after delayed hydrate when generation is stale', () => { + expect(SOURCE).toContain('linkTimeoutBridgeGenerationRef'); + expect(SOURCE).toMatch(/const bridgeGeneration = linkTimeoutBridgeGenerationRef\.current/); + expect(SOURCE).toContain('generation stale after hydrate'); + // Generation bumps on identity change, tearDown, and disconnect. + const bumps = SOURCE.match(/linkTimeoutBridgeGenerationRef\.current \+= 1/g) ?? []; + expect(bumps.length).toBeGreaterThanOrEqual(3); + }); + it('wires propagation store + sidecar health into Reticulum diagnostics', () => { expect(SOURCE).toMatch(/sidecarUnhealthySince:\s*sidecarStatus\.unhealthySince/); expect(SOURCE).toMatch(/useReticulumPropagationStore\.subscribe/); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 063679f6f..e4a205848 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -293,6 +293,8 @@ export function useReticulumRuntime(): ProtocolRuntime { const processedLinkTimeoutDestsRef = useRef(new Set()); /** Defer link-timeout failure bridge until first propagation store refresh completes. */ const propagationHydratedForBridgeRef = useRef(false); + /** Bumped on identity change / tearDown / disconnect to abort stale bridge IIFEs. */ + const linkTimeoutBridgeGenerationRef = useRef(0); const identityIdRef = useRef(identityId); const nodeStoreSlice = useNodeStore((s) => (identityId ? s.nodes[identityId] : undefined)); @@ -317,6 +319,7 @@ export function useReticulumRuntime(): ProtocolRuntime { useEffect(() => { processedLinkTimeoutDestsRef.current.clear(); propagationHydratedForBridgeRef.current = false; + linkTimeoutBridgeGenerationRef.current += 1; }, [identityId]); const selfNodeId = useMemo( @@ -1476,6 +1479,7 @@ export function useReticulumRuntime(): ProtocolRuntime { clearReticulumSessionStores(); processedLinkTimeoutDestsRef.current.clear(); propagationHydratedForBridgeRef.current = false; + linkTimeoutBridgeGenerationRef.current += 1; setReticulumBleBondDesyncActive(false); setReticulumAnnounceBusPressureActive(false); setState(INITIAL_STATE); @@ -1499,6 +1503,7 @@ export function useReticulumRuntime(): ProtocolRuntime { const timeouts = status.interfaceIssueAlert?.linkDeliveryTimeouts; if (identityId && timeouts?.length) { const bridgeIdentityId = identityId; + const bridgeGeneration = linkTimeoutBridgeGenerationRef.current; void (async () => { if (!propagationHydratedForBridgeRef.current) { const stampBefore = useReticulumPropagationStore.getState().lastRefreshedAt; @@ -1510,7 +1515,15 @@ export function useReticulumRuntime(): ProtocolRuntime { errLikeToLogString(e), ); } - if (identityIdRef.current !== bridgeIdentityId) return; + if ( + identityIdRef.current !== bridgeIdentityId || + linkTimeoutBridgeGenerationRef.current !== bridgeGeneration + ) { + console.debug( + '[useReticulumRuntime] link-timeout bridge abort — generation stale after hydrate', + ); + return; + } const stampAfter = useReticulumPropagationStore.getState().lastRefreshedAt; const hydratedOk = stampAfter != null && stampAfter !== stampBefore; if (!hydratedOk) { @@ -1521,7 +1534,12 @@ export function useReticulumRuntime(): ProtocolRuntime { } propagationHydratedForBridgeRef.current = true; } - if (identityIdRef.current !== bridgeIdentityId) return; + if ( + identityIdRef.current !== bridgeIdentityId || + linkTimeoutBridgeGenerationRef.current !== bridgeGeneration + ) { + return; + } const propState = useReticulumPropagationStore.getState(); // Empty + no preferred + never refreshed: cascade capacity unknown — do not fail DMs. if ( @@ -1542,7 +1560,12 @@ export function useReticulumRuntime(): ProtocolRuntime { `[useReticulumRuntime] link-timeout bridge apply=${applyBridge} preferred=${propState.preferredId ?? 'none'} nodes=${propState.nodes.length}`, ); for (const { destinationHash } of timeouts) { - if (identityIdRef.current !== bridgeIdentityId) return; + if ( + identityIdRef.current !== bridgeIdentityId || + linkTimeoutBridgeGenerationRef.current !== bridgeGeneration + ) { + return; + } const norm = destinationHash.replace(/[^0-9a-f]/gi, '').toLowerCase(); if (!norm || processedLinkTimeoutDestsRef.current.has(norm)) continue; // PN cascade (remote or local-prop): sidecar owns outcome via WS. @@ -1738,6 +1761,7 @@ export function useReticulumRuntime(): ProtocolRuntime { clearReticulumSessionStores(); processedLinkTimeoutDestsRef.current.clear(); propagationHydratedForBridgeRef.current = false; + linkTimeoutBridgeGenerationRef.current += 1; setReticulumBleBondDesyncActive(false); setReticulumAnnounceBusPressureActive(false); setState(INITIAL_STATE);