Debugging Flipper Desktop App

Photo by Drew Dau on Unsplash

Debugging Flipper Desktop App

How to deal with Flipper Plugin is unavailable

Background

While upgrading React Native project at work, one problem occurs to me is that I’m not able to use the Hermes debugger plugin on Flipper (Desktop App). Before the upgrade, my team has been using Chrome V8 for Android. This is where the exploration begins.

Explore Flow

Below are my exploration flow

  • Is it due to React Native version and Flipper version mismatch? ⇒ ❌

    • Things are expected
  • Is it possible Flipper has a bug in the current version? ⇒ ❌

    • Look through the repo Github and did not find a relevant issue

    • Setting up a new pure React Native project with the specific version seems to work well with the Hermes debugger plugin

  • Is it due to some special package or setting on the current project? ⇒ ❌

    • Comparing package-lock file between the pure React Native project, related packages are using the same version
  • Is it possible Metro server had worked differently? ⇒ ❌

    • While digging through the react-native-cli, did not find suspicious things

    • While launching the Metro sever on the pure React Native project, the Hermes debugger was showing

Since the above doesn’t seem to get me any further, I decide to explore on Flipper Desktop App more. But at least the problem has been nailed down as to Why Flipper couldn’t connect to the existing project’s Metro server?

Flipper (Desktop App)

How Flipper does connect?

Run from Source

To run Flipper Desktop App from the source

git clone <https://github.com/facebook/flipper.git>
cd flipper/desktop
yarn
yarn start

Debugging

Let’s start from figure out where this screen comes from

First, this plugin is located at plugins/public/hermesdebuggerrn .

// /plugins/public/hermesdebuggerrn/package.json
{
  "$schema": "<https://fbflipper.com/schemas/plugin-package/v2.json>",
  "name": "flipper-plugin-hermesdebuggerrn",
  "id": "Hermesdebuggerrn",
  "pluginType": "device",
  "supportedDevices": [
    {
      "os": "Metro",
      "archived": false
    }
  ] ...
}

Second, let’s look at the rendering part of the plugin

// plugins/public/hermesdebuggerrn/index.tsx
...
renderContent() {
    const {error, selectedTarget, targets} = this.state;
    console.log('Mark >>>>>>>>>>>>>>> ', error, selectedTarget, targets);

    if (selectedTarget) {
      ...
      return ( <ChromeDevTools ... />);
    } else if (targets != null && targets.length === 0) {
      return <LaunchScreen />;
    } else if (targets != null && targets.length > 0) {
      return <SelectScreen targets={targets} onSelect={this.handleSelect} />;
    } else if (error != null) {
      return <ErrorScreen error={error} />;
    } else {
      return null;
    }
  }
...

After adding the debug log, it doesn’t seem to show. So the issue should be coming from another place. By searching the substring of the error message, we were able to find the following.

// flipper-ui-core/src/utils/pluginUtils.tsx
export function computePluginLists(connections, plugins, device, metroDevice, client){
...
if (device) {
    // find all device plugins that aren't part of the current device / metro
    for (const p of plugins.devicePlugins.values()) {
      if (!device.supportsPlugin(p) && !metroDevice?.supportsPlugin(p)) {
        unavailablePlugins.push([
          p.details,
          `Device plugin '${getPluginTitle(
            p.details,
          )}' is not supported by the selected device '${device.title}' (${
            device.os
          })`,]);
      }}...
    }
...
}

So the origin of the error message has been found, let’s figure out how it got into this condition.

// flipper-ui-core/src/utils/pluginUtils.tsx
console.log('Device >>>> ', device, metroDevice);
if (!device.supportsPlugin(p) && !metroDevice?.supportsPlugin(p)) {
  unavailablePlugins.push([...])
 ...
}

The reason that Hermes debugger is being pushed into the unavailablePlugins is that metroDevice was null.

Wonderful, we’ve nailed down the problem of metro Devices not being unavailable. Next, we just need to figure out why. Let’s find the caller for computePluginLists, to see what has been passed in for the metroDevice.

// flipper-ui-core/src/selectors/connections.tsx
export const getPluginLists = createSelector(
  ({
    connections: {
      enabledDevicePlugins,
      enabledPlugins,
      selectedAppPluginListRevision, // used only to invalidate cache
    },
  }) => ({
    enabledDevicePlugins,
    enabledPlugins,
    selectedAppPluginListRevision,
  }),
  ({
    plugins: {
      clientPlugins,
      ...
    },
  }) => ({
    clientPlugins,
    ...
  }),
  getActiveDevice,
  getMetroDevice,
  getActiveClient,
  computePluginLists,
);

To learn more about the createSelector

Exploring more on getMetroDevice

// flipper-ui-core/src/selectors/connections.tsx

export const getMetroDevice = createSelector(getDevices, (devices) => {
  console.log('Mark >>>>>>>>', devices);
  return (
    devices.find((device) => **device.os === 'Metro' && !device.isArchived) ??
    null
  );
});

As we see from the log, there doesn’t exist any BaseDevice which device.os === 'Metro'.

More about the Metro Device part

Next, we need to have a look at the state

// flipper-ui-core/src/selectors/connections.tsx

const getDevices = (state: State) => state.connections.devices;

export const getMetroDevice = createSelector(**getDevices**, (devices) => {
  return (
    devices.find((device) => device.os === 'Metro' && !device.isArchived) ??
    null
  );
});

Let’s explore the reducer of connections

// flipper-ui-core/src/reducers/connections.tsx

export default (state: State = INITAL_STATE, action: Actions): State => {
  switch (action.type) {
        ...
        case 'REGISTER_DEVICE': {
              const {payload} = action;
              const newDevices = state.devices.slice();      
                ...
              return {
                ...state,
                devices: newDevices,
                selectedDevice: selectNewDevice ? payload : state.selectedDevice,
                selectedAppId,
              };
            }
   }
    ...
}

Next, let’s try to find where the Actions are being called

// flipper-ui-core/src/dispatcher/flipperServer.tsx

export function handleDeviceConnected(
  server: FlipperServer,
  store: Store,
  logger: Logger,
  deviceInfo: DeviceDescription,
) {
  ...
  console.log('Device info >>>> ', server, deviceInfo);
  const device = new BaseDevice(server, deviceInfo);
  ...
  store.dispatch({
    type: 'REGISTER_DEVICE',
    payload: device,
  });
}

Logs collect above

The caller of handleDeviceConnected would be connectFlipperServerToStore

// flipper-ui-core/src/dispatcher/flipperServer.tsx

export function connectFlipperServerToStore(
  server: FlipperServer,
  store: Store,
  logger: Logger,
) {
    ...
    server.on('device-connected', (deviceInfo) => {
    **handleDeviceConnected**(server, store, logger, deviceInfo);
  });
  ...
  waitFor(store, (state) => state.plugins.initialized)
    .then(() => server.exec('device-list'))
    .then((devices) => {
      // register all devices
      devices.forEach((device) => {
        **handleDeviceConnected**(server, store, logger, device);
      });
    })
 ...
}

We’ll need to find the server

The caller of connectFlipperServerToStore is the init in startFlipperDesktop.tsx

// flipper-ui-core/src/startFlipperDesktop.tsx

function init(flipperServer: FlipperServer) {
  const settings = getRenderHostInstance().serverConfig.settings;
  const store = getStore();
  const logger = initLogger(store);
  ...
  connectFlipperServerToStore(flipperServer, store, logger);
  ...
}

export async function startFlipperDesktop(flipperServer: FlipperServer) {
  getRenderHostInstance(); // renderHost instance should be set at this point!
  init(flipperServer);
}

To proceed, we will need to look at callers of startFlipperDesktop

// app/src/init.tsx

async function start() {
    ...

  const electronIpcClient = new ElectronIpcClientRenderer();

  const flipperServer: FlipperServer = await getFlipperServer**(
    logger,
    electronIpcClient,
  );
  const flipperServerConfig = await flipperServer.exec('get-config');

  await initializeElectron(
    flipperServer,
    flipperServerConfig,
    electronIpcClient,
  );

  ...

  require('flipper-ui-core').startFlipperDesktop(flipperServer);
  await flipperServer.connect();
  ...
}

start().catch((e) => { ... });

Following, we should look into getFlipperServer

// app/src/init.tsx
async function getFlipperServer(
  logger: Logger,
  electronIpcClient: ElectronIpcClientRenderer,
): Promise<FlipperServer> {
    ...
    const getEmbeddedServer = async () => {
    const server = new FlipperServerImpl(
      {
        environmentInfo,
        env: parseEnvironmentVariables(env),
        gatekeepers: gatekeepers,
        paths: {
          appPath,
          homePath: await electronIpcClient.send('getPath', 'home'),
          execPath,
          staticPath,
          tempPath: await electronIpcClient.send('getPath', 'temp'),
          desktopPath: await electronIpcClient.send('getPath', 'desktop'),
        },
        launcherSettings: await loadLauncherSettings(),
        processConfig: loadProcessConfig(env),
        settings,
        validWebSocketOrigins:
          constants.VALID_WEB_SOCKET_REQUEST_ORIGIN_PREFIXES,
      },
      logger,
      keytar,
    );

    return server;
  };

    ...

    return getEmbeddedServer();
}

Dig deeper, we should look at the implementation of FlipperServerImpl

In the app/src/init.tsx called flipperServer.connect();

// flipper-server-core/src/FlipperServerImpl.tsx

export class FlipperServerImpl implements FlipperServer {
    ...
  async connect() {
    ...
    try {
      await this.createFolders();
      await this.server.init();
      await this.pluginManager.start();
      await this.startDeviceListeners();
      this.setServerState('started');
    } catch (e) {...}
  }
    ...
}

More on the this.startDeviceListeners() part

// flipper-server-core/src/FlipperServerImpl.tsx

import metroDevice from './devices/metro/metroDeviceManager';

async startDeviceListeners() {
    const asyncDeviceListenersPromises: Array<Promise<void>> = [];
    if (this.config.settings.enableAndroid) {
      ...
    }
    if (this.config.settings.enableIOS) {
      ...
    }
    const asyncDeviceListeners = await Promise.all(
      asyncDeviceListenersPromises,
    );
    this.disposers.push(
      ...asyncDeviceListeners,
      metroDevice(this),
      desktopDevice(this),
    );
  }

Deeper into metroDevice

// flipper-server-core/src/devices/metro/metroDeviceManager.tsx

export default (flipperServer: FlipperServerImpl) => {
  let timeoutHandle: NodeJS.Timeout;
  let ws: WebSocket | undefined;
  let unregistered = false;

  async function tryConnectToMetro() {
    if (ws) {
      return;
    }

    if (await **isMetroRunning**()) {
      ...
        }
  }

}

More in isMetroRunning

// flipper-server-core/src/devices/metro/metroDeviceManager.tsx

const METRO_MESSAGE = ['React Native packager is running', 'Metro is running'];
async function isMetroRunning(): Promise<boolean> {
  console.log('Metro >>>>>>  is Running');
  return new Promise((resolve) => {
    http
      .get(METRO_URL, (resp) => {
        let data = '';
        resp
          .on('data', (chunk) => {
            data += chunk;
            console.log('Metro >>>>>> data', data);
          })
          .on('end', () => {
            const isMetro = METRO_MESSAGE.some((msg) => data.includes(msg));
            console.log('Metro >>>>>> isMetro', isMetro);
            resolve(isMetro);
          });
      })
      .on('error', (err: any) => {
        if (err.code !== 'ECONNREFUSED' && err.code !== 'ECONNRESET') {
          console.error('Could not connect to METRO ' + err);
        }
        resolve(false);
      });
  });
}

Logs from about will get the below

So the reason why isMetro is being false is that the data part doesn’t contain any of the substrings.

// flipper-server-core/src/devices/metro/metroDeviceManager.tsx

const METRO_HOST = 'localhost';
const METRO_PORT = parseEnvironmentVariableAsNumber('METRO_SERVER_PORT', 8081);
const METRO_URL = `http://${METRO_HOST}:${METRO_PORT}`;

const METRO_MESSAGE = ['React Native packager is running', 'Metro is running'];

const isMetro = METRO_MESSAGE.some((msg) => data.includes(msg));

And since the current localhost:8081 will get the below

<!DOCTYPE html>
<html style="height:100%">
<head>
    <title>OLIO</title>
</head>
<body style="height:100%">
    <div id="root" style="display:flex;height:100%"></div>
    <script type="text/javascript" src="/bundle.web.js"></script>
</body>
</html>

For some historical reason, we had an index.html as above in the root directory. Since it doesn’t contain any substring in METRO_MESSAGE. In this case isMetro being false.

How to Fix it

To apply a quick fix to this would just add a comment to the index.html

<!DOCTYPE html>
<html style="height:100%">
<head>
    <title>OLIO</title>
</head>
<body style="height:100%">
    <!-- React Native packager is running. -->
    <div id="root" style="display:flex;height:100%"></div>
    <script type="text/javascript" src="/bundle.web.js"></script>
</body>
</html>

In the final step, add the comment (<!-- React Native packager is running. -->) ⇒ ✅

Conclusion

It’s quite a journey to explore the Flipper source code and found out that things are caused by something that you never had to imagine. It's common to see that Flipper plugin doesn't work and with the limited logs it provides, it could be tricky to figure out what the root cause was. As in this post, we explore how you could build Flipper from source and debug around. Hope this post built up some confidence while playing around with Flipper.