summaryrefslogtreecommitdiffstats
path: root/devtools/shared/resources/target-list.js
blob: a23b5313646d31235e1f73b85a2fb1c619b356dd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
/* 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 Services = require("Services");
const EventEmitter = require("devtools/shared/event-emitter");

const BROWSERTOOLBOX_FISSION_ENABLED = "devtools.browsertoolbox.fission";

const {
  LegacyProcessesWatcher,
} = require("devtools/shared/resources/legacy-target-watchers/legacy-processes-watcher");
const {
  LegacyServiceWorkersWatcher,
} = require("devtools/shared/resources/legacy-target-watchers/legacy-serviceworkers-watcher");
const {
  LegacySharedWorkersWatcher,
} = require("devtools/shared/resources/legacy-target-watchers/legacy-sharedworkers-watcher");
const {
  LegacyWorkersWatcher,
} = require("devtools/shared/resources/legacy-target-watchers/legacy-workers-watcher");

// eslint-disable-next-line mozilla/reject-some-requires
loader.lazyRequireGetter(
  this,
  "TargetFactory",
  "devtools/client/framework/target",
  true
);

class TargetList extends EventEmitter {
  /**
   * This class helps managing, iterating over and listening for Targets.
   *
   * It exposes:
   *  - the top level target, typically the main process target for the browser toolbox
   *    or the browsing context target for a regular web toolbox
   *  - target of remoted iframe, in case Fission is enabled and some <iframe>
   *    are running in a distinct process
   *  - target switching. If the top level target changes for a new one,
   *    all the targets are going to be declared as destroyed and the new ones
   *    will be notified to the user of this API.
   *
   * @fires target-tread-wrong-order-on-resume : An event that is emitted when resuming
   *        the thread throws with the "wrongOrder" error.
   *
   * @param {RootFront} rootFront
   *        The root front.
   * @param {TargetFront} targetFront
   *        The top level target to debug. Note that in case of target switching,
   *        this may be replaced by a new one over time.
   */
  constructor(rootFront, targetFront) {
    super();

    this.rootFront = rootFront;

    // Once we have descriptor for all targets we create a toolbox for,
    // we should try to only pass the descriptor to the Toolbox constructor,
    // and, only receive the root and descriptor front as an argument to TargetList.
    // Bug 1573779, we only miss descriptors for workers.
    this.descriptorFront = targetFront.descriptorFront;

    // Note that this is a public attribute, used outside of this class
    // and helps knowing what is the current top level target we debug.
    this.targetFront = targetFront;
    targetFront.setTargetType(this.getTargetType(targetFront));
    targetFront.setIsTopLevel(true);

    // Until Watcher actor notify about new top level target when navigating to another process
    // we have to manually switch to a new target from the client side
    this.onLocalTabRemotenessChange = this.onLocalTabRemotenessChange.bind(
      this
    );
    if (this.descriptorFront?.isLocalTab) {
      this.descriptorFront.on(
        "remoteness-change",
        this.onLocalTabRemotenessChange
      );
    }

    // Reports if we have at least one listener for the given target type
    this._listenersStarted = new Set();

    // List of all the target fronts
    this._targets = new Set();
    // {Map<Function, Set<targetFront>>} A Map keyed by `onAvailable` function passed to
    // `watchTargets`, whose initial value is a Set of the existing target fronts at the
    // time watchTargets is called.
    this._pendingWatchTargetInitialization = new Map();

    // Add the top-level target to debug to the list of targets.
    this._targets.add(targetFront);

    // Listeners for target creation and destruction
    this._createListeners = new EventEmitter();
    this._destroyListeners = new EventEmitter();

    this._onTargetAvailable = this._onTargetAvailable.bind(this);
    this._onTargetDestroyed = this._onTargetDestroyed.bind(this);

    this.legacyImplementation = {
      process: new LegacyProcessesWatcher(
        this,
        this._onTargetAvailable,
        this._onTargetDestroyed
      ),
      worker: new LegacyWorkersWatcher(
        this,
        this._onTargetAvailable,
        this._onTargetDestroyed
      ),
      shared_worker: new LegacySharedWorkersWatcher(
        this,
        this._onTargetAvailable,
        this._onTargetDestroyed
      ),
      service_worker: new LegacyServiceWorkersWatcher(
        this,
        this._onTargetAvailable,
        this._onTargetDestroyed
      ),
    };

    // Public flag to allow listening for workers even if the fission pref is off
    // This allows listening for workers in the content toolbox outside of fission contexts
    // For now, this is only toggled by tests.
    this.listenForWorkers =
      this.rootFront.traits.workerConsoleApiMessagesDispatchedToMainThread ===
      false;
    this.listenForServiceWorkers = false;
    this.destroyServiceWorkersOnNavigation = false;
  }

  // Called whenever a new Target front is available.
  // Either because a target was already available as we started calling startListening
  // or if it has just been created
  async _onTargetAvailable(targetFront, isTargetSwitching = false) {
    if (this._targets.has(targetFront)) {
      // The top level target front can be reported via listProcesses in the
      // case of the BrowserToolbox. For any other target, log an error if it is
      // already registered.
      if (targetFront != this.targetFront) {
        console.error(
          "Target is already registered in the TargetList",
          targetFront.actorID
        );
      }
      return;
    }

    if (this.isDestroyed() || targetFront.isDestroyedOrBeingDestroyed()) {
      return;
    }

    // Handle top level target switching
    // Note that, for now, `_onTargetAvailable` isn't called for the *initial* top level target.
    // i.e. the one that is passed to TargetList constructor.
    if (targetFront.isTopLevel) {
      // First report that all existing targets are destroyed
      for (const target of this._targets) {
        // We only consider the top level target to be switched
        const isDestroyedTargetSwitching = target == this.targetFront;
        this._onTargetDestroyed(target, isDestroyedTargetSwitching);
      }
      // Stop listening to legacy listeners as we now have to listen
      // on the new target.
      this.stopListening({ onlyLegacy: true });

      // Clear the cached target list
      this._targets.clear();

      // Update the reference to the memoized top level target
      this.targetFront = targetFront;
    }

    // Map the descriptor typeName to a target type.
    const targetType = this.getTargetType(targetFront);
    targetFront.setTargetType(targetType);

    this._targets.add(targetFront);
    try {
      await targetFront.attachAndInitThread(this);
    } catch (e) {
      console.error("Error when attaching target:", e);
      this._targets.delete(targetFront);
      return;
    }

    for (const targetFrontsSet of this._pendingWatchTargetInitialization.values()) {
      targetFrontsSet.delete(targetFront);
    }

    // Then, once the target is attached, notify the target front creation listeners
    await this._createListeners.emitAsync(targetType, {
      targetFront,
      isTargetSwitching,
    });

    // Re-register the listeners as the top level target changed
    // and some targets are fetched from it
    if (targetFront.isTopLevel) {
      await this.startListening({ onlyLegacy: true });
    }

    // To be consumed by tests triggering frame navigations, spawning workers...
    this.emitForTests("processed-available-target", targetFront);
  }

  _onTargetDestroyed(targetFront, isTargetSwitching = false) {
    this._destroyListeners.emit(targetFront.targetType, {
      targetFront,
      isTargetSwitching,
    });
    this._targets.delete(targetFront);
  }

  _setListening(type, value) {
    if (value) {
      this._listenersStarted.add(type);
    } else {
      this._listenersStarted.delete(type);
    }
  }

  _isListening(type) {
    return this._listenersStarted.has(type);
  }

  hasTargetWatcherSupport(type) {
    return !!this.watcherFront?.traits[type];
  }

  /**
   * Start listening for targets from the server
   *
   * Interact with the actors in order to start listening for new types of targets.
   * This will fire the _onTargetAvailable function for all already-existing targets,
   * as well as the next one to be created. It will also call _onTargetDestroyed
   * everytime a target is reported as destroyed by the actors.
   * By the time this function resolves, all the already-existing targets will be
   * reported to _onTargetAvailable.
   *
   * @param Object options
   *        Dictionary object with `onlyLegacy` optional boolean.
   *        If true, we wouldn't register listener set on the Watcher Actor,
   *        but still register listeners set via Legacy Listeners.
   */
  async startListening({ onlyLegacy = false } = {}) {
    // Cache the Watcher once for all, the first time we call `startListening()`.
    // This `watcherFront` attribute may be then used in any function in TargetList or ResourceWatcher after this.
    if (!this.watcherFront) {
      // Bug 1675763: Watcher actor is not available in all situations yet.
      const supportsWatcher = this.descriptorFront?.traits?.watcher;
      if (supportsWatcher) {
        this.watcherFront = await this.descriptorFront.getWatcher();
      }
    }

    let types = [];
    if (this.targetFront.isParentProcess) {
      const fissionBrowserToolboxEnabled = Services.prefs.getBoolPref(
        BROWSERTOOLBOX_FISSION_ENABLED
      );
      if (fissionBrowserToolboxEnabled) {
        types = TargetList.ALL_TYPES;
      }
    } else if (this.targetFront.isLocalTab) {
      types = [TargetList.TYPES.FRAME];
    }
    if (this.listenForWorkers && !types.includes(TargetList.TYPES.WORKER)) {
      types.push(TargetList.TYPES.WORKER);
    }
    if (
      this.listenForWorkers &&
      !types.includes(TargetList.TYPES.SHARED_WORKER)
    ) {
      types.push(TargetList.TYPES.SHARED_WORKER);
    }
    if (
      this.listenForServiceWorkers &&
      !types.includes(TargetList.TYPES.SERVICE_WORKER)
    ) {
      types.push(TargetList.TYPES.SERVICE_WORKER);
    }

    // If no pref are set to true, nor is listenForWorkers set to true,
    // we won't listen for any additional target. Only the top level target
    // will be managed. We may still do target-switching.

    for (const type of types) {
      if (this._isListening(type)) {
        continue;
      }
      this._setListening(type, true);

      // Only a few top level targets support the watcher actor at the moment (see WatcherActor
      // traits in the _form method). Bug 1675763 tracks watcher actor support for all targets.
      if (this.hasTargetWatcherSupport(type)) {
        // When we switch to a new top level target, we don't have to stop and restart
        // Watcher listener as it is independant from the top level target.
        // This isn't the case for some Legacy Listeners, which fetch targets from the top level target
        if (onlyLegacy) {
          continue;
        }
        if (!this._startedListeningToWatcher) {
          this._startedListeningToWatcher = true;
          this.watcherFront.on("target-available", this._onTargetAvailable);
          this.watcherFront.on("target-destroyed", this._onTargetDestroyed);
        }
        await this.watcherFront.watchTargets(type);
        continue;
      }
      if (this.legacyImplementation[type]) {
        await this.legacyImplementation[type].listen();
      } else {
        throw new Error(`Unsupported target type '${type}'`);
      }
    }
  }

  /**
   * Stop listening for targets from the server
   *
   * @param Object options
   *        Dictionary object with `onlyLegacy` optional boolean.
   *        If true, we wouldn't unregister listener set on the Watcher Actor,
   *        but still unregister listeners set via Legacy Listeners.
   */
  stopListening({ onlyLegacy = false } = {}) {
    for (const type of TargetList.ALL_TYPES) {
      if (!this._isListening(type)) {
        continue;
      }
      this._setListening(type, false);

      // Only a few top level targets support the watcher actor at the moment (see WatcherActor
      // traits in the _form method). Bug 1675763 tracks watcher actor support for all targets.
      if (this.hasTargetWatcherSupport(type)) {
        // When we switch to a new top level target, we don't have to stop and restart
        // Watcher listener as it is independant from the top level target.
        // This isn't the case for some Legacy Listeners, which fetch targets from the top level target
        if (!onlyLegacy) {
          this.watcherFront.unwatchTargets(type);
        }
        continue;
      }
      if (this.legacyImplementation[type]) {
        this.legacyImplementation[type].unlisten();
      } else {
        throw new Error(`Unsupported target type '${type}'`);
      }
    }
  }

  getTargetType(target) {
    const { typeName } = target;
    if (typeName == "browsingContextTarget") {
      return TargetList.TYPES.FRAME;
    }

    if (
      typeName == "contentProcessTarget" ||
      typeName == "parentProcessTarget"
    ) {
      return TargetList.TYPES.PROCESS;
    }

    if (typeName == "workerDescriptor" || typeName == "workerTarget") {
      if (target.isSharedWorker) {
        return TargetList.TYPES.SHARED_WORKER;
      }

      if (target.isServiceWorker) {
        return TargetList.TYPES.SERVICE_WORKER;
      }

      return TargetList.TYPES.WORKER;
    }

    throw new Error("Unsupported target typeName: " + typeName);
  }

  _matchTargetType(type, target) {
    return type === target.targetType;
  }

  /**
   * Listen for the creation and/or destruction of target fronts matching one of the provided types.
   *
   * @param {Array<String>} types
   *        The type of target to listen for. Constant of TargetList.TYPES.
   * @param {Function} onAvailable
   *        Callback fired when a target has been just created or was already available.
   *        The function is called with the following arguments:
   *        - {TargetFront} targetFront: The target Front
   *        - {Boolean} isTargetSwitching: Is this target relates to a navigation and
   *                    this replaced a previously available target, this flag will be true
   * @param {Function} onDestroy
   *        Callback fired in case of target front destruction.
   *        The function is called with the same arguments than onAvailable.
   */
  async watchTargets(types, onAvailable, onDestroy) {
    if (typeof onAvailable != "function") {
      throw new Error(
        "TargetList.watchTargets expects a function as second argument"
      );
    }

    // Notify about already existing target of these types
    const targetFronts = [...this._targets].filter(targetFront =>
      types.includes(targetFront.targetType)
    );
    this._pendingWatchTargetInitialization.set(
      onAvailable,
      new Set(targetFronts)
    );
    const promises = targetFronts.map(async targetFront => {
      // Attach the targets that aren't attached yet (e.g. the initial top-level target),
      // and wait for the other ones to be fully attached.
      try {
        await targetFront.attachAndInitThread(this);
      } catch (e) {
        console.error("Error when attaching target:", e);
        return;
      }

      // It can happen that onAvailable was already called with this targetFront at
      // this time (via _onTargetAvailable). If that's the case, we don't want to call
      // onAvailable a second time.
      if (
        this._pendingWatchTargetInitialization &&
        this._pendingWatchTargetInitialization.has(onAvailable) &&
        !this._pendingWatchTargetInitialization
          .get(onAvailable)
          .has(targetFront)
      ) {
        return;
      }

      try {
        // Ensure waiting for eventual async create listeners
        // which may setup things regarding the existing targets
        // and listen callsite may care about the full initialization
        await onAvailable({
          targetFront,
          isTargetSwitching: false,
        });
      } catch (e) {
        // Prevent throwing when onAvailable handler throws on one target
        // so that it can try to register the other targets
        console.error(
          "Exception when calling onAvailable handler",
          e.message,
          e
        );
      }
    });

    for (const type of types) {
      this._createListeners.on(type, onAvailable);
      if (onDestroy) {
        this._destroyListeners.on(type, onDestroy);
      }
    }

    await Promise.all(promises);
    this._pendingWatchTargetInitialization.delete(onAvailable);
  }

  /**
   * Stop listening for the creation and/or destruction of a given type of target fronts.
   * See `watchTargets()` for documentation of the arguments.
   */
  unwatchTargets(types, onAvailable, onDestroy) {
    if (typeof onAvailable != "function") {
      throw new Error(
        "TargetList.unwatchTargets expects a function as second argument"
      );
    }

    for (const type of types) {
      this._createListeners.off(type, onAvailable);
      if (onDestroy) {
        this._destroyListeners.off(type, onDestroy);
      }
    }
    this._pendingWatchTargetInitialization.delete(onAvailable);
  }

  /**
   * Retrieve all the current target fronts of a given type.
   *
   * @param {Array<String>} types
   *        The types of target to retrieve. Array of TargetList.TYPES
   * @return {Array<TargetFront>} Array of target fronts matching any of the
   *         provided types.
   */
  getAllTargets(types) {
    if (!types?.length) {
      throw new Error("getAllTargets expects a non-empty array of types");
    }

    const targets = [...this._targets].filter(target =>
      types.some(type => this._matchTargetType(type, target))
    );

    return targets;
  }

  /**
   * For all the target fronts of a given type, retrieve all the target-scoped fronts of a given type.
   *
   * @param {String} targetType
   *        The type of target to iterate over. Constant of TargetList.TYPES.
   * @param {String} frontType
   *        The type of target-scoped front to retrieve. It can be "inspector", "console", "thread",...
   */
  async getAllFronts(targetType, frontType) {
    const fronts = [];
    const targets = this.getAllTargets([targetType]);
    for (const target of targets) {
      const front = await target.getFront(frontType);
      fronts.push(front);
    }
    return fronts;
  }

  /**
   * This function is triggered by an event sent by the TabDescriptor when
   * the tab navigates to a distinct process.
   *
   * @param TargetFront targetFront
   *        The BrowsingContextTargetFront instance that navigated to another process
   */
  async onLocalTabRemotenessChange(targetFront) {
    // Cache the tab & client as this property will be nullified when the target is closed
    const client = targetFront.client;
    const localTab = targetFront.localTab;

    // By default, we do close the DevToolsClient when the target is destroyed.
    // This happens when we close the toolbox (Toolbox.destroy calls Target.destroy),
    // or when the tab is closes, the server emits tabDetached and the target
    // destroy itself.
    // Here, in the context of the process switch, the current target will be destroyed
    // due to a tabDetached event and a we will create a new one. But we want to reuse
    // the same client.
    targetFront.shouldCloseClient = false;

    // Wait for the target to be destroyed so that TargetFactory clears its memoized target for this tab
    await targetFront.once("target-destroyed");

    // Fetch the new target from the existing client so that the new target uses the same client.
    const newTarget = await TargetFactory.forTab(localTab, client);

    this.switchToTarget(newTarget);
  }

  /**
   * Called when the top level target is replaced by a new one.
   * Typically when we navigate to another domain which requires to be loaded in a distinct process.
   *
   * @param {TargetFront} newTarget
   *        The new top level target to debug.
   */
  async switchToTarget(newTarget) {
    newTarget.setIsTopLevel(true);

    // Notify about this new target to creation listeners
    await this._onTargetAvailable(newTarget, true);

    this.emit("switched-target", newTarget);
  }

  isTargetRegistered(targetFront) {
    return this._targets.has(targetFront);
  }

  isDestroyed() {
    return this._isDestroyed;
  }

  destroy() {
    this.stopListening();
    this._createListeners.off();
    this._destroyListeners.off();
    this._isDestroyed = true;
  }
}

/**
 * All types of target:
 */
TargetList.TYPES = TargetList.prototype.TYPES = {
  PROCESS: "process",
  FRAME: "frame",
  WORKER: "worker",
  SHARED_WORKER: "shared_worker",
  SERVICE_WORKER: "service_worker",
};
TargetList.ALL_TYPES = TargetList.prototype.ALL_TYPES = Object.values(
  TargetList.TYPES
);

module.exports = { TargetList };