desktop/plugins/public/kaios-allocations/index.tsx (374 lines of code) (raw):

/** * 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 {FlipperDevicePlugin, Device} from 'flipper'; import { Button, Toolbar, ManagedTable, Panel, Label, Input, Select, } from 'flipper'; import {sleep} from 'flipper'; import util from 'util'; import FirefoxClient from 'firefox-client'; import BaseClientMethods from 'firefox-client/lib/client-methods'; import extend from 'firefox-client/lib/extend'; import {getFlipperLib} from 'flipper-plugin'; // 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 getFlipperLib().remoteServerContext.childProcess.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> ); } }