diff options
Diffstat (limited to 'devtools/server/performance')
-rw-r--r-- | devtools/server/performance/framerate.js | 105 | ||||
-rw-r--r-- | devtools/server/performance/memory.js | 501 | ||||
-rw-r--r-- | devtools/server/performance/moz.build | 16 | ||||
-rw-r--r-- | devtools/server/performance/profiler.js | 604 | ||||
-rw-r--r-- | devtools/server/performance/recorder.js | 529 | ||||
-rw-r--r-- | devtools/server/performance/timeline.js | 375 |
6 files changed, 2130 insertions, 0 deletions
diff --git a/devtools/server/performance/framerate.js b/devtools/server/performance/framerate.js new file mode 100644 index 0000000000..330c270c58 --- /dev/null +++ b/devtools/server/performance/framerate.js @@ -0,0 +1,105 @@ +/* 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/. */ +"use strict"; + +/** + * A very simple utility for monitoring framerate. Takes a `targetActor` + * and monitors framerate over time. The actor wrapper around this + * can be found at devtools/server/actors/framerate.js + */ +class Framerate { + constructor(targetActor) { + this.targetActor = targetActor; + this._contentWin = targetActor.window; + this._onRefreshDriverTick = this._onRefreshDriverTick.bind(this); + this._onGlobalCreated = this._onGlobalCreated.bind(this); + this.targetActor.on("window-ready", this._onGlobalCreated); + } + + destroy(conn) { + this.targetActor.off("window-ready", this._onGlobalCreated); + this.stopRecording(); + } + + /** + * Starts monitoring framerate, storing the frames per second. + */ + startRecording() { + if (this._recording) { + return; + } + this._recording = true; + this._ticks = []; + this._startTime = this.targetActor.docShell.now(); + this._rafID = this._contentWin.requestAnimationFrame( + this._onRefreshDriverTick + ); + } + + /** + * Stops monitoring framerate, returning the recorded values. + */ + stopRecording(beginAt = 0, endAt = Number.MAX_SAFE_INTEGER) { + if (!this._recording) { + return []; + } + const ticks = this.getPendingTicks(beginAt, endAt); + this.cancelRecording(); + return ticks; + } + + /** + * Stops monitoring framerate, without returning the recorded values. + */ + cancelRecording() { + this._contentWin.cancelAnimationFrame(this._rafID); + this._recording = false; + this._ticks = null; + this._rafID = -1; + } + + /** + * Returns whether this instance is currently recording. + */ + isRecording() { + return !!this._recording; + } + + /** + * Gets the refresh driver ticks recorded so far. + */ + getPendingTicks(beginAt = 0, endAt = Number.MAX_SAFE_INTEGER) { + if (!this._ticks) { + return []; + } + return this._ticks.filter(e => e >= beginAt && e <= endAt); + } + + /** + * Function invoked along with the refresh driver. + */ + _onRefreshDriverTick() { + if (!this._recording) { + return; + } + this._rafID = this._contentWin.requestAnimationFrame( + this._onRefreshDriverTick + ); + this._ticks.push(this.targetActor.docShell.now() - this._startTime); + } + + /** + * When the content window for the target actor is created. + */ + _onGlobalCreated(win) { + if (this._recording) { + this._contentWin.cancelAnimationFrame(this._rafID); + this._rafID = this._contentWin.requestAnimationFrame( + this._onRefreshDriverTick + ); + } + } +} + +exports.Framerate = Framerate; diff --git a/devtools/server/performance/memory.js b/devtools/server/performance/memory.js new file mode 100644 index 0000000000..c6e21e3dab --- /dev/null +++ b/devtools/server/performance/memory.js @@ -0,0 +1,501 @@ +/* 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/. */ + +"use strict"; + +const { Cc, Ci, Cu } = require("chrome"); +const { reportException } = require("devtools/shared/DevToolsUtils"); +const { expectState } = require("devtools/server/actors/common"); + +loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); +loader.lazyRequireGetter( + this, + "DeferredTask", + "resource://gre/modules/DeferredTask.jsm", + true +); +loader.lazyRequireGetter( + this, + "StackFrameCache", + "devtools/server/actors/utils/stack", + true +); +loader.lazyRequireGetter(this, "ChromeUtils"); +loader.lazyRequireGetter( + this, + "ParentProcessTargetActor", + "devtools/server/actors/targets/parent-process", + true +); +loader.lazyRequireGetter( + this, + "ContentProcessTargetActor", + "devtools/server/actors/targets/content-process", + true +); + +/** + * A class that returns memory data for a parent actor's window. + * Using a target-scoped actor with this instance will measure the memory footprint of its + * parent tab. Using a global-scoped actor instance however, will measure the memory + * footprint of the chrome window referenced by its root actor. + * + * To be consumed by actor's, like MemoryActor using this module to + * send information over RDP, and TimelineActor for using more light-weight + * utilities like GC events and measuring memory consumption. + */ +function Memory(parent, frameCache = new StackFrameCache()) { + EventEmitter.decorate(this); + + this.parent = parent; + this._mgr = Cc["@mozilla.org/memory-reporter-manager;1"].getService( + Ci.nsIMemoryReporterManager + ); + this.state = "detached"; + this._dbg = null; + this._frameCache = frameCache; + + this._onGarbageCollection = this._onGarbageCollection.bind(this); + this._emitAllocations = this._emitAllocations.bind(this); + this._onWindowReady = this._onWindowReady.bind(this); + + EventEmitter.on(this.parent, "window-ready", this._onWindowReady); +} + +Memory.prototype = { + destroy: function() { + EventEmitter.off(this.parent, "window-ready", this._onWindowReady); + + this._mgr = null; + if (this.state === "attached") { + this.detach(); + } + }, + + get dbg() { + if (!this._dbg) { + this._dbg = this.parent.makeDebugger(); + } + return this._dbg; + }, + + /** + * Attach to this MemoryBridge. + * + * This attaches the MemoryBridge's Debugger instance so that you can start + * recording allocations or take a census of the heap. In addition, the + * MemoryBridge will start emitting GC events. + */ + attach: expectState( + "detached", + function() { + this.dbg.addDebuggees(); + this.dbg.memory.onGarbageCollection = this._onGarbageCollection.bind( + this + ); + this.state = "attached"; + return this.state; + }, + "attaching to the debugger" + ), + + /** + * Detach from this MemoryBridge. + */ + detach: expectState( + "attached", + function() { + this._clearDebuggees(); + this.dbg.disable(); + this._dbg = null; + this.state = "detached"; + return this.state; + }, + "detaching from the debugger" + ), + + /** + * Gets the current MemoryBridge attach/detach state. + */ + getState: function() { + return this.state; + }, + + _clearDebuggees: function() { + if (this._dbg) { + if (this.isRecordingAllocations()) { + this.dbg.memory.drainAllocationsLog(); + } + this._clearFrames(); + this.dbg.removeAllDebuggees(); + } + }, + + _clearFrames: function() { + if (this.isRecordingAllocations()) { + this._frameCache.clearFrames(); + } + }, + + /** + * Handler for the parent actor's "window-ready" event. + */ + _onWindowReady: function({ isTopLevel }) { + if (this.state == "attached") { + this._clearDebuggees(); + if (isTopLevel && this.isRecordingAllocations()) { + this._frameCache.initFrames(); + } + this.dbg.addDebuggees(); + } + }, + + /** + * Returns a boolean indicating whether or not allocation + * sites are being tracked. + */ + isRecordingAllocations: function() { + return this.dbg.memory.trackingAllocationSites; + }, + + /** + * Save a heap snapshot scoped to the current debuggees' portion of the heap + * graph. + * + * @param {Object|null} boundaries + * + * @returns {String} The snapshot id. + */ + saveHeapSnapshot: expectState( + "attached", + function(boundaries = null) { + // If we are observing the whole process, then scope the snapshot + // accordingly. Otherwise, use the debugger's debuggees. + if (!boundaries) { + if ( + this.parent instanceof ParentProcessTargetActor || + this.parent instanceof ContentProcessTargetActor + ) { + boundaries = { runtime: true }; + } else { + boundaries = { debugger: this.dbg }; + } + } + return ChromeUtils.saveHeapSnapshotGetId(boundaries); + }, + "saveHeapSnapshot" + ), + + /** + * Take a census of the heap. See js/src/doc/Debugger/Debugger.Memory.md for + * more information. + */ + takeCensus: expectState( + "attached", + function() { + return this.dbg.memory.takeCensus(); + }, + "taking census" + ), + + /** + * Start recording allocation sites. + * + * @param {number} options.probability + * The probability we sample any given allocation when recording + * allocations. Must be between 0 and 1 -- defaults to 1. + * @param {number} options.maxLogLength + * The maximum number of allocation events to keep in the + * log. If new allocs occur while at capacity, oldest + * allocations are lost. Must fit in a 32 bit signed integer. + * @param {number} options.drainAllocationsTimeout + * A number in milliseconds of how often, at least, an `allocation` + * event gets emitted (and drained), and also emits and drains on every + * GC event, resetting the timer. + */ + startRecordingAllocations: expectState( + "attached", + function(options = {}) { + if (this.isRecordingAllocations()) { + return this._getCurrentTime(); + } + + this._frameCache.initFrames(); + + this.dbg.memory.allocationSamplingProbability = + options.probability != null ? options.probability : 1.0; + + this.drainAllocationsTimeoutTimer = options.drainAllocationsTimeout; + + if (this.drainAllocationsTimeoutTimer != null) { + if (this._poller) { + this._poller.disarm(); + } + this._poller = new DeferredTask( + this._emitAllocations, + this.drainAllocationsTimeoutTimer, + 0 + ); + this._poller.arm(); + } + + if (options.maxLogLength != null) { + this.dbg.memory.maxAllocationsLogLength = options.maxLogLength; + } + this.dbg.memory.trackingAllocationSites = true; + + return this._getCurrentTime(); + }, + "starting recording allocations" + ), + + /** + * Stop recording allocation sites. + */ + stopRecordingAllocations: expectState( + "attached", + function() { + if (!this.isRecordingAllocations()) { + return this._getCurrentTime(); + } + this.dbg.memory.trackingAllocationSites = false; + this._clearFrames(); + + if (this._poller) { + this._poller.disarm(); + this._poller = null; + } + + return this._getCurrentTime(); + }, + "stopping recording allocations" + ), + + /** + * Return settings used in `startRecordingAllocations` for `probability` + * and `maxLogLength`. Currently only uses in tests. + */ + getAllocationsSettings: expectState( + "attached", + function() { + return { + maxLogLength: this.dbg.memory.maxAllocationsLogLength, + probability: this.dbg.memory.allocationSamplingProbability, + }; + }, + "getting allocations settings" + ), + + /** + * Get a list of the most recent allocations since the last time we got + * allocations, as well as a summary of all allocations since we've been + * recording. + * + * @returns Object + * An object of the form: + * + * { + * allocations: [<index into "frames" below>, ...], + * allocationsTimestamps: [ + * <timestamp for allocations[0]>, + * <timestamp for allocations[1]>, + * ... + * ], + * allocationSizes: [ + * <bytesize for allocations[0]>, + * <bytesize for allocations[1]>, + * ... + * ], + * frames: [ + * { + * line: <line number for this frame>, + * column: <column number for this frame>, + * source: <filename string for this frame>, + * functionDisplayName: + * <this frame's inferred function name function or null>, + * parent: <index into "frames"> + * }, + * ... + * ], + * } + * + * The timestamps' unit is microseconds since the epoch. + * + * Subsequent `getAllocations` request within the same recording and + * tab navigation will always place the same stack frames at the same + * indices as previous `getAllocations` requests in the same + * recording. In other words, it is safe to use the index as a + * unique, persistent id for its frame. + * + * Additionally, the root node (null) is always at index 0. + * + * We use the indices into the "frames" array to avoid repeating the + * description of duplicate stack frames both when listing + * allocations, and when many stacks share the same tail of older + * frames. There shouldn't be any duplicates in the "frames" array, + * as that would defeat the purpose of this compression trick. + * + * In the future, we might want to split out a frame's "source" and + * "functionDisplayName" properties out the same way we have split + * frames out with the "frames" array. While this would further + * compress the size of the response packet, it would increase CPU + * usage to build the packet, and it should, of course, be guided by + * profiling and done only when necessary. + */ + getAllocations: expectState( + "attached", + function() { + if (this.dbg.memory.allocationsLogOverflowed) { + // Since the last time we drained the allocations log, there have been + // more allocations than the log's capacity, and we lost some data. There + // isn't anything actionable we can do about this, but put a message in + // the browser console so we at least know that it occurred. + reportException( + "MemoryBridge.prototype.getAllocations", + "Warning: allocations log overflowed and lost some data." + ); + } + + const allocations = this.dbg.memory.drainAllocationsLog(); + const packet = { + allocations: [], + allocationsTimestamps: [], + allocationSizes: [], + }; + for (const { frame: stack, timestamp, size } of allocations) { + if (stack && Cu.isDeadWrapper(stack)) { + continue; + } + + // Safe because SavedFrames are frozen/immutable. + const waived = Cu.waiveXrays(stack); + + // Ensure that we have a form, size, and index for new allocations + // because we potentially haven't seen some or all of them yet. After this + // loop, we can rely on the fact that every frame we deal with already has + // its metadata stored. + const index = this._frameCache.addFrame(waived); + + packet.allocations.push(index); + packet.allocationsTimestamps.push(timestamp); + packet.allocationSizes.push(size); + } + + return this._frameCache.updateFramePacket(packet); + }, + "getting allocations" + ), + + /* + * Force a browser-wide GC. + */ + forceGarbageCollection: function() { + for (let i = 0; i < 3; i++) { + Cu.forceGC(); + } + }, + + /** + * Force an XPCOM cycle collection. For more information on XPCOM cycle + * collection, see + * https://developer.mozilla.org/en-US/docs/Interfacing_with_the_XPCOM_cycle_collector#What_the_cycle_collector_does + */ + forceCycleCollection: function() { + Cu.forceCC(); + }, + + /** + * A method that returns a detailed breakdown of the memory consumption of the + * associated window. + * + * @returns object + */ + measure: function() { + const result = {}; + + const jsObjectsSize = {}; + const jsStringsSize = {}; + const jsOtherSize = {}; + const domSize = {}; + const styleSize = {}; + const otherSize = {}; + const totalSize = {}; + const jsMilliseconds = {}; + const nonJSMilliseconds = {}; + + try { + this._mgr.sizeOfTab( + this.parent.window, + jsObjectsSize, + jsStringsSize, + jsOtherSize, + domSize, + styleSize, + otherSize, + totalSize, + jsMilliseconds, + nonJSMilliseconds + ); + result.total = totalSize.value; + result.domSize = domSize.value; + result.styleSize = styleSize.value; + result.jsObjectsSize = jsObjectsSize.value; + result.jsStringsSize = jsStringsSize.value; + result.jsOtherSize = jsOtherSize.value; + result.otherSize = otherSize.value; + result.jsMilliseconds = jsMilliseconds.value.toFixed(1); + result.nonJSMilliseconds = nonJSMilliseconds.value.toFixed(1); + } catch (e) { + reportException("MemoryBridge.prototype.measure", e); + } + + return result; + }, + + residentUnique: function() { + return this._mgr.residentUnique; + }, + + /** + * Handler for GC events on the Debugger.Memory instance. + */ + _onGarbageCollection: function(data) { + this.emit("garbage-collection", data); + + // If `drainAllocationsTimeout` set, fire an allocations event with the drained log, + // which will restart the timer. + if (this._poller) { + this._poller.disarm(); + this._emitAllocations(); + } + }, + + /** + * Called on `drainAllocationsTimeoutTimer` interval if and only if set + * during `startRecordingAllocations`, or on a garbage collection event if + * drainAllocationsTimeout was set. + * Drains allocation log and emits as an event and restarts the timer. + */ + _emitAllocations: function() { + this.emit("allocations", this.getAllocations()); + this._poller.arm(); + }, + + /** + * Accesses the docshell to return the current process time. + */ + _getCurrentTime: function() { + const docShell = this.parent.isRootActor + ? this.parent.docShell + : this.parent.originalDocShell; + if (docShell) { + return docShell.now(); + } + // When used from the ContentProcessTargetActor, parent has no docShell, + // so fallback to Cu.now + return Cu.now(); + }, +}; + +exports.Memory = Memory; diff --git a/devtools/server/performance/moz.build b/devtools/server/performance/moz.build new file mode 100644 index 0000000000..ff3435b3d4 --- /dev/null +++ b/devtools/server/performance/moz.build @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# 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( + "framerate.js", + "memory.js", + "profiler.js", + "recorder.js", + "timeline.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Performance Tools (Profiler/Timeline)") diff --git a/devtools/server/performance/profiler.js b/devtools/server/performance/profiler.js new file mode 100644 index 0000000000..3d4d02e5df --- /dev/null +++ b/devtools/server/performance/profiler.js @@ -0,0 +1,604 @@ +/* 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/. */ +"use strict"; + +const { Cu } = require("chrome"); +const Services = require("Services"); + +loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); +loader.lazyRequireGetter( + this, + "DevToolsUtils", + "devtools/shared/DevToolsUtils" +); +loader.lazyRequireGetter( + this, + "DeferredTask", + "resource://gre/modules/DeferredTask.jsm", + true +); + +// Events piped from system observers to Profiler instances. +const PROFILER_SYSTEM_EVENTS = [ + "console-api-profiler", + "profiler-started", + "profiler-stopped", +]; + +// How often the "profiler-status" is emitted by default (in ms) +const BUFFER_STATUS_INTERVAL_DEFAULT = 5000; + +var DEFAULT_PROFILER_OPTIONS = { + // When using the DevTools Performance Tools, this will be overridden + // by the pref `devtools.performance.profiler.buffer-size`. + entries: Math.pow(10, 7), + // When using the DevTools Performance Tools, this will be overridden + // by the pref `devtools.performance.profiler.sample-frequency-hz`. + interval: 1, + features: ["js"], + threadFilters: ["GeckoMain"], +}; + +/** + * Main interface for interacting with nsIProfiler + */ +const ProfilerManager = (function() { + const consumers = new Set(); + + return { + // How often the "profiler-status" is emitted + _profilerStatusInterval: BUFFER_STATUS_INTERVAL_DEFAULT, + + // How many subscribers there + _profilerStatusSubscribers: 0, + + // Has the profiler ever been started by the actor? + started: false, + + /** + * The nsIProfiler is target agnostic and interacts with the whole platform. + * Therefore, special care needs to be given to make sure different profiler + * consumers (i.e. "toolboxes") don't interfere with each other. Register + * the profiler actor instances here. + * + * @param Profiler instance + * A profiler actor class. + */ + addInstance: function(instance) { + consumers.add(instance); + + // Lazily register events + this.registerEventListeners(); + }, + + /** + * Remove the profiler actor instances here. + * + * @param Profiler instance + * A profiler actor class. + */ + removeInstance: function(instance) { + consumers.delete(instance); + + if (this.length < 0) { + const msg = "Somehow the number of started profilers is now negative."; + DevToolsUtils.reportException("Profiler", msg); + } + + if (this.length === 0) { + this.unregisterEventListeners(); + this.stop(); + } + }, + + /** + * Starts the nsIProfiler module. Doing so will discard any samples + * that might have been accumulated so far. + * + * @param {number} entries [optional] + * @param {number} interval [optional] + * @param {Array<string>} features [optional] + * @param {Array<string>} threadFilters [description] + * + * @return {object} + */ + start: function(options = {}) { + const config = (this._profilerStartOptions = { + entries: options.entries || DEFAULT_PROFILER_OPTIONS.entries, + interval: options.interval || DEFAULT_PROFILER_OPTIONS.interval, + features: options.features || DEFAULT_PROFILER_OPTIONS.features, + threadFilters: + options.threadFilters || DEFAULT_PROFILER_OPTIONS.threadFilters, + }); + + // The start time should be before any samples we might be + // interested in. + const currentTime = Services.profiler.getElapsedTime(); + + try { + Services.profiler.StartProfiler( + config.entries, + config.interval, + config.features, + config.threadFilters + ); + } catch (e) { + // For some reason, the profiler couldn't be started. This could happen, + // for example, when in private browsing mode. + Cu.reportError(`Could not start the profiler module: ${e.message}`); + return { started: false, reason: e, currentTime }; + } + this.started = true; + + this._updateProfilerStatusPolling(); + + const { position, totalSize, generation } = this.getBufferInfo(); + return { started: true, position, totalSize, generation, currentTime }; + }, + + /** + * Attempts to stop the nsIProfiler module. + */ + stop: function() { + // Actually stop the profiler only if the last client has stopped profiling. + // Since this is used as a root actor, and the profiler module interacts + // with the whole platform, we need to avoid a case in which the profiler + // is stopped when there might be other clients still profiling. + // Also check for `started` to only stop the profiler when the actor + // actually started it. This is to prevent stopping the profiler initiated + // by some other code, like Talos. + if (this.length <= 1 && this.started) { + Services.profiler.StopProfiler(); + this.started = false; + } + this._updateProfilerStatusPolling(); + return { started: false }; + }, + + /** + * Returns all the samples accumulated since the profiler was started, + * along with the current time. The data has the following format: + * { + * libs: string, + * meta: { + * interval: number, + * platform: string, + * ... + * }, + * threads: [{ + * samples: [{ + * frames: [{ + * line: number, + * location: string, + * category: number + * } ... ], + * name: string + * responsiveness: number + * time: number + * } ... ] + * } ... ] + * } + * + * + * @param number startTime + * Since the circular buffer will only grow as long as the profiler lives, + * the buffer can contain unwanted samples. Pass in a `startTime` to only + * retrieve samples that took place after the `startTime`, with 0 being + * when the profiler just started. + * @param boolean stringify + * Whether or not the returned profile object should be a string or not to + * save JSON parse/stringify cycle if emitting over RDP. + */ + getProfile: function(options) { + const startTime = options.startTime || 0; + const profile = options.stringify + ? Services.profiler.GetProfile(startTime) + : Services.profiler.getProfileData(startTime); + + return { + profile: profile, + currentTime: Services.profiler.getElapsedTime(), + }; + }, + + /** + * Returns an array of feature strings, describing the profiler features + * that are available on this platform. Can be called while the profiler + * is stopped. + * + * @return {object} + */ + getFeatures: function() { + return { features: Services.profiler.GetFeatures() }; + }, + + /** + * Returns an object with the values of the current status of the + * circular buffer in the profiler, returning `position`, `totalSize`, + * and the current `generation` of the buffer. + * + * @return {object} + */ + getBufferInfo: function() { + const position = {}, + totalSize = {}, + generation = {}; + Services.profiler.GetBufferInfo(position, totalSize, generation); + return { + position: position.value, + totalSize: totalSize.value, + generation: generation.value, + }; + }, + + /** + * Returns the configuration used that was originally passed in to start up the + * profiler. Used for tests, and does not account for others using nsIProfiler. + * + * @param {object} + */ + getStartOptions: function() { + return this._profilerStartOptions || {}; + }, + + /** + * Verifies whether or not the nsIProfiler module has started. + * If already active, the current time is also returned. + * + * @return {object} + */ + isActive: function() { + const isActive = Services.profiler.IsActive(); + const elapsedTime = isActive + ? Services.profiler.getElapsedTime() + : undefined; + const { position, totalSize, generation } = this.getBufferInfo(); + return { + isActive, + currentTime: elapsedTime, + position, + totalSize, + generation, + }; + }, + + /** + * Returns an array of objects that describes the shared libraries + * which are currently loaded into our process. Can be called while the + * profiler is stopped. + */ + get sharedLibraries() { + return { + sharedLibraries: Services.profiler.sharedLibraries, + }; + }, + + /** + * Number of profiler instances. + * + * @return {number} + */ + get length() { + return consumers.size; + }, + + /** + * Callback for all observed notifications. + * @param object subject + * @param string topic + * @param object data + */ + observe: sanitizeHandler(function(subject, topic, data) { + let details; + + // An optional label may be specified when calling `console.profile`. + // If that's the case, stringify it and send it over with the response. + const { action, arguments: args } = subject || {}; + const profileLabel = args && args.length > 0 ? `${args[0]}` : void 0; + + // If the event was generated from `console.profile` or `console.profileEnd` + // we need to start the profiler right away and then just notify the client. + // Otherwise, we'll lose precious samples. + if ( + topic === "console-api-profiler" && + (action === "profile" || action === "profileEnd") + ) { + const { isActive, currentTime } = this.isActive(); + + // Start the profiler only if it wasn't already active. Otherwise, any + // samples that might have been accumulated so far will be discarded. + if (!isActive && action === "profile") { + this.start(); + details = { profileLabel, currentTime: 0 }; + } else if (!isActive) { + // Otherwise, if inactive and a call to profile end, do nothing + // and don't emit event. + return; + } + + // Otherwise, the profiler is already active, so just send + // to the front the current time, label, and the notification + // adds the action as well. + details = { profileLabel, currentTime }; + } + + // Propagate the event to the profiler instances that + // are subscribed to this event. + this.emitEvent(topic, { subject, topic, data, details }); + }, "ProfilerManager.observe"), + + /** + * Registers handlers for the following events to be emitted + * on active Profiler instances: + * - "console-api-profiler" + * - "profiler-started" + * - "profiler-stopped" + * - "profiler-status" + * + * The ProfilerManager listens to all events, and individual + * consumers filter which events they are interested in. + */ + registerEventListeners: function() { + if (!this._eventsRegistered) { + PROFILER_SYSTEM_EVENTS.forEach(eventName => + Services.obs.addObserver(this, eventName) + ); + this._eventsRegistered = true; + } + }, + + /** + * Unregisters handlers for all system events. + */ + unregisterEventListeners: function() { + if (this._eventsRegistered) { + PROFILER_SYSTEM_EVENTS.forEach(eventName => + Services.obs.removeObserver(this, eventName) + ); + this._eventsRegistered = false; + } + }, + + /** + * Takes an event name and additional data and emits them + * through each profiler instance that is subscribed to the event. + * + * @param {string} eventName + * @param {object} data + */ + emitEvent: function(eventName, data) { + const subscribers = Array.from(consumers).filter(c => { + return c.subscribedEvents.has(eventName); + }); + + for (const subscriber of subscribers) { + subscriber.emit(eventName, data); + } + }, + + /** + * Updates the frequency that the "profiler-status" event is emitted + * during recording. + * + * @param {number} interval + */ + setProfilerStatusInterval: function(interval) { + this._profilerStatusInterval = interval; + if (this._poller) { + this._poller._delayMs = interval; + } + }, + + subscribeToProfilerStatusEvents: function() { + this._profilerStatusSubscribers++; + this._updateProfilerStatusPolling(); + }, + + unsubscribeToProfilerStatusEvents: function() { + this._profilerStatusSubscribers--; + this._updateProfilerStatusPolling(); + }, + + /** + * Will enable or disable "profiler-status" events depending on + * if there are subscribers and if the profiler is current recording. + */ + _updateProfilerStatusPolling: function() { + if (this._profilerStatusSubscribers > 0 && Services.profiler.IsActive()) { + if (!this._poller) { + this._poller = new DeferredTask( + this._emitProfilerStatus.bind(this), + this._profilerStatusInterval, + 0 + ); + } + this._poller.arm(); + } else if (this._poller) { + // No subscribers; turn off if it exists. + this._poller.disarm(); + } + }, + + _emitProfilerStatus: function() { + this.emitEvent("profiler-status", this.isActive()); + this._poller.arm(); + }, + }; +})(); + +/** + * The profiler actor provides remote access to the built-in nsIProfiler module. + */ +class Profiler { + constructor() { + EventEmitter.decorate(this); + + this.subscribedEvents = new Set(); + ProfilerManager.addInstance(this); + } + + destroy() { + this.unregisterEventNotifications({ + events: Array.from(this.subscribedEvents), + }); + this.subscribedEvents = null; + + ProfilerManager.removeInstance(this); + } + + /** + * @see ProfilerManager.start + */ + start(options) { + return ProfilerManager.start(options); + } + + /** + * @see ProfilerManager.stop + */ + stop() { + return ProfilerManager.stop(); + } + + /** + * @see ProfilerManager.getProfile + */ + getProfile(request = {}) { + return ProfilerManager.getProfile(request); + } + + /** + * @see ProfilerManager.getFeatures + */ + getFeatures() { + return ProfilerManager.getFeatures(); + } + + /** + * @see ProfilerManager.getBufferInfo + */ + getBufferInfo() { + return ProfilerManager.getBufferInfo(); + } + + /** + * @see ProfilerManager.getStartOptions + */ + getStartOptions() { + return ProfilerManager.getStartOptions(); + } + + /** + * @see ProfilerManager.isActive + */ + isActive() { + return ProfilerManager.isActive(); + } + + /** + * @see ProfilerManager.sharedLibraries + */ + sharedLibraries() { + return ProfilerManager.sharedLibraries; + } + + /** + * @see ProfilerManager.setProfilerStatusInterval + */ + setProfilerStatusInterval(interval) { + return ProfilerManager.setProfilerStatusInterval(interval); + } + + /** + * Subscribes this instance to one of several events defined in + * an events array. + * - "console-api-profiler", + * - "profiler-started", + * - "profiler-stopped" + * - "profiler-status" + * + * @param {Array<string>} data.event + * @return {object} + */ + registerEventNotifications(data = {}) { + const response = []; + (data.events || []).forEach(e => { + if (!this.subscribedEvents.has(e)) { + if (e === "profiler-status") { + ProfilerManager.subscribeToProfilerStatusEvents(); + } + this.subscribedEvents.add(e); + response.push(e); + } + }); + return { registered: response }; + } + + /** + * Unsubscribes this instance to one of several events defined in + * an events array. + * + * @param {Array<string>} data.event + * @return {object} + */ + unregisterEventNotifications(data = {}) { + const response = []; + (data.events || []).forEach(e => { + if (this.subscribedEvents.has(e)) { + if (e === "profiler-status") { + ProfilerManager.unsubscribeToProfilerStatusEvents(); + } + this.subscribedEvents.delete(e); + response.push(e); + } + }); + return { registered: response }; + } + + /** + * Checks whether or not the profiler module can currently run. + * @return boolean + */ + static canProfile() { + return Services.profiler.CanProfile(); + } +} + +/** + * JSON.stringify callback used in Profiler.prototype.observe. + */ +function cycleBreaker(key, value) { + if (key == "wrappedJSObject") { + return undefined; + } + return value; +} + +/** + * Create JSON objects suitable for transportation across the RDP, + * by breaking cycles and making a copy of the `subject` and `data` via + * JSON.stringifying those values with a replacer that omits properties + * known to introduce cycles, and then JSON.parsing the result. + * This spends some CPU cycles, but it's simple. + * + * @TODO Also wraps it in a `makeInfallible` -- is this still necessary? + * + * @param {function} handler + * @return {function} + */ +function sanitizeHandler(handler, identifier) { + return DevToolsUtils.makeInfallible(function(subject, topic, data) { + subject = + (subject && !Cu.isXrayWrapper(subject) && subject.wrappedJSObject) || + subject; + subject = JSON.parse(JSON.stringify(subject, cycleBreaker)); + data = (data && !Cu.isXrayWrapper(data) && data.wrappedJSObject) || data; + data = JSON.parse(JSON.stringify(data, cycleBreaker)); + + // Pass in clean data to the underlying handler + return handler.call(this, subject, topic, data); + }, identifier); +} + +exports.Profiler = Profiler; diff --git a/devtools/server/performance/recorder.js b/devtools/server/performance/recorder.js new file mode 100644 index 0000000000..7429491e08 --- /dev/null +++ b/devtools/server/performance/recorder.js @@ -0,0 +1,529 @@ +/* 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/. */ +"use strict"; + +const { Cu } = require("chrome"); + +loader.lazyRequireGetter(this, "Services"); +loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); + +loader.lazyRequireGetter( + this, + "Memory", + "devtools/server/performance/memory", + true +); +loader.lazyRequireGetter( + this, + "Timeline", + "devtools/server/performance/timeline", + true +); +loader.lazyRequireGetter( + this, + "Profiler", + "devtools/server/performance/profiler", + true +); +loader.lazyRequireGetter( + this, + "PerformanceRecordingActor", + "devtools/server/actors/performance-recording", + true +); +loader.lazyRequireGetter( + this, + "mapRecordingOptions", + "devtools/shared/performance/recording-utils", + true +); +loader.lazyRequireGetter(this, "getSystemInfo", "devtools/shared/system", true); + +const PROFILER_EVENTS = [ + "console-api-profiler", + "profiler-started", + "profiler-stopped", + "profiler-status", +]; + +// Max time in milliseconds for the allocations event to occur, which will +// occur on every GC, or at least as often as DRAIN_ALLOCATIONS_TIMEOUT. +const DRAIN_ALLOCATIONS_TIMEOUT = 2000; + +/** + * A connection to underlying actors (profiler, memory, framerate, etc.) + * shared by all tools in a target. + * + * @param Target target + * The target owning this connection. + */ +function PerformanceRecorder(conn, targetActor) { + EventEmitter.decorate(this); + + this.conn = conn; + this.targetActor = targetActor; + + this._pendingConsoleRecordings = []; + this._recordings = []; + + this._onTimelineData = this._onTimelineData.bind(this); + this._onProfilerEvent = this._onProfilerEvent.bind(this); +} + +PerformanceRecorder.prototype = { + /** + * Initializes a connection to the profiler and other miscellaneous actors. + * If in the process of opening, or already open, nothing happens. + * + * @param {Object} options.systemClient + * Metadata about the client's system to attach to the recording models. + * + * @return object + * A promise that is resolved once the connection is established. + */ + connect: function(options) { + if (this._connected) { + return; + } + + // Sets `this._profiler`, `this._timeline` and `this._memory`. + // Only initialize the timeline and memory fronts if the respective actors + // are available. Older Gecko versions don't have existing implementations, + // in which case all the methods we need can be easily mocked. + this._connectComponents(); + this._registerListeners(); + + this._systemClient = options.systemClient; + + this._connected = true; + }, + + /** + * Destroys this connection. + */ + destroy: function() { + this._unregisterListeners(); + this._disconnectComponents(); + + this._connected = null; + this._profiler = null; + this._timeline = null; + this._memory = null; + this._target = null; + this._client = null; + }, + + /** + * Initializes fronts and connects to the underlying actors using the facades + * found in ./actors.js. + */ + _connectComponents: function() { + this._profiler = new Profiler(this.targetActor); + this._memory = new Memory(this.targetActor); + this._timeline = new Timeline(this.targetActor); + this._profiler.registerEventNotifications({ events: PROFILER_EVENTS }); + }, + + /** + * Registers listeners on events from the underlying + * actors, so the connection can handle them. + */ + _registerListeners: function() { + this._timeline.on("*", this._onTimelineData); + this._memory.on("*", this._onTimelineData); + this._profiler.on("*", this._onProfilerEvent); + }, + + /** + * Unregisters listeners on events on the underlying actors. + */ + _unregisterListeners: function() { + this._timeline.off("*", this._onTimelineData); + this._memory.off("*", this._onTimelineData); + this._profiler.off("*", this._onProfilerEvent); + }, + + /** + * Closes the connections to non-profiler actors. + */ + _disconnectComponents: function() { + this._profiler.unregisterEventNotifications({ events: PROFILER_EVENTS }); + this._profiler.destroy(); + this._timeline.destroy(); + this._memory.destroy(); + }, + + _onProfilerEvent: function(topic, data) { + if (topic === "console-api-profiler") { + if (data.subject.action === "profile") { + this._onConsoleProfileStart(data.details); + } else if (data.subject.action === "profileEnd") { + this._onConsoleProfileEnd(data.details); + } + } else if (topic === "profiler-stopped") { + // Some other API stopped the profiler. Ignore it. + } else if (topic === "profiler-status") { + this.emit("profiler-status", data); + } + }, + + /** + * Invoked whenever `console.profile` is called. + * + * @param string profileLabel + * The provided string argument if available; undefined otherwise. + * @param number currentTime + * The time (in milliseconds) when the call was made, relative to when + * the nsIProfiler module was started. + */ + async _onConsoleProfileStart({ profileLabel, currentTime }) { + const recordings = this._recordings; + + // Abort if a profile with this label already exists. + if (recordings.find(e => e.getLabel() === profileLabel)) { + return; + } + + // Immediately emit this so the client can start setting things up, + // expecting a recording very soon. + this.emit("console-profile-start"); + + await this.startRecording( + Object.assign({}, getPerformanceRecordingPrefs(), { + console: true, + label: profileLabel, + }) + ); + }, + + /** + * Invoked whenever `console.profileEnd` is called. + * + * @param string profileLabel + * The provided string argument if available; undefined otherwise. + * @param number currentTime + * The time (in milliseconds) when the call was made, relative to when + * the nsIProfiler module was started. + */ + async _onConsoleProfileEnd(data) { + // If no data, abort; can occur if profiler isn't running and we get a surprise + // call to console.profileEnd() + if (!data) { + return; + } + const { profileLabel } = data; + + const pending = this._recordings.filter( + r => r.isConsole() && r.isRecording() + ); + if (pending.length === 0) { + return; + } + + let model; + // Try to find the corresponding `console.profile` call if + // a label was used in profileEnd(). If no matches, abort. + if (profileLabel) { + model = pending.find(e => e.getLabel() === profileLabel); + } else { + // If no label supplied, pop off the most recent pending console recording + model = pending[pending.length - 1]; + } + + // If `profileEnd()` was called with a label, and there are no matching + // sessions, abort. + if (!model) { + Cu.reportError( + "console.profileEnd() called with label that does not match a recording." + ); + return; + } + + await this.stopRecording(model); + }, + + /** + * Called whenever there is timeline data of any of the following types: + * - markers + * - frames + * - memory + * - ticks + * - allocations + */ + _onTimelineData: function(eventName, ...data) { + let eventData = Object.create(null); + + switch (eventName) { + case "markers": { + eventData = { markers: data[0], endTime: data[1] }; + break; + } + case "ticks": { + eventData = { delta: data[0], timestamps: data[1] }; + break; + } + case "memory": { + eventData = { delta: data[0], measurement: data[1] }; + break; + } + case "frames": { + eventData = { delta: data[0], frames: data[1] }; + break; + } + case "allocations": { + eventData = data[0]; + break; + } + } + + // Filter by only recordings that are currently recording; + // TODO should filter by recordings that have realtimeMarkers enabled. + const activeRecordings = this._recordings.filter(r => r.isRecording()); + + if (activeRecordings.length) { + this.emit("timeline-data", eventName, eventData, activeRecordings); + } + }, + + /** + * Checks whether or not recording is currently supported. At the moment, + * this is only influenced by private browsing mode and the profiler. + */ + canCurrentlyRecord: function() { + let success = true; + const reasons = []; + + if (!Profiler.canProfile()) { + success = false; + reasons.push("profiler-unavailable"); + } + + // Check other factors that will affect the possibility of successfully + // starting a recording here. + + return { success, reasons }; + }, + + /** + * Begins a recording session + * + * @param boolean options.withMarkers + * @param boolean options.withTicks + * @param boolean options.withMemory + * @param boolean options.withAllocations + * @param boolean options.allocationsSampleProbability + * @param boolean options.allocationsMaxLogLength + * @param boolean options.bufferSize + * @param boolean options.sampleFrequency + * @param boolean options.console + * @param string options.label + * @param boolean options.realtimeMarkers + * @return object + * A promise that is resolved once recording has started. + */ + async startRecording(options) { + let timelineStart, memoryStart; + + const profilerStart = async function() { + const data = await this._profiler.isActive(); + if (data.isActive) { + return data; + } + const startData = await this._profiler.start( + mapRecordingOptions("profiler", options) + ); + + // If no current time is exposed from starting, set it to 0 -- this is an + // older Gecko that does not return its starting time, and uses an epoch based + // on the profiler's start time. + if (startData.currentTime == null) { + startData.currentTime = 0; + } + return startData; + }.bind(this)(); + + // Timeline will almost always be on if using the DevTools, but using component + // independently could result in no timeline. + if (options.withMarkers || options.withTicks || options.withMemory) { + timelineStart = this._timeline.start( + mapRecordingOptions("timeline", options) + ); + } + + if (options.withAllocations) { + if (this._memory.getState() === "detached") { + this._memory.attach(); + } + const recordingOptions = Object.assign( + mapRecordingOptions("memory", options), + { + drainAllocationsTimeout: DRAIN_ALLOCATIONS_TIMEOUT, + } + ); + memoryStart = this._memory.startRecordingAllocations(recordingOptions); + } + + const [ + profilerStartData, + timelineStartData, + memoryStartData, + ] = await Promise.all([profilerStart, timelineStart, memoryStart]); + + const data = Object.create(null); + // Filter out start times that are not actually used (0 or undefined), and + // find the earliest time since all sources use same epoch. + const startTimes = [ + profilerStartData.currentTime, + memoryStartData, + timelineStartData, + ].filter(Boolean); + data.startTime = Math.min(...startTimes); + data.position = profilerStartData.position; + data.generation = profilerStartData.generation; + data.totalSize = profilerStartData.totalSize; + + data.systemClient = this._systemClient; + data.systemHost = await getSystemInfo(); + + const model = new PerformanceRecordingActor(this.conn, options, data); + this._recordings.push(model); + + this.emit("recording-started", model); + return model; + }, + + /** + * Manually ends the recording session for the corresponding PerformanceRecording. + * + * @param PerformanceRecording model + * The corresponding PerformanceRecording that belongs to the recording + * session wished to stop. + * @return PerformanceRecording + * Returns the same model, populated with the profiling data. + */ + async stopRecording(model) { + // If model isn't in the Recorder's internal store, + // then do nothing, like if this was a console.profileEnd + // from a different target. + if (!this._recordings.includes(model)) { + return model; + } + + // Flag the recording as no longer recording, so that `model.isRecording()` + // is false. Do this before we fetch all the data, and then subsequently + // the recording can be considered "completed". + this.emit("recording-stopping", model); + + // Currently there are two ways profiles stop recording. Either manually in the + // performance tool, or via console.profileEnd. Once a recording is done, + // we want to deliver the model to the performance tool (either as a return + // from the PerformanceFront or via `console-profile-stop` event) and then + // remove it from the internal store. + // + // In the case where a console.profile is generated via the console (so the tools are + // open), we initialize the Performance tool so it can listen to those events. + this._recordings.splice(this._recordings.indexOf(model), 1); + + const startTime = model._startTime; + const profilerData = this._profiler.getProfile({ startTime }); + + // Only if there are no more sessions recording do we stop + // the underlying memory and timeline actors. If we're still recording, + // juse use Date.now() for the memory and timeline end times, as those + // are only used in tests. + if (!this.isRecording()) { + // Check to see if memory is recording, so we only stop recording + // if necessary (otherwise if the memory component is not attached, this will fail) + if (this._memory.isRecordingAllocations()) { + this._memory.stopRecordingAllocations(); + } + this._timeline.stop(); + } + + const recordingData = { + // Data available only at the end of a recording. + profile: profilerData.profile, + // End times for all the actors. + duration: profilerData.currentTime - startTime, + }; + + this.emit("recording-stopped", model, recordingData); + return model; + }, + + /** + * Checks all currently stored recording handles and returns a boolean + * if there is a session currently being recorded. + * + * @return Boolean + */ + isRecording: function() { + return this._recordings.some(h => h.isRecording()); + }, + + /** + * Returns all current recordings. + */ + getRecordings: function() { + return this._recordings; + }, + + /** + * Sets how often the "profiler-status" event should be emitted. + * Used in tests. + */ + setProfilerStatusInterval: function(n) { + this._profiler.setProfilerStatusInterval(n); + }, + + /** + * Returns the configurations set on underlying components, used in tests. + * Returns an object with `probability`, `maxLogLength` for allocations, and + * `features`, `threadFilters`, `entries` and `interval` for profiler. + * + * @return {object} + */ + getConfiguration: function() { + let allocationSettings = Object.create(null); + + if (this._memory.getState() === "attached") { + allocationSettings = this._memory.getAllocationsSettings(); + } + + return Object.assign( + {}, + allocationSettings, + this._profiler.getStartOptions() + ); + }, + + toString: () => "[object PerformanceRecorder]", +}; + +/** + * Creates an object of configurations based off of + * preferences for a PerformanceRecording. + */ +function getPerformanceRecordingPrefs() { + return { + withMarkers: true, + withMemory: Services.prefs.getBoolPref( + "devtools.performance.ui.enable-memory" + ), + withTicks: Services.prefs.getBoolPref( + "devtools.performance.ui.enable-framerate" + ), + withAllocations: Services.prefs.getBoolPref( + "devtools.performance.ui.enable-allocations" + ), + allocationsSampleProbability: +Services.prefs.getCharPref( + "devtools.performance.memory.sample-probability" + ), + allocationsMaxLogLength: Services.prefs.getIntPref( + "devtools.performance.memory.max-log-length" + ), + }; +} + +exports.PerformanceRecorder = PerformanceRecorder; diff --git a/devtools/server/performance/timeline.js b/devtools/server/performance/timeline.js new file mode 100644 index 0000000000..551e8339ae --- /dev/null +++ b/devtools/server/performance/timeline.js @@ -0,0 +1,375 @@ +/* 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/. */ +"use strict"; + +/** + * Many Gecko operations (painting, reflows, restyle, ...) can be tracked + * in real time. A marker is a representation of one operation. A marker + * has a name, start and end timestamps. Markers are stored in docShells. + * + * This module exposes this tracking mechanism. To use with devtools' RDP, + * use devtools/server/actors/timeline.js directly. + * + * To start/stop recording markers: + * timeline.start() + * timeline.stop() + * timeline.isRecording() + * + * When markers are available, an event is emitted: + * timeline.on("markers", function(markers) {...}) + */ + +const { Ci, Cu } = require("chrome"); + +// Be aggressive about lazy loading, as this will run on every +// toolbox startup +loader.lazyRequireGetter( + this, + "Memory", + "devtools/server/performance/memory", + true +); +loader.lazyRequireGetter( + this, + "Framerate", + "devtools/server/performance/framerate", + true +); +loader.lazyRequireGetter( + this, + "StackFrameCache", + "devtools/server/actors/utils/stack", + true +); +loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); + +// How often do we pull markers from the docShells, and therefore, how often do +// we send events to the front (knowing that when there are no markers in the +// docShell, no event is sent). In milliseconds. +const DEFAULT_TIMELINE_DATA_PULL_TIMEOUT = 200; + +/** + * The timeline actor pops and forwards timeline markers registered in docshells. + */ +function Timeline(targetActor) { + EventEmitter.decorate(this); + + this.targetActor = targetActor; + + this._isRecording = false; + this._stackFrames = null; + this._memory = null; + this._framerate = null; + + // Make sure to get markers from new windows as they become available + this._onWindowReady = this._onWindowReady.bind(this); + this._onGarbageCollection = this._onGarbageCollection.bind(this); + this.targetActor.on("window-ready", this._onWindowReady); +} + +Timeline.prototype = { + /** + * Destroys this actor, stopping recording first. + */ + destroy: function() { + this.stop(); + + this.targetActor.off("window-ready", this._onWindowReady); + this.targetActor = null; + }, + + /** + * Get the list of docShells in the currently attached targetActor. Note that + * we always list the docShells included in the real root docShell, even if + * the targetActor was switched to a child frame. This is because for now, + * paint markers are only recorded at parent frame level so switching the + * timeline to a child frame would hide all paint markers. + * See https://bugzilla.mozilla.org/show_bug.cgi?id=1050773#c14 + * @return {Array} + */ + get docShells() { + let originalDocShell; + + if (this.targetActor.isRootActor) { + originalDocShell = this.targetActor.docShell; + } else { + originalDocShell = this.targetActor.originalDocShell; + } + + if (!originalDocShell) { + return []; + } + + const docShells = originalDocShell.getAllDocShellsInSubtree( + Ci.nsIDocShellTreeItem.typeAll, + Ci.nsIDocShell.ENUMERATE_FORWARDS + ); + + return docShells; + }, + + /** + * At regular intervals, pop the markers from the docshell, and forward + * markers, memory, tick and frames events, if any. + */ + _pullTimelineData: function() { + const docShells = this.docShells; + if (!this._isRecording || !docShells.length) { + return; + } + + const endTime = docShells[0].now(); + const markers = []; + + // Gather markers if requested. + if (this._withMarkers || this._withDocLoadingEvents) { + for (const docShell of docShells) { + for (const marker of docShell.popProfileTimelineMarkers()) { + markers.push(marker); + + // The docshell may return markers with stack traces attached. + // Here we transform the stack traces via the stack frame cache, + // which lets us preserve tail sharing when transferring the + // frames to the client. We must waive xrays here because Firefox + // doesn't understand that the Debugger.Frame object is safe to + // use from chrome. See Tutorial-Alloc-Log-Tree.md. + if (this._withFrames) { + if (marker.stack) { + marker.stack = this._stackFrames.addFrame( + Cu.waiveXrays(marker.stack) + ); + } + if (marker.endStack) { + marker.endStack = this._stackFrames.addFrame( + Cu.waiveXrays(marker.endStack) + ); + } + } + + // Emit some helper events for "DOMContentLoaded" and "Load" markers. + if (this._withDocLoadingEvents) { + if ( + marker.name == "document::DOMContentLoaded" || + marker.name == "document::Load" + ) { + this.emit("doc-loading", marker, endTime); + } + } + } + } + } + + // Emit markers if requested. + if (this._withMarkers && markers.length > 0) { + this.emit("markers", markers, endTime); + } + + // Emit framerate data if requested. + if (this._withTicks) { + this.emit("ticks", endTime, this._framerate.getPendingTicks()); + } + + // Emit memory data if requested. + if (this._withMemory) { + this.emit("memory", endTime, this._memory.measure()); + } + + // Emit stack frames data if requested. + if (this._withFrames && this._withMarkers) { + const frames = this._stackFrames.makeEvent(); + if (frames) { + this.emit("frames", endTime, frames); + } + } + + this._dataPullTimeout = setTimeout(() => { + this._pullTimelineData(); + }, DEFAULT_TIMELINE_DATA_PULL_TIMEOUT); + }, + + /** + * Are we recording profile markers currently? + */ + isRecording: function() { + return this._isRecording; + }, + + /** + * Start recording profile markers. + * + * @option {boolean} withMarkers + * Boolean indicating whether or not timeline markers are emitted + * once they're accumulated every `DEFAULT_TIMELINE_DATA_PULL_TIMEOUT` + * milliseconds. + * @option {boolean} withTicks + * Boolean indicating whether a `ticks` event is fired and a + * FramerateActor is created. + * @option {boolean} withMemory + * Boolean indiciating whether we want memory measurements sampled. + * @option {boolean} withFrames + * Boolean indicating whether or not stack frames should be handled + * from timeline markers. + * @option {boolean} withGCEvents + * Boolean indicating whether or not GC markers should be emitted. + * TODO: Remove these fake GC markers altogether in bug 1198127. + * @option {boolean} withDocLoadingEvents + * Boolean indicating whether or not DOMContentLoaded and Load + * marker events are emitted. + */ + async start({ + withMarkers, + withTicks, + withMemory, + withFrames, + withGCEvents, + withDocLoadingEvents, + }) { + const docShells = this.docShells; + if (!docShells.length) { + return -1; + } + const startTime = (this._startTime = docShells[0].now()); + if (this._isRecording) { + return startTime; + } + + this._isRecording = true; + this._withMarkers = !!withMarkers; + this._withTicks = !!withTicks; + this._withMemory = !!withMemory; + this._withFrames = !!withFrames; + this._withGCEvents = !!withGCEvents; + this._withDocLoadingEvents = !!withDocLoadingEvents; + + if (this._withMarkers || this._withDocLoadingEvents) { + for (const docShell of docShells) { + docShell.recordProfileTimelineMarkers = true; + } + } + + if (this._withTicks) { + this._framerate = new Framerate(this.targetActor); + this._framerate.startRecording(); + } + + if (this._withMemory || this._withGCEvents) { + this._memory = new Memory(this.targetActor, this._stackFrames); + this._memory.attach(); + } + + if (this._withGCEvents) { + this._memory.on("garbage-collection", this._onGarbageCollection); + } + + if (this._withFrames && this._withMarkers) { + this._stackFrames = new StackFrameCache(); + this._stackFrames.initFrames(); + } + + this._pullTimelineData(); + return startTime; + }, + + /** + * Stop recording profile markers. + */ + async stop() { + const docShells = this.docShells; + if (!docShells.length) { + return -1; + } + const endTime = (this._startTime = docShells[0].now()); + if (!this._isRecording) { + return endTime; + } + + if (this._withMarkers || this._withDocLoadingEvents) { + for (const docShell of docShells) { + docShell.recordProfileTimelineMarkers = false; + } + } + + if (this._withTicks) { + this._framerate.stopRecording(); + this._framerate.destroy(); + this._framerate = null; + } + + if (this._withMemory || this._withGCEvents) { + this._memory.detach(); + this._memory.destroy(); + } + + if (this._withGCEvents) { + this._memory.off("garbage-collection", this._onGarbageCollection); + } + + if (this._withFrames && this._withMarkers) { + this._stackFrames = null; + } + + this._isRecording = false; + this._withMarkers = false; + this._withTicks = false; + this._withMemory = false; + this._withFrames = false; + this._withDocLoadingEvents = false; + this._withGCEvents = false; + + clearTimeout(this._dataPullTimeout); + + return endTime; + }, + + /** + * When a new window becomes available in the targetActor, start recording its + * markers if we were recording. + */ + _onWindowReady: function({ window }) { + if (this._isRecording) { + const docShell = window.docShell; + docShell.recordProfileTimelineMarkers = true; + } + }, + + /** + * Fired when the Memory component emits a `garbage-collection` event. Used to + * take the data and make it look like the rest of our markers. + * + * A GC "marker" here represents a full GC cycle, which may contain several incremental + * events within its `collection` array. The marker contains a `reason` field, + * indicating why there was a GC, and may contain a `nonincrementalReason` when + * SpiderMonkey could not incrementally collect garbage. + */ + _onGarbageCollection: function({ + collections, + gcCycleNumber, + reason, + nonincrementalReason, + }) { + const docShells = this.docShells; + if (!this._isRecording || !docShells.length) { + return; + } + + const endTime = docShells[0].now(); + + this.emit( + "markers", + collections.map(({ startTimestamp: start, endTimestamp: end }) => { + return { + name: "GarbageCollection", + causeName: reason, + nonincrementalReason: nonincrementalReason, + cycle: gcCycleNumber, + start, + end, + }; + }), + endTime + ); + }, +}; + +exports.Timeline = Timeline; |