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..f71467c4a 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts @@ -7,9 +7,16 @@ import type { MeshCoreConnection, RxPacketEntry, } from '@/renderer/lib/meshcore/meshcoreHookTypes'; -import { resetMeshcoreWaitingMessagesDrainState } from '@/renderer/lib/meshcoreWaitingMessagesDrain'; +import { + beginMeshcoreSilentBulkAttempt, + 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'; @@ -17,6 +24,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 +163,9 @@ describe('attachMeshcoreConnSideEffects', () => { beforeEach(() => { resetMeshcoreWaitingMessagesDrainState(0); + setMeshcoreProcessWaitingMessagesInFlight(null); + clearMeshcoreWaitingMessagesFollowUp(); + resetMeshcoreWaitingMessagesSilentFollowUpChain(); useNodeStore.setState({ nodes: {} }); useMessageStore.setState({ messages: {} }); }); @@ -337,17 +352,235 @@ 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' + ? MESHCORE_WAITING_MESSAGES_SERIAL_SILENT_TIMEOUT_MS + : MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS, + ); + 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 flush silent bulk when unmounted during ingest await', async () => { + vi.useFakeTimers(); + const h = makeHarness(); + vi.mocked(h.conn.getWaitingMessages).mockResolvedValue([ + { + channelMessage: { + channelIdx: 0, + text: 'UnmountPeer: queued', + senderTimestamp: 1_700_000_000, + }, + }, + ]); + vi.mocked(h.ctx.setWaitingMessagesSyncProgress).mockImplementation(() => { + h.ctx.meshcoreHookMountedRef.current = false; + }); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + await h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: false }); + await vi.runAllTimersAsync(); + + expect(h.ctx.addMessagesBatch).not.toHaveBeenCalled(); + }); + + it('does not flush silent bulk when superseded during ingest await', async () => { + vi.useFakeTimers(); + const h = makeHarness(); + vi.mocked(h.conn.getWaitingMessages).mockResolvedValue([ + { + channelMessage: { + channelIdx: 0, + text: 'StalePeer: queued', + senderTimestamp: 1_700_000_000, + }, + }, + ]); + vi.mocked(h.ctx.setWaitingMessagesSyncProgress).mockImplementation(() => { + beginMeshcoreSilentBulkAttempt(); + }); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + await h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: false }); + await vi.runAllTimersAsync(); + + expect(h.ctx.addMessagesBatch).not.toHaveBeenCalled(); + }); + + it('does not fallback after silent bulk timeout when lifecycle reset superseded the attempt', async () => { + vi.useFakeTimers(); + const h = makeHarness(); + vi.mocked(h.conn.getWaitingMessages).mockImplementation( + () => new Promise(() => undefined), // hang until withTimeout + ); + h.syncNextMessage.mockResolvedValue(null); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + const drainPromise = h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: false }); + await Promise.resolve(); + resetMeshcoreWaitingMessagesDrainState(0); + await vi.advanceTimersByTimeAsync(MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS); + await vi.runAllTimersAsync(); + await drainPromise; + + expect(h.syncNextMessage).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 +675,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..1f828cfde 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; @@ -279,16 +294,110 @@ async function drainWaitingMessagesSilent( const item = normalizeMeshcoreWaitingMessageItem(raw); if (!item) break; await ingestMeshcoreWaitingMessageItem(item, state, deps); + // Re-check after await — unmount during ingest must not flush / chain follow-ups. + if (!deps.meshcoreHookMountedRef.current) return; if (i === MESHCORE_SYNC_NEXT_MESSAGE_MAX_PER_DRAIN - 1) { silentDrainExhaustedCap = true; } } + if (!deps.meshcoreHookMountedRef.current) return; if (silentDrainExhaustedCap) { requestMeshcoreWaitingMessagesFollowUp(); } 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) return; + if (!isMeshcoreSilentBulkAttemptCurrent(attemptId)) return; + await ingestMeshcoreWaitingMessageItem(m, state, deps); + // Re-check after await — unmount/supersession must not flush partial state. + if (!deps.meshcoreHookMountedRef.current) return; + if (!isMeshcoreSilentBulkAttemptCurrent(attemptId)) return; + if ( + state.processed % MESHCORE_WAITING_MESSAGES_BATCH_YIELD === 0 || + state.pendingMessages.length >= MESHCORE_WAITING_MESSAGES_BATCH_YIELD + ) { + flushMeshcoreWaitingState(state, deps); + } + } + if (!deps.meshcoreHookMountedRef.current) return; + if (!isMeshcoreSilentBulkAttemptCurrent(attemptId)) return; + flushMeshcoreWaitingState(state, deps); + return; + } catch (e: unknown) { + if (isMeshcoreWaitingMessagesTransportDeadError(e)) { + // Transport dead — reconnect owns link; do not fallback or disconnect here. + logMeshcoreWaitingMessagesDrainError('silent bulk transport dead', e, false); + return; + } + if ( + isMeshcoreWaitingMessagesBulkFallbackError(e) || + isMeshcoreGetWaitingMessagesTimeoutError(e) + ) { + // Lifecycle reset / a newer drain may have already superseded this attempt. + const stillOwner = isMeshcoreSilentBulkAttemptCurrent(attemptId); + // Abandon bulk ownership so a late getWaitingMessages resolve cannot ingest. + abandonMeshcoreSilentBulkAttempt(attemptId); + if (!stillOwner || !deps.meshcoreHookMountedRef.current) return; + 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', + }), + ); + if (!deps.meshcoreHookMountedRef.current) return; + 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 +412,7 @@ async function runMeshcoreWaitingMessagesDrain( const state: MeshcoreWaitingMessagesDrainState = { processed: 0, bannerActive: false, + progressActive: false, syncTotal: 0, pendingMessages: [], dirtyNodeIds: new Set(), @@ -319,11 +429,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 +453,8 @@ async function runMeshcoreWaitingMessagesDrain( deps.setWaitingMessagesCount(0); deps.setWaitingMessagesSyncActive(false); deps.setWaitingMessagesSyncProgress(null); + } else if (state.progressActive) { + deps.setWaitingMessagesSyncProgress(null); } } } @@ -644,7 +767,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..1c328bff3 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,62 @@ 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); + }); + + 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); + }); + + it('does not recycle silent bulk attempt ids across lifecycle reset', () => { + resetMeshcoreWaitingMessagesDrainState(0); + const oldId = beginMeshcoreSilentBulkAttempt(); + expect(isMeshcoreSilentBulkAttemptCurrent(oldId)).toBe(true); + resetMeshcoreWaitingMessagesDrainState(0); + expect(isMeshcoreSilentBulkAttemptCurrent(oldId)).toBe(false); + const newId = beginMeshcoreSilentBulkAttempt(); + expect(newId).not.toBe(oldId); + expect(isMeshcoreSilentBulkAttemptCurrent(oldId)).toBe(false); + expect(isMeshcoreSilentBulkAttemptCurrent(newId)).toBe(true); + }); +}); + describe('shouldRunMeshcoreWaitingMessagesPeriodicPoll', () => { beforeEach(() => { resetMeshcoreWaitingMessagesDrainState(0); diff --git a/src/renderer/lib/meshcoreWaitingMessagesDrain.ts b/src/renderer/lib/meshcoreWaitingMessagesDrain.ts index 42b46722d..8e1e0341a 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,13 +17,19 @@ 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 { lastCompanionTxAt = Date.now(); } -/** Test hook — reset module state between unit tests. */ +/** + * Test hook — reset debounce/TX stamps between unit tests. + * Invalidates in-flight silent bulk attempts by bumping the monotonic counter (never + * recycles a prior id back to 0, so late pre-reset results stay stale). + */ export function resetMeshcoreWaitingMessagesDrainState(now = 0): void { if (debounceTimer) { clearTimeout(debounceTimer); @@ -30,6 +37,25 @@ export function resetMeshcoreWaitingMessagesDrainState(now = 0): void { } lastCompanionTxAt = now; lastMsgWaitingEventAt = now; + silentBulkAttemptId += 1; +} + +/** 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 +78,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..7dad315f9 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()', softApFirstRpcIdx); - const softApAcceptIdx = RUNTIME_SOURCE.indexOf( - 'setMeshcoreTcpSoftApDeadAccepted(true)', - softApFirstRpcIdx, + const notifyIdx = RUNTIME_SOURCE.indexOf( + 'notifyMeshcoreTcpLiveForUserTx()', + openHopFirstRpcIdx, ); - const disconnectIdx = RUNTIME_SOURCE.indexOf('meshcore.tcp.disconnect()', softApFirstRpcIdx); - expect(pendingIdx).toBeGreaterThan(softApFirstRpcIdx); + const openHopAcceptIdx = RUNTIME_SOURCE.indexOf( + 'setMeshcoreTcpOpenHopDeadAccepted(true)', + openHopFirstRpcIdx, + ); + 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'/); }); }); @@ -622,6 +627,22 @@ describe('useMeshcoreRuntime manual disconnect must not auto-reconnect', () => { ); }); + it('defers post-connect self telemetry once when waiting-message drain is busy', () => { + expect(RUNTIME_SOURCE).toContain('schedulePostConnectSelfTelemetry'); + expect(RUNTIME_SOURCE).toContain( + 'post-connect self telemetry deferred (waiting-message drain busy)', + ); + expect(RUNTIME_SOURCE).toContain( + 'post-connect self telemetry skipped (waiting-message drain still busy)', + ); + expect(RUNTIME_SOURCE).toMatch( + /schedulePostConnectSelfTelemetry = \(allowReschedule: boolean\)[\s\S]*?waitingMessagesDrainBusyRef\.current[\s\S]*?schedulePostConnectSelfTelemetry\(false\)/, + ); + expect(RUNTIME_SOURCE).toMatch( + /if \(waitingMessagesDrainBusyRef\.current\) \{[\s\S]*?schedulePostConnectSelfTelemetry\(false\);[\s\S]*?\} else \{[\s\S]*?schedulePostConnectSelfTelemetry\(true\);/, + ); + }); + it('onPowerResume skips reconnect after explicit user disconnect', () => { assertPowerResumeSkipsOnExplicitDisconnect( RUNTIME_SOURCE, @@ -638,8 +659,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 +712,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..25caaf78b 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 (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,46 @@ export function useMeshcoreRuntime() { '[useMeshcoreRuntime] post-connect refreshOurPosition ' + errLikeToLogString(e), ); }); - void requestTelemetryMeshCoreRef.current(myNodeId).catch((e: unknown) => { - if (isMeshcoreTcpTransportDeadError(e) || isMeshcoreSetupAbortError(e)) return; + // Give MsgWaiting drain a head start; if the lane is still busy, defer once + // more after the same window (do not drop telemetry on first busy sighting). + const postConnectTelemetryDelayMs = + MESHCORE_WAITING_MESSAGES_DRAIN_DEBOUNCE_MS + + MESHCORE_WAITING_MESSAGES_AFTER_TX_DEFER_MS; + const schedulePostConnectSelfTelemetry = (allowReschedule: boolean): void => { + window.setTimeout(() => { + if (meshcoreSetupGenerationRef.current !== setupGen || connRef.current !== conn) { + return; + } + if (waitingMessagesDrainBusyRef.current) { + if (allowReschedule) { + console.debug( + '[useMeshcoreRuntime] post-connect self telemetry deferred (waiting-message drain busy)', + ); + schedulePostConnectSelfTelemetry(false); + return; + } + console.debug( + '[useMeshcoreRuntime] post-connect self telemetry skipped (waiting-message drain still 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), + ); + }); + }, postConnectTelemetryDelayMs); + }; + if (waitingMessagesDrainBusyRef.current) { console.debug( - '[useMeshcoreRuntime] post-connect self telemetry (altitude) ' + - errLikeToLogString(e), + '[useMeshcoreRuntime] post-connect self telemetry deferred (waiting-message drain busy)', ); - }); + schedulePostConnectSelfTelemetry(false); + } else { + schedulePostConnectSelfTelemetry(true); + } }); }); @@ -2981,10 +3023,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 +3039,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 +3128,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 +3145,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 +3504,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 +3522,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 +3727,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 +3739,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 +3911,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 +3966,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 +4056,7 @@ export function useMeshcoreRuntime() { [prepareRfConnect, attachRfSession, handleRfConnectFailure], ); useLayoutEffect(() => { - meshcoreConnectForSoftApTxRef.current = connect; + meshcoreConnectForOpenHopTxRef.current = connect; }, [connect]); /** @@ -4300,7 +4342,7 @@ export function useMeshcoreRuntime() { try { const hadRadioConn = connRef.current != null || - isMeshcoreTcpSoftApDeadAccepted() || + isMeshcoreTcpOpenHopDeadAccepted() || meshcoreTcpBridgeDeadRef.current; if (hadRadioConn) { await runMeshcoreUserTxWithLiveTcp(async () => { @@ -4308,8 +4350,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 +6749,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 +6782,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 +6820,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 +7089,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 +7132,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 +7165,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 +7534,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 +7796,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 +7812,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 +7831,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,