summaryrefslogtreecommitdiffstats
path: root/devtools/server/performance
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/performance')
-rw-r--r--devtools/server/performance/framerate.js105
-rw-r--r--devtools/server/performance/memory.js501
-rw-r--r--devtools/server/performance/moz.build16
-rw-r--r--devtools/server/performance/profiler.js604
-rw-r--r--devtools/server/performance/recorder.js529
-rw-r--r--devtools/server/performance/timeline.js375
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;