diff options
Diffstat (limited to 'devtools/server/actors/call-watcher.js')
-rw-r--r-- | devtools/server/actors/call-watcher.js | 634 |
1 files changed, 634 insertions, 0 deletions
diff --git a/devtools/server/actors/call-watcher.js b/devtools/server/actors/call-watcher.js new file mode 100644 index 000000000..5729f9508 --- /dev/null +++ b/devtools/server/actors/call-watcher.js @@ -0,0 +1,634 @@ +/* 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 events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const {serializeStack, parseStack} = require("toolkit/loader"); + +const {on, once, off, emit} = events; +const {method, Arg, Option, RetVal} = protocol; + +const { functionCallSpec, callWatcherSpec } = require("devtools/shared/specs/call-watcher"); +const { CallWatcherFront } = require("devtools/shared/fronts/call-watcher"); + +/** + * This actor contains information about a function call, like the function + * type, name, stack, arguments, returned value etc. + */ +var FunctionCallActor = protocol.ActorClassWithSpec(functionCallSpec, { + /** + * Creates the function call actor. + * + * @param DebuggerServerConnection conn + * The server connection. + * @param DOMWindow window + * The content window. + * @param string global + * The name of the global object owning this function, like + * "CanvasRenderingContext2D" or "WebGLRenderingContext". + * @param object caller + * The object owning the function when it was called. + * For example, in `foo.bar()`, the caller is `foo`. + * @param number type + * Either METHOD_FUNCTION, METHOD_GETTER or METHOD_SETTER. + * @param string name + * The called function's name. + * @param array stack + * The called function's stack, as a list of { name, file, line } objects. + * @param number timestamp + * The performance.now() timestamp when the function was called. + * @param array args + * The called function's arguments. + * @param any result + * The value returned by the function call. + * @param boolean holdWeak + * Determines whether or not FunctionCallActor stores a weak reference + * to the underlying objects. + */ + initialize: function (conn, [window, global, caller, type, name, stack, timestamp, args, result], holdWeak) { + protocol.Actor.prototype.initialize.call(this, conn); + + this.details = { + global: global, + type: type, + name: name, + stack: stack, + timestamp: timestamp + }; + + // Store a weak reference to all objects so we don't + // prevent natural GC if `holdWeak` was passed into + // setup as truthy. + if (holdWeak) { + let weakRefs = { + window: Cu.getWeakReference(window), + caller: Cu.getWeakReference(caller), + args: Cu.getWeakReference(args), + result: Cu.getWeakReference(result), + }; + + Object.defineProperties(this.details, { + window: { get: () => weakRefs.window.get() }, + caller: { get: () => weakRefs.caller.get() }, + args: { get: () => weakRefs.args.get() }, + result: { get: () => weakRefs.result.get() }, + }); + } + // Otherwise, hold strong references to the objects. + else { + this.details.window = window; + this.details.caller = caller; + this.details.args = args; + this.details.result = result; + } + + // The caller, args and results are string names for now. It would + // certainly be nicer if they were Object actors. Make this smarter, so + // that the frontend can inspect each argument, be it object or primitive. + // Bug 978960. + this.details.previews = { + caller: this._generateStringPreview(caller), + args: this._generateArgsPreview(args), + result: this._generateStringPreview(result) + }; + }, + + /** + * Customize the marshalling of this actor to provide some generic information + * directly on the Front instance. + */ + form: function () { + return { + actor: this.actorID, + type: this.details.type, + name: this.details.name, + file: this.details.stack[0].file, + line: this.details.stack[0].line, + timestamp: this.details.timestamp, + callerPreview: this.details.previews.caller, + argsPreview: this.details.previews.args, + resultPreview: this.details.previews.result + }; + }, + + /** + * Gets more information about this function call, which is not necessarily + * available on the Front instance. + */ + getDetails: function () { + let { type, name, stack, timestamp } = this.details; + + // Since not all calls on the stack have corresponding owner files (e.g. + // callbacks of a requestAnimationFrame etc.), there's no benefit in + // returning them, as the user can't jump to the Debugger from them. + for (let i = stack.length - 1; ;) { + if (stack[i].file) { + break; + } + stack.pop(); + i--; + } + + // XXX: Use grips for objects and serialize them properly, in order + // to add the function's caller, arguments and return value. Bug 978957. + return { + type: type, + name: name, + stack: stack, + timestamp: timestamp + }; + }, + + /** + * Serializes the arguments so that they can be easily be transferred + * as a string, but still be useful when displayed in a potential UI. + * + * @param array args + * The source arguments. + * @return string + * The arguments as a string. + */ + _generateArgsPreview: function (args) { + let { global, name, caller } = this.details; + + // Get method signature to determine if there are any enums + // used in this method. + let methodSignatureEnums; + + let knownGlobal = CallWatcherFront.KNOWN_METHODS[global]; + if (knownGlobal) { + let knownMethod = knownGlobal[name]; + if (knownMethod) { + let isOverloaded = typeof knownMethod.enums === "function"; + if (isOverloaded) { + methodSignatureEnums = methodSignatureEnums(args); + } else { + methodSignatureEnums = knownMethod.enums; + } + } + } + + let serializeArgs = () => args.map((arg, i) => { + // XXX: Bug 978960. + if (arg === undefined) { + return "undefined"; + } + if (arg === null) { + return "null"; + } + if (typeof arg == "function") { + return "Function"; + } + if (typeof arg == "object") { + return "Object"; + } + // If this argument matches the method's signature + // and is an enum, change it to its constant name. + if (methodSignatureEnums && methodSignatureEnums.has(i)) { + return getBitToEnumValue(global, caller, arg); + } + return arg + ""; + }); + + return serializeArgs().join(", "); + }, + + /** + * Serializes the data so that it can be easily be transferred + * as a string, but still be useful when displayed in a potential UI. + * + * @param object data + * The source data. + * @return string + * The arguments as a string. + */ + _generateStringPreview: function (data) { + // XXX: Bug 978960. + if (data === undefined) { + return "undefined"; + } + if (data === null) { + return "null"; + } + if (typeof data == "function") { + return "Function"; + } + if (typeof data == "object") { + return "Object"; + } + return data + ""; + } +}); + +/** + * This actor observes function calls on certain objects or globals. + */ +var CallWatcherActor = exports.CallWatcherActor = protocol.ActorClassWithSpec(callWatcherSpec, { + initialize: function (conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + this._onGlobalCreated = this._onGlobalCreated.bind(this); + this._onGlobalDestroyed = this._onGlobalDestroyed.bind(this); + this._onContentFunctionCall = this._onContentFunctionCall.bind(this); + on(this.tabActor, "window-ready", this._onGlobalCreated); + on(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + }, + destroy: function (conn) { + protocol.Actor.prototype.destroy.call(this, conn); + off(this.tabActor, "window-ready", this._onGlobalCreated); + off(this.tabActor, "window-destroyed", this._onGlobalDestroyed); + this.finalize(); + }, + + /** + * Lightweight listener invoked whenever an instrumented function is called + * while recording. We're doing this to avoid the event emitter overhead, + * since this is expected to be a very hot function. + */ + onCall: null, + + /** + * Starts waiting for the current tab actor's document global to be + * created, in order to instrument the specified objects and become + * aware of everything the content does with them. + */ + setup: function ({ tracedGlobals, tracedFunctions, startRecording, performReload, holdWeak, storeCalls }) { + if (this._initialized) { + return; + } + this._initialized = true; + this._timestampEpoch = 0; + + this._functionCalls = []; + this._tracedGlobals = tracedGlobals || []; + this._tracedFunctions = tracedFunctions || []; + this._holdWeak = !!holdWeak; + this._storeCalls = !!storeCalls; + + if (startRecording) { + this.resumeRecording(); + } + if (performReload) { + this.tabActor.window.location.reload(); + } + }, + + /** + * Stops listening for document global changes and puts this actor + * to hibernation. This method is called automatically just before the + * actor is destroyed. + */ + finalize: function () { + if (!this._initialized) { + return; + } + this._initialized = false; + this._finalized = true; + + this._tracedGlobals = null; + this._tracedFunctions = null; + }, + + /** + * Returns whether the instrumented function calls are currently recorded. + */ + isRecording: function () { + return this._recording; + }, + + /** + * Initialize the timestamp epoch used to offset function call timestamps. + */ + initTimestampEpoch: function () { + this._timestampEpoch = this.tabActor.window.performance.now(); + }, + + /** + * Starts recording function calls. + */ + resumeRecording: function () { + this._recording = true; + }, + + /** + * Stops recording function calls. + */ + pauseRecording: function () { + this._recording = false; + return this._functionCalls; + }, + + /** + * Erases all the recorded function calls. + * Calling `resumeRecording` or `pauseRecording` does not erase history. + */ + eraseRecording: function () { + this._functionCalls = []; + }, + + /** + * Invoked whenever the current tab actor's document global is created. + */ + _onGlobalCreated: function ({window, id, isTopLevel}) { + if (!this._initialized) { + return; + } + + // TODO: bug 981748, support more than just the top-level documents. + if (!isTopLevel) { + return; + } + + let self = this; + this._tracedWindowId = id; + + let unwrappedWindow = XPCNativeWrapper.unwrap(window); + let callback = this._onContentFunctionCall; + + for (let global of this._tracedGlobals) { + let prototype = unwrappedWindow[global].prototype; + let properties = Object.keys(prototype); + properties.forEach(name => overrideSymbol(global, prototype, name, callback)); + } + + for (let name of this._tracedFunctions) { + overrideSymbol("window", unwrappedWindow, name, callback); + } + + /** + * Instruments a method, getter or setter on the specified target object to + * invoke a callback whenever it is called. + */ + function overrideSymbol(global, target, name, callback) { + let propertyDescriptor = Object.getOwnPropertyDescriptor(target, name); + + if (propertyDescriptor.get || propertyDescriptor.set) { + overrideAccessor(global, target, name, propertyDescriptor, callback); + return; + } + if (propertyDescriptor.writable && typeof propertyDescriptor.value == "function") { + overrideFunction(global, target, name, propertyDescriptor, callback); + return; + } + } + + /** + * Instruments a function on the specified target object. + */ + function overrideFunction(global, target, name, descriptor, callback) { + // Invoking .apply on an unxrayed content function doesn't work, because + // the arguments array is inaccessible to it. Get Xrays back. + let originalFunc = Cu.unwaiveXrays(target[name]); + + Cu.exportFunction(function (...args) { + let result; + try { + result = Cu.waiveXrays(originalFunc.apply(this, args)); + } catch (e) { + throw createContentError(e, unwrappedWindow); + } + + if (self._recording) { + let type = CallWatcherFront.METHOD_FUNCTION; + let stack = getStack(name); + let timestamp = self.tabActor.window.performance.now() - self._timestampEpoch; + callback(unwrappedWindow, global, this, type, name, stack, timestamp, args, result); + } + return result; + }, target, { defineAs: name }); + + Object.defineProperty(target, name, { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + writable: true + }); + } + + /** + * Instruments a getter or setter on the specified target object. + */ + function overrideAccessor(global, target, name, descriptor, callback) { + // Invoking .apply on an unxrayed content function doesn't work, because + // the arguments array is inaccessible to it. Get Xrays back. + let originalGetter = Cu.unwaiveXrays(target.__lookupGetter__(name)); + let originalSetter = Cu.unwaiveXrays(target.__lookupSetter__(name)); + + Object.defineProperty(target, name, { + get: function (...args) { + if (!originalGetter) return undefined; + let result = Cu.waiveXrays(originalGetter.apply(this, args)); + + if (self._recording) { + let type = CallWatcherFront.GETTER_FUNCTION; + let stack = getStack(name); + let timestamp = self.tabActor.window.performance.now() - self._timestampEpoch; + callback(unwrappedWindow, global, this, type, name, stack, timestamp, args, result); + } + return result; + }, + set: function (...args) { + if (!originalSetter) return; + originalSetter.apply(this, args); + + if (self._recording) { + let type = CallWatcherFront.SETTER_FUNCTION; + let stack = getStack(name); + let timestamp = self.tabActor.window.performance.now() - self._timestampEpoch; + callback(unwrappedWindow, global, this, type, name, stack, timestamp, args, undefined); + } + }, + configurable: descriptor.configurable, + enumerable: descriptor.enumerable + }); + } + + /** + * Stores the relevant information about calls on the stack when + * a function is called. + */ + function getStack(caller) { + try { + // Using Components.stack wouldn't be a better idea, since it's + // much slower because it attempts to retrieve the C++ stack as well. + throw new Error(); + } catch (e) { + var stack = e.stack; + } + + // Of course, using a simple regex like /(.*?)@(.*):(\d*):\d*/ would be + // much prettier, but this is a very hot function, so let's sqeeze + // every drop of performance out of it. + let calls = []; + let callIndex = 0; + let currNewLinePivot = stack.indexOf("\n") + 1; + let nextNewLinePivot = stack.indexOf("\n", currNewLinePivot); + + while (nextNewLinePivot > 0) { + let nameDelimiterIndex = stack.indexOf("@", currNewLinePivot); + let columnDelimiterIndex = stack.lastIndexOf(":", nextNewLinePivot - 1); + let lineDelimiterIndex = stack.lastIndexOf(":", columnDelimiterIndex - 1); + + if (!calls[callIndex]) { + calls[callIndex] = { name: "", file: "", line: 0 }; + } + if (!calls[callIndex + 1]) { + calls[callIndex + 1] = { name: "", file: "", line: 0 }; + } + + if (callIndex > 0) { + let file = stack.substring(nameDelimiterIndex + 1, lineDelimiterIndex); + let line = stack.substring(lineDelimiterIndex + 1, columnDelimiterIndex); + let name = stack.substring(currNewLinePivot, nameDelimiterIndex); + calls[callIndex].name = name; + calls[callIndex - 1].file = file; + calls[callIndex - 1].line = line; + } else { + // Since the topmost stack frame is actually our overwritten function, + // it will not have the expected name. + calls[0].name = caller; + } + + currNewLinePivot = nextNewLinePivot + 1; + nextNewLinePivot = stack.indexOf("\n", currNewLinePivot); + callIndex++; + } + + return calls; + } + }, + + /** + * Invoked whenever the current tab actor's inner window is destroyed. + */ + _onGlobalDestroyed: function ({window, id, isTopLevel}) { + if (this._tracedWindowId == id) { + this.pauseRecording(); + this.eraseRecording(); + this._timestampEpoch = 0; + } + }, + + /** + * Invoked whenever an instrumented function is called. + */ + _onContentFunctionCall: function (...details) { + // If the consuming tool has finalized call-watcher, ignore the + // still-instrumented calls. + if (this._finalized) { + return; + } + + let functionCall = new FunctionCallActor(this.conn, details, this._holdWeak); + + if (this._storeCalls) { + this._functionCalls.push(functionCall); + } + + if (this.onCall) { + this.onCall(functionCall); + } else { + emit(this, "call", functionCall); + } + } +}); + +/** + * A lookup table for cross-referencing flags or properties with their name + * assuming they look LIKE_THIS most of the time. + * + * For example, when gl.clear(gl.COLOR_BUFFER_BIT) is called, the actual passed + * argument's value is 16384, which we want identified as "COLOR_BUFFER_BIT". + */ +var gEnumRegex = /^[A-Z][A-Z0-9_]+$/; +var gEnumsLookupTable = {}; + +// These values are returned from errors, or empty values, +// and need to be ignored when checking arguments due to the bitwise math. +var INVALID_ENUMS = [ + "INVALID_ENUM", "NO_ERROR", "INVALID_VALUE", "OUT_OF_MEMORY", "NONE" +]; + +function getBitToEnumValue(type, object, arg) { + let table = gEnumsLookupTable[type]; + + // If mapping not yet created, do it on the first run. + if (!table) { + table = gEnumsLookupTable[type] = {}; + + for (let key in object) { + if (key.match(gEnumRegex)) { + // Maps `16384` to `"COLOR_BUFFER_BIT"`, etc. + table[object[key]] = key; + } + } + } + + // If a single bit value, just return it. + if (table[arg]) { + return table[arg]; + } + + // Otherwise, attempt to reduce it to the original bit flags: + // `16640` -> "COLOR_BUFFER_BIT | DEPTH_BUFFER_BIT" + let flags = []; + for (let flag in table) { + if (INVALID_ENUMS.indexOf(table[flag]) !== -1) { + continue; + } + + // Cast to integer as all values are stored as strings + // in `table` + flag = flag | 0; + if (flag && (arg & flag) === flag) { + flags.push(table[flag]); + } + } + + // Cache the combined bitmask value + return table[arg] = flags.join(" | ") || arg; +} + +/** + * Creates a new error from an error that originated from content but was called + * from a wrapped overridden method. This is so we can make our own error + * that does not look like it originated from the call watcher. + * + * We use toolkit/loader's parseStack and serializeStack rather than the + * parsing done in the local `getStack` function, because it does not expose + * column number, would have to change the protocol models `call-stack-items` and `call-details` + * which hurts backwards compatibility, and the local `getStack` is an optimized, hot function. + */ +function createContentError(e, win) { + let { message, name, stack } = e; + let parsedStack = parseStack(stack); + let { fileName, lineNumber, columnNumber } = parsedStack[parsedStack.length - 1]; + let error; + + let isDOMException = e instanceof Ci.nsIDOMDOMException; + let constructor = isDOMException ? win.DOMException : (win[e.name] || win.Error); + + if (isDOMException) { + error = new constructor(message, name); + Object.defineProperties(error, { + code: { value: e.code }, + columnNumber: { value: 0 }, // columnNumber is always 0 for DOMExceptions? + filename: { value: fileName }, // note the lowercase `filename` + lineNumber: { value: lineNumber }, + result: { value: e.result }, + stack: { value: serializeStack(parsedStack) } + }); + } + else { + // Constructing an error here retains all the stack information, + // and we can add message, fileName and lineNumber via constructor, though + // need to manually add columnNumber. + error = new constructor(message, fileName, lineNumber); + Object.defineProperty(error, "columnNumber", { + value: columnNumber + }); + } + return error; +} |