239 lines
8.4 KiB
TypeScript
239 lines
8.4 KiB
TypeScript
import { IStore } from '../../app/types';
|
|
import { JitsiIceRestartReason } from '../lib-jitsi-meet';
|
|
import { INetInfoState } from '../net-info/reducer';
|
|
|
|
import { getCurrentConference } from './functions';
|
|
import logger from './logger';
|
|
|
|
/**
|
|
* How long the network state has to stay put before a change is acted upon. A real interface handover produces a
|
|
* burst of events from the OS (wifi may drop to 'none' and back before settling on cellular), and reacting to each
|
|
* one would request several restarts for a single physical event.
|
|
*/
|
|
export const NETWORK_CHANGE_DEBOUNCE_MS = 3000;
|
|
|
|
/**
|
|
* The minimum time between two ICE restarts triggered by a network change. Jicofo rate-limits the requests server
|
|
* side as well; this keeps a flapping radio from burning through that budget.
|
|
*/
|
|
export const NETWORK_CHANGE_COOLDOWN_MS = 30000;
|
|
|
|
/**
|
|
* A tag used to make the log lines of this module greppable together with the ones the bridge and Jicofo emit.
|
|
*/
|
|
const LOG_PREFIX = 'ICE restart (network change):';
|
|
|
|
/**
|
|
* The identity of the network the current JVB ICE session was established on, as returned by
|
|
* {@link getNetworkIdentity}. A restart is requested when the device's current network no longer matches it.
|
|
*/
|
|
let sessionNetworkIdentity: string | undefined;
|
|
|
|
/**
|
|
* Set when a SET_NETWORK_INFO update reports the device offline while a session is active. Consumed (and
|
|
* cleared) the next time the device is back online. A reconnect is treated as a change even if it lands back
|
|
* on the exact same address (e.g. the same DHCP lease) - the interface genuinely went down and came back up,
|
|
* so any ICE candidates gathered on it are stale regardless of whether the address string still matches.
|
|
*/
|
|
let hadOfflineGap = false;
|
|
|
|
/**
|
|
* The pending debounce timer, if any.
|
|
*/
|
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
/**
|
|
* When the last restart was requested, used to enforce {@link NETWORK_CHANGE_COOLDOWN_MS}.
|
|
*/
|
|
let lastRequestTime = 0;
|
|
|
|
/**
|
|
* Derives a stable identifier for the network the device is currently attached to. Two values comparing unequal
|
|
* mean the local address the media session was set up on is no longer usable, so the session's local ICE
|
|
* candidates are stale.
|
|
*
|
|
* The network type alone is not enough: moving between two wifi networks (leaving home for the office) keeps the
|
|
* type at 'wifi' while changing the local address, so the address is included when the OS reports one. It is
|
|
* reported for wifi and ethernet but not for cellular, so two different cellular attachments are
|
|
* indistinguishable here and will not be detected as a change.
|
|
*
|
|
* @param {INetInfoState} netInfo - The 'base/net-info' state.
|
|
* @returns {string|undefined} - The identity, or undefined if the network type is not known yet.
|
|
*/
|
|
export function getNetworkIdentity(netInfo?: INetInfoState): string | undefined {
|
|
const networkType = netInfo?.networkType;
|
|
|
|
if (!networkType) {
|
|
return undefined;
|
|
}
|
|
|
|
const ipAddress = netInfo?.details?.ipAddress;
|
|
|
|
return ipAddress ? `${networkType}/${ipAddress}` : networkType;
|
|
}
|
|
|
|
/**
|
|
* Records the network the JVB ICE session has just been established on, so that later changes can be detected
|
|
* relative to it. Called when a conference is joined and after a restart has been requested.
|
|
*
|
|
* @param {IStore} store - The redux store.
|
|
* @returns {void}
|
|
*/
|
|
export function noteSessionNetwork(store: IStore): void {
|
|
sessionNetworkIdentity = getNetworkIdentity(store.getState()['features/base/net-info']);
|
|
|
|
logger.debug(`${LOG_PREFIX} session network is ${sessionNetworkIdentity}`);
|
|
}
|
|
|
|
/**
|
|
* Drops any pending work and forgets the session's network. Called when the conference goes away.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
export function resetNetworkChangeState(): void {
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = undefined;
|
|
}
|
|
|
|
sessionNetworkIdentity = undefined;
|
|
hadOfflineGap = false;
|
|
}
|
|
|
|
/**
|
|
* Requests the restart, if all of the conditions still hold. Everything is re-read from the store here rather than
|
|
* captured when the timer was armed, so that a network which changed and changed back within the debounce window
|
|
* does not produce a pointless restart.
|
|
*
|
|
* @param {IStore} store - The redux store.
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
function _restartIce(store: IStore): void {
|
|
const state = store.getState();
|
|
const netInfo = state['features/base/net-info'];
|
|
const identity = getNetworkIdentity(netInfo);
|
|
const recoveringFromOfflineGap = hadOfflineGap;
|
|
|
|
if (!identity || (!recoveringFromOfflineGap && identity === sessionNetworkIdentity)) {
|
|
logger.debug(`${LOG_PREFIX} network settled back to ${sessionNetworkIdentity}, nothing to do`);
|
|
|
|
return;
|
|
}
|
|
|
|
// Going offline is not something a restart can recover from - there is no interface to move to. The next
|
|
// change, when the device attaches to something, is what we want to act on.
|
|
if (!netInfo?.isOnline) {
|
|
logger.info(`${LOG_PREFIX} offline (${identity}), not requesting a restart`);
|
|
|
|
hadOfflineGap = true;
|
|
|
|
return;
|
|
}
|
|
|
|
hadOfflineGap = false;
|
|
|
|
const conference = getCurrentConference(state);
|
|
|
|
if (!conference) {
|
|
return;
|
|
}
|
|
|
|
if (!conference.isIceRestartSupported()) {
|
|
return;
|
|
}
|
|
|
|
// While P2P is active the JVB session is on hold and not carrying media, so there is nothing to preserve.
|
|
if (conference.isP2PActive()) {
|
|
logger.info(`${LOG_PREFIX} P2P is active, not requesting a restart`);
|
|
|
|
return;
|
|
}
|
|
|
|
const sinceLastRequest = Date.now() - lastRequestTime;
|
|
|
|
if (sinceLastRequest < NETWORK_CHANGE_COOLDOWN_MS) {
|
|
logger.warn(`${LOG_PREFIX} suppressed, last request was ${sinceLastRequest}ms ago`);
|
|
|
|
return;
|
|
}
|
|
|
|
logger.info(`${LOG_PREFIX} requesting a restart, ${sessionNetworkIdentity} -> ${identity}`);
|
|
|
|
// Set before the request so that a second change arriving while this one is in flight does not request
|
|
// another restart.
|
|
lastRequestTime = Date.now();
|
|
|
|
conference.restartJvbIce(JitsiIceRestartReason.NETWORK_CHANGE)
|
|
.then(() => {
|
|
// The request was accepted; the bridge's new transport arrives asynchronously. Treat the new network
|
|
// as the session's own from here so a repeat of the same change is not acted upon again.
|
|
noteSessionNetwork(store);
|
|
})
|
|
.catch((error: Error) => {
|
|
// Not fatal and deliberately not escalated: the pre-existing recovery flow (a full session restart
|
|
// once ICE actually fails) is untouched, and until then media keeps flowing on the old path.
|
|
logger.warn(`${LOG_PREFIX} request failed: ${error?.message ?? error}`);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Reacts to a change of the device's network. An in-place ICE restart is make-before-break - the bridge keeps
|
|
* carrying media on the established path while the new one is brought up - so it is safe to request whenever the
|
|
* local address the session was set up on may have gone stale, rather than only once ICE has failed.
|
|
*
|
|
* The work is debounced; see {@link NETWORK_CHANGE_DEBOUNCE_MS}.
|
|
*
|
|
* @param {IStore} store - The redux store.
|
|
* @returns {void}
|
|
*/
|
|
export function maybeRestartIceOnNetworkChange(store: IStore): void {
|
|
const state = store.getState();
|
|
|
|
// No session to move.
|
|
if (!getCurrentConference(state)) {
|
|
return;
|
|
}
|
|
|
|
const netInfo = state['features/base/net-info'];
|
|
const identity = getNetworkIdentity(netInfo);
|
|
|
|
if (!identity) {
|
|
return;
|
|
}
|
|
|
|
// The OS had not reported a network yet when the conference was joined, so this is the first chance to learn
|
|
// which network the session was established on.
|
|
if (sessionNetworkIdentity === undefined) {
|
|
sessionNetworkIdentity = identity;
|
|
|
|
return;
|
|
}
|
|
|
|
if (!netInfo?.isOnline) {
|
|
hadOfflineGap = true;
|
|
|
|
return;
|
|
}
|
|
|
|
// Still on the network the session was established on, and no intervening offline gap to recover from.
|
|
if (identity === sessionNetworkIdentity && !hadOfflineGap) {
|
|
return;
|
|
}
|
|
|
|
const { enableIceRestartOnNetworkChange = true } = state['features/base/config'];
|
|
|
|
if (!enableIceRestartOnNetworkChange) {
|
|
return;
|
|
}
|
|
|
|
logger.debug(`${LOG_PREFIX} ${sessionNetworkIdentity} -> ${identity}, settling for `
|
|
+ `${NETWORK_CHANGE_DEBOUNCE_MS}ms`);
|
|
|
|
debounceTimer && clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => {
|
|
debounceTimer = undefined;
|
|
_restartIce(store);
|
|
}, NETWORK_CHANGE_DEBOUNCE_MS);
|
|
}
|