diff --git a/src/__tests__/lib/lolex.js b/src/__tests__/lib/lolex.js index e03cf8809b3..5231bfaa7db 100644 --- a/src/__tests__/lib/lolex.js +++ b/src/__tests__/lib/lolex.js @@ -86,6 +86,18 @@ export class Lolex { this._clock.runToLast(); } + async runOnlyPendingTimersAsync(): Promise { + this._clock.runToLastAsync(); + } + + runAllTimers(): void { + this._clock.runAll(); + } + + async runAllTimersAsync(): Promise { + this._clock.runAllAsync(); + } + advanceTimersByTime(msToRun: number): void { this._clock.tick(msToRun); } diff --git a/src/actionConstants.js b/src/actionConstants.js index b7e2f00eae9..d71b945aa9e 100644 --- a/src/actionConstants.js +++ b/src/actionConstants.js @@ -20,6 +20,7 @@ export const DO_NARROW: 'DO_NARROW' = 'DO_NARROW'; export const INIT_SAFE_AREA_INSETS: 'INIT_SAFE_AREA_INSETS' = 'INIT_SAFE_AREA_INSETS'; export const INITIAL_FETCH_START: 'INITIAL_FETCH_START' = 'INITIAL_FETCH_START'; export const INITIAL_FETCH_COMPLETE: 'INITIAL_FETCH_COMPLETE' = 'INITIAL_FETCH_COMPLETE'; +export const INITIAL_FETCH_ABORT: 'INITIAL_FETCH_ABORT' = 'INITIAL_FETCH_ABORT'; export const INIT_TOPICS: 'INIT_TOPICS' = 'INIT_TOPICS'; export const EVENT: 'EVENT' = 'EVENT'; diff --git a/src/actionTypes.js b/src/actionTypes.js index 0fc51f4dbbb..b93ae7a337b 100644 --- a/src/actionTypes.js +++ b/src/actionTypes.js @@ -19,6 +19,7 @@ import { MESSAGE_FETCH_COMPLETE, INITIAL_FETCH_START, INITIAL_FETCH_COMPLETE, + INITIAL_FETCH_ABORT, SETTINGS_CHANGE, DRAFT_UPDATE, DO_NARROW, @@ -212,6 +213,10 @@ type InitialFetchCompleteAction = {| type: typeof INITIAL_FETCH_COMPLETE, |}; +type InitialFetchAbortAction = {| + type: typeof INITIAL_FETCH_ABORT, +|}; + type ServerEvent = {| id: number, |}; @@ -568,7 +573,11 @@ type AccountAction = | LoginSuccessAction | LogoutAction; -type LoadingAction = DeadQueueAction | InitialFetchStartAction | InitialFetchCompleteAction; +type LoadingAction = + | DeadQueueAction + | InitialFetchStartAction + | InitialFetchCompleteAction + | InitialFetchAbortAction; type MessageAction = MessageFetchStartAction | MessageFetchCompleteAction; diff --git a/src/message/__tests__/fetchActions-test.js b/src/message/__tests__/fetchActions-test.js index 4bc5901497d..ee2e78f002a 100644 --- a/src/message/__tests__/fetchActions-test.js +++ b/src/message/__tests__/fetchActions-test.js @@ -1,8 +1,11 @@ import mockStore from 'redux-mock-store'; // eslint-disable-line -import { fetchMessages, fetchOlder, fetchNewer } from '../fetchActions'; +import { Lolex } from '../../__tests__/lib/lolex'; +import { fetchMessages, fetchOlder, fetchNewer, tryFetch } from '../fetchActions'; import { streamNarrow, HOME_NARROW, HOME_NARROW_STR } from '../../utils/narrow'; import { navStateWithNarrow } from '../../utils/testHelpers'; +import { ApiError } from '../../api/apiErrors'; +import { TimeoutError } from '../../utils/async'; const narrow = streamNarrow('some stream'); const streamNarrowStr = JSON.stringify(narrow); @@ -14,6 +17,79 @@ describe('fetchActions', () => { fetch.reset(); }); + describe('tryFetch', () => { + const lolex = new Lolex(); + afterAll(() => { + lolex.dispose(); + }); + + afterEach(() => { + // clear any unset timers + lolex.clearAllTimers(); + }); + + test('retries a call, if there is an exception', async () => { + // fail on first call, succeed second time + let callCount = 0; + const thrower = () => { + callCount++; + if (callCount === 1) { + throw new Error('First run exception'); + } + return 'hello'; + }; + + const tryFetchPromise = tryFetch(async () => { + await new Promise(r => setTimeout(r, 10)); + return thrower(); + }); + await lolex.runAllTimersAsync(); + const result = await tryFetchPromise; + + expect(result).toEqual('hello'); + }); + + test('Rethrows a 4xx error without retrying', async () => { + const apiError = new ApiError(400, { + code: 'BAD_REQUEST', + msg: 'Bad Request', + result: 'error', + }); + + const func = jest.fn().mockImplementation(async () => { + throw apiError; + }); + const tryFetchPromise = tryFetch(func); + + await lolex.runAllTimersAsync(); + expect(func).toHaveBeenCalledTimes(1); + return expect(tryFetchPromise).rejects.toThrow(apiError); + }); + + test('times out after many short-duration 5xx errors', async () => { + const tryFetchPromise = tryFetch(async () => { + await new Promise(r => setTimeout(r, 50)); + throw new ApiError(500, { + code: 'SOME_ERROR_CODE', + msg: 'Internal Server Error', + result: 'error', + }); + }); + + await lolex.runAllTimersAsync(); + return expect(tryFetchPromise).rejects.toThrow(TimeoutError); + }); + + test('times out after hanging on one request', async () => { + const tryFetchPromise = tryFetch(async () => { + await new Promise((resolve, reject) => {}); + }); + + await lolex.runAllTimersAsync(); + return expect(tryFetchPromise).rejects.toThrow(TimeoutError); + }); + }); + describe('fetchMessages', () => { test('message fetch success action is dispatched after successful fetch', async () => { const store = mockStore({ diff --git a/src/message/fetchActions.js b/src/message/fetchActions.js index 7d5fec48592..5c0cd0f4390 100644 --- a/src/message/fetchActions.js +++ b/src/message/fetchActions.js @@ -10,6 +10,7 @@ import type { } from '../types'; import type { InitialData } from '../api/initialDataTypes'; import * as api from '../api'; +import { isClientError } from '../api/apiErrors'; import { getAuth, getSession, @@ -23,12 +24,14 @@ import config from '../config'; import { INITIAL_FETCH_START, INITIAL_FETCH_COMPLETE, + INITIAL_FETCH_ABORT, MESSAGE_FETCH_START, MESSAGE_FETCH_COMPLETE, } from '../actionConstants'; import { FIRST_UNREAD_ANCHOR, LAST_MESSAGE_ANCHOR } from '../anchor'; import { ALL_PRIVATE_NARROW } from '../utils/narrow'; -import { tryUntilSuccessful } from '../utils/async'; +import { BackoffMachine, promiseTimeout, TimeoutError } from '../utils/async'; +import * as logging from '../utils/logging'; import { initNotifications } from '../notification/notificationActions'; import { addToOutbox, sendOutbox } from '../outbox/outboxActions'; import { realmInit } from '../realm/realmActions'; @@ -126,6 +129,10 @@ const initialFetchComplete = (): Action => ({ type: INITIAL_FETCH_COMPLETE, }); +const initialFetchAbort = (): Action => ({ + type: INITIAL_FETCH_ABORT, +}); + const isFetchNeededAtAnchor = (state: GlobalState, narrow: Narrow, anchor: number): boolean => { // Ideally this would detect whether, even if we don't have *all* the // messages in the narrow, we have enough of them around the anchor @@ -178,14 +185,12 @@ export const fetchMessagesInNarrow = ( */ const fetchPrivateMessages = () => async (dispatch: Dispatch, getState: GetState) => { const auth = getAuth(getState()); - const { messages, found_newest, found_oldest } = await tryUntilSuccessful(() => - api.getMessages(auth, { - narrow: ALL_PRIVATE_NARROW, - anchor: LAST_MESSAGE_ANCHOR, - numBefore: 100, - numAfter: 0, - }), - ); + const { messages, found_newest, found_oldest } = await api.getMessages(auth, { + narrow: ALL_PRIVATE_NARROW, + anchor: LAST_MESSAGE_ANCHOR, + numBefore: 100, + numAfter: 0, + }); dispatch( messageFetchComplete({ messages, @@ -222,6 +227,54 @@ const fetchTopMostNarrow = () => async (dispatch: Dispatch, getState: GetState) } }; +/** + * Calls an async function and if unsuccessful retries the call. + * + * If the function is an API call and the response has HTTP status code 4xx + * the error is considered unrecoverable and the exception is rethrown, to be + * handled further up in the call stack. + * + * After a certain duration, times out with a TimeoutError. + */ +export async function tryFetch(func: () => Promise): Promise { + const MAX_TIME_MS: number = 60000; + const backoffMachine = new BackoffMachine(); + + // TODO: Use AbortController instead of this stateful flag; #4170 + let timerHasExpired = false; + + return promiseTimeout( + (async () => { + // eslint-disable-next-line no-constant-condition + while (true) { + if (timerHasExpired) { + // No one is listening for this Promise's outcome, so stop + // doing more work. + throw new Error(); + } + try { + return await func(); + } catch (e) { + if (isClientError(e)) { + throw e; + } + } + await backoffMachine.wait(); + } + // Without this, Flow 0.92.1 does not know this code is unreachable, + // and it incorrectly thinks Promise could be returned, + // which is inconsistent with the stated Promise return type. + // eslint-disable-next-line no-unreachable + throw new Error(); + })(), + MAX_TIME_MS, + () => { + timerHasExpired = true; + throw new TimeoutError(); + }, + ); +} + /** * Fetch lots of state from the server, and start an event queue. * @@ -245,7 +298,7 @@ export const doInitialFetch = () => async (dispatch: Dispatch, getState: GetStat try { [initData, serverSettings] = await Promise.all([ - tryUntilSuccessful(() => + tryFetch(() => api.registerForEvents(auth, { fetch_event_types: config.serverDataOnStartup, apply_markdown: true, @@ -257,12 +310,18 @@ export const doInitialFetch = () => async (dispatch: Dispatch, getState: GetStat }, }), ), - tryUntilSuccessful(() => api.getServerSettings(auth.realm)), + tryFetch(() => api.getServerSettings(auth.realm)), ]); } catch (e) { - // This should only happen on a 4xx HTTP status, which should only - // happen when `auth` is no longer valid. No use retrying; just log out. - dispatch(logout()); + if (isClientError(e)) { + dispatch(logout()); + } else if (e instanceof TimeoutError) { + dispatch(initialFetchAbort()); + } else { + logging.warn(e, { + message: 'Unexpected error during initial fetch and serverSettings fetch.', + }); + } return; } diff --git a/src/nav/__tests__/navReducer-test.js b/src/nav/__tests__/navReducer-test.js index 7716328c4a2..a488bba1bbc 100644 --- a/src/nav/__tests__/navReducer-test.js +++ b/src/nav/__tests__/navReducer-test.js @@ -1,6 +1,11 @@ import deepFreeze from 'deep-freeze'; -import { LOGIN_SUCCESS, INITIAL_FETCH_COMPLETE, REHYDRATE } from '../../actionConstants'; +import { + LOGIN_SUCCESS, + INITIAL_FETCH_COMPLETE, + INITIAL_FETCH_ABORT, + REHYDRATE, +} from '../../actionConstants'; import navReducer, { getStateForRoute } from '../navReducer'; import { NULL_OBJECT } from '../../nullObjects'; @@ -42,6 +47,26 @@ describe('navReducer', () => { }); }); + describe('INITIAL_FETCH_ABORT', () => { + test('navigate to account screen', () => { + const prevState = getStateForRoute('main'); + + const action = deepFreeze({ + type: INITIAL_FETCH_ABORT, + }); + + const expectedState = { + index: 0, + routes: [{ routeName: 'account' }], + }; + + const newState = navReducer(prevState, action); + + expect(newState.index).toEqual(expectedState.index); + expect(newState.routes[0].routeName).toEqual(expectedState.routes[0].routeName); + }); + }); + describe('REHYDRATE', () => { test('when no previous navigation is given do not throw but return some result', () => { const initialState = NULL_OBJECT; @@ -60,7 +85,7 @@ describe('navReducer', () => { expect(nav.routes).toHaveLength(1); }); - test('if logged in, go to main screen', () => { + test('if logged in and users is empty, go to loading', () => { const initialState = NULL_OBJECT; const action = deepFreeze({ @@ -74,6 +99,24 @@ describe('navReducer', () => { const nav = navReducer(initialState, action); + expect(nav.routes).toHaveLength(1); + expect(nav.routes[0].routeName).toEqual('loading'); + }); + + test('if logged in and users is not empty, go to main', () => { + const initialState = NULL_OBJECT; + + const action = deepFreeze({ + type: REHYDRATE, + payload: { + accounts: [{ apiKey: '123' }], + users: [{ user_id: 123 }], + realm: {}, + }, + }); + + const nav = navReducer(initialState, action); + expect(nav.routes).toHaveLength(1); expect(nav.routes[0].routeName).toEqual('main'); }); diff --git a/src/nav/navReducer.js b/src/nav/navReducer.js index 1a3a533031c..6b4c79a0305 100644 --- a/src/nav/navReducer.js +++ b/src/nav/navReducer.js @@ -6,6 +6,7 @@ import AppNavigator from './AppNavigator'; import { REHYDRATE, INITIAL_FETCH_COMPLETE, + INITIAL_FETCH_ABORT, ACCOUNT_SWITCH, LOGIN_SUCCESS, LOGOUT, @@ -60,7 +61,7 @@ const rehydrate = (state, action) => { // will have set `needsInitialFetch`, too, so we really will be loading. // // (Valid server data must have a user: the self user, at a minimum.) - if (rehydratedState.users === undefined) { + if (rehydratedState.users === undefined || rehydratedState.users.length === 0) { return getStateForRoute('loading'); } @@ -83,6 +84,7 @@ export default (state: NavigationState = initialState, action: Action): Navigati case INITIAL_FETCH_COMPLETE: return state.routes[0].routeName === 'main' ? state : getStateForRoute('main'); + case INITIAL_FETCH_ABORT: case LOGOUT: return getStateForRoute('account'); diff --git a/src/session/__tests__/sessionReducer-test.js b/src/session/__tests__/sessionReducer-test.js index 044a1204f13..329dc2102d1 100644 --- a/src/session/__tests__/sessionReducer-test.js +++ b/src/session/__tests__/sessionReducer-test.js @@ -7,6 +7,7 @@ import { DO_NARROW, APP_ONLINE, INITIAL_FETCH_COMPLETE, + INITIAL_FETCH_ABORT, INIT_SAFE_AREA_INSETS, APP_ORIENTATION, GOT_PUSH_TOKEN, @@ -87,6 +88,12 @@ describe('sessionReducer', () => { expect(newState).toEqual({ ...baseState, isOnline: true }); }); + test('INITIAL_FETCH_ABORT', () => { + const state = deepFreeze({ ...baseState, needsInitialFetch: true, loading: true }); + const newState = sessionReducer(state, deepFreeze({ type: INITIAL_FETCH_ABORT })); + expect(newState).toEqual({ ...baseState, needsInitialFetch: false, loading: false }); + }); + test('INITIAL_FETCH_COMPLETE', () => { const state = deepFreeze({ ...baseState, needsInitialFetch: true, loading: true }); const newState = sessionReducer(state, deepFreeze({ type: INITIAL_FETCH_COMPLETE })); diff --git a/src/session/sessionReducer.js b/src/session/sessionReducer.js index 7353f42f57e..3a84c70226c 100644 --- a/src/session/sessionReducer.js +++ b/src/session/sessionReducer.js @@ -10,6 +10,7 @@ import { REALM_INIT, INIT_SAFE_AREA_INSETS, INITIAL_FETCH_COMPLETE, + INITIAL_FETCH_ABORT, INITIAL_FETCH_START, APP_ORIENTATION, TOGGLE_OUTBOX_SENDING, @@ -162,6 +163,7 @@ export default (state: SessionState = initialState, action: Action): SessionStat loading: true, }; + case INITIAL_FETCH_ABORT: case INITIAL_FETCH_COMPLETE: return { ...state, diff --git a/src/utils/__tests__/async-test.js b/src/utils/__tests__/async-test.js index 3caa376260b..02a8f3f75ae 100644 --- a/src/utils/__tests__/async-test.js +++ b/src/utils/__tests__/async-test.js @@ -1,5 +1,5 @@ /* @flow strict-local */ -import { sleep, tryUntilSuccessful } from '../async'; +import { sleep } from '../async'; import { Lolex } from '../../__tests__/lib/lolex'; const sleepMeasure = async (expectedMs: number) => { @@ -60,35 +60,3 @@ describe('sleep (real)', () => { // unpredictable loads. }); }); - -describe('tryUntilSuccessful', () => { - test('resolves any value when there is no exception', async () => { - const result = await tryUntilSuccessful(async () => 'hello'); - - expect(result).toEqual('hello'); - }); - - test('resolves any promise, if there is no exception', async () => { - const result = await tryUntilSuccessful( - () => new Promise(resolve => setTimeout(() => resolve('hello'), 100)), - ); - - expect(result).toEqual('hello'); - }); - - test('retries a call, if there is an exception', async () => { - // fail on first call, succeed second time - let callCount = 0; - const thrower = () => { - callCount++; - if (callCount === 1) { - throw new Error('First run exception'); - } - return 'hello'; - }; - - const result = await tryUntilSuccessful(async () => thrower()); - - expect(result).toEqual('hello'); - }); -}); diff --git a/src/utils/__tests__/promiseTimeout-test.js b/src/utils/__tests__/promiseTimeout-test.js new file mode 100644 index 00000000000..8f2795fb4b8 --- /dev/null +++ b/src/utils/__tests__/promiseTimeout-test.js @@ -0,0 +1,141 @@ +/* @flow strict-local */ +import { promiseTimeout, TimeoutError, sleep } from '../async'; +import { Lolex } from '../../__tests__/lib/lolex'; + +const ONE_MINUTE_MS: number = 1000 * 60 * 60; + +describe('promiseTimeout', () => { + const lolex: Lolex = new Lolex(); + + afterAll(() => { + lolex.dispose(); + }); + + afterEach(() => { + // clear any unset timers + lolex.clearAllTimers(); + }); + + test('If `promise` resolves with `x` before time is up, resolves with `x`, `onTimeout` not called', async () => { + const x = Math.random(); + const quickPromise = new Promise(resolve => setTimeout(() => resolve(x), 1)); + + const onTimeout = jest.fn(); + + const quickPromiseWithTimeout = promiseTimeout(quickPromise, ONE_MINUTE_MS, onTimeout); + lolex.runOnlyPendingTimers(); + + const resolution = await quickPromiseWithTimeout; + + expect(resolution).toEqual(x); + expect(onTimeout).not.toHaveBeenCalled(); + }); + + test('If `promise` rejects before time is up, rejects with that reason, `onTimeout` not called', async () => { + const x = Math.random(); + const quickPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error(x.toString())), 1), + ); + + const onTimeout = jest.fn(); + + const quickPromiseWithTimeout = promiseTimeout(quickPromise, ONE_MINUTE_MS, onTimeout); + lolex.runOnlyPendingTimers(); + + await expect(quickPromiseWithTimeout).rejects.toThrow(x.toString()); + expect(onTimeout).not.toHaveBeenCalled(); + }); + + describe('If time is up', () => { + test('Throws a TimeoutError if `onTimeout` not passed', async () => { + const endlessPromise = new Promise((resolve, reject) => {}); + + const endlessPromiseWithTimeout = promiseTimeout(endlessPromise, ONE_MINUTE_MS); + lolex.runOnlyPendingTimers(); + + await expect(endlessPromiseWithTimeout).rejects.toThrow(TimeoutError); + }); + + test('If `onTimeout` passed, calls it once, with no arguments', async () => { + const endlessPromise = new Promise((resolve, reject) => {}); + + const onTimeout = jest.fn(); + + const endlessPromiseWithTimeout = promiseTimeout(endlessPromise, ONE_MINUTE_MS, onTimeout); + lolex.runOnlyPendingTimers(); + + await endlessPromiseWithTimeout; + + expect(onTimeout).toHaveBeenCalledTimes(1); + expect(onTimeout).toHaveBeenCalledWith(); + }); + + test('If `onTimeout` returns a non-Promise `x`, resolves to `x` immediately', async () => { + const endlessPromise = new Promise((resolve, reject) => {}); + + const x = Math.random(); + const onTimeout = () => x; + + const endlessPromiseWithTimeout = promiseTimeout(endlessPromise, ONE_MINUTE_MS, onTimeout); + lolex.runOnlyPendingTimers(); + + const result = await endlessPromiseWithTimeout; + + expect(result).toEqual(x); + }); + + test('If `onTimeout` returns a Promise that resolves to `x`, resolves to `x`', async () => { + const endlessPromise = new Promise((resolve, reject) => {}); + + const x = Math.random(); + const onTimeout = async () => { + await sleep(ONE_MINUTE_MS); + return x; + }; + + const endlessPromiseWithTimeout = promiseTimeout(endlessPromise, ONE_MINUTE_MS, onTimeout); + await lolex.runAllTimersAsync(); + + const result = await endlessPromiseWithTimeout; + + expect(result).toEqual(x); + }); + + test('If `onTimeout` returns a Promise that rejects, rejects with that reason', async () => { + const endlessPromise = new Promise((resolve, reject) => {}); + + const x = Math.random(); + const onTimeout = async () => { + await sleep(ONE_MINUTE_MS); + throw new Error(x.toString()); + }; + + const endlessPromiseWithTimeout = promiseTimeout(endlessPromise, ONE_MINUTE_MS, onTimeout); + await lolex.runAllTimersAsync(); + + await expect(endlessPromiseWithTimeout).rejects.toThrow(x.toString()); + }); + + test('If `onTimeout` returns a Promise, that promise does not give `promise` extra time', async () => { + const ONE_SECOND: number = 1000; + const TWO_SECONDS: number = 2000; + + const promise = (async () => { + await sleep(TWO_SECONDS); + throw new Error(`Should have timed out after ${ONE_SECOND}ms.`); + })(); + + const onTimeout = async (): Promise => { + await sleep(TWO_SECONDS); + return true; + }; + + const promiseWithTimeout = promiseTimeout(promise, ONE_SECOND, onTimeout); + await lolex.runAllTimersAsync(); + + const result = await promiseWithTimeout; + + expect(result).toEqual(true); + }); + }); +}); diff --git a/src/utils/async.js b/src/utils/async.js index 957089657ed..9f7811c1c37 100644 --- a/src/utils/async.js +++ b/src/utils/async.js @@ -1,5 +1,43 @@ /* @flow strict-local */ -import { isClientError } from '../api/apiErrors'; + +export class TimeoutError extends Error { + name = 'TimeoutError'; +} + +/** + * Time-out a Promise after `timeLimitMs` has passed. + * + * Returns a new Promise with the same outcome as `promise`, if + * `promise` completes in time. + * + * If `promise` does not complete before `timeLimitMs` has passed, + * `onTimeout` is called, and its outcome is used as the outcome of + * the returned Promise. + * + * If `onTimeout` is omitted, a timeout will cause the returned + * Promise to reject with a TimeoutError. + * + * Interface modeled after + * https://api.dart.dev/stable/2.8.4/dart-async/Future/timeout.html. + */ +export async function promiseTimeout( + promise: Promise, + timeLimitMs: number, + onTimeout?: () => T | Promise, +): Promise { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new TimeoutError()), timeLimitMs), + ); + try { + return await Promise.race([promise, timeoutPromise]); + } catch (e) { + if (e instanceof TimeoutError && onTimeout !== undefined) { + return onTimeout(); + } else { + throw e; + } + } +} /** Like setTimeout(..., 0), but returns a Promise of the result. */ export function delay(callback: () => T): Promise { @@ -76,31 +114,3 @@ export class BackoffMachine { this._waitsCompleted++; }; } - -/** - * Calls an async function and if unsuccessful retries the call. - * - * If the function is an API call and the response has HTTP status code 4xx - * the error is considered unrecoverable and the exception is rethrown, to be - * handled further up in the call stack. - */ -export async function tryUntilSuccessful(func: () => Promise): Promise { - const backoffMachine = new BackoffMachine(); - // eslint-disable-next-line no-constant-condition - while (true) { - try { - return await func(); - } catch (e) { - if (isClientError(e)) { - throw e; - } - await backoffMachine.wait(); - } - } - - // Without this, Flow 0.92.1 does not know this code is unreachable, - // and it incorrectly thinks Promise could be returned, - // which is inconsistent with the stated Promise return type. - // eslint-disable-next-line no-unreachable - throw new Error(); -}