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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 145 additions & 2 deletions src/renderer/components/NomadNetworkPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([
[
Expand Down Expand Up @@ -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(<NomadNetworkPanel />);
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(<NomadNetworkPanel />);
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();
Expand Down
9 changes: 7 additions & 2 deletions src/renderer/components/NomadNetworkPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -449,14 +450,15 @@ 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,
});
}, [
announceReloadDone,
loadNodePage,
markAnnounceReloadDone,
pageErrorCode,
pageErrorEgress,
pageErrorNodeSnapshot,
pageLoading,
pagePath,
Expand Down Expand Up @@ -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,
});
}}
Expand Down
10 changes: 9 additions & 1 deletion src/renderer/lib/nomad/nomadPageErrorHumanize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
28 changes: 23 additions & 5 deletions src/renderer/lib/nomad/nomadPageErrorHumanize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,23 @@ 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',
'pubkey_not_found',
'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();
Expand All @@ -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. */
Expand Down
83 changes: 68 additions & 15 deletions src/renderer/stores/nomadPageViewerLoad.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading