diff options
Diffstat (limited to 'devtools/server/connectors/js-process-actor/target-watchers/worker.sys.mjs')
-rw-r--r-- | devtools/server/connectors/js-process-actor/target-watchers/worker.sys.mjs | 457 |
1 files changed, 457 insertions, 0 deletions
diff --git a/devtools/server/connectors/js-process-actor/target-watchers/worker.sys.mjs b/devtools/server/connectors/js-process-actor/target-watchers/worker.sys.mjs new file mode 100644 index 0000000000..0b67e8b038 --- /dev/null +++ b/devtools/server/connectors/js-process-actor/target-watchers/worker.sys.mjs @@ -0,0 +1,457 @@ +/* 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 { ContentProcessWatcherRegistry } from "resource://devtools/server/connectors/js-process-actor/ContentProcessWatcherRegistry.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyServiceGetter( + lazy, + "wdm", + "@mozilla.org/dom/workers/workerdebuggermanager;1", + "nsIWorkerDebuggerManager" +); + +const { TYPE_DEDICATED, TYPE_SERVICE, TYPE_SHARED } = Ci.nsIWorkerDebugger; + +export class WorkerTargetWatcherClass { + constructor(workerTargetType = "worker") { + this.#workerTargetType = workerTargetType; + this.#workerDebuggerListener = { + onRegister: this.#onWorkerRegister.bind(this), + onUnregister: this.#onWorkerUnregister.bind(this), + }; + } + + // {String} + #workerTargetType; + // {nsIWorkerDebuggerListener} + #workerDebuggerListener; + + watch() { + lazy.wdm.addListener(this.#workerDebuggerListener); + } + + unwatch() { + lazy.wdm.removeListener(this.#workerDebuggerListener); + } + + createTargetsForWatcher(watcherDataObject) { + const { sessionData } = watcherDataObject; + for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) { + if (!this.shouldHandleWorker(sessionData, dbg, this.#workerTargetType)) { + continue; + } + this.createWorkerTargetActor(watcherDataObject, dbg); + } + } + + async addOrSetSessionDataEntry(watcherDataObject, type, entries, updateType) { + // Collect the SessionData update into `pendingWorkers` in order to notify + // about the updates to workers which are still in process of being hooked by devtools. + for (const concurrentSessionUpdates of watcherDataObject.pendingWorkers) { + concurrentSessionUpdates.push({ + type, + entries, + updateType, + }); + } + + const promises = []; + for (const { + dbg, + workerThreadServerForwardingPrefix, + } of watcherDataObject.workers) { + promises.push( + addOrSetSessionDataEntryInWorkerTarget({ + dbg, + workerThreadServerForwardingPrefix, + type, + entries, + updateType, + }) + ); + } + await Promise.all(promises); + } + + /** + * Called whenever a new Worker is instantiated in the current process + * + * @param {WorkerDebugger} dbg + */ + #onWorkerRegister(dbg) { + // Create a Target Actor for each watcher currently watching for Workers + for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects( + this.#workerTargetType + )) { + const { sessionData } = watcherDataObject; + if (this.shouldHandleWorker(sessionData, dbg, this.#workerTargetType)) { + this.createWorkerTargetActor(watcherDataObject, dbg); + } + } + } + + /** + * Called whenever a Worker is destroyed in the current process + * + * @param {WorkerDebugger} dbg + */ + #onWorkerUnregister(dbg) { + for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects( + this.#workerTargetType + )) { + const { watcherActorID, workers } = watcherDataObject; + // 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]; + // Close the transport made to the worker thread + transport.close(); + + try { + watcherDataObject.jsProcessActor.sendAsyncMessage( + "DevToolsProcessChild:targetDestroyed", + { + actors: [ + { + watcherActorID, + targetActorForm: workerTargetForm, + }, + ], + options: {}, + } + ); + } catch (e) { + // This often throws as the JSActor is being destroyed when DevTools closes + // and we are trying to notify about the destroyed targets. + } + + workers.splice(unregisteredActorIndex, 1); + } + } + + /** + * Instantiate a worker target actor related to a given WorkerDebugger object + * and for a given watcher actor. + * + * @param {Object} watcherDataObject + * @param {WorkerDebugger} dbg + */ + async createWorkerTargetActor(watcherDataObject, dbg) { + // 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) { + if (!e.message.startsWith("Component returned failure code")) { + throw e; + } + } + + const { watcherActorID } = watcherDataObject; + const { connection, loader } = + ContentProcessWatcherRegistry.getOrCreateConnectionForWatcher( + watcherActorID + ); + + // Compute a unique prefix for the bridge made between this content process main thread + // and the worker thread. + const workerThreadServerForwardingPrefix = + connection.allocID("workerTarget"); + + const { connectToWorker } = loader.require( + "resource://devtools/server/connectors/worker-connector.js" + ); + + // Create the actual worker target actor, in the worker thread. + const { sessionData, sessionContext } = watcherDataObject; + const onConnectToWorker = connectToWorker( + connection, + dbg, + workerThreadServerForwardingPrefix, + { + sessionData, + sessionContext, + } + ); + + // Only add data to the connection if we successfully send the + // workerTargetAvailable message. + const workerInfo = { + dbg, + workerThreadServerForwardingPrefix, + }; + watcherDataObject.workers.push(workerInfo); + + // The onConnectToWorker is async and we may receive new Session Data (e.g breakpoints) + // while we are instantiating the worker targets. + // Let cache the pending session data and flush it after the targets are being instantiated. + const concurrentSessionUpdates = []; + watcherDataObject.pendingWorkers.add(concurrentSessionUpdates); + + try { + await onConnectToWorker; + } catch (e) { + // connectToWorker is supposed to call setDebuggerReady(true) to release the worker execution. + // But if anything goes wrong and an exception is thrown, ensure releasing its execution, + // otherwise if devtools is broken, it will freeze the worker indefinitely. + // + // 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); + } + // Also unregister the worker + watcherDataObject.workers.splice( + watcherDataObject.workers.indexOf(workerInfo), + 1 + ); + watcherDataObject.pendingWorkers.delete(concurrentSessionUpdates); + return; + } + watcherDataObject.pendingWorkers.delete(concurrentSessionUpdates); + + const { workerTargetForm, transport } = await onConnectToWorker; + workerInfo.workerTargetForm = workerTargetForm; + workerInfo.transport = transport; + + const { forwardingPrefix } = watcherDataObject; + // Immediately queue a message for the parent process, before applying any SessionData + // as it may start emitting RDP events on the target actor and be lost if the client + // didn't get notified about the target actor first + try { + watcherDataObject.jsProcessActor.sendAsyncMessage( + "DevToolsProcessChild:targetAvailable", + { + watcherActorID, + forwardingPrefix, + targetActorForm: 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(); + // Also unregister the worker + watcherDataObject.workers.splice( + watcherDataObject.workers.indexOf(workerInfo), + 1 + ); + return; + } + + // Dispatch to the worker thread any SessionData updates which may have been notified + // while we were waiting for onConnectToWorker to resolve. + const promises = []; + for (const { type, entries, updateType } of concurrentSessionUpdates) { + promises.push( + addOrSetSessionDataEntryInWorkerTarget({ + dbg, + workerThreadServerForwardingPrefix, + type, + entries, + updateType, + }) + ); + } + await Promise.all(promises); + } + + destroyTargetsForWatcher(watcherDataObject) { + // Notify to all worker threads to destroy their target actor running in them + for (const { + dbg, + workerThreadServerForwardingPrefix, + transport, + } of watcherDataObject.workers) { + if (isWorkerDebuggerAlive(dbg)) { + try { + dbg.postMessage( + JSON.stringify({ + type: "disconnect", + forwardingPrefix: workerThreadServerForwardingPrefix, + }) + ); + } catch (e) {} + } + // Also cleanup the DevToolsTransport created in the main thread to bridge RDP to the worker thread + if (transport) { + transport.close(); + } + } + // Wipe all workers info + watcherDataObject.workers = []; + } + + /** + * Indicates whether or not we should handle the worker debugger + * + * @param {Object} sessionData + * The session data for a given watcher, which includes metadata + * about the debugged context. + * @param {WorkerDebugger} dbg + * The worker debugger we want to check. + * @param {String} targetType + * The expected worker target type. + * @returns {Boolean} + */ + shouldHandleWorker(sessionData, dbg, targetType) { + if (!isWorkerDebuggerAlive(dbg)) { + return false; + } + + if ( + (dbg.type === TYPE_DEDICATED && targetType != "worker") || + (dbg.type === TYPE_SERVICE && targetType != "service_worker") || + (dbg.type === TYPE_SHARED && targetType != "shared_worker") + ) { + return false; + } + + const { type: sessionContextType } = sessionData.sessionContext; + if (sessionContextType == "all") { + return true; + } + if (sessionContextType == "content-process") { + throw new Error( + "Content process session type shouldn't try to spawn workers" + ); + } + if (sessionContextType == "worker") { + throw new Error( + "worker session type should spawn only one target via the WorkerDescriptor" + ); + } + + if (dbg.type === TYPE_DEDICATED) { + // Assume that all dedicated workers executes in the same process as the debugged document. + const browsingContext = BrowsingContext.getCurrentTopByBrowserId( + sessionData.sessionContext.browserId + ); + // If we aren't executing in the same process as the worker and its BrowsingContext, + // it will be undefined. + if (!browsingContext) { + return false; + } + for (const subBrowsingContext of browsingContext.getAllBrowsingContextsInSubtree()) { + if ( + subBrowsingContext.currentWindowContext && + dbg.windowIDs.includes( + subBrowsingContext.currentWindowContext.innerWindowId + ) + ) { + return true; + } + } + return false; + } + + if (dbg.type === TYPE_SERVICE) { + // Accessing `nsIPrincipal.host` may easily throw on non-http URLs. + // Ignore all non-HTTP as they most likely don't have any valid host name. + if (!dbg.principal.scheme.startsWith("http")) { + return false; + } + + const workerHost = dbg.principal.hostPort; + return workerHost == sessionData["browser-element-host"][0]; + } + + if (dbg.type === TYPE_SHARED) { + // We still don't fully support instantiating targets for shared workers from the server side + throw new Error( + "Server side listening for shared workers isn't supported" + ); + } + + return false; + } +} + +/** + * 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 (!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") { + dbg.removeListener(listener); + resolve(); + } + }, + // Resolve if the worker is being destroyed so we don't have a dangling promise. + onClose: () => { + dbg.removeListener(listener); + resolve(); + }, + }; + + dbg.addListener(listener); + + dbg.postMessage( + JSON.stringify({ + type: "add-or-set-session-data-entry", + forwardingPrefix: workerThreadServerForwardingPrefix, + dataEntryType: type, + entries, + updateType, + }) + ); + }); +} + +function isWorkerDebuggerAlive(dbg) { + if (dbg.isClosed) { + return false; + } + // Some workers are zombies. `isClosed` is false, but nothing works. + // `postMessage` is a noop, `addListener`'s `onClosed` doesn't work. + return ( + dbg.window?.docShell || + // consider dbg without `window` as being alive, as they aren't related + // to any docShell and probably do not suffer from this issue + !dbg.window + ); +} + +export const WorkerTargetWatcher = new WorkerTargetWatcherClass(); |