Bug 1497393 Part 5 - Add ReplayInspector module for accessing objects in a replaying process, r=loganfsmyth.
authorBrian Hackett <bhackett1024@gmail.com>
Sat, 09 Mar 2019 16:41:32 -1000
changeset 468289 6c051fac78f3947be26546bab32f75cf6d25974a
parent 468288 47c9d34dbef10537efde4d8915571307812a6283
child 468290 da8ea59a87f40b7d934e537771b508899b1e0c44
push id112699
push userbhackett@mozilla.com
push dateSun, 07 Apr 2019 03:06:36 +0000
treeherdermozilla-inbound@46a995ea433f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersloganfsmyth
bugs1497393
milestone68.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1497393 Part 5 - Add ReplayInspector module for accessing objects in a replaying process, r=loganfsmyth. Differential Revision: https://phabricator.services.mozilla.com/D22861
devtools/server/actors/replay/debugger.js
devtools/server/actors/replay/inspector.js
devtools/server/actors/replay/moz.build
devtools/server/actors/replay/replay.js
devtools/shared/builtin-modules.js
--- a/devtools/server/actors/replay/debugger.js
+++ b/devtools/server/actors/replay/debugger.js
@@ -1057,16 +1057,18 @@ ReplayDebuggerObject.prototype = {
   seal: NotAllowed,
   freeze: NotAllowed,
   defineProperty: NotAllowed,
   defineProperties: NotAllowed,
   deleteProperty: NotAllowed,
   forceLexicalInitializationByName: NotAllowed,
 };
 
+ReplayDebugger.Object = ReplayDebuggerObject;
+
 ///////////////////////////////////////////////////////////////////////////////
 // ReplayDebuggerObjectSnapshot
 ///////////////////////////////////////////////////////////////////////////////
 
 // Create an object based on snapshot data which can be consulted without
 // communicating with the child process. This uses data provided by the child
 // process in the same format as for normal ReplayDebuggerObjects, except that
 // it does not contain references to any other objects.
new file mode 100644
--- /dev/null
+++ b/devtools/server/actors/replay/inspector.js
@@ -0,0 +1,395 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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/. */
+/* eslint-disable spaced-comment, brace-style, indent-legacy, consistent-return */
+
+"use strict";
+
+// Normally, the server Inspector code inspects the DOM in the process it is
+// running in. When inspecting a replaying process, the DOM is in the replaying
+// process itself instead of the middleman process where server code runs.
+//
+// To allow the same server code to work in both cases, while replaying we make
+// the following changes, which are managed here:
+//
+// - Objects in the replaying process are represented by proxies in the
+//   middleman process, which are provided to the server code and can be
+//   accessed in the same way that normal DOM objects are.
+//
+// - Global bindings accessing C++ interfaces that expect DOM objects from the
+//   current process are replaced with interfaces that operate on the
+//   replaying object proxies.
+
+const ReplayDebugger = require("devtools/server/actors/replay/debugger");
+
+let _dbg = null;
+function dbg() {
+  if (!_dbg) {
+    _dbg = new ReplayDebugger();
+  }
+  return _dbg;
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// Public Interface
+///////////////////////////////////////////////////////////////////////////////
+
+const ReplayInspector = {
+  // Return a proxy for the window in the replaying process.
+  get window() {
+    return gWindow;
+  },
+
+  // Return a proxy for a new tree walker in the replaying process.
+  newDeepTreeWalker() {
+    const data = dbg()._sendRequestAllowDiverge({ type: "newDeepTreeWalker" });
+    const obj = dbg()._getObject(data.id);
+    return wrapObject(obj);
+  },
+
+  // Create the InspectorUtils object to bind for other server users.
+  createInspectorUtils(utils) {
+    // Overwrite some APIs that will fail if called on proxies from the
+    // replaying process.
+    return {
+      ...utils,
+      hasPseudoClassLock() { return false; },
+      getAllStyleSheets() { return []; },
+      getCSSStyleRules() { return []; },
+    };
+  },
+
+  // Modified EventListenerService to use when replaying. It would be nice to
+  // bind a special Services object for other server users, but doing so creates
+  // initialization problems.
+  els: {
+    getListenerInfoFor(node) {
+      const id = unwrapValue(node)._data.id;
+      const rv = dbg()._sendRequestAllowDiverge({
+        type: "getListenerInfoFor",
+        id,
+      });
+      const obj = dbg()._getObject(rv.id);
+      return wrapValue(obj);
+    },
+  },
+
+  // Find the element in the replaying process which is being targeted by a
+  // mouse event in the middleman process.
+  findEventTarget(event) {
+    const rv = dbg()._sendRequestAllowDiverge({
+      type: "findEventTarget",
+      clientX: event.clientX,
+      clientY: event.clientY,
+    });
+    const obj = dbg()._getObject(rv.id);
+    return wrapValue(obj);
+  },
+
+  // Get the ReplayDebugger.Object underlying a replaying object proxy.
+  getDebuggerObject(node) {
+    return unwrapValue(node);
+  },
+};
+
+///////////////////////////////////////////////////////////////////////////////
+// Replaying Object Proxies
+///////////////////////////////////////////////////////////////////////////////
+
+// Map from replaying object proxies to the underlying Debugger.Object.
+const proxyMap = new Map();
+
+// Create an array with the contents of obj.
+function createArrayObject(obj) {
+  const target = [];
+  for (const name of obj.getOwnPropertyNames()) {
+    const desc = obj.getOwnPropertyDescriptor(name);
+    if (desc && "value" in desc) {
+      target[name] = wrapValue(desc.value);
+    }
+  }
+  return target;
+}
+
+function createInspectorObject(obj) {
+  if (obj.class == "Array") {
+    // Eagerly create an array in this process which supports calls to map() and
+    // so forth without needing to send callbacks to the replaying process.
+    return createArrayObject(obj);
+  }
+
+  let target;
+  if (obj.callable) {
+    // Proxies need callable targets in order to be callable themselves.
+    target = function() {};
+    target.object = obj;
+  } else {
+    // Place non-callable targets in a box as well, so that we can change the
+    // underlying ReplayDebugger.Object later.
+    target = { object: obj };
+  }
+  const proxy = new Proxy(target, ReplayInspectorProxyHandler);
+
+  proxyMap.set(proxy, obj);
+  return proxy;
+}
+
+function wrapObject(obj) {
+  assert(obj instanceof ReplayDebugger.Object);
+  if (!obj._inspectorObject) {
+    obj._inspectorObject = createInspectorObject(obj);
+  }
+  return obj._inspectorObject;
+}
+
+function wrapValue(value) {
+  if (value && typeof value == "object") {
+    return wrapObject(value);
+  }
+  return value;
+}
+
+function unwrapValue(value) {
+  if (!isNonNullObject(value)) {
+    return value;
+  }
+
+  const obj = proxyMap.get(value);
+  if (obj) {
+    return obj;
+  }
+
+  if (value instanceof Object) {
+    const rv = dbg()._sendRequest({ type: "createObject" });
+    const newobj = dbg()._getObject(rv.id);
+
+    Object.entries(value).forEach(([name, propvalue]) => {
+      const unwrapped = unwrapValue(propvalue);
+      setObjectProperty(newobj, name, unwrapped);
+    });
+    return newobj;
+  }
+
+  ThrowError("Can't unwrap value");
+}
+
+function getObjectProperty(obj, name) {
+  const rv = dbg()._sendRequestAllowDiverge({
+    type: "getObjectPropertyValue",
+    id: obj._data.id,
+    name,
+  });
+  return dbg()._convertCompletionValue(rv);
+}
+
+function setObjectProperty(obj, name, value) {
+  const rv = dbg()._sendRequestAllowDiverge({
+    type: "setObjectPropertyValue",
+    id: obj._data.id,
+    name,
+    value: dbg()._convertValueForChild(value),
+  });
+  return dbg()._convertCompletionValue(rv);
+}
+
+function getTargetObject(target) {
+  if (!target.object._data) {
+    // This should be a fixed proxy (window or window.document), in which case
+    // we briefly pause and update the proxy according to its current contents.
+    // Other proxies should not be used again after the replaying process
+    // unpauses: when repausing the client should regenerate the entire DOM.
+    updateFixedProxies();
+    assert(target.object._data);
+  }
+  return target.object;
+}
+
+const ReplayInspectorProxyHandler = {
+  getPrototypeOf(target) {
+    target = getTargetObject(target);
+
+    // Cherry pick some objects that are used in instanceof comparisons by
+    // server inspector code.
+    if (target._data.class == "NamedNodeMap") {
+      return NamedNodeMap.prototype;
+    }
+
+    return null;
+  },
+
+  has(target, name) {
+    target = getTargetObject(target);
+
+    if (typeof name == "symbol") {
+      return name == Symbol.iterator;
+    }
+
+    if (name == "toString") {
+      return true;
+    }
+
+    // See if this is an 'own' data property.
+    const desc = target.getOwnPropertyDescriptor(name);
+    return !!desc;
+  },
+
+  get(target, name, receiver) {
+    target = getTargetObject(target);
+
+    if (typeof name == "symbol") {
+      if (name == Symbol.iterator) {
+        const array = createArrayObject(target);
+        return array[Symbol.iterator];
+      }
+
+      return undefined;
+    }
+
+    if (name == "toString") {
+      return () => `ReplayInspectorProxy #${target._data.id}`;
+    }
+
+    // See if this is an 'own' data property.
+    const desc = target.getOwnPropertyDescriptor(name);
+    if (desc && "value" in desc) {
+      return wrapValue(desc.value);
+    }
+
+    // Get the property on the target object directly in the replaying process.
+    const rv = getObjectProperty(target, name);
+    if ("return" in rv) {
+      return wrapValue(rv.return);
+    }
+    ThrowError(rv.throw);
+  },
+
+  set(target, name, value) {
+    target = getTargetObject(target);
+
+    const rv = setObjectProperty(target, name, unwrapValue(value));
+    if ("return" in rv) {
+      return true;
+    }
+    ThrowError(rv.throw);
+  },
+
+  apply(target, thisArg, args) {
+    target = getTargetObject(target);
+
+    const rv = target.apply(unwrapValue(thisArg), args.map(v => unwrapValue(v)));
+    if ("return" in rv) {
+      return wrapValue(rv.return);
+    }
+    ThrowError(rv.throw);
+  },
+
+  construct(target, args) {
+    target = getTargetObject(target);
+    const proxy = wrapObject(target);
+
+    // Create fake MutationObservers to satisfy callers in the inspector.
+    if (proxy == gWindow.MutationObserver) {
+      return {
+        observe: () => {},
+        disconnect: () => {},
+      };
+    }
+
+    NotAllowed();
+  },
+
+  getOwnPropertyDescriptor(target, name) {
+    target = getTargetObject(target);
+
+    const desc = target.getOwnPropertyDescriptor(name);
+    if (!desc) {
+      return null;
+    }
+
+    // Note: ReplayDebugger.Object.getOwnPropertyDescriptor always returns a
+    // fresh object, so we can modify it in place.
+    if ("value" in desc) {
+      desc.value = wrapValue(desc.value);
+    }
+    if ("get" in desc) {
+      desc.get = wrapValue(desc.get);
+    }
+    if ("set" in desc) {
+      desc.set = wrapValue(desc.set);
+    }
+    desc.configurable = true;
+    return desc;
+  },
+
+  ownKeys(target) {
+    target = getTargetObject(target);
+    return target.getOwnPropertyNames();
+  },
+
+  isExtensible(target) { NYI(); },
+
+  setPrototypeOf() { NotAllowed(); },
+  preventExtensions() { NotAllowed(); },
+  defineProperty() { NotAllowed(); },
+  deleteProperty() { NotAllowed(); },
+};
+
+///////////////////////////////////////////////////////////////////////////////
+// Fixed Proxies
+///////////////////////////////////////////////////////////////////////////////
+
+// Proxies for the window and root document are reused to ensure consistent
+// actors are used for these objects.
+const gWindowTarget = { object: {} }, gDocumentTarget = { object: {} };
+const gWindow = new Proxy(gWindowTarget, ReplayInspectorProxyHandler);
+const gDocument = new Proxy(gDocumentTarget, ReplayInspectorProxyHandler);
+
+function initFixedProxy(proxy, target, obj) {
+  target.object = obj;
+  proxyMap.set(proxy, obj);
+  obj._inspectorObject = proxy;
+}
+
+function updateFixedProxies() {
+  dbg()._ensurePaused();
+
+  const data = dbg()._sendRequestAllowDiverge({ type: "getWindow" });
+  const dbgWindow = dbg()._getObject(data.id);
+  initFixedProxy(gWindow, gWindowTarget, dbgWindow);
+
+  const rv = getObjectProperty(dbgWindow, "document");
+  assert(rv.return instanceof ReplayDebugger.Object);
+  initFixedProxy(gDocument, gDocumentTarget, rv.return);
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// Utilities
+///////////////////////////////////////////////////////////////////////////////
+
+function NYI() {
+  ThrowError("Not yet implemented");
+}
+
+function NotAllowed() {
+  ThrowError("Not allowed");
+}
+
+function ThrowError(msg) {
+  const error = new Error(msg);
+  dump("ReplayInspector Server Error: " + msg + " Stack: " + error.stack + "\n");
+  throw error;
+}
+
+function assert(v) {
+  if (!v) {
+    ThrowError("Assertion Failed!");
+  }
+}
+
+function isNonNullObject(obj) {
+  return obj && (typeof obj == "object" || typeof obj == "function");
+}
+
+module.exports = ReplayInspector;
--- a/devtools/server/actors/replay/moz.build
+++ b/devtools/server/actors/replay/moz.build
@@ -3,16 +3,17 @@
 # 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(
     'control.js',
     'debugger.js',
     'graphics.js',
+    'inspector.js',
     'replay.js',
 )
 
 XPIDL_MODULE = 'devtools_rr'
 
 XPIDL_SOURCES = [
     'rrIControl.idl',
     'rrIGraphics.idl',
--- a/devtools/server/actors/replay/replay.js
+++ b/devtools/server/actors/replay/replay.js
@@ -51,19 +51,21 @@ dbg.onNewGlobalObject = function(global)
     }
   }
 };
 
 ///////////////////////////////////////////////////////////////////////////////
 // Utilities
 ///////////////////////////////////////////////////////////////////////////////
 
+const dump = RecordReplayControl.dump;
+
 function assert(v) {
   if (!v) {
-    RecordReplayControl.dump("Assertion Failed: " + (new Error()).stack + "\n");
+    dump("Assertion Failed: " + (new Error()).stack + "\n");
     throw new Error("Assertion Failed!");
   }
 }
 
 // Bidirectional map between objects and IDs.
 function IdMap() {
   this._idToObject = [ undefined ];
   this._objectToId = new Map();
@@ -464,22 +466,35 @@ function GetEntryPosition(position) {
   return null;
 }
 
 ///////////////////////////////////////////////////////////////////////////////
 // Paused State
 ///////////////////////////////////////////////////////////////////////////////
 
 let gPausedObjects = new IdMap();
+let gDereferencedObjects = new Map();
 
 function getObjectId(obj) {
   const id = gPausedObjects.getId(obj);
   if (!id && obj) {
     assert((obj instanceof Debugger.Object) ||
            (obj instanceof Debugger.Environment));
+
+    // There can be multiple Debugger.Objects for the same dereferenced object.
+    // gDereferencedObjects is used to make sure the IDs we send to the
+    // middleman are canonical and are specific to their referent.
+    if (obj instanceof Debugger.Object) {
+      if (gDereferencedObjects.has(obj.unsafeDereference())) {
+        const canonical = gDereferencedObjects.get(obj.unsafeDereference());
+        return gPausedObjects.getId(canonical);
+      }
+      gDereferencedObjects.set(obj.unsafeDereference(), obj);
+    }
+
     return gPausedObjects.add(obj);
   }
   return id;
 }
 
 // Convert a value for sending to the parent.
 function convertValue(value, options) {
   if (value instanceof Debugger.Object) {
@@ -534,19 +549,28 @@ function makeDebuggeeValue(value) {
     assert(!(value instanceof Debugger.Object));
     const global = Cu.getGlobalForObject(value);
     const dbgGlobal = dbg.makeGlobalObjectReference(global);
     return dbgGlobal.makeDebuggeeValue(value);
   }
   return value;
 }
 
+function getDebuggeeValue(value) {
+  if (value && typeof value == "object") {
+    assert(value instanceof Debugger.Object);
+    return value.unsafeDereference();
+  }
+  return value;
+}
+
 // eslint-disable-next-line no-unused-vars
 function ClearPausedState() {
   gPausedObjects = new IdMap();
+  gDereferencedObjects = new Map();
 }
 
 ///////////////////////////////////////////////////////////////////////////////
 // Handler Helpers
 ///////////////////////////////////////////////////////////////////////////////
 
 function getScriptData(id) {
   const script = gScripts.getObject(id);
@@ -596,29 +620,41 @@ function getObjectProperties(object) {
     }
     if ("set" in desc) {
       desc.set = getObjectId(desc.set);
     }
     return { name, desc };
   });
 }
 
+function getWindow() {
+  // Hopefully there is exactly one window in this enumerator.
+  for (const window of Services.ww.getWindowEnumerator()) {
+    return window;
+  }
+  return null;
+}
+
 ///////////////////////////////////////////////////////////////////////////////
 // Handlers
 ///////////////////////////////////////////////////////////////////////////////
 
 const gRequestHandlers = {
 
   repaint() {
     if (!RecordReplayControl.maybeDivergeFromRecording()) {
       return {};
     }
     return RecordReplayControl.repaint();
   },
 
+  /////////////////////////////////////////////////////////
+  // Debugger Requests
+  /////////////////////////////////////////////////////////
+
   findScripts(request) {
     const query = Object.assign({}, request.query);
     if ("global" in query) {
       query.global = gPausedObjects.getObject(query.global);
     }
     if ("source" in query) {
       query.source = gScriptSources.getObject(query.source);
       if (!query.source) {
@@ -821,33 +857,113 @@ const gRequestHandlers = {
 
   currentExecutionPoint(request) {
     return RecordReplayControl.currentExecutionPoint();
   },
 
   recordingEndpoint(request) {
     return RecordReplayControl.recordingEndpoint();
   },
+
+  /////////////////////////////////////////////////////////
+  // Inspector Requests
+  /////////////////////////////////////////////////////////
+
+  getWindow(request) {
+    if (!RecordReplayControl.maybeDivergeFromRecording()) {
+      return { throw: "Recording divergence in getWindow" };
+    }
+
+    // Hopefully there is exactly one window in this enumerator.
+    return { id: getObjectId(makeDebuggeeValue(getWindow())) };
+  },
+
+  newDeepTreeWalker(request) {
+    if (!RecordReplayControl.maybeDivergeFromRecording()) {
+      return { throw: "Recording divergence in newDeepTreeWalker" };
+    }
+
+    const walker = Cc["@mozilla.org/inspector/deep-tree-walker;1"]
+      .createInstance(Ci.inIDeepTreeWalker);
+    return { id: getObjectId(makeDebuggeeValue(walker)) };
+  },
+
+  getObjectPropertyValue(request) {
+    if (!RecordReplayControl.maybeDivergeFromRecording()) {
+      return { throw: "Recording divergence in getObjectPropertyValue" };
+    }
+
+    const object = gPausedObjects.getObject(request.id);
+
+    try {
+      const rv = object.unsafeDereference()[request.name];
+      return { "return": convertValue(makeDebuggeeValue(rv)) };
+    } catch (e) {
+      return { "throw": "" + e };
+    }
+  },
+
+  setObjectPropertyValue(request) {
+    if (!RecordReplayControl.maybeDivergeFromRecording()) {
+      return { throw: "Recording divergence in getObjectPropertyValue" };
+    }
+
+    const object = gPausedObjects.getObject(request.id);
+    const value = getDebuggeeValue(convertValueFromParent(request.value));
+
+    try {
+      object.unsafeDereference()[request.name] = value;
+      return { "return": request.value };
+    } catch (e) {
+      return { "throw": "" + e };
+    }
+  },
+
+  createObject(request) {
+    const global = dbg.getDebuggees()[0];
+    const value = global.executeInGlobal("({})");
+    return { id: getObjectId(value.return) };
+  },
+
+  findEventTarget(request) {
+    const element =
+      getWindow().document.elementFromPoint(request.clientX, request.clientY);
+    if (!element) {
+      return { id: 0 };
+    }
+    const obj = makeDebuggeeValue(element);
+    return { id: getObjectId(obj) };
+  },
+
+  getListenerInfoFor(request) {
+    if (!RecordReplayControl.maybeDivergeFromRecording()) {
+      return { throw: "Recording divergence in getListenerInfoFor" };
+    }
+
+    const node = gPausedObjects.getObject(request.id).unsafeDereference();
+    const obj = makeDebuggeeValue(Services.els.getListenerInfoFor(node) || []);
+    return { id: getObjectId(obj) };
+  },
 };
 
 // eslint-disable-next-line no-unused-vars
 function ProcessRequest(request) {
   try {
     if (gRequestHandlers[request.type]) {
       return gRequestHandlers[request.type](request);
     }
     return { exception: "No handler for " + request.type };
   } catch (e) {
     let msg;
     try {
-      msg = "" + e;
+      msg = "" + e + " line " + e.lineNumber;
     } catch (ee) {
       msg = "Unknown";
     }
-    RecordReplayControl.dump("ReplayDebugger Record/Replay Error: " + msg + "\n");
+    dump("ReplayDebugger Record/Replay Error: " + msg + "\n");
     return { exception: msg };
   }
 }
 
 // eslint-disable-next-line no-unused-vars
 var EXPORTED_SYMBOLS = [
   "EnsurePositionHandler",
   "ClearPositionHandlers",
--- a/devtools/shared/builtin-modules.js
+++ b/devtools/shared/builtin-modules.js
@@ -225,17 +225,16 @@ function lazyRequireGetter(obj, property
     enumerable: true,
   });
 }
 
 // List of pseudo modules exposed to all devtools modules.
 exports.modules = {
   ChromeUtils,
   HeapSnapshot,
-  InspectorUtils,
   promise,
   // Expose "chrome" Promise, which aren't related to any document
   // and so are never frozen, even if the browser loader module which
   // pull it is destroyed. See bug 1402779.
   Promise,
   Services: Object.create(Services),
   TelemetryStopwatch,
 };
@@ -264,16 +263,24 @@ defineLazyGetter(exports.modules, "Recor
   if (global.RecordReplayControl) {
     return global.RecordReplayControl;
   }
   const { addDebuggerToGlobal } = ChromeUtils.import("resource://gre/modules/jsdebugger.jsm");
   addDebuggerToGlobal(global);
   return global.RecordReplayControl;
 });
 
+defineLazyGetter(exports.modules, "InspectorUtils", () => {
+  if (exports.modules.Debugger.recordReplayProcessKind() == "Middleman") {
+    const ReplayInspector = require("devtools/server/actors/replay/inspector");
+    return ReplayInspector.createInspectorUtils(InspectorUtils);
+  }
+  return InspectorUtils;
+});
+
 defineLazyGetter(exports.modules, "Timer", () => {
   const {setTimeout, clearTimeout} = require("resource://gre/modules/Timer.jsm");
   // Do not return Cu.import result, as DevTools loader would freeze Timer.jsm globals...
   return {
     setTimeout,
     clearTimeout,
   };
 });