worker.sys.mjs (15835B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { ContentProcessWatcherRegistry } from "resource://devtools/server/connectors/js-process-actor/ContentProcessWatcherRegistry.sys.mjs"; 6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 7 8 const lazy = {}; 9 XPCOMUtils.defineLazyServiceGetter( 10 lazy, 11 "wdm", 12 "@mozilla.org/dom/workers/workerdebuggermanager;1", 13 Ci.nsIWorkerDebuggerManager 14 ); 15 16 const { TYPE_DEDICATED, TYPE_SERVICE, TYPE_SHARED } = Ci.nsIWorkerDebugger; 17 18 export class WorkerTargetWatcherClass { 19 constructor(workerTargetType = "worker") { 20 this.#workerTargetType = workerTargetType; 21 this.#workerDebuggerListener = { 22 onRegister: this.#onWorkerRegister.bind(this), 23 onUnregister: this.#onWorkerUnregister.bind(this), 24 }; 25 } 26 27 // {String} 28 #workerTargetType; 29 // {nsIWorkerDebuggerListener} 30 #workerDebuggerListener; 31 32 watch() { 33 lazy.wdm.addListener(this.#workerDebuggerListener); 34 } 35 36 unwatch() { 37 lazy.wdm.removeListener(this.#workerDebuggerListener); 38 } 39 40 createTargetsForWatcher(watcherDataObject) { 41 const { sessionData } = watcherDataObject; 42 for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) { 43 if (!this.shouldHandleWorker(sessionData, dbg, this.#workerTargetType)) { 44 continue; 45 } 46 this.createWorkerTargetActor(watcherDataObject, dbg); 47 } 48 } 49 50 async addOrSetSessionDataEntry(watcherDataObject, type, entries, updateType) { 51 // Collect the SessionData update into `pendingWorkers` in order to notify 52 // about the updates to workers which are still in process of being hooked by devtools. 53 for (const concurrentSessionUpdates of watcherDataObject.pendingWorkers[ 54 this.#workerTargetType 55 ]) { 56 concurrentSessionUpdates.push({ 57 type, 58 entries, 59 updateType, 60 }); 61 } 62 63 const promises = []; 64 for (const { dbg, workerThreadServerForwardingPrefix } of watcherDataObject 65 .workers[this.#workerTargetType]) { 66 promises.push( 67 addOrSetSessionDataEntryInWorkerTarget({ 68 dbg, 69 workerThreadServerForwardingPrefix, 70 type, 71 entries, 72 updateType, 73 }) 74 ); 75 } 76 await Promise.all(promises); 77 } 78 79 removeSessionDataEntry(watcherDataObject, type, entries) { 80 for (const { dbg, workerThreadServerForwardingPrefix } of watcherDataObject 81 .workers[this.#workerTargetType]) { 82 if (!isWorkerDebuggerAlive(dbg)) { 83 continue; 84 } 85 86 dbg.postMessage( 87 JSON.stringify({ 88 type: "remove-session-data-entry", 89 forwardingPrefix: workerThreadServerForwardingPrefix, 90 dataEntryType: type, 91 entries, 92 }) 93 ); 94 } 95 } 96 97 /** 98 * Called whenever a new Worker is instantiated in the current process 99 * 100 * @param {WorkerDebugger} dbg 101 */ 102 #onWorkerRegister(dbg) { 103 // Create a Target Actor for each watcher currently watching for Workers 104 for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects( 105 this.#workerTargetType 106 )) { 107 const { sessionData } = watcherDataObject; 108 if (this.shouldHandleWorker(sessionData, dbg, this.#workerTargetType)) { 109 this.createWorkerTargetActor(watcherDataObject, dbg); 110 } 111 } 112 } 113 114 /** 115 * Called whenever a Worker is destroyed in the current process 116 * 117 * @param {WorkerDebugger} dbg 118 */ 119 #onWorkerUnregister(dbg) { 120 for (const watcherDataObject of ContentProcessWatcherRegistry.getAllWatchersDataObjects( 121 this.#workerTargetType 122 )) { 123 const { watcherActorID } = watcherDataObject; 124 const workerList = watcherDataObject.workers[this.#workerTargetType]; 125 // Check if the worker registration was handled for this watcherActorID. 126 const unregisteredActorIndex = workerList.findIndex(worker => { 127 try { 128 // Accessing the WorkerDebugger id might throw (NS_ERROR_UNEXPECTED). 129 return worker.dbg.id === dbg.id; 130 } catch (e) { 131 return false; 132 } 133 }); 134 if (unregisteredActorIndex === -1) { 135 continue; 136 } 137 138 const { workerTargetForm, transport } = 139 workerList[unregisteredActorIndex]; 140 // Close the transport made to the worker thread 141 transport.close(); 142 143 try { 144 watcherDataObject.jsProcessActor.sendAsyncMessage( 145 "DevToolsProcessChild:targetDestroyed", 146 { 147 actors: [ 148 { 149 watcherActorID, 150 targetActorForm: workerTargetForm, 151 }, 152 ], 153 options: {}, 154 } 155 ); 156 } catch (e) { 157 // This often throws as the JSActor is being destroyed when DevTools closes 158 // and we are trying to notify about the destroyed targets. 159 } 160 161 workerList.splice(unregisteredActorIndex, 1); 162 } 163 } 164 165 /** 166 * Instantiate a worker target actor related to a given WorkerDebugger object 167 * and for a given watcher actor. 168 * 169 * @param {object} watcherDataObject 170 * @param {WorkerDebugger} dbg 171 */ 172 async createWorkerTargetActor(watcherDataObject, dbg) { 173 // Prevent the debuggee from executing in this worker until the client has 174 // finished attaching to it. This call will throw if the debugger is already "registered" 175 // (i.e. if this is called outside of the register listener) 176 // See https://searchfox.org/mozilla-central/rev/84922363f4014eae684aabc4f1d06380066494c5/dom/workers/nsIWorkerDebugger.idl#55-66 177 try { 178 dbg.setDebuggerReady(false); 179 } catch (e) { 180 if (!e.message.startsWith("Component returned failure code")) { 181 throw e; 182 } 183 } 184 185 const { watcherActorID } = watcherDataObject; 186 const { connection, loader } = 187 ContentProcessWatcherRegistry.getOrCreateConnectionForWatcher( 188 watcherActorID 189 ); 190 191 // Compute a unique prefix for the bridge made between this content process main thread 192 // and the worker thread. 193 const workerThreadServerForwardingPrefix = 194 connection.allocID("workerTarget"); 195 196 const { connectToWorker } = loader.require( 197 "resource://devtools/server/connectors/worker-connector.js" 198 ); 199 200 // Create the actual worker target actor, in the worker thread. 201 const { sessionData, sessionContext } = watcherDataObject; 202 const onConnectToWorker = connectToWorker( 203 connection, 204 dbg, 205 workerThreadServerForwardingPrefix, 206 { 207 sessionData, 208 sessionContext, 209 } 210 ); 211 212 // Only add data to the connection if we successfully send the 213 // workerTargetAvailable message. 214 const workerInfo = { 215 dbg, 216 workerThreadServerForwardingPrefix, 217 }; 218 const workerList = watcherDataObject.workers[this.#workerTargetType]; 219 workerList.push(workerInfo); 220 221 // The onConnectToWorker is async and we may receive new Session Data (e.g breakpoints) 222 // while we are instantiating the worker targets. 223 // Let cache the pending session data and flush it after the targets are being instantiated. 224 const concurrentSessionUpdates = []; 225 const pendingWorkers = 226 watcherDataObject.pendingWorkers[this.#workerTargetType]; 227 pendingWorkers.add(concurrentSessionUpdates); 228 229 try { 230 await onConnectToWorker; 231 } catch (e) { 232 // connectToWorker is supposed to call setDebuggerReady(true) to release the worker execution. 233 // But if anything goes wrong and an exception is thrown, ensure releasing its execution, 234 // otherwise if devtools is broken, it will freeze the worker indefinitely. 235 // 236 // onConnectToWorker can reject if the Worker Debugger is closed; so we only want to 237 // resume the debugger if it is not closed (otherwise it can cause crashes). 238 if (!dbg.isClosed) { 239 dbg.setDebuggerReady(true); 240 } 241 // Also unregister the worker 242 workerList.splice(workerList.indexOf(workerInfo), 1); 243 pendingWorkers.delete(concurrentSessionUpdates); 244 return; 245 } 246 pendingWorkers.delete(concurrentSessionUpdates); 247 248 const { workerTargetForm, transport } = await onConnectToWorker; 249 workerInfo.workerTargetForm = workerTargetForm; 250 workerInfo.transport = transport; 251 252 // Bail out and cleanup the actor by closing the transport, 253 // if we stopped listening for workers while waiting for onConnectToWorker resolution. 254 if (!workerList.includes(workerInfo)) { 255 transport.close(); 256 return; 257 } 258 259 const { forwardingPrefix } = watcherDataObject; 260 // Immediately queue a message for the parent process, before applying any SessionData 261 // as it may start emitting RDP events on the target actor and be lost if the client 262 // didn't get notified about the target actor first 263 try { 264 watcherDataObject.jsProcessActor.sendAsyncMessage( 265 "DevToolsProcessChild:targetAvailable", 266 { 267 watcherActorID, 268 forwardingPrefix, 269 targetActorForm: workerTargetForm, 270 } 271 ); 272 } catch (e) { 273 // If there was an error while sending the message, we are not going to use this 274 // connection to communicate with the worker. 275 transport.close(); 276 // Also unregister the worker 277 workerList.splice(workerList.indexOf(workerInfo), 1); 278 return; 279 } 280 281 // Dispatch to the worker thread any SessionData updates which may have been notified 282 // while we were waiting for onConnectToWorker to resolve. 283 const promises = []; 284 for (const { type, entries, updateType } of concurrentSessionUpdates) { 285 promises.push( 286 addOrSetSessionDataEntryInWorkerTarget({ 287 dbg, 288 workerThreadServerForwardingPrefix, 289 type, 290 entries, 291 updateType, 292 }) 293 ); 294 } 295 await Promise.all(promises); 296 } 297 298 destroyTargetsForWatcher(watcherDataObject) { 299 // Notify to all worker threads to destroy their target actor running in them 300 for (const { transport } of watcherDataObject.workers[ 301 this.#workerTargetType 302 ]) { 303 // The transport may not be set if the worker is still being connected to from createWorkerTargetActor. 304 if (transport) { 305 // Clean the DevToolsTransport created in the main thread to bridge RDP to the worker thread. 306 // This will also send a last message to the worker to clean things up in the other thread. 307 transport.close(); 308 } 309 } 310 // Wipe all workers info 311 watcherDataObject.workers[this.#workerTargetType] = []; 312 } 313 314 /** 315 * Indicates whether or not we should handle the worker debugger 316 * 317 * @param {object} sessionData 318 * The session data for a given watcher, which includes metadata 319 * about the debugged context. 320 * @param {WorkerDebugger} dbg 321 * The worker debugger we want to check. 322 * @param {string} targetType 323 * The expected worker target type. 324 * @returns {boolean} 325 */ 326 shouldHandleWorker(sessionData, dbg, targetType) { 327 if (!isWorkerDebuggerAlive(dbg)) { 328 return false; 329 } 330 331 if ( 332 (dbg.type === TYPE_DEDICATED && targetType != "worker") || 333 (dbg.type === TYPE_SERVICE && targetType != "service_worker") || 334 (dbg.type === TYPE_SHARED && targetType != "shared_worker") 335 ) { 336 return false; 337 } 338 339 // subprocess workers are ignored because they take several seconds to 340 // attach to when opening the browser toolbox. See bug 1594597. 341 if ( 342 /resource:\/\/gre\/modules\/subprocess\/subprocess_.*\.worker\.js/.test( 343 dbg.url 344 ) 345 ) { 346 return false; 347 } 348 349 const { type: sessionContextType } = sessionData.sessionContext; 350 if (sessionContextType == "all") { 351 return true; 352 } 353 if (sessionContextType == "content-process") { 354 throw new Error( 355 "Content process session type shouldn't try to spawn workers" 356 ); 357 } 358 if (sessionContextType == "worker") { 359 throw new Error( 360 "worker session type should spawn only one target via the WorkerDescriptor" 361 ); 362 } 363 364 if (dbg.type === TYPE_DEDICATED) { 365 // Assume that all dedicated workers executes in the same process as the debugged document. 366 const browsingContext = BrowsingContext.getCurrentTopByBrowserId( 367 sessionData.sessionContext.browserId 368 ); 369 // If we aren't executing in the same process as the worker and its BrowsingContext, 370 // it will be undefined. 371 if (!browsingContext) { 372 return false; 373 } 374 for (const subBrowsingContext of browsingContext.getAllBrowsingContextsInSubtree()) { 375 if ( 376 subBrowsingContext.currentWindowContext && 377 dbg.windowIDs.includes( 378 subBrowsingContext.currentWindowContext.innerWindowId 379 ) 380 ) { 381 return true; 382 } 383 } 384 return false; 385 } 386 387 if (dbg.type === TYPE_SERVICE) { 388 // Accessing `nsIPrincipal.host` may easily throw on non-http URLs. 389 // Ignore all non-HTTP as they most likely don't have any valid host name. 390 if (!dbg.principal.scheme.startsWith("http")) { 391 return false; 392 } 393 394 const workerHost = dbg.principal.hostPort; 395 return workerHost == sessionData["browser-element-host"][0]; 396 } 397 398 if (dbg.type === TYPE_SHARED) { 399 // Bug 1607778 - Don't expose shared workers when debugging a tab. 400 // For now, they are only exposed in the browser toolbox, when Session Context Type is set to "all". 401 return false; 402 } 403 404 return false; 405 } 406 } 407 408 /** 409 * Communicate the type and entries to the Worker Target actor, via the WorkerDebugger. 410 * 411 * @param {WorkerDebugger} dbg 412 * @param {string} workerThreadServerForwardingPrefix 413 * @param {string} type 414 * Session data type name 415 * @param {Array} entries 416 * Session data entries to add or set. 417 * @param {string} updateType 418 * Either "add" or "set", to control if we should only add some items, 419 * or replace the whole data set with the new entries. 420 * @returns {Promise} Returns a Promise that resolves once the data entry were handled 421 * by the worker target. 422 */ 423 function addOrSetSessionDataEntryInWorkerTarget({ 424 dbg, 425 workerThreadServerForwardingPrefix, 426 type, 427 entries, 428 updateType, 429 }) { 430 if (!isWorkerDebuggerAlive(dbg)) { 431 return Promise.resolve(); 432 } 433 434 return new Promise(resolve => { 435 // Wait until we're notified by the worker that the resources are watched. 436 // This is important so we know existing resources were handled. 437 const listener = { 438 onMessage: message => { 439 message = JSON.parse(message); 440 if (message.type === "session-data-entry-added-or-set") { 441 dbg.removeListener(listener); 442 resolve(); 443 } 444 }, 445 // Resolve if the worker is being destroyed so we don't have a dangling promise. 446 onClose: () => { 447 dbg.removeListener(listener); 448 resolve(); 449 }, 450 }; 451 452 dbg.addListener(listener); 453 454 dbg.postMessage( 455 JSON.stringify({ 456 type: "add-or-set-session-data-entry", 457 forwardingPrefix: workerThreadServerForwardingPrefix, 458 dataEntryType: type, 459 entries, 460 updateType, 461 }) 462 ); 463 }); 464 } 465 466 function isWorkerDebuggerAlive(dbg) { 467 if (dbg.isClosed) { 468 return false; 469 } 470 // Some workers are zombies. `isClosed` is false, but nothing works. 471 // `postMessage` is a noop, `addListener`'s `onClosed` doesn't work. 472 return ( 473 dbg.window?.docShell || 474 // consider dbg without `window` as being alive, as they aren't related 475 // to any docShell and probably do not suffer from this issue 476 !dbg.window 477 ); 478 } 479 480 export const WorkerTargetWatcher = new WorkerTargetWatcherClass();