diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 1c6828079..e4cdc7ae1 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -72,7 +72,7 @@ const LXMF_EGRESS_TAP_SETTLE_MS: u64 = 1500; /// Cap blocking transport control queries so HTTP handlers return cached state /// before the Electron IPC proxy GET timeout (10s default). -const TRANSPORT_QUERY_TIMEOUT: Duration = Duration::from_secs(8); +const TRANSPORT_QUERY_TIMEOUT: Duration = Duration::from_secs(20); /// Aspect Nomad Network nodes announce and serve page/file requests under. const NOMAD_NODE_ASPECT: &str = "nomadnetwork.node"; @@ -3400,9 +3400,9 @@ fn rate_limited_inbound_lxmf_warn(from: &str, message_hash: &str) { } /// Cap membership growth event payloads under path-table floods. -const MAX_PEERS_UPDATED_ADDED: usize = 1024; +const MAX_PEERS_UPDATED_ADDED: usize = 4096; /// Bound announce / contact display-name labels independently of the live path table. -const MAX_DISPLAY_NAME_CACHE: usize = 50_000; +const MAX_DISPLAY_NAME_CACHE: usize = 100_000; /// Serve HTTP peer list from the maintenance snapshot when newer than this. const PATH_PEER_CACHE_TTL: Duration = Duration::from_secs(2); diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 2bb28c0f3..0c61415c1 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -2514,8 +2514,8 @@ fn enumerate_serial_ports() -> Vec { } /// Hard ceiling on peer rows returned / persisted after a live path-table sync. -/// Matches the renderer destination cap (`50_000` / `MAX_MESH_ENTITY_CAP` floor). -const MAX_PEER_CACHE: usize = 50_000; +/// Matches the renderer `MAX_MESH_ENTITY_CAP` (100_000). +const MAX_PEER_CACHE: usize = 100_000; /// Cap on peers retained after leaving the live path table (e.g. Clear Contacts demotions). const MAX_ORPHAN_PEERS: usize = 5_000; /// Drop orphaned peers with `last_seen` older than this (Unix seconds). Missing diff --git a/src/main/ipc/reticulum-handlers.ts b/src/main/ipc/reticulum-handlers.ts index 5adb18cc5..1ddec7dc5 100644 --- a/src/main/ipc/reticulum-handlers.ts +++ b/src/main/ipc/reticulum-handlers.ts @@ -33,7 +33,7 @@ import { assertIpcSender } from '../validate-ipc-sender'; /** Shared rolling window for all reticulum proxy verbs (Get/Post/Put/Delete). */ const reticulumProxyIpcRateLimit = createIpcRateLimiter({ - max: 120, + max: 300, windowMs: MS_PER_MINUTE, label: 'reticulum:proxy', }); @@ -52,7 +52,8 @@ function isExpectedReticulumProxyError(message: string): boolean { message.includes('404') || lower.includes('fetch failed') || lower.includes('aborted') || - lower.includes('timeout') + lower.includes('timeout') || + lower.includes('rate limit exceeded') ); } diff --git a/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts b/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts new file mode 100644 index 000000000..b2ba0f8d2 --- /dev/null +++ b/src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment node +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; + +const HANDLERS_SOURCE = readFileSync(join(__dirname, 'reticulum-handlers.ts'), 'utf-8'); +const SIDECAR_STACK_SOURCE = readFileSync( + join(__dirname, '../../../reticulum-sidecar/src/stack/mod.rs'), + 'utf-8', +); +const SIDECAR_LIVE_SOURCE = readFileSync( + join(__dirname, '../../../reticulum-sidecar/src/stack/live.rs'), + 'utf-8', +); + +describe('reticulum proxy rate limit + 100k peer ceilings (source contract)', () => { + it('caps shared proxy IPC at 300/min and treats rate-limit as expected', () => { + expect(HANDLERS_SOURCE).toMatch(/max:\s*300/); + expect(HANDLERS_SOURCE).toContain("label: 'reticulum:proxy'"); + expect(HANDLERS_SOURCE).toContain("lower.includes('rate limit exceeded')"); + }); + + it('aligns sidecar peer cache and WS added batch with ~100k scale', () => { + expect(SIDECAR_STACK_SOURCE).toMatch(/const MAX_PEER_CACHE: usize = 100_000;/); + expect(SIDECAR_LIVE_SOURCE).toMatch(/const MAX_PEERS_UPDATED_ADDED: usize = 4096;/); + expect(SIDECAR_LIVE_SOURCE).toMatch(/const MAX_DISPLAY_NAME_CACHE: usize = 100_000;/); + expect(SIDECAR_LIVE_SOURCE).toMatch( + /const TRANSPORT_QUERY_TIMEOUT: Duration = Duration::from_secs\(20\);/, + ); + }); +}); diff --git a/src/renderer/components/AppPanel.tsx b/src/renderer/components/AppPanel.tsx index 76928334f..12ac583a3 100644 --- a/src/renderer/components/AppPanel.tsx +++ b/src/renderer/components/AppPanel.tsx @@ -1387,12 +1387,12 @@ export default function AppPanel({ id="apppanel-reticulum-destination-cap-count" type="number" min={1} - max={50000} + max={100000} value={settings.reticulumDestinationCapCount} onChange={(e) => { updateSetting( 'reticulumDestinationCapCount', - Math.max(1, Math.min(50000, parseInt(e.target.value) || 1)), + Math.max(1, Math.min(100000, parseInt(e.target.value) || 1)), ); }} disabled={!settings.reticulumDestinationCapEnabled} diff --git a/src/renderer/components/ReticulumNetworkPanel.tsx b/src/renderer/components/ReticulumNetworkPanel.tsx index 1636e60a0..2384957aa 100644 --- a/src/renderer/components/ReticulumNetworkPanel.tsx +++ b/src/renderer/components/ReticulumNetworkPanel.tsx @@ -569,7 +569,9 @@ export function ReticulumNetworkPanel({ last_heard: Math.floor(Date.now() / 1000), }); addToast(t('qrIngest.contactImported'), 'success'); - void refreshReticulumPeersFromSidecar({ forceRefresh: true }); + void refreshReticulumPeersFromSidecar({ forceRefresh: true }).catch(() => { + // catch-no-log-ok rate-limit rethrow from peer store — already debug-logged + }); } catch (err) { console.error( '[ReticulumNetworkPanel] QR contact upsert failed: ' + diff --git a/src/renderer/components/ReticulumPeerListPanel.tsx b/src/renderer/components/ReticulumPeerListPanel.tsx index a3ef845b5..399585e23 100644 --- a/src/renderer/components/ReticulumPeerListPanel.tsx +++ b/src/renderer/components/ReticulumPeerListPanel.tsx @@ -223,8 +223,8 @@ export default function ReticulumPeerListPanel({ }: ReticulumPeerListPanelProps) { const { t } = useTranslation(); const { addToast } = useToast(); - const peers = useReticulumPeerStore((s) => s.peers); const peersRevision = useReticulumPeerStore((s) => s.peersRevision); + const peersSize = useReticulumPeerStore((s) => s.peers.size); const contacts = useReticulumPeerStore((s) => s.contacts); const peerAppearanceByHash = useReticulumPeerStore((s) => s.peerAppearanceByHash); const isContact = useReticulumPeerStore((s) => s.isContact); @@ -350,6 +350,7 @@ export default function ReticulumPeerListPanel({ const gen = ++sortedRowsPrepGenRef.current; const run = () => { if (gen !== sortedRowsPrepGenRef.current) return; + const peers = useReticulumPeerStore.getState().peers; const sourceRows = buildSourcePeerRows( activeTab, peers, @@ -370,12 +371,13 @@ export default function ReticulumPeerListPanel({ }; const approxCount = activeTab === 'peers' - ? peers.size + ? peersSize : activeTab === 'contacts' ? contacts.size - : peers.size + contacts.size; - // Debounce large-list rebuilds under patch storms; keep small lists snappy. - const debounceMs = approxCount > RETICULUM_PEER_VIRTUALIZE_THRESHOLD ? 120 : 0; + : peersSize + contacts.size; + // Debounce large-list rebuilds under patch storms; stretch further at mega-mesh. + const debounceMs = + approxCount > 10_000 ? 400 : approxCount > RETICULUM_PEER_VIRTUALIZE_THRESHOLD ? 250 : 0; let timer: ReturnType | null = null; if (debounceMs > 0) { timer = setTimeout(() => { @@ -387,14 +389,14 @@ export default function ReticulumPeerListPanel({ return () => { if (timer != null) clearTimeout(timer); }; - // peersRevision ensures Map identity churn still recomputes when patches flush. + // peersRevision (not Map identity) drives rebuilds when patches flush. }, [ activeTab, contacts, debouncedSearchQuery, groupMemberIds, - peers, peersRevision, + peersSize, resolvePeerLabel, selectedGroupId, sortDir, @@ -447,9 +449,7 @@ export default function ReticulumPeerListPanel({ const result = await requestReticulumPeerPath(hash); const toast = formatReticulumPeerPathToast(t, result); addToast(toast.message, toast.variant); - if (result.ok) { - await refreshReticulumPeersFromSidecar({ forceRefresh: true }); - } + // Path results arrive via WS peers_updated patches — avoid a full dump. } catch (e) { console.warn('[ReticulumPeerListPanel] path ' + errLikeToLogString(e)); } finally { @@ -470,9 +470,7 @@ export default function ReticulumPeerListPanel({ if (result.ok && result.hops != null) { useReticulumPeerStore.getState().updatePeer(hash, { hops: result.hops }); } - if (result.ok) { - await refreshReticulumPeersFromSidecar({ forceRefresh: true }); - } + // Probe hops applied locally; skip full path-table refresh. } catch (e) { console.warn('[ReticulumPeerListPanel] probe ' + errLikeToLogString(e)); } finally { diff --git a/src/renderer/hooks/useReticulumDmPathProbe.test.ts b/src/renderer/hooks/useReticulumDmPathProbe.test.ts index 5802df240..041e036a4 100644 --- a/src/renderer/hooks/useReticulumDmPathProbe.test.ts +++ b/src/renderer/hooks/useReticulumDmPathProbe.test.ts @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const probeReticulumPeerMock = vi.fn(); const updatePeerMock = vi.fn(); -const refreshPeersMock = vi.fn(); vi.mock('@/renderer/lib/reticulum/reticulumSidecarReads', () => ({ probeReticulumPeer: (...args: unknown[]) => probeReticulumPeerMock(...args), @@ -15,7 +14,6 @@ vi.mock('@/renderer/stores/reticulumPeerStore', () => ({ updatePeer: (...args: unknown[]) => updatePeerMock(...args), }), }, - refreshReticulumPeersFromSidecar: (...args: unknown[]) => refreshPeersMock(...args), })); import { useReticulumDmPathProbe } from './useReticulumDmPathProbe'; @@ -24,8 +22,6 @@ describe('useReticulumDmPathProbe', () => { beforeEach(() => { probeReticulumPeerMock.mockReset(); updatePeerMock.mockReset(); - refreshPeersMock.mockReset(); - refreshPeersMock.mockResolvedValue([]); }); it('probes when enabled and destination hash is set', async () => { @@ -44,7 +40,6 @@ describe('useReticulumDmPathProbe', () => { expect(result.current.hops).toBe(2); expect(probeReticulumPeerMock).toHaveBeenCalledWith('aabbccddeeff00112233445566778899'); expect(updatePeerMock).toHaveBeenCalledWith('aabbccddeeff00112233445566778899', { hops: 2 }); - expect(refreshPeersMock).toHaveBeenCalled(); }); it('reprobe re-runs the sidecar probe', async () => { @@ -147,7 +142,6 @@ describe('useReticulumDmPathProbe', () => { expect(result.current.status).toBe('unreachable'); }); expect(result.current.hops).toBeNull(); - expect(refreshPeersMock).not.toHaveBeenCalled(); }); it('reprobe forces probing even when passive hops are known', async () => { diff --git a/src/renderer/hooks/useReticulumDmPathProbe.ts b/src/renderer/hooks/useReticulumDmPathProbe.ts index 7c987f87f..12d68da93 100644 --- a/src/renderer/hooks/useReticulumDmPathProbe.ts +++ b/src/renderer/hooks/useReticulumDmPathProbe.ts @@ -8,10 +8,7 @@ import { seedReticulumDmPathStatus, } from '@/renderer/lib/reticulum/reticulumDmPathReachability'; import { probeReticulumPeer } from '@/renderer/lib/reticulum/reticulumSidecarReads'; -import { - refreshReticulumPeersFromSidecar, - useReticulumPeerStore, -} from '@/renderer/stores/reticulumPeerStore'; +import { useReticulumPeerStore } from '@/renderer/stores/reticulumPeerStore'; export interface UseReticulumDmPathProbeArgs { /** When false, resets to idle and does not probe. */ @@ -87,11 +84,8 @@ export function useReticulumDmPathProbe({ setStatus(reticulumDmPathStatusFromProbe(result.ok)); const nextHops = result.hops ?? null; setHops(nextHops); - if (result.ok) { - if (nextHops != null) { - useReticulumPeerStore.getState().updatePeer(destinationHash, { hops: nextHops }); - } - void refreshReticulumPeersFromSidecar(); + if (result.ok && nextHops != null) { + useReticulumPeerStore.getState().updatePeer(destinationHash, { hops: nextHops }); } } catch (e) { // Failure point: probe IPC reject. Fallback: treat as unreachable. diff --git a/src/renderer/lib/defaultAppSettings.ts b/src/renderer/lib/defaultAppSettings.ts index 22f6ca6e5..5a7bdd75d 100644 --- a/src/renderer/lib/defaultAppSettings.ts +++ b/src/renderer/lib/defaultAppSettings.ts @@ -19,7 +19,7 @@ export const DEFAULT_APP_SETTINGS_SHARED = { reticulumAutoPruneEnabled: true, reticulumAutoPruneDays: 30, reticulumDestinationCapEnabled: true, - reticulumDestinationCapCount: 10000, + reticulumDestinationCapCount: 50000, distanceFilterEnabled: false, distanceFilterMax: 500, distanceUnit: 'miles' as const, diff --git a/src/renderer/lib/reticulum/reticulumSidecarPeerRefreshEvents.test.ts b/src/renderer/lib/reticulum/reticulumSidecarPeerRefreshEvents.test.ts index a5c67254b..d10074350 100644 --- a/src/renderer/lib/reticulum/reticulumSidecarPeerRefreshEvents.test.ts +++ b/src/renderer/lib/reticulum/reticulumSidecarPeerRefreshEvents.test.ts @@ -73,10 +73,13 @@ describe('peersUpdatedRequiresFullRefresh', () => { expect(peersUpdatedRequiresFullRefresh({ added: ['aa'], count: 1 })).toBe(false); }); - it('returns true for clear / demote / probe payloads', () => { + it('returns true for clear / demote payloads', () => { expect(peersUpdatedRequiresFullRefresh({ cleared: true })).toBe(true); expect(peersUpdatedRequiresFullRefresh({ demoted_from_contacts: 3 })).toBe(true); - expect(peersUpdatedRequiresFullRefresh({ hash: 'aabb' })).toBe(true); + }); + + it('returns false for probe / path-request single-hash payloads', () => { + expect(peersUpdatedRequiresFullRefresh({ hash: 'aabb' })).toBe(false); }); }); diff --git a/src/renderer/lib/reticulum/reticulumSidecarPeerRefreshEvents.ts b/src/renderer/lib/reticulum/reticulumSidecarPeerRefreshEvents.ts index bc546c81d..b14152f1b 100644 --- a/src/renderer/lib/reticulum/reticulumSidecarPeerRefreshEvents.ts +++ b/src/renderer/lib/reticulum/reticulumSidecarPeerRefreshEvents.ts @@ -98,7 +98,7 @@ export function peersUpdatedRequiresFullRefresh(payload: unknown): boolean { if (typeof p.demoted_from_contacts === 'number') return true; if (Array.isArray(p.patches) && p.patches.length > 0) return false; if (Array.isArray(p.added) && p.added.length > 0) return false; - // Probe / path-request single-hash events — rare; reconcile with a full dump. - if (typeof p.hash === 'string' && p.hash.trim()) return true; + // Probe / path-request single-hash events — apply incrementally (no full dump). + if (typeof p.hash === 'string' && p.hash.trim()) return false; return true; } diff --git a/src/renderer/lib/reticulum/reticulumSidecarReads.test.ts b/src/renderer/lib/reticulum/reticulumSidecarReads.test.ts index 2be90a140..7212a78cc 100644 --- a/src/renderer/lib/reticulum/reticulumSidecarReads.test.ts +++ b/src/renderer/lib/reticulum/reticulumSidecarReads.test.ts @@ -19,9 +19,14 @@ import { fetchReticulumIdentityStatus, fetchReticulumInterfaces, fetchReticulumRmapDiscovered, + fetchReticulumSerialPortOptions, + fetchReticulumSerialPorts, formatReticulumPeerProbeToast, + invalidateReticulumInterfacesCache, isReticulumSidecar404Error, + isReticulumSidecarExpectedProxyError, isReticulumSidecarNotRunningError, + isReticulumSidecarRateLimitError, isReticulumSidecarRunning, pingReticulumDestination, probeReticulumPeer, @@ -33,6 +38,7 @@ describe('reticulumSidecarReads', () => { getStatus.mockReset(); proxyGet.mockReset(); proxyPost.mockReset(); + invalidateReticulumInterfacesCache(); }); it('isReticulumSidecarRunning returns true when sidecar reports running with port', async () => { @@ -45,13 +51,74 @@ describe('reticulumSidecarReads', () => { await expect(isReticulumSidecarRunning()).resolves.toBe(false); }); - it('classifies not-running and 404 proxy errors', () => { + it('classifies not-running, 404, and rate-limit proxy errors', () => { expect(isReticulumSidecarNotRunningError(new Error('Reticulum sidecar is not running'))).toBe( true, ); expect(isReticulumSidecar404Error(new Error('sidecar GET /api/v1/topology failed: 404'))).toBe( true, ); + expect( + isReticulumSidecarRateLimitError(new Error('reticulum:proxy: rate limit exceeded')), + ).toBe(true); + expect( + isReticulumSidecarExpectedProxyError(new Error('reticulum:proxy: rate limit exceeded')), + ).toBe(true); + }); + + it('fetchReticulumInterfaces rethrows rate-limit only when propagateRateLimit is set', async () => { + getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); + proxyGet.mockResolvedValueOnce({ + interfaces: [{ id: '1', name: 'tcp', type: 'tcp', enabled: true, status: 'up' }], + }); + await expect(fetchReticulumInterfaces()).resolves.toHaveLength(1); + expect(proxyGet).toHaveBeenCalledTimes(1); + + proxyGet.mockRejectedValue(new Error('reticulum:proxy: rate limit exceeded')); + await expect(fetchReticulumInterfaces()).resolves.toHaveLength(1); + await expect(fetchReticulumInterfaces()).resolves.toHaveLength(1); + // Cache TTL still warm — no extra proxyGet after the seed call. + expect(proxyGet).toHaveBeenCalledTimes(1); + + invalidateReticulumInterfacesCache(); + await expect(fetchReticulumInterfaces()).resolves.toHaveLength(1); + expect(proxyGet).toHaveBeenCalledTimes(2); + await expect(fetchReticulumInterfaces({ propagateRateLimit: true })).rejects.toThrow( + 'rate limit exceeded', + ); + expect(proxyGet).toHaveBeenCalledTimes(3); + }); + + it('fetchReticulumSerialPortOptions shares cache and rate-limit fallback with path helper', async () => { + getStatus.mockResolvedValue({ running: true, port: 1, pid: 1 }); + proxyGet.mockResolvedValueOnce({ + ports: [{ path: '/dev/ttyUSB0', label: 'USB' }], + }); + await expect(fetchReticulumSerialPortOptions()).resolves.toEqual([ + { path: '/dev/ttyUSB0', label: 'USB' }, + ]); + await expect(fetchReticulumSerialPorts()).resolves.toEqual(['/dev/ttyUSB0']); + expect(proxyGet).toHaveBeenCalledTimes(1); + + proxyGet.mockRejectedValue(new Error('reticulum:proxy: rate limit exceeded')); + await expect(fetchReticulumSerialPortOptions()).resolves.toEqual([ + { path: '/dev/ttyUSB0', label: 'USB' }, + ]); + await expect(fetchReticulumSerialPorts()).resolves.toEqual(['/dev/ttyUSB0']); + expect(proxyGet).toHaveBeenCalledTimes(1); + + invalidateReticulumInterfacesCache(); + await expect(fetchReticulumSerialPortOptions()).resolves.toEqual([ + { path: '/dev/ttyUSB0', label: 'USB' }, + ]); + expect(proxyGet).toHaveBeenCalledTimes(2); + await expect(fetchReticulumSerialPortOptions({ propagateRateLimit: true })).rejects.toThrow( + 'rate limit exceeded', + ); + await expect(fetchReticulumSerialPorts({ propagateRateLimit: true })).rejects.toThrow( + 'rate limit exceeded', + ); + expect(proxyGet).toHaveBeenCalledTimes(4); }); it('fetchReticulumIdentityStatus skips proxyGet when sidecar is down', async () => { diff --git a/src/renderer/lib/reticulum/reticulumSidecarReads.ts b/src/renderer/lib/reticulum/reticulumSidecarReads.ts index a05c8b3a4..914b46911 100644 --- a/src/renderer/lib/reticulum/reticulumSidecarReads.ts +++ b/src/renderer/lib/reticulum/reticulumSidecarReads.ts @@ -52,11 +52,16 @@ export function isReticulumSidecar404Error(err: unknown): boolean { return errLikeToLogString(err).includes('404'); } +export function isReticulumSidecarRateLimitError(err: unknown): boolean { + return errLikeToLogString(err).toLowerCase().includes('rate limit exceeded'); +} + export function isReticulumSidecarExpectedProxyError(err: unknown): boolean { const msg = errLikeToLogString(err).toLowerCase(); return ( isReticulumSidecarNotRunningError(err) || isReticulumSidecar404Error(err) || + isReticulumSidecarRateLimitError(err) || msg.includes('fetch failed') || msg.includes('aborted') ); @@ -69,41 +74,109 @@ export interface ReticulumSidecarInterfaceRow { enabled: boolean; status: string; serial_port?: string | null; + host?: string | null; + port?: number | null; + frequency?: number | null; + bandwidth?: number | null; + txpower?: number | null; + spreading_factor?: number | null; + coding_rate?: number | null; + callsign?: string | null; + preset?: string | null; + mode?: string | null; + seed_addresses?: string[]; + discoverable?: boolean | null; + latitude?: number | null; + longitude?: number | null; + height?: number | null; + discovery_name?: string | null; + announce_interval_min?: number | null; + connectable?: boolean | null; + reachable_on?: string | null; + network_name?: string | null; + passphrase?: string | null; + extra_config?: Record | null; +} + +export interface ReticulumSerialPortOption { + path: string; + label?: string; } const RETICULUM_INTERFACES_CACHE_MS = 5_000; let cachedReticulumInterfaces: ReticulumSidecarInterfaceRow[] = []; let cachedEffectivePrimaryLocalSerialInterfaceId: string | null = null; let cachedReticulumInterfacesAt = 0; +let cachedReticulumSerialPorts: ReticulumSerialPortOption[] = []; +let cachedReticulumSerialPortsAt = 0; export function invalidateReticulumInterfacesCache(): void { cachedReticulumInterfacesAt = 0; + cachedReticulumSerialPortsAt = 0; } export function getCachedReticulumEffectivePrimaryLocalSerialInterfaceId(): string | null { return cachedEffectivePrimaryLocalSerialInterfaceId; } -/** Fetch OS serial port paths from the sidecar (for local interface health checks). */ -export async function fetchReticulumSerialPorts(): Promise { +export interface FetchReticulumSidecarReadOpts { + /** + * When true, rate-limit errors are rethrown so pollers can back off. + * Default false: return cached rows (or []) so unguarded callers keep working. + */ + propagateRateLimit?: boolean; +} + +/** Fetch OS serial port options from the sidecar (shared cache with path-only helper). */ +export async function fetchReticulumSerialPortOptions( + opts?: FetchReticulumSidecarReadOpts, +): Promise { if (!(await isReticulumSidecarRunning())) { + cachedReticulumSerialPorts = []; + cachedReticulumSerialPortsAt = 0; return []; } + const now = Date.now(); + if ( + cachedReticulumSerialPorts.length > 0 && + now - cachedReticulumSerialPortsAt < RETICULUM_INTERFACES_CACHE_MS + ) { + return cachedReticulumSerialPorts; + } try { const body = (await window.electronAPI.reticulum.proxyGet('/api/v1/serial/ports')) as { - ports?: { path: string }[]; + ports?: ReticulumSerialPortOption[]; }; - return (body.ports ?? []).map((p) => p.path); + const ports = body.ports ?? []; + cachedReticulumSerialPorts = ports; + cachedReticulumSerialPortsAt = now; + return ports; } catch (e) { + if (opts?.propagateRateLimit && isReticulumSidecarRateLimitError(e)) { + throw e instanceof Error ? e : new Error(String(e)); + } if (!isReticulumSidecarExpectedProxyError(e)) { console.debug('[reticulumSidecarReads] serial ports ' + errLikeToLogString(e)); } + if (cachedReticulumSerialPorts.length > 0) { + return cachedReticulumSerialPorts; + } return []; } } -/** Fetch configured sidecar interfaces (shared by runtime and radio panel). */ -export async function fetchReticulumInterfaces(): Promise { +/** Fetch OS serial port paths from the sidecar (for local interface health checks). */ +export async function fetchReticulumSerialPorts( + opts?: FetchReticulumSidecarReadOpts, +): Promise { + const ports = await fetchReticulumSerialPortOptions(opts); + return ports.map((p) => p.path); +} + +/** Fetch configured sidecar interfaces (shared by runtime and Connection panel). */ +export async function fetchReticulumInterfaces( + opts?: FetchReticulumSidecarReadOpts, +): Promise { if (!(await isReticulumSidecarRunning())) { cachedReticulumInterfaces = []; cachedEffectivePrimaryLocalSerialInterfaceId = null; @@ -129,6 +202,9 @@ export async function fetchReticulumInterfaces(): Promise ({ scheduleReticulumLocalInterfaceBurst: vi.fn().mockReturnValue(() => {}), })); -vi.mock('@/renderer/lib/reticulum/reticulumSidecarReads', () => ({ - invalidateReticulumInterfacesCache: vi.fn(), -})); - describe('useReticulumInterfaceSnapshot', () => { beforeEach(() => { resetReticulumBleConnectGraceForTests(); + invalidateReticulumInterfacesCache(); + vi.mocked(window.electronAPI.reticulum.getStatus).mockResolvedValue({ + running: true, + port: 19437, + pid: 1, + healthy: true, + }); vi.mocked(window.electronAPI.reticulum.proxyGet).mockReset(); vi.mocked(window.electronAPI.reticulum.proxyGet).mockImplementation((path: string) => { if (path === '/api/v1/interfaces') { @@ -136,7 +140,14 @@ const BLE_RNODE_ROW = { describe('useReticulumInterfaceSnapshot Noble BLE yield', () => { beforeEach(() => { resetReticulumBleConnectGraceForTests(); + invalidateReticulumInterfacesCache(); vi.mocked(syncReticulumNobleBleYield).mockClear(); + vi.mocked(window.electronAPI.reticulum.getStatus).mockResolvedValue({ + running: true, + port: 19437, + pid: 1, + healthy: true, + }); vi.mocked(window.electronAPI.reticulum.proxyGet).mockImplementation((path: string) => { if (path === '/api/v1/interfaces') { return Promise.resolve({ interfaces: [BLE_RNODE_ROW] }); diff --git a/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.ts b/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.ts index 9a8e4fa49..d3297dbfc 100644 --- a/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.ts +++ b/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.ts @@ -13,9 +13,16 @@ import type { ReticulumLocalInterfaceHealthOptions } from '@/renderer/lib/reticu import { logReticulumLocalInterfaceHealthChanges } from '@/renderer/lib/reticulum/reticulumLocalInterfaceLogging'; import { pickReticulumLocalHealthPollMs, + RETICULUM_LOCAL_HEALTH_POLL_MS, scheduleReticulumLocalInterfaceBurst, } from '@/renderer/lib/reticulum/reticulumLocalInterfaceRefresh'; -import { invalidateReticulumInterfacesCache } from '@/renderer/lib/reticulum/reticulumSidecarReads'; +import { + fetchReticulumInterfaces, + fetchReticulumSerialPortOptions, + getCachedReticulumEffectivePrimaryLocalSerialInterfaceId, + invalidateReticulumInterfacesCache, + isReticulumSidecarRateLimitError, +} from '@/renderer/lib/reticulum/reticulumSidecarReads'; import type { ReticulumSidecarEvent } from '@/shared/reticulum-types'; export interface ReticulumInterfaceRow { @@ -103,30 +110,25 @@ export function useReticulumInterfaceSnapshot({ const refresh = useCallback(async () => { if (!sidecarApiReady) return undefined; try { - invalidateReticulumInterfacesCache(); - const [body, portsBody] = await Promise.all([ - window.electronAPI.reticulum.proxyGet('/api/v1/interfaces') as Promise<{ - interfaces?: ReticulumInterfaceRow[]; - effective_primary_local_serial_interface_id?: string | null; - }>, - window.electronAPI.reticulum.proxyGet('/api/v1/serial/ports') as Promise<{ - ports?: ReticulumSerialPortOption[]; - }>, + const [rows, ports] = await Promise.all([ + fetchReticulumInterfaces({ propagateRateLimit: true }), + fetchReticulumSerialPortOptions({ propagateRateLimit: true }), ]); - const rows = body.interfaces ?? []; - const ports = portsBody.ports ?? []; const paths = ports.map((p) => p.path); setInterfaces(rows); setSerialPorts(ports); setInterfacesHydrated(true); setEffectivePrimaryLocalSerialInterfaceId( - body.effective_primary_local_serial_interface_id ?? null, + getCachedReticulumEffectivePrimaryLocalSerialInterfaceId(), ); logReticulumLocalInterfaceHealthChanges(rows, paths); await syncReticulumBleRegistry(rows); return { interfaces: rows, paths }; } catch (e) { console.debug('[useReticulumInterfaceSnapshot] refresh ' + errLikeToLogString(e)); + if (isReticulumSidecarRateLimitError(e)) { + return { interfaces: [], paths: [], rateLimited: true as const }; + } return undefined; } }, [sidecarApiReady]); @@ -146,6 +148,9 @@ export function useReticulumInterfaceSnapshot({ if (evt.type === 'stack_restart_requested') { beginBleConnectGrace(); } + if (evt.type === 'interface.state' || evt.type === 'stack_restart_requested') { + invalidateReticulumInterfacesCache(); + } void refreshRef.current?.(); } }, @@ -166,6 +171,7 @@ export function useReticulumInterfaceSnapshot({ return; } beginBleConnectGrace(); + invalidateReticulumInterfacesCache(); void refresh(); burstCancelRef.current?.(); burstCancelRef.current = scheduleReticulumLocalInterfaceBurst(() => { @@ -198,6 +204,10 @@ export function useReticulumInterfaceSnapshot({ const tick = async () => { const snapshot = await refreshRef.current?.(); if (cancelled || !snapshot) return; + if ('rateLimited' in snapshot && snapshot.rateLimited) { + scheduleNextPoll(RETICULUM_LOCAL_HEALTH_POLL_MS); + return; + } scheduleNextPoll( pickReticulumLocalHealthPollMs(snapshot.interfaces, snapshot.paths, healthOptions), ); @@ -221,7 +231,10 @@ export function useReticulumInterfaceSnapshot({ serialPortPaths, effectivePrimaryLocalSerialInterfaceId, healthOptions, - refresh, + refresh: async () => { + invalidateReticulumInterfacesCache(); + return refresh(); + }, beginBleConnectGrace, handleSidecarEvent, }; diff --git a/src/renderer/lib/sessionMemoryCaps.test.ts b/src/renderer/lib/sessionMemoryCaps.test.ts index dbc4ed8f1..d24b82cbb 100644 --- a/src/renderer/lib/sessionMemoryCaps.test.ts +++ b/src/renderer/lib/sessionMemoryCaps.test.ts @@ -6,18 +6,22 @@ import { MAX_MESH_ENTITY_CAP, MAX_RETICULUM_IDENTITY_DESTINATIONS, MAX_RMAP_DISCOVERED_ROWS, + MEGA_MESH_FULL_PEER_REFRESH_MAX_AGE_MS, + MEGA_MESH_NODE_THRESHOLD, trimArrayTail, trimMapToMaxSize, trimMapToMaxSizeKeeping, } from './sessionMemoryCaps'; describe('sessionMemoryCaps', () => { - it('aligns product caps at 10k', () => { + it('aligns product caps and large/mega mesh thresholds', () => { expect(MAX_MESH_ENTITY_CAP).toBe(100_000); expect(MAX_DIAGNOSTICS_TRACKED_NODES).toBe(MAX_MESH_ENTITY_CAP); expect(MAX_RETICULUM_IDENTITY_DESTINATIONS).toBe(MAX_MESH_ENTITY_CAP); expect(MAX_RMAP_DISCOVERED_ROWS).toBe(2_000); expect(LARGE_MESH_NODE_THRESHOLD).toBe(2000); + expect(MEGA_MESH_NODE_THRESHOLD).toBe(10_000); + expect(MEGA_MESH_FULL_PEER_REFRESH_MAX_AGE_MS).toBe(10 * 60_000); }); it('trimArrayTail keeps newest entries', () => { diff --git a/src/renderer/lib/sessionMemoryCaps.ts b/src/renderer/lib/sessionMemoryCaps.ts index 448fad629..bc33544f0 100644 --- a/src/renderer/lib/sessionMemoryCaps.ts +++ b/src/renderer/lib/sessionMemoryCaps.ts @@ -1,10 +1,10 @@ /** Shared in-memory retention limits for long-running sessions. */ -import { MS_PER_HOUR } from '@/shared/timeConstants'; +import { MS_PER_HOUR, MS_PER_MINUTE } from '@/shared/timeConstants'; /** * In-memory hard ceiling for Meshtastic nodes, MeshCore contacts, and Reticulum peers. - * User-facing destination/node caps (default 10k, Reticulum max 50k) apply first. + * User-facing destination/node caps (default 50k, Reticulum max {@link MAX_MESH_ENTITY_CAP}) apply first. */ export const MAX_MESH_ENTITY_CAP = 100_000; @@ -25,6 +25,10 @@ export const MAX_RRC_MEMBERS_PER_ROOM = 256; */ export const RRC_ROOM_HISTORY_LOAD_COUNT = 500; export const LARGE_MESH_NODE_THRESHOLD = 2000; +/** Above this, skip periodic full peer snapshots unless last full refresh is stale. */ +export const MEGA_MESH_NODE_THRESHOLD = 10_000; +/** Max age of a warm full peer snapshot before mega-mesh timer refresh may run again. */ +export const MEGA_MESH_FULL_PEER_REFRESH_MAX_AGE_MS = 10 * MS_PER_MINUTE; export const LARGE_MESH_DIAGNOSTICS_REANALYSIS_DELAY_MS = 10_000; export const SESSION_DB_PRUNE_INTERVAL_MS = 6 * MS_PER_HOUR; diff --git a/src/renderer/lib/startupDbPrune.ts b/src/renderer/lib/startupDbPrune.ts index 7b613163d..f6db828ae 100644 --- a/src/renderer/lib/startupDbPrune.ts +++ b/src/renderer/lib/startupDbPrune.ts @@ -205,7 +205,7 @@ async function executeDbPrune(label: 'startup' | 'session'): Promise { if (s.reticulumDestinationCapEnabled) { const cap = typeof s.reticulumDestinationCapCount === 'number' && s.reticulumDestinationCapCount > 0 - ? Math.min(50_000, s.reticulumDestinationCapCount) + ? Math.min(100_000, s.reticulumDestinationCapCount) : DEFAULT_APP_SETTINGS_SHARED.reticulumDestinationCapCount; ops.push( window.electronAPI.db.pruneReticulumDestinationsByCount(cap).catch((e: unknown) => { diff --git a/src/renderer/runtime/useReticulumRuntime.mega-mesh.contract.test.ts b/src/renderer/runtime/useReticulumRuntime.mega-mesh.contract.test.ts new file mode 100644 index 000000000..3751dd4c3 --- /dev/null +++ b/src/renderer/runtime/useReticulumRuntime.mega-mesh.contract.test.ts @@ -0,0 +1,50 @@ +/** + * Source contract: large/mega-mesh peer refresh cadence and diagnostics dedupe. + */ +import { describe, expect, it } from 'vitest'; + +import { loadRuntimeSource } from '../lib/sourceContractTestHelpers'; + +const SOURCE = loadRuntimeSource('useReticulumRuntime.ts'); + +describe('useReticulumRuntime mega-mesh / proxy thrash guards (source contract)', () => { + it('uses 120s large-mesh peer refresh and does not accelerate LXMF catch-up', () => { + expect(SOURCE).toMatch(/RETICULUM_PEER_REFRESH_LARGE_MS\s*=\s*120_000/); + expect(SOURCE).toMatch(/RETICULUM_INBOUND_LXMF_CATCHUP_MS\s*=\s*60_000/); + expect(SOURCE).not.toMatch(/RETICULUM_INBOUND_LXMF_CATCHUP_LARGE_MS/); + }); + + it('skips warm mega-mesh periodic full peer dumps within the max age window', () => { + expect(SOURCE).toContain('MEGA_MESH_NODE_THRESHOLD'); + expect(SOURCE).toContain('MEGA_MESH_FULL_PEER_REFRESH_MAX_AGE_MS'); + expect(SOURCE).toMatch( + /count > MEGA_MESH_NODE_THRESHOLD[\s\S]*?Date\.now\(\) - lastRefreshAt < MEGA_MESH_FULL_PEER_REFRESH_MAX_AGE_MS/, + ); + expect(SOURCE).toMatch(/skipNomad:\s*count > LARGE_MESH_NODE_THRESHOLD/); + }); + + it('passes prefetched health into diagnostics and skips diagnostics on large-mesh health ticks', () => { + expect(SOURCE).toMatch(/prefetchedHealth\?:/); + expect(SOURCE).toMatch(/prefetchedHealth\s*\?\s*Promise\.resolve\(prefetchedHealth\)/); + expect(SOURCE).toMatch( + /peerCount <= LARGE_MESH_NODE_THRESHOLD[\s\S]*?syncDiagnosticsFromSidecar\(health\)/, + ); + }); + + it('backs off local interface polls on IPC rate-limit errors', () => { + expect(SOURCE).toContain('isReticulumSidecarRateLimitError'); + expect(SOURCE).toMatch(/propagateRateLimit:\s*true/); + expect(SOURCE).toMatch( + /isReticulumSidecarRateLimitError\(e\)[\s\S]*?RETICULUM_LOCAL_HEALTH_POLL_MS/, + ); + }); + + it('does not invalidate the interfaces cache on every health refresh', () => { + const refreshBody = + /const refreshLocalInterfacesFromSidecar = useCallback\(async \(\) => \{([\s\S]*?)\}, \[\]\);/.exec( + SOURCE, + )?.[1]; + expect(refreshBody).toBeTruthy(); + expect(refreshBody).not.toContain('invalidateReticulumInterfacesCache'); + }); +}); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 4f1b6e6af..aaedba38f 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -58,6 +58,7 @@ import { } from '@/renderer/lib/reticulum/reticulumLocalInterfaceLogging'; import { pickReticulumLocalHealthPollMs, + RETICULUM_LOCAL_HEALTH_POLL_MS, scheduleReticulumLocalInterfaceBurst, } from '@/renderer/lib/reticulum/reticulumLocalInterfaceRefresh'; import { @@ -91,6 +92,7 @@ import { fetchReticulumSerialPorts, getCachedReticulumEffectivePrimaryLocalSerialInterfaceId, invalidateReticulumInterfacesCache, + isReticulumSidecarRateLimitError, type ReticulumSidecarInterfaceRow, } from '@/renderer/lib/reticulum/reticulumSidecarReads'; import { parseReticulumStackSettingsPayload } from '@/renderer/lib/reticulum/reticulumStackSettings'; @@ -99,7 +101,11 @@ import { useReticulumPropagationAutoSync } from '@/renderer/lib/reticulum/useRet import { reconcileRncpListenerFromSidecar } from '@/renderer/lib/rncpListenerApply'; import { consumeRncpReceiveDestSharePending } from '@/renderer/lib/rncpReceiveDestSharePending'; import { isRrcRoomMuted } from '@/renderer/lib/rrcMention'; -import { LARGE_MESH_NODE_THRESHOLD } from '@/renderer/lib/sessionMemoryCaps'; +import { + LARGE_MESH_NODE_THRESHOLD, + MEGA_MESH_FULL_PEER_REFRESH_MAX_AGE_MS, + MEGA_MESH_NODE_THRESHOLD, +} from '@/renderer/lib/sessionMemoryCaps'; import { registerReticulumSession } from '@/renderer/lib/sessions/reticulumSession'; import { nodeRecordsToMeshNodeMap, @@ -171,11 +177,9 @@ import { } from '../stores/rrcSessionStore'; import type { ProtocolRuntime } from './protocolRuntime'; -/** Safety poll interval when the path table is large. */ -const RETICULUM_PEER_REFRESH_LARGE_MS = 60_000; -/** Periodic inbound LXMF ring catch-up on large meshes (O(1) HTTP poll). */ -const RETICULUM_INBOUND_LXMF_CATCHUP_LARGE_MS = 15_000; -/** Periodic inbound LXMF ring catch-up on smaller meshes. */ +/** Safety poll interval when the path table is large (>2k peers). */ +const RETICULUM_PEER_REFRESH_LARGE_MS = 120_000; +/** Periodic inbound LXMF ring catch-up (same cadence at all mesh sizes). */ const RETICULUM_INBOUND_LXMF_CATCHUP_MS = 60_000; const INITIAL_STATE: DeviceState = { @@ -321,7 +325,7 @@ export function useReticulumRuntime(): ProtocolRuntime { }, [identityId, selfNodeId]); const refreshContactsFromSidecar = useCallback( - async (opts?: { forceRefresh?: boolean }) => { + async (opts?: { forceRefresh?: boolean; skipNomad?: boolean }) => { await refreshReticulumPeersFromSidecar(opts); applyContactNodesFromStore(); }, @@ -402,7 +406,6 @@ export function useReticulumRuntime(): ProtocolRuntime { }, [identityId, selfLxmfHash, syncSelfNodeFromIdentityStatus]); const refreshLocalInterfacesFromSidecar = useCallback(async () => { - invalidateReticulumInterfacesCache(); const [interfaces, osSerialPorts] = await Promise.all([ fetchReticulumInterfaces(), fetchReticulumSerialPorts(), @@ -412,55 +415,60 @@ export function useReticulumRuntime(): ProtocolRuntime { return { interfaces, osSerialPorts }; }, []); - const syncDiagnosticsFromSidecar = useCallback(async () => { - try { - const [snapshot, health, auditIssues, sidecarStatus, stackRaw] = await Promise.all([ - window.electronAPI.reticulum.proxyGet('/api/v1/diagnostics') as Promise< - Parameters[0] - >, - refreshLocalInterfacesFromSidecar(), - fetchReticulumConfigAudit().catch((e: unknown) => { - console.debug('[useReticulumRuntime] config audit failed ' + String(e)); - return []; - }), - window.electronAPI.reticulum.getStatus(), - window.electronAPI.reticulum.proxyGet('/api/v1/stack/settings').catch(() => { - // catch-no-log-ok optional stack settings - return null; - }), - ]); - const { interfaces, osSerialPorts } = health; - const selfNodeId = selfLxmfHash ? reticulumHashToNodeId(selfLxmfHash) : 0; - const shareInstanceEnabled = - stackRaw != null ? parseReticulumStackSettingsPayload(stackRaw).share_instance : false; - const propState = useReticulumPropagationStore.getState(); - const rows = buildReticulumDiagnosticRows(snapshot, { - selfNodeId, - interfaces, - osSerialPorts, - auditIssues, - autoBeaconAlert: sidecarStatus.autoBeaconAlert ?? null, - interfaceIssueAlert: sidecarStatus.interfaceIssueAlert ?? null, - shareInstanceEnabled, - sidecarRunning: sidecarStatus.running, - sidecarHealthy: sidecarStatus.healthy, - sidecarUnhealthySince: sidecarStatus.unhealthySince, - inboundLxmf: getReticulumInboundLxmfDiagnostics(), - propagation: { - syncActive: propState.sync.active, - syncProgress: propState.sync.progress, - lastSyncError: propState.lastSyncError, - lastAttemptAt: - propState.activePropagationSyncAttemptAt ?? propState.lastPropagationSyncAttemptAt, - }, - }); - useDiagnosticsStore.setState((s) => ({ - diagnosticRows: mergeReticulumDiagnosticRows(s.diagnosticRows, rows), - })); - } catch (e) { - console.debug('[useReticulumRuntime] diagnostics ' + errLikeToLogString(e)); - } - }, [refreshLocalInterfacesFromSidecar, selfLxmfHash]); + const syncDiagnosticsFromSidecar = useCallback( + async (prefetchedHealth?: Awaited>) => { + try { + const [snapshot, health, auditIssues, sidecarStatus, stackRaw] = await Promise.all([ + window.electronAPI.reticulum.proxyGet('/api/v1/diagnostics') as Promise< + Parameters[0] + >, + prefetchedHealth + ? Promise.resolve(prefetchedHealth) + : refreshLocalInterfacesFromSidecar(), + fetchReticulumConfigAudit().catch((e: unknown) => { + console.debug('[useReticulumRuntime] config audit failed ' + String(e)); + return []; + }), + window.electronAPI.reticulum.getStatus(), + window.electronAPI.reticulum.proxyGet('/api/v1/stack/settings').catch(() => { + // catch-no-log-ok optional stack settings + return null; + }), + ]); + const { interfaces, osSerialPorts } = health; + const selfNodeId = selfLxmfHash ? reticulumHashToNodeId(selfLxmfHash) : 0; + const shareInstanceEnabled = + stackRaw != null ? parseReticulumStackSettingsPayload(stackRaw).share_instance : false; + const propState = useReticulumPropagationStore.getState(); + const rows = buildReticulumDiagnosticRows(snapshot, { + selfNodeId, + interfaces, + osSerialPorts, + auditIssues, + autoBeaconAlert: sidecarStatus.autoBeaconAlert ?? null, + interfaceIssueAlert: sidecarStatus.interfaceIssueAlert ?? null, + shareInstanceEnabled, + sidecarRunning: sidecarStatus.running, + sidecarHealthy: sidecarStatus.healthy, + sidecarUnhealthySince: sidecarStatus.unhealthySince, + inboundLxmf: getReticulumInboundLxmfDiagnostics(), + propagation: { + syncActive: propState.sync.active, + syncProgress: propState.sync.progress, + lastSyncError: propState.lastSyncError, + lastAttemptAt: + propState.activePropagationSyncAttemptAt ?? propState.lastPropagationSyncAttemptAt, + }, + }); + useDiagnosticsStore.setState((s) => ({ + diagnosticRows: mergeReticulumDiagnosticRows(s.diagnosticRows, rows), + })); + } catch (e) { + console.debug('[useReticulumRuntime] diagnostics ' + errLikeToLogString(e)); + } + }, + [refreshLocalInterfacesFromSidecar, selfLxmfHash], + ); const scheduleLocalInterfaceStatusBurst = useCallback(() => { localInterfaceBurstCancelRef.current?.(); @@ -472,7 +480,9 @@ export function useReticulumRuntime(): ProtocolRuntime { const scheduleFullPeerRefresh = useCallback(() => { const peerCount = useReticulumPeerStore.getState().peers.size; const onRefresh = () => { - void refreshContactsFromSidecar(); + void refreshContactsFromSidecar().catch(() => { + // catch-no-log-ok rate-limit rethrow from peer store — already debug-logged + }); void syncDiagnosticsFromSidecar(); }; if (peerCount > LARGE_MESH_NODE_THRESHOLD) { @@ -1521,16 +1531,35 @@ export function useReticulumRuntime(): ProtocolRuntime { if (state.status !== 'configured' && state.status !== 'connected' && state.status !== 'stale') { return; } - void refreshContactsFromSidecar(); + void refreshContactsFromSidecar().catch(() => { + // catch-no-log-ok rate-limit rethrow from peer store — already debug-logged + }); void refreshSelfNodeDisplayNameFromSidecar(); let timeoutId: ReturnType | null = null; const scheduleNext = () => { + const peerCount = useReticulumPeerStore.getState().peers.size; const ms = - useReticulumPeerStore.getState().peers.size > LARGE_MESH_NODE_THRESHOLD + peerCount > LARGE_MESH_NODE_THRESHOLD ? RETICULUM_PEER_REFRESH_LARGE_MS : RETICULUM_PEER_REFRESH_MS; timeoutId = setTimeout(() => { - void refreshContactsFromSidecar(); + const store = useReticulumPeerStore.getState(); + const count = store.peers.size; + const lastRefreshAt = store.lastRefreshAt ?? 0; + if ( + count > MEGA_MESH_NODE_THRESHOLD && + lastRefreshAt > 0 && + Date.now() - lastRefreshAt < MEGA_MESH_FULL_PEER_REFRESH_MAX_AGE_MS + ) { + void refreshSelfNodeDisplayNameFromSidecar(); + scheduleNext(); + return; + } + void refreshContactsFromSidecar({ + skipNomad: count > LARGE_MESH_NODE_THRESHOLD, + }).catch(() => { + // catch-no-log-ok rate-limit rethrow from peer store — already debug-logged + }); void refreshSelfNodeDisplayNameFromSidecar(); scheduleNext(); }, ms); @@ -1548,10 +1577,6 @@ export function useReticulumRuntime(): ProtocolRuntime { } let timeoutId: ReturnType | null = null; const scheduleNext = () => { - const ms = - useReticulumPeerStore.getState().peers.size > LARGE_MESH_NODE_THRESHOLD - ? RETICULUM_INBOUND_LXMF_CATCHUP_LARGE_MS - : RETICULUM_INBOUND_LXMF_CATCHUP_MS; timeoutId = setTimeout(() => { const sinceTs = getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs ?? undefined; void catchUpRecentInboundLxmf({ sinceTs, reason: 'periodic' }).catch((e: unknown) => { @@ -1560,7 +1585,7 @@ export function useReticulumRuntime(): ProtocolRuntime { ); }); scheduleNext(); - }, ms); + }, RETICULUM_INBOUND_LXMF_CATCHUP_MS); }; scheduleNext(); return () => { @@ -1613,10 +1638,34 @@ export function useReticulumRuntime(): ProtocolRuntime { }; const tick = async () => { - const health = await refreshLocalInterfacesFromSidecar(); - if (cancelled) return; - void syncDiagnosticsFromSidecar(); - scheduleNextPoll(pickReticulumLocalHealthPollMs(health.interfaces, health.osSerialPorts)); + try { + // Propagate rate-limit so we can back off; other refreshLocalInterfaces + // callers keep the cached-fallback default. + const [interfaces, osSerialPorts] = await Promise.all([ + fetchReticulumInterfaces({ propagateRateLimit: true }), + fetchReticulumSerialPorts({ propagateRateLimit: true }), + ]); + localInterfacesRef.current = interfaces; + logReticulumLocalInterfaceHealthChanges(interfaces, osSerialPorts); + const health = { interfaces, osSerialPorts }; + if (cancelled) return; + const peerCount = useReticulumPeerStore.getState().peers.size; + // Large meshes: rely on WS-debounced diagnostics; avoid pairing a heavy + // diagnostics bundle with every interface health tick. + if (peerCount <= LARGE_MESH_NODE_THRESHOLD) { + void syncDiagnosticsFromSidecar(health); + } + scheduleNextPoll(pickReticulumLocalHealthPollMs(health.interfaces, health.osSerialPorts)); + } catch (e) { + if (cancelled) return; + if (isReticulumSidecarRateLimitError(e)) { + console.debug('[useReticulumRuntime] local interface poll rate-limited — backing off'); + scheduleNextPoll(RETICULUM_LOCAL_HEALTH_POLL_MS); + return; + } + console.debug('[useReticulumRuntime] local interface poll ' + errLikeToLogString(e)); + scheduleNextPoll(RETICULUM_LOCAL_HEALTH_POLL_MS); + } }; void tick(); @@ -1630,7 +1679,7 @@ export function useReticulumRuntime(): ProtocolRuntime { localInterfaceBurstCancelRef.current?.(); localInterfaceBurstCancelRef.current = null; }; - }, [state.status, refreshLocalInterfacesFromSidecar, syncDiagnosticsFromSidecar]); + }, [state.status, syncDiagnosticsFromSidecar]); const connectAutomatic = useCallback(async () => { await connect(); diff --git a/src/renderer/stores/reticulumPeerStore.test.ts b/src/renderer/stores/reticulumPeerStore.test.ts index 0d63772e7..a3248e7bf 100644 --- a/src/renderer/stores/reticulumPeerStore.test.ts +++ b/src/renderer/stores/reticulumPeerStore.test.ts @@ -521,6 +521,20 @@ describe('reticulumPeerStore', () => { expect(useReticulumPeerStore.getState().peers.get('aa')?.hops).toBe(4); }); + it('refreshReticulumPeersFromSidecar rethrows rate-limit errors after debug log', async () => { + const proxyGet = vi.fn().mockRejectedValue(new Error('reticulum:proxy: rate limit exceeded')); + vi.stubGlobal('window', { + electronAPI: { + reticulum: { proxyGet }, + db: { getReticulumDestinations: vi.fn().mockResolvedValue([]) }, + }, + }); + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + await expect(refreshReticulumPeersFromSidecar()).rejects.toThrow('rate limit exceeded'); + expect(debug).toHaveBeenCalled(); + debug.mockRestore(); + }); + it('refreshReticulumPeersFromSidecar OR-accumulates forceRefresh across coalesced callers', async () => { let releaseFirst!: () => void; const firstGate = new Promise((resolve) => { diff --git a/src/renderer/stores/reticulumPeerStore.ts b/src/renderer/stores/reticulumPeerStore.ts index 02420e52a..aa3d54dbb 100644 --- a/src/renderer/stores/reticulumPeerStore.ts +++ b/src/renderer/stores/reticulumPeerStore.ts @@ -84,8 +84,8 @@ function readReticulumDestinationCap(): number { typeof s.reticulumDestinationCapCount === 'number' && s.reticulumDestinationCapCount > 0 ? Math.floor(s.reticulumDestinationCapCount) : DEFAULT_APP_SETTINGS_SHARED.reticulumDestinationCapCount; - /** User-facing max is 50k; hard ceiling is {@link MAX_MESH_ENTITY_CAP}. */ - return Math.min(Math.max(1, cap), 50_000, MAX_MESH_ENTITY_CAP); + /** User-facing max matches {@link MAX_MESH_ENTITY_CAP}. */ + return Math.min(Math.max(1, cap), MAX_MESH_ENTITY_CAP); } function normalizeHash(hash: string): string { @@ -750,6 +750,16 @@ export function applyReticulumPeersUpdatedPatches(payload: unknown): void { if (peer) patches.push(peer); } } + // Probe / path-request single-hash events — seed/touch without a full dump. + if (patches.length === 0 && typeof p.hash === 'string' && p.hash.trim()) { + const peer = peerFromWirePatch({ + destination_hash: p.hash, + last_seen: typeof p.last_seen === 'number' ? p.last_seen : Date.now(), + hops: typeof p.hops === 'number' ? p.hops : undefined, + interface: typeof p.interface === 'string' ? p.interface : undefined, + }); + if (peer) patches.push(peer); + } if (patches.length === 0) return; bufferReticulumPeerPatches(patches); for (const peer of patches) { @@ -868,17 +878,22 @@ let peerRefreshInFlight: Promise | null = null; let peerRefreshPendingRerun = false; /** OR of forceRefresh across coalesced callers (manual Refresh must not soften to cache). */ let peerRefreshPendingForce = false; +/** AND of skipNomad across coalesced callers (any non-skip wins). */ +let peerRefreshPendingSkipNomad = true; /** Test helper — reset peer-refresh coalesce state. */ export function resetReticulumPeerRefreshSingleFlightForTests(): void { peerRefreshInFlight = null; peerRefreshPendingRerun = false; peerRefreshPendingForce = false; + peerRefreshPendingSkipNomad = true; } export interface RefreshReticulumPeersOptions { /** Force live GetPathTable (`?refresh=1`) — required for manual Refresh. */ forceRefresh?: boolean; + /** Skip Nomad nodes overlay (large-mesh timer refresh). */ + skipNomad?: boolean; } async function refreshReticulumPeersFromSidecarOnce( @@ -894,9 +909,13 @@ async function refreshReticulumPeersFromSidecarOnce( peers?: ReticulumPeerWireRow[]; }>, window.electronAPI.db.getReticulumDestinations() as Promise, - window.electronAPI.reticulum.proxyGet('/api/v1/nomadnetwork/nodes') as Promise<{ - nodes?: { destination_hash: string; display_name?: string | null }[]; - }>, + opts.skipNomad + ? Promise.resolve({ + nodes: [] as { destination_hash: string; display_name?: string | null }[], + }) + : (window.electronAPI.reticulum.proxyGet('/api/v1/nomadnetwork/nodes') as Promise<{ + nodes?: { destination_hash: string; display_name?: string | null }[]; + }>), ]); // A newer request arrived while we were fetching — skip applying this stale snapshot. @@ -1007,28 +1026,39 @@ export function refreshReticulumPeersFromSidecar( if (peerRefreshInFlight) { peerRefreshPendingRerun = true; if (opts.forceRefresh) peerRefreshPendingForce = true; + if (!opts.skipNomad) peerRefreshPendingSkipNomad = false; return peerRefreshInFlight; } peerRefreshInFlight = (async () => { try { let forceRefresh = Boolean(opts.forceRefresh) || peerRefreshPendingForce; + let skipNomad = Boolean(opts.skipNomad) && peerRefreshPendingSkipNomad; peerRefreshPendingForce = false; + peerRefreshPendingSkipNomad = true; peerRefreshPendingRerun = false; - let result = await refreshReticulumPeersFromSidecarOnce({ forceRefresh }); + let result = await refreshReticulumPeersFromSidecarOnce({ forceRefresh, skipNomad }); while (peerRefreshPendingRerun) { peerRefreshPendingRerun = false; forceRefresh = peerRefreshPendingForce; + skipNomad = peerRefreshPendingSkipNomad; peerRefreshPendingForce = false; - result = await refreshReticulumPeersFromSidecarOnce({ forceRefresh }); + peerRefreshPendingSkipNomad = true; + result = await refreshReticulumPeersFromSidecarOnce({ forceRefresh, skipNomad }); } return result; } catch (e) { - console.warn('[reticulumPeerStore] refresh ' + errLikeToLogString(e)); + const msg = errLikeToLogString(e); + if (msg.toLowerCase().includes('rate limit exceeded')) { + console.debug('[reticulumPeerStore] refresh ' + msg); + throw e instanceof Error ? e : new Error(msg); + } + console.warn('[reticulumPeerStore] refresh ' + msg); return []; } finally { peerRefreshInFlight = null; peerRefreshPendingForce = false; + peerRefreshPendingSkipNomad = true; } })();