Files
flipper/desktop/plugins/public/kaios-allocations/index.tsx
Michel Weststrate 2d838efd4d Separate device in server and client version [2/n]
Summary:
This stack takes care of handling care of moving all device interactions over the (possible) async channel FlipperServer. The FlipperServer interface (see previous diff) allows listening to specific server events using `on`, and emit commands to be executed by the server by using `exec` (e.g. `exec('take-screenshot', serial) => Promise<buffer>`).

FlipperServerImpl implements this interface on the server side.

The device implementations are split as follows

```
server / backend process:

ServerDevice
- iOSDevice
- AndroidDevice
- MetroDevice
- DummyDevice
- Mac/Windows Device

frontend / ui:

BaseDevice: a normal connected, device, implements device apis as they already existed
- ArchivedDevice (note that this doesn't have a server counterpart)
- TestDevice (for unit tests, with stubbed backend communication)

```

All features of devices are for simplicity unified (since the deviations are small), where specific device types might not implement certain features like taking screenshots or running shell commands.

To avoid making this diff unnecessarily big, some open Todo's will be addressed later in this stack, and it shouldn't be landed alone.

Reviewed By: timur-valiev

Differential Revision: D30909346

fbshipit-source-id: cce0bee94fdd5db59bebe3577a6084219a038719
2021-09-22 09:03:32 -07:00

444 lines
12 KiB
TypeScript

/**
* Copyright (c) Facebook, Inc. and its 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 {FlipperDevicePlugin, Device} from 'flipper';
import {
Button,
Toolbar,
ManagedTable,
Panel,
Label,
Input,
Select,
} from 'flipper';
import {sleep} from 'flipper';
import util from 'util';
import {exec} from 'promisify-child-process';
import FirefoxClient from 'firefox-client';
import BaseClientMethods from 'firefox-client/lib/client-methods';
import extend from 'firefox-client/lib/extend';
// This uses legacy `extend` from `firefox-client`, since this seems to be what the implementation expects
// It's probably possible to rewrite this in a modern way and properly type it, but for now leaving this as it is
const ClientMethods: any = extend(BaseClientMethods, {
initialize: function (client: any, actor: any) {
this.client = client;
this.actor = actor;
this.cb = function (this: typeof ClientMethods, message: any) {
if (message.from === this.actor) {
this.emit(message.type, message);
}
}.bind(this);
this.client.on('message', this.cb);
},
disconnect: function () {
this.client.removeListener('message', this.cb);
},
});
function Memory(this: typeof ClientMethods, client: any, actor: any): any {
this.initialize(client, actor);
}
// Repetitive, it is probably better to refactor this
// to use API like `runCommand(commandName, params): Promise`
Memory.prototype = extend(ClientMethods, {
attach: function (cb: any) {
this.request('attach', function (err: any, resp: any) {
cb(err, resp);
});
},
getState: function (cb: any) {
this.request('getState', function (err: any, resp: any) {
cb(err, resp);
});
},
takeCensus: function (cb: any) {
this.request('takeCensus', function (err: any, resp: any) {
cb(err, resp);
});
},
getAllocations: function (cb: any) {
this.request('getAllocations', function (err: any, resp: any) {
cb(err, resp);
});
},
startRecordingAllocations: function (options: any, cb: any) {
this.request(
'startRecordingAllocations',
{options},
function (err: any, resp: any) {
cb(err, resp);
},
);
},
stopRecordingAllocations: function (cb: any) {
this.request('stopRecordingAllocations', function (err: any, resp: any) {
cb(err, resp);
});
},
measure: function (cb: any) {
this.request('measure', function (err: any, resp: any) {
cb(err, resp);
});
},
getAllocationsSettings: function (cb: any) {
this.request('getAllocationsSettings', function (err: any, resp: any) {
cb(err, resp);
});
},
});
const ffPromisify = (o: {[key: string]: any}, m: string) =>
util.promisify(o[m].bind(o));
const ColumnSizes = {
timestamp: 'flex',
freeMem: 'flex',
};
const Columns = {
timestamp: {
value: 'time',
resizable: true,
},
allocationSize: {
value: 'Allocation bytes',
resizable: true,
},
functionName: {
value: 'Function',
resizable: true,
},
};
type Allocation = {
timestamp: number;
allocationSize: number;
functionName: string;
};
type State = {
apps: {[key: string]: string};
runningAppName: null | string;
allocationData: Array<Allocation>;
totalAllocations: number;
totalAllocatedBytes: number;
monitoring: boolean;
allocationsBySize: {[key: string]: number};
minAllocationSizeInTable: number;
};
const LOCALSTORAGE_APP_NAME_KEY = '__KAIOS_ALLOCATIONS_PLUGIN_CACHED_APP_NAME';
const LOCALSTORAGE_MIN_ALLOCATION_SIZE_KEY =
'__KAIOS_ALLOCATIONS_PLUGIN_MIN_ALLOCATION_SIZE';
const DEFAULT_MIN_ALLOCATION_SIZE = 128;
function getMinAllocationSizeFromLocalStorage(): number {
const ls = localStorage.getItem(LOCALSTORAGE_MIN_ALLOCATION_SIZE_KEY);
if (!ls) {
return DEFAULT_MIN_ALLOCATION_SIZE;
}
const parsed = parseInt(ls, 10);
return !isNaN(parsed) ? parsed : DEFAULT_MIN_ALLOCATION_SIZE;
}
export default class AllocationsPlugin extends FlipperDevicePlugin<
State,
any,
any
> {
currentApp: any = null;
memory: any = null;
client: any = null;
webApps: any = null;
state: State = {
apps: {},
runningAppName: null,
monitoring: false,
allocationData: [],
totalAllocations: 0,
totalAllocatedBytes: 0,
allocationsBySize: {},
minAllocationSizeInTable: getMinAllocationSizeFromLocalStorage(),
};
static supportsDevice(device: Device) {
return device.description.specs?.includes('KaiOS') ?? false;
}
onStartMonitor = async () => {
if (this.state.monitoring) {
return;
}
// TODO: try to reconnect in case of failure
await ffPromisify(
this.memory,
'startRecordingAllocations',
)({
probability: 1.0,
maxLogLength: 20000,
drainAllocationsTimeout: 1500,
trackingAllocationSites: true,
});
this.setState({monitoring: true});
};
onStopMonitor = async () => {
if (!this.state.monitoring) {
return;
}
this.tearDownApp();
this.setState({monitoring: false});
};
// reloads the list of apps every two seconds
reloadAppListWhenNotMonitoring = async () => {
while (true) {
if (!this.state.monitoring) {
try {
await this.processListOfApps();
} catch (e) {
console.error('Exception, attempting to reconnect', e);
await this.connectToDebugApi();
// processing the list of the apps is going to be automatically retried now
}
}
await sleep(2000);
}
};
async connectToDebugApi(): Promise<void> {
this.client = new FirefoxClient({log: false});
await ffPromisify(this.client, 'connect')(6000, 'localhost');
this.webApps = await ffPromisify(this.client, 'getWebapps')();
}
async processListOfApps(): Promise<void> {
const runningAppUrls = await ffPromisify(this.webApps, 'listRunningApps')();
const lastUsedAppName = localStorage.getItem(LOCALSTORAGE_APP_NAME_KEY);
let runningAppName = null;
const appTitleToUrl: {[key: string]: string} = {};
for (const runningAppUrl of runningAppUrls) {
const app = await ffPromisify(this.webApps, 'getApp')(runningAppUrl);
appTitleToUrl[app.title] = runningAppUrl;
if (app.title === lastUsedAppName) {
runningAppName = app.title;
}
}
if (runningAppName && this.state.runningAppName !== runningAppName) {
this.setUpApp(appTitleToUrl[runningAppName]);
}
this.setState({
apps: appTitleToUrl,
runningAppName,
});
}
async init() {
await exec(
'adb forward tcp:6000 localfilesystem:/data/local/debugger-socket',
);
await this.connectToDebugApi();
await this.processListOfApps();
// no await because reloading runs in the background
this.reloadAppListWhenNotMonitoring();
}
async teardown() {
if (this.state.monitoring) {
await this.onStopMonitor();
}
}
async setUpApp(appUrl: string) {
this.currentApp = await ffPromisify(this.webApps, 'getApp')(appUrl);
if (!this.currentApp) {
// TODO: notify user?
throw new Error('Cannot connect to app');
}
const {
tab: {memoryActor},
} = this.currentApp;
this.memory = new (Memory as any)(this.currentApp.client, memoryActor);
await ffPromisify(this.memory, 'attach')();
this.currentApp.client.on('message', this.processAllocationsMsg);
}
async tearDownApp() {
if (!this.currentApp) {
return;
}
this.currentApp.client.off('message', this.processAllocationsMsg);
await ffPromisify(this.memory, 'stopRecordingAllocations')();
this.currentApp = null;
this.memory = null;
}
processAllocationsMsg = (msg: any) => {
if (msg.type !== 'allocations') {
return;
}
this.updateAllocations(msg.data);
};
updateAllocations = (data: any) => {
const {allocations, allocationsTimestamps, allocationSizes, frames} = data;
const newAllocationData = [...this.state.allocationData];
let newTotalAllocations = this.state.totalAllocations;
let newTotalAllocatedBytes = this.state.totalAllocatedBytes;
const newAllocationsBySize = {...this.state.allocationsBySize};
for (let i = 0; i < allocations.length; ++i) {
const frameId = allocations[i];
const timestamp = allocationsTimestamps[i];
const allocationSize = allocationSizes[i];
const functionName = frames[frameId]
? frames[frameId].functionDisplayName
: null;
if (allocationSize >= this.state.minAllocationSizeInTable) {
newAllocationData.push({timestamp, allocationSize, functionName});
}
newAllocationsBySize[allocationSize] =
(newAllocationsBySize[allocationSize] || 0) + 1;
newTotalAllocations++;
newTotalAllocatedBytes += allocationSize;
}
this.setState({
allocationData: newAllocationData,
totalAllocations: newTotalAllocations,
totalAllocatedBytes: newTotalAllocatedBytes,
allocationsBySize: newAllocationsBySize,
});
};
buildMemRows = () => {
return this.state.allocationData.map((info) => {
return {
columns: {
timestamp: {
value: info.timestamp,
filterValue: info.timestamp,
},
allocationSize: {
value: info.allocationSize,
filterValue: info.allocationSize,
},
functionName: {
value: info.functionName,
filterValue: info.functionName,
},
},
key: `${info.timestamp} ${info.allocationSize} ${info.functionName}`,
copyText: `${info.timestamp} ${info.allocationSize} ${info.functionName}`,
filterValue: `${info.timestamp} ${info.allocationSize} ${info.functionName}`,
};
});
};
onAppChange = (newAppTitle: string) => {
localStorage[LOCALSTORAGE_APP_NAME_KEY] = newAppTitle;
this.setState({runningAppName: newAppTitle});
this.tearDownApp();
this.setUpApp(this.state.apps[newAppTitle]);
};
onMinAllocationSizeChange = (event: any) => {
const newMinAllocationSize = event.target.value;
this.setState({
minAllocationSizeInTable: newMinAllocationSize,
});
};
render() {
const appTitlesForSelect: {[key: string]: string} = {};
for (const [appTitle] of Object.entries(this.state.apps)) {
appTitlesForSelect[appTitle] = appTitle;
}
return (
<React.Fragment>
<Panel
padded={false}
heading="Page allocations"
floating={false}
collapsable={false}
grow>
<Toolbar position="top">
<Select
options={appTitlesForSelect}
onChangeWithKey={this.onAppChange}
selected={this.state.runningAppName}
disabled={this.state.monitoring}
/>
{this.state.monitoring ? (
<Button onClick={this.onStopMonitor} icon="pause">
Pause
</Button>
) : (
<Button onClick={this.onStartMonitor} icon="play">
Start
</Button>
)}
<Label>
Min allocation size in bytes{' '}
<Input
placeholder="min bytes"
value={this.state.minAllocationSizeInTable}
type="number"
onChange={this.onMinAllocationSizeChange}
disabled={this.state.monitoring}
/>
</Label>
</Toolbar>
<Label>
Total number of allocations: {this.state.totalAllocations}
</Label>
<br />
<Label>
Total MBs allocated:{' '}
{(this.state.totalAllocatedBytes / 1024 / 1024).toFixed(3)}
</Label>
<ManagedTable
multiline
columnSizes={ColumnSizes}
columns={Columns}
floating={false}
zebra
rows={this.buildMemRows()}
/>
</Panel>
</React.Fragment>
);
}
}