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
18 changes: 17 additions & 1 deletion src/libs/FailureTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import CONST from '@src/CONST';
let failureCount = 0;
let firstFailureTimestamp = 0;
const sustainedFailureListeners = new Set<(active: boolean) => void>();
const successListeners = new Set<() => void>();

/**
* Register a listener for sustained failure state changes.
Expand All @@ -13,11 +14,26 @@ function onSustainedFailureChange(listener: (active: boolean) => void): () => vo
return () => sustainedFailureListeners.delete(listener);
}

/**
* Register a listener fired on every successful request, even with zero recorded
* failures. Reads and side-effect commands bypass the paused queue and can succeed
* while the INTERNET_UNREACHABLE hard stop is set. NetworkState needs that signal
* to clear the hard stop, because the Ping alone may keep failing.
*/
function onSuccess(listener: () => void): () => void {
successListeners.add(listener);
return () => successListeners.delete(listener);
}

/**
* Record a successful request outcome.
* Resets the failure tracker — one success proves connectivity.
*/
function recordSuccess() {
for (const cb of successListeners) {
cb();
}

if (failureCount === 0) {
return;
}
Expand Down Expand Up @@ -75,4 +91,4 @@ function getFailureCount(): number {
return failureCount;
}

export {recordSuccess, recordFailure, reset, getFailureCount, onSustainedFailureChange};
export {recordSuccess, recordFailure, reset, getFailureCount, onSustainedFailureChange, onSuccess};
46 changes: 35 additions & 11 deletions src/libs/NetworkState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import Onyx from 'react-native-onyx';

import {getCommandURL} from './ApiUtils';
import getEnvironment from './Environment/getEnvironment';
import {onSustainedFailureChange, reset as resetFailureCounters} from './FailureTracker';
import {onSuccess as onRequestSuccess, onSustainedFailureChange, reset as resetFailureCounters} from './FailureTracker';
import Log from './Log';

let hasRadio = true;
Expand All @@ -32,8 +32,23 @@ let configuredReachabilityUrl: string | undefined;
const listeners = new Set<() => void>();
const reconnectListeners = new Set<() => void>();

// Wire FailureTracker → NetworkState so sustained failures trigger offline state.
// Wire FailureTracker → NetworkState so sustained failures trigger offline state and a
// successful request clears the INTERNET_UNREACHABLE hard stop. Without the latter only
// the Ping could clear it. Reads and side-effect commands bypass the paused queue, so the
// app could keep succeeding at real requests while staying stuck offline.
onSustainedFailureChange((active) => setSustainedFailures(active));
// A server response proves the network works, same as a passing Ping.
// prevIsInternetReachable stays false on purpose: the Ping may keep failing, and a repeated
// false does not re-set the hard stop. When the Ping finally passes, the NetInfo listener
// sees the app is already online and does nothing.
onRequestSuccess(() => {
if (!internetUnreachable) {
return;
}
Log.info('[NetworkState] INTERNET_UNREACHABLE cleared — a successful request proved connectivity');
clearHardStops();
scheduleJitteredReconnect();
Comment thread
adhorodyski marked this conversation as resolved.
});

function getIsOffline(): boolean {
return !hasRadio || internetUnreachable || sustainedFailuresActive || shouldForceOffline || simulatedOffline;
Expand Down Expand Up @@ -144,14 +159,26 @@ function setSustainedFailures(active: boolean) {
// A reconnect that coincides with one already in flight is collapsed at push time by the
// reconnect coverage resolver (resolveReconnectDuplicationConflictAction): a redundant one is
// dropped, a wider one runs after. It consults the ongoing request and the waiting queue.

// Jitter (0–5s) staggers reconnection across clients after a server-wide outage
// to avoid a stampede of ReconnectApp calls hitting the backend simultaneously.
const jitter = Math.floor(Math.random() * CONST.NETWORK.RECONNECT_STAMPEDE_JITTER_MS);
setTimeout(() => notifyReconnectListeners(), jitter);
scheduleJitteredReconnect();
}
}

/**
* Jitter (0–5s) staggers reconnection across clients after a server-wide outage
* to avoid a stampede of ReconnectApp calls hitting the backend simultaneously.
*/
function scheduleJitteredReconnect() {
const jitter = Math.floor(Math.random() * CONST.NETWORK.RECONNECT_STAMPEDE_JITTER_MS);
setTimeout(() => notifyReconnectListeners(), jitter);
}

function clearHardStops() {
internetUnreachable = false;
sustainedFailuresActive = false;
resetFailureCounters();
updateState();
}

/**
* The debug paths clear their hard stop right away, so when the refreshed Ping confirms
* reachability the app already looks online and the listener would ignore it. The token
Expand Down Expand Up @@ -205,10 +232,7 @@ function setFailAllRequests(failAll: boolean) {
function onReachabilityRestored() {
Log.info('[NetworkState] Internet reachability restored — clearing hard stops');
hasRadio = true;
internetUnreachable = false;
sustainedFailuresActive = false;
resetFailureCounters();
updateState();
clearHardStops();

// Notify reconnect listeners (Reconnect.ts will handle app data sync)
notifyReconnectListeners();
Expand Down
117 changes: 117 additions & 0 deletions tests/unit/NetworkStateReachabilityTest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import CONST from '@src/CONST';
import type Middleware from '@src/libs/Middleware/types';
import type * as NetworkState from '@src/libs/NetworkState';
import type Request from '@src/types/onyx/Request';

import type {NetInfoState} from '@react-native-community/netinfo';
import type {OnyxKey} from 'react-native-onyx';

let netInfoListener: ((state: NetInfoState) => void) | null = null;
const mockOnyxCallbacks = new Map<string, (value: unknown) => void>();
Expand Down Expand Up @@ -270,3 +274,116 @@ describe('NetworkState — reachability recovery triggers reconnect', () => {
expect(reconnectListener).toHaveBeenCalledTimes(1);
});
});

describe('NetworkState — a successful request clears the INTERNET_UNREACHABLE hard stop', () => {
const mockRequest: Request<OnyxKey> = {command: 'MockCommand'};
let getIsOffline: typeof NetworkState.getIsOffline;
let onReachabilityConfirmed: typeof NetworkState.onReachabilityConfirmed;
let subscribe: typeof NetworkState.subscribe;
let FailureTracking: Middleware;

beforeEach(() => {
jest.resetModules();
netInfoListener = null;
mockOnyxCallbacks.clear();

// Require NetworkState and the middleware in the same module registry generation so
// they share FailureTracker's module state (recordSuccess → onSustainedFailureChange).
const mod = require<typeof NetworkState>('@src/libs/NetworkState');
getIsOffline = mod.getIsOffline;
onReachabilityConfirmed = mod.onReachabilityConfirmed;
subscribe = mod.subscribe;
FailureTracking = require<{default: Middleware}>('@src/libs/Middleware/FailureTracking').default;
});

afterEach(() => {
jest.useRealTimers();
});

test('success while INTERNET_UNREACHABLE is set clears the hard stop and fires reconnect once (jittered)', async () => {
jest.useFakeTimers();
const reconnectListener = jest.fn();
onReachabilityConfirmed(reconnectListener);

// Ping fails and sets the INTERNET_UNREACHABLE hard stop
fireNetInfoState({isInternetReachable: false});
expect(getIsOffline()).toBe(true);

// A read/side-effect command resolves through the FailureTracking middleware
await FailureTracking(Promise.resolve({jsonCode: 200}), mockRequest, false);

expect(getIsOffline()).toBe(false);
// Reconnect is deferred with jitter to stagger clients after a server-wide outage
expect(reconnectListener).not.toHaveBeenCalled();
jest.runAllTimers();
expect(reconnectListener).toHaveBeenCalledTimes(1);
});

test('success while the app was never offline is a complete no-op', async () => {
jest.useFakeTimers();
const reconnectListener = jest.fn();
const stateListener = jest.fn();
onReachabilityConfirmed(reconnectListener);

fireNetInfoState({isInternetReachable: true});
expect(getIsOffline()).toBe(false);
subscribe(stateListener);

await FailureTracking(Promise.resolve({jsonCode: 200}), mockRequest, false);
jest.runAllTimers();

expect(getIsOffline()).toBe(false);
expect(reconnectListener).not.toHaveBeenCalled();
expect(stateListener).not.toHaveBeenCalled();
});

test('after recovery-by-success, steady Ping failure does not re-trigger and a later Ping success does not double-fire', async () => {
jest.useFakeTimers();
const reconnectListener = jest.fn();
onReachabilityConfirmed(reconnectListener);

fireNetInfoState({isInternetReachable: false});
expect(getIsOffline()).toBe(true);

await FailureTracking(Promise.resolve({jsonCode: 200}), mockRequest, false);
jest.runAllTimers();
expect(getIsOffline()).toBe(false);
expect(reconnectListener).toHaveBeenCalledTimes(1);

// Ping keeps failing: prev stays false, so false→false must not re-set the hard stop
fireNetInfoState({isInternetReachable: false});
expect(getIsOffline()).toBe(false);

// Ping finally recovers: the app is already online, so the re-confirmation is ignored
fireNetInfoState({isInternetReachable: true});
jest.runAllTimers();
expect(reconnectListener).toHaveBeenCalledTimes(1);
});

test('success while BOTH hard stops are set clears both and fires reconnect exactly once', async () => {
jest.useFakeTimers();
const reconnectListener = jest.fn();
onReachabilityConfirmed(reconnectListener);

// Ping fails and sets the INTERNET_UNREACHABLE hard stop
fireNetInfoState({isInternetReachable: false});
expect(getIsOffline()).toBe(true);

// Requests fail past both thresholds, adding the SUSTAINED_FAILURES hard stop on top
const connectivityError = new Error(CONST.ERROR.FAILED_TO_FETCH);
for (let i = 0; i < CONST.NETWORK.SUSTAINED_FAILURE_THRESHOLD_COUNT - 1; i++) {
await expect(FailureTracking(Promise.reject(connectivityError), mockRequest, false)).rejects.toThrow();
}
jest.advanceTimersByTime(CONST.NETWORK.SUSTAINED_FAILURE_WINDOW_MS + 1);
await expect(FailureTracking(Promise.reject(connectivityError), mockRequest, false)).rejects.toThrow();
expect(getIsOffline()).toBe(true);

// One success clears both stops. The success listener resets the failure counters
// before recordSuccess reaches its early return, so the sustained-failure path
// must not schedule a second reconnect.
await FailureTracking(Promise.resolve({jsonCode: 200}), mockRequest, false);
expect(getIsOffline()).toBe(false);
jest.runAllTimers();
expect(reconnectListener).toHaveBeenCalledTimes(1);
});
});
Loading