Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions reticulum-sidecar/src/stack/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions reticulum-sidecar/src/stack/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2514,8 +2514,8 @@ fn enumerate_serial_ports() -> Vec<serde_json::Value> {
}

/// 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
Expand Down
5 changes: 3 additions & 2 deletions src/main/ipc/reticulum-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand All @@ -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')
);
}

Expand Down
31 changes: 31 additions & 0 deletions src/main/ipc/reticulum-proxy-rate-limit.contract.test.ts
Original file line number Diff line number Diff line change
@@ -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\);/,
);
});
});
4 changes: 2 additions & 2 deletions src/renderer/components/AppPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/components/ReticulumNetworkPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: ' +
Expand Down
24 changes: 11 additions & 13 deletions src/renderer/components/ReticulumPeerListPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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<typeof setTimeout> | null = null;
if (debounceMs > 0) {
timer = setTimeout(() => {
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
6 changes: 0 additions & 6 deletions src/renderer/hooks/useReticulumDmPathProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -15,7 +14,6 @@ vi.mock('@/renderer/stores/reticulumPeerStore', () => ({
updatePeer: (...args: unknown[]) => updatePeerMock(...args),
}),
},
refreshReticulumPeersFromSidecar: (...args: unknown[]) => refreshPeersMock(...args),
}));

import { useReticulumDmPathProbe } from './useReticulumDmPathProbe';
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
12 changes: 3 additions & 9 deletions src/renderer/hooks/useReticulumDmPathProbe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/lib/defaultAppSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
69 changes: 68 additions & 1 deletion src/renderer/lib/reticulum/reticulumSidecarReads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,14 @@ import {
fetchReticulumIdentityStatus,
fetchReticulumInterfaces,
fetchReticulumRmapDiscovered,
fetchReticulumSerialPortOptions,
fetchReticulumSerialPorts,
formatReticulumPeerProbeToast,
invalidateReticulumInterfacesCache,
isReticulumSidecar404Error,
isReticulumSidecarExpectedProxyError,
isReticulumSidecarNotRunningError,
isReticulumSidecarRateLimitError,
isReticulumSidecarRunning,
pingReticulumDestination,
probeReticulumPeer,
Expand All @@ -33,6 +38,7 @@ describe('reticulumSidecarReads', () => {
getStatus.mockReset();
proxyGet.mockReset();
proxyPost.mockReset();
invalidateReticulumInterfacesCache();
});

it('isReticulumSidecarRunning returns true when sidecar reports running with port', async () => {
Expand All @@ -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',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 () => {
Expand Down
Loading