Files
flipper/desktop/flipper-ui-core/src/dispatcher/flipperServer.tsx
Michel Weststrate b6c884f011 Make sure flipper server initialization errors are propagated properly
Summary:
This diff makes sure that errors are propagated similarly in flipper desktop and browser version, and that they are shown in either case.

Since in the browser version, the UI loads after the error happened, we'll store the error so that any client connecting in the future will read and report it.

Also added a `--failFast` flag to flipper-server, so that the process exits immediately if misconfigured, which is convenient in CI use cases and such

Reviewed By: nikoant

Differential Revision: D33348922

fbshipit-source-id: 0f584104f881141fde38da3f0031748415343ea2
2022-01-04 02:57:25 -08:00

389 lines
9.9 KiB
TypeScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React from 'react';
import {State, Store} from '../reducers/index';
import {
FlipperServer,
Logger,
NoLongerConnectedToClientError,
isTest,
DeviceDescription,
FlipperServerState,
} from 'flipper-common';
import Client from '../Client';
import {notification} from 'antd';
import BaseDevice from '../devices/BaseDevice';
import {ClientDescription, timeout} from 'flipper-common';
import {reportPlatformFailures} from 'flipper-common';
import {sideEffect} from '../utils/sideEffect';
import {waitFor} from '../utils/waitFor';
export function connectFlipperServerToStore(
server: FlipperServer,
store: Store,
logger: Logger,
) {
server.on('notification', ({type, title, description}) => {
const text = `[$type] ${title}: ${description}`;
console.warn(text);
notification.open({
message: title,
description: description,
type: type,
duration: 0,
key: text,
});
});
server.on('server-state', handleServerStateChange);
server.on('server-error', (err) => {
notification.error({
message: 'Failed to start connection server',
description:
err.code === 'EADDRINUSE' ? (
<>
Couldn't start connection server. Looks like you have multiple
copies of Flipper running or another process is using the same
port(s). As a result devices will not be able to connect to Flipper.
<br />
<br />
Please try to kill the offending process by running{' '}
<code>kill $(lsof -ti:PORTNUMBER)</code> and restart flipper.
<br />
<br />
{'' + err}
</>
) : (
<>Failed to start Flipper server: {err.message ?? err}</>
),
duration: null,
});
});
server.on('device-connected', (deviceInfo) => {
handleDeviceConnected(server, store, logger, deviceInfo);
});
server.on('device-disconnected', (device) => {
logger.track('usage', 'unregister-device', {
os: device.os,
serial: device.serial,
});
// N.B.: note that we don't remove the device, we keep it in offline
});
server.on('client-setup', (client) => {
store.dispatch({
type: 'START_CLIENT_SETUP',
payload: client,
});
});
server.on('client-connected', (payload: ClientDescription) =>
handleClientConnected(server, store, logger, payload),
);
server.on('client-disconnected', ({id}) => {
const existingClient = store.getState().connections.clients.get(id);
existingClient?.disconnect();
});
server.on('client-message', ({id, message}) => {
const existingClient = store.getState().connections.clients.get(id);
existingClient?.onMessage(message);
});
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
server.close();
});
}
let sideEffectDisposer: undefined | (() => void);
if (!isTest()) {
sideEffectDisposer = startSideEffects(store, server);
}
console.log(
'Flipper server started and accepting device / client connections',
);
server
.exec('get-server-state')
.then(handleServerStateChange)
.catch((e) => {
console.error(`Failed to get initial server state`, e);
});
// this flow is spawned delibarately from this main flow
waitFor(store, (state) => state.plugins.initialized)
.then(() => server.exec('device-list'))
.then((devices) => {
// register all devices
devices.forEach((device) => {
handleDeviceConnected(server, store, logger, device);
});
})
.then(() => server.exec('client-list'))
.then((clients) => {
clients.forEach((client) => {
handleClientConnected(server, store, logger, client);
});
})
.catch((e) => {
console.error('Failed to get initial device/client list: ', e);
});
return () => {
sideEffectDisposer?.();
server.close();
};
}
function startSideEffects(store: Store, server: FlipperServer) {
const dispose1 = sideEffect(
store,
{
name: 'settingsPersistor',
throttleMs: 100,
},
(state) => state.settingsState,
(settings) => {
server.exec('persist-settings', settings).catch((e) => {
console.error('Failed to persist Flipper settings', e);
});
},
);
const dispose2 = sideEffect(
store,
{
name: 'launcherSettingsPersistor',
throttleMs: 100,
},
(state) => state.launcherSettingsState,
(settings) => {
server.exec('persist-launcher-settings', settings).catch((e) => {
console.error('Failed to persist launcher settings', e);
});
},
);
return () => {
dispose1();
dispose2();
};
}
function handleServerStateChange({
state,
error,
}: {
state: FlipperServerState;
error?: string;
}) {
if (state === 'error') {
console.warn(`[conn] Flipper server state -> ${state}`, error);
notification.error({
message: 'Failed to start flipper-server',
description: '' + error,
duration: null,
});
} else {
console.info(`[conn] Flipper server state -> ${state}`);
}
}
function handleDeviceConnected(
server: FlipperServer,
store: Store,
logger: Logger,
deviceInfo: DeviceDescription,
) {
logger.track('usage', 'register-device', {
os: deviceInfo.os,
name: deviceInfo.title,
serial: deviceInfo.serial,
});
const existing = store
.getState()
.connections.devices.find((device) => device.serial === deviceInfo.serial);
// handled outside reducer, as it might emit new redux actions...
if (existing) {
if (existing.connected.get()) {
console.warn(
`Tried to replace still connected device '${existing.serial}' with a new instance.`,
);
}
existing.destroy();
}
const device = new BaseDevice(server, deviceInfo);
device.loadDevicePlugins(
store.getState().plugins.devicePlugins,
store.getState().connections.enabledDevicePlugins,
);
store.dispatch({
type: 'REGISTER_DEVICE',
payload: device,
});
}
export async function handleClientConnected(
server: Pick<FlipperServer, 'exec'>,
store: Store,
logger: Logger,
{id, query}: ClientDescription,
) {
const {connections} = store.getState();
const existingClient = connections.clients.get(id);
if (existingClient) {
existingClient.destroy();
store.dispatch({
type: 'CLEAR_CLIENT_PLUGINS_STATE',
payload: {
clientId: id,
devicePlugins: new Set(),
},
});
store.dispatch({
type: 'CLIENT_REMOVED',
payload: id,
});
}
console.log(
`[conn] Searching matching device ${query.device_id} for client ${query.app}...`,
);
const device =
getDeviceBySerial(store.getState(), query.device_id) ??
(await findDeviceForConnection(store, query.app, query.device_id).catch(
(e) => {
console.error(
`[conn] Failed to find device '${query.device_id}' while connection app '${query.app}'`,
e,
);
notification.error({
message: 'Connection failed',
description: `Failed to find device '${query.device_id}' while trying to connect app '${query.app}'`,
duration: 0,
});
},
));
if (!device) {
return;
}
const client = new Client(
id,
query,
{
send(data: any) {
server.exec('client-request', id, data);
},
async sendExpectResponse(data: any) {
return await server.exec('client-request-response', id, data);
},
},
logger,
store,
undefined,
device,
);
console.debug(
`Device client initialized: ${client.id}. Supported plugins: ${Array.from(
client.plugins,
).join(', ')}`,
'server',
);
store.dispatch({
type: 'NEW_CLIENT',
payload: client,
});
try {
await timeout(
30 * 1000,
client.init(),
`[conn] Failed to initialize client ${query.app} on ${query.device_id} in a timely manner`,
);
console.log(
`[conn] ${query.app} on ${query.device_id} connected and ready.`,
);
} catch (e) {
if (e instanceof NoLongerConnectedToClientError) {
console.warn(
`[conn] Client ${query.app} on ${query.device_id} disconnected while initialising`,
);
return;
}
throw e;
}
}
function getDeviceBySerial(
state: State,
serial: string,
): BaseDevice | undefined {
return state.connections.devices.find((device) => device.serial === serial);
}
async function findDeviceForConnection(
store: Store,
clientId: string,
serial: string,
): Promise<BaseDevice> {
let lastSeenDeviceList: BaseDevice[] = [];
/* All clients should have a corresponding Device in the store.
However, clients can connect before a device is registered, so wait a
while for the device to be registered if it isn't already. */
return reportPlatformFailures(
new Promise<BaseDevice>((resolve, reject) => {
let unsubscribe: () => void = () => {};
const timeout = setTimeout(() => {
unsubscribe();
reject(
new Error(
`Timed out waiting for device ${serial} for client ${clientId}`,
),
);
}, 15000);
unsubscribe = sideEffect(
store,
{name: 'waitForDevice', throttleMs: 100},
(state) => state.connections.devices,
(newDeviceList) => {
if (newDeviceList === lastSeenDeviceList) {
return;
}
lastSeenDeviceList = newDeviceList;
const matchingDevice = newDeviceList.find(
(device) => device.serial === serial,
);
if (matchingDevice) {
console.log(`[conn] Found device for: ${clientId} on ${serial}.`);
clearTimeout(timeout);
resolve(matchingDevice);
unsubscribe();
}
},
);
}),
'client-setMatchingDevice',
);
}