From 3ffef32ba03a3e795912bca8b1387df49ae491a4 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Tue, 11 Aug 2026 06:38:08 -0600 Subject: [PATCH 1/9] fix: stop room auto-login stampede and quiet expected reconnect log noise Concurrent MeshCore connect auto-login was racing pathSync on every node-list change; silent bulk getWaitingMessages always waited 45s before fallback. Collapse auto-login to a single flight, skip bulk after consecutive timeouts, and stop logging expected TCP/ResizeObserver races as errors. --- src/main/index.contract.test.ts | 7 ++ src/main/index.ts | 6 +- src/main/log-service.test.ts | 18 +++ src/main/log-service.ts | 11 ++ src/preload/index.ts | 6 +- .../meshcore/meshcoreConnSideEffects.test.ts | 30 +++++ .../hooks/meshcore/meshcoreConnSideEffects.ts | 17 +++ .../meshcoreRoomAutoLoginOnConnect.test.ts | 112 ++++++++++++++++++ .../lib/meshcoreRoomAutoLoginOnConnect.ts | 63 ++++++++++ .../lib/meshcoreWaitingMessagesDrain.test.ts | 43 +++++++ .../lib/meshcoreWaitingMessagesDrain.ts | 38 ++++++ src/renderer/lib/timeConstants.ts | 5 + src/renderer/runtime/useMeshcoreRuntime.ts | 97 ++++++++------- 13 files changed, 409 insertions(+), 44 deletions(-) create mode 100644 src/renderer/lib/meshcoreRoomAutoLoginOnConnect.test.ts create mode 100644 src/renderer/lib/meshcoreRoomAutoLoginOnConnect.ts diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index cfb028694..11b252f8f 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -29,6 +29,13 @@ describe('Noble BLE disconnect handling (source contract)', () => { ); }); + it('resolves meshtastic:tcp-write with no-socket instead of rejecting when the socket is gone', () => { + expect(INDEX_SOURCE).toMatch( + /meshtastic:tcp-write[\s\S]{0,800}console\.debug\('\[IPC\] meshtastic:tcp-write: no active socket'\)[\s\S]{0,80}return 'no-socket'/, + ); + expect(PRELOAD_SOURCE).toMatch(/result === 'no-socket'/); + }); + it('returns scan_busy result instead of throwing when Reticulum holds the scan mutex', () => { expect(INDEX_SOURCE).toContain('BleScanBusyError'); expect(INDEX_SOURCE).toMatch( diff --git a/src/main/index.ts b/src/main/index.ts index 23389acac..fa846b7b4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6397,9 +6397,9 @@ ipcMain.handle('meshtastic:tcp-write', (event, bytes: number[]) => { return Promise.reject(new Error('meshtastic:tcp-write: byte values must be integers 0-255')); } if (!meshtasticTcpSocket) { - const msg = 'meshtastic:tcp-write: no active socket'; - console.warn(`[IPC] ${msg}`); - return Promise.reject(new Error(msg)); + // Expected reconnect race — resolve so Electron does not log handler [error]. + console.debug('[IPC] meshtastic:tcp-write: no active socket'); + return 'no-socket'; } const sock = meshtasticTcpSocket; return new Promise((resolve, reject) => { diff --git a/src/main/log-service.test.ts b/src/main/log-service.test.ts index cee4d6d32..69b155c69 100644 --- a/src/main/log-service.test.ts +++ b/src/main/log-service.test.ts @@ -414,6 +414,24 @@ describe('isDroppableMeshtasticSdkLogLine', () => { }); }); +describe('isDroppableRendererConsoleNoise', () => { + it('drops ResizeObserver loop completed warnings', async () => { + const { isDroppableRendererConsoleNoise } = await import('./log-service'); + expect( + isDroppableRendererConsoleNoise( + 'ResizeObserver loop completed with undelivered notifications.', + ), + ).toBe(true); + expect(isDroppableRendererConsoleNoise('ResizeObserver loop limit exceeded')).toBe(true); + }); + + it('keeps other renderer errors', async () => { + const { isDroppableRendererConsoleNoise } = await import('./log-service'); + expect(isDroppableRendererConsoleNoise('Error sending packet 123')).toBe(false); + expect(isDroppableRendererConsoleNoise('ResizeObserver is not defined')).toBe(false); + }); +}); + describe('formatRuntimeLogTag', () => { it('includes platform, arch, electron, node, packaged, and buildChannel fields', async () => { const { formatRuntimeLogTag } = await import('./log-service'); diff --git a/src/main/log-service.ts b/src/main/log-service.ts index e7a7b226f..8c0a011e2 100644 --- a/src/main/log-service.ts +++ b/src/main/log-service.ts @@ -405,6 +405,16 @@ export function isDroppableMeshtasticSdkLogLine(message: string): boolean { return false; } +/** + * Chromium ResizeObserver loop warnings — harmless, logged as error by the renderer. + * Keep real application errors. + */ +export function isDroppableRendererConsoleNoise(message: string): boolean { + return /ResizeObserver loop (completed with undelivered notifications|limit exceeded)\.?/i.test( + message, + ); +} + /** * Renderer console-message (Electron 40+): single event object with message, level, lineNumber, sourceId. * level is 'info' | 'warning' | 'error' | 'debug'. @@ -428,5 +438,6 @@ export function forwardRendererConsoleMessage(details: { : 'renderer'; const msg = sanitizeLogMessage(stripConsoleStyles(details.message)); if (isDroppableMeshtasticSdkLogLine(msg)) return; + if (isDroppableRendererConsoleNoise(msg)) return; appendLine(mapped, src, msg); } diff --git a/src/preload/index.ts b/src/preload/index.ts index cc8415c4e..d640a3245 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1025,7 +1025,11 @@ contextBridge.exposeInMainWorld('electronAPI', { tcp: { connect: (host: string, port: number): Promise => ipcRenderer.invoke('meshtastic:tcp-connect', host, port), - write: (bytes: number[]): Promise => ipcRenderer.invoke('meshtastic:tcp-write', bytes), + write: async (bytes: number[]): Promise => { + const result: unknown = await ipcRenderer.invoke('meshtastic:tcp-write', bytes); + // Main resolves 'no-socket' on expected reconnect races (no handler [error]). + if (result === 'no-socket') return; + }, disconnect: (): Promise => ipcRenderer.invoke('meshtastic:tcp-disconnect'), onData: (cb: (bytes: Uint8Array) => void): (() => void) => { const handler = (_: unknown, bytes: Uint8Array) => { diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts index f71467c4a..e41a90eb7 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts @@ -15,6 +15,7 @@ import type { DomainEvent } from '@/renderer/lib/protocols/Protocol'; import { MESHCORE_WAITING_MESSAGES_DRAIN_DEBOUNCE_MS, MESHCORE_WAITING_MESSAGES_SERIAL_SILENT_TIMEOUT_MS, + MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP, MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS, } from '@/renderer/lib/timeConstants'; import type { ChatMessage, DeviceState, TelemetryPoint } from '@/renderer/lib/types'; @@ -421,6 +422,35 @@ describe('attachMeshcoreConnSideEffects', () => { }, ); + it('skips silent bulk after consecutive timeouts and drains incrementally', 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); + + for (let i = 0; i < MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP; i += 1) { + const drainPromise = h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: false }); + await vi.advanceTimersByTimeAsync(MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS); + await vi.runAllTimersAsync(); + await drainPromise; + } + expect(h.conn.getWaitingMessages).toHaveBeenCalledTimes( + MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP, + ); + + vi.mocked(h.conn.getWaitingMessages).mockClear(); + const skipped = h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: false }); + await vi.runAllTimersAsync(); + await skipped; + + expect(h.conn.getWaitingMessages).not.toHaveBeenCalled(); + expect(h.syncNextMessage).toHaveBeenCalled(); + expect(h.handleConnectionLost).not.toHaveBeenCalled(); + }); + it('ignores late bulk resolve after timeout fallback has started', async () => { vi.useFakeTimers(); const h = makeHarness(); diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts index ba54e39ed..c1d8fb661 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts @@ -38,9 +38,12 @@ import { isMeshcoreWaitingMessagesTransportDeadError, logMeshcoreWaitingMessagesDrainError, markMeshcoreMsgWaitingEvent, + noteMeshcoreSilentBulkSuccess, + noteMeshcoreSilentBulkTimeout, resetMeshcoreWaitingMessagesDrainSchedule, scheduleMeshcoreWaitingMessagesDrain, shouldActivateWaitingMessagesBanner, + shouldSkipMeshcoreSilentBulkGetWaitingMessages, waitingMessagesDrainTimeoutMs, } from '../../lib/meshcoreWaitingMessagesDrain'; import type { DomainEvent } from '../../lib/protocols/Protocol'; @@ -306,6 +309,11 @@ async function drainWaitingMessagesSilent( state: MeshcoreWaitingMessagesDrainState, deps: MeshcoreWaitingMessagesDrainDeps, ): Promise { + if (shouldSkipMeshcoreSilentBulkGetWaitingMessages()) { + await drainWaitingMessagesIncremental(conn, state, deps); + return; + } + const attemptId = beginMeshcoreSilentBulkAttempt(); try { @@ -319,6 +327,7 @@ async function drainWaitingMessagesSilent( return; } if (!deps.meshcoreHookMountedRef.current) return; + noteMeshcoreSilentBulkSuccess(); const arr = normalizeMeshcoreWaitingMessageBatch(msgs); if (arr.length === 0) { return; @@ -359,6 +368,14 @@ async function drainWaitingMessagesSilent( // Abandon bulk ownership so a late getWaitingMessages resolve cannot ingest. abandonMeshcoreSilentBulkAttempt(attemptId); if (!stillOwner || !deps.meshcoreHookMountedRef.current) return; + if (isMeshcoreGetWaitingMessagesTimeoutError(e)) { + const tripped = noteMeshcoreSilentBulkTimeout(); + if (tripped) { + console.debug( + '[useMeshcoreRuntime] silent bulk getWaitingMessages circuit-open; skipping bulk until reconnect', + ); + } + } logMeshcoreWaitingMessagesDrainError('silent bulk fallback to syncNextMessage', e, false); state.syncTotal = 0; state.progressActive = true; diff --git a/src/renderer/lib/meshcoreRoomAutoLoginOnConnect.test.ts b/src/renderer/lib/meshcoreRoomAutoLoginOnConnect.test.ts new file mode 100644 index 000000000..559f7204b --- /dev/null +++ b/src/renderer/lib/meshcoreRoomAutoLoginOnConnect.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import type { MeshcoreRoomAutoLoginTargetProbe } from './meshcoreRoomAutoLoginOnConnect'; +import { + isMeshcoreRoomAutoLoginInFlight, + meshcoreRoomAutoLoginReadyKey, + resetMeshcoreRoomAutoLoginSingleFlight, + runMeshcoreRoomAutoLoginSingleFlight, + selectMeshcoreRoomAutoLoginTargets, +} from './meshcoreRoomAutoLoginOnConnect'; + +const READY: MeshcoreRoomAutoLoginTargetProbe = { + isRoom: true, + hasCredential: true, + hasPubKey: true, + loggedIn: false, + queued: false, + autoLoginFailed: false, +}; + +describe('selectMeshcoreRoomAutoLoginTargets', () => { + it('keeps rooms that are ready to log in', () => { + expect(selectMeshcoreRoomAutoLoginTargets([1, 2], () => READY)).toEqual([1, 2]); + }); + + it('skips logged-in and queued rooms', () => { + expect( + selectMeshcoreRoomAutoLoginTargets([1, 2, 3], (id) => ({ + ...READY, + loggedIn: id === 1, + queued: id === 2, + })), + ).toEqual([3]); + }); + + it('skips non-room, missing credential/pubkey, and prior auto-login failure', () => { + expect( + selectMeshcoreRoomAutoLoginTargets([1, 2, 3, 4], (id) => ({ + ...READY, + isRoom: id !== 1, + hasCredential: id !== 2, + hasPubKey: id !== 3, + autoLoginFailed: id === 4, + })), + ).toEqual([]); + }); +}); + +describe('meshcoreRoomAutoLoginReadyKey', () => { + it('only includes configured ids that are Room contacts', () => { + expect(meshcoreRoomAutoLoginReadyKey([10, 20, 30], (id) => id === 20 || id === 10)).toBe( + '10,20', + ); + }); + + it('is stable when unrelated nodes appear', () => { + const rooms = new Set([42]); + const before = meshcoreRoomAutoLoginReadyKey([42], (id) => rooms.has(id)); + rooms.add(99); + const after = meshcoreRoomAutoLoginReadyKey([42], (id) => rooms.has(id)); + expect(after).toBe(before); + }); + + it('changes when a configured room contact becomes available', () => { + const rooms = new Set(); + expect(meshcoreRoomAutoLoginReadyKey([7], (id) => rooms.has(id))).toBe(''); + rooms.add(7); + expect(meshcoreRoomAutoLoginReadyKey([7], (id) => rooms.has(id))).toBe('7'); + }); +}); + +describe('runMeshcoreRoomAutoLoginSingleFlight', () => { + afterEach(() => { + resetMeshcoreRoomAutoLoginSingleFlight(); + }); + + it('collapses concurrent triggers onto one run', async () => { + let started = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const run = async (): Promise => { + started += 1; + await gate; + }; + + const first = runMeshcoreRoomAutoLoginSingleFlight(run); + const second = runMeshcoreRoomAutoLoginSingleFlight(run); + expect(isMeshcoreRoomAutoLoginInFlight()).toBe(true); + expect(first).toBe(second); + expect(started).toBe(1); + + release(); + await Promise.all([first, second]); + expect(started).toBe(1); + expect(isMeshcoreRoomAutoLoginInFlight()).toBe(false); + }); + + it('allows a later pass after the in-flight one finishes', async () => { + let started = 0; + await runMeshcoreRoomAutoLoginSingleFlight(() => { + started += 1; + return Promise.resolve(); + }); + await runMeshcoreRoomAutoLoginSingleFlight(() => { + started += 1; + return Promise.resolve(); + }); + expect(started).toBe(2); + }); +}); diff --git a/src/renderer/lib/meshcoreRoomAutoLoginOnConnect.ts b/src/renderer/lib/meshcoreRoomAutoLoginOnConnect.ts new file mode 100644 index 000000000..ab313e1a5 --- /dev/null +++ b/src/renderer/lib/meshcoreRoomAutoLoginOnConnect.ts @@ -0,0 +1,63 @@ +/** Probe fields used to decide whether a configured room should auto-login. */ +export interface MeshcoreRoomAutoLoginTargetProbe { + isRoom: boolean; + hasCredential: boolean; + hasPubKey: boolean; + loggedIn: boolean; + queued: boolean; + autoLoginFailed: boolean; +} + +/** + * Rooms that should run connect auto-login. Skips logged-in, queued, failed, and + * not-yet-hydrated contacts so overlapping triggers cannot stampede pathSync. + */ +export function selectMeshcoreRoomAutoLoginTargets( + configuredIds: number[], + probe: (nodeId: number) => MeshcoreRoomAutoLoginTargetProbe, +): number[] { + return configuredIds.filter((nodeId) => { + const p = probe(nodeId); + return ( + p.isRoom && p.hasCredential && p.hasPubKey && !p.loggedIn && !p.queued && !p.autoLoginFailed + ); + }); +} + +/** + * Stable key of configured auto-login rooms that are present as Room contacts. + * Changes when a room contact becomes available — not on unrelated node-list churn. + */ +export function meshcoreRoomAutoLoginReadyKey( + configuredIds: number[], + isRoom: (nodeId: number) => boolean, +): string { + return configuredIds + .filter((id) => isRoom(id)) + .sort((a, b) => a - b) + .join(','); +} + +let inFlight: Promise | null = null; + +/** True while a connect auto-login pass is running (including an empty target list). */ +export function isMeshcoreRoomAutoLoginInFlight(): boolean { + return inFlight != null; +} + +/** + * Collapse overlapping connect auto-login triggers onto one pass. + * Later callers await the in-flight work instead of starting another pathSync. + */ +export function runMeshcoreRoomAutoLoginSingleFlight(run: () => Promise): Promise { + if (inFlight) return inFlight; + inFlight = run().finally(() => { + inFlight = null; + }); + return inFlight; +} + +/** Test / disconnect hook — does not abort an in-flight pass. */ +export function resetMeshcoreRoomAutoLoginSingleFlight(): void { + inFlight = null; +} diff --git a/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts b/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts index 1c328bff3..c1b07b1fa 100644 --- a/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts +++ b/src/renderer/lib/meshcoreWaitingMessagesDrain.test.ts @@ -13,11 +13,15 @@ import { logMeshcoreWaitingMessagesDrainError, markMeshcoreCompanionTx, markMeshcoreMsgWaitingEvent, + noteMeshcoreSilentBulkSuccess, + noteMeshcoreSilentBulkTimeout, + resetMeshcoreSilentBulkBreaker, resetMeshcoreWaitingMessagesDrainSchedule, resetMeshcoreWaitingMessagesDrainState, scheduleMeshcoreWaitingMessagesDrain, shouldActivateWaitingMessagesBanner, shouldRunMeshcoreWaitingMessagesPeriodicPoll, + shouldSkipMeshcoreSilentBulkGetWaitingMessages, waitingMessagesDrainTimeoutMs, } from './meshcoreWaitingMessagesDrain'; import { @@ -26,6 +30,7 @@ import { MESHCORE_WAITING_MESSAGES_DRAIN_DEBOUNCE_MS, MESHCORE_WAITING_MESSAGES_POLL_MS, MESHCORE_WAITING_MESSAGES_SERIAL_SILENT_TIMEOUT_MS, + MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP, MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS, MESHCORE_WAITING_MESSAGES_SYNC_TIMEOUT_MS, } from './timeConstants'; @@ -329,3 +334,41 @@ describe('resetMeshcoreWaitingMessagesDrainSchedule', () => { expect(drain).not.toHaveBeenCalled(); }); }); + +describe('silent bulk timeout circuit breaker', () => { + afterEach(() => { + resetMeshcoreSilentBulkBreaker(); + }); + + it('stays closed before the trip count', () => { + expect(noteMeshcoreSilentBulkTimeout()).toBe(false); + expect(shouldSkipMeshcoreSilentBulkGetWaitingMessages()).toBe(false); + }); + + it('opens on the trip timeout and only reports the trip once', () => { + for (let i = 1; i < MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP; i += 1) { + expect(noteMeshcoreSilentBulkTimeout()).toBe(false); + } + expect(noteMeshcoreSilentBulkTimeout()).toBe(true); + expect(shouldSkipMeshcoreSilentBulkGetWaitingMessages()).toBe(true); + expect(noteMeshcoreSilentBulkTimeout()).toBe(false); + expect(shouldSkipMeshcoreSilentBulkGetWaitingMessages()).toBe(true); + }); + + it('resets on successful bulk', () => { + for (let i = 0; i < MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP; i += 1) { + noteMeshcoreSilentBulkTimeout(); + } + expect(shouldSkipMeshcoreSilentBulkGetWaitingMessages()).toBe(true); + noteMeshcoreSilentBulkSuccess(); + expect(shouldSkipMeshcoreSilentBulkGetWaitingMessages()).toBe(false); + }); + + it('resets on drain state reset (reconnect)', () => { + for (let i = 0; i < MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP; i += 1) { + noteMeshcoreSilentBulkTimeout(); + } + resetMeshcoreWaitingMessagesDrainState(0); + expect(shouldSkipMeshcoreSilentBulkGetWaitingMessages()).toBe(false); + }); +}); diff --git a/src/renderer/lib/meshcoreWaitingMessagesDrain.ts b/src/renderer/lib/meshcoreWaitingMessagesDrain.ts index 8e1e0341a..dedfd1f50 100644 --- a/src/renderer/lib/meshcoreWaitingMessagesDrain.ts +++ b/src/renderer/lib/meshcoreWaitingMessagesDrain.ts @@ -10,6 +10,7 @@ import { MESHCORE_WAITING_MESSAGES_DRAIN_DEBOUNCE_MS, MESHCORE_WAITING_MESSAGES_POLL_MS, MESHCORE_WAITING_MESSAGES_SERIAL_SILENT_TIMEOUT_MS, + MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP, MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS, MESHCORE_WAITING_MESSAGES_SYNC_TIMEOUT_MS, } from './timeConstants'; @@ -19,6 +20,10 @@ let lastCompanionTxAt = 0; let lastMsgWaitingEventAt = 0; /** Bumped when silent bulk is abandoned so a late getWaitingMessages resolve is ignored. */ let silentBulkAttemptId = 0; +/** Consecutive silent-bulk getWaitingMessages timeouts on this connection. */ +let silentBulkTimeoutStreak = 0; +/** Once tripped, skip bulk and go straight to syncNextMessage until reconnect/success. */ +let silentBulkSkipped = false; /** Record outbound companion RF TX so auto-drains can defer until the radio settles. */ export function markMeshcoreCompanionTx(): void { @@ -38,6 +43,7 @@ export function resetMeshcoreWaitingMessagesDrainState(now = 0): void { lastCompanionTxAt = now; lastMsgWaitingEventAt = now; silentBulkAttemptId += 1; + resetMeshcoreSilentBulkBreaker(); } /** Start a silent bulk getWaitingMessages attempt; return id used by {@link isMeshcoreSilentBulkAttemptCurrent}. */ @@ -114,6 +120,38 @@ export function resetMeshcoreWaitingMessagesDrainSchedule(): void { clearTimeout(debounceTimer); debounceTimer = null; } + resetMeshcoreSilentBulkBreaker(); +} + +/** Skip silent bulk getWaitingMessages after consecutive timeouts on this connection. */ +export function shouldSkipMeshcoreSilentBulkGetWaitingMessages(): boolean { + return silentBulkSkipped; +} + +/** Record a successful silent bulk drain (including empty queue). */ +export function noteMeshcoreSilentBulkSuccess(): void { + silentBulkTimeoutStreak = 0; + silentBulkSkipped = false; +} + +/** + * Record a silent-bulk getWaitingMessages timeout. + * @returns true when this call opens the circuit (log once). + */ +export function noteMeshcoreSilentBulkTimeout(): boolean { + silentBulkTimeoutStreak += 1; + if (silentBulkTimeoutStreak < MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP) { + return false; + } + if (silentBulkSkipped) return false; + silentBulkSkipped = true; + return true; +} + +/** Clear the silent-bulk timeout circuit (reconnect / tests). */ +export function resetMeshcoreSilentBulkBreaker(): void { + silentBulkTimeoutStreak = 0; + silentBulkSkipped = false; } export type MeshcoreCompanionTransport = 'ble' | 'serial' | 'tcp' | null | undefined; diff --git a/src/renderer/lib/timeConstants.ts b/src/renderer/lib/timeConstants.ts index 47bdbd5aa..bd6c77966 100644 --- a/src/renderer/lib/timeConstants.ts +++ b/src/renderer/lib/timeConstants.ts @@ -166,6 +166,9 @@ export const MESHCORE_ROOM_LOGIN_MAX_ATTEMPTS = 2; /** Delay between failed room login attempts. */ export const MESHCORE_ROOM_LOGIN_RETRY_DELAY_MS = 2_000; +/** Coalesce connect auto-login when Room contacts appear after configure. */ +export const MESHCORE_ROOM_AUTO_LOGIN_DEBOUNCE_MS = 500; + /** Background room sync scheduler tick interval. */ export const MESHCORE_ROOM_SYNC_TICK_MS = 60_000; @@ -178,6 +181,8 @@ export const MESHCORE_WAITING_MESSAGES_POLL_MS = 5 * MS_PER_MINUTE; export const MESHCORE_WAITING_MESSAGES_SYNC_TIMEOUT_MS = 60_000; /** Fail-fast timeout for silent auto-drains (event 131, connect, poll). */ export const MESHCORE_WAITING_MESSAGES_SILENT_TIMEOUT_MS = 45 * MS_PER_SECOND; +/** Consecutive silent-bulk getWaitingMessages timeouts before skipping bulk until reconnect. */ +export const MESHCORE_WAITING_MESSAGES_SILENT_BULK_TIMEOUT_TRIP = 2; /** Shorter silent timeout on USB serial (single companion RPC lane). */ export const MESHCORE_WAITING_MESSAGES_SERIAL_SILENT_TIMEOUT_MS = 15 * MS_PER_SECOND; /** Per-item timeout for silent syncNextMessage incremental drain. */ diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 0837c4cb3..2003449d1 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -279,6 +279,12 @@ import { clearMeshcoreRoomAutoLoginFailure, getMeshcoreRoomAutoLoginFailure, } from '../lib/meshcoreRoomAutoLoginFailure'; +import { + meshcoreRoomAutoLoginReadyKey, + resetMeshcoreRoomAutoLoginSingleFlight, + runMeshcoreRoomAutoLoginSingleFlight, + selectMeshcoreRoomAutoLoginTargets, +} from '../lib/meshcoreRoomAutoLoginOnConnect'; import { getMeshcoreRoomCredential, listMeshcoreRoomCredentialNodeIds, @@ -426,6 +432,7 @@ import { messageRecordsToChatMessages, nodeRecordsToMeshNodeMap } from '../lib/s import { computeRoomPostTotalTimeoutMs, MESHCORE_MAX_RECONNECT_DELAY_MS, + MESHCORE_ROOM_AUTO_LOGIN_DEBOUNCE_MS, MESHCORE_ROOM_LOGIN_HOP_BASE_MS, MESHCORE_ROOM_LOGIN_HOP_INCREMENT_MS, MESHCORE_ROOM_LOGIN_ROUTE_RESOLVE_MAX_MS, @@ -3261,6 +3268,7 @@ export function useMeshcoreRuntime() { clearTimeout(roomAutoLoginRetryTimerRef.current); roomAutoLoginRetryTimerRef.current = null; } + resetMeshcoreRoomAutoLoginSingleFlight(); teardownMeshcoreConnEventListeners({ driverDisconnect: disconnectDriver }); if (!usedDriverConnect) { try { @@ -6285,47 +6293,45 @@ export function useMeshcoreRuntime() { }, [state.status, runRoomSyncSchedulerTickBody]); const runRoomAutoLoginOnConnect = useCallback(async (): Promise => { - if (!connRef.current) return; - if (meshcoreCompanionRepeaterRfBusy()) { - console.debug('[useMeshcoreRuntime] room auto-login deferred (repeater RF busy)'); - roomAutoLoginRetryTimerRef.current ??= setTimeout(() => { - roomAutoLoginRetryTimerRef.current = null; - triggerRoomAutoLoginRef.current(); - }, MESHCORE_ROOM_SYNC_TICK_MS); - return; - } - const configuredIds = listMeshcoreRoomAutoLoginOnConnectNodeIds(); - const nodeIds = configuredIds.filter( - (id) => getIdentityNode(meshcoreIdentityIdRef.current, id)?.hw_model === 'Room', - ); - const targets = nodeIds.filter((nodeId) => { - if (meshcoreIsRoomLoggedIn(nodeId)) return false; - if (!getMeshcoreRoomCredential(nodeId)) return false; - if (getMeshcoreRoomAutoLoginFailure(nodeId)) return false; - if (!pubKeyMapRef.current.get(nodeId)) { - return false; + await runMeshcoreRoomAutoLoginSingleFlight(async () => { + if (!connRef.current) return; + if (meshcoreCompanionRepeaterRfBusy()) { + console.debug('[useMeshcoreRuntime] room auto-login deferred (repeater RF busy)'); + roomAutoLoginRetryTimerRef.current ??= setTimeout(() => { + roomAutoLoginRetryTimerRef.current = null; + triggerRoomAutoLoginRef.current(); + }, MESHCORE_ROOM_SYNC_TICK_MS); + return; } - return true; - }); - await Promise.allSettled( - targets.map(async (nodeId) => { - try { - await loginRoomWithSaved(nodeId); - lastMeshcoreRoomSyncTxAtRef.current = Date.now(); - } catch (e: unknown) { - if (!meshcoreIsRoomLoginAbortError(e)) { - await applyMeshcoreRoomLoginFailure( - nodeId, - e, - 'useMeshcoreRuntime room auto-login on connect', + const configuredIds = listMeshcoreRoomAutoLoginOnConnectNodeIds(); + const targets = selectMeshcoreRoomAutoLoginTargets(configuredIds, (nodeId) => ({ + isRoom: getIdentityNode(meshcoreIdentityIdRef.current, nodeId)?.hw_model === 'Room', + hasCredential: Boolean(getMeshcoreRoomCredential(nodeId)), + hasPubKey: Boolean(pubKeyMapRef.current.get(nodeId)), + loggedIn: meshcoreIsRoomLoggedIn(nodeId), + queued: meshcoreIsRoomLoginQueued(nodeId), + autoLoginFailed: Boolean(getMeshcoreRoomAutoLoginFailure(nodeId)), + })); + await Promise.allSettled( + targets.map(async (nodeId) => { + try { + await loginRoomWithSaved(nodeId); + lastMeshcoreRoomSyncTxAtRef.current = Date.now(); + } catch (e: unknown) { + if (!meshcoreIsRoomLoginAbortError(e)) { + await applyMeshcoreRoomLoginFailure( + nodeId, + e, + 'useMeshcoreRuntime room auto-login on connect', + ); + } + console.warn( + '[useMeshcoreRuntime] room auto-login on connect failed ' + errLikeToLogString(e), ); } - console.warn( - '[useMeshcoreRuntime] room auto-login on connect failed ' + errLikeToLogString(e), - ); - } - }), - ); + }), + ); + }); }, [loginRoomWithSaved]); const runRoomReconnectSync = useCallback(async (): Promise => { @@ -6398,10 +6404,21 @@ export function useMeshcoreRuntime() { void runRoomAutoLoginOnConnect(); }; + const roomAutoLoginReadyKey = useMemo( + () => + meshcoreRoomAutoLoginReadyKey(listMeshcoreRoomAutoLoginOnConnectNodeIds(), (id) => { + return nodes.get(id)?.hw_model === 'Room'; + }), + [nodes], + ); + useEffect(() => { if (state.status !== 'configured') return; - triggerRoomAutoLoginRef.current(); - }, [state.status, nodes.size]); + const timer = setTimeout(() => { + triggerRoomAutoLoginRef.current(); + }, MESHCORE_ROOM_AUTO_LOGIN_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [state.status, roomAutoLoginReadyKey]); useEffect(() => { const operational = state.status === 'configured' || state.status === 'connected'; From 96f8287d62167accc101cf3579d499b7c17381ed Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Tue, 11 Aug 2026 06:49:49 -0600 Subject: [PATCH 2/9] fix(rrc): stop hidden /who from flooding the room transcript Auto /who was gated by a panel ref, so returning to RRC remounted and polled again, dumping member lists into chat. Keep the once-per-join gate on the hub session, show the first roster line only, and route unscoped hub notices to [hub]. --- src/renderer/components/RrcPanel.test.tsx | 211 ++++++++++++++++++ src/renderer/components/RrcPanel.tsx | 47 ++-- src/renderer/lib/rrcMessageDisplay.test.ts | 35 +++ src/renderer/lib/rrcMessageDisplay.ts | 26 +++ src/renderer/lib/rrcNoticeParsers.test.ts | 11 + .../runtime/useReticulumRuntime.rrc.test.ts | 8 + src/renderer/runtime/useReticulumRuntime.ts | 17 +- src/renderer/stores/rrcSessionStore.test.ts | 95 ++++++++ src/renderer/stores/rrcSessionStore.ts | 84 +++++++ 9 files changed, 500 insertions(+), 34 deletions(-) diff --git a/src/renderer/components/RrcPanel.test.tsx b/src/renderer/components/RrcPanel.test.tsx index e3e5d0cef..4a50d262a 100644 --- a/src/renderer/components/RrcPanel.test.tsx +++ b/src/renderer/components/RrcPanel.test.tsx @@ -24,6 +24,13 @@ vi.mock('@/renderer/lib/reticulum/reticulumSidecarReads', () => ({ const hubA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const hubB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +function whoSendCalls(): unknown[][] { + return vi.mocked(window.electronAPI.reticulum.rrc.send).mock.calls.filter((args) => { + const body = (args[0] as { body?: string } | undefined)?.body; + return typeof body === 'string' && body.startsWith('/who'); + }); +} + describe('RrcPanel', () => { beforeEach(() => { useRrcSessionStore.getState().clearSession(); @@ -36,6 +43,12 @@ describe('RrcPanel', () => { vi.mocked(window.electronAPI.reticulum.rrc.connect).mockResolvedValue({ ok: true }); vi.mocked(window.electronAPI.reticulum.rrc.disconnect).mockReset(); vi.mocked(window.electronAPI.reticulum.rrc.disconnect).mockResolvedValue({ ok: true }); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockReset(); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockResolvedValue({ ok: true }); + vi.mocked(window.electronAPI.reticulum.rrc.join).mockReset(); + vi.mocked(window.electronAPI.reticulum.rrc.join).mockResolvedValue({ ok: true }); + vi.mocked(window.electronAPI.reticulum.rrc.setNickname).mockReset(); + vi.mocked(window.electronAPI.reticulum.rrc.setNickname).mockResolvedValue({ ok: true }); vi.mocked(window.electronAPI.db.deleteRrcMessagesByRoom).mockClear(); vi.mocked(window.electronAPI.db.deleteRrcMessagesByRoom).mockResolvedValue({ changes: 1 }); localStorage.removeItem('mesh-client:rrc:hubAutoJoin'); @@ -427,6 +440,204 @@ describe('RrcPanel', () => { expect(whoCalls.some((args) => (args[0] as { room?: string }).room === `@${peerHash}`)).toBe( false, ); + expect(whoCalls.some((args) => (args[0] as { body?: string }).body === '/who #general')).toBe( + true, + ); + expect(whoCalls.every((args) => (args[0] as { room?: string }).room == null)).toBe(true); + }); + + it('does not re-send /who or reconnect when remounting a populated roster', async () => { + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.roomJoined('general', [ + { identity_hash: 'cccccccccccccccccccccccccccccccc', nickname: 'Alice' }, + ]); + store.setActiveRoom('general'); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + vi.mocked(window.electronAPI.reticulum.rrc.connect).mockClear(); + vi.mocked(window.electronAPI.reticulum.rrc.disconnect).mockClear(); + + const { unmount } = render(); + await new Promise((r) => setTimeout(r, 40)); + expect(whoSendCalls()).toHaveLength(0); + expect(window.electronAPI.reticulum.rrc.connect).not.toHaveBeenCalled(); + expect(window.electronAPI.reticulum.rrc.disconnect).not.toHaveBeenCalled(); + + unmount(); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + vi.mocked(window.electronAPI.reticulum.rrc.connect).mockClear(); + vi.mocked(window.electronAPI.reticulum.rrc.disconnect).mockClear(); + + render(); + await new Promise((r) => setTimeout(r, 40)); + expect(whoSendCalls()).toHaveLength(0); + expect(window.electronAPI.reticulum.rrc.connect).not.toHaveBeenCalled(); + expect(window.electronAPI.reticulum.rrc.disconnect).not.toHaveBeenCalled(); + }); + + it('does not re-send /who when isActive toggles on a still-mounted panel', async () => { + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.roomJoined('general', [ + { identity_hash: 'cccccccccccccccccccccccccccccccc', nickname: 'Alice' }, + ]); + store.setActiveRoom('general'); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + vi.mocked(window.electronAPI.reticulum.rrc.connect).mockClear(); + vi.mocked(window.electronAPI.reticulum.rrc.disconnect).mockClear(); + + const { rerender } = render(); + await new Promise((r) => setTimeout(r, 40)); + rerender(); + await new Promise((r) => setTimeout(r, 40)); + expect(whoSendCalls()).toHaveLength(0); + expect(window.electronAPI.reticulum.rrc.connect).not.toHaveBeenCalled(); + expect(window.electronAPI.reticulum.rrc.disconnect).not.toHaveBeenCalled(); + }); + + it('sends one hub-global /who for an empty joined roster', async () => { + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.roomJoined('general'); + store.setActiveRoom('general'); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + + render(); + await waitFor(() => { + expect(whoSendCalls()).toHaveLength(1); + }); + expect(whoSendCalls()[0]?.[0]).toEqual({ + hub_dest_hash: hubA, + body: '/who general', + type: 'msg', + }); + }); + + it('sends one /who per empty joined room and none after remount', async () => { + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.roomJoined('general'); + store.roomJoined('lobby'); + store.setActiveRoom('general'); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + + const { unmount } = render(); + await waitFor(() => { + expect(whoSendCalls()).toHaveLength(2); + }); + const bodies = whoSendCalls() + .map((args) => (args[0] as { body: string }).body) + .sort(); + expect(bodies).toEqual(['/who general', '/who lobby']); + + unmount(); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + render(); + await new Promise((r) => setTimeout(r, 40)); + expect(whoSendCalls()).toHaveLength(0); + }); + + it('does not force /who when /join targets an already-joined room', async () => { + const user = userEvent.setup(); + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.roomJoined('general', [ + { identity_hash: 'cccccccccccccccccccccccccccccccc', nickname: 'Alice' }, + ]); + store.setActiveRoom('[hub]'); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + vi.mocked(window.electronAPI.reticulum.rrc.join).mockClear(); + + render(); + await new Promise((r) => setTimeout(r, 40)); + expect(whoSendCalls()).toHaveLength(0); + + const composer = screen.getByRole('textbox', { name: /Message or \/command/i }); + await user.clear(composer); + await user.type(composer, '/join general'); + await user.click(screen.getByRole('button', { name: 'Send' })); + + await waitFor(() => { + expect(useRrcSessionStore.getState().activeRoom).toBe('general'); + }); + expect(window.electronAPI.reticulum.rrc.join).not.toHaveBeenCalled(); + expect(whoSendCalls()).toHaveLength(0); + }); + + it('still sends /who from the nicklist refresh button', async () => { + const user = userEvent.setup(); + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.roomJoined('general', [ + { identity_hash: 'cccccccccccccccccccccccccccccccc', nickname: 'Alice' }, + ]); + store.setActiveRoom('general'); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + + render(); + await new Promise((r) => setTimeout(r, 40)); + expect(whoSendCalls()).toHaveLength(0); + + await user.click(screen.getByRole('button', { name: 'Refresh members (/who)' })); + await waitFor(() => { + expect(whoSendCalls()).toHaveLength(1); + }); + expect(whoSendCalls()[0]?.[0]).toEqual({ + hub_dest_hash: hubA, + body: '/who general', + type: 'msg', + }); + }); + + it('still sends hub /who after /nick', async () => { + const user = userEvent.setup(); + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.roomJoined('general', [ + { identity_hash: 'cccccccccccccccccccccccccccccccc', nickname: 'Alice' }, + ]); + store.setActiveRoom('general'); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + + render(); + const composer = screen.getByRole('textbox', { name: /Message or \/command/i }); + await user.clear(composer); + await user.type(composer, '/nick NewNick'); + await user.click(screen.getByRole('button', { name: 'Send' })); + + await waitFor(() => { + expect(window.electronAPI.reticulum.rrc.setNickname).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(whoSendCalls().some((args) => (args[0] as { body?: string }).body === '/who')).toBe( + true, + ); + }); + }); + + it('allows auto /who again after part then rejoin', async () => { + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.roomJoined('general'); + store.setActiveRoom('general'); + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + + render(); + await waitFor(() => { + expect(whoSendCalls()).toHaveLength(1); + }); + + vi.mocked(window.electronAPI.reticulum.rrc.send).mockClear(); + store.roomParted('general'); + store.roomJoined('general'); + await waitFor(() => { + expect(whoSendCalls()).toHaveLength(1); + }); + expect(whoSendCalls()[0]?.[0]).toEqual({ + hub_dest_hash: hubA, + body: '/who general', + type: 'msg', + }); }); it('rejects plain text in [hub] with join-room prompt', async () => { diff --git a/src/renderer/components/RrcPanel.tsx b/src/renderer/components/RrcPanel.tsx index dacadec86..98490234c 100644 --- a/src/renderer/components/RrcPanel.tsx +++ b/src/renderer/components/RrcPanel.tsx @@ -1,5 +1,5 @@ import { Bell, BellOff, Clock, LogOut, Trash2, X } from 'lucide-react-motion'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ConfirmModal } from '@/renderer/components/ConfirmModal'; @@ -155,8 +155,6 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: const [mutedViews, setMutedViews] = useState(() => loadMutedViews('reticulum')); const [draft, setDraft] = useState(''); const [confirmClearHistory, setConfirmClearHistory] = useState(false); - /** Per-hub room keys we already requested `/who` for (rrcd JOINED often has no roster). */ - const whoRequestedRef = useRef(new Set()); useEffect(() => { try { @@ -255,12 +253,6 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: [activeRoom, hubDestHash, setError, status, t], ); - useEffect(() => { - if (sessionsByHub.size === 0) { - whoRequestedRef.current.clear(); - } - }, [sessionsByHub]); - const requestRoomWho = useCallback( (roomRaw: string, force = false) => { if (status !== 'active' || !hubDestHash) return; @@ -270,35 +262,35 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: }); // Never /who synthetic streams or per-peer DMs (client-local only). if (!room || room.startsWith('[') || isRrcDmRoom(room)) return; - const reqKey = `${hubDestHash}::${rrcRoomMatchKey(room)}`; - if (!force && whoRequestedRef.current.has(reqKey)) return; - whoRequestedRef.current.add(reqKey); + const session = useRrcSessionStore.getState(); + if (!force) { + const info = [...rooms.values()].find((r) => rrcRoomsMatch(r.name, room)); + if ((info?.members?.length ?? 0) > 0) { + session.markWhoRequested(room, hubDestHash); + return; + } + if (!session.markWhoRequested(room, hubDestHash)) return; + } else { + session.markWhoRequested(room, hubDestHash); + } + // Hub-global slash command — omit K_ROOM so rrcd does not treat this as room chat. void window.electronAPI.reticulum.rrc - .send({ hub_dest_hash: hubDestHash, room, body: `/who ${room}`, type: 'msg' }) + .send({ hub_dest_hash: hubDestHash, body: `/who ${room}`, type: 'msg' }) .catch((e: unknown) => { - whoRequestedRef.current.delete(reqKey); + if (!force) useRrcSessionStore.getState().releaseWhoRequested(room, hubDestHash); console.debug('[RrcPanel] /who ' + errLikeToLogString(e)); }); }, [status, hubDestHash, listedRooms, rooms], ); - // rrcd JOINED member lists are optional (off by default) — request `/who` per joined room. + // rrcd JOINED member lists are optional (off by default) — request `/who` once per join. useEffect(() => { if (status !== 'active' || !hubDestHash) return; - const live = new Set(); for (const key of rooms.keys()) { if (!key || key.startsWith('[') || isRrcDmRoom(key)) continue; - const reqKey = `${hubDestHash}::${rrcRoomMatchKey(key)}`; - live.add(reqKey); requestRoomWho(key, false); } - // Drop parted rooms so a later re-join triggers a fresh `/who`. - for (const prev of [...whoRequestedRef.current]) { - if (prev.startsWith(`${hubDestHash}::`) && !live.has(prev)) { - whoRequestedRef.current.delete(prev); - } - } }, [status, hubDestHash, rooms, requestRoomWho]); const hubList = useMemo(() => { @@ -560,11 +552,10 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: if (isRrcDmRoom(room)) setActiveRoom(room); return; } - // Already in this channel (possibly under `#name` vs `name`) — focus + refresh roster. + // Already in this channel (possibly under `#name` vs `name`) — focus only. const existingKey = [...rooms.keys()].find((k) => rrcRoomsMatch(k, room)); if (existingKey) { setActiveRoom(existingKey); - requestRoomWho(existingKey, true); return; } setActionBusy(true); @@ -580,8 +571,6 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: setActiveRoom(room); pushRrcRecentRoom(hubDestHash, rrcRoomMatchKey(room)); setRecentRoomsEpoch((n) => n + 1); - // Always refresh people list — rrcd JOINED often has no member body. - requestRoomWho(room, true); } } catch (e) { // catch-no-log-ok error surfaced via setError @@ -590,7 +579,7 @@ export default function RrcPanel({ isActive, alwaysShowMessageActions = false }: setActionBusy(false); } }, - [hubDestHash, listedRooms, rooms, requestRoomWho, setActiveRoom, setError, t], + [hubDestHash, listedRooms, rooms, setActiveRoom, setError, t], ); const handlePart = useCallback( diff --git a/src/renderer/lib/rrcMessageDisplay.test.ts b/src/renderer/lib/rrcMessageDisplay.test.ts index 878755401..265e2d34b 100644 --- a/src/renderer/lib/rrcMessageDisplay.test.ts +++ b/src/renderer/lib/rrcMessageDisplay.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from 'vitest'; +import { RRC_HUB_STREAM_ROOM } from '@/renderer/stores/rrcSessionStore'; + import { parseRrcWhisperEcho, + resolveRrcInboundChatRoom, + RRC_UNSCOPED_NOTICE_ROOM, shouldDisplayRrcChatMessage, shouldDropEmptyRrcInbound, + shouldShowRrcWhoTranscript, } from './rrcMessageDisplay'; describe('shouldDisplayRrcChatMessage / shouldDropEmptyRrcInbound', () => { @@ -23,6 +28,36 @@ describe('shouldDisplayRrcChatMessage / shouldDropEmptyRrcInbound', () => { }); }); +describe('resolveRrcInboundChatRoom', () => { + it('keeps room-scoped envelopes in that room', () => { + expect(resolveRrcInboundChatRoom('general')).toBe('general'); + expect(resolveRrcInboundChatRoom(' #lobby ')).toBe('#lobby'); + }); + + it('routes empty K_ROOM to [hub], not the focused chat room', () => { + expect(resolveRrcInboundChatRoom('')).toBe(RRC_HUB_STREAM_ROOM); + expect(resolveRrcInboundChatRoom(null)).toBe(RRC_HUB_STREAM_ROOM); + expect(resolveRrcInboundChatRoom(undefined)).toBe(RRC_HUB_STREAM_ROOM); + expect(RRC_UNSCOPED_NOTICE_ROOM).toBe(RRC_HUB_STREAM_ROOM); + }); +}); + +describe('shouldShowRrcWhoTranscript', () => { + it('allows the first /who snapshot per room and hides later ones', () => { + const shown = new Set(); + expect(shouldShowRrcWhoTranscript(shown, 'general')).toBe(true); + shown.add('general'); + expect(shouldShowRrcWhoTranscript(shown, 'general')).toBe(false); + expect(shouldShowRrcWhoTranscript(shown, '#general')).toBe(false); + expect(shouldShowRrcWhoTranscript(shown, 'lobby')).toBe(true); + }); + + it('never shows synthetic or DM rooms as /who transcript lines', () => { + expect(shouldShowRrcWhoTranscript(new Set(), '[hub]')).toBe(false); + expect(shouldShowRrcWhoTranscript(new Set(), '@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')).toBe(false); + }); +}); + describe('parseRrcWhisperEcho', () => { it('parses → name: text', () => { expect(parseRrcWhisperEcho('→ Zeva: hello')).toEqual({ name: 'Zeva', text: 'hello' }); diff --git a/src/renderer/lib/rrcMessageDisplay.ts b/src/renderer/lib/rrcMessageDisplay.ts index 15af8dd59..18f01a1d2 100644 --- a/src/renderer/lib/rrcMessageDisplay.ts +++ b/src/renderer/lib/rrcMessageDisplay.ts @@ -1,5 +1,9 @@ +import { rrcRoomMatchKey } from '@/renderer/lib/rrcRoomName'; import type { RrcChatMessage } from '@/shared/rrc-types'; +/** Hub-scoped stream for inbound notices with no K_ROOM (must match RRC_HUB_STREAM_ROOM). */ +export const RRC_UNSCOPED_NOTICE_ROOM = '[hub]'; + /** * Empty notice/system/error rows render as a lone IRC `*` — hide them. * MSG/ACTION with empty body are still shown (rare). @@ -19,6 +23,28 @@ export function shouldDropEmptyRrcInbound(kind: string, body: string): boolean { return false; } +/** + * Room for a non-DM inbound envelope. Empty K_ROOM (hub-global /list, /who, greeting) + * goes to `[hub]` — never the focused chat room. + */ +export function resolveRrcInboundChatRoom(wireRoom: string | null | undefined): string { + const room = wireRoom?.trim() ?? ''; + return room || RRC_UNSCOPED_NOTICE_ROOM; +} + +/** + * First `/who` NOTICE per room join may appear in chat; later snapshots update the + * nicklist only. `shownMatchKeys` holds `rrcRoomMatchKey` values already shown. + */ +export function shouldShowRrcWhoTranscript( + shownMatchKeys: ReadonlySet, + room: string, +): boolean { + const key = rrcRoomMatchKey(room); + if (!key || key.startsWith('[') || key.startsWith('@')) return false; + return !shownMatchKeys.has(key); +} + /** * Parse legacy local whisper-sent echo body (`→ name: text`). * New outbound whispers are stored as room-style `msg` rows. diff --git a/src/renderer/lib/rrcNoticeParsers.test.ts b/src/renderer/lib/rrcNoticeParsers.test.ts index 6ab28f1ce..cdb3903e6 100644 --- a/src/renderer/lib/rrcNoticeParsers.test.ts +++ b/src/renderer/lib/rrcNoticeParsers.test.ts @@ -51,6 +51,17 @@ describe('parseRrcWhoNotice', () => { members: [], }); }); + + it('classifies /who lines and rejects join-info and /list', () => { + expect(parseRrcWhoNotice('members in general: Alice (aabbccddeeff)')).not.toBeNull(); + expect(parseRrcWhoNotice('members in general: (none)')).not.toBeNull(); + expect( + parseRrcWhoNotice('room general: registered; mode=+r; topic=General chat - Colorado Mesh'), + ).toBeNull(); + expect( + parseRrcWhoNotice('Registered public rooms:\n general - General chat - Colorado Mesh'), + ).toBeNull(); + }); }); describe('parseRrcTopicNotice', () => { diff --git a/src/renderer/runtime/useReticulumRuntime.rrc.test.ts b/src/renderer/runtime/useReticulumRuntime.rrc.test.ts index 7cf0eee3b..233411420 100644 --- a/src/renderer/runtime/useReticulumRuntime.rrc.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.rrc.test.ts @@ -39,6 +39,14 @@ describe('useReticulumRuntime RRC event routing (regression)', () => { expect(SOURCE).toMatch(/addMessage\([\s\S]*?\{ hubDestHash \}/); }); + it('suppresses later /who notices after mergeRoomMembers and consumeWhoTranscriptSlot', () => { + expect(SOURCE).toContain('resolveRrcInboundChatRoom'); + expect(SOURCE).toMatch(/room = resolveRrcInboundChatRoom\(/); + expect(SOURCE).toMatch(/mergeRoomMembers\(who\.room, who\.members, 'replace', hubDestHash\)/); + expect(SOURCE).toMatch(/consumeWhoTranscriptSlot\(who\.room, hubDestHash\)[\s\S]*?return;/); + expect(SOURCE).toMatch(/if \(who\) \{[\s\S]*?room = who\.room/); + }); + it('routes direct NOTICE into per-peer @hash DMs via applyRrcDirectMessageRoom', () => { expect(SOURCE).toContain('applyRrcDirectMessageRoom'); expect(SOURCE).toMatch(/applyRrcDirectMessageRoom\(\{[\s\S]*?openDm:/); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index f4e679d6b..33a4179f2 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -129,7 +129,10 @@ import { import { consumeRncpReceiveDestSharePending } from '@/renderer/lib/rncpReceiveDestSharePending'; import { applyRrcDirectMessageRoom } from '@/renderer/lib/rrcDirectMessageRoute'; import { isRrcRoomMuted } from '@/renderer/lib/rrcMention'; -import { shouldDropEmptyRrcInbound } from '@/renderer/lib/rrcMessageDisplay'; +import { + resolveRrcInboundChatRoom, + shouldDropEmptyRrcInbound, +} from '@/renderer/lib/rrcMessageDisplay'; import { LARGE_MESH_NODE_THRESHOLD, MEGA_MESH_FULL_PEER_REFRESH_MAX_AGE_MS, @@ -1154,10 +1157,7 @@ export function useReticulumRuntime(): ProtocolRuntime { }, }); } else { - room = - typeof p.room === 'string' && p.room.trim() - ? p.room - : (view.activeRoom ?? RRC_HUB_STREAM_ROOM); + room = resolveRrcInboundChatRoom(typeof p.room === 'string' ? p.room : undefined); } if (kind === 'notice') { @@ -1183,6 +1183,13 @@ export function useReticulumRuntime(): ProtocolRuntime { // Reserve kick/ban banner copy for moderation notices; transcript keeps hub text. session.setModerationBanner('rrc.moderation.removedFromRoom', hubDestHash); } + // First `/who` snapshot may appear in the named room; later ones are nicklist-only. + if (who) { + if (!session.consumeWhoTranscriptSlot(who.room, hubDestHash)) { + return; + } + room = who.room; + } } // Opportunistic nicklist: room chat reveals senders even before `/who`. diff --git a/src/renderer/stores/rrcSessionStore.test.ts b/src/renderer/stores/rrcSessionStore.test.ts index ba9116ca7..89942bc26 100644 --- a/src/renderer/stores/rrcSessionStore.test.ts +++ b/src/renderer/stores/rrcSessionStore.test.ts @@ -541,4 +541,99 @@ describe('rrcSessionStore', () => { expect(list.map((m) => m.id)).toEqual(['hist-1', 'live-1']); expect(list[1]?.body).toBe('live'); }); + + it('shows the first /who NOTICE and suppresses later snapshots while updating roster', () => { + const hub = '28c7c1a68c735693aa8e6b8193ed44b2'; + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hub, 'Community'); + store.roomJoined('general'); + store.setActiveRoom('general'); + store.mergeRoomMembers( + 'general', + [{ identity_hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', nickname: 'Alice' }], + 'replace', + ); + expect(store.consumeWhoTranscriptSlot('general')).toBe(true); + store.addMessage({ + id: 'who-1', + room: 'general', + kind: 'notice', + body: 'members in general: Alice (aaaaaaaaaaaa)', + timestamp: 1, + }); + store.mergeRoomMembers( + 'general', + [{ identity_hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', nickname: 'Bob' }], + 'replace', + ); + expect(useRrcSessionStore.getState().consumeWhoTranscriptSlot('general')).toBe(false); + const key = useRrcSessionStore.getState().roomMessageKey('general', hub)!; + expect(useRrcSessionStore.getState().messages.get(key)).toHaveLength(1); + expect(useRrcSessionStore.getState().rooms.get('general')?.members).toEqual([ + { identity_hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', nickname: 'Bob' }, + ]); + }); + + it('resets /who gates on part for that room only', () => { + const hub = '28c7c1a68c735693aa8e6b8193ed44b2'; + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hub, 'Community'); + store.roomJoined('general'); + store.roomJoined('lobby'); + expect(store.markWhoRequested('general')).toBe(true); + expect(store.consumeWhoTranscriptSlot('general')).toBe(true); + expect(store.markWhoRequested('lobby')).toBe(true); + expect(store.consumeWhoTranscriptSlot('lobby')).toBe(true); + store.roomParted('general'); + expect(useRrcSessionStore.getState().markWhoRequested('general')).toBe(true); + expect(useRrcSessionStore.getState().consumeWhoTranscriptSlot('general')).toBe(true); + expect(useRrcSessionStore.getState().markWhoRequested('lobby')).toBe(false); + expect(useRrcSessionStore.getState().consumeWhoTranscriptSlot('lobby')).toBe(false); + }); + + it('keeps /who gates and rooms across reconnecting → active', () => { + const hub = '28c7c1a68c735693aa8e6b8193ed44b2'; + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hub, 'Community'); + store.roomJoined('general'); + expect(store.markWhoRequested('general')).toBe(true); + expect(store.consumeWhoTranscriptSlot('general')).toBe(true); + store.applyStatus('reconnecting', hub); + store.applyStatus('active', hub, 'Community'); + const session = useRrcSessionStore.getState().sessionsByHub.get(hub); + expect(session?.rooms.has('general')).toBe(true); + expect(session?.whoRequestedRooms.has('general')).toBe(true); + expect(session?.whoTranscriptShownRooms.has('general')).toBe(true); + expect(useRrcSessionStore.getState().markWhoRequested('general')).toBe(false); + expect(useRrcSessionStore.getState().consumeWhoTranscriptSlot('general')).toBe(false); + }); + + it('drops /who gates on clearHubSession so a new connection can show one roster', () => { + const hub = '28c7c1a68c735693aa8e6b8193ed44b2'; + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hub, 'Community'); + store.roomJoined('general'); + expect(store.markWhoRequested('general')).toBe(true); + expect(store.consumeWhoTranscriptSlot('general')).toBe(true); + store.clearHubSession(hub); + store.applyStatus('active', hub, 'Community'); + expect(useRrcSessionStore.getState().markWhoRequested('general')).toBe(true); + expect(useRrcSessionStore.getState().consumeWhoTranscriptSlot('general')).toBe(true); + }); + + it('keeps independent /who slots per hub and per room', () => { + const hubA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const hubB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const store = useRrcSessionStore.getState(); + store.applyStatus('active', hubA, 'Hub A'); + store.roomJoined('general', undefined, hubA); + expect(store.markWhoRequested('general', hubA)).toBe(true); + expect(store.consumeWhoTranscriptSlot('general', hubA)).toBe(true); + store.applyStatus('active', hubB, 'Hub B'); + store.roomJoined('general', undefined, hubB); + expect(store.markWhoRequested('general', hubB)).toBe(true); + expect(store.consumeWhoTranscriptSlot('general', hubB)).toBe(true); + expect(store.markWhoRequested('general', hubA)).toBe(false); + expect(store.consumeWhoTranscriptSlot('general', hubA)).toBe(false); + }); }); diff --git a/src/renderer/stores/rrcSessionStore.ts b/src/renderer/stores/rrcSessionStore.ts index b274f20c4..ab24726a5 100644 --- a/src/renderer/stores/rrcSessionStore.ts +++ b/src/renderer/stores/rrcSessionStore.ts @@ -6,6 +6,7 @@ import { type RrcDmPeer, rrcDmRoomKey, } from '@/renderer/lib/rrcDmRoom'; +import { shouldShowRrcWhoTranscript } from '@/renderer/lib/rrcMessageDisplay'; import { persistRrcMessage } from '@/renderer/lib/rrcMessagePersist'; import { removeRrcOpenDm, upsertRrcOpenDm } from '@/renderer/lib/rrcOpenDms'; import { clearHydratedRrcRoomKeysForHub } from '@/renderer/lib/rrcRoomHistoryHydration'; @@ -135,6 +136,10 @@ export interface RrcHubSessionState { partIntentRooms: Set; /** True when user requested disconnect (not hub drop). */ disconnectIntent: boolean; + /** Soft room keys we already auto-requested `/who` for (survives panel remount). */ + whoRequestedRooms: Set; + /** Soft room keys that already showed one `/who` NOTICE in the transcript. */ + whoTranscriptShownRooms: Set; } export function emptyHubSession(): RrcHubSessionState { @@ -150,9 +155,19 @@ export function emptyHubSession(): RrcHubSessionState { unreadByRoom: new Map(), partIntentRooms: new Set(), disconnectIntent: false, + whoRequestedRooms: new Set(), + whoTranscriptShownRooms: new Set(), }; } +function dropMatchingWhoKeys(set: Set, room: string): Set { + const next = new Set(set); + for (const k of next) { + if (rrcRoomsMatch(k, room)) next.delete(k); + } + return next; +} + /** Mirror the focused hub's per-hub fields onto the store's top-level compat fields. */ function mirrorFromSession( hub: string | null, @@ -337,6 +352,18 @@ interface RrcSessionStoreState { clearSession: () => void; /** Tear down one hub after a local disconnect. */ clearHubSession: (hubHash: string) => void; + /** + * Mark a room as auto-`/who`'d. Returns true when this call newly reserved the + * slot (caller should send). Survives remount; cleared on part / hub teardown. + */ + markWhoRequested: (room: string, hubHash?: string) => boolean; + /** Release the auto-`/who` slot after a failed send so a later attempt can retry. */ + releaseWhoRequested: (room: string, hubHash?: string) => void; + /** + * First `/who` NOTICE per join may go to chat. Returns true when this notice + * should be appended; later snapshots update the nicklist only. + */ + consumeWhoTranscriptSlot: (room: string, hubHash?: string) => boolean; /** Sum of live unread across every session, plus stashed unread for removed hubs. */ totalUnread: () => number; unreadForHub: (hubHash: string) => number; @@ -720,12 +747,16 @@ export const useRrcSessionStore = create((set, get) => ({ for (const k of [...partIntentRooms]) { if (rrcRoomsMatch(k, room)) partIntentRooms.delete(k); } + const whoRequestedRooms = dropMatchingWhoKeys(existing.whoRequestedRooms, room); + const whoTranscriptShownRooms = dropMatchingWhoKeys(existing.whoTranscriptShownRooms, room); const activeGone = existing.activeRoom != null && rrcRoomsMatch(existing.activeRoom, room); const nextSession: RrcHubSessionState = { ...existing, rooms, unreadByRoom, partIntentRooms, + whoRequestedRooms, + whoTranscriptShownRooms, activeRoom: activeGone ? null : existing.activeRoom, }; const sessionsByHub = new Map(s.sessionsByHub); @@ -900,4 +931,57 @@ export const useRrcSessionStore = create((set, get) => ({ if (!hub) return null; return msgKey(hub, room); }, + + markWhoRequested: (room, hubHash) => { + let added = false; + set((s) => { + const hub = hubHash !== undefined ? normHub(hubHash) : s.focusedHubHash; + if (!hub) return {}; + const existing = s.sessionsByHub.get(hub); + if (!existing) return {}; + const key = rrcRoomMatchKey(room); + if (!key) return {}; + if (existing.whoRequestedRooms.has(key)) return {}; + added = true; + const whoRequestedRooms = new Set(existing.whoRequestedRooms); + whoRequestedRooms.add(key); + const nextSession: RrcHubSessionState = { ...existing, whoRequestedRooms }; + const sessionsByHub = new Map(s.sessionsByHub); + sessionsByHub.set(hub, nextSession); + const mirror = hub === s.focusedHubHash ? mirrorFromSession(hub, nextSession) : {}; + return { sessionsByHub, ...mirror }; + }); + return added; + }, + + releaseWhoRequested: (room, hubHash) => { + set((s) => + mutateHubSession(s, hubHash, (session) => ({ + ...session, + whoRequestedRooms: dropMatchingWhoKeys(session.whoRequestedRooms, room), + })), + ); + }, + + consumeWhoTranscriptSlot: (room, hubHash) => { + let show = false; + set((s) => { + const hub = hubHash !== undefined ? normHub(hubHash) : s.focusedHubHash; + if (!hub) return {}; + const existing = s.sessionsByHub.get(hub); + if (!existing) return {}; + if (!shouldShowRrcWhoTranscript(existing.whoTranscriptShownRooms, room)) return {}; + const key = rrcRoomMatchKey(room); + if (!key) return {}; + show = true; + const whoTranscriptShownRooms = new Set(existing.whoTranscriptShownRooms); + whoTranscriptShownRooms.add(key); + const nextSession: RrcHubSessionState = { ...existing, whoTranscriptShownRooms }; + const sessionsByHub = new Map(s.sessionsByHub); + sessionsByHub.set(hub, nextSession); + const mirror = hub === s.focusedHubHash ? mirrorFromSession(hub, nextSession) : {}; + return { sessionsByHub, ...mirror }; + }); + return show; + }, })); From 11e6f38a918ddfd82a0475603fe79cf9c4dc70ce Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Tue, 11 Aug 2026 07:19:52 -0600 Subject: [PATCH 3/9] fix: stop Graph/Topology from hiding nodes despite hop filters Apply the 48-node layout budget only when distant peers are hidden; use 400 when they are shown. Document the cap, show it on both panels, and add a Reticulum Topology RF-only filter. --- README.md | 1 + docs/agents/reticulum.md | 2 +- docs/diagnostics.md | 7 +- docs/reticulum.md | 2 +- docs/troubleshooting.md | 8 + .../components/PeerGraphPanel.test.tsx | 115 ++++++++ src/renderer/components/PeerGraphPanel.tsx | 26 +- .../ReticulumTopologyPanel.test.tsx | 158 +++++++++-- .../components/ReticulumTopologyPanel.tsx | 83 ++++-- .../TopologyVisibleLimitNote.test.tsx | 17 ++ .../components/TopologyVisibleLimitNote.tsx | 15 + .../lib/buildMeshPeerTopologyGraph.test.ts | 154 +++++++++++ .../lib/buildMeshPeerTopologyGraph.ts | 13 +- .../buildReticulumTopologyLayout.test.ts | 256 +++++++++++++++++- .../reticulum/buildReticulumTopologyLayout.ts | 40 ++- .../reticulumTopologyPeerRenderSelect.test.ts | 51 ++++ .../reticulumTopologyPeerRenderSelect.ts | 30 ++ .../reticulumTopologyRfFilter.test.ts | 86 ++++++ .../reticulum/reticulumTopologyRfFilter.ts | 76 ++++++ src/renderer/lib/topologyGraphLimits.test.ts | 33 +++ src/renderer/lib/topologyGraphLimits.ts | 14 + src/renderer/locales/cs/translation.json | 9 +- src/renderer/locales/de/translation.json | 9 +- src/renderer/locales/en/translation.json | 5 + src/renderer/locales/es/translation.json | 9 +- src/renderer/locales/fr/translation.json | 9 +- src/renderer/locales/id/translation.json | 9 +- src/renderer/locales/it/translation.json | 9 +- src/renderer/locales/ja/translation.json | 9 +- src/renderer/locales/ko/translation.json | 9 +- src/renderer/locales/nl/translation.json | 9 +- src/renderer/locales/pl/translation.json | 9 +- src/renderer/locales/pt-BR/translation.json | 9 +- src/renderer/locales/ru/translation.json | 9 +- src/renderer/locales/tr/translation.json | 9 +- src/renderer/locales/uk/translation.json | 9 +- src/renderer/locales/zh/translation.json | 9 +- 37 files changed, 1236 insertions(+), 91 deletions(-) create mode 100644 src/renderer/components/PeerGraphPanel.test.tsx create mode 100644 src/renderer/components/TopologyVisibleLimitNote.test.tsx create mode 100644 src/renderer/components/TopologyVisibleLimitNote.tsx create mode 100644 src/renderer/lib/reticulum/reticulumTopologyPeerRenderSelect.test.ts create mode 100644 src/renderer/lib/reticulum/reticulumTopologyPeerRenderSelect.ts create mode 100644 src/renderer/lib/reticulum/reticulumTopologyRfFilter.test.ts create mode 100644 src/renderer/lib/reticulum/reticulumTopologyRfFilter.ts create mode 100644 src/renderer/lib/topologyGraphLimits.test.ts create mode 100644 src/renderer/lib/topologyGraphLimits.ts diff --git a/README.md b/README.md index ff3089811..f999107e7 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,7 @@ Architecture and API: [docs/reticulum.md](docs/reticulum.md). Games wire parity: - **Map tiles; OpenStreetMap Referer requirement**: Packaged desktop builds load the UI from the local filesystem. The main process now loads the renderer with an explicit HTTP referrer so OpenStreetMap tile requests include a valid `Referer` header and comply with the [tile usage policy](https://operations.osmfoundation.org/policies/tiles/). If you point the app at a different tile server, ensure its usage policy permits this client. - **Reticulum — no LoRa companion parity**: Reticulum does not use Meshtastic/MeshCore `ConnectionDriver`, MQTT hybrid, channel pills, Rooms BBS, or Hop Goblins diagnostics. The **Chat** tab is **DM-only**; hub room chat lives on the **RRC** tab. Interface add/edit/delete updates config on disk — **restart the stack** after changes under `rns-stack`. - **Reticulum — sidecar license**: The spawned `mesh-client-reticulum` binary is **AGPL-3.0** (separate process from the MIT Electron shell). See [docs/reticulum.md](docs/reticulum.md) and [docs/credits.md](docs/credits.md#bundled-binaries). +- **Graph / Topology visible-node cap**: Meshtastic and MeshCore **Graph** and Reticulum **Topology** render at most **400** nodes when **Show distant peers** is on (force-layout budget; **48** when distant peers are hidden). Hop filters apply first. The Graph/Topology toolbar states this limit. Reticulum Topology can also filter **RF only** (RNode / KISS / BLE; hides TCP/I2P/Auto). Reticulum path-table ingest is a separate layer (renderer feed **800**, sidecar **2,000**). - **Reticulum — propagation required for offline peers**: LXMF send fails with `no_propagation_node` when the destination is not in the path table and no cascade candidates exist (enabled remotes or local-prop). Local inbox Completes (`stored_locally`) ≠ peer delivery at a remote PN. When a path exists, Direct is tried first; on Direct fail the sidecar cascades preferred remote → other enabled remotes (hop-sorted) → local-prop last. --- diff --git a/docs/agents/reticulum.md b/docs/agents/reticulum.md index ee6260a32..fbd05ca96 100644 --- a/docs/agents/reticulum.md +++ b/docs/agents/reticulum.md @@ -20,7 +20,7 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Re - **LXMF outbound delivery:** sidecar `lxmf_delivery.rs` / `lxmf_outbound.rs` / `pn_cascade.rs` (Direct-first; after Direct exhausts **multi-PN cascade**: preferred remote → other enabled remotes hop-sorted → in **Auto** only, up to 3 heard-but-not-added Discovered PNs hop-sorted → local-prop last; intermediate WS `sending` + `delivery_method: "propagated"` or `"stored_locally"`; terminal `delivered` at remote PN vs `stored_locally` for local hosted PN). **Local-prop** is a full PN (in-process cascade deposit via `accept_stamped_propagated_blob`; host peer `/offer` sync; auto Chat drain after peer ingress + post-peer silent `/get`; explicit local Sync via `drain_local_inbox`) — not an outbox; clients need not Prefer you. Propagated **link establishment timeout** advances the cascade when other PNs remain (avoids Prefer-hash timeout storms). Sync vs deposit: `PROPAGATION_SYNC_OUTBOUND_BUSY` / `PN_DEPOSIT_DEFER_ADVANCE_AFTER`. Renderer `applyReticulumOutboundDeliveryStatus.ts` (WS `lxmf_outbound_status` → Zustand + SQLite `delivery_status` + `delivery_method`; early-status buffer; hash/status allowlist), `reticulumOutboundFailureBridge.ts` (`shouldApplyLinkDeliveryTimeoutFailureBridge` skips the link-timeout Failed bridge when cascade capacity remains — remote **or** enabled local-prop; also skips `propagated` / `stored_locally` rows so cascade is not killed), `markStaleReticulumOutbound.ts`. Optimistic pending rows use `reticulum-pending-*`; send-path rekey passes `replaces_message_hash` on SQLite upsert to delete the prior pending hash. Remote PN Completes UI: **Stored at propagation node** (`ReticulumMessageStatusBadge` PN + green check); local-prop Completes: deposited on your hosted node (PN + amber house; peer sync may still propagate). Mode Off has no cascade capacity, so the link-timeout bridge fails the row. **Paper exception:** `createReticulumPaperMessage` / paper create Completes immediately (`delivery_method: paper`, `ReticulumMessageStatusBadge` **Paper**) via `lxmf_message` — no `lxmf_outbound_status`; shared `reticulumMessageTransport` / `reticulumPaperErrors` keep IPC allowlists and i18n codes aligned. - **DM path reachability:** `useReticulumDmPathProbe.ts`, `reticulumDmPathReachability.ts`, `ReticulumDmPathReachabilityBadge.tsx` — Chat **Probe** matches Peer List (sidecar running check → `/probe` → toast → refresh); `applyProbeResult(forHash, …)` applies the settle without a second `/probe` and ignores stale completions after DM switch; manual reprobe forces Checking… even when passive hops look reachable; Peers virtualizes above 100 rows via `reticulumPeerListRows.ts`; peer refresh policy in `reticulumSidecarPeerRefreshEvents.ts` - **Inbound transport labels:** `received_via` resolves the path-table interface name against local interface config type, so a TCP hub display name still renders as TCP. -- **Topology:** `via_hash` is an immediate transport id; sidecar synthesizes missing relay nodes. `ReticulumTopologyPanel` uses force layout; sidecar caps graph input at 2,000 peers and renderer caps visible peers at 800 (grid repulsion above 400). +- **Topology:** `via_hash` is an immediate transport id; sidecar synthesizes missing relay nodes. `ReticulumTopologyPanel` uses force layout; sidecar caps graph input at 2,000 peers, renderer ingest 800, drawn graph 400 when distant peers are on (48 when hidden; same as LoRa Graph). **RF only** checkbox keeps RNode/KISS/BLE spokes. - **Retention:** App defaults Reticulum destination age/count pruning to 30 days / 10,000 destinations (favorites preserved; count max 50,000); Reticulum message retention independently enabled at 4,000. RRC room history retention independently enabled by default at **10,000** messages (30-day age prune) via `rrcMessageRetention*` settings and `db:pruneRrcMessagesByCount` / `db:pruneRrcMessagesByAge`. - **Self label / header:** `reticulumSelfNodeLabel.ts` (`resolveReticulumSelfHeaderLabel` — Network display name in app header) - **Nomad errors:** `lib/nomad/nomadPageErrorHumanize.ts` (sidecar error codes → i18n); LinkClient Nomad overlay in `reticulum-sidecar/patches/` diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 957427a22..913a17985 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -9,7 +9,7 @@ This document is the authoritative reference for every diagnostic output in Mesh - **NodeListPanel**: inline anomaly badges, redundancy `+N` echo count, MQTT-only node dimming, Node Health Score badge, JSON export - **MapPanel**: channel utilization halos, routing anomaly aura circles - **RF Histograms panel**: SNR, RSSI, and hop-count bar charts across all nodes -- **Peer Graph panel**: SVG force-directed graph of directly connected nodes (hops 0–1) — **Meshtastic and MeshCore only**; Reticulum uses the **Topology** tab instead +- **Peer Graph panel**: SVG force-directed graph of mesh peers with hop filters — **Meshtastic and MeshCore only**; Reticulum uses the **Topology** tab instead (same 48/400 visible-node cap) All three protocols share one **Diagnostics** sidebar tab; sections differ by `ProtocolCapabilities` (see **Multi-protocol tab scoping** below). @@ -416,9 +416,10 @@ Data is read directly from the node store; no additional telemetry required. Accessible via the graph icon in the sidebar. -SVG force-directed graph of nodes within direct reach (hops 0–1 from the connected device). +SVG force-directed graph of Meshtastic and MeshCore peers (Reticulum uses the **Topology** tab with the same hop controls and visible-node cap). -- Only nodes with `hops_away` 0 or 1 are included; avoids O(n²) edge explosion on large meshes +- **Show distant peers** (default off) and **Max hops** (default 2) filter the node set first +- Layout budget (known limitation): at most **48** nodes when distant peers are hidden; at most **400** when they are shown (`FORCE_REPULSION_FULL_PAIR_CAP`). The toolbar states this limit; when the cap hides nodes the status reads “limit 48” / “limit 400”, not “distant peers hidden” if that checkbox is on - Both Meshtastic and MeshCore use the `hops_away` field for edge inference - Nodes are clickable; clicking opens NodeDetailModal for that node - Layout uses D3-style force simulation; positions stabilize after initial render diff --git a/docs/reticulum.md b/docs/reticulum.md index 3db03ed62..1cde43464 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -135,7 +135,7 @@ The **Map** tab shows **local** RMAP v4 discovery data — interfaces your stack **Publish settings (Network → RMAP v4 discovery):** announce interval **60–1440 min** (default **360**); optional height (meters) and `reachable_on` (max 256 chars). LoRa/BLE publish auto-enables `enable_transport` and the **`rmap.world:4242`** hub. Stack restart confirm after enabling publish. -**Performance / memory:** Renderer mirrors discovery rows in `reticulumDiscoveryMapStore` (in-memory only; capped at **2,000** newest rows with client-side 7-day `last_heard` eviction). Peers tab opens use the sidecar’s soft cached peer read; manual **Refresh** forces live `GET /api/v1/peers?refresh=1`. Path-table peers apply **incremental** `peers_updated` / announce patches (50ms batch); full dumps run on connect, manual Refresh, stack restart, exceptional `peers_updated` events, and a slow safety poll (30s, or 60s above 2,000 peers). In-memory hard ceiling **100,000**; App tab destination cap defaults to **10,000** (max **50,000**) and age prune for SQLite contact meta. The sidecar selects at most **2,000** peers before topology graph construction; the renderer renders at most **800**, and its force layout uses grid repulsion above **400** nodes. Topology auto-refresh pauses above the large-mesh threshold. Leaflet uses `preferCanvas`; tile layer `keepBuffer={1}`. Stores clear on disconnect and unexpected sidecar stop. +**Performance / memory:** Renderer mirrors discovery rows in `reticulumDiscoveryMapStore` (in-memory only; capped at **2,000** newest rows with client-side 7-day `last_heard` eviction). Peers tab opens use the sidecar’s soft cached peer read; manual **Refresh** forces live `GET /api/v1/peers?refresh=1`. Path-table peers apply **incremental** `peers_updated` / announce patches (50ms batch); full dumps run on connect, manual Refresh, stack restart, exceptional `peers_updated` events, and a slow safety poll (30s, or 60s above 2,000 peers). In-memory hard ceiling **100,000**; App tab destination cap defaults to **10,000** (max **50,000**) and age prune for SQLite contact meta. The sidecar selects at most **2,000** peers before topology graph construction; the renderer feeds at most **800** path-table rows into Topology. The **drawn** graph uses the same visible-node cap as Meshtastic/MeshCore Graph: **400** when **Show distant peers** is on, **48** when hidden (force layout switches to grid repulsion above **400**). Topology also has an **RF only** filter (RNode / KISS / BLE RNode / BLE Peer; hides TCP / I2P / Auto hubs and their peers). The RF-only filter runs before the 800 last-seen ingest slice. Topology auto-refresh pauses above the large-mesh threshold. Leaflet uses `preferCanvas`; tile layer `keepBuffer={1}`. Stores clear on disconnect and unexpected sidecar stop. ### App → Retention & limits diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e2faa861c..4ed55ab55 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1759,6 +1759,14 @@ Legacy SQLite rows could cross-contaminate the shared `nodes` table before proto **Fix**: Foreign-LoRa overhear tables render on the **Meshtastic** and **MeshCore** Diagnostics tabs (keyed by that protocol’s self node id). Reticulum RNode promiscuous foreign LoRa is not implemented (sidecar tap exposes parsed RNS frames only). +### Graph or Topology does not show all nodes + +**Symptoms**: MeshCore/Meshtastic **Graph** or Reticulum **Topology** says it is showing 48 (or 400) of N nodes even with **Show distant peers** ticked and **Max hops** set to **All hops**. + +**Cause**: The force-directed layout has a hard visible-node budget: **48** when distant peers are hidden, **400** when they are shown. Hop filters apply first; leftover nodes beyond that budget are omitted on purpose. Reticulum Topology also ingests at most 800 path-table rows (sidecar 2,000). + +**Fix**: This is expected. The toolbar note states the 400 / 48 limit. Turn on **Show distant peers** and set **Max hops** to **All hops** to include multi-hop peers up to 400. On Topology, use **RF only** to drop TCP/I2P hubs if you only want RNode/KISS/BLE. Narrow **Max hops** if the graph is too dense. + ### No signal bars on some nodes **Cause**: Signal strength is only available for **direct (0-hop) RF** neighbors. Multi-hop and MQTT-heard nodes have no client-side signal strength. diff --git a/src/renderer/components/PeerGraphPanel.test.tsx b/src/renderer/components/PeerGraphPanel.test.tsx new file mode 100644 index 000000000..c0aa1d8d9 --- /dev/null +++ b/src/renderer/components/PeerGraphPanel.test.tsx @@ -0,0 +1,115 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('react-i18next', () => { + const t = (key: string, opts?: Record) => { + if (opts && 'distantLimit' in opts && 'nearbyLimit' in opts) { + return `${key}:${opts.distantLimit}/${opts.nearbyLimit}`; + } + if (opts && 'shown' in opts && 'total' in opts && 'limit' in opts) { + return `${key}:${opts.shown}/${opts.total}/${opts.limit}`; + } + if (opts && 'shown' in opts && 'total' in opts) { + return `${key}:${opts.shown}/${opts.total}`; + } + if (opts && 'count' in opts) return `${key}:${opts.count}`; + return key; + }; + return { + useTranslation: () => ({ t }), + }; +}); + +vi.mock('@/renderer/lib/forceDirectedGraphLayout', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + startForceSimulationLoop: () => () => {}, + }; +}); + +import type { MeshNode } from '@/renderer/lib/types'; + +import PeerGraphPanel from './PeerGraphPanel'; + +function node(id: number, hopsAway: number): MeshNode { + return { + node_id: id, + long_name: `Node ${id}`, + short_name: `N${id}`, + hw_model: 'T-Beam', + snr: 5, + battery: 80, + last_heard: Date.now(), + latitude: null, + longitude: null, + hops_away: hopsAway, + }; +} + +function nodeMap(peerCount: number, hopsAway: number): Map { + const nodes = new Map([[1, node(1, 0)]]); + for (let i = 2; i <= peerCount + 1; i++) { + nodes.set(i, node(i, hopsAway)); + } + return nodes; +} + +describe('PeerGraphPanel', () => { + it('always shows the 400/48 limit note', () => { + render(); + expect(screen.getByText('peerGraph.visibleNodeLimitNote:400/48')).toBeInTheDocument(); + }); + + it('defaults to distant peers off and max hops 2', () => { + render(); + expect(screen.getByLabelText('peerGraph.showDistantPeers').checked).toBe( + false, + ); + expect(screen.getByLabelText('peerGraph.maxHopsFilter').value).toBe('2'); + }); + + it('shows the full 168-node set when distant peers are on and max hops is all', () => { + render(); + fireEvent.click(screen.getByLabelText('peerGraph.showDistantPeers')); + fireEvent.change(screen.getByLabelText('peerGraph.maxHopsFilter'), { + target: { value: 'all' }, + }); + expect(screen.queryByText(/peerGraph\.hiddenCount:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/peerGraph\.hiddenCountLimit:/)).not.toBeInTheDocument(); + expect(screen.getByText(/peerGraph\.nodeCount:168/)).toBeInTheDocument(); + }); + + it('uses distant-hidden copy when the checkbox is off and the nearby cap fires', () => { + render(); + expect(screen.getByText(/peerGraph\.hiddenCount:\d/)).toBeInTheDocument(); + expect(screen.queryByText(/peerGraph\.hiddenCountLimit:/)).not.toBeInTheDocument(); + }); + + it('uses graph-limit copy with 400 when distant peers are on and over the cap', () => { + render(); + fireEvent.click(screen.getByLabelText('peerGraph.showDistantPeers')); + fireEvent.change(screen.getByLabelText('peerGraph.maxHopsFilter'), { + target: { value: 'all' }, + }); + expect(screen.getByText(/peerGraph\.hiddenCountLimit:/)).toBeInTheDocument(); + expect(screen.getByText(/peerGraph\.hiddenCountLimit:/).textContent).toContain('/400'); + expect(screen.queryByText(/peerGraph\.hiddenCount:\d/)).not.toBeInTheDocument(); + }); + + it('changes the rendered node count when max hops changes with distant peers on', () => { + const nodes = new Map([[1, node(1, 0)]]); + for (let i = 2; i <= 31; i++) nodes.set(i, node(i, 1)); + for (let i = 32; i <= 71; i++) nodes.set(i, node(i, 3)); + render(); + fireEvent.click(screen.getByLabelText('peerGraph.showDistantPeers')); + fireEvent.change(screen.getByLabelText('peerGraph.maxHopsFilter'), { + target: { value: 'all' }, + }); + expect(screen.getByText(/peerGraph\.nodeCount:71/)).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('peerGraph.maxHopsFilter'), { + target: { value: '1' }, + }); + expect(screen.getByText(/peerGraph\.nodeCount:31/)).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/PeerGraphPanel.tsx b/src/renderer/components/PeerGraphPanel.tsx index ec9ff2f31..6babe8638 100644 --- a/src/renderer/components/PeerGraphPanel.tsx +++ b/src/renderer/components/PeerGraphPanel.tsx @@ -12,10 +12,16 @@ import { type SimNodeState, startForceSimulationLoop, } from '@/renderer/lib/forceDirectedGraphLayout'; +import { + TOPOLOGY_GRAPH_DISTANT_NODE_CAP, + TOPOLOGY_GRAPH_NEARBY_NODE_CAP, + topologyGraphVisibleNodeCap, +} from '@/renderer/lib/topologyGraphLimits'; import type { MeshNode } from '@/renderer/lib/types'; import { useSvgPanZoom } from '@/renderer/lib/useSvgPanZoom'; import { TopologyHopFilterControls } from './TopologyHopFilterControls'; +import { TopologyVisibleLimitNote } from './TopologyVisibleLimitNote'; interface PeerGraphPanelProps { nodes: Map; @@ -230,6 +236,12 @@ export default function PeerGraphPanel({ nodes, myNodeId, onNodeClick }: PeerGra maxHopsAllLabel={t('peerGraph.maxHopsAll')} maxHopsOptionLabel={(hops) => t('peerGraph.maxHopsOption', { count: hops })} /> +