fune/devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs

569 lines
18 KiB
JavaScript

/* 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/. */
import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
XPCOMUtils.defineLazyServiceGetter(
lazy,
"wdm",
"@mozilla.org/dom/workers/workerdebuggermanager;1",
"nsIWorkerDebuggerManager"
);
ChromeUtils.defineLazyGetter(lazy, "Loader", () =>
ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs")
);
ChromeUtils.defineLazyGetter(lazy, "DevToolsUtils", () =>
lazy.Loader.require("resource://devtools/shared/DevToolsUtils.js")
);
ChromeUtils.defineESModuleGetters(lazy, {
isWindowGlobalPartOfContext:
"resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs",
SessionDataHelpers:
"resource://devtools/server/actors/watcher/SessionDataHelpers.sys.mjs",
});
// Name of the attribute into which we save data in `sharedData` object.
const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher";
export class DevToolsWorkerChild extends JSWindowActorChild {
constructor() {
super();
// The map is indexed by the Watcher Actor ID.
// The values are objects containing the following properties:
// - connection: the DevToolsServerConnection itself
// - workers: An array of object containing the following properties:
// - dbg: A WorkerDebuggerInstance
// - workerTargetForm: The associated worker target instance form
// - workerThreadServerForwardingPrefix: The prefix used to forward events to the
// worker target on the worker thread ().
// - forwardingPrefix: Prefix used by the JSWindowActorTransport pair to communicate
// between content and parent processes.
// - sessionData: Data (targets, resources, …) the watcher wants to be notified about.
// See WatcherRegistry.getSessionData to see the full list of properties.
this._connections = new Map();
EventEmitter.decorate(this);
}
_onWorkerRegistered(dbg) {
if (!this._shouldHandleWorker(dbg)) {
return;
}
for (const [watcherActorID, { connection, forwardingPrefix }] of this
._connections) {
this._createWorkerTargetActor({
dbg,
connection,
forwardingPrefix,
watcherActorID,
});
}
}
_onWorkerUnregistered(dbg) {
for (const [watcherActorID, { workers, forwardingPrefix }] of this
._connections) {
// Check if the worker registration was handled for this watcherActorID.
const unregisteredActorIndex = workers.findIndex(worker => {
try {
// Accessing the WorkerDebugger id might throw (NS_ERROR_UNEXPECTED).
return worker.dbg.id === dbg.id;
} catch (e) {
return false;
}
});
if (unregisteredActorIndex === -1) {
continue;
}
const { workerTargetForm, transport } = workers[unregisteredActorIndex];
transport.close();
try {
this.sendAsyncMessage("DevToolsWorkerChild:workerTargetDestroyed", {
watcherActorID,
forwardingPrefix,
workerTargetForm,
});
} catch (e) {
return;
}
workers.splice(unregisteredActorIndex, 1);
}
}
onDOMWindowCreated() {
const { sharedData } = Services.cpmm;
const sessionDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME);
if (!sessionDataByWatcherActor) {
throw new Error(
"Request to instantiate the target(s) for the Worker, but `sharedData` is empty about watched targets"
);
}
// Create one Target actor for each prefix/client which listen to workers
for (const [watcherActorID, sessionData] of sessionDataByWatcherActor) {
const { targets, connectionPrefix, sessionContext } = sessionData;
if (
targets?.includes("worker") &&
lazy.isWindowGlobalPartOfContext(this.manager, sessionContext, {
acceptInitialDocument: true,
forceAcceptTopLevelTarget: true,
acceptSameProcessIframes: true,
})
) {
this._watchWorkerTargets({
watcherActorID,
parentConnectionPrefix: connectionPrefix,
sessionData,
});
}
}
}
/**
* Function handling messages sent by DevToolsWorkerParent (part of JSWindowActor API).
*
* @param {Object} message
* @param {String} message.name
* @param {*} message.data
*/
receiveMessage(message) {
// All messages pass `sessionContext` (except packet) and are expected
// to match isWindowGlobalPartOfContext result.
if (message.name != "DevToolsWorkerParent:packet") {
const { browserId } = message.data.sessionContext;
// Re-check here, just to ensure that both parent and content processes agree
// on what should or should not be watched.
if (
this.manager.browsingContext.browserId != browserId &&
!lazy.isWindowGlobalPartOfContext(
this.manager,
message.data.sessionContext,
{
acceptInitialDocument: true,
}
)
) {
throw new Error(
"Mismatch between DevToolsWorkerParent and DevToolsWorkerChild " +
(this.manager.browsingContext.browserId == browserId
? "window global shouldn't be notified (isWindowGlobalPartOfContext mismatch)"
: `expected browsing context with ID ${browserId}, but got ${this.manager.browsingContext.browserId}`)
);
}
}
switch (message.name) {
case "DevToolsWorkerParent:instantiate-already-available": {
const { watcherActorID, connectionPrefix, sessionData } = message.data;
return this._watchWorkerTargets({
watcherActorID,
parentConnectionPrefix: connectionPrefix,
sessionData,
});
}
case "DevToolsWorkerParent:destroy": {
const { watcherActorID } = message.data;
return this._destroyTargetActors(watcherActorID);
}
case "DevToolsWorkerParent:addOrSetSessionDataEntry": {
const { watcherActorID, type, entries, updateType } = message.data;
return this._addOrSetSessionDataEntry(
watcherActorID,
type,
entries,
updateType
);
}
case "DevToolsWorkerParent:removeSessionDataEntry": {
const { watcherActorID, type, entries } = message.data;
return this._removeSessionDataEntry(watcherActorID, type, entries);
}
case "DevToolsWorkerParent:packet":
return this.emit("packet-received", message);
default:
throw new Error(
"Unsupported message in DevToolsWorkerParent: " + message.name
);
}
}
/**
* Instantiate targets for existing workers, watch for worker registration and listen
* for resources on those workers, for given connection and context. Targets are sent
* to the DevToolsWorkerParent via the DevToolsWorkerChild:workerTargetAvailable message.
*
* @param {Object} options
* @param {String} options.watcherActorID: The ID of the WatcherActor who requested to
* observe and create these target actors.
* @param {String} options.parentConnectionPrefix: The prefix of the DevToolsServerConnection
* of the Watcher Actor. This is used to compute a unique ID for the target actor.
* @param {Object} options.sessionData: Data (targets, resources, …) the watcher wants
* to be notified about. See WatcherRegistry.getSessionData to see the full list
* of properties.
*/
async _watchWorkerTargets({
watcherActorID,
parentConnectionPrefix,
sessionData,
}) {
if (this._connections.has(watcherActorID)) {
throw new Error(
"DevToolsWorkerChild _watchWorkerTargets was called more than once" +
` for the same Watcher (Actor ID: "${watcherActorID}")`
);
}
// Listen for new workers that will be spawned.
if (!this._workerDebuggerListener) {
this._workerDebuggerListener = {
onRegister: this._onWorkerRegistered.bind(this),
onUnregister: this._onWorkerUnregistered.bind(this),
};
lazy.wdm.addListener(this._workerDebuggerListener);
}
// Compute a unique prefix, just for this WindowGlobal,
// which will be used to create a JSWindowActorTransport pair between content and parent processes.
// This is slightly hacky as we typicaly compute Prefix and Actor ID via `DevToolsServerConnection.allocID()`,
// but here, we can't have access to any DevTools connection as we are really early in the content process startup
// WindowGlobalChild's innerWindowId should be unique across processes, so it should be safe?
// (this.manager == WindowGlobalChild interface)
const forwardingPrefix =
parentConnectionPrefix + "workerGlobal" + this.manager.innerWindowId;
const connection = this._createConnection(forwardingPrefix);
this._connections.set(watcherActorID, {
connection,
workers: [],
forwardingPrefix,
sessionData,
});
const promises = [];
for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) {
if (!this._shouldHandleWorker(dbg)) {
continue;
}
promises.push(
this._createWorkerTargetActor({
dbg,
connection,
forwardingPrefix,
watcherActorID,
})
);
}
await Promise.all(promises);
}
_createConnection(forwardingPrefix) {
const { DevToolsServer } = lazy.Loader.require(
"resource://devtools/server/devtools-server.js"
);
DevToolsServer.init();
// We want a special server without any root actor and only target-scoped actors.
// We are going to spawn a WorkerTargetActor instance in the next few lines,
// it is going to act like a root actor without being one.
DevToolsServer.registerActors({ target: true });
const connection = DevToolsServer.connectToParentWindowActor(
this,
forwardingPrefix
);
return connection;
}
/**
* Indicates whether or not we should handle the worker debugger
*
* @param {WorkerDebugger} dbg: The worker debugger we want to check.
* @returns {Boolean}
*/
_shouldHandleWorker(dbg) {
// We only want to create targets for non-closed dedicated worker, in the same document
return (
lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg) &&
dbg.type === Ci.nsIWorkerDebugger.TYPE_DEDICATED &&
dbg.windowIDs.includes(this.manager.innerWindowId)
);
}
async _createWorkerTargetActor({
dbg,
connection,
forwardingPrefix,
watcherActorID,
}) {
// Prevent the debuggee from executing in this worker until the client has
// finished attaching to it. This call will throw if the debugger is already "registered"
// (i.e. if this is called outside of the register listener)
// See https://searchfox.org/mozilla-central/rev/84922363f4014eae684aabc4f1d06380066494c5/dom/workers/nsIWorkerDebugger.idl#55-66
try {
dbg.setDebuggerReady(false);
} catch (e) {}
const watcherConnectionData = this._connections.get(watcherActorID);
const { sessionData } = watcherConnectionData;
const workerThreadServerForwardingPrefix =
connection.allocID("workerTarget");
// Create the actual worker target actor, in the worker thread.
const { connectToWorker } = lazy.Loader.require(
"resource://devtools/server/connectors/worker-connector.js"
);
const onConnectToWorker = connectToWorker(
connection,
dbg,
workerThreadServerForwardingPrefix,
{
sessionData,
sessionContext: sessionData.sessionContext,
}
);
try {
await onConnectToWorker;
} catch (e) {
// onConnectToWorker can reject if the Worker Debugger is closed; so we only want to
// resume the debugger if it is not closed (otherwise it can cause crashes).
if (!dbg.isClosed) {
dbg.setDebuggerReady(true);
}
return;
}
const { workerTargetForm, transport } = await onConnectToWorker;
try {
this.sendAsyncMessage("DevToolsWorkerChild:workerTargetAvailable", {
watcherActorID,
forwardingPrefix,
workerTargetForm,
});
} catch (e) {
// If there was an error while sending the message, we are not going to use this
// connection to communicate with the worker.
transport.close();
return;
}
// Only add data to the connection if we successfully send the
// workerTargetAvailable message.
watcherConnectionData.workers.push({
dbg,
transport,
workerTargetForm,
workerThreadServerForwardingPrefix,
});
}
_destroyTargetActors(watcherActorID) {
const watcherConnectionData = this._connections.get(watcherActorID);
this._connections.delete(watcherActorID);
// This connection has already been cleaned?
if (!watcherConnectionData) {
console.error(
`Trying to destroy a target actor that doesn't exists, or has already been destroyed. Watcher Actor ID:${watcherActorID}`
);
return;
}
for (const {
dbg,
transport,
workerThreadServerForwardingPrefix,
} of watcherConnectionData.workers) {
try {
if (lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
dbg.postMessage(
JSON.stringify({
type: "disconnect",
forwardingPrefix: workerThreadServerForwardingPrefix,
})
);
}
} catch (e) {}
transport.close();
}
watcherConnectionData.connection.close();
}
async sendPacket(packet, prefix) {
return this.sendAsyncMessage("DevToolsWorkerChild:packet", {
packet,
prefix,
});
}
async _addOrSetSessionDataEntry(watcherActorID, type, entries, updateType) {
const watcherConnectionData = this._connections.get(watcherActorID);
if (!watcherConnectionData) {
return;
}
lazy.SessionDataHelpers.addOrSetSessionDataEntry(
watcherConnectionData.sessionData,
type,
entries,
updateType
);
const promises = [];
for (const {
dbg,
workerThreadServerForwardingPrefix,
} of watcherConnectionData.workers) {
promises.push(
addOrSetSessionDataEntryInWorkerTarget({
dbg,
workerThreadServerForwardingPrefix,
type,
entries,
updateType,
})
);
}
await Promise.all(promises);
}
_removeSessionDataEntry(watcherActorID, type, entries) {
const watcherConnectionData = this._connections.get(watcherActorID);
if (!watcherConnectionData) {
return;
}
lazy.SessionDataHelpers.removeSessionDataEntry(
watcherConnectionData.sessionData,
type,
entries
);
for (const {
dbg,
workerThreadServerForwardingPrefix,
} of watcherConnectionData.workers) {
if (lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
dbg.postMessage(
JSON.stringify({
type: "remove-session-data-entry",
forwardingPrefix: workerThreadServerForwardingPrefix,
dataEntryType: type,
entries,
})
);
}
}
}
handleEvent({ type }) {
// DOMWindowCreated is registered from the WatcherRegistry via `ActorManagerParent.addJSWindowActors`
// as a DOM event to be listened to and so is fired by JSWindowActor platform code.
if (type == "DOMWindowCreated") {
this.onDOMWindowCreated();
}
}
_removeExistingWorkerDebuggerListener() {
if (this._workerDebuggerListener) {
lazy.wdm.removeListener(this._workerDebuggerListener);
this._workerDebuggerListener = null;
}
}
/**
* Part of JSActor API
* https://searchfox.org/mozilla-central/rev/d9f92154813fbd4a528453c33886dc3a74f27abb/dom/chrome-webidl/JSActor.webidl#41-42,52
*
* > The didDestroy method, if present, will be called after the actor is no
* > longer able to receive any more messages.
*/
didDestroy() {
this._removeExistingWorkerDebuggerListener();
for (const [watcherActorID, watcherConnectionData] of this._connections) {
const { connection } = watcherConnectionData;
this._destroyTargetActors(watcherActorID);
connection.close();
}
this._connections.clear();
}
}
/**
* Communicate the type and entries to the Worker Target actor, via the WorkerDebugger.
*
* @param {WorkerDebugger} dbg
* @param {String} workerThreadServerForwardingPrefix
* @param {String} type
* Session data type name
* @param {Array} entries
* Session data entries to add or set.
* @param {String} updateType
* Either "add" or "set", to control if we should only add some items,
* or replace the whole data set with the new entries.
* @returns {Promise} Returns a Promise that resolves once the data entry were handled
* by the worker target.
*/
function addOrSetSessionDataEntryInWorkerTarget({
dbg,
workerThreadServerForwardingPrefix,
type,
entries,
updateType,
}) {
if (!lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
return Promise.resolve();
}
return new Promise(resolve => {
// Wait until we're notified by the worker that the resources are watched.
// This is important so we know existing resources were handled.
const listener = {
onMessage: message => {
message = JSON.parse(message);
if (message.type === "session-data-entry-added-or-set") {
resolve();
dbg.removeListener(listener);
}
},
// Resolve if the worker is being destroyed so we don't have a dangling promise.
onClose: () => resolve(),
};
dbg.addListener(listener);
dbg.postMessage(
JSON.stringify({
type: "add-or-set-session-data-entry",
forwardingPrefix: workerThreadServerForwardingPrefix,
dataEntryType: type,
entries,
updateType,
})
);
});
}