Bug 1392760 - Avoid exponential behavior when inspecting nested proxies. r=nchevobbe
☠☠ backed out by 57f669c6fab1 ☠ ☠
authorOriol Brufau <oriol-bugzilla@hotmail.com>
Fri, 26 Apr 2019 15:32:50 +0000
changeset 531456 a0a023dac829933a76c7ab83ceb578878a086238
parent 531455 2268e6a9359ed9ddcf3a0325853984c32f93930e
child 531457 57f669c6fab1a6a0ec7446aeee9e7e01c701c0dc
push id11265
push userffxbld-merge
push dateMon, 13 May 2019 10:53:39 +0000
treeherdermozilla-beta@77e0fe8dbdd3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnchevobbe
bugs1392760
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 1392760 - Avoid exponential behavior when inspecting nested proxies. r=nchevobbe Differential Revision: https://phabricator.services.mozilla.com/D28214
devtools/client/debugger/packages/devtools-reps/src/object-inspector/types.js
devtools/client/debugger/packages/devtools-reps/src/object-inspector/utils/client.js
devtools/client/debugger/packages/devtools-reps/src/object-inspector/utils/load-properties.js
devtools/client/debugger/packages/devtools-reps/src/object-inspector/utils/node.js
devtools/client/debugger/packages/devtools-reps/src/reps/stubs/grip.js
devtools/client/shared/components/reps/reps.js
devtools/client/shared/widgets/VariablesViewController.jsm
devtools/client/webconsole/test/mochitest/browser.ini
devtools/client/webconsole/test/mochitest/browser_webconsole_object_inspector_nested_proxy.js
devtools/server/actors/object.js
devtools/server/actors/object/previewers.js
devtools/server/tests/unit/test_objectgrips-17.js
devtools/server/tests/unit/test_objectgrips-nested-proxy.js
devtools/server/tests/unit/xpcshell.ini
devtools/shared/client/debugger-client.js
devtools/shared/client/object-client.js
devtools/shared/specs/object.js
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/types.js
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/types.js
@@ -69,16 +69,17 @@ export type PropertiesIterator = {
   slice: (start: number, count: number) => Promise<GripProperties>
 };
 
 export type ObjectClient = {
   enumEntries: () => Promise<PropertiesIterator>,
   enumProperties: (options: Object) => Promise<PropertiesIterator>,
   enumSymbols: () => Promise<PropertiesIterator>,
   getPrototype: () => Promise<{ prototype: Object }>
+  getProxySlots: () => Promise<{ proxyTarget: Object, proxyHandler: Object }>
 };
 
 export type LongStringClient = {
   substring: (
     start: number,
     end: number,
     response: {
       substring?: string,
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/utils/client.js
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/utils/client.js
@@ -112,16 +112,22 @@ async function getFullText(
 
       resolve({
         fullText: initial + response.substring
       });
     });
   });
 }
 
+async function getProxySlots(
+  objectClient: ObjectClient
+): Promise<{ proxyTarget?: Object, proxyHandler?: Object }> {
+  return objectClient.getProxySlots();
+}
+
 function iteratorSlice(
   iterator: PropertiesIterator,
   start: ?number,
   end: ?number
 ): Promise<GripProperties> {
   start = start || 0;
   const count = end ? end - start + 1 : iterator.count;
 
@@ -132,10 +138,11 @@ function iteratorSlice(
 }
 
 module.exports = {
   enumEntries,
   enumIndexedProperties,
   enumNonIndexedProperties,
   enumSymbols,
   getPrototype,
-  getFullText
+  getFullText,
+  getProxySlots
 };
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/utils/load-properties.js
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/utils/load-properties.js
@@ -3,17 +3,18 @@
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 const {
   enumEntries,
   enumIndexedProperties,
   enumNonIndexedProperties,
   getPrototype,
   enumSymbols,
-  getFullText
+  getFullText,
+  getProxySlots
 } = require("./client");
 
 const {
   getClosestGripNode,
   getClosestNonBucketNode,
   getValue,
   nodeHasAccessors,
   nodeHasAllEntriesInPreview,
@@ -72,16 +73,20 @@ function loadItemProperties(
   if (shouldLoadItemSymbols(item, loadedProperties)) {
     promises.push(enumSymbols(getObjectClient(), start, end));
   }
 
   if (shouldLoadItemFullText(item, loadedProperties)) {
     promises.push(getFullText(createLongStringClient(value), item));
   }
 
+  if (shouldLoadItemProxySlots(item, loadedProperties)) {
+    promises.push(getProxySlots(getObjectClient()));
+  }
+
   return Promise.all(promises).then(mergeResponses);
 }
 
 function mergeResponses(responses: Array<Object>): Object {
   const data = {};
 
   for (const response of responses) {
     if (response.hasOwnProperty("ownProperties")) {
@@ -94,16 +99,21 @@ function mergeResponses(responses: Array
 
     if (response.prototype) {
       data.prototype = response.prototype;
     }
 
     if (response.fullText) {
       data.fullText = response.fullText;
     }
+
+    if (response.proxyTarget && response.proxyHandler) {
+      data.proxyTarget = response.proxyTarget;
+      data.proxyHandler = response.proxyHandler;
+    }
   }
 
   return data;
 }
 
 function shouldLoadItemIndexedProperties(
   item: Node,
   loadedProperties: LoadedProperties = new Map()
@@ -199,18 +209,26 @@ function shouldLoadItemSymbols(
 
 function shouldLoadItemFullText(
   item: Node,
   loadedProperties: LoadedProperties = new Map()
 ) {
   return !loadedProperties.has(item.path) && nodeIsLongString(item);
 }
 
+function shouldLoadItemProxySlots(
+  item: Node,
+  loadedProperties: LoadedProperties = new Map()
+): boolean {
+  return !loadedProperties.has(item.path) && nodeIsProxy(item);
+}
+
 module.exports = {
   loadItemProperties,
   mergeResponses,
   shouldLoadItemEntries,
   shouldLoadItemIndexedProperties,
   shouldLoadItemNonIndexedProperties,
   shouldLoadItemPrototype,
   shouldLoadItemSymbols,
-  shouldLoadItemFullText
+  shouldLoadItemFullText,
+  shouldLoadItemProxySlots
 };
--- a/devtools/client/debugger/packages/devtools-reps/src/object-inspector/utils/node.js
+++ b/devtools/client/debugger/packages/devtools-reps/src/object-inspector/utils/node.js
@@ -341,18 +341,21 @@ function makeNodesForPromiseProperties(i
         type: NODE_TYPES.PROMISE_VALUE
       })
     );
   }
 
   return properties;
 }
 
-function makeNodesForProxyProperties(item: Node): Array<Node> {
-  const { proxyHandler, proxyTarget } = getValue(item);
+function makeNodesForProxyProperties(
+  loadedProps: GripProperties,
+  item: Node
+): Array<Node> {
+  const { proxyHandler, proxyTarget } = loadedProps;
 
   return [
     createNode({
       parent: item,
       name: "<target>",
       contents: { value: proxyTarget },
       type: NODE_TYPES.PROXY_TARGET
     }),
@@ -787,18 +790,18 @@ function getChildren(options: {
   if (nodeHasChildren(item)) {
     return addToCache(item.contents);
   }
 
   if (nodeIsMapEntry(item)) {
     return addToCache(makeNodesForMapEntry(item));
   }
 
-  if (nodeIsProxy(item)) {
-    return addToCache(makeNodesForProxyProperties(item));
+  if (nodeIsProxy(item) && hasLoadedProps) {
+    return addToCache(makeNodesForProxyProperties(loadedProps, item));
   }
 
   if (nodeIsLongString(item) && hasLoadedProps) {
     // Set longString object's fullText to fetched one.
     return addToCache(setNodeFullText(loadedProps, item));
   }
 
   if (nodeNeedsNumericalBuckets(item) && hasLoadedProps) {
--- a/devtools/client/debugger/packages/devtools-reps/src/reps/stubs/grip.js
+++ b/devtools/client/debugger/packages/devtools-reps/src/reps/stubs/grip.js
@@ -298,32 +298,16 @@ stubs.set("testStringObject", {
     safeGetterValues: {},
     wrappedValue: "foo"
   }
 });
 stubs.set("testProxy", {
   type: "object",
   actor: "server1.conn1.child1/obj47",
   class: "Proxy",
-  proxyTarget: {
-    type: "object",
-    actor: "server1.conn1.child1/obj48",
-    class: "Object",
-    ownPropertyLength: 1
-  },
-  proxyHandler: {
-    type: "object",
-    actor: "server1.conn1.child1/obj49",
-    class: "Array",
-    ownPropertyLength: 4,
-    preview: {
-      kind: "ArrayLike",
-      length: 3
-    }
-  },
   preview: {
     kind: "Object",
     ownProperties: {
       "<target>": {
         value: {
           type: "object",
           actor: "server1.conn1.child1/obj48",
           class: "Object",
--- a/devtools/client/shared/components/reps/reps.js
+++ b/devtools/client/shared/components/reps/reps.js
@@ -1707,18 +1707,18 @@ function makeNodesForPromiseProperties(i
       contents: { value: value },
       type: NODE_TYPES.PROMISE_VALUE
     }));
   }
 
   return properties;
 }
 
-function makeNodesForProxyProperties(item) {
-  const { proxyHandler, proxyTarget } = getValue(item);
+function makeNodesForProxyProperties(loadedProps, item) {
+  const { proxyHandler, proxyTarget } = loadedProps;
 
   return [createNode({
     parent: item,
     name: "<target>",
     contents: { value: proxyTarget },
     type: NODE_TYPES.PROXY_TARGET
   }), createNode({
     parent: item,
@@ -2101,18 +2101,18 @@ function getChildren(options) {
   if (nodeHasChildren(item)) {
     return addToCache(item.contents);
   }
 
   if (nodeIsMapEntry(item)) {
     return addToCache(makeNodesForMapEntry(item));
   }
 
-  if (nodeIsProxy(item)) {
-    return addToCache(makeNodesForProxyProperties(item));
+  if (nodeIsProxy(item) && hasLoadedProps) {
+    return addToCache(makeNodesForProxyProperties(loadedProps, item));
   }
 
   if (nodeIsLongString(item) && hasLoadedProps) {
     // Set longString object's fullText to fetched one.
     return addToCache(setNodeFullText(loadedProps, item));
   }
 
   if (nodeNeedsNumericalBuckets(item) && hasLoadedProps) {
@@ -3476,17 +3476,18 @@ module.exports = {
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 const {
   enumEntries,
   enumIndexedProperties,
   enumNonIndexedProperties,
   getPrototype,
   enumSymbols,
-  getFullText
+  getFullText,
+  getProxySlots
 } = __webpack_require__(197);
 
 const {
   getClosestGripNode,
   getClosestNonBucketNode,
   getValue,
   nodeHasAccessors,
   nodeHasAllEntriesInPreview,
@@ -3530,16 +3531,20 @@ function loadItemProperties(item, create
   if (shouldLoadItemSymbols(item, loadedProperties)) {
     promises.push(enumSymbols(getObjectClient(), start, end));
   }
 
   if (shouldLoadItemFullText(item, loadedProperties)) {
     promises.push(getFullText(createLongStringClient(value), item));
   }
 
+  if (shouldLoadItemProxySlots(item, loadedProperties)) {
+    promises.push(getProxySlots(getObjectClient()));
+  }
+
   return Promise.all(promises).then(mergeResponses);
 }
 
 function mergeResponses(responses) {
   const data = {};
 
   for (const response of responses) {
     if (response.hasOwnProperty("ownProperties")) {
@@ -3552,16 +3557,21 @@ function mergeResponses(responses) {
 
     if (response.prototype) {
       data.prototype = response.prototype;
     }
 
     if (response.fullText) {
       data.fullText = response.fullText;
     }
+
+    if (response.proxyTarget && response.proxyHandler) {
+      data.proxyTarget = response.proxyTarget;
+      data.proxyHandler = response.proxyHandler;
+    }
   }
 
   return data;
 }
 
 function shouldLoadItemIndexedProperties(item, loadedProperties = new Map()) {
   const gripItem = getClosestGripNode(item);
   const value = getValue(gripItem);
@@ -3598,25 +3608,30 @@ function shouldLoadItemSymbols(item, loa
 
   return value && !loadedProperties.has(item.path) && !nodeIsBucket(item) && !nodeIsMapEntry(item) && !nodeIsEntries(item) && !nodeIsDefaultProperties(item) && !nodeHasAccessors(item) && !nodeIsPrimitive(item) && !nodeIsLongString(item) && !nodeIsProxy(item);
 }
 
 function shouldLoadItemFullText(item, loadedProperties = new Map()) {
   return !loadedProperties.has(item.path) && nodeIsLongString(item);
 }
 
+function shouldLoadItemProxySlots(item, loadedProperties = new Map()) {
+  return !loadedProperties.has(item.path) && nodeIsProxy(item);
+}
+
 module.exports = {
   loadItemProperties,
   mergeResponses,
   shouldLoadItemEntries,
   shouldLoadItemIndexedProperties,
   shouldLoadItemNonIndexedProperties,
   shouldLoadItemPrototype,
   shouldLoadItemSymbols,
-  shouldLoadItemFullText
+  shouldLoadItemFullText,
+  shouldLoadItemProxySlots
 };
 
 /***/ }),
 
 /***/ 197:
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
@@ -3701,33 +3716,38 @@ async function getFullText(longStringCli
 
       resolve({
         fullText: initial + response.substring
       });
     });
   });
 }
 
+async function getProxySlots(objectClient) {
+  return objectClient.getProxySlots();
+}
+
 function iteratorSlice(iterator, start, end) {
   start = start || 0;
   const count = end ? end - start + 1 : iterator.count;
 
   if (count === 0) {
     return Promise.resolve({});
   }
   return iterator.slice(start, count);
 }
 
 module.exports = {
   enumEntries,
   enumIndexedProperties,
   enumNonIndexedProperties,
   enumSymbols,
   getPrototype,
-  getFullText
+  getFullText,
+  getProxySlots
 };
 
 /***/ }),
 
 /***/ 2:
 /***/ (function(module, exports, __webpack_require__) {
 
 "use strict";
--- a/devtools/client/shared/widgets/VariablesViewController.jsm
+++ b/devtools/client/shared/widgets/VariablesViewController.jsm
@@ -313,26 +313,28 @@ VariablesViewController.prototype = {
    *
    * @param Scope aTarget
    *        The Scope where the properties will be placed into.
    * @param object aGrip
    *        The grip to use to populate the target.
    */
   _populateFromObject: function(aTarget, aGrip) {
     if (aGrip.class === "Proxy") {
-      this.addExpander(
-        aTarget.addItem("<target>", { value: aGrip.proxyTarget }, { internalItem: true }),
-        aGrip.proxyTarget);
-      this.addExpander(
-        aTarget.addItem("<handler>", { value: aGrip.proxyHandler }, { internalItem: true }),
-        aGrip.proxyHandler);
-
-      // Refuse to play the proxy's stupid game and return immediately
+      // Refuse to play the proxy's stupid game and just expose the target and handler.
       const deferred = defer();
-      deferred.resolve();
+      const objectClient = this._getObjectClient(aGrip);
+      objectClient.getProxySlots(aResponse => {
+        const target = aTarget.addItem("<target>", { value: aResponse.proxyTarget },
+          { internalItem: true });
+        this.addExpander(target, aResponse.proxyTarget);
+        const handler = aTarget.addItem("<handler>", { value: aResponse.proxyHandler },
+          { internalItem: true });
+        this.addExpander(handler, aResponse.proxyHandler);
+        deferred.resolve();
+      });
       return deferred.promise;
     }
 
     if (aGrip.class === "Promise" && aGrip.promiseState) {
       const { state, value, reason } = aGrip.promiseState;
       aTarget.addItem("<state>", { value: state }, { internalItem: true });
       if (state === "fulfilled") {
         this.addExpander(
--- a/devtools/client/webconsole/test/mochitest/browser.ini
+++ b/devtools/client/webconsole/test/mochitest/browser.ini
@@ -349,16 +349,17 @@ skip-if = true  # Bug 1438979
 [browser_webconsole_object_inspector.js]
 [browser_webconsole_object_inspector__proto__.js]
 [browser_webconsole_object_inspector_entries.js]
 [browser_webconsole_object_inspector_getters.js]
 [browser_webconsole_object_inspector_getters_prototype.js]
 [browser_webconsole_object_inspector_getters_shadowed.js]
 [browser_webconsole_object_inspector_key_sorting.js]
 [browser_webconsole_object_inspector_local_session_storage.js]
+[browser_webconsole_object_inspector_nested_proxy.js]
 [browser_webconsole_object_inspector_selected_text.js]
 [browser_webconsole_object_inspector_scroll.js]
 [browser_webconsole_object_inspector_while_debugging_and_inspecting.js]
 [browser_webconsole_observer_notifications.js]
 [browser_webconsole_optimized_out_vars.js]
 [browser_webconsole_output_copy.js]
 tags = clipboard
 [browser_webconsole_output_copy_newlines.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/test/mochitest/browser_webconsole_object_inspector_nested_proxy.js
@@ -0,0 +1,50 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check evaluating and expanding getters in the console.
+const TEST_URI = "data:text/html;charset=utf8,"
+ + "<h1>Object Inspector on deeply nested proxies</h1>";
+
+add_task(async function() {
+  const hud = await openNewTabAndConsole(TEST_URI);
+
+  await ContentTask.spawn(gBrowser.selectedBrowser, null, function() {
+    let proxy = new Proxy({}, {});
+    for (let i = 0; i < 1e5; ++i) {
+      proxy = new Proxy(proxy, proxy);
+    }
+    content.wrappedJSObject.console.log("oi-test", proxy);
+  });
+
+  const node = await waitFor(() => findMessage(hud, "oi-test"));
+  const oi = node.querySelector(".tree");
+  const [proxyNode] = getObjectInspectorNodes(oi);
+
+  expandObjectInspectorNode(proxyNode);
+  await waitFor(() => getObjectInspectorNodes(oi).length > 1);
+  checkChildren(proxyNode, [`<target>`, `<handler>`]);
+
+  const targetNode = findObjectInspectorNode(oi, "<target>");
+  expandObjectInspectorNode(targetNode);
+  await waitFor(() => getObjectInspectorChildrenNodes(targetNode).length > 0);
+  checkChildren(targetNode, [`<target>`, `<handler>`]);
+
+  const handlerNode = findObjectInspectorNode(oi, "<handler>");
+  expandObjectInspectorNode(handlerNode);
+  await waitFor(() => getObjectInspectorChildrenNodes(handlerNode).length > 0);
+  checkChildren(handlerNode, [`<target>`, `<handler>`]);
+});
+
+function checkChildren(node, expectedChildren) {
+  const children = getObjectInspectorChildrenNodes(node);
+  is(children.length, expectedChildren.length,
+    "There is the expected number of children");
+  children.forEach((child, index) => {
+    ok(child.textContent.includes(expectedChildren[index]),
+      `Expected "${expectedChildren[index]}" child`);
+  });
+}
--- a/devtools/server/actors/object.js
+++ b/devtools/server/actors/object.js
@@ -850,16 +850,33 @@ const proto = {
       source,
       line: stack.line,
       column: stack.column,
       functionDisplayName: stack.functionDisplayName,
     };
   },
 
   /**
+   * Handle a protocol request to get the target and handler internal slots of a proxy.
+   */
+  proxySlots: function() {
+    // There could be transparent security wrappers, unwrap to check if it's a proxy.
+    // However, retrieve proxyTarget and proxyHandler from `this.obj` to avoid exposing
+    // the unwrapped target and handler.
+    if (!DevToolsUtils.unwrap(this.obj).isProxy) {
+      return this.throwError("objectNotProxy",
+        "'proxySlots' request is only valid for grips with a 'Proxy' class.");
+    }
+    return {
+      proxyTarget: this.hooks.createValueGrip(this.obj.proxyTarget),
+      proxyHandler: this.hooks.createValueGrip(this.obj.proxyHandler),
+    };
+  },
+
+  /**
    * Release the actor, when it isn't needed anymore.
    * Protocol.js uses this release method to call the destroy method.
    */
   release: function() {},
 };
 
 exports.ObjectActor = protocol.ActorClassWithSpec(objectSpec, proto);
 exports.ObjectActorProto = proto;
--- a/devtools/server/actors/object/previewers.js
+++ b/devtools/server/actors/object/previewers.js
@@ -283,37 +283,37 @@ const previewers = {
         break;
       }
     }
 
     return true;
   }],
 
   Proxy: [function({obj, hooks}, grip, rawObj) {
+    // Only preview top-level proxies, avoiding recursion. Otherwise, since both the
+    // target and handler can also be proxies, we could get an exponential behavior.
+    if (hooks.getGripDepth() > 1) {
+      return true;
+    }
+
     // The `isProxy` getter of the debuggee object only detects proxies without
     // security wrappers. If false, the target and handler are not available.
     const hasTargetAndHandler = obj.isProxy;
-    if (hasTargetAndHandler) {
-      grip.proxyTarget = hooks.createValueGrip(obj.proxyTarget);
-      grip.proxyHandler = hooks.createValueGrip(obj.proxyHandler);
-    }
 
     grip.preview = {
       kind: "Object",
       ownProperties: Object.create(null),
       ownPropertiesLength: 2 * hasTargetAndHandler,
     };
 
-    if (hooks.getGripDepth() > 1) {
-      return true;
-    }
-
     if (hasTargetAndHandler) {
-      grip.preview.ownProperties["<target>"] = {value: grip.proxyTarget};
-      grip.preview.ownProperties["<handler>"] = {value: grip.proxyHandler};
+      Object.assign(grip.preview.ownProperties, {
+        "<target>": {value: hooks.createValueGrip(obj.proxyTarget)},
+        "<handler>": {value: hooks.createValueGrip(obj.proxyHandler)},
+      });
     }
 
     return true;
   }],
 };
 
 /**
  * Generic previewer for classes wrapping primitives, like String,
--- a/devtools/server/tests/unit/test_objectgrips-17.js
+++ b/devtools/server/tests/unit/test_objectgrips-17.js
@@ -52,18 +52,22 @@ function test({ threadClient, debuggee }
   return new Promise(function(resolve) {
     threadClient.addOneTimeListener("paused", async function(event, packet) {
       // Get the grips.
       const [proxyGrip, inheritsProxyGrip, inheritsProxy2Grip] = packet.frame.arguments;
 
       // Check the grip of the proxy object.
       check_proxy_grip(debuggee, testOptions, proxyGrip);
 
+      // Check the target and handler slots of the proxy object.
+      const proxyClient = threadClient.pauseGrip(proxyGrip);
+      const proxySlots = await proxyClient.getProxySlots();
+      check_proxy_slots(debuggee, testOptions, proxyGrip, proxySlots);
+
       // Check the prototype and properties of the proxy object.
-      const proxyClient = threadClient.pauseGrip(proxyGrip);
       const proxyResponse = await proxyClient.getPrototypeAndProperties();
       check_properties(testOptions, proxyResponse.ownProperties, true, false);
       check_prototype(debuggee, testOptions, proxyResponse.prototype, true, false);
 
       // Check the prototype and properties of the object which inherits from the proxy.
       const inheritsProxyClient = threadClient.pauseGrip(inheritsProxyGrip);
       const inheritsProxyResponse = await inheritsProxyClient.getPrototypeAndProperties();
       check_properties(testOptions, inheritsProxyResponse.ownProperties, false, false);
@@ -112,47 +116,58 @@ function test({ threadClient, debuggee }
 
 function check_proxy_grip(debuggee, testOptions, grip) {
   const { global, isOpaque, subsumes, globalIsInvisible } = testOptions;
   const {preview} = grip;
 
   if (global === debuggee) {
     // The proxy has no security wrappers.
     strictEqual(grip.class, "Proxy", "The grip has a Proxy class.");
-    ok(grip.proxyTarget, "There is a [[ProxyTarget]] grip.");
-    ok(grip.proxyHandler, "There is a [[ProxyHandler]] grip.");
     strictEqual(preview.ownPropertiesLength, 2, "The preview has 2 properties.");
-    const target = preview.ownProperties["<target>"].value;
-    strictEqual(target, grip.proxyTarget, "<target> contains the [[ProxyTarget]].");
-    const handler = preview.ownProperties["<handler>"].value;
-    strictEqual(handler, grip.proxyHandler, "<handler> contains the [[ProxyHandler]].");
+    const props = preview.ownProperties;
+    ok(props["<target>"].value, "<target> contains the [[ProxyTarget]].");
+    ok(props["<handler>"].value, "<handler> contains the [[ProxyHandler]].");
   } else if (isOpaque) {
     // The proxy has opaque security wrappers.
     strictEqual(grip.class, "Opaque", "The grip has an Opaque class.");
     strictEqual(grip.ownPropertyLength, 0, "The grip has no properties.");
   } else if (!subsumes) {
     // The proxy belongs to compartment not subsumed by the debuggee.
-    strictEqual(grip.class, "Restricted", "The grip has an Restricted class.");
+    strictEqual(grip.class, "Restricted", "The grip has a Restricted class.");
     ok(!("ownPropertyLength" in grip), "The grip doesn't know the number of properties.");
   } else if (globalIsInvisible) {
     // The proxy belongs to an invisible-to-debugger compartment.
     strictEqual(grip.class, "InvisibleToDebugger: Object",
                 "The grip has an InvisibleToDebugger class.");
     ok(!("ownPropertyLength" in grip), "The grip doesn't know the number of properties.");
   } else {
     // The proxy has non-opaque security wrappers.
     strictEqual(grip.class, "Proxy", "The grip has a Proxy class.");
-    ok(!("proxyTarget" in grip), "There is no [[ProxyTarget]] grip.");
-    ok(!("proxyHandler" in grip), "There is no [[ProxyHandler]] grip.");
     strictEqual(preview.ownPropertiesLength, 0, "The preview has no properties.");
     ok(!("<target>" in preview), "The preview has no <target> property.");
     ok(!("<handler>" in preview), "The preview has no <handler> property.");
   }
 }
 
+function check_proxy_slots(debuggee, testOptions, grip, proxySlots) {
+  const { global } = testOptions;
+
+  if (grip.class !== "Proxy") {
+    strictEqual(proxySlots, undefined, "Slots can only be retrived for Proxy grips.");
+  } else if (global === debuggee) {
+    const { proxyTarget, proxyHandler } = proxySlots;
+    strictEqual(proxyTarget.type, "object", "There is a [[ProxyTarget]] grip.");
+    strictEqual(proxyHandler.type, "object", "There is a [[ProxyHandler]] grip.");
+  } else {
+    const { proxyTarget, proxyHandler } = proxySlots;
+    strictEqual(proxyTarget.type, "undefined", "There is no [[ProxyTarget]] grip.");
+    strictEqual(proxyHandler.type, "undefined", "There is no [[ProxyHandler]] grip.");
+  }
+}
+
 function check_properties(testOptions, props, isProxy, createdInDebuggee) {
   const { subsumes, globalIsInvisible } = testOptions;
   const ownPropertiesLength = Reflect.ownKeys(props).length;
 
   if (createdInDebuggee || !isProxy && subsumes && !globalIsInvisible) {
     // The debuggee can access the properties.
     strictEqual(ownPropertiesLength, 1, "1 own property was retrieved.");
     strictEqual(props.x.value, 1, "The property has the right value.");
new file mode 100644
--- /dev/null
+++ b/devtools/server/tests/unit/test_objectgrips-nested-proxy.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+  Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(threadClientTest(async ({ threadClient, debuggee, client }) => {
+  await new Promise(function(resolve) {
+    threadClient.addOneTimeListener("paused", async function(event, packet) {
+      const [grip] = packet.frame.arguments;
+      const objClient = threadClient.pauseGrip(grip);
+      const {proxyTarget, proxyHandler} = await objClient.getProxySlots();
+
+      strictEqual(grip.class, "Proxy", "Its a proxy grip.");
+      strictEqual(proxyTarget.class, "Proxy", "The target is also a proxy.");
+      strictEqual(proxyHandler.class, "Proxy", "The handler is also a proxy.");
+
+      await threadClient.resume();
+      resolve();
+    });
+    debuggee.eval(function stopMe(arg) {
+      debugger;
+    }.toString());
+    debuggee.eval(`
+      var proxy = new Proxy({}, {});
+      for (let i = 0; i < 1e5; ++i)
+        proxy = new Proxy(proxy, proxy);
+      stopMe(proxy);
+    `);
+  });
+}));
--- a/devtools/server/tests/unit/xpcshell.ini
+++ b/devtools/server/tests/unit/xpcshell.ini
@@ -165,16 +165,17 @@ skip-if = true # breakpoint sliding is n
 [test_objectgrips-22.js]
 [test_objectgrips-array-like-object.js]
 [test_objectgrips-property-value-01.js]
 [test_objectgrips-property-value-02.js]
 [test_objectgrips-property-value-03.js]
 [test_objectgrips-fn-apply-01.js]
 [test_objectgrips-fn-apply-02.js]
 [test_objectgrips-fn-apply-03.js]
+[test_objectgrips-nested-proxy.js]
 [test_promise_state-01.js]
 [test_promise_state-02.js]
 [test_promise_state-03.js]
 [test_interrupt.js]
 [test_stepping-01.js]
 [test_stepping-02.js]
 [test_stepping-03.js]
 [test_stepping-04.js]
--- a/devtools/shared/client/debugger-client.js
+++ b/devtools/shared/client/debugger-client.js
@@ -354,20 +354,21 @@ DebuggerClient.prototype = {
     request.format = "json";
     request.stack = getStack();
 
     // Implement a Promise like API on the returned object
     // that resolves/rejects on request response
     const deferred = promise.defer();
     function listenerJson(resp) {
       removeRequestListeners();
+      resp = safeOnResponse(resp);
       if (resp.error) {
-        deferred.reject(safeOnResponse(resp));
+        deferred.reject(resp);
       } else {
-        deferred.resolve(safeOnResponse(resp));
+        deferred.resolve(resp);
       }
     }
     function listenerBulk(resp) {
       removeRequestListeners();
       deferred.resolve(safeOnResponse(resp));
     }
 
     const removeRequestListeners = () => {
--- a/devtools/shared/client/object-client.js
+++ b/devtools/shared/client/object-client.js
@@ -298,11 +298,34 @@ ObjectClient.prototype = {
     before: function(packet) {
       if (this._grip.class !== "Promise") {
         throw new Error("getPromiseRejectionStack is only valid for " +
           "promise grips.");
       }
       return packet;
     },
   }),
+
+  /**
+   * Request the target and handler internal slots of a proxy.
+   */
+  getProxySlots: DebuggerClient.requester({
+    type: "proxySlots",
+  }, {
+    before: function(packet) {
+      if (this._grip.class !== "Proxy") {
+        throw new Error("getProxySlots is only valid for proxy grips.");
+      }
+      return packet;
+    },
+    after: function(response) {
+      // Before Firefox 68 (bug 1392760), the proxySlots request didn't exist.
+      // The proxy target and handler were directly included in the grip.
+      if (response.error === "unrecognizedPacketType") {
+        const {proxyTarget, proxyHandler} = this._grip;
+        return {proxyTarget, proxyHandler};
+      }
+      return response;
+    },
+  }),
 };
 
 module.exports = ObjectClient;
--- a/devtools/shared/specs/object.js
+++ b/devtools/shared/specs/object.js
@@ -97,16 +97,21 @@ types.addDictType("object.dependentPromi
 
 types.addDictType("object.originalSourceLocation", {
   source: "source",
   line: "number",
   column: "number",
   functionDisplayName: "string",
 });
 
+types.addDictType("object.proxySlots", {
+  proxyTarget: "object.descriptor",
+  proxyHandler: "object.descriptor",
+});
+
 const objectSpec = generateActorSpec({
   typeName: "obj",
 
   methods: {
     allocationStack: {
       request: {},
       response: {
         allocationStack: RetVal("array:object.originalSourceLocation"),
@@ -193,16 +198,20 @@ const objectSpec = generateActorSpec({
       response: RetVal("object.apply"),
     },
     rejectionStack: {
       request: {},
       response: {
         rejectionStack: RetVal("array:object.originalSourceLocation"),
       },
     },
+    proxySlots: {
+      request: {},
+      response: RetVal("object.proxySlots"),
+    },
     release: { release: true },
     scope: {
       request: {},
       response: RetVal("object.scope"),
     },
     // Needed for the PauseScopedObjectActor which extends the ObjectActor.
     threadGrip: {
       request: {},