target-mixin.js (20744B)
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 "use strict"; 6 7 loader.lazyRequireGetter( 8 this, 9 "getFront", 10 "resource://devtools/shared/protocol.js", 11 true 12 ); 13 14 /** 15 * A Target represents a debuggable context. It can be a browser tab, a tab on 16 * a remote device, like a tab on Firefox for Android. But it can also be an add-on, 17 * as well as firefox parent process, or just one of its content process. 18 * A Target is related to a given TargetActor, for which we derive this class. 19 * 20 * Providing a generalized abstraction of a web-page or web-browser (available 21 * either locally or remotely) is beyond the scope of this class (and maybe 22 * also beyond the scope of this universe) However Target does attempt to 23 * abstract some common events and read-only properties common to many Tools. 24 * 25 * Supported read-only properties: 26 * - name, url 27 * 28 * Target extends EventEmitter and provides support for the following events: 29 * - close: The target window has been closed. All tools attached to this 30 * target should close. This event is not currently cancelable. 31 * 32 * Optional events only dispatched by WindowGlobalTarget: 33 * - will-navigate: The target window will navigate to a different URL 34 * - navigate: The target window has navigated to a different URL 35 */ 36 function TargetMixin(parentClass) { 37 class Target extends parentClass { 38 constructor(client, targetFront, parentFront) { 39 super(client, targetFront, parentFront); 40 41 // TargetCommand._onTargetAvailable will set this public attribute. 42 // This is a reference to the related `commands` object and helps all fronts 43 // easily call any command method. Without this bit of magic, Fronts wouldn't 44 // be able to interact with any commands while it is frequently useful. 45 this.commands = null; 46 47 this.destroy = this.destroy.bind(this); 48 49 this.threadFront = null; 50 51 this._client = client; 52 53 // Cache of already created targed-scoped fronts 54 // [typeName:string => Front instance] 55 this.fronts = new Map(); 56 57 // `resources-available-array` and `resources-updated-array` events can be emitted 58 // by target actors before the ResourceCommand could add event listeners. 59 // The target front will cache those events until the ResourceCommand has 60 // added the listeners. 61 this._resourceCache = {}; 62 63 // In order to avoid destroying the `_resourceCache[event]`, we need to call `super.on()` 64 // instead of `this.on()`. 65 const offResourceAvailableArray = super.on( 66 "resources-available-array", 67 this._onResourceEventArray.bind(this, "resources-available-array") 68 ); 69 const offResourceUpdatedArray = super.on( 70 "resources-updated-array", 71 this._onResourceEventArray.bind(this, "resources-updated-array") 72 ); 73 74 this._offResourceEvent = new Map([ 75 ["resources-available-array", offResourceAvailableArray], 76 ["resources-updated-array", offResourceUpdatedArray], 77 ]); 78 79 // Expose a promise that is resolved once the target front is usable 80 // i.e. once attachAndInitThread has been called and resolved. 81 this.initialized = new Promise(resolve => { 82 this._onInitialized = resolve; 83 }); 84 } 85 86 on(eventName, listener) { 87 if (this._offResourceEvent && this._offResourceEvent.has(eventName)) { 88 // If a callsite sets an event listener for resource-(available|update)-(form|array): 89 90 // we want to remove the listener we set here in the constructor… 91 const off = this._offResourceEvent.get(eventName); 92 this._offResourceEvent.delete(eventName); 93 off(); 94 95 // …and call the new listener with the resources that were put in the cache. 96 if (this._resourceCache[eventName]) { 97 for (const cache of this._resourceCache[eventName]) { 98 listener(cache); 99 } 100 delete this._resourceCache[eventName]; 101 } 102 } 103 104 return super.on(eventName, listener); 105 } 106 107 /** 108 * Boolean flag to help distinguish Target Fronts from other Fronts. 109 * As we are using a Mixin, we can't easily distinguish these fronts via instanceof(). 110 */ 111 get isTargetFront() { 112 return true; 113 } 114 115 get targetType() { 116 return this.targetForm.targetType; 117 } 118 119 get isTopLevel() { 120 // We can't use `getTrait` here as this might be called from a destroyed target (e.g. 121 // from an onTargetDestroyed callback that was triggered by a legacy listener), which 122 // means `this.client` would be null, which would make `getTrait` throw (See Bug 1714974) 123 if (!this.targetForm.hasOwnProperty("isTopLevelTarget")) { 124 return !!this._isTopLevel; 125 } 126 127 return this.targetForm.isTopLevelTarget; 128 } 129 130 setIsTopLevel(isTopLevel) { 131 if (!this.getTrait("supportsTopLevelTargetFlag")) { 132 this._isTopLevel = isTopLevel; 133 } 134 } 135 136 /** 137 * Get the immediate parent target for this target. 138 * 139 * @return {TargetMixin} the parent target. 140 */ 141 async getParentTarget() { 142 return this.commands.targetCommand.getParentTarget(this); 143 } 144 145 /** 146 * Returns a Promise that resolves to a boolean indicating if the provided target is 147 * an ancestor of this instance. 148 * 149 * @param {TargetFront} target: The possible ancestor target. 150 * @returns Promise<Boolean> 151 */ 152 async isTargetAnAncestor(target) { 153 const parentTargetFront = await this.getParentTarget(); 154 if (!parentTargetFront) { 155 return false; 156 } 157 158 if (parentTargetFront == target) { 159 return true; 160 } 161 162 return parentTargetFront.isTargetAnAncestor(target); 163 } 164 165 /** 166 * Get the target for the given Browsing Context ID. 167 * 168 * @return {TargetMixin} the requested target. 169 */ 170 async getWindowGlobalTarget(browsingContextID) { 171 // Just for sanity as commands attribute is set late from TargetCommand._onTargetAvailable 172 // but ideally target front should be used before this happens. 173 if (!this.commands) { 174 return null; 175 } 176 // Tab and Process Descriptors expose a Watcher, which is creating the 177 // targets and should be used to fetch any. 178 const { watcherFront } = this.commands; 179 if (watcherFront) { 180 // Safety check, in theory all watcher should support frames. 181 if (watcherFront.traits.frame) { 182 return watcherFront.getWindowGlobalTarget(browsingContextID); 183 } 184 return null; 185 } 186 187 // For descriptors which don't expose a watcher (e.g. WebExtension) 188 // we used to call RootActor::getBrowsingContextDescriptor, but it was 189 // removed in FF77. 190 // Support for watcher in WebExtension descriptors is Bug 1644341. 191 throw new Error( 192 `Unable to call getWindowGlobalTarget for ${this.actorID}` 193 ); 194 } 195 196 /** 197 * Returns a boolean indicating whether or not the specific actor 198 * type exists. 199 * 200 * @param {string} actorName 201 * @return {boolean} 202 */ 203 hasActor(actorName) { 204 if (this.targetForm) { 205 return !!this.targetForm[actorName + "Actor"]; 206 } 207 return false; 208 } 209 210 /** 211 * Returns a trait from the target actor if it exists, 212 * if not it will fallback to that on the root actor. 213 * 214 * @param {string} traitName 215 * @return {Mixed} 216 */ 217 getTrait(traitName) { 218 if (this.isDestroyedOrBeingDestroyed()) { 219 return null; 220 } 221 // If the targeted actor exposes traits and has a defined value for this 222 // traits, override the root actor traits 223 if (this.targetForm.traits && traitName in this.targetForm.traits) { 224 return this.targetForm.traits[traitName]; 225 } 226 227 return this.client.traits[traitName]; 228 } 229 230 // Get a Front for a target-scoped actor. 231 // i.e. an actor served by RootActor.listTabs or RootActorActor.getTab requests 232 async getFront(typeName) { 233 if (this.isDestroyed()) { 234 throw new Error( 235 "Target already destroyed, unable to fetch children fronts" 236 ); 237 } 238 let front = this.fronts.get(typeName); 239 if (front) { 240 // XXX: This is typically the kind of spot where switching to 241 // `isDestroyed()` is complicated, because `front` is not necessarily a 242 // Front... 243 const isFrontInitializing = typeof front.then === "function"; 244 const isFrontAlive = !isFrontInitializing && !front.isDestroyed(); 245 if (isFrontInitializing || isFrontAlive) { 246 return front; 247 } 248 } 249 250 front = getFront(this.client, typeName, this.targetForm, this); 251 this.fronts.set(typeName, front); 252 // replace the placeholder with the instance of the front once it has loaded 253 front = await front; 254 this.fronts.set(typeName, front); 255 return front; 256 } 257 258 getCachedFront(typeName) { 259 // do not wait for async fronts; 260 const front = this.fronts.get(typeName); 261 // ensure that the front is a front, and not async front 262 if (front?.actorID) { 263 return front; 264 } 265 return null; 266 } 267 268 get client() { 269 return this._client; 270 } 271 272 // Tells us if the related actor implements WindowGlobalTargetActor 273 // interface and requires to call `attach` request before being used and 274 // `detach` during cleanup. 275 get isBrowsingContext() { 276 return this.typeName === "windowGlobalTarget"; 277 } 278 279 /** 280 * Return the name to be displayed in the debugger and console context selector. 281 */ 282 get name() { 283 // When debugging Web Extensions, all documents have moz-extension://${uuid}/... URL 284 // When the developer don't set a custom title, fallback on displaying the pathname 285 // to avoid displaying long URL prefix with the addon internal UUID. 286 if (this.commands.descriptorFront.isWebExtensionDescriptor) { 287 if (this._title) { 288 return this._title; 289 } 290 const parsedURL = URL.parse(this._url); 291 if (parsedURL) { 292 return parsedURL.pathname; 293 } 294 // If document URL can't be parsed, fallback to the raw URL. 295 return this._url; 296 } 297 298 if (this.isContentProcess) { 299 return this.targetForm.name; 300 } 301 return this.title; 302 } 303 304 get title() { 305 return this._title || this.url; 306 } 307 308 get url() { 309 return this._url; 310 } 311 312 get isWorkerTarget() { 313 // XXX Remove the check on `workerDescriptor` as part of Bug 1667404. 314 return ( 315 this.typeName === "workerTarget" || this.typeName === "workerDescriptor" 316 ); 317 } 318 319 get isContentProcess() { 320 // browser content toolbox's form will be of the form: 321 // server0.conn0.content-process0/contentProcessTarget7 322 // while xpcshell debugging will be: 323 // server1.conn0.contentProcessTarget7 324 return !!( 325 this.targetForm && 326 this.targetForm.actor && 327 this.targetForm.actor.match( 328 /conn\d+\.(content-process\d+\/)?contentProcessTarget\d+/ 329 ) 330 ); 331 } 332 333 get isParentProcess() { 334 return !!( 335 this.targetForm && 336 this.targetForm.actor && 337 this.targetForm.actor.match(/conn\d+\.parentProcessTarget\d+/) 338 ); 339 } 340 341 /** 342 * This method attaches the target and then attaches its related thread, sending it 343 * the options it needs (e.g. breakpoints, pause on exception setting, …). 344 * This function can be called multiple times, it will only perform the actual 345 * initialization process once; on subsequent call the original promise (_onThreadInitialized) 346 * will be returned. 347 * 348 * @param {TargetCommand} targetCommand 349 * @returns {Promise} A promise that resolves once the thread is attached and resumed. 350 */ 351 attachAndInitThread(targetCommand) { 352 if (this._onThreadInitialized) { 353 return this._onThreadInitialized; 354 } 355 356 this._onThreadInitialized = this._attachAndInitThread(targetCommand); 357 // Resolve the `initialized` promise, while ignoring errors 358 // The empty function passed to catch will avoid spawning a new possibly rejected promise 359 this._onThreadInitialized.catch(() => {}).then(this._onInitialized); 360 return this._onThreadInitialized; 361 } 362 363 /** 364 * This method attach the target and then attach its related thread, sending it the 365 * options it needs (e.g. breakpoints, pause on exception setting, …) 366 * 367 * @private 368 * @param {TargetCommand} targetCommand 369 * @returns {Promise} A promise that resolves once the thread is attached and resumed. 370 */ 371 async _attachAndInitThread(targetCommand) { 372 // If the target is destroyed or soon will be, don't go further 373 if (this.isDestroyedOrBeingDestroyed()) { 374 return; 375 } 376 377 // The current class we have is actually the WorkerDescriptorFront, 378 // which will morph into a target by fetching the underlying target's form. 379 // Ideally, worker targets would be spawn by the server, and we would no longer 380 // have the hybrid descriptor/target class which brings lots of complexity and confusion. 381 // To be removed in bug 1651522. 382 if (this.morphWorkerDescriptorIntoWorkerTarget) { 383 await this.morphWorkerDescriptorIntoWorkerTarget(); 384 } 385 386 const isBrowserToolbox = 387 targetCommand.descriptorFront.isBrowserProcessDescriptor; 388 const isNonTopLevelFrameTarget = 389 !this.isTopLevel && this.targetType === targetCommand.TYPES.FRAME; 390 391 if (isBrowserToolbox && isNonTopLevelFrameTarget) { 392 // In the BrowserToolbox, non-top-level frame targets are already 393 // debugged via content-process targets. 394 // Do not attach the thread here, as it was already done by the 395 // corresponding content-process target. 396 return; 397 } 398 399 // Avoid attaching any thread actor in the browser console or in 400 // webextension commands in order to avoid triggering any type of 401 // breakpoint. 402 if (targetCommand.descriptorFront.doNotAttachThreadActor) { 403 return; 404 } 405 406 // If the target is destroyed or soon will be, don't go further 407 if (this.isDestroyedOrBeingDestroyed()) { 408 return; 409 } 410 if (!this.targetForm || !this.targetForm.threadActor) { 411 throw new Error( 412 "TargetMixin sub class should set targetForm.threadActor before calling attachAndInitThread" 413 ); 414 } 415 this.threadFront = await this.getFront("thread"); 416 } 417 418 isDestroyedOrBeingDestroyed() { 419 return this.isDestroyed() || this._destroyer; 420 } 421 422 /** 423 * Target is not alive anymore. 424 */ 425 destroy() { 426 // If several things call destroy then we give them all the same 427 // destruction promise so we're sure to destroy only once 428 if (this._destroyer) { 429 return this._destroyer; 430 } 431 432 // This pattern allows to immediately return the destroyer promise. 433 // See Bug 1602727 for more details. 434 let destroyerResolve; 435 this._destroyer = new Promise(r => (destroyerResolve = r)); 436 this._destroyTarget().then(destroyerResolve); 437 438 return this._destroyer; 439 } 440 441 async _destroyTarget() { 442 // If the target is being attached, try to wait until it's done, to prevent having 443 // pending connection to the server when the toolbox is destroyed. 444 if (this._onThreadInitialized) { 445 try { 446 await this._onThreadInitialized; 447 } catch (e) { 448 // We might still get into cases where attaching fails (e.g. the worker we're 449 // trying to attach to is already closed). Since the target is being destroyed, 450 // we don't need to do anything special here. 451 } 452 } 453 454 for (let [name, front] of this.fronts) { 455 try { 456 // If a Front with an async initialize method is still being instantiated, 457 // we should wait for completion before trying to destroy it. 458 if (front instanceof Promise) { 459 front = await front; 460 } 461 front.destroy(); 462 } catch (e) { 463 console.warn("Error while destroying front:", name, e); 464 } 465 } 466 this.fronts.clear(); 467 468 this.threadFront = null; 469 this._offResourceEvent = null; 470 471 // This event should be emitted before calling super.destroy(), because 472 // super.destroy() will remove all event listeners attached to this front. 473 this.emit("target-destroyed"); 474 475 // Not all targets supports attach/detach. For example content process doesn't. 476 // Also ensure that the front is still active before trying to do the request. 477 if (this.detach && !this.isDestroyed()) { 478 // The client was handed to us, so we are not responsible for closing 479 // it. We just need to detach from the tab, if already attached. 480 // |detach| may fail if the connection is already dead, so proceed with 481 // cleanup directly after this. 482 try { 483 await this.detach(); 484 } catch (e) { 485 this.logDetachError(e); 486 } 487 } 488 489 // Do that very last in order to let a chance to dispatch `detach` requests. 490 super.destroy(); 491 492 this._cleanup(); 493 } 494 495 /** 496 * Detach can fail under regular circumstances, if the target was already 497 * destroyed on the server side. All target fronts should handle detach 498 * error logging in similar ways so this might be used by subclasses 499 * with custom detach() implementations. 500 * 501 * @param {Error} e 502 * The real error object. 503 * @param {string} targetType 504 * The type of the target front ("worker", "browsing-context", ...) 505 */ 506 logDetachError(e, targetType) { 507 const ignoredError = 508 e?.message.includes("noSuchActor") || 509 e?.message.includes("Connection closed"); 510 511 // Silence exceptions for already destroyed actors and fronts: 512 // - "noSuchActor" errors from the server 513 // - "Connection closed" errors from the client, when purging requests 514 if (ignoredError) { 515 return; 516 } 517 518 // Properly log any other error. 519 const message = targetType 520 ? `Error while detaching the ${targetType} target:` 521 : "Error while detaching target:"; 522 console.warn(message, e); 523 } 524 525 /** 526 * Clean up references to what this target points to. 527 */ 528 _cleanup() { 529 this.threadFront = null; 530 this._client = null; 531 532 this._title = null; 533 this._url = null; 534 } 535 536 _onResourceEventArray(eventName, array) { 537 if (!this._resourceCache[eventName]) { 538 this._resourceCache[eventName] = []; 539 } 540 this._resourceCache[eventName].push(array); 541 } 542 543 toString() { 544 const id = this.targetForm ? this.targetForm.actor : null; 545 return `Target:${id}`; 546 } 547 548 dumpPools() { 549 // NOTE: dumpPools is defined in the Thread actor to avoid 550 // adding it to multiple target specs and actors. 551 return this.threadFront.dumpPools(); 552 } 553 554 /** 555 * Log an error of some kind to the tab's console. 556 * 557 * @param {string} text 558 * The text to log. 559 * @param {string} category 560 * The category of the message. @see nsIScriptError. 561 * @returns {Promise} 562 */ 563 logErrorInPage(text, category) { 564 if (this.getTrait("logInPage")) { 565 const errorFlag = 0; 566 return this.logInPage({ text, category, flags: errorFlag }); 567 } 568 return Promise.resolve(); 569 } 570 571 /** 572 * Log a warning of some kind to the tab's console. 573 * 574 * @param {string} text 575 * The text to log. 576 * @param {string} category 577 * The category of the message. @see nsIScriptError. 578 * @returns {Promise} 579 */ 580 logWarningInPage(text, category) { 581 if (this.getTrait("logInPage")) { 582 const warningFlag = 1; 583 return this.logInPage({ text, category, flags: warningFlag }); 584 } 585 return Promise.resolve(); 586 } 587 588 /** 589 * The tracer actor emits frames which should be collected per target/thread. 590 * The tracer will emit other resources, refering to the frame indexes in that collected array. 591 * The indexes and this array in general is specific to a given tracer actor instance 592 * and so is specific per thread and target. 593 */ 594 #jsTracerCollectedFrames = []; 595 getJsTracerCollectedFramesArray() { 596 return this.#jsTracerCollectedFrames; 597 } 598 } 599 return Target; 600 } 601 exports.TargetMixin = TargetMixin;