diff options
Diffstat (limited to 'devtools/client/performance-new/shared')
9 files changed, 2878 insertions, 0 deletions
diff --git a/devtools/client/performance-new/shared/README.md b/devtools/client/performance-new/shared/README.md new file mode 100644 index 0000000000..c83272c882 --- /dev/null +++ b/devtools/client/performance-new/shared/README.md @@ -0,0 +1,5 @@ +This directory contains files that are common to all UIs (popup, devtools panel, +about:profiling) interacting with the profiler. +Other UIs external to the profiler (one example is about:logging) can also use +these files, especially background.jsm.js, to interact with the profiler with +more capabilities than Services.profiler. diff --git a/devtools/client/performance-new/shared/background.jsm.js b/devtools/client/performance-new/shared/background.jsm.js new file mode 100644 index 0000000000..968a827557 --- /dev/null +++ b/devtools/client/performance-new/shared/background.jsm.js @@ -0,0 +1,922 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check +"use strict"; + +/** + * This file contains all of the background logic for controlling the state and + * configuration of the profiler. It is in a JSM so that the logic can be shared + * with both the popup client, and the keyboard shortcuts. The shortcuts don't need + * access to any UI, and need to be loaded independent of the popup. + */ + +// The following are not lazily loaded as they are needed during initialization. + +const { createLazyLoaders } = ChromeUtils.import( + "resource://devtools/client/performance-new/shared/typescript-lazy-load.jsm.js" +); +// For some reason TypeScript was giving me an error when de-structuring AppConstants. I +// suspect a bug in TypeScript was at play. +const AppConstants = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +).AppConstants; + +/** + * @typedef {import("../@types/perf").RecordingSettings} RecordingSettings + * @typedef {import("../@types/perf").SymbolTableAsTuple} SymbolTableAsTuple + * @typedef {import("../@types/perf").Library} Library + * @typedef {import("../@types/perf").PerformancePref} PerformancePref + * @typedef {import("../@types/perf").ProfilerWebChannel} ProfilerWebChannel + * @typedef {import("../@types/perf").PageContext} PageContext + * @typedef {import("../@types/perf").PrefObserver} PrefObserver + * @typedef {import("../@types/perf").PrefPostfix} PrefPostfix + * @typedef {import("../@types/perf").Presets} Presets + * @typedef {import("../@types/perf").ProfilerViewMode} ProfilerViewMode + * @typedef {import("../@types/perf").MessageFromFrontend} MessageFromFrontend + * @typedef {import("../@types/perf").RequestFromFrontend} RequestFromFrontend + * @typedef {import("../@types/perf").ResponseToFrontend} ResponseToFrontend + * @typedef {import("../@types/perf").SymbolicationService} SymbolicationService + * @typedef {import("../@types/perf").ProfilerBrowserInfo} ProfilerBrowserInfo + * @typedef {import("../@types/perf").ProfileCaptureResult} ProfileCaptureResult + */ + +/** @type {PerformancePref["Entries"]} */ +const ENTRIES_PREF = "devtools.performance.recording.entries"; +/** @type {PerformancePref["Interval"]} */ +const INTERVAL_PREF = "devtools.performance.recording.interval"; +/** @type {PerformancePref["Features"]} */ +const FEATURES_PREF = "devtools.performance.recording.features"; +/** @type {PerformancePref["Threads"]} */ +const THREADS_PREF = "devtools.performance.recording.threads"; +/** @type {PerformancePref["ObjDirs"]} */ +const OBJDIRS_PREF = "devtools.performance.recording.objdirs"; +/** @type {PerformancePref["Duration"]} */ +const DURATION_PREF = "devtools.performance.recording.duration"; +/** @type {PerformancePref["Preset"]} */ +const PRESET_PREF = "devtools.performance.recording.preset"; +/** @type {PerformancePref["PopupFeatureFlag"]} */ +const POPUP_FEATURE_FLAG_PREF = "devtools.performance.popup.feature-flag"; +/* This will be used to observe all profiler-related prefs. */ +const PREF_PREFIX = "devtools.performance.recording."; + +// The version of the profiler WebChannel. +// This is reported from the STATUS_QUERY message, and identifies the +// capabilities of the WebChannel. The front-end can handle old WebChannel +// versions and has a full list of versions and capabilities here: +// https://github.com/firefox-devtools/profiler/blob/main/src/app-logic/web-channel.js +const CURRENT_WEBCHANNEL_VERSION = 1; + +const lazyRequire = {}; +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(lazyRequire, { + require: "resource://devtools/shared/loader/Loader.sys.mjs", +}); +// Lazily load the require function, when it's needed. +// Avoid using ChromeUtils.defineESModuleGetters for now as: +// * we can't replace createLazyLoaders as we still load commonjs+jsm+esm +// It will be easier once we only load sys.mjs files. +// * we would need to find a way to accomodate typescript to this special function. +// @ts-ignore:next-line +function require(path) { + // @ts-ignore:next-line + return lazyRequire.require(path); +} + +// The following utilities are lazily loaded as they are not needed when controlling the +// global state of the profiler, and only are used during specific funcationality like +// symbolication or capturing a profile. +const lazy = createLazyLoaders({ + Utils: () => + require("resource://devtools/client/performance-new/shared/utils.js"), + BrowserModule: () => + require("resource://devtools/client/performance-new/shared/browser.js"), + RecordingUtils: () => + require("resource://devtools/shared/performance-new/recording-utils.js"), + CustomizableUI: () => + ChromeUtils.importESModule("resource:///modules/CustomizableUI.sys.mjs"), + PerfSymbolication: () => + ChromeUtils.import( + "resource://devtools/client/performance-new/shared/symbolication.jsm.js" + ), + ProfilerMenuButton: () => + ChromeUtils.import( + "resource://devtools/client/performance-new/popup/menu-button.jsm.js" + ), +}); + +// The presets that we find in all interfaces are defined here. + +// The property l10nIds contain all FTL l10n IDs for these cases: +// - properties in "popup" are used in the popup's select box. +// - properties in "devtools" are used in other UIs (about:profiling and devtools panels). +// +// Properties for both cases have the same values, but because they're not used +// in the same way we need to duplicate them. +// Their values for the en-US locale are in the files: +// devtools/client/locales/en-US/perftools.ftl +// browser/locales/en-US/browser/appmenu.ftl + +/** @type {Presets} */ +const presets = { + "web-developer": { + entries: 128 * 1024 * 1024, + interval: 1, + features: ["screenshots", "js", "cpu"], + threads: ["GeckoMain", "Compositor", "Renderer", "DOM Worker"], + duration: 0, + profilerViewMode: "active-tab", + l10nIds: { + popup: { + label: "profiler-popup-presets-web-developer-label", + description: "profiler-popup-presets-web-developer-description", + }, + devtools: { + label: "perftools-presets-web-developer-label", + description: "perftools-presets-web-developer-description", + }, + }, + }, + "firefox-platform": { + entries: 128 * 1024 * 1024, + interval: 1, + features: ["screenshots", "js", "stackwalk", "cpu", "java", "processcpu"], + threads: [ + "GeckoMain", + "Compositor", + "Renderer", + "SwComposite", + "DOM Worker", + ], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-firefox-label", + description: "profiler-popup-presets-firefox-description", + }, + devtools: { + label: "perftools-presets-firefox-label", + description: "perftools-presets-firefox-description", + }, + }, + }, + graphics: { + entries: 128 * 1024 * 1024, + interval: 1, + features: ["stackwalk", "js", "cpu", "java", "processcpu"], + threads: [ + "GeckoMain", + "Compositor", + "Renderer", + "SwComposite", + "RenderBackend", + "SceneBuilder", + "WrWorker", + "CanvasWorkers", + ], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-graphics-label", + description: "profiler-popup-presets-graphics-description", + }, + devtools: { + label: "perftools-presets-graphics-label", + description: "perftools-presets-graphics-description", + }, + }, + }, + media: { + entries: 128 * 1024 * 1024, + interval: 1, + features: [ + "js", + "stackwalk", + "cpu", + "audiocallbacktracing", + "ipcmessages", + "processcpu", + ], + threads: [ + "cubeb", + "audio", + "BackgroundThreadPool", + "camera", + "capture", + "Compositor", + "decoder", + "GeckoMain", + "gmp", + "graph", + "grph", + "InotifyEventThread", + "IPDL Background", + "media", + "ModuleProcessThread", + "PacerThread", + "RemVidChild", + "RenderBackend", + "Renderer", + "Socket Thread", + "SwComposite", + "webrtc", + ], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-media-label", + description: "profiler-popup-presets-media-description2", + }, + devtools: { + label: "perftools-presets-media-label", + description: "perftools-presets-media-description2", + }, + }, + }, + networking: { + entries: 128 * 1024 * 1024, + interval: 1, + features: ["screenshots", "js", "stackwalk", "cpu", "java", "processcpu"], + threads: [ + "Compositor", + "DNS Resolver", + "DOM Worker", + "GeckoMain", + "Renderer", + "Socket Thread", + "StreamTrans", + "SwComposite", + "TRR Background", + ], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-networking-label", + description: "profiler-popup-presets-networking-description", + }, + devtools: { + label: "perftools-presets-networking-label", + description: "perftools-presets-networking-description", + }, + }, + }, + power: { + entries: 128 * 1024 * 1024, + interval: 10, + features: [ + "screenshots", + "js", + "stackwalk", + "cpu", + "processcpu", + "nostacksampling", + "ipcmessages", + "markersallthreads", + "power", + ], + threads: ["GeckoMain", "Renderer"], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-power-label", + description: "profiler-popup-presets-power-description", + }, + devtools: { + label: "perftools-presets-power-label", + description: "perftools-presets-power-description", + }, + }, + }, +}; + +/** + * Return the proper view mode for the Firefox Profiler front-end timeline by + * looking at the proper preset that is selected. + * Return value can be undefined when the preset is unknown or custom. + * @param {PageContext} pageContext + * @return {ProfilerViewMode | undefined} + */ +function getProfilerViewModeForCurrentPreset(pageContext) { + const prefPostfix = getPrefPostfix(pageContext); + const presetName = Services.prefs.getCharPref(PRESET_PREF + prefPostfix); + + if (presetName === "custom") { + return undefined; + } + + const preset = presets[presetName]; + if (!preset) { + console.error(`Unknown profiler preset was encountered: "${presetName}"`); + return undefined; + } + return preset.profilerViewMode; +} + +/** + * This function is called when the profile is captured with the shortcut + * keys, with the profiler toolbarbutton, or with the button inside the + * popup. + * @param {PageContext} pageContext + * @return {Promise<void>} + */ +async function captureProfile(pageContext) { + if (!Services.profiler.IsActive()) { + // The profiler is not active, ignore. + return; + } + if (Services.profiler.IsPaused()) { + // The profiler is already paused for capture, ignore. + return; + } + + // Pause profiler before we collect the profile, so that we don't capture + // more samples while the parent process waits for subprocess profiles. + Services.profiler.Pause(); + + /** + * @type {MockedExports.ProfileGenerationAdditionalInformation | undefined} + */ + let additionalInfo; + /** + * @type {ProfileCaptureResult} + */ + const profileCaptureResult = await Services.profiler + .getProfileDataAsGzippedArrayBuffer() + .then( + ({ profile, additionalInformation }) => { + additionalInfo = additionalInformation; + return { type: "SUCCESS", profile }; + }, + error => { + console.error(error); + return { type: "ERROR", error }; + } + ); + + const profilerViewMode = getProfilerViewModeForCurrentPreset(pageContext); + const sharedLibraries = additionalInfo?.sharedLibraries + ? additionalInfo.sharedLibraries + : Services.profiler.sharedLibraries; + const objdirs = getObjdirPrefValue(); + + const { createLocalSymbolicationService } = lazy.PerfSymbolication(); + const symbolicationService = createLocalSymbolicationService( + sharedLibraries, + objdirs + ); + + const { openProfilerTab } = lazy.BrowserModule(); + const browser = await openProfilerTab(profilerViewMode); + registerProfileCaptureForBrowser( + browser, + profileCaptureResult, + symbolicationService + ); + + Services.profiler.StopProfiler(); +} + +/** + * This function is called when the profiler is started with the shortcut + * keys, with the profiler toolbarbutton, or with the button inside the + * popup. + * @param {PageContext} pageContext + */ +function startProfiler(pageContext) { + const { entries, interval, features, threads, duration } = + getRecordingSettings(pageContext, Services.profiler.GetFeatures()); + + // Get the active Browser ID from browser. + const { getActiveBrowserID } = lazy.RecordingUtils(); + const activeTabID = getActiveBrowserID(); + + Services.profiler.StartProfiler( + entries, + interval, + features, + threads, + activeTabID, + duration + ); +} + +/** + * This function is called directly by devtools/startup/DevToolsStartup.jsm when + * using the shortcut keys to capture a profile. + * @type {() => void} + */ +function stopProfiler() { + Services.profiler.StopProfiler(); +} + +/** + * This function is called directly by devtools/startup/DevToolsStartup.jsm when + * using the shortcut keys to start and stop the profiler. + * @param {PageContext} pageContext + * @return {void} + */ +function toggleProfiler(pageContext) { + if (Services.profiler.IsPaused()) { + // The profiler is currently paused, which means that the user is already + // attempting to capture a profile. Ignore this request. + return; + } + if (Services.profiler.IsActive()) { + stopProfiler(); + } else { + startProfiler(pageContext); + } +} + +/** + * @param {PageContext} pageContext + */ +function restartProfiler(pageContext) { + stopProfiler(); + startProfiler(pageContext); +} + +/** + * @param {string} prefName + * @return {string[]} + */ +function _getArrayOfStringsPref(prefName) { + const text = Services.prefs.getCharPref(prefName); + return JSON.parse(text); +} + +/** + * The profiler recording workflow uses two different pref paths. One set of prefs + * is stored for local profiling, and another for remote profiling. This function + * decides which to use. The remote prefs have ".remote" appended to the end of + * their pref names. + * + * @param {PageContext} pageContext + * @returns {PrefPostfix} + */ +function getPrefPostfix(pageContext) { + switch (pageContext) { + case "devtools": + case "aboutprofiling": + case "aboutlogging": + // Don't use any postfix on the prefs. + return ""; + case "devtools-remote": + case "aboutprofiling-remote": + return ".remote"; + default: { + const { UnhandledCaseError } = lazy.Utils(); + throw new UnhandledCaseError(pageContext, "Page Context"); + } + } +} + +/** + * @param {string[]} objdirs + */ +function setObjdirPrefValue(objdirs) { + Services.prefs.setCharPref(OBJDIRS_PREF, JSON.stringify(objdirs)); +} + +/** + * Before Firefox 92, the objdir lists for local and remote profiling were + * stored in separate lists. In Firefox 92 those two prefs were merged into + * one. This function performs the migration. + */ +function migrateObjdirsPrefsIfNeeded() { + const OLD_REMOTE_OBJDIRS_PREF = OBJDIRS_PREF + ".remote"; + const remoteString = Services.prefs.getCharPref(OLD_REMOTE_OBJDIRS_PREF, ""); + if (remoteString === "") { + // No migration necessary. + return; + } + + const remoteList = JSON.parse(remoteString); + const localList = _getArrayOfStringsPref(OBJDIRS_PREF); + + // Merge the two lists, eliminating any duplicates. + const mergedList = [...new Set(localList.concat(remoteList))]; + setObjdirPrefValue(mergedList); + Services.prefs.clearUserPref(OLD_REMOTE_OBJDIRS_PREF); +} + +/** + * @returns {string[]} + */ +function getObjdirPrefValue() { + migrateObjdirsPrefsIfNeeded(); + return _getArrayOfStringsPref(OBJDIRS_PREF); +} + +/** + * @param {PageContext} pageContext + * @param {string[]} supportedFeatures + * @returns {RecordingSettings} + */ +function getRecordingSettings(pageContext, supportedFeatures) { + const objdirs = getObjdirPrefValue(); + const prefPostfix = getPrefPostfix(pageContext); + const presetName = Services.prefs.getCharPref(PRESET_PREF + prefPostfix); + + // First try to get the values from a preset. If the preset is "custom" or + // unrecognized, getRecordingSettingsFromPreset will return null and we will + // get the settings from individual prefs instead. + return ( + getRecordingSettingsFromPreset(presetName, supportedFeatures, objdirs) ?? + getRecordingSettingsFromPrefs(supportedFeatures, objdirs, prefPostfix) + ); +} + +/** + * @param {string} presetName + * @param {string[]} supportedFeatures + * @param {string[]} objdirs + * @return {RecordingSettings | null} + */ +function getRecordingSettingsFromPreset( + presetName, + supportedFeatures, + objdirs +) { + if (presetName === "custom") { + return null; + } + + const preset = presets[presetName]; + if (!preset) { + console.error(`Unknown profiler preset was encountered: "${presetName}"`); + return null; + } + + return { + presetName, + entries: preset.entries, + interval: preset.interval, + // Validate the features before passing them to the profiler. + features: preset.features.filter(feature => + supportedFeatures.includes(feature) + ), + threads: preset.threads, + objdirs, + duration: preset.duration, + }; +} + +/** + * @param {string[]} supportedFeatures + * @param {string[]} objdirs + * @param {PrefPostfix} prefPostfix + * @return {RecordingSettings} + */ +function getRecordingSettingsFromPrefs( + supportedFeatures, + objdirs, + prefPostfix +) { + // If you add a new preference here, please do not forget to update + // `revertRecordingSettings` as well. + + const entries = Services.prefs.getIntPref(ENTRIES_PREF + prefPostfix); + const intervalInMicroseconds = Services.prefs.getIntPref( + INTERVAL_PREF + prefPostfix + ); + const interval = intervalInMicroseconds / 1000; + const features = _getArrayOfStringsPref(FEATURES_PREF + prefPostfix); + const threads = _getArrayOfStringsPref(THREADS_PREF + prefPostfix); + const duration = Services.prefs.getIntPref(DURATION_PREF + prefPostfix); + + return { + presetName: "custom", + entries, + interval, + // Validate the features before passing them to the profiler. + features: features.filter(feature => supportedFeatures.includes(feature)), + threads, + objdirs, + duration, + }; +} + +/** + * @param {PageContext} pageContext + * @param {RecordingSettings} prefs + */ +function setRecordingSettings(pageContext, prefs) { + const prefPostfix = getPrefPostfix(pageContext); + Services.prefs.setCharPref(PRESET_PREF + prefPostfix, prefs.presetName); + Services.prefs.setIntPref(ENTRIES_PREF + prefPostfix, prefs.entries); + // The interval pref stores the value in microseconds for extra precision. + const intervalInMicroseconds = prefs.interval * 1000; + Services.prefs.setIntPref( + INTERVAL_PREF + prefPostfix, + intervalInMicroseconds + ); + Services.prefs.setCharPref( + FEATURES_PREF + prefPostfix, + JSON.stringify(prefs.features) + ); + Services.prefs.setCharPref( + THREADS_PREF + prefPostfix, + JSON.stringify(prefs.threads) + ); + setObjdirPrefValue(prefs.objdirs); +} + +const platform = AppConstants.platform; + +/** + * Revert the recording prefs for both local and remote profiling. + * @return {void} + */ +function revertRecordingSettings() { + for (const prefPostfix of ["", ".remote"]) { + Services.prefs.clearUserPref(PRESET_PREF + prefPostfix); + Services.prefs.clearUserPref(ENTRIES_PREF + prefPostfix); + Services.prefs.clearUserPref(INTERVAL_PREF + prefPostfix); + Services.prefs.clearUserPref(FEATURES_PREF + prefPostfix); + Services.prefs.clearUserPref(THREADS_PREF + prefPostfix); + Services.prefs.clearUserPref(DURATION_PREF + prefPostfix); + } + Services.prefs.clearUserPref(OBJDIRS_PREF); + Services.prefs.clearUserPref(POPUP_FEATURE_FLAG_PREF); +} + +/** + * Change the prefs based on a preset. This mechanism is used by the popup to + * easily switch between different settings. + * @param {string} presetName + * @param {PageContext} pageContext + * @param {string[]} supportedFeatures + * @return {void} + */ +function changePreset(pageContext, presetName, supportedFeatures) { + const prefPostfix = getPrefPostfix(pageContext); + const objdirs = getObjdirPrefValue(); + let recordingSettings = getRecordingSettingsFromPreset( + presetName, + supportedFeatures, + objdirs + ); + + if (!recordingSettings) { + // No recordingSettings were found for that preset. Most likely this means this + // is a custom preset, or it's one that we dont recognize for some reason. + // Get the preferences from the individual preference values. + Services.prefs.setCharPref(PRESET_PREF + prefPostfix, presetName); + recordingSettings = getRecordingSettings(pageContext, supportedFeatures); + } + + setRecordingSettings(pageContext, recordingSettings); +} + +/** + * Add an observer for the profiler-related preferences. + * @param {PrefObserver} observer + * @return {void} + */ +function addPrefObserver(observer) { + Services.prefs.addObserver(PREF_PREFIX, observer); +} + +/** + * Removes an observer for the profiler-related preferences. + * @param {PrefObserver} observer + * @return {void} + */ +function removePrefObserver(observer) { + Services.prefs.removeObserver(PREF_PREFIX, observer); +} + +/** + * This map stores information that is associated with a "profile capturing" + * action, so that we can look up this information for WebChannel messages + * from the profiler tab. + * Most importantly, this stores the captured profile. When the profiler tab + * requests the profile, we can respond to the message with the correct profile. + * This works even if the request happens long after the tab opened. It also + * works for an "old" tab even if new profiles have been captured since that + * tab was opened. + * Supporting tab refresh is important because the tab sometimes reloads itself: + * If an old version of the front-end is cached in the service worker, and the + * browser supplies a profile with a newer format version, then the front-end + * updates its service worker and reloads itself, so that the updated version + * can parse the profile. + * + * This is a WeakMap so that the profile can be garbage-collected when the tab + * is closed. + * + * @type {WeakMap<MockedExports.Browser, ProfilerBrowserInfo>} + */ +const infoForBrowserMap = new WeakMap(); + +/** + * This handler computes the response for any messages coming + * from the WebChannel from profiler.firefox.com. + * + * @param {RequestFromFrontend} request + * @param {MockedExports.Browser} browser - The tab's browser. + * @return {Promise<ResponseToFrontend>} + */ +async function getResponseForMessage(request, browser) { + switch (request.type) { + case "STATUS_QUERY": { + // The content page wants to know if this channel exists. It does, so respond + // back to the ping. + const { ProfilerMenuButton } = lazy.ProfilerMenuButton(); + return { + version: CURRENT_WEBCHANNEL_VERSION, + menuButtonIsEnabled: ProfilerMenuButton.isInNavbar(), + }; + } + case "ENABLE_MENU_BUTTON": { + const { ownerDocument } = browser; + if (!ownerDocument) { + throw new Error( + "Could not find the owner document for the current browser while enabling " + + "the profiler menu button" + ); + } + // Ensure the widget is enabled. + Services.prefs.setBoolPref(POPUP_FEATURE_FLAG_PREF, true); + + // Force the preset to be "firefox-platform" if we enable the menu button + // via web channel. If user goes through profiler.firefox.com to enable + // it, it means that either user is a platform developer or filing a bug + // report for performance engineers to look at. + const supportedFeatures = Services.profiler.GetFeatures(); + changePreset("aboutprofiling", "firefox-platform", supportedFeatures); + + // Enable the profiler menu button. + const { ProfilerMenuButton } = lazy.ProfilerMenuButton(); + ProfilerMenuButton.addToNavbar(ownerDocument); + + // Dispatch the change event manually, so that the shortcuts will also be + // added. + const { CustomizableUI } = lazy.CustomizableUI(); + CustomizableUI.dispatchToolboxEvent("customizationchange"); + + // Open the popup with a message. + ProfilerMenuButton.openPopup(ownerDocument); + + // There is no response data for this message. + return undefined; + } + case "GET_PROFILE": { + const infoForBrowser = infoForBrowserMap.get(browser); + if (infoForBrowser === undefined) { + throw new Error("Could not find a profile for this tab."); + } + const { profileCaptureResult } = infoForBrowser; + switch (profileCaptureResult.type) { + case "SUCCESS": + return profileCaptureResult.profile; + case "ERROR": + throw profileCaptureResult.error; + default: + const { UnhandledCaseError } = lazy.Utils(); + throw new UnhandledCaseError( + profileCaptureResult, + "profileCaptureResult" + ); + } + } + case "GET_SYMBOL_TABLE": { + const { debugName, breakpadId } = request; + const symbolicationService = getSymbolicationServiceForBrowser(browser); + return symbolicationService.getSymbolTable(debugName, breakpadId); + } + case "QUERY_SYMBOLICATION_API": { + const { path, requestJson } = request; + const symbolicationService = getSymbolicationServiceForBrowser(browser); + return symbolicationService.querySymbolicationApi(path, requestJson); + } + default: + console.error( + "An unknown message type was received by the profiler's WebChannel handler.", + request + ); + const { UnhandledCaseError } = lazy.Utils(); + throw new UnhandledCaseError(request, "WebChannel request"); + } +} + +/** + * Get the symbolicationService for the capture that opened this browser's + * tab, or a fallback service for browsers from tabs opened by the user. + * + * @param {MockedExports.Browser} browser + * @return {SymbolicationService} + */ +function getSymbolicationServiceForBrowser(browser) { + // We try to serve symbolication requests that come from tabs that we + // opened when a profile was captured, and for tabs that the user opened + // independently, for example because the user wants to load an existing + // profile from a file. + const infoForBrowser = infoForBrowserMap.get(browser); + if (infoForBrowser !== undefined) { + // We opened this tab when a profile was captured. Use the symbolication + // service for that capture. + return infoForBrowser.symbolicationService; + } + + // For the "foreign" tabs, we provide a fallback symbolication service so that + // we can find symbols for any libraries that are loaded in this process. This + // means that symbolication will work if the existing file has been captured + // from the same build. + const { createLocalSymbolicationService } = lazy.PerfSymbolication(); + return createLocalSymbolicationService( + Services.profiler.sharedLibraries, + getObjdirPrefValue() + ); +} + +/** + * This handler handles any messages coming from the WebChannel from profiler.firefox.com. + * + * @param {ProfilerWebChannel} channel + * @param {string} id + * @param {any} message + * @param {MockedExports.WebChannelTarget} target + */ +async function handleWebChannelMessage(channel, id, message, target) { + if (typeof message !== "object" || typeof message.type !== "string") { + console.error( + "An malformed message was received by the profiler's WebChannel handler.", + message + ); + return; + } + const messageFromFrontend = /** @type {MessageFromFrontend} */ (message); + const { requestId } = messageFromFrontend; + + try { + const response = await getResponseForMessage( + messageFromFrontend, + target.browser + ); + channel.send( + { + type: "SUCCESS_RESPONSE", + requestId, + response, + }, + target + ); + } catch (error) { + channel.send( + { + type: "ERROR_RESPONSE", + requestId, + error: `${error.name}: ${error.message}`, + }, + target + ); + } +} + +/** + * @param {MockedExports.Browser} browser - The tab's browser. + * @param {ProfileCaptureResult} profileCaptureResult - The Gecko profile. + * @param {SymbolicationService} symbolicationService - An object which implements the + * SymbolicationService interface, whose getSymbolTable method will be invoked + * when profiler.firefox.com sends GET_SYMBOL_TABLE WebChannel messages to us. This + * method should obtain a symbol table for the requested binary and resolve the + * returned promise with it. + */ +function registerProfileCaptureForBrowser( + browser, + profileCaptureResult, + symbolicationService +) { + infoForBrowserMap.set(browser, { + profileCaptureResult, + symbolicationService, + }); +} + +// Provide a fake module.exports for the JSM to be properly read by TypeScript. +/** @type {any} */ +var module = { exports: {} }; + +module.exports = { + presets, + captureProfile, + startProfiler, + stopProfiler, + restartProfiler, + toggleProfiler, + platform, + getRecordingSettings, + setRecordingSettings, + revertRecordingSettings, + changePreset, + handleWebChannelMessage, + registerProfileCaptureForBrowser, + addPrefObserver, + removePrefObserver, + getProfilerViewModeForCurrentPreset, +}; + +// Object.keys() confuses the linting which expects a static array expression. +// eslint-disable-next-line +var EXPORTED_SYMBOLS = Object.keys(module.exports); diff --git a/devtools/client/performance-new/shared/browser.js b/devtools/client/performance-new/shared/browser.js new file mode 100644 index 0000000000..1f3b91e278 --- /dev/null +++ b/devtools/client/performance-new/shared/browser.js @@ -0,0 +1,172 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check +"use strict"; + +/** + * @typedef {import("../@types/perf").Action} Action + * @typedef {import("../@types/perf").Library} Library + * @typedef {import("../@types/perf").PerfFront} PerfFront + * @typedef {import("../@types/perf").SymbolTableAsTuple} SymbolTableAsTuple + * @typedef {import("../@types/perf").RecordingState} RecordingState + * @typedef {import("../@types/perf").SymbolicationService} SymbolicationService + * @typedef {import("../@types/perf").PreferenceFront} PreferenceFront + * @typedef {import("../@types/perf").PerformancePref} PerformancePref + * @typedef {import("../@types/perf").RecordingSettings} RecordingSettings + * @typedef {import("../@types/perf").RestartBrowserWithEnvironmentVariable} RestartBrowserWithEnvironmentVariable + * @typedef {import("../@types/perf").GetActiveBrowserID} GetActiveBrowserID + * @typedef {import("../@types/perf").MinimallyTypedGeckoProfile} MinimallyTypedGeckoProfile + * * @typedef {import("../@types/perf").ProfilerViewMode} ProfilerViewMode + */ + +/** @type {PerformancePref["UIBaseUrl"]} */ +const UI_BASE_URL_PREF = "devtools.performance.recording.ui-base-url"; +/** @type {PerformancePref["UIBaseUrlPathPref"]} */ +const UI_BASE_URL_PATH_PREF = "devtools.performance.recording.ui-base-url-path"; + +/** @type {PerformancePref["UIEnableActiveTabView"]} */ +const UI_ENABLE_ACTIVE_TAB_PREF = + "devtools.performance.recording.active-tab-view.enabled"; + +const UI_BASE_URL_DEFAULT = "https://profiler.firefox.com"; +const UI_BASE_URL_PATH_DEFAULT = "/from-browser"; + +/** + * This file contains all of the privileged browser-specific functionality. This helps + * keep a clear separation between the privileged and non-privileged client code. It + * is also helpful in being able to mock out browser behavior for tests, without + * worrying about polluting the browser environment. + */ + +/** + * Once a profile is received from the actor, it needs to be opened up in + * profiler.firefox.com to be analyzed. This function opens up profiler.firefox.com + * into a new browser tab. + * @param {ProfilerViewMode | undefined} profilerViewMode - View mode for the Firefox Profiler + * front-end timeline. While opening the url, we should append a query string + * if a view other than "full" needs to be displayed. + * @returns {Promise<MockedExports.Browser>} The browser for the opened tab. + */ +async function openProfilerTab(profilerViewMode) { + // Allow the user to point to something other than profiler.firefox.com. + const baseUrl = Services.prefs.getStringPref( + UI_BASE_URL_PREF, + UI_BASE_URL_DEFAULT + ); + // Allow tests to override the path. + const baseUrlPath = Services.prefs.getStringPref( + UI_BASE_URL_PATH_PREF, + UI_BASE_URL_PATH_DEFAULT + ); + // This controls whether we enable the active tab view when capturing in web + // developer preset. + const enableActiveTab = Services.prefs.getBoolPref( + UI_ENABLE_ACTIVE_TAB_PREF, + false + ); + + // We automatically open up the "full" mode if no query string is present. + // `undefined` also means nothing is specified, and it should open the "full" + // timeline view in that case. + let viewModeQueryString = ""; + if (profilerViewMode === "active-tab") { + // We're not enabling the active-tab view in all environments until we + // iron out all its issues. + if (enableActiveTab) { + viewModeQueryString = "?view=active-tab&implementation=js"; + } else { + viewModeQueryString = "?implementation=js"; + } + } else if (profilerViewMode !== undefined && profilerViewMode !== "full") { + viewModeQueryString = `?view=${profilerViewMode}`; + } + + const urlToLoad = `${baseUrl}${baseUrlPath}${viewModeQueryString}`; + + // Find the most recently used window, as the DevTools client could be in a variety + // of hosts. + // Note that when running from the browser toolbox, there won't be the browser window, + // but only the browser toolbox document. + const win = + Services.wm.getMostRecentWindow("navigator:browser") || + Services.wm.getMostRecentWindow("devtools:toolbox"); + if (!win) { + throw new Error("No browser window"); + } + win.focus(); + + // The profiler frontend currently doesn't support being loaded in a private + // window, because it does some storage writes in IndexedDB. That's why we + // force the opening of the tab in a non-private window. This might open a new + // non-private window if the only currently opened window is a private window. + const contentBrowser = await new Promise(resolveOnContentBrowserCreated => + win.openWebLinkIn(urlToLoad, "tab", { + forceNonPrivate: true, + resolveOnContentBrowserCreated, + userContextId: win.gBrowser?.contentPrincipal.userContextId, + }) + ); + return contentBrowser; +} + +/** + * Flatten all the sharedLibraries of the different processes in the profile + * into one list of libraries. + * @param {MinimallyTypedGeckoProfile} profile - The profile JSON object + * @returns {Library[]} + */ +function sharedLibrariesFromProfile(profile) { + /** + * @param {MinimallyTypedGeckoProfile} processProfile + * @returns {Library[]} + */ + function getLibsRecursive(processProfile) { + return processProfile.libs.concat( + ...processProfile.processes.map(getLibsRecursive) + ); + } + + return getLibsRecursive(profile); +} + +/** + * Restarts the browser with a given environment variable set to a value. + * + * @type {RestartBrowserWithEnvironmentVariable} + */ +function restartBrowserWithEnvironmentVariable(envName, value) { + Services.env.set(envName, value); + + Services.startup.quit( + Services.startup.eForceQuit | Services.startup.eRestart + ); +} + +/** + * @param {Window} window + * @param {string[]} objdirs + * @param {(objdirs: string[]) => unknown} changeObjdirs + */ +function openFilePickerForObjdir(window, objdirs, changeObjdirs) { + const FilePicker = Cc["@mozilla.org/filepicker;1"].createInstance( + Ci.nsIFilePicker + ); + FilePicker.init(window, "Pick build directory", FilePicker.modeGetFolder); + FilePicker.open(rv => { + if (rv == FilePicker.returnOK) { + const path = FilePicker.file.path; + if (path && !objdirs.includes(path)) { + const newObjdirs = [...objdirs, path]; + changeObjdirs(newObjdirs); + } + } + }); +} + +module.exports = { + openProfilerTab, + sharedLibrariesFromProfile, + restartBrowserWithEnvironmentVariable, + openFilePickerForObjdir, +}; diff --git a/devtools/client/performance-new/shared/moz.build b/devtools/client/performance-new/shared/moz.build new file mode 100644 index 0000000000..b98771ab72 --- /dev/null +++ b/devtools/client/performance-new/shared/moz.build @@ -0,0 +1,17 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "background.jsm.js", + "browser.js", + "profiler_get_symbols.js", + "symbolication-worker.js", + "symbolication.jsm.js", + "typescript-lazy-load.jsm.js", + "utils.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Performance Tools (Profiler/Timeline)") diff --git a/devtools/client/performance-new/shared/profiler_get_symbols.js b/devtools/client/performance-new/shared/profiler_get_symbols.js new file mode 100644 index 0000000000..dc190853ba --- /dev/null +++ b/devtools/client/performance-new/shared/profiler_get_symbols.js @@ -0,0 +1,498 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// +// THIS FILE IS AUTOGENERATED by wasm-bindgen. +// +// Generated from: +// https://github.com/mstange/profiler-get-symbols/commit/0373708893e45e8299e58ca692764be448e3457d +// by following the instructions in that repository's Readme.md +// + +let wasm_bindgen; +(function() { + const __exports = {}; + let wasm; + + const heap = new Array(32).fill(undefined); + + heap.push(undefined, null, true, false); + +function getObject(idx) { return heap[idx]; } + +let heap_next = heap.length; + +function dropObject(idx) { + if (idx < 36) return; + heap[idx] = heap_next; + heap_next = idx; +} + +function takeObject(idx) { + const ret = getObject(idx); + dropObject(idx); + return ret; +} + +let WASM_VECTOR_LEN = 0; + +let cachedUint8Memory0 = new Uint8Array(); + +function getUint8Memory0() { + if (cachedUint8Memory0.byteLength === 0) { + cachedUint8Memory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8Memory0; +} + +const cachedTextEncoder = new TextEncoder(); + +const encodeString = (typeof cachedTextEncoder.encodeInto === 'function' + ? function (arg, view) { + return cachedTextEncoder.encodeInto(arg, view); +} + : function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; +}); + +function passStringToWasm0(arg, malloc, realloc) { + + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length); + getUint8Memory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len); + + const mem = getUint8Memory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3); + const view = getUint8Memory0().subarray(ptr + offset, ptr + len); + const ret = encodeString(arg, view); + + offset += ret.written; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +let cachedInt32Memory0 = new Int32Array(); + +function getInt32Memory0() { + if (cachedInt32Memory0.byteLength === 0) { + cachedInt32Memory0 = new Int32Array(wasm.memory.buffer); + } + return cachedInt32Memory0; +} + +function addHeapObject(obj) { + if (heap_next === heap.length) heap.push(heap.length + 1); + const idx = heap_next; + heap_next = heap[idx]; + + heap[idx] = obj; + return idx; +} + +const cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + +cachedTextDecoder.decode(); + +function getStringFromWasm0(ptr, len) { + return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len)); +} + +function makeMutClosure(arg0, arg1, dtor, f) { + const state = { a: arg0, b: arg1, cnt: 1, dtor }; + const real = (...args) => { + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + if (--state.cnt === 0) { + wasm.__wbindgen_export_2.get(state.dtor)(a, state.b); + + } else { + state.a = a; + } + } + }; + real.original = state; + + return real; +} +function __wbg_adapter_16(arg0, arg1, arg2) { + wasm._dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h19297bd79b0d78f6(arg0, arg1, addHeapObject(arg2)); +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + wasm.__wbindgen_exn_store(addHeapObject(e)); + } +} +function __wbg_adapter_33(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures__invoke2_mut__h80dea6fd01e77b95(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3)); +} + +/** +* Usage: +* +* ```js +* async function getSymbolTable(debugName, breakpadId, libKeyToPathMap) { +* const helper = { +* getCandidatePathsForDebugFile: (info) => { +* const path = libKeyToPathMap.get(`${info.debugName}/${info.breakpadId}`); +* if (path !== undefined) { +* return [path]; +* } +* return []; +* }, +* getCandidatePathsForBinary: (info) => [], +* readFile: async (filename) => { +* const byteLength = await getFileSizeInBytes(filename); +* const fileHandle = getFileHandle(filename); +* return { +* size: byteLength, +* readBytesInto: (array, offset) => { +* syncReadFilePartIntoBuffer(fileHandle, array, offset); +* }, +* close: () => {}, +* }; +* }, +* }; +* +* const [addr, index, buffer] = await getCompactSymbolTable(debugName, breakpadId, helper); +* return [addr, index, buffer]; +* } +* ``` +* @param {string} debug_name +* @param {string} breakpad_id +* @param {any} helper +* @returns {Promise<any>} +*/ +__exports.getCompactSymbolTable = function(debug_name, breakpad_id, helper) { + const ptr0 = passStringToWasm0(debug_name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(breakpad_id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.getCompactSymbolTable(ptr0, len0, ptr1, len1, addHeapObject(helper)); + return takeObject(ret); +}; + +/** +* Usage: +* +* ```js +* async function queryAPIWrapper(url, requestJSONString, libKeyToPathMap) { +* const helper = { +* getCandidatePathsForDebugFile: (info) => { +* const path = libKeyToPathMap.get(`${info.debugName}/${info.breakpadId}`); +* if (path !== undefined) { +* return [path]; +* } +* return []; +* }, +* getCandidatePathsForBinary: (info) => [], +* readFile: async (filename) => { +* const byteLength = await getFileSizeInBytes(filename); +* const fileHandle = getFileHandle(filename); +* return { +* size: byteLength, +* readBytesInto: (array, offset) => { +* syncReadFilePartIntoBuffer(fileHandle, array, offset); +* }, +* close: () => {}, +* }; +* }, +* }; +* +* const responseJSONString = await queryAPI(url, requestJSONString, helper); +* return responseJSONString; +* } +* ``` +* @param {string} url +* @param {string} request_json +* @param {any} helper +* @returns {Promise<any>} +*/ +__exports.queryAPI = function(url, request_json, helper) { + const ptr0 = passStringToWasm0(url, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.queryAPI(ptr0, len0, ptr1, len1, addHeapObject(helper)); + return takeObject(ret); +}; + +async function load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + + } catch (e) { + if (module.headers.get('Content-Type') != 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { + throw e; + } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + + } else { + return instance; + } + } +} + +function getImports() { + const imports = {}; + imports.wbg = {}; + imports.wbg.__wbindgen_object_drop_ref = function(arg0) { + takeObject(arg0); + }; + imports.wbg.__wbindgen_cb_drop = function(arg0) { + const obj = takeObject(arg0).original; + if (obj.cnt-- == 1) { + obj.a = 0; + return true; + } + const ret = false; + return ret; + }; + imports.wbg.__wbindgen_string_get = function(arg0, arg1) { + const obj = getObject(arg1); + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr0 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len0 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len0; + getInt32Memory0()[arg0 / 4 + 0] = ptr0; + }; + imports.wbg.__wbg_close_334601fc6c36b53e = function() { return handleError(function (arg0) { + getObject(arg0).close(); + }, arguments) }; + imports.wbg.__wbg_get_57245cc7d7c7619d = function(arg0, arg1) { + const ret = getObject(arg0)[arg1 >>> 0]; + return addHeapObject(ret); + }; + imports.wbg.__wbindgen_memory = function() { + const ret = wasm.memory; + return addHeapObject(ret); + }; + imports.wbg.__wbg_buffer_3f3d764d4747d564 = function(arg0) { + const ret = getObject(arg0).buffer; + return addHeapObject(ret); + }; + imports.wbg.__wbg_newwithbyteoffsetandlength_d9aa266703cb98be = function(arg0, arg1, arg2) { + const ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0); + return addHeapObject(ret); + }; + imports.wbg.__wbg_readBytesInto_3ec3b16ea5839a95 = function() { return handleError(function (arg0, arg1, arg2) { + getObject(arg0).readBytesInto(takeObject(arg1), arg2); + }, arguments) }; + imports.wbg.__wbg_name_48eda3ae6aa697ca = function(arg0) { + const ret = getObject(arg0).name; + return addHeapObject(ret); + }; + imports.wbg.__wbg_message_fe2af63ccc8985bc = function(arg0) { + const ret = getObject(arg0).message; + return addHeapObject(ret); + }; + imports.wbg.__wbg_new_0b9bfdd97583284e = function() { + const ret = new Object(); + return addHeapObject(ret); + }; + imports.wbg.__wbindgen_string_new = function(arg0, arg1) { + const ret = getStringFromWasm0(arg0, arg1); + return addHeapObject(ret); + }; + imports.wbg.__wbg_set_20cbc34131e76824 = function(arg0, arg1, arg2) { + getObject(arg0)[takeObject(arg1)] = takeObject(arg2); + }; + imports.wbg.__wbindgen_object_clone_ref = function(arg0) { + const ret = getObject(arg0); + return addHeapObject(ret); + }; + imports.wbg.__wbg_new_9962f939219f1820 = function(arg0, arg1) { + try { + var state0 = {a: arg0, b: arg1}; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return __wbg_adapter_33(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + const ret = new Promise(cb0); + return addHeapObject(ret); + } finally { + state0.a = state0.b = 0; + } + }; + imports.wbg.__wbg_newwithbyteoffsetandlength_9cc9adccd861aa26 = function(arg0, arg1, arg2) { + const ret = new Uint32Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0); + return addHeapObject(ret); + }; + imports.wbg.__wbg_new_949418f5ed1e29f7 = function(arg0) { + const ret = new Uint32Array(getObject(arg0)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_new_8c3f0052272a457a = function(arg0) { + const ret = new Uint8Array(getObject(arg0)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_of_22ee6ea02403744c = function(arg0, arg1, arg2) { + const ret = Array.of(getObject(arg0), getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_call_168da88779e35f61 = function() { return handleError(function (arg0, arg1, arg2) { + const ret = getObject(arg0).call(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }, arguments) }; + imports.wbg.__wbg_getCandidatePathsForDebugFile_19de1ea293153630 = function() { return handleError(function (arg0, arg1) { + const ret = getObject(arg0).getCandidatePathsForDebugFile(takeObject(arg1)); + return addHeapObject(ret); + }, arguments) }; + imports.wbg.__wbg_from_7ce3cb27cb258569 = function(arg0) { + const ret = Array.from(getObject(arg0)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_length_6e3bbe7c8bd4dbd8 = function(arg0) { + const ret = getObject(arg0).length; + return ret; + }; + imports.wbg.__wbg_getCandidatePathsForBinary_8311cb7aeae90263 = function() { return handleError(function (arg0, arg1) { + const ret = getObject(arg0).getCandidatePathsForBinary(takeObject(arg1)); + return addHeapObject(ret); + }, arguments) }; + imports.wbg.__wbg_readFile_33b95391c6839d48 = function(arg0, arg1, arg2) { + const ret = getObject(arg0).readFile(getStringFromWasm0(arg1, arg2)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_then_cedad20fbbd9418a = function(arg0, arg1, arg2) { + const ret = getObject(arg0).then(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_size_c5375fc90958b38d = function() { return handleError(function (arg0) { + const ret = getObject(arg0).size; + return ret; + }, arguments) }; + imports.wbg.__wbindgen_throw = function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }; + imports.wbg.__wbg_then_11f7a54d67b4bfad = function(arg0, arg1) { + const ret = getObject(arg0).then(getObject(arg1)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_resolve_99fe17964f31ffc0 = function(arg0) { + const ret = Promise.resolve(getObject(arg0)); + return addHeapObject(ret); + }; + imports.wbg.__wbindgen_closure_wrapper1912 = function(arg0, arg1, arg2) { + const ret = makeMutClosure(arg0, arg1, 143, __wbg_adapter_16); + return addHeapObject(ret); + }; + + return imports; +} + +function initMemory(imports, maybe_memory) { + +} + +function finalizeInit(instance, module) { + wasm = instance.exports; + init.__wbindgen_wasm_module = module; + cachedInt32Memory0 = new Int32Array(); + cachedUint8Memory0 = new Uint8Array(); + + + return wasm; +} + +function initSync(module) { + const imports = getImports(); + + initMemory(imports); + + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + + const instance = new WebAssembly.Instance(module, imports); + + return finalizeInit(instance, module); +} + +async function init(input) { + if (typeof input === 'undefined') { + let src; + if (typeof document === 'undefined') { + src = location.href; + } else { + src = document.currentScript.src; + } + input = src.replace(/\.js$/, '_bg.wasm'); + } + const imports = getImports(); + + if (typeof input === 'string' || (typeof Request === 'function' && input instanceof Request) || (typeof URL === 'function' && input instanceof URL)) { + input = fetch(input); + } + + initMemory(imports); + + const { instance, module } = await load(await input, imports); + + return finalizeInit(instance, module); +} + +wasm_bindgen = Object.assign(init, { initSync }, __exports); + +})(); diff --git a/devtools/client/performance-new/shared/symbolication-worker.js b/devtools/client/performance-new/shared/symbolication-worker.js new file mode 100644 index 0000000000..4f2ee02f29 --- /dev/null +++ b/devtools/client/performance-new/shared/symbolication-worker.js @@ -0,0 +1,279 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env mozilla/chrome-worker */ + +// FIXME: This file is currently not covered by TypeScript, there is no "@ts-check" comment. +// We should fix this once we know how to deal with the module imports below. +// (Maybe once Firefox supports worker module? Bug 1247687) + +"use strict"; + +/* import-globals-from profiler_get_symbols.js */ +importScripts( + "resource://devtools/client/performance-new/shared/profiler_get_symbols.js" +); + +/** + * @typedef {import("../@types/perf").SymbolicationWorkerInitialMessage} SymbolicationWorkerInitialMessage + * @typedef {import("../@types/perf").FileHandle} FileHandle + */ + +// This worker uses the wasm module that was generated from https://github.com/mstange/profiler-get-symbols. +// See ProfilerGetSymbols.jsm for more information. +// +// The worker instantiates the module, reads the binary into wasm memory, runs +// the wasm code, and returns the symbol table or an error. Then it shuts down +// itself. + +/* eslint camelcase: 0*/ +const { getCompactSymbolTable, queryAPI } = wasm_bindgen; + +// Returns a plain object that is Structured Cloneable and has name and +// message properties. +function createPlainErrorObject(e) { + // Regular errors: just rewrap the object. + const { name, message, fileName, lineNumber } = e; + return { name, message, fileName, lineNumber }; +} + +/** + * A FileAndPathHelper object is passed to getCompactSymbolTable, which calls + * the methods `getCandidatePathsForBinaryOrPdb` and `readFile` on it. + */ +class FileAndPathHelper { + constructor(libInfoMap, objdirs) { + this._libInfoMap = libInfoMap; + this._objdirs = objdirs; + } + + /** + * Enumerate all paths at which we could find files with symbol information. + * This method is called by wasm code (via the bindings). + * + * @param {LibraryInfo} libraryInfo + * @returns {Array<string>} + */ + getCandidatePathsForDebugFile(libraryInfo) { + const { debugName, breakpadId } = libraryInfo; + const key = `${debugName}:${breakpadId}`; + const lib = this._libInfoMap.get(key); + if (!lib) { + throw new Error( + `Could not find the library for "${debugName}", "${breakpadId}".` + ); + } + + const { name, path, debugPath, arch } = lib; + const candidatePaths = []; + + // First, try to find a binary with a matching file name and breakpadId + // in one of the manually specified objdirs. + // This is needed if the debuggee is a build running on a remote machine that + // was compiled by the developer on *this* machine (the "host machine"). In + // that case, the objdir will contain the compiled binary with full symbol and + // debug information, whereas the binary on the device may not exist in + // uncompressed form or may have been stripped of debug information and some + // symbol information. + // An objdir, or "object directory", is a directory on the host machine that's + // used to store build artifacts ("object files") from the compilation process. + // This only works if the binary is one of the Gecko binaries and not + // a system library. + for (const objdirPath of this._objdirs) { + try { + // Binaries are usually expected to exist at objdir/dist/bin/filename. + candidatePaths.push(PathUtils.join(objdirPath, "dist", "bin", name)); + + // Also search in the "objdir" directory itself (not just in dist/bin). + // If, for some unforeseen reason, the relevant binary is not inside the + // objdirs dist/bin/ directory, this provides a way out because it lets the + // user specify the actual location. + candidatePaths.push(PathUtils.join(objdirPath, name)); + } catch (e) { + // PathUtils.join throws if objdirPath is not an absolute path. + // Ignore those invalid objdir paths. + } + } + + // Check the absolute paths of the library last. + // We do this after the objdir search because the library's path may point + // to a stripped binary, which will have fewer symbols than the original + // binaries in the objdir. + if (debugPath !== path) { + // We're on Windows, and debugPath points to a PDB file. + // On non-Windows, path and debugPath are always the same. + + // Check the PDB file before the binary because the PDB has the symbol + // information. The binary is only used as a last-ditch fallback + // for things like Windows system libraries (e.g. graphics drivers). + candidatePaths.push(debugPath); + } + + // The location of the binary. If the profile was obtained on this machine + // (and not, for example, on an Android device), this file should always + // exist. + candidatePaths.push(path); + + // On macOS, for system libraries, add a final fallback for the dyld shared + // cache. Starting with macOS 11, most system libraries are located in this + // system-wide cache file and not present as individual files. + if (arch && (path.startsWith("/usr/") || path.startsWith("/System/"))) { + // Use the special syntax `dyldcache:<dyldcachepath>:<librarypath>`. + + // Dyld cache location used on macOS 13+: + candidatePaths.push( + `dyldcache:/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_${arch}:${path}` + ); + // Dyld cache location used on macOS 11 and 12: + candidatePaths.push( + `dyldcache:/System/Library/dyld/dyld_shared_cache_${arch}:${path}` + ); + } + + return candidatePaths; + } + + /** + * Enumerate all paths at which we could find the binary which matches the + * given libraryInfo, in order to disassemble machine code. + * This method is called by wasm code (via the bindings). + * + * @param {LibraryInfo} libraryInfo + * @returns {Array<string>} + */ + getCandidatePathsForBinary(libraryInfo) { + const { debugName, breakpadId } = libraryInfo; + const key = `${debugName}:${breakpadId}`; + const lib = this._libInfoMap.get(key); + if (!lib) { + throw new Error( + `Could not find the library for "${debugName}", "${breakpadId}".` + ); + } + + const { name, path, arch } = lib; + const candidatePaths = []; + + // The location of the binary. If the profile was obtained on this machine + // (and not, for example, on an Android device), this file should always + // exist. + candidatePaths.push(path); + + // Fall back to searching in the manually specified objdirs. + // This is needed if the debuggee is a build running on a remote machine that + // was compiled by the developer on *this* machine (the "host machine"). In + // that case, the objdir will contain the compiled binary. + for (const objdirPath of this._objdirs) { + try { + // Binaries are usually expected to exist at objdir/dist/bin/filename. + candidatePaths.push(PathUtils.join(objdirPath, "dist", "bin", name)); + + // Also search in the "objdir" directory itself (not just in dist/bin). + // If, for some unforeseen reason, the relevant binary is not inside the + // objdirs dist/bin/ directory, this provides a way out because it lets the + // user specify the actual location. + candidatePaths.push(PathUtils.join(objdirPath, name)); + } catch (e) { + // PathUtils.join throws if objdirPath is not an absolute path. + // Ignore those invalid objdir paths. + } + } + + // On macOS, for system libraries, add a final fallback for the dyld shared + // cache. Starting with macOS 11, most system libraries are located in this + // system-wide cache file and not present as individual files. + if (arch && (path.startsWith("/usr/") || path.startsWith("/System/"))) { + // Use the special syntax `dyldcache:<dyldcachepath>:<librarypath>`. + + // Dyld cache location used on macOS 13+: + candidatePaths.push( + `dyldcache:/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_${arch}:${path}` + ); + // Dyld cache location used on macOS 11 and 12: + candidatePaths.push( + `dyldcache:/System/Library/dyld/dyld_shared_cache_${arch}:${path}` + ); + } + + return candidatePaths; + } + + /** + * Asynchronously prepare the file at `path` for synchronous reading. + * This method is called by wasm code (via the bindings). + * + * @param {string} path + * @returns {FileHandle} + */ + async readFile(path) { + const info = await IOUtils.stat(path); + if (info.type === "directory") { + throw new Error(`Path "${path}" is a directory.`); + } + + return IOUtils.openFileForSyncReading(path); + } +} + +/** @param {MessageEvent<SymbolicationWorkerInitialMessage>} e */ +onmessage = async e => { + try { + const { request, libInfoMap, objdirs, module } = e.data; + + if (!(module instanceof WebAssembly.Module)) { + throw new Error("invalid WebAssembly module"); + } + + // Instantiate the WASM module. + await wasm_bindgen(module); + + const helper = new FileAndPathHelper(libInfoMap, objdirs); + + switch (request.type) { + case "GET_SYMBOL_TABLE": { + const { debugName, breakpadId } = request; + const result = await getCompactSymbolTable( + debugName, + breakpadId, + helper + ); + postMessage( + { result }, + result.map(r => r.buffer) + ); + break; + } + case "QUERY_SYMBOLICATION_API": { + const { path, requestJson } = request; + const result = await queryAPI(path, requestJson, helper); + postMessage({ result }); + break; + } + default: + throw new Error(`Unexpected request type ${request.type}`); + } + } catch (error) { + postMessage({ error: createPlainErrorObject(error) }); + } + close(); +}; + +onunhandledrejection = e => { + // Unhandled rejections can happen if the WASM code throws a + // "RuntimeError: unreachable executed" exception, which can happen + // if the Rust code panics or runs out of memory. + // These panics currently are not propagated to the promise reject + // callback, see https://github.com/rustwasm/wasm-bindgen/issues/2724 . + // Ideally, the Rust code should never panic and handle all error + // cases gracefully. + e.preventDefault(); + postMessage({ error: createPlainErrorObject(e.reason) }); +}; + +// Catch any other unhandled errors, just to be sure. +onerror = e => { + postMessage({ error: createPlainErrorObject(e) }); +}; diff --git a/devtools/client/performance-new/shared/symbolication.jsm.js b/devtools/client/performance-new/shared/symbolication.jsm.js new file mode 100644 index 0000000000..f79cffe6cb --- /dev/null +++ b/devtools/client/performance-new/shared/symbolication.jsm.js @@ -0,0 +1,364 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check +"use strict"; + +/** @type {any} */ +const lazy = {}; + +/** + * @typedef {import("../@types/perf").Library} Library + * @typedef {import("../@types/perf").PerfFront} PerfFront + * @typedef {import("../@types/perf").SymbolTableAsTuple} SymbolTableAsTuple + * @typedef {import("../@types/perf").SymbolicationService} SymbolicationService + * @typedef {import("../@types/perf").SymbolicationWorkerInitialMessage} SymbolicationWorkerInitialMessage + */ + +/** + * @template R + * @typedef {import("../@types/perf").SymbolicationWorkerReplyData<R>} SymbolicationWorkerReplyData<R> + */ + +ChromeUtils.defineESModuleGetters(lazy, { + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +/** @type {any} */ +const global = globalThis; + +// This module obtains symbol tables for binaries. +// It does so with the help of a WASM module which gets pulled in from the +// internet on demand. We're doing this purely for the purposes of saving on +// code size. The contents of the WASM module are expected to be static, they +// are checked against the hash specified below. +// The WASM code is run on a ChromeWorker thread. It takes the raw byte +// contents of the to-be-dumped binary (and of an additional optional pdb file +// on Windows) as its input, and returns a set of typed arrays which make up +// the symbol table. + +// Don't let the strange looking URLs and strings below scare you. +// The hash check ensures that the contents of the wasm module are what we +// expect them to be. +// The source code is at https://github.com/mstange/profiler-get-symbols/ . +// Documentation is at https://docs.rs/samply-api/ . +// The sha384 sum can be computed with the following command (tested on macOS): +// shasum -b -a 384 profiler_get_symbols_wasm_bg.wasm | awk '{ print $1 }' | xxd -r -p | base64 + +// Generated from https://github.com/mstange/profiler-get-symbols/commit/0373708893e45e8299e58ca692764be448e3457d +const WASM_MODULE_URL = + "https://storage.googleapis.com/firefox-profiler-get-symbols/0373708893e45e8299e58ca692764be448e3457d.wasm"; +const WASM_MODULE_INTEGRITY = + "sha384-rUGgHTg1eAKP2MB4JcX/HGROSBlRUmvpm6FFIihH0gGQ74zfJE2p7P8cxR86faQ7"; + +const EXPIRY_TIME_IN_MS = 5 * 60 * 1000; // 5 minutes + +/** @type {Promise<WebAssembly.Module> | null} */ +let gCachedWASMModulePromise = null; +let gCachedWASMModuleExpiryTimer = 0; + +// Keep active workers alive (see bug 1592227). +const gActiveWorkers = new Set(); + +function clearCachedWASMModule() { + gCachedWASMModulePromise = null; + gCachedWASMModuleExpiryTimer = 0; +} + +function getWASMProfilerGetSymbolsModule() { + if (!gCachedWASMModulePromise) { + gCachedWASMModulePromise = (async function () { + const request = new Request(WASM_MODULE_URL, { + integrity: WASM_MODULE_INTEGRITY, + credentials: "omit", + }); + return WebAssembly.compileStreaming(fetch(request)); + })(); + } + + // Reset expiry timer. + lazy.clearTimeout(gCachedWASMModuleExpiryTimer); + gCachedWASMModuleExpiryTimer = lazy.setTimeout( + clearCachedWASMModule, + EXPIRY_TIME_IN_MS + ); + + return gCachedWASMModulePromise; +} + +/** + * Handle the entire life cycle of a worker, and report its result. + * This method creates a new worker, sends the initial message to it, handles + * any errors, and accepts the result. + * Returns a promise that resolves with the contents of the (singular) result + * message or rejects with an error. + * + * @template M + * @template R + * @param {string} workerURL + * @param {M} initialMessageToWorker + * @returns {Promise<R>} + */ +async function getResultFromWorker(workerURL, initialMessageToWorker) { + return new Promise((resolve, reject) => { + const worker = new ChromeWorker(workerURL); + gActiveWorkers.add(worker); + + /** @param {MessageEvent<SymbolicationWorkerReplyData<R>>} msg */ + worker.onmessage = msg => { + gActiveWorkers.delete(worker); + if ("error" in msg.data) { + const error = msg.data.error; + if (error.name) { + // Turn the JSON error object into a real Error object. + const { name, message, fileName, lineNumber } = error; + const ErrorObjConstructor = + name in global && Error.isPrototypeOf(global[name]) + ? global[name] + : Error; + const e = new ErrorObjConstructor(message, fileName, lineNumber); + e.name = name; + reject(e); + } else { + reject(error); + } + return; + } + resolve(msg.data.result); + }; + + // Handle uncaught errors from the worker script. onerror is called if + // there's a syntax error in the worker script, for example, or when an + // unhandled exception is thrown, but not for unhandled promise + // rejections. Without this handler, mistakes during development such as + // syntax errors can be hard to track down. + worker.onerror = errorEvent => { + gActiveWorkers.delete(worker); + worker.terminate(); + if (ErrorEvent.isInstance(errorEvent)) { + const { message, filename, lineno } = errorEvent; + const error = new Error(`${message} at ${filename}:${lineno}`); + error.name = "WorkerError"; + reject(error); + } else { + reject(new Error("Error in worker")); + } + }; + + // Handle errors from messages that cannot be deserialized. I'm not sure + // how to get into such a state, but having this handler seems like a good + // idea. + worker.onmessageerror = () => { + gActiveWorkers.delete(worker); + worker.terminate(); + reject(new Error("Error in worker")); + }; + + worker.postMessage(initialMessageToWorker); + }); +} + +/** + * @param {PerfFront} perfFront + * @param {string} path + * @param {string} breakpadId + * @returns {Promise<SymbolTableAsTuple>} + */ +async function getSymbolTableFromDebuggee(perfFront, path, breakpadId) { + const [addresses, index, buffer] = await perfFront.getSymbolTable( + path, + breakpadId + ); + // The protocol transmits these arrays as plain JavaScript arrays of + // numbers, but we want to pass them on as typed arrays. Convert them now. + return [ + new Uint32Array(addresses), + new Uint32Array(index), + new Uint8Array(buffer), + ]; +} + +/** + * Profiling through the DevTools remote debugging protocol supports multiple + * different modes. This class is specialized to handle various profiling + * modes such as: + * + * 1) Profiling the same browser on the same machine. + * 2) Profiling a remote browser on the same machine. + * 3) Profiling a remote browser on a different device. + * + * It's also built to handle symbolication requests for both Gecko libraries and + * system libraries. However, it only handles cases where symbol information + * can be found in a local file on this machine. There is one case that is not + * covered by that restriction: Android system libraries. That case requires + * the help of the perf actor and is implemented in + * LocalSymbolicationServiceWithRemoteSymbolTableFallback. + */ +class LocalSymbolicationService { + /** + * @param {Library[]} sharedLibraries - Information about the shared libraries. + * This allows mapping (debugName, breakpadId) pairs to the absolute path of + * the binary and/or PDB file, and it ensures that these absolute paths come + * from a trusted source and not from the profiler UI. + * @param {string[]} objdirs - An array of objdir paths + * on the host machine that should be searched for relevant build artifacts. + */ + constructor(sharedLibraries, objdirs) { + this._libInfoMap = new Map( + sharedLibraries.map(lib => { + const { debugName, breakpadId } = lib; + const key = `${debugName}:${breakpadId}`; + return [key, lib]; + }) + ); + this._objdirs = objdirs; + } + + /** + * @param {string} debugName + * @param {string} breakpadId + * @returns {Promise<SymbolTableAsTuple>} + */ + async getSymbolTable(debugName, breakpadId) { + const module = await getWASMProfilerGetSymbolsModule(); + /** @type {SymbolicationWorkerInitialMessage} */ + const initialMessage = { + request: { + type: "GET_SYMBOL_TABLE", + debugName, + breakpadId, + }, + libInfoMap: this._libInfoMap, + objdirs: this._objdirs, + module, + }; + return getResultFromWorker( + "resource://devtools/client/performance-new/shared/symbolication-worker.js", + initialMessage + ); + } + + /** + * @param {string} path + * @param {string} requestJson + * @returns {Promise<string>} + */ + async querySymbolicationApi(path, requestJson) { + const module = await getWASMProfilerGetSymbolsModule(); + /** @type {SymbolicationWorkerInitialMessage} */ + const initialMessage = { + request: { + type: "QUERY_SYMBOLICATION_API", + path, + requestJson, + }, + libInfoMap: this._libInfoMap, + objdirs: this._objdirs, + module, + }; + return getResultFromWorker( + "resource://devtools/client/performance-new/shared/symbolication-worker.js", + initialMessage + ); + } +} + +/** + * An implementation of the SymbolicationService interface which also + * covers the Android system library case. + * We first try to get symbols from the wrapped SymbolicationService. + * If that fails, we try to get the symbol table through the perf actor. + */ +class LocalSymbolicationServiceWithRemoteSymbolTableFallback { + /** + * @param {SymbolicationService} symbolicationService - The regular symbolication service. + * @param {Library[]} sharedLibraries - Information about the shared libraries + * @param {PerfFront} perfFront - A perf actor, to obtain symbol + * tables from remote targets + */ + constructor(symbolicationService, sharedLibraries, perfFront) { + this._symbolicationService = symbolicationService; + this._libs = sharedLibraries; + this._perfFront = perfFront; + } + + /** + * @param {string} debugName + * @param {string} breakpadId + * @returns {Promise<SymbolTableAsTuple>} + */ + async getSymbolTable(debugName, breakpadId) { + try { + return await this._symbolicationService.getSymbolTable( + debugName, + breakpadId + ); + } catch (errorFromLocalFiles) { + // Try to obtain the symbol table on the debuggee. We get into this + // branch in the following cases: + // - Android system libraries + // - Firefox binaries that have no matching equivalent on the host + // machine, for example because the user didn't point us at the + // corresponding objdir, or if the build was compiled somewhere + // else, or if the build on the device is outdated. + // For now, the "debuggee" is never a Windows machine, which is why we don't + // need to pass the library's debugPath. (path and debugPath are always the + // same on non-Windows.) + const lib = this._libs.find( + l => l.debugName === debugName && l.breakpadId === breakpadId + ); + if (!lib) { + throw new Error( + `Could not find the library for "${debugName}", "${breakpadId}" after falling ` + + `back to remote symbol table querying because regular getSymbolTable failed ` + + `with error: ${errorFromLocalFiles.message}.` + ); + } + return getSymbolTableFromDebuggee(this._perfFront, lib.path, breakpadId); + } + } + + /** + * @param {string} path + * @param {string} requestJson + * @returns {Promise<string>} + */ + async querySymbolicationApi(path, requestJson) { + return this._symbolicationService.querySymbolicationApi(path, requestJson); + } +} + +/** + * Return an object that implements the SymbolicationService interface. + * + * @param {Library[]} sharedLibraries - Information about the shared libraries + * @param {string[]} objdirs - An array of objdir paths + * on the host machine that should be searched for relevant build artifacts. + * @param {PerfFront} [perfFront] - An optional perf actor, to obtain symbol + * tables from remote targets + * @return {SymbolicationService} + */ +function createLocalSymbolicationService(sharedLibraries, objdirs, perfFront) { + const service = new LocalSymbolicationService(sharedLibraries, objdirs); + if (perfFront) { + return new LocalSymbolicationServiceWithRemoteSymbolTableFallback( + service, + sharedLibraries, + perfFront + ); + } + return service; +} + +// Provide an exports object for the JSM to be properly read by TypeScript. +/** @type {any} */ +var module = {}; + +module.exports = { + createLocalSymbolicationService, +}; + +// Object.keys() confuses the linting which expects a static array expression. +// eslint-disable-next-line +var EXPORTED_SYMBOLS = Object.keys(module.exports); diff --git a/devtools/client/performance-new/shared/typescript-lazy-load.jsm.js b/devtools/client/performance-new/shared/typescript-lazy-load.jsm.js new file mode 100644 index 0000000000..c640572c53 --- /dev/null +++ b/devtools/client/performance-new/shared/typescript-lazy-load.jsm.js @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check +"use strict"; + +/** + * TypeScript can't understand the lazyRequireGetter mechanism, due to how it defines + * properties as a getter. This function, instead provides lazy loading in a + * TypeScript-friendly manner. It applies the lazy load memoization to each property + * of the provided object. + * + * Example usage: + * + * const lazy = createLazyLoaders({ + * moduleA: () => require("module/a"), + * moduleB: () => require("module/b"), + * }); + * + * Later: + * + * const moduleA = lazy.moduleA(); + * const { objectInModuleB } = lazy.moduleB(); + * + * @template T + * @param {T} definition - An object where each property has a function that loads a module. + * @returns {T} - The load memoized version of T. + */ +function createLazyLoaders(definition) { + /** @type {any} */ + const result = {}; + for (const [key, callback] of Object.entries(definition)) { + /** @type {any} */ + let cache; + result[key] = () => { + if (cache === undefined) { + cache = callback(); + } + return cache; + }; + } + return result; +} + +// Provide an exports object for the JSM to be properly read by TypeScript. +/** @type {any} */ +var module = {}; + +module.exports = { + createLazyLoaders, +}; + +// Object.keys() confuses the linting which expects a static array expression. +// eslint-disable-next-line +var EXPORTED_SYMBOLS = Object.keys(module.exports); diff --git a/devtools/client/performance-new/shared/utils.js b/devtools/client/performance-new/shared/utils.js new file mode 100644 index 0000000000..034e572186 --- /dev/null +++ b/devtools/client/performance-new/shared/utils.js @@ -0,0 +1,566 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check +/** + * @typedef {import("../@types/perf").NumberScaler} NumberScaler + * @typedef {import("../@types/perf").ScaleFunctions} ScaleFunctions + * @typedef {import("../@types/perf").FeatureDescription} FeatureDescription + */ +"use strict"; + +const UNITS = ["B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]; + +const AppConstants = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +).AppConstants; + +/** + * Linearly interpolate between values. + * https://en.wikipedia.org/wiki/Linear_interpolation + * + * @param {number} frac - Value ranged 0 - 1 to interpolate between the range start and range end. + * @param {number} rangeStart - The value to start from. + * @param {number} rangeEnd - The value to interpolate to. + * @returns {number} + */ +function lerp(frac, rangeStart, rangeEnd) { + return (1 - frac) * rangeStart + frac * rangeEnd; +} + +/** + * Make sure a value is clamped between a min and max value. + * + * @param {number} val - The value to clamp. + * @param {number} min - The minimum value. + * @param {number} max - The max value. + * @returns {number} + */ +function clamp(val, min, max) { + return Math.max(min, Math.min(max, val)); +} + +/** + * Formats a file size. + * @param {number} num - The number (in bytes) to format. + * @returns {string} e.g. "10 B", "100 MiB" + */ +function formatFileSize(num) { + if (!Number.isFinite(num)) { + throw new TypeError(`Expected a finite number, got ${typeof num}: ${num}`); + } + + const neg = num < 0; + + if (neg) { + num = -num; + } + + if (num < 1) { + return (neg ? "-" : "") + num + " B"; + } + + const exponent = Math.min( + Math.floor(Math.log2(num) / Math.log2(1024)), + UNITS.length - 1 + ); + const numStr = Number((num / Math.pow(1024, exponent)).toPrecision(3)); + const unit = UNITS[exponent]; + + return (neg ? "-" : "") + numStr + " " + unit; +} + +/** + * Creates numbers that increment linearly within a base 10 scale: + * 0.1, 0.2, 0.3, ..., 0.8, 0.9, 1, 2, 3, ..., 9, 10, 20, 30, etc. + * + * @param {number} rangeStart + * @param {number} rangeEnd + * + * @returns {ScaleFunctions} + */ +function makeLinear10Scale(rangeStart, rangeEnd) { + const start10 = Math.log10(rangeStart); + const end10 = Math.log10(rangeEnd); + + if (!Number.isInteger(start10)) { + throw new Error(`rangeStart is not a power of 10: ${rangeStart}`); + } + + if (!Number.isInteger(end10)) { + throw new Error(`rangeEnd is not a power of 10: ${rangeEnd}`); + } + + // Intervals are base 10 intervals: + // - [0.01 .. 0.09] + // - [0.1 .. 0.9] + // - [1 .. 9] + // - [10 .. 90] + const intervals = end10 - start10; + + // Note that there are only 9 steps per interval, not 10: + // 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9 + const STEP_PER_INTERVAL = 9; + + const steps = intervals * STEP_PER_INTERVAL; + + /** @type {NumberScaler} */ + const fromFractionToValue = frac => { + const step = Math.round(frac * steps); + const base = Math.floor(step / STEP_PER_INTERVAL); + const factor = (step % STEP_PER_INTERVAL) + 1; + return Math.pow(10, base) * factor * rangeStart; + }; + + /** @type {NumberScaler} */ + const fromValueToFraction = value => { + const interval = Math.floor(Math.log10(value / rangeStart)); + const base = rangeStart * Math.pow(10, interval); + return (interval * STEP_PER_INTERVAL + value / base - 1) / steps; + }; + + /** @type {NumberScaler} */ + const fromFractionToSingleDigitValue = frac => { + return +fromFractionToValue(frac).toPrecision(1); + }; + + return { + // Takes a number ranged 0-1 and returns it within the range. + fromFractionToValue, + // Takes a number in the range, and returns a value between 0-1 + fromValueToFraction, + // Takes a number ranged 0-1 and returns a value in the range, but with + // a single digit value. + fromFractionToSingleDigitValue, + // The number of steps available on this scale. + steps, + }; +} + +/** + * Creates numbers that scale exponentially as powers of 2. + * + * @param {number} rangeStart + * @param {number} rangeEnd + * + * @returns {ScaleFunctions} + */ +function makePowerOf2Scale(rangeStart, rangeEnd) { + const startExp = Math.log2(rangeStart); + const endExp = Math.log2(rangeEnd); + + if (!Number.isInteger(startExp)) { + throw new Error(`rangeStart is not a power of 2: ${rangeStart}`); + } + + if (!Number.isInteger(endExp)) { + throw new Error(`rangeEnd is not a power of 2: ${rangeEnd}`); + } + + const steps = endExp - startExp; + + /** @type {NumberScaler} */ + const fromFractionToValue = frac => + Math.pow(2, Math.round((1 - frac) * startExp + frac * endExp)); + + /** @type {NumberScaler} */ + const fromValueToFraction = value => + (Math.log2(value) - startExp) / (endExp - startExp); + + /** @type {NumberScaler} */ + const fromFractionToSingleDigitValue = frac => { + // fromFractionToValue returns an exact power of 2, we don't want to change + // its precision. Note that formatFileSize will display it in a nice binary + // unit with up to 3 digits. + return fromFractionToValue(frac); + }; + + return { + // Takes a number ranged 0-1 and returns it within the range. + fromFractionToValue, + // Takes a number in the range, and returns a value between 0-1 + fromValueToFraction, + // Takes a number ranged 0-1 and returns a value in the range, but with + // a single digit value. + fromFractionToSingleDigitValue, + // The number of steps available on this scale. + steps, + }; +} + +/** + * Scale a source range to a destination range, but clamp it within the + * destination range. + * @param {number} val - The source range value to map to the destination range, + * @param {number} sourceRangeStart, + * @param {number} sourceRangeEnd, + * @param {number} destRangeStart, + * @param {number} destRangeEnd + */ +function scaleRangeWithClamping( + val, + sourceRangeStart, + sourceRangeEnd, + destRangeStart, + destRangeEnd +) { + const frac = clamp( + (val - sourceRangeStart) / (sourceRangeEnd - sourceRangeStart), + 0, + 1 + ); + return lerp(frac, destRangeStart, destRangeEnd); +} + +/** + * Use some heuristics to guess at the overhead of the recording settings. + * + * TODO - Bug 1597383. The UI for this has been removed, but it needs to be reworked + * for new overhead calculations. Keep it for now in tree. + * + * @param {number} interval + * @param {number} bufferSize + * @param {string[]} features - List of the selected features. + */ +function calculateOverhead(interval, bufferSize, features) { + // NOT "nostacksampling" (double negative) means periodic sampling is on. + const periodicSampling = !features.includes("nostacksampling"); + const overheadFromSampling = periodicSampling + ? scaleRangeWithClamping( + Math.log(interval), + Math.log(0.05), + Math.log(1), + 1, + 0 + ) + + scaleRangeWithClamping( + Math.log(interval), + Math.log(1), + Math.log(100), + 0.1, + 0 + ) + : 0; + const overheadFromBuffersize = scaleRangeWithClamping( + Math.log(bufferSize), + Math.log(10), + Math.log(1000000), + 0, + 0.1 + ); + const overheadFromStackwalk = + features.includes("stackwalk") && periodicSampling ? 0.05 : 0; + const overheadFromJavaScript = + features.includes("js") && periodicSampling ? 0.05 : 0; + const overheadFromJSTracer = features.includes("jstracer") ? 0.05 : 0; + const overheadFromJSAllocations = features.includes("jsallocations") + ? 0.05 + : 0; + const overheadFromNativeAllocations = features.includes("nativeallocations") + ? 0.5 + : 0; + + return clamp( + overheadFromSampling + + overheadFromBuffersize + + overheadFromStackwalk + + overheadFromJavaScript + + overheadFromJSTracer + + overheadFromJSAllocations + + overheadFromNativeAllocations, + 0, + 1 + ); +} + +/** + * Given an array of absolute paths on the file system, return an array that + * doesn't contain the common prefix of the paths; in other words, if all paths + * share a common ancestor directory, cut off the path to that ancestor + * directory and only leave the path components that differ. + * This makes some lists look a little nicer. For example, this turns the list + * ["/Users/foo/code/obj-m-android-opt", "/Users/foo/code/obj-m-android-debug"] + * into the list ["obj-m-android-opt", "obj-m-android-debug"]. + * + * @param {string[]} pathArray The array of absolute paths. + * @returns {string[]} A new array with the described adjustment. + */ +function withCommonPathPrefixRemoved(pathArray) { + if (pathArray.length === 0) { + return []; + } + + const firstPath = pathArray[0]; + const isWin = /^[A-Za-z]:/.test(firstPath); + const firstWinDrive = getWinDrive(firstPath); + for (const path of pathArray) { + const winDrive = getWinDrive(path); + + if (!PathUtils.isAbsolute(path) || winDrive !== firstWinDrive) { + // We expect all paths to be absolute and on Windows we expect all + // paths to be on the same disk. If this is not the case return the + // original array. + return pathArray; + } + } + + // At this point we're either not on Windows or all paths are on the same + // Windows disk and all paths are absolute. + // Find the common prefix. Start by assuming the entire path except for the + // last folder is shared. + const splitPaths = pathArray.map(path => PathUtils.split(path)); + const [firstSplitPath, ...otherSplitPaths] = splitPaths; + const prefix = firstSplitPath.slice(0, -1); + for (const sp of otherSplitPaths) { + prefix.length = Math.min(prefix.length, sp.length - 1); + for (let i = 0; i < prefix.length; i++) { + if (prefix[i] !== sp[i]) { + prefix.length = i; + break; + } + } + } + if ( + prefix.length === 0 || + (prefix.length === 1 && (prefix[0] === firstWinDrive || prefix[0] === "/")) + ) { + // There is no shared prefix. + // We treat a prefix of ["/"] as "no prefix", too: Absolute paths on + // non-Windows start with a slash, so PathUtils.split(path) always returns + // an array whose first element is "/" on those platforms. + // Stripping off a prefix of ["/"] from the split paths would simply remove + // the leading slash from the un-split paths, which is not useful. + return pathArray; + } + + // Strip the common prefix from all paths. + return splitPaths.map(sp => { + return sp.slice(prefix.length).join(isWin ? "\\" : "/"); + }); +} + +/** + * This method has been copied from `ospath_win.jsm` as part of the migration + * from `OS.Path` to `PathUtils`. + * + * Return the windows drive name of a path, or |null| if the path does + * not contain a drive name. + * + * Drive name appear either as "DriveName:..." (the return drive + * name includes the ":") or "\\\\DriveName..." (the returned drive name + * includes "\\\\"). + * + * @param {string} path The path from which we are to return the Windows drive name. + * @returns {?string} Windows drive name e.g. "C:" or null if path is not a Windows path. + */ +function getWinDrive(path) { + if (path == null) { + throw new TypeError("path is invalid"); + } + + if (path.startsWith("\\\\")) { + // UNC path + if (path.length == 2) { + return null; + } + const index = path.indexOf("\\", 2); + if (index == -1) { + return path; + } + return path.slice(0, index); + } + // Non-UNC path + const index = path.indexOf(":"); + if (index <= 0) { + return null; + } + return path.slice(0, index + 1); +} + +class UnhandledCaseError extends Error { + /** + * @param {never} value - Check that + * @param {string} typeName - A friendly type name. + */ + constructor(value, typeName) { + super(`There was an unhandled case for "${typeName}": ${value}`); + this.name = "UnhandledCaseError"; + } +} + +/** + * @type {FeatureDescription[]} + */ +const featureDescriptions = [ + { + name: "Native Stacks", + value: "stackwalk", + title: + "Record native stacks (C++ and Rust). This is not available on all platforms.", + recommended: true, + disabledReason: "Native stack walking is not supported on this platform.", + }, + { + name: "JavaScript", + value: "js", + title: + "Record JavaScript stack information, and interleave it with native stacks.", + recommended: true, + }, + { + name: "CPU Utilization", + value: "cpu", + title: + "Record how much CPU has been used between samples by each profiled thread.", + recommended: true, + }, + { + name: "Java", + value: "java", + title: "Profile Java code", + disabledReason: "This feature is only available on Android.", + }, + { + name: "No Periodic Sampling", + value: "nostacksampling", + title: "Disable interval-based stack sampling", + }, + { + name: "Main Thread File IO", + value: "mainthreadio", + title: "Record main thread File I/O markers.", + }, + { + name: "Profiled Threads File IO", + value: "fileio", + title: "Record File I/O markers from only profiled threads.", + }, + { + name: "All File IO", + value: "fileioall", + title: + "Record File I/O markers from all threads, even unregistered threads.", + }, + { + name: "No Marker Stacks", + value: "nomarkerstacks", + title: "Do not capture stacks when recording markers, to reduce overhead.", + }, + { + name: "Sequential Styling", + value: "seqstyle", + title: "Disable parallel traversal in styling.", + }, + { + name: "Screenshots", + value: "screenshots", + title: "Record screenshots of all browser windows.", + }, + { + name: "JSTracer", + value: "jstracer", + title: "Trace JS engine", + experimental: true, + disabledReason: + "JS Tracer is currently disabled due to crashes. See Bug 1565788.", + }, + { + name: "IPC Messages", + value: "ipcmessages", + title: "Track IPC messages.", + }, + { + name: "JS Allocations", + value: "jsallocations", + title: "Track JavaScript allocations", + }, + { + name: "Native Allocations", + value: "nativeallocations", + title: "Track native allocations", + }, + { + name: "Audio Callback Tracing", + value: "audiocallbacktracing", + title: "Trace real-time audio callbacks.", + }, + { + name: "No Timer Resolution Change", + value: "notimerresolutionchange", + title: + "Do not enhance the timer resolution for sampling intervals < 10ms, to " + + "avoid affecting timer-sensitive code. Warning: Sampling interval may " + + "increase in some processes.", + disabledReason: "Windows only.", + }, + { + name: "CPU Utilization - All Threads", + value: "cpuallthreads", + title: + "Record how much CPU has been used between samples by ALL registered thread.", + experimental: true, + }, + { + name: "Periodic Sampling - All Threads", + value: "samplingallthreads", + title: "Capture stack samples in ALL registered thread.", + experimental: true, + }, + { + name: "Markers - All Threads", + value: "markersallthreads", + title: "Record markers in ALL registered threads.", + experimental: true, + }, + { + name: "Unregistered Threads", + value: "unregisteredthreads", + title: + "Periodically discover unregistered threads and record them and their " + + "CPU utilization as markers in the main thread -- Beware: expensive!", + experimental: true, + }, + { + name: "Process CPU Utilization", + value: "processcpu", + title: + "Record how much CPU has been used between samples by each process. " + + "To see graphs: When viewing the profile, open the JS console and run: " + + "experimental.enableProcessCPUTracks()", + experimental: true, + }, + { + name: "Power Use", + value: "power", + title: (() => { + switch (AppConstants.platform) { + case "win": + return ( + "Record the value of every energy meter available on the system with " + + "each sample. Only available on Windows 11 with Intel CPUs." + ); + case "linux": + return ( + "Record the power used by the entire system with each sample. " + + "Only available with Intel CPUs and requires setting the sysctl kernel.perf_event_paranoid to 0." + ); + case "macosx": + return "Record the power used by the entire system (Intel) or each process (Apple Silicon) with each sample."; + default: + return "Not supported on this platform."; + } + })(), + experimental: true, + }, +]; + +module.exports = { + formatFileSize, + makeLinear10Scale, + makePowerOf2Scale, + scaleRangeWithClamping, + calculateOverhead, + withCommonPathPrefixRemoved, + UnhandledCaseError, + featureDescriptions, +}; |