/* 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"); Cu.import("resource://gre/modules/Task.jsm"); loader.lazyRequireGetter(this, "Services"); loader.lazyRequireGetter(this, "promise"); loader.lazyRequireGetter(this, "EventEmitter", "devtools/toolkit/event-emitter"); loader.lazyRequireGetter(this, "FramerateFront", "devtools/server/actors/framerate", true); loader.lazyRequireGetter(this, "DevToolsUtils", "devtools/toolkit/DevToolsUtils"); loader.lazyImporter(this, "gDevTools", "resource:///modules/devtools/gDevTools.jsm"); /** * A cache of all ProfilerConnection instances. The keys are Toolbox objects. */ let SharedProfilerConnection = new WeakMap(); /** * Instantiates a shared ProfilerConnection for the specified toolbox. * Consumers must yield on `open` to make sure the connection is established. * * @param Toolbox toolbox * The toolbox owning this connection. */ SharedProfilerConnection.forToolbox = function(toolbox) { if (this.has(toolbox)) { return this.get(toolbox); } let instance = new ProfilerConnection(toolbox); this.set(toolbox, instance); return instance; }; /** * A connection to the profiler actor, along with other miscellaneous actors, * shared by all tools in a toolbox. * * Use `SharedProfilerConnection.forToolbox` to make sure you get the same * instance every time, and the `ProfilerFront` to start/stop recordings. * * @param Toolbox toolbox * The toolbox owning this connection. */ function ProfilerConnection(toolbox) { EventEmitter.decorate(this); this._toolbox = toolbox; this._target = this._toolbox.target; this._client = this._target.client; this._request = this._request.bind(this); this._pendingFramerateConsumers = 0; this._pendingConsoleRecordings = []; this._finishedConsoleRecordings = []; this._onEventNotification = this._onEventNotification.bind(this); Services.obs.notifyObservers(null, "profiler-connection-created", null); } ProfilerConnection.prototype = { /** * Initializes a connection to the profiler and other miscellaneous actors. * If already open, nothing happens. * * @return object * A promise that is resolved once the connection is established. */ open: Task.async(function*() { if (this._connected) { return; } // Local debugging needs to make the target remote. yield this._target.makeRemote(); // Chrome debugging targets have already obtained a reference // to the profiler actor. if (this._target.chrome) { this._profiler = this._target.form.profilerActor; } // Or when we are debugging content processes, we already have the tab // specific one. Use it immediately. else if (this._target.form && this._target.form.profilerActor) { this._profiler = this._target.form.profilerActor; yield this._registerEventNotifications(); } // Check if we already have a grip to the `listTabs` response object // and, if we do, use it to get to the profiler actor. else if (this._target.root && this._target.root.profilerActor) { this._profiler = this._target.root.profilerActor; yield this._registerEventNotifications(); } // Otherwise, call `listTabs`. else { this._profiler = (yield listTabs(this._client)).profilerActor; yield this._registerEventNotifications(); } this._connectMiscActors(); this._connected = true; Services.obs.notifyObservers(null, "profiler-connection-opened", null); }), /** * Destroys this connection. */ destroy: function() { this._disconnectMiscActors(); this._connected = false; }, /** * Initializes a connection to miscellaneous actors which are going to be * used in tandem with the profiler actor. */ _connectMiscActors: function() { // Only initialize the framerate front if the respective actor is available. // Older Gecko versions don't have an existing implementation, in which case // all the methods we need can be easily mocked. if (this._target.form && this._target.form.framerateActor) { this._framerate = new FramerateFront(this._target.client, this._target.form); } else { this._framerate = { destroy: () => {}, startRecording: () => {}, stopRecording: () => {}, cancelRecording: () => {}, isRecording: () => false, getPendingTicks: () => null }; } }, /** * Closes the connections to miscellaneous actors. * @see ProfilerConnection.prototype._connectMiscActors */ _disconnectMiscActors: function() { this._framerate.destroy(); }, /** * Sends the request over the remote debugging protocol to the * specified actor. * * @param string actor * The designated actor. Currently supported: "profiler", "framerate". * @param string method * Method to call on the backend. * @param any args [optional] * Additional data or arguments to send with the request. * @return object * A promise resolved with the response once the request finishes. */ _request: function(actor, method, ...args) { // Handle requests to the profiler actor. if (actor == "profiler") { let deferred = promise.defer(); let data = args[0] || {}; data.to = this._profiler; data.type = method; this._client.request(data, deferred.resolve); return deferred.promise; } // Handle requests to the framerate actor. if (actor == "framerate") { switch (method) { // Only stop recording framerate if there are no other pending consumers. // Otherwise, for example, the next time `console.profileEnd` is called // there won't be any framerate data available, since we're reusing the // same actor for multiple overlapping recordings. case "startRecording": this._pendingFramerateConsumers++; break; case "stopRecording": case "cancelRecording": if (--this._pendingFramerateConsumers > 0) return; break; // Some versions of the framerate actor don't have a 'getPendingTicks' // method available, in which case it's impossible to get all the // accumulated framerate data without stopping the recording. Bail out. case "getPendingTicks": if (method in this._framerate) break; return null; } checkPendingFramerateConsumers(this); return this._framerate[method].apply(this._framerate, args); } }, /** * Starts listening to certain events emitted by the profiler actor. * * @return object * A promise that is resolved once the notifications are registered. */ _registerEventNotifications: Task.async(function*() { let events = ["console-api-profiler", "profiler-stopped"]; yield this._request("profiler", "registerEventNotifications", { events }); this._client.addListener("eventNotification", this._onEventNotification); }), /** * Invoked whenever a registered event was emitted by the profiler actor. * * @param object response * The data received from the backend. */ _onEventNotification: function(event, response) { let toolbox = gDevTools.getToolbox(this._target); if (toolbox == null) { return; } if (response.topic == "console-api-profiler") { let action = response.subject.action; let details = response.details; if (action == "profile") { this.emit("invoked-console-profile", details.profileLabel); // used in tests this._onConsoleProfileStart(details); } else if (action == "profileEnd") { this.emit("invoked-console-profileEnd", details.profileLabel); // used in tests this._onConsoleProfileEnd(details); } } else if (response.topic == "profiler-stopped") { this._onProfilerUnexpectedlyStopped(); } }, /** * 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 }) { let pending = this._pendingConsoleRecordings; if (pending.find(e => e.profileLabel == profileLabel)) { return; } // Push this unique `console.profile` call to the stack. pending.push({ profileLabel: profileLabel, profilingStartTime: currentTime }); // Profiling was automatically started when `console.profile` was invoked, // so we all we have to do is make sure the framerate actor is recording. yield this._request("framerate", "startRecording"); // Signal that a call to `console.profile` actually created a new recording. this.emit("profile", profileLabel); }), /** * Invoked whenever `console.profileEnd` is called. * * @param object profilerData * The profiler data received from the backend. */ _onConsoleProfileEnd: Task.async(function*(profilerData) { let pending = this._pendingConsoleRecordings; if (pending.length == 0) { return; } // Try to find the corresponding `console.profile` call in the stack // with the specified label (be it undefined, or a string). let info = pending.find(e => e.profileLabel == profilerData.profileLabel); if (info) { pending.splice(pending.indexOf(info), 1); } // If no corresponding `console.profile` call was found, and if no label // is specified, pop the most recent entry from the stack. else if (!profilerData.profileLabel) { info = pending.pop(); profilerData.profileLabel = info.profileLabel; } // ...Otherwise this is a call to `console.profileEnd` with a label that // doesn't exist on the stack. Bail out. else { return; } // The profile is already available when console.profileEnd() was invoked, // but we need to filter out all samples that fall out of current profile's // range. This is necessary because the profiler is continuously running. filterSamples(profilerData, info.profilingStartTime); offsetSampleTimes(profilerData, info.profilingStartTime); // Fetch the recorded refresh driver ticks, during the same time window // as the filtered profiler data. let beginAt = findEarliestSampleTime(profilerData); let endAt = findOldestSampleTime(profilerData); let ticksData = yield this._request("framerate", "getPendingTicks", beginAt, endAt); yield this._request("framerate", "cancelRecording"); // Join all the acquired data and emit it for outside consumers. let recordingData = { recordingDuration: profilerData.currentTime - info.profilingStartTime, profilerData: profilerData, ticksData: ticksData }; this._finishedConsoleRecordings.push(recordingData); // Signal that a call to `console.profileEnd` actually finished a recording. this.emit("profileEnd", recordingData); }), /** * Invoked whenever the built-in profiler module is deactivated. Since this * should *never* happen while there's a consumer (i.e. "toolbox") available, * treat this notification as being unexpected. * * This may happen, for example, if the Gecko Profiler add-on is installed * (and isn't using the profiler actor over the remote protocol). There's no * way to prevent it from stopping the profiler and affecting our tool. */ _onProfilerUnexpectedlyStopped: function() { // Pop all pending `console.profile` calls from the stack. this._pendingConsoleRecordings.length = 0; this.emit("profiler-unexpectedly-stopped"); } }; /** * A thin wrapper around a shared ProfilerConnection for the parent toolbox. * Handles manually starting and stopping a recording. * * @param ProfilerConnection connection * The shared instance for the parent toolbox. */ function ProfilerFront(connection) { EventEmitter.decorate(this); this._request = connection._request; this.pendingConsoleRecordings = connection._pendingConsoleRecordings; this.finishedConsoleRecordings = connection._finishedConsoleRecordings; connection.on("profile", (e, args) => this.emit(e, args)); connection.on("profileEnd", (e, args) => this.emit(e, args)); connection.on("profiler-unexpectedly-stopped", (e, args) => this.emit(e, args)); } ProfilerFront.prototype = { /** * Manually begins a recording session. * * @return object * A promise that is resolved once recording has started. */ startRecording: Task.async(function*() { let { isActive, currentTime } = yield this._request("profiler", "isActive"); // Start the profiler only if it wasn't already active. The built-in // nsIProfiler module will be kept recording, because it's the same instance // for all toolboxes and interacts with the whole platform, so we don't want // to affect other clients by stopping (or restarting) it. if (!isActive) { // Make a copy of the options, because eventually _request wants // to freeze the packet. let localOptions = Cu.cloneInto(this._customProfilerOptions, {}); yield this._request("profiler", "startProfiler", localOptions); this._profilingStartTime = 0; this.emit("profiler-activated"); } else { this._profilingStartTime = currentTime; this.emit("profiler-already-active"); } // The framerate actor is target-dependent, so just make sure // it's recording. yield this._request("framerate", "startRecording"); }), /** * Manually ends the current recording session. * * @return object * A promise that is resolved once recording has stopped, * with the profiler and framerate data. */ stopRecording: Task.async(function*() { // We'll need to filter out all samples that fall out of current profile's // range. This is necessary because the profiler is continuously running. let profilerData = yield this._request("profiler", "getProfile"); filterSamples(profilerData, this._profilingStartTime); offsetSampleTimes(profilerData, this._profilingStartTime); // Fetch the recorded refresh driver ticks, during the same time window // as the filtered profiler data. let beginAt = findEarliestSampleTime(profilerData); let endAt = findOldestSampleTime(profilerData); let ticksData = yield this._request("framerate", "getPendingTicks", beginAt, endAt); yield this._request("framerate", "cancelRecording"); // Join all the aquired data and return it for outside consumers. return { recordingDuration: profilerData.currentTime - this._profilingStartTime, profilerData: profilerData, ticksData: ticksData }; }), /** * Ends the current recording session without trying to retrieve any data. */ cancelRecording: Task.async(function*() { yield this._request("framerate", "cancelRecording"); }), /** * Overrides the options sent to the built-in profiler module when activating, * such as the maximum entries count, the sampling interval etc. * * Used in tests and for older backend implementations. */ _customProfilerOptions: { entries: 1000000, interval: 1, features: ["js"], threadFilters: ["GeckoMain"] } }; /** * Filters all the samples in the provided profiler data to be more recent * than the specified start time. * * @param object profilerData * The profiler data received from the backend. * @param number profilingStartTime * The earliest acceptable sample time (in milliseconds). */ function filterSamples(profilerData, profilingStartTime) { let firstThread = profilerData.profile.threads[0]; firstThread.samples = firstThread.samples.filter(e => { return e.time >= profilingStartTime; }); } /** * Offsets all the samples in the provided profiler data by the specified time. * * @param object profilerData * The profiler data received from the backend. * @param number timeOffset * The amount of time to offset by (in milliseconds). */ function offsetSampleTimes(profilerData, timeOffset) { let firstThreadSamples = profilerData.profile.threads[0].samples; for (let sample of firstThreadSamples) { sample.time -= timeOffset; } } /** * Finds the earliest sample time in the provided profiler data. * * @param object profilerData * The profiler data received from the backend. * @return number * The earliest sample time (in milliseconds). */ function findEarliestSampleTime(profilerData) { let firstThreadSamples = profilerData.profile.threads[0].samples; for (let sample of firstThreadSamples) { if ("time" in sample) { return sample.time; } } } /** * Finds the oldest sample time in the provided profiler data. * * @param object profilerData * The profiler data received from the backend. * @return number * The oldest sample time (in milliseconds). */ function findOldestSampleTime(profilerData) { let firstThreadSamples = profilerData.profile.threads[0].samples; for (let i = firstThreadSamples.length - 1; i >= 0; i--) { if ("time" in firstThreadSamples[i]) { return firstThreadSamples[i].time; } } } /** * Asserts the value sanity of `pendingFramerateConsumers`. */ function checkPendingFramerateConsumers(connection) { if (connection._pendingFramerateConsumers < 0) { let msg = "Somehow the number of framerate consumers is now negative."; DevToolsUtils.reportException("ProfilerConnection", msg); } } /** * A collection of small wrappers promisifying functions invoking callbacks. */ function listTabs(client) { let deferred = promise.defer(); client.listTabs(deferred.resolve); return deferred.promise; } exports.getProfilerConnection = toolbox => SharedProfilerConnection.forToolbox(toolbox); exports.ProfilerFront = ProfilerFront;