diff --git a/package.json b/package.json index 3f778be877..89eb7a25ba 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "build-l10n-prod:quiet": "yarn build:clean && yarn build-photon && cross-env NODE_ENV=production L10N=1 webpack", "build-l10n-prod": "yarn build-l10n-prod:quiet --progress", "build-photon": "webpack --config res/photon/webpack.config.js", + "build-batch-checker": "yarn build-batch-checker:quiet --progress", + "build-batch-checker:quiet": "yarn build:clean && cross-env NODE_ENV=production webpack --config src/batch-checker/webpack.config.js", "build-symbolicator-cli": "yarn build-symbolicator-cli:quiet --progress", "build-symbolicator-cli:quiet": "yarn build:clean && cross-env NODE_ENV=production webpack --config src/symbolicator-cli/webpack.config.js", "lint": "node bin/output-fixing-commands.js run-p lint-js lint-css prettier-run", diff --git a/src/batch-checker/index.js b/src/batch-checker/index.js new file mode 100644 index 0000000000..0406c3a0ce --- /dev/null +++ b/src/batch-checker/index.js @@ -0,0 +1,178 @@ +// @flow + +/** + * This implements a simple CLI to check existing public profiles for certain + * criteria. + * + * To use it it first needs to be built: + * yarn build-batch-checker + * + * Then it can be run from the `dist` directory: + * node dist/batch-checker.js --hashes-file + * + * For example: + * yarn build-batch-checker && node dist/batch-checker.js --hashes-file ~/Downloads/profile-hashes.txt + * + */ + +const fs = require('fs'); + +import { unserializeProfileOfArbitraryFormat } from '../profile-logic/process-profile'; +import { getProfileUrlForHash } from '../actions/receive-profile'; +import { getTimeRangeIncludingAllThreads } from '../profile-logic/profile-data'; +import { encodeUintSetForUrlComponent } from '../utils/uintarray-encoding'; + +interface CliOptions { + hashesFile: string; +} + +function checkProfileThreadCPUDelta(profile: any, hash: string): Set { + const outcomes = new Set(); + const rootRange = getTimeRangeIncludingAllThreads(profile); + const { threads } = profile; + for (let threadIndex = 0; threadIndex < threads.length; threadIndex++) { + const thread = threads[threadIndex]; + const threadCPUDelta = thread.samples.threadCPUDelta; + if (!threadCPUDelta) { + outcomes.add('has thread without threadCPUDelta'); + continue; + } + + outcomes.add('has thread with threadCPUDelta'); + const len = thread.samples.length; + if (len < 2) { + outcomes.add('has thread with fewer than two samples'); + continue; + } + if (threadCPUDelta[0] === null) { + outcomes.add('has null in first threadCPUDelta'); + } + const firstNonNullIndex = threadCPUDelta.findIndex((d) => d !== null); + if (firstNonNullIndex !== -1) { + for (let i = firstNonNullIndex + 1; i < len; i++) { + if (threadCPUDelta[i] === null) { + outcomes.add('has null after first null value in threadCPUDelta'); + const sampleTime = thread.samples.time[i]; + const relativeSampleTime = sampleTime - rootRange.start; + const url = `https://profiler.firefox.com/public/${hash}/?v=10&thread=${encodeUintSetForUrlComponent(new Set([threadIndex]))}`; + console.log( + `non-null at sample ${i} on thread ${threadIndex} at relative time ${(relativeSampleTime / 1000).toFixed(3)}s: ${url}` + ); + break; + } + } + } + } + return outcomes; +} + +function checkProfileSchemaMatching(profile: any, _hash: string): Set { + const { meta, threads } = profile; + const { markerSchema } = meta; + const markerSchemaNames = new Set(markerSchema.map((schema) => schema.name)); + const tracingCategories = new Set(); + const textNames = new Set(); + + const outcomes = new Set(); + for (let threadIndex = 0; threadIndex < threads.length; threadIndex++) { + const thread = threads[threadIndex]; + const { markers, stringTable } = thread; + for (let markerIndex = 0; markerIndex < markers.length; markerIndex++) { + const nameIndex = markers.name[markerIndex]; + const data = markers.data[markerIndex]; + if ( + data && + data.type && + data.type === 'tracing' && + data.category && + markerSchemaNames.has(data.category) + ) { + if (!tracingCategories.has(data.category)) { + console.log( + `Found tracing marker whose schema is for category ${data.category}, thread index ${threadIndex}, marker index ${markerIndex}` + ); + outcomes.add( + `has tracing marker whose schema is for category ${data.category}` + ); + tracingCategories.add(data.category); + } + continue; + } + const name = stringTable.getString(nameIndex); + if ( + data && + data.type && + data.type === 'Text' && + markerSchemaNames.has(name) + ) { + if (!textNames.has(name)) { + console.log( + `Found Text marker whose schema is for name ${name}, thread index ${threadIndex}, marker index ${markerIndex}` + ); + outcomes.add(`has Text marker whose schema is for name ${name}`); + textNames.add(name); + } + continue; + } + } + } + + return outcomes; +} + +function checkProfile(profile: any, hash: string): Set { + return checkProfileSchemaMatching(profile, hash); +} + +export async function run(options: CliOptions) { + const hashes = fs.readFileSync(options.hashesFile, 'utf8').split('\n'); + console.log(`Have ${hashes.length} hashes.`); + + for (let i = 0; i < hashes.length; i++) { + const hash = hashes[i]; + console.log( + `Checking profile ${i + 1} of ${hashes.length} with hash ${hash}` + ); + try { + const response = await fetch(getProfileUrlForHash(hash)); + const serializedProfile = await response.json(); + const profile = + await unserializeProfileOfArbitraryFormat(serializedProfile); + if (profile === undefined) { + throw new Error('Unable to parse the profile.'); + } + const outcome = checkProfile(profile, hash); + console.log(`Outcome: ${[...outcome].join(', ')}`); + } catch (e) { + console.log(`Failed: ${e}`); + } + } + + console.log('Finished.'); +} + +export function makeOptionsFromArgv(processArgv: string[]): CliOptions { + const argv = require('minimist')(processArgv.slice(2)); + + if (!('hashes-file' in argv && typeof argv['hashes-file'] === 'string')) { + throw new Error( + 'Argument --hashes-file must be supplied with the path to a text file of profile hashes' + ); + } + + return { + hashesFile: argv['hashes-file'], + }; +} + +if (!module.parent) { + try { + const options = makeOptionsFromArgv(process.argv); + run(options).catch((err) => { + throw err; + }); + } catch (e) { + console.error(e); + process.exit(1); + } +} diff --git a/src/batch-checker/webpack.config.js b/src/batch-checker/webpack.config.js new file mode 100644 index 0000000000..d0ff0e0449 --- /dev/null +++ b/src/batch-checker/webpack.config.js @@ -0,0 +1,32 @@ +// @noflow +const path = require('path'); +const projectRoot = path.join(__dirname, '../..'); +const includes = [path.join(projectRoot, 'src')]; + +module.exports = { + name: 'batch-checker', + target: 'node', + mode: process.env.NODE_ENV, + output: { + path: path.resolve(projectRoot, 'dist'), + filename: 'batch-checker.js', + }, + entry: './src/batch-checker/index.js', + module: { + rules: [ + { + test: /\.js$/, + use: ['babel-loader'], + include: includes, + }, + { + test: /\.svg$/, + type: 'asset/resource', + }, + ], + }, + experiments: { + // Make WebAssembly work just like in webpack v4 + syncWebAssembly: true, + }, +};