diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /devtools/server/performance | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/server/performance')
-rw-r--r-- | devtools/server/performance/framerate.js | 99 | ||||
-rw-r--r-- | devtools/server/performance/memory.js | 425 | ||||
-rw-r--r-- | devtools/server/performance/moz.build | 13 | ||||
-rw-r--r-- | devtools/server/performance/profiler.js | 546 | ||||
-rw-r--r-- | devtools/server/performance/recorder.js | 494 | ||||
-rw-r--r-- | devtools/server/performance/timeline.js | 356 |
6 files changed, 1933 insertions, 0 deletions
diff --git a/devtools/server/performance/framerate.js b/devtools/server/performance/framerate.js new file mode 100644 index 000000000..24f8a7a6b --- /dev/null +++ b/devtools/server/performance/framerate.js @@ -0,0 +1,99 @@ +/* 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 { on, once, off, emit } = require("sdk/event/core"); +const { Class } = require("sdk/core/heritage"); + +/** + * A very simple utility for monitoring framerate. Takes a `tabActor` + * and monitors framerate over time. The actor wrapper around this + * can be found at devtools/server/actors/framerate.js + */ +var Framerate = exports.Framerate = Class({ + initialize: function (tabActor) { + this.tabActor = tabActor; + this._contentWin = tabActor.window; + this._onRefreshDriverTick = this._onRefreshDriverTick.bind(this); + this._onGlobalCreated = this._onGlobalCreated.bind(this); + on(this.tabActor, "window-ready", this._onGlobalCreated); + }, + destroy: function (conn) { + off(this.tabActor, "window-ready", this._onGlobalCreated); + this.stopRecording(); + }, + + /** + * Starts monitoring framerate, storing the frames per second. + */ + startRecording: function () { + if (this._recording) { + return; + } + this._recording = true; + this._ticks = []; + this._startTime = this.tabActor.docShell.now(); + this._rafID = this._contentWin.requestAnimationFrame(this._onRefreshDriverTick); + }, + + /** + * Stops monitoring framerate, returning the recorded values. + */ + stopRecording: function (beginAt = 0, endAt = Number.MAX_SAFE_INTEGER) { + if (!this._recording) { + return []; + } + let ticks = this.getPendingTicks(beginAt, endAt); + this.cancelRecording(); + return ticks; + }, + + /** + * Stops monitoring framerate, without returning the recorded values. + */ + cancelRecording: function () { + this._contentWin.cancelAnimationFrame(this._rafID); + this._recording = false; + this._ticks = null; + this._rafID = -1; + }, + + /** + * Returns whether this instance is currently recording. + */ + isRecording: function () { + return !!this._recording; + }, + + /** + * Gets the refresh driver ticks recorded so far. + */ + getPendingTicks: function (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: function () { + if (!this._recording) { + return; + } + this._rafID = this._contentWin.requestAnimationFrame(this._onRefreshDriverTick); + this._ticks.push(this.tabActor.docShell.now() - this._startTime); + }, + + /** + * When the content window for the tab actor is created. + */ + _onGlobalCreated: function (win) { + if (this._recording) { + this._contentWin.cancelAnimationFrame(this._rafID); + this._rafID = this._contentWin.requestAnimationFrame(this._onRefreshDriverTick); + } + } +}); diff --git a/devtools/server/performance/memory.js b/devtools/server/performance/memory.js new file mode 100644 index 000000000..77ce348cc --- /dev/null +++ b/devtools/server/performance/memory.js @@ -0,0 +1,425 @@ +/* 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 { Class } = require("sdk/core/heritage"); +const { expectState } = require("devtools/server/actors/common"); +loader.lazyRequireGetter(this, "events", "sdk/event/core"); +loader.lazyRequireGetter(this, "EventTarget", "sdk/event/target", true); +loader.lazyRequireGetter(this, "DeferredTask", + "resource://gre/modules/DeferredTask.jsm", true); +loader.lazyRequireGetter(this, "StackFrameCache", + "devtools/server/actors/utils/stack", true); +loader.lazyRequireGetter(this, "ThreadSafeChromeUtils"); +loader.lazyRequireGetter(this, "HeapSnapshotFileUtils", + "devtools/shared/heapsnapshot/HeapSnapshotFileUtils"); +loader.lazyRequireGetter(this, "ChromeActor", "devtools/server/actors/chrome", + true); +loader.lazyRequireGetter(this, "ChildProcessActor", + "devtools/server/actors/child-process", true); + +/** + * A class that returns memory data for a parent actor's window. + * Using a tab-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. + */ +var Memory = exports.Memory = Class({ + extends: EventTarget, + + /** + * Requires a root actor and a StackFrameCache. + */ + initialize: function (parent, frameCache = new StackFrameCache()) { + 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); + + events.on(this.parent, "window-ready", this._onWindowReady); + }, + + destroy: function () { + events.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"; + }, "attaching to the debugger"), + + /** + * Detach from this MemoryBridge. + */ + detach: expectState("attached", function () { + this._clearDebuggees(); + this.dbg.enabled = false; + this._dbg = null; + this.state = "detached"; + }, "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) { + boundaries = this.parent instanceof ChromeActor || this.parent instanceof ChildProcessActor + ? { runtime: true } + : { debugger: this.dbg }; + } + const path = ThreadSafeChromeUtils.saveHeapSnapshot(boundaries); + return HeapSnapshotFileUtils.getSnapshotIdFromPath(path); + }, "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 = typeof options.drainAllocationsTimeout === "number" ? options.drainAllocationsTimeout : null; + + if (this.drainAllocationsTimeoutTimer != null) { + if (this._poller) { + this._poller.disarm(); + } + this._poller = new DeferredTask(this._emitAllocations, this.drainAllocationsTimeoutTimer); + 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 (let { frame: stack, timestamp, size } of allocations) { + if (stack && Cu.isDeadWrapper(stack)) { + continue; + } + + // Safe because SavedFrames are frozen/immutable. + let 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. + let 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 () { + let result = {}; + + let jsObjectsSize = {}; + let jsStringsSize = {}; + let jsOtherSize = {}; + let domSize = {}; + let styleSize = {}; + let otherSize = {}; + let totalSize = {}; + let jsMilliseconds = {}; + let 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) { + events.emit(this, "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 () { + events.emit(this, "allocations", this.getAllocations()); + this._poller.arm(); + }, + + /** + * Accesses the docshell to return the current process time. + */ + _getCurrentTime: function () { + return (this.parent.isRootActor ? this.parent.docShell : this.parent.originalDocShell).now(); + }, + +}); diff --git a/devtools/server/performance/moz.build b/devtools/server/performance/moz.build new file mode 100644 index 000000000..e7b1ed00c --- /dev/null +++ b/devtools/server/performance/moz.build @@ -0,0 +1,13 @@ +# -*- 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', +) diff --git a/devtools/server/performance/profiler.js b/devtools/server/performance/profiler.js new file mode 100644 index 000000000..700d48147 --- /dev/null +++ b/devtools/server/performance/profiler.js @@ -0,0 +1,546 @@ +/* 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 Services = require("Services"); +const { Class } = require("sdk/core/heritage"); +loader.lazyRequireGetter(this, "events", "sdk/event/core"); +loader.lazyRequireGetter(this, "EventTarget", "sdk/event/target", true); +loader.lazyRequireGetter(this, "DevToolsUtils", "devtools/shared/DevToolsUtils"); +loader.lazyRequireGetter(this, "DeferredTask", "resource://gre/modules/DeferredTask.jsm", true); +loader.lazyRequireGetter(this, "Task", "devtools/shared/task", 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 +const BUFFER_STATUS_INTERVAL_DEFAULT = 5000; // ms + +loader.lazyGetter(this, "nsIProfilerModule", () => { + return Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler); +}); + +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-rate-khz`. + interval: 1, + features: ["js"], + threadFilters: ["GeckoMain"] +}; + +/** + * Main interface for interacting with nsIProfiler + */ +const ProfilerManager = (function () { + let consumers = new Set(); + + return { + + // How often the "profiler-status" is emitted + _profilerStatusInterval: BUFFER_STATUS_INTERVAL_DEFAULT, + + // How many subscribers there + _profilerStatusSubscribers: 0, + + /** + * 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) { + let 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 = {}) { + let 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. + let currentTime = nsIProfilerModule.getElapsedTime(); + + try { + nsIProfilerModule.StartProfiler( + config.entries, + config.interval, + config.features, + config.features.length, + config.threadFilters, + config.threadFilters.length + ); + } 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._updateProfilerStatusPolling(); + + let { 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. + if (this.length <= 1) { + nsIProfilerModule.StopProfiler(); + } + 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) { + let startTime = options.startTime || 0; + let profile = options.stringify ? + nsIProfilerModule.GetProfile(startTime) : + nsIProfilerModule.getProfileData(startTime); + + return { profile: profile, currentTime: nsIProfilerModule.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: nsIProfilerModule.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 () { + let position = {}, totalSize = {}, generation = {}; + nsIProfilerModule.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 () { + let isActive = nsIProfilerModule.IsActive(); + let elapsedTime = isActive ? nsIProfilerModule.getElapsedTime() : undefined; + let { position, totalSize, generation } = this.getBufferInfo(); + return { isActive: isActive, currentTime: elapsedTime, position, totalSize, generation }; + }, + + /** + * Returns a stringified JSON object that describes the shared libraries + * which are currently loaded into our process. Can be called while the + * profiler is stopped. + */ + getSharedLibraryInformation: function () { + return { sharedLibraryInformation: nsIProfilerModule.getSharedLibraryInformation() }; + }, + + /** + * 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. + let { action, arguments: args } = subject || {}; + let 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")) { + let { 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 }; + } + // Otherwise, if inactive and a call to profile end, do nothing + // and don't emit event. + else if (!isActive) { + 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, false)); + 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) { + let subscribers = Array.from(consumers).filter(c => c.subscribedEvents.has(eventName)); + + for (let subscriber of subscribers) { + events.emit(subscriber, 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 && nsIProfilerModule.IsActive()) { + if (!this._poller) { + this._poller = new DeferredTask(this._emitProfilerStatus.bind(this), this._profilerStatusInterval); + } + this._poller.arm(); + } + // No subscribers; turn off if it exists. + else if (this._poller) { + 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. + */ +var Profiler = exports.Profiler = Class({ + extends: EventTarget, + + initialize: function () { + this.subscribedEvents = new Set(); + ProfilerManager.addInstance(this); + }, + + destroy: function () { + this.unregisterEventNotifications({ events: Array.from(this.subscribedEvents) }); + this.subscribedEvents = null; + ProfilerManager.removeInstance(this); + }, + + /** + * @see ProfilerManager.start + */ + start: function (options) { return ProfilerManager.start(options); }, + + /** + * @see ProfilerManager.stop + */ + stop: function () { return ProfilerManager.stop(); }, + + /** + * @see ProfilerManager.getProfile + */ + getProfile: function (request = {}) { return ProfilerManager.getProfile(request); }, + + /** + * @see ProfilerManager.getFeatures + */ + getFeatures: function () { return ProfilerManager.getFeatures(); }, + + /** + * @see ProfilerManager.getBufferInfo + */ + getBufferInfo: function () { return ProfilerManager.getBufferInfo(); }, + + /** + * @see ProfilerManager.getStartOptions + */ + getStartOptions: function () { return ProfilerManager.getStartOptions(); }, + + /** + * @see ProfilerManager.isActive + */ + isActive: function () { return ProfilerManager.isActive(); }, + + /** + * @see ProfilerManager.isActive + */ + getSharedLibraryInformation: function () { return ProfilerManager.getSharedLibraryInformation(); }, + + /** + * @see ProfilerManager.setProfilerStatusInterval + */ + setProfilerStatusInterval: function (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: function (data = {}) { + let 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: function (data = {}) { + let 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 + */ +Profiler.canProfile = function () { + return nsIProfilerModule.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); +} diff --git a/devtools/server/performance/recorder.js b/devtools/server/performance/recorder.js new file mode 100644 index 000000000..fda61ca99 --- /dev/null +++ b/devtools/server/performance/recorder.js @@ -0,0 +1,494 @@ +/* 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, Cr } = require("chrome"); +const { Task } = require("devtools/shared/task"); + +loader.lazyRequireGetter(this, "Services"); +loader.lazyRequireGetter(this, "promise"); +loader.lazyRequireGetter(this, "extend", + "sdk/util/object", true); +loader.lazyRequireGetter(this, "Class", + "sdk/core/heritage", true); +loader.lazyRequireGetter(this, "EventTarget", + "sdk/event/target", true); +loader.lazyRequireGetter(this, "events", + "sdk/event/core"); + +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, "PerformanceRecordingFront", + "devtools/server/actors/performance-recording", true); +loader.lazyRequireGetter(this, "mapRecordingOptions", + "devtools/shared/performance/recording-utils", true); +loader.lazyRequireGetter(this, "DevToolsUtils", + "devtools/shared/DevToolsUtils"); +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. + */ +const PerformanceRecorder = exports.PerformanceRecorder = Class({ + extends: EventTarget, + + initialize: function (conn, tabActor) { + this.conn = conn; + this.tabActor = tabActor; + + this._pendingConsoleRecordings = []; + this._recordings = []; + + this._onTimelineData = this._onTimelineData.bind(this); + this._onProfilerEvent = this._onProfilerEvent.bind(this); + }, + + /** + * 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.tabActor); + this._memory = new Memory(this.tabActor); + this._timeline = new Timeline(this.tabActor); + 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") { + this._onProfilerUnexpectedlyStopped(); + } else if (topic === "profiler-status") { + events.emit(this, "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. + */ + _onConsoleProfileStart: Task.async(function* ({ profileLabel, currentTime: startTime }) { + let 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. + events.emit(this, "console-profile-start"); + + let model = yield this.startRecording(extend({}, 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. + */ + _onConsoleProfileEnd: Task.async(function* (data) { + // If no data, abort; can occur if profiler isn't running and we get a surprise + // call to console.profileEnd() + if (!data) { + return; + } + let { profileLabel, currentTime: endTime } = data; + + let 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); + } + // If no label supplied, pop off the most recent pending console recording + else { + 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; + } + + yield this.stopRecording(model); + }), + + /** + * TODO handle bug 1144438 + */ + _onProfilerUnexpectedlyStopped: function () { + Cu.reportError("Profiler unexpectedly stopped.", arguments); + }, + + /** + * 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. + let activeRecordings = this._recordings.filter(r => r.isRecording()); + + if (activeRecordings.length) { + events.emit(this, "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; + let 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. + */ + startRecording: Task.async(function* (options) { + let profilerStart, timelineStart, memoryStart; + + profilerStart = Task.spawn(function* () { + let data = yield this._profiler.isActive(); + if (data.isActive) { + return data; + } + let startData = yield 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(); + } + memoryStart = this._memory.startRecordingAllocations(extend(mapRecordingOptions("memory", options), { + drainAllocationsTimeout: DRAIN_ALLOCATIONS_TIMEOUT + })); + } + + let [profilerStartData, timelineStartData, memoryStartData] = yield promise.all([ + profilerStart, timelineStart, memoryStart + ]); + + let 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. + let 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 = yield getSystemInfo(); + + let model = new PerformanceRecordingActor(this.conn, options, data); + this._recordings.push(model); + + events.emit(this, "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. + */ + stopRecording: Task.async(function* (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.indexOf(model) === -1) { + 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". + let endTime = Date.now(); + events.emit(this, "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); + + let startTime = model._startTime; + let 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(); + } + + let recordingData = { + // Data available only at the end of a recording. + profile: profilerData.profile, + // End times for all the actors. + duration: profilerData.currentTime - startTime, + }; + + events.emit(this, "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 extend({}, 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") + }; +} diff --git a/devtools/server/performance/timeline.js b/devtools/server/performance/timeline.js new file mode 100644 index 000000000..3d7a90811 --- /dev/null +++ b/devtools/server/performance/timeline.js @@ -0,0 +1,356 @@ +/* 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"); +const { Class } = require("sdk/core/heritage"); +// Be aggressive about lazy loading, as this will run on every +// toolbox startup +loader.lazyRequireGetter(this, "events", "sdk/event/core"); +loader.lazyRequireGetter(this, "Task", "devtools/shared/task", true); +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, "EventTarget", "sdk/event/target", true); + +// 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). +const DEFAULT_TIMELINE_DATA_PULL_TIMEOUT = 200; // ms + +/** + * The timeline actor pops and forwards timeline markers registered in docshells. + */ +var Timeline = exports.Timeline = Class({ + extends: EventTarget, + + /** + * Initializes this actor with the provided connection and tab actor. + */ + initialize: function (tabActor) { + this.tabActor = tabActor; + + 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); + events.on(this.tabActor, "window-ready", this._onWindowReady); + }, + + /** + * Destroys this actor, stopping recording first. + */ + destroy: function () { + this.stop(); + + events.off(this.tabActor, "window-ready", this._onWindowReady); + this.tabActor = null; + }, + + /** + * Get the list of docShells in the currently attached tabActor. Note that we + * always list the docShells included in the real root docShell, even if the + * tabActor 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; + let docShells = []; + + if (this.tabActor.isRootActor) { + originalDocShell = this.tabActor.docShell; + } else { + originalDocShell = this.tabActor.originalDocShell; + } + + if (!originalDocShell) { + return docShells; + } + + let docShellsEnum = originalDocShell.getDocShellEnumerator( + Ci.nsIDocShellTreeItem.typeAll, + Ci.nsIDocShell.ENUMERATE_FORWARDS + ); + + while (docShellsEnum.hasMoreElements()) { + let docShell = docShellsEnum.getNext(); + docShells.push(docShell.QueryInterface(Ci.nsIDocShell)); + } + + return docShells; + }, + + /** + * At regular intervals, pop the markers from the docshell, and forward + * markers, memory, tick and frames events, if any. + */ + _pullTimelineData: function () { + let docShells = this.docShells; + if (!this._isRecording || !docShells.length) { + return; + } + + let endTime = docShells[0].now(); + let markers = []; + + // Gather markers if requested. + if (this._withMarkers || this._withDocLoadingEvents) { + for (let docShell of docShells) { + for (let 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") { + events.emit(this, "doc-loading", marker, endTime); + } + } + } + } + } + + // Emit markers if requested. + if (this._withMarkers && markers.length > 0) { + events.emit(this, "markers", markers, endTime); + } + + // Emit framerate data if requested. + if (this._withTicks) { + events.emit(this, "ticks", endTime, this._framerate.getPendingTicks()); + } + + // Emit memory data if requested. + if (this._withMemory) { + events.emit(this, "memory", endTime, this._memory.measure()); + } + + // Emit stack frames data if requested. + if (this._withFrames && this._withMarkers) { + let frames = this._stackFrames.makeEvent(); + if (frames) { + events.emit(this, "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. + */ + start: Task.async(function* ({ + withMarkers, + withTicks, + withMemory, + withFrames, + withGCEvents, + withDocLoadingEvents, + }) { + let docShells = this.docShells; + if (!docShells.length) { + return -1; + } + let 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 (let docShell of docShells) { + docShell.recordProfileTimelineMarkers = true; + } + } + + if (this._withTicks) { + this._framerate = new Framerate(this.tabActor); + this._framerate.startRecording(); + } + + if (this._withMemory || this._withGCEvents) { + this._memory = new Memory(this.tabActor, this._stackFrames); + this._memory.attach(); + } + + if (this._withGCEvents) { + events.on(this._memory, "garbage-collection", this._onGarbageCollection); + } + + if (this._withFrames && this._withMarkers) { + this._stackFrames = new StackFrameCache(); + this._stackFrames.initFrames(); + } + + this._pullTimelineData(); + return startTime; + }), + + /** + * Stop recording profile markers. + */ + stop: Task.async(function* () { + let docShells = this.docShells; + if (!docShells.length) { + return -1; + } + let endTime = this._startTime = docShells[0].now(); + if (!this._isRecording) { + return endTime; + } + + if (this._withMarkers || this._withDocLoadingEvents) { + for (let 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) { + events.off(this._memory, "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 tabActor, start recording its + * markers if we were recording. + */ + _onWindowReady: function ({ window }) { + if (this._isRecording) { + let docShell = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + 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 }) { + let docShells = this.docShells; + if (!this._isRecording || !docShells.length) { + return; + } + + let endTime = docShells[0].now(); + + events.emit(this, "markers", collections.map(({ startTimestamp: start, endTimestamp: end }) => { + return { + name: "GarbageCollection", + causeName: reason, + nonincrementalReason: nonincrementalReason, + cycle: gcCycleNumber, + start, + end, + }; + }), endTime); + }, +}); |