Bug 1875045 - [devtools] Release Object actors by bulk. r=devtools-reviewers,devtools-backward-compat-reviewers,nchevobbe

For now we were releasing object actors one by one.
This would force to send an individual RDP request for each of them.
The console often release all objects actors related to older console message
going over the maximum limit of displayed console messages (10k).
This can easily grow in a large number of actors to be released,
either if console message are receiving many arguments and/or
if many console are logged.

We have to have one request per target as the actors could only be reached
within same-thread actor.
In order to prepare for ObjectFront removal, introduce a target-scoped "Objects" actor
which is a singleton per Target. It will receive the new "release in bulk objects actors"
method. Later, it will start implementing all the existing methods of the Object Actor
in order to migrate away from having to instantiate one Object Front (notice the singular on "Object"),
per inspected JS Object.

On the fronted side a new Object Command is introduced in order to abstract away the RDP/Fronts work.

Differential Revision: https://phabricator.services.mozilla.com/D198784
This commit is contained in:
Alexandre Poirot 2024-01-29 13:59:51 +00:00
parent bd77930536
commit 6b018ef63f
24 changed files with 438 additions and 45 deletions

View File

@ -32,6 +32,7 @@ DevToolsModules(
"network-parent.js",
"node.js",
"object.js",
"objects-manager.js",
"page-style.js",
"perf.js",
"preference.js",

View File

@ -0,0 +1,25 @@
/* 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 {
objectsManagerSpec,
} = require("resource://devtools/shared/specs/objects-manager.js");
const {
FrontClassWithSpec,
registerFront,
} = require("resource://devtools/shared/protocol.js");
class ObjectsManagerFront extends FrontClassWithSpec(objectsManagerSpec) {
constructor(client, targetFront, parentFront) {
super(client, targetFront, parentFront);
// Attribute name from which to retrieve the actorID out of the target actor's form
this.formAttributeName = "objectsManagerActor";
}
}
module.exports = ObjectsManagerFront;
registerFront(ObjectsManagerFront);

View File

@ -26,25 +26,19 @@ function enableActorReleaser(webConsoleUI) {
webConsoleUI &&
[MESSAGES_ADD, MESSAGES_CLEAR, PRIVATE_MESSAGES_CLEAR].includes(type)
) {
const promises = [];
state.messages.frontsToRelease.forEach(front => {
// We only release the front if it actually has a release method, if it isn't
// already destroyed, and if it's not in the sidebar (where we might still need it).
if (
front &&
typeof front.release === "function" &&
!front.isDestroyed() &&
(!state.ui.frontInSidebar ||
state.ui.frontInSidebar.actorID !== front.actorID)
) {
promises.push(front.release());
}
});
const { frontInSidebar } = state.ui;
let { frontsToRelease } = state.messages;
// Ignore the front for object still displayed in the sidebar, if there is one.
frontsToRelease = frontInSidebar
? frontsToRelease.filter(
front => frontInSidebar.actorID !== front.actorID
)
: state.messages.frontsToRelease;
// Emit an event we can listen to to make sure all the fronts were released.
Promise.all(promises).then(() =>
webConsoleUI.emitForTests("fronts-released")
);
webConsoleUI.hud.commands.objectCommand
.releaseObjects(frontsToRelease)
// Emit an event we can listen to to make sure all the fronts were released.
.then(() => webConsoleUI.emitForTests("fronts-released"));
// Reset `frontsToRelease` in message reducer.
state = reducer(state, {

View File

@ -25,6 +25,11 @@ async function getWebConsoleWrapper() {
const hud = {
currentTarget: { client: {}, getFront: () => {} },
getMappedExpression: () => {},
commands: {
objectCommand: {
releaseObjects: async frontsToRelease => {},
},
},
};
const webConsoleUi = getWebConsoleUiMock(hud);

View File

@ -142,7 +142,21 @@ function getWebConsoleUiMock(hud) {
return {
emit: () => {},
emitForTests: () => {},
hud,
hud: {
// @backward-compat { version 123 } A new Objects Manager front has a new "releaseActors" method.
// Once 123 is release, supportsReleaseActors could be removed.
commands: {
client: {
mainRoot: {
supportsReleaseActors: true,
},
},
objectCommand: {
releaseObjects: async frontsToRelease => {},
},
},
...hud,
},
clearNetworkRequests: () => {},
clearMessagesCache: () => {},
inspectObjectActor: () => {},

View File

@ -18,6 +18,7 @@ const {
getFirstMessage,
getLastMessage,
getPrivatePacket,
getWebConsoleUiMock,
setupActions,
setupStore,
} = require("resource://devtools/client/webconsole/test/node/helpers.js");
@ -202,7 +203,24 @@ describe("private messages", () => {
it("releases private backend actors on PRIVATE_MESSAGES_CLEAR action", () => {
const releasedActors = [];
const { dispatch, getState } = setupStore([]);
const { dispatch, getState } = setupStore([], {
webConsoleUI: getWebConsoleUiMock({
commands: {
client: {
mainRoot: {
supportsReleaseActors: true,
},
},
objectCommand: {
releaseObjects: async frontsToRelease => {
for (const front of frontsToRelease) {
releasedActors.push(front.actorID);
}
},
},
},
}),
});
const mockFrontRelease = function () {
releasedActors.push(this.actorID);
};

View File

@ -6,6 +6,7 @@ const {
getFirstMessage,
setupActions,
setupStore,
getWebConsoleUiMock,
} = require("resource://devtools/client/webconsole/test/node/helpers.js");
const {
@ -24,19 +25,31 @@ describe("Release actor enhancer:", () => {
it("releases backend actors when limit reached adding a single message", () => {
const logLimit = 100;
const releasedActors = [];
const mockFrontRelease = function () {
releasedActors.push(this.actorID);
};
const { dispatch, getState } = setupStore([], {
storeOptions: { logLimit },
webConsoleUI: getWebConsoleUiMock({
commands: {
client: {
mainRoot: {
supportsReleaseActors: true,
},
},
objectCommand: {
releaseObjects: async frontsToRelease => {
for (const front of frontsToRelease) {
releasedActors.push(front.actorID);
}
},
},
},
}),
});
// Add a log message.
const packet = stubPackets.get(
"console.log('myarray', ['red', 'green', 'blue'])"
);
packet.message.arguments[1].release = mockFrontRelease;
dispatch(actions.messagesAdd([packet]));
const firstMessage = getFirstMessage(getState());
@ -44,7 +57,6 @@ describe("Release actor enhancer:", () => {
// Add an evaluation result message (see Bug 1408321).
const evaluationResultPacket = stubPackets.get("new Date(0)");
evaluationResultPacket.result.release = mockFrontRelease;
dispatch(actions.messagesAdd([evaluationResultPacket]));
const secondMessageActor = evaluationResultPacket.result.actorID;
@ -52,7 +64,6 @@ describe("Release actor enhancer:", () => {
const assertPacket = stubPackets.get(
"console.assert(false, {message: 'foobar'})"
);
assertPacket.message.arguments[0].release = mockFrontRelease;
const thirdMessageActor = assertPacket.message.arguments[0].actorID;
for (let i = 1; i <= logCount; i++) {
@ -71,17 +82,28 @@ describe("Release actor enhancer:", () => {
const releasedActors = [];
const { dispatch, getState } = setupStore([], {
storeOptions: { logLimit },
webConsoleUI: getWebConsoleUiMock({
commands: {
client: {
mainRoot: {
supportsReleaseActors: true,
},
},
objectCommand: {
releaseObjects: async frontsToRelease => {
for (const front of frontsToRelease) {
releasedActors.push(front.actorID);
}
},
},
},
}),
});
const mockFrontRelease = function () {
releasedActors.push(this.actorID);
};
// Add a log message.
const logPacket = stubPackets.get(
"console.log('myarray', ['red', 'green', 'blue'])"
);
logPacket.message.arguments[1].release = mockFrontRelease;
dispatch(actions.messagesAdd([logPacket]));
const firstMessage = getFirstMessage(getState());
@ -89,7 +111,6 @@ describe("Release actor enhancer:", () => {
// Add an evaluation result message (see Bug 1408321).
const evaluationResultPacket = stubPackets.get("new Date(0)");
evaluationResultPacket.result.release = mockFrontRelease;
dispatch(actions.messagesAdd([evaluationResultPacket]));
const secondMessageActor = evaluationResultPacket.result.actorID;
@ -97,7 +118,6 @@ describe("Release actor enhancer:", () => {
const assertPacket = stubPackets.get(
"console.assert(false, {message: 'foobar'})"
);
assertPacket.message.arguments[0].release = mockFrontRelease;
dispatch(actions.messagesAdd([assertPacket]));
const thirdMessageActor = assertPacket.message.arguments[0].actorID;
@ -122,17 +142,29 @@ describe("Release actor enhancer:", () => {
it("properly releases backend actors after clear", () => {
const releasedActors = [];
const { dispatch, getState } = setupStore([]);
const mockFrontRelease = function () {
releasedActors.push(this.actorID);
};
const { dispatch, getState } = setupStore([], {
webConsoleUI: getWebConsoleUiMock({
commands: {
client: {
mainRoot: {
supportsReleaseActors: true,
},
},
objectCommand: {
releaseObjects: async frontsToRelease => {
for (const front of frontsToRelease) {
releasedActors.push(front.actorID);
}
},
},
},
}),
});
// Add a log message.
const logPacket = stubPackets.get(
"console.log('myarray', ['red', 'green', 'blue'])"
);
logPacket.message.arguments[1].release = mockFrontRelease;
dispatch(actions.messagesAdd([logPacket]));
const firstMessage = getFirstMessage(getState());
@ -142,31 +174,30 @@ describe("Release actor enhancer:", () => {
const assertPacket = stubPackets.get(
"console.assert(false, {message: 'foobar'})"
);
assertPacket.message.arguments[0].release = mockFrontRelease;
dispatch(actions.messagesAdd([assertPacket]));
const secondMessageActor = assertPacket.message.arguments[0].actorID;
// Add an evaluation result message (see Bug 1408321).
const evaluationResultPacket = stubPackets.get("new Date(0)");
evaluationResultPacket.result.release = mockFrontRelease;
dispatch(actions.messagesAdd([evaluationResultPacket]));
const thirdMessageActor = evaluationResultPacket.result.actorID;
// Add a message with a long string messageText property.
const longStringPacket = stubPackets.get("TypeError longString message");
longStringPacket.pageError.errorMessage.release = mockFrontRelease;
dispatch(actions.messagesAdd([longStringPacket]));
const fourthMessageActor =
longStringPacket.pageError.errorMessage.actorID;
const fifthMessageActor = longStringPacket.pageError.exception.actorID;
// Kick-off the actor release.
dispatch(actions.messagesClear());
expect(releasedActors.length).toBe(4);
expect(releasedActors.length).toBe(5);
expect(releasedActors).toInclude(firstMessageActor);
expect(releasedActors).toInclude(secondMessageActor);
expect(releasedActors).toInclude(thirdMessageActor);
expect(releasedActors).toInclude(fourthMessageActor);
expect(releasedActors).toInclude(fifthMessageActor);
});
});
});

View File

@ -42,6 +42,7 @@ DevToolsModules(
"manifest.js",
"memory.js",
"object.js",
"objects-manager.js",
"page-style.js",
"pause-scoped.js",
"perf.js",

View File

@ -815,6 +815,8 @@ class ObjectActor extends Actor {
this.hooks.customFormatterConfigDbgObj = null;
}
this._customFormatterItem = null;
this.obj = null;
this.thread = null;
}
}

View File

@ -0,0 +1,39 @@
/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
const {
objectsManagerSpec,
} = require("resource://devtools/shared/specs/objects-manager.js");
/**
* This actor is a singleton per Target which allows interacting with JS Object
* inspected by DevTools. Typically from the Console or Debugger.
*/
class ObjectsManagerActor extends Actor {
constructor(conn, targetActor) {
super(conn, objectsManagerSpec);
}
/**
* Release Actors by bulk by specifying their actor IDs.
* (Passing the whole Front [i.e. Actor's form] would be more expensive than passing only their IDs)
*
* @param {Array<string>} actorIDs
* List of all actor's IDs to release.
*/
releaseObjects(actorIDs) {
for (const actorID of actorIDs) {
const actor = this.conn.getActor(actorID);
// Note that release will also typically call Actor's destroy and unregister the actor from its Pool
if (actor) {
actor.release();
}
}
}
}
exports.ObjectsManagerActor = ObjectsManagerActor;

View File

@ -135,6 +135,8 @@ class RootActor extends Actor {
"dom.worker.console.dispatch_events_to_main_thread"
)
: true,
// @backward-compat { version 123 } A new Objects Manager front has a new "releaseActors" method.
supportsReleaseActors: true,
};
}

View File

@ -13,6 +13,9 @@ const {
} = require("resource://devtools/server/actors/webconsole.js");
const { ThreadActor } = require("resource://devtools/server/actors/thread.js");
const { TracerActor } = require("resource://devtools/server/actors/tracer.js");
const {
ObjectsManagerActor,
} = require("resource://devtools/server/actors/objects-manager.js");
const Targets = require("resource://devtools/server/actors/targets/index.js");
@ -75,10 +78,12 @@ class WorkerTargetActor extends BaseTargetActor {
this._consoleActor = new WebConsoleActor(this.conn, this);
this.tracerActor = new TracerActor(this.conn, this);
this.objectsManagerActor = new ObjectsManagerActor(this.conn, this);
this.manage(this.threadActor);
this.manage(this._consoleActor);
this.manage(this.tracerActor);
this.manage(this.objectsManagerActor);
}
// Expose the worker URL to the thread actor.
@ -94,6 +99,7 @@ class WorkerTargetActor extends BaseTargetActor {
consoleActor: this._consoleActor?.actorID,
threadActor: this.threadActor?.actorID,
tracerActor: this.tracerActor?.actorID,
objectsManagerActor: this.objectsManagerActor?.actorID,
id: this._workerDebuggerData.id,
type: this._workerDebuggerData.type,

View File

@ -255,6 +255,11 @@ const ActorRegistry = {
constructor: "TracerActor",
type: { target: true },
});
this.registerModule("devtools/server/actors/objects-manager", {
prefix: "objectsManager",
constructor: "ObjectsManagerActor",
type: { target: true },
});
},
/**

View File

@ -276,8 +276,6 @@ async function test_unsafe_grips(
response = await objClient.getPrototype();
check_prototype(response.prototype, data, isUnsafe, isWorkerServer);
await objClient.release();
}
await threadFront.resume();

View File

@ -13,6 +13,7 @@ const Commands = {
"devtools/shared/commands/inspected-window/inspected-window-command",
inspectorCommand: "devtools/shared/commands/inspector/inspector-command",
networkCommand: "devtools/shared/commands/network/network-command",
objectCommand: "devtools/shared/commands/object/object-command",
resourceCommand: "devtools/shared/commands/resource/resource-command",
rootResourceCommand:
"devtools/shared/commands/root-resource/root-resource-command",

View File

@ -6,6 +6,7 @@ DIRS += [
"inspected-window",
"inspector",
"network",
"object",
"resource",
"root-resource",
"script",

View File

@ -0,0 +1,10 @@
# 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(
"object-command.js",
)
if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"]

View File

@ -0,0 +1,63 @@
/* 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";
/**
* The ObjectCommand helps inspecting and managing lifecycle
* of all inspected JavaScript objects.
*/
class ObjectCommand {
constructor({ commands, descriptorFront, watcherFront }) {
this.#commands = commands;
}
#commands = null;
/**
* Release a set of object actors all at once.
*
* @param {Array<ObjectFront>} frontsToRelease
* List of fronts for the object to release.
*/
async releaseObjects(frontsToRelease) {
// @backward-compat { version 123 } A new Objects Manager front has a new "releaseActors" method.
// Only supportsReleaseActors=true codepath can be kept once 123 is the release channel.
const { supportsReleaseActors } = this.#commands.client.mainRoot.traits;
// First group all object fronts per target
const actorsPerTarget = new Map();
const promises = [];
for (const frontToRelease of frontsToRelease) {
const { targetFront } = frontToRelease;
// If the front is already destroyed, its target front will be nullified.
if (!targetFront) {
continue;
}
let actorIDsToRemove = actorsPerTarget.get(targetFront);
if (!actorIDsToRemove) {
actorIDsToRemove = [];
actorsPerTarget.set(targetFront, actorIDsToRemove);
}
if (supportsReleaseActors) {
actorIDsToRemove.push(frontToRelease.actorID);
frontToRelease.destroy();
} else {
promises.push(frontToRelease.release());
}
}
if (supportsReleaseActors) {
// Then release all fronts by bulk per target
for (const [targetFront, actorIDs] of actorsPerTarget) {
const objectsManagerFront = await targetFront.getFront("objects-manager");
promises.push(objectsManagerFront.releaseObjects(actorIDs));
}
}
await Promise.all(promises);
}
}
module.exports = ObjectCommand;

View File

@ -0,0 +1,9 @@
[DEFAULT]
tags = "devtools"
subsuite = "devtools"
support-files = [
"!/devtools/client/shared/test/shared-head.js",
"head.js",
]
["browser_object.js"]

View File

@ -0,0 +1,125 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
// Test the ObjectCommand
add_task(async function testObjectRelease() {
const tab = await addTab("data:text/html;charset=utf-8,Test page<script>var foo = { bar: 42 };</script>");
const commands = await CommandsFactory.forTab(tab);
await commands.targetCommand.startListening();
const { objectCommand } = commands;
const evaluationResponse = await commands.scriptCommand.execute(
"window.foo"
);
// Execute a second time so that the WebConsoleActor set this._lastConsoleInputEvaluation to another value
// and so we prevent freeing `window.foo`
await commands.scriptCommand.execute("");
await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
is(content.wrappedJSObject.foo.bar, 42);
const weakRef = Cu.getWeakReference(content.wrappedJSObject.foo);
// Hold off the weak reference on SpecialPowsers so that it can be accessed in the next SpecialPowers.spawn
SpecialPowers.weakRef = weakRef;
// Nullify this variable so that it should be freed
// unless the DevTools inspection still hold it in memory
content.wrappedJSObject.foo = null;
Cu.forceGC();
Cu.forceCC();
ok(SpecialPowers.weakRef.get(), "The 'foo' object can't be freed because of DevTools keeping a reference on it");
});
info("Release the server side actors which are keeping the object in memory");
const objectFront = evaluationResponse.result;
await commands.objectCommand.releaseObjects([objectFront]);
ok(objectFront.isDestroyed(), "The passed object front has been destroyed");
await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
await ContentTaskUtils.waitForCondition(() => {
Cu.forceGC();
Cu.forceCC();
return !SpecialPowers.weakRef.get();
}, "Wait for JS object to be freed", 500);
ok(!SpecialPowers.weakRef.get(), "The 'foo' object has been freed");
});
await commands.destroy();
BrowserTestUtils.removeTab(tab);
});
add_task(async function testMultiTargetObjectRelease() {
// This test fails with EFT disabled
if (!isEveryFrameTargetEnabled()) {
return;
}
const tab = await addTab(`data:text/html;charset=utf-8,Test page<iframe src="data:text/html,bar">/iframe>`);
const commands = await CommandsFactory.forTab(tab);
await commands.targetCommand.startListening();
const [,iframeTarget] = commands.targetCommand.getAllTargets(commands.targetCommand.ALL_TYPES);
is(iframeTarget.url, "data:text/html,bar");
const { objectCommand } = commands;
const evaluationResponse1 = await commands.scriptCommand.execute(
"window"
);
const evaluationResponse2 = await commands.scriptCommand.execute(
"window", {
selectedTargetFront: iframeTarget,
}
);
const object1 = evaluationResponse1.result;
const object2 = evaluationResponse2.result;
isnot(object1, object2, "The two window object fronts are different");
isnot(object1.targetFront, object2.targetFront, "The two window object fronts relates to two distinct targets");
is(object2.targetFront, iframeTarget, "The second object relates to the iframe target");
await commands.objectCommand.releaseObjects([object1, object2]);
ok(object1.isDestroyed(), "The first object front is destroyed");
ok(object2.isDestroyed(), "The second object front is destroyed");
await commands.destroy();
BrowserTestUtils.removeTab(tab);
});
add_task(async function testWorkerObjectRelease() {
const workerUrl = `data:text/javascript,const foo = {}`;
const tab = await addTab(`data:text/html;charset=utf-8,Test page<script>const worker = new Worker("${workerUrl}")</script>`);
const commands = await CommandsFactory.forTab(tab);
commands.targetCommand.listenForWorkers = true;
await commands.targetCommand.startListening();
const [,workerTarget] = commands.targetCommand.getAllTargets(commands.targetCommand.ALL_TYPES);
is(workerTarget.url, workerUrl);
const { objectCommand } = commands;
const evaluationResponse = await commands.scriptCommand.execute(
"foo", {
selectedTargetFront: workerTarget,
}
);
const object = evaluationResponse.result;
is(object.targetFront, workerTarget, "The 'foo' object relates to the worker target");
await commands.objectCommand.releaseObjects([object]);
ok(object.isDestroyed(), "The object front is destroyed");
await commands.destroy();
BrowserTestUtils.removeTab(tab);
});

View File

@ -0,0 +1,12 @@
/* 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";
/* eslint no-unused-vars: [2, {"vars": "local"}] */
Services.scriptloader.loadSubScript(
"chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js",
this
);

View File

@ -165,6 +165,11 @@ const Types = (exports.__TypesForTests = [
spec: "devtools/shared/specs/object",
front: null,
},
{
types: ["objects-manager"],
spec: "devtools/shared/specs/objects-manager",
front: "devtools/client/fronts/objects-manager",
},
{
types: ["pagestyle"],
spec: "devtools/shared/specs/page-style",

View File

@ -36,6 +36,7 @@ DevToolsModules(
"network-parent.js",
"node.js",
"object.js",
"objects-manager.js",
"page-style.js",
"perf.js",
"preference.js",

View File

@ -0,0 +1,25 @@
/* 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 {
generateActorSpec,
Arg,
} = require("resource://devtools/shared/protocol.js");
const objectsManagerSpec = generateActorSpec({
typeName: "objects-manager",
methods: {
releaseObjects: {
request: {
actorIDs: Arg(0, "array:string"),
},
response: {},
},
},
});
exports.objectsManagerSpec = objectsManagerSpec;