From 0aac7989c093819e9262eecfc845688b8cdaf3bb Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Fri, 31 Jul 2026 21:43:09 -0600 Subject: [PATCH] fix(reticulum): force-path refresh on TCP Nomad link_timeout Restore DropPath retries for hub peers after #756 so stale TCP routes like TTP Node can recover, while RF/BLE link_timeout stays one-shot. --- .../components/NomadNetworkPanel.test.tsx | 147 +++++++++++++++++- src/renderer/components/NomadNetworkPanel.tsx | 9 +- .../lib/nomad/nomadPageErrorHumanize.test.ts | 10 +- .../lib/nomad/nomadPageErrorHumanize.ts | 28 +++- .../stores/nomadPageViewerLoad.test.ts | 83 ++++++++-- src/renderer/stores/nomadPageViewerStore.ts | 21 ++- 6 files changed, 271 insertions(+), 27 deletions(-) diff --git a/src/renderer/components/NomadNetworkPanel.test.tsx b/src/renderer/components/NomadNetworkPanel.test.tsx index 79067cc9b..ec5d5f693 100644 --- a/src/renderer/components/NomadNetworkPanel.test.tsx +++ b/src/renderer/components/NomadNetworkPanel.test.tsx @@ -874,12 +874,14 @@ describe('NomadNetworkPanel', () => { } }); - it('does not auto-retry link_timeout (shows error after one fetch)', async () => { + it('does not auto-retry RF link_timeout (shows error after one fetch)', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const { restore } = mockConsoleWarn(); try { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - const fetchNomadPage = vi.fn().mockResolvedValue({ ok: false, error: 'link_timeout' }); + const fetchNomadPage = vi + .fn() + .mockResolvedValue({ ok: false, error: 'link_timeout', egress: 'rf' }); useNomadNetworkStore.setState({ nodes: new Map([ [ @@ -916,6 +918,147 @@ describe('NomadNetworkPanel', () => { } }); + it('auto-retries TCP link_timeout once with forcePathRefresh', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const { restore } = mockConsoleWarn(); + try { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const fetchNomadPage = vi + .fn() + .mockResolvedValueOnce({ ok: false, error: 'link_timeout', egress: 'tcp' }) + .mockResolvedValueOnce({ + ok: true, + content: '>>>hello after tcp retry', + content_type: 'micron', + egress: 'tcp', + }); + useNomadNetworkStore.setState({ + nodes: new Map([ + [ + 'abc1234567890', + { + destination_hash: 'abc1234567890', + display_name: 'TTP Node', + favorited: false, + last_seen: 100, + hops: 1, + }, + ], + ]), + fetchNomadPage, + }); + + render(); + await openAnnouncesNode(user); + + await act(async () => { + await vi.advanceTimersByTimeAsync(NOMAD_PAGE_FETCH_DEBOUNCE_MS); + }); + await waitFor(() => { + expect(fetchNomadPage).toHaveBeenCalledTimes(1); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(NOMAD_PAGE_FETCH_RETRY_SETTLE_MS); + }); + await waitFor(() => { + expect(fetchNomadPage).toHaveBeenCalledTimes(2); + expect(document.querySelector('.nomad-micron-page')).toBeTruthy(); + }); + expect(fetchNomadPage).toHaveBeenNthCalledWith( + 2, + 'abc1234567890', + '/page/index.mu', + undefined, + { forcePathRefresh: true }, + ); + } finally { + restore(); + vi.useRealTimers(); + } + }); + + it('announce reload after TCP link_timeout uses forcePathRefresh', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const { restore } = mockConsoleWarn(); + try { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const fetchNomadPage = vi + .fn() + .mockResolvedValueOnce({ ok: false, error: 'link_timeout', egress: 'tcp' }) + .mockResolvedValueOnce({ ok: false, error: 'link_timeout', egress: 'tcp' }) + .mockResolvedValueOnce({ + ok: true, + content: '>>>hello after announce', + content_type: 'micron', + egress: 'tcp', + }); + useNomadNetworkStore.setState({ + nodes: new Map([ + [ + 'abc1234567890', + { + destination_hash: 'abc1234567890', + display_name: 'TTP Node', + favorited: false, + last_seen: 100, + hops: 1, + }, + ], + ]), + fetchNomadPage, + }); + + render(); + await openAnnouncesNode(user); + + await act(async () => { + await vi.advanceTimersByTimeAsync(NOMAD_PAGE_FETCH_DEBOUNCE_MS); + }); + await waitFor(() => { + expect(fetchNomadPage).toHaveBeenCalledTimes(1); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(NOMAD_PAGE_FETCH_RETRY_SETTLE_MS); + }); + await waitFor(() => { + expect(fetchNomadPage).toHaveBeenCalledTimes(2); + expect(screen.getByText(/nomadNetwork.pageFailed/)).toBeInTheDocument(); + }); + + act(() => { + useNomadNetworkStore.setState({ + nodes: new Map([ + [ + 'abc1234567890', + { + destination_hash: 'abc1234567890', + display_name: 'TTP Node', + favorited: false, + last_seen: 200, + hops: 1, + }, + ], + ]), + }); + }); + + await waitFor(() => { + expect(fetchNomadPage).toHaveBeenCalledTimes(3); + expect(document.querySelector('.nomad-micron-page')).toBeTruthy(); + }); + expect(fetchNomadPage).toHaveBeenNthCalledWith( + 3, + 'abc1234567890', + '/page/index.mu', + undefined, + { forcePathRefresh: true }, + ); + } finally { + restore(); + vi.useRealTimers(); + } + }); + it('shows page error once when both path_timeout fetch attempts fail', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const { restore } = mockConsoleWarn(); diff --git a/src/renderer/components/NomadNetworkPanel.tsx b/src/renderer/components/NomadNetworkPanel.tsx index 15a856acf..c55f08fa5 100644 --- a/src/renderer/components/NomadNetworkPanel.tsx +++ b/src/renderer/components/NomadNetworkPanel.tsx @@ -254,6 +254,7 @@ export default function NomadNetworkPanel({ const pageLoadingStartedAt = useNomadPageViewerStore((s) => s.pageLoadingStartedAt); const pageLoadingBudgetSec = useNomadPageViewerStore((s) => s.pageLoadingBudgetSec); const pageErrorRaw = useNomadPageViewerStore((s) => s.pageErrorRaw); + const pageErrorEgress = useNomadPageViewerStore((s) => s.pageErrorEgress); const pageErrorNodeSnapshot = useNomadPageViewerStore((s) => s.pageErrorNodeSnapshot); const announceReloadDone = useNomadPageViewerStore((s) => s.announceReloadDone); const loadPage = useNomadPageViewerStore((s) => s.loadPage); @@ -449,7 +450,7 @@ export default function NomadNetworkPanel({ // eslint-disable-next-line react-hooks/set-state-in-effect -- auto-retry after announce updates node metadata void loadNodePage(selectedHash, pagePath, { forceReload: true, - forcePathRefresh: shouldForceNomadPathRefreshRetry(pageErrorCode), + forcePathRefresh: shouldForceNomadPathRefreshRetry(pageErrorCode, pageErrorEgress), requestData: pageRequestData, }); }, [ @@ -457,6 +458,7 @@ export default function NomadNetworkPanel({ loadNodePage, markAnnounceReloadDone, pageErrorCode, + pageErrorEgress, pageErrorNodeSnapshot, pageLoading, pagePath, @@ -981,7 +983,10 @@ export default function NomadNetworkPanel({ onClick={() => { void loadNodePage(selectedNode.destination_hash, pagePath, { forceReload: true, - forcePathRefresh: shouldForceNomadPathRefreshRetry(pageErrorCode), + forcePathRefresh: shouldForceNomadPathRefreshRetry( + pageErrorCode, + pageErrorEgress, + ), requestData: pageRequestData, }); }} diff --git a/src/renderer/lib/nomad/nomadPageErrorHumanize.test.ts b/src/renderer/lib/nomad/nomadPageErrorHumanize.test.ts index 82df82b30..3c96a2885 100644 --- a/src/renderer/lib/nomad/nomadPageErrorHumanize.test.ts +++ b/src/renderer/lib/nomad/nomadPageErrorHumanize.test.ts @@ -63,8 +63,16 @@ describe('nomadPageErrorHumanize', () => { expect(shouldForceNomadPathRefreshRetry('path_timeout')).toBe(true); expect(shouldForceNomadPathRefreshRetry('pubkey_not_found')).toBe(true); expect(shouldForceNomadPathRefreshRetry('missing_identity_hash')).toBe(true); - expect(shouldForceNomadPathRefreshRetry('link_timeout')).toBe(false); expect(shouldForceNomadPathRefreshRetry('response_timeout')).toBe(false); expect(shouldForceNomadPathRefreshRetry('nomad_busy')).toBe(false); + + // TCP/network hub link_timeout: force DropPath; RF/BLE does not. + expect(shouldForceNomadPathRefreshRetry('link_timeout', 'tcp')).toBe(true); + expect(shouldForceNomadPathRefreshRetry('link_timeout', 'network')).toBe(true); + expect(shouldForceNomadPathRefreshRetry('link_timeout')).toBe(true); + expect(shouldForceNomadPathRefreshRetry('link_timeout', 'unknown')).toBe(true); + expect(shouldForceNomadPathRefreshRetry('link_timeout', 'rf')).toBe(false); + expect(shouldForceNomadPathRefreshRetry('link_timeout', 'ble')).toBe(false); + expect(shouldForceNomadPathRefreshRetry('link_timeout', 'RF')).toBe(false); }); }); diff --git a/src/renderer/lib/nomad/nomadPageErrorHumanize.ts b/src/renderer/lib/nomad/nomadPageErrorHumanize.ts index 731220162..c0e1054eb 100644 --- a/src/renderer/lib/nomad/nomadPageErrorHumanize.ts +++ b/src/renderer/lib/nomad/nomadPageErrorHumanize.ts @@ -37,8 +37,10 @@ const ANNOUNCE_RELOAD_NOMAD_PAGE_ERRORS = new Set([ /** * Errors worth one automatic re-fetch with `force_path_refresh`. - * Link/response timeouts already exercised path+link — forcing RequestPath again - * doubles RF lock time without fixing the failure mode. + * RF/BLE `link_timeout` already exercised path+link — forcing RequestPath again + * doubles RF lock time without fixing the failure mode. TCP/network hub routes + * often keep a present-but-dead path; DropPath + short Nomad fall-through can + * recover those (release 5.25.0 always force-pathed `link_timeout`). */ const FORCE_PATH_REFRESH_NOMAD_PAGE_ERRORS = new Set([ 'path_timeout', @@ -46,6 +48,12 @@ const FORCE_PATH_REFRESH_NOMAD_PAGE_ERRORS = new Set([ 'missing_identity_hash', ]); +/** True when sidecar egress is RF/BLE (skip force-path on link_timeout). */ +function isRfOrBleNomadEgress(egress: string | null | undefined): boolean { + const atom = egress?.trim().toLowerCase(); + return atom === 'rf' || atom === 'ble'; +} + export function nomadPageErrorI18nKey(error: string | null | undefined): string | null { if (error == null) return null; const trimmed = error.trim(); @@ -60,11 +68,21 @@ export function isRetryableNomadPageError(error: string | null | undefined): boo return ANNOUNCE_RELOAD_NOMAD_PAGE_ERRORS.has(trimmed); } -/** True when one-shot auto-retry should call fetch with forcePathRefresh. */ -export function shouldForceNomadPathRefreshRetry(error: string | null | undefined): boolean { +/** + * True when one-shot auto-retry / announce reload should call fetch with forcePathRefresh. + * Pass sidecar `egress` when known so TCP hub `link_timeout` can DropPath while RF/BLE does not. + */ +export function shouldForceNomadPathRefreshRetry( + error: string | null | undefined, + egress?: string | null, +): boolean { const trimmed = error?.trim(); if (!trimmed) return false; - return FORCE_PATH_REFRESH_NOMAD_PAGE_ERRORS.has(trimmed); + if (FORCE_PATH_REFRESH_NOMAD_PAGE_ERRORS.has(trimmed)) return true; + // Hub peers: present-but-stale TCP routes often surface as link_timeout, not path_timeout. + // Missing/unknown egress defaults to force (TCP countdown default); only skip RF/BLE. + if (trimmed === 'link_timeout' && !isRfOrBleNomadEgress(egress)) return true; + return false; } /** Resolve a Nomad page/file error for display via i18n when known. */ diff --git a/src/renderer/stores/nomadPageViewerLoad.test.ts b/src/renderer/stores/nomadPageViewerLoad.test.ts index 0ae28bec5..397d05f36 100644 --- a/src/renderer/stores/nomadPageViewerLoad.test.ts +++ b/src/renderer/stores/nomadPageViewerLoad.test.ts @@ -5,6 +5,10 @@ import { getNomadPageCache, nomadPageCacheSizeForTests, } from '@/renderer/lib/nomad/nomadPageCache'; +import { + NOMAD_PAGE_FETCH_DEBOUNCE_MS, + NOMAD_PAGE_FETCH_RETRY_SETTLE_MS, +} from '@/renderer/lib/timeConstants'; import { mockConsoleWarn } from '@/renderer/lib/vitestConsoleMock'; import { resetNomadEgressCacheForTests, useNomadNetworkStore } from './nomadNetworkStore'; @@ -60,23 +64,72 @@ describe('nomadPageViewerStore loadPage cache', () => { it('updates countdown budget from sidecar egress on uncached RF responses', async () => { vi.useFakeTimers(); - const fetchNomadPage = vi.fn().mockResolvedValue({ - ok: false, - error: 'link_timeout', - egress: 'rf', - timeout_secs: 99, - }); - useNomadNetworkStore.setState({ fetchNomadPage }); + const { restore } = mockConsoleWarn(); + try { + const fetchNomadPage = vi.fn().mockResolvedValue({ + ok: false, + error: 'link_timeout', + egress: 'rf', + timeout_secs: 99, + }); + useNomadNetworkStore.setState({ fetchNomadPage }); + + const loadPromise = useNomadPageViewerStore + .getState() + .loadPage('abc1234567890', '/page/index.mu'); + await vi.advanceTimersByTimeAsync(NOMAD_PAGE_FETCH_DEBOUNCE_MS); + await loadPromise; + + expect(fetchNomadPage).toHaveBeenCalledTimes(1); + expect(useNomadPageViewerStore.getState().pageLoadingBudgetSec).toBe(99); + expect(useNomadPageViewerStore.getState().pageErrorRaw).toBe('link_timeout'); + expect(useNomadPageViewerStore.getState().pageErrorEgress).toBe('rf'); + } finally { + restore(); + vi.useRealTimers(); + } + }); - const loadPromise = useNomadPageViewerStore - .getState() - .loadPage('abc1234567890', '/page/index.mu'); - await vi.advanceTimersByTimeAsync(300); - await loadPromise; + it('auto-retries TCP link_timeout once with forcePathRefresh', async () => { + vi.useFakeTimers(); + const { restore } = mockConsoleWarn(); + try { + const fetchNomadPage = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + error: 'link_timeout', + egress: 'tcp', + }) + .mockResolvedValueOnce({ + ok: true, + content: 'hello after tcp retry', + content_type: 'micron', + egress: 'tcp', + }); + useNomadNetworkStore.setState({ fetchNomadPage }); - expect(useNomadPageViewerStore.getState().pageLoadingBudgetSec).toBe(99); - expect(useNomadPageViewerStore.getState().pageErrorRaw).toBe('link_timeout'); - vi.useRealTimers(); + const loadPromise = useNomadPageViewerStore + .getState() + .loadPage('abc1234567890', '/page/index.mu'); + await vi.advanceTimersByTimeAsync(NOMAD_PAGE_FETCH_DEBOUNCE_MS); + await vi.advanceTimersByTimeAsync(NOMAD_PAGE_FETCH_RETRY_SETTLE_MS); + await loadPromise; + + expect(fetchNomadPage).toHaveBeenCalledTimes(2); + expect(fetchNomadPage).toHaveBeenNthCalledWith( + 2, + 'abc1234567890', + '/page/index.mu', + undefined, + { forcePathRefresh: true }, + ); + expect(useNomadPageViewerStore.getState().pageContent).toBe('hello after tcp retry'); + expect(useNomadPageViewerStore.getState().pageErrorEgress).toBeNull(); + } finally { + restore(); + vi.useRealTimers(); + } }); it('snapshots the node on unexpected fetch rejection', async () => { diff --git a/src/renderer/stores/nomadPageViewerStore.ts b/src/renderer/stores/nomadPageViewerStore.ts index 342f4df0c..80a3a05b4 100644 --- a/src/renderer/stores/nomadPageViewerStore.ts +++ b/src/renderer/stores/nomadPageViewerStore.ts @@ -58,6 +58,8 @@ interface NomadPageViewerState { pageLoadingBudgetSec: number; /** Raw sidecar/proxy error code or message (humanize in UI). */ pageErrorRaw: string | null; + /** Sidecar egress atom from the failed fetch (`tcp` / `rf` / …) for retry policy. */ + pageErrorEgress: string | null; pageErrorNodeSnapshot: NomadPageErrorNodeSnapshot | null; announceReloadDone: boolean; /** True while Nomad tab is visible — suppress completion toast when true. */ @@ -181,12 +183,19 @@ const initialViewerState = { pageLoadingStartedAt: null as number | null, pageLoadingBudgetSec: 0, pageErrorRaw: null as string | null, + pageErrorEgress: null as string | null, pageErrorNodeSnapshot: null as NomadPageErrorNodeSnapshot | null, announceReloadDone: false, panelActive: false, loadGeneration: 0, }; +function egressFromNomadPageResponse(res: NomadPageResponse): string | null { + if (typeof res.egress !== 'string') return null; + const trimmed = res.egress.trim(); + return trimmed || null; +} + export const useNomadPageViewerStore = create((set, get) => ({ ...initialViewerState, @@ -195,7 +204,7 @@ export const useNomadPageViewerStore = create((set, get) = }, clearPageErrorForAnnounceReload: () => { - set({ pageErrorRaw: null, pageErrorNodeSnapshot: null }); + set({ pageErrorRaw: null, pageErrorEgress: null, pageErrorNodeSnapshot: null }); }, markAnnounceReloadDone: () => { @@ -205,6 +214,7 @@ export const useNomadPageViewerStore = create((set, get) = setInvalidUrlError: () => { set({ pageErrorRaw: 'invalid_url', + pageErrorEgress: null, pageErrorNodeSnapshot: null, pageLoading: false, pageLoadingStartedAt: null, @@ -240,6 +250,7 @@ export const useNomadPageViewerStore = create((set, get) = pageLoadingStartedAt: null, pageLoadingBudgetSec: budgetSec, pageErrorRaw: null, + pageErrorEgress: null, pageErrorNodeSnapshot: null, announceReloadDone: false, loadGeneration: generation, @@ -300,7 +311,10 @@ export const useNomadPageViewerStore = create((set, get) = set({ pageLoadingBudgetSec: budgetSec }); } - if ((!res.ok || !res.content) && shouldForceNomadPathRefreshRetry(res.error)) { + if ( + (!res.ok || !res.content) && + shouldForceNomadPathRefreshRetry(res.error, egressFromNomadPageResponse(res)) + ) { const retryCode = res.error?.trim() || 'unknown'; console.warn(`[NomadNetwork] page fetch retry after ${retryCode}`); await new Promise((resolve) => { @@ -323,6 +337,7 @@ export const useNomadPageViewerStore = create((set, get) = pageLoading: false, pageLoadingStartedAt: null, pageErrorRaw: 'unknown', + pageErrorEgress: null, pageErrorNodeSnapshot: snapshotNomadNodeForPageError(hash, liveNode), }); return; @@ -337,6 +352,7 @@ export const useNomadPageViewerStore = create((set, get) = pageLoading: false, pageLoadingStartedAt: null, pageErrorRaw: rawCode, + pageErrorEgress: egressFromNomadPageResponse(res), pageErrorNodeSnapshot: snapshotNomadNodeForPageError(hash, liveNode), announceReloadDone: false, }); @@ -363,6 +379,7 @@ export const useNomadPageViewerStore = create((set, get) = pageContentType: res.content_type, pageContentTruncated: truncated, pageErrorRaw: null, + pageErrorEgress: null, pageErrorNodeSnapshot: null, });