tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

tracer.sys.mjs (39293B)


      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 /**
      6 * This module implements the JavaScript tracer.
      7 *
      8 * It is being used by:
      9 * - any code that want to manually toggle the tracer, typically when debugging code,
     10 * - the tracer actor to start and stop tracing from DevTools UI,
     11 * - the tracing state resource watcher in order to notify DevTools UI about the tracing state.
     12 *
     13 * It will default logging the tracers to the terminal/stdout.
     14 * But if DevTools are opened, it may delegate the logging to the tracer actor.
     15 * It will typically log the traces to the Web Console.
     16 *
     17 * `JavaScriptTracer.onEnterFrame` method is hot codepath and should be reviewed accordingly.
     18 */
     19 
     20 const NEXT_INTERACTION_MESSAGE =
     21  "Waiting for next user interaction before tracing (next mousedown or keydown event)";
     22 
     23 const FRAME_EXIT_REASONS = {
     24  // The function has been early terminated by the Debugger API
     25  TERMINATED: "terminated",
     26  // The function simply ends by returning a value
     27  RETURN: "return",
     28  // The function yields a new value
     29  YIELD: "yield",
     30  // The function await on a promise
     31  AWAIT: "await",
     32  // The function throws an exception
     33  THROW: "throw",
     34 };
     35 
     36 const DOM_MUTATIONS = {
     37  // Track all DOM Node being added
     38  ADD: "add",
     39  // Track all attributes being modified
     40  ATTRIBUTES: "attributes",
     41  // Track all DOM Node being removed
     42  REMOVE: "remove",
     43 };
     44 
     45 const listeners = new Set();
     46 
     47 // Detecting worker is different if this file is loaded via Common JS loader (isWorker global)
     48 // or as a JSM (constructor name)
     49 // eslint-disable-next-line no-shadow
     50 const isWorker =
     51  globalThis.isWorker ||
     52  globalThis.constructor.name == "WorkerDebuggerGlobalScope";
     53 
     54 // This module can be loaded from the worker thread, where we can't use ChromeUtils.
     55 // So implement custom lazy getters (without XPCOMUtils ESM) from here.
     56 // Worker codepath in DevTools will pass a custom Debugger instance.
     57 const customLazy = {
     58  get Debugger() {
     59    // When this code runs in the worker thread, loaded via `loadSubScript`
     60    // (ex: browser_worker_tracer.js and WorkerDebugger.tracer.js),
     61    // this module runs within the WorkerDebuggerGlobalScope and have immediate access to Debugger class.
     62    if (globalThis.Debugger) {
     63      return globalThis.Debugger;
     64    }
     65    // When this code runs in the worker thread, loaded via `require`
     66    // (ex: from tracer actor module),
     67    // this module no longer has WorkerDebuggerGlobalScope as global,
     68    // but has to use require() to pull Debugger.
     69    if (isWorker) {
     70      // require is defined for workers.
     71      // eslint-disable-next-line no-undef
     72      return require("Debugger");
     73    }
     74    const { addDebuggerToGlobal } = ChromeUtils.importESModule(
     75      "resource://gre/modules/jsdebugger.sys.mjs"
     76    );
     77    // Avoid polluting all Modules global scope by using a Sandox as global.
     78    const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
     79    const debuggerSandbox = Cu.Sandbox(systemPrincipal);
     80    addDebuggerToGlobal(debuggerSandbox);
     81    delete customLazy.Debugger;
     82    customLazy.Debugger = debuggerSandbox.Debugger;
     83    return customLazy.Debugger;
     84  },
     85 
     86  get DistinctCompartmentDebugger() {
     87    const { addDebuggerToGlobal } = ChromeUtils.importESModule(
     88      "resource://gre/modules/jsdebugger.sys.mjs",
     89      { global: "contextual" }
     90    );
     91    const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
     92    const debuggerSandbox = Cu.Sandbox(systemPrincipal, {
     93      // As we may debug the JSM/ESM shared global, we should be using a Debugger
     94      // from another system global.
     95      freshCompartment: true,
     96    });
     97    addDebuggerToGlobal(debuggerSandbox);
     98    delete customLazy.DistinctCompartmentDebugger;
     99    customLazy.DistinctCompartmentDebugger = debuggerSandbox.Debugger;
    100    return customLazy.DistinctCompartmentDebugger;
    101  },
    102 };
    103 
    104 /**
    105 * Start tracing against a given JS global.
    106 * Only code run from that global will be logged.
    107 *
    108 * @param {object} options
    109 *        Object with configurations:
    110 * @param {object} options.global
    111 *        The tracer only log traces related to the code executed within this global.
    112 *        When omitted, it will default to the options object's global.
    113 * @param {boolean} options.traceAllGlobals
    114 *        When set to true, this will trace all the globals running in the current thread.
    115 * @param {string} options.prefix
    116 *        Optional string logged as a prefix to all traces.
    117 * @param {boolean} options.loggingMethod
    118 *        Optional setting to use something else than `dump()` to log traces to stdout.
    119 *        This is mostly used by tests.
    120 * @param {boolean} options.traceDOMEvents
    121 *        Optional setting to enable tracing all the DOM events being going through
    122 *        dom/events/EventListenerManager.cpp's `EventListenerManager`.
    123 * @param {Array<string>} options.traceDOMMutations
    124 *        Optional setting to enable tracing all the DOM mutations.
    125 *        This array may contains three strings:
    126 *          - "add": trace all new DOM Node being added,
    127 *          - "attributes": trace all DOM attribute modifications,
    128 *          - "delete": trace all DOM Node being removed.
    129 * @param {boolean} options.traceValues
    130 *        Optional setting to enable tracing all function call values as well,
    131 *        as returned values (when we do log returned frames).
    132 * @param {boolean} options.traceOnNextInteraction
    133 *        Optional setting to enable when the tracing should only start when the
    134 *        use starts interacting with the page. i.e. on next keydown or mousedown.
    135 * @param {boolean} options.traceSteps
    136 *        Optional setting to enable tracing each frame within a function execution.
    137 *        (i.e. not only function call and function returns [when traceFunctionReturn is true])
    138 * @param {boolean} options.traceFunctionReturn
    139 *        Optional setting to enable when the tracing should notify about frame exit.
    140 *        i.e. when a function call returns or throws.
    141 * @param {string} options.filterFrameSourceUrl
    142 *        Optional setting to restrict all traces to only a given source URL.
    143 *        This is a loose check, so any source whose URL includes the passed string will be traced.
    144 * @param {number} options.maxDepth
    145 *        Optional setting to ignore frames when depth is greater than the passed number.
    146 * @param {number} options.maxRecords
    147 *        Optional setting to stop the tracer after having recorded at least
    148 *        the passed number of top level frames.
    149 * @param {number} options.pauseOnStep
    150 *        Optional setting to delay each frame execution for a given amount of time in ms.
    151 */
    152 class JavaScriptTracer {
    153  constructor(options) {
    154    this.onEnterFrame = this.onEnterFrame.bind(this);
    155 
    156    // DevTools CommonJS Workers modules don't have access to AbortController
    157    if (!isWorker) {
    158      this.abortController = new AbortController();
    159    }
    160 
    161    if (options.traceAllGlobals) {
    162      this.traceAllGlobals = true;
    163      if (options.traceOnNextInteraction) {
    164        throw new Error(
    165          "Tracing all globals and waiting for next user interaction are not yet compatible"
    166        );
    167      }
    168      if (this.traceDOMEvents) {
    169        throw new Error(
    170          "Tracing all globals and DOM Events are not yet compatible"
    171        );
    172      }
    173      if (options.global) {
    174        throw new Error(
    175          "'global' option should be omitted when using 'traceAllGlobals'"
    176        );
    177      }
    178    } else {
    179      // By default, we would trace only JavaScript related to caller's global.
    180      // As there is no way to compute the caller's global default to the global of the
    181      // mandatory options argument.
    182      this.tracedGlobal = options.global || Cu.getGlobalForObject(options);
    183    }
    184 
    185    // Instantiate a brand new Debugger API so that we can trace independently
    186    // of all other DevTools operations. i.e. we can pause while tracing without any interference.
    187    this.dbg = this.makeDebugger();
    188 
    189    this.prefix = options.prefix ? `${options.prefix}: ` : "";
    190 
    191    // List of all async frame which are poped per Spidermonkey API
    192    // but are actually waiting for async operation.
    193    // We should later enter them again when the async task they are being waiting for is completed.
    194    this.pendingAwaitFrames = new Set();
    195 
    196    this.loggingMethod = options.loggingMethod;
    197    if (!this.loggingMethod) {
    198      // On workers, `dump` can't be called with JavaScript on another object,
    199      // so bind it.
    200      this.loggingMethod = isWorker ? dump.bind(null) : dump;
    201    }
    202 
    203    this.traceDOMEvents = !!options.traceDOMEvents;
    204 
    205    if (options.traceDOMMutations) {
    206      if (!Array.isArray(options.traceDOMMutations)) {
    207        throw new Error("'traceDOMMutations' attribute should be an array");
    208      }
    209      const acceptedValues = Object.values(DOM_MUTATIONS);
    210      if (!options.traceDOMMutations.every(e => acceptedValues.includes(e))) {
    211        throw new Error(
    212          `'traceDOMMutations' only accept array of strings whose values can be: ${acceptedValues}`
    213        );
    214      }
    215      this.traceDOMMutations = options.traceDOMMutations;
    216    }
    217    this.traceSteps = !!options.traceSteps;
    218    this.traceValues = !!options.traceValues;
    219    this.traceFunctionReturn = !!options.traceFunctionReturn;
    220    this.maxDepth = options.maxDepth;
    221    this.maxRecords = options.maxRecords;
    222    this.records = 0;
    223    if ("pauseOnStep" in options) {
    224      if (typeof options.pauseOnStep != "number") {
    225        throw new Error("'pauseOnStep' attribute should be a number");
    226      }
    227      this.pauseOnStep = options.pauseOnStep;
    228    }
    229    if ("filterFrameSourceUrl" in options) {
    230      if (typeof options.filterFrameSourceUrl != "string") {
    231        throw new Error("'filterFrameSourceUrl' attribute should be a string");
    232      }
    233      this.filterFrameSourceUrl = options.filterFrameSourceUrl;
    234    }
    235 
    236    // An increment used to identify function calls and their returned/exit frames
    237    this.frameId = 0;
    238 
    239    // This feature isn't supported on Workers as they aren't involving user events
    240    if (options.traceOnNextInteraction && !isWorker) {
    241      this.#waitForNextInteraction();
    242    } else {
    243      this.#startTracing();
    244    }
    245  }
    246 
    247  // Is actively tracing?
    248  // We typically start tracing from the constructor, unless the "trace on next user interaction" feature is used.
    249  isTracing = false;
    250 
    251  /**
    252   * In case `traceOnNextInteraction` option is used, delay the actual start of tracing until a first user interaction.
    253   */
    254  #waitForNextInteraction() {
    255    // Use a dedicated Abort Controller as we are going to stop it as soon as we get the first user interaction,
    256    // whereas other listeners would typically wait for tracer stop.
    257    this.nextInteractionAbortController = new AbortController();
    258 
    259    const listener = () => {
    260      this.nextInteractionAbortController.abort();
    261      // Avoid tracing if the users asked to stop tracing while we were waiting for the user interaction.
    262      if (this.dbg) {
    263        this.#startTracing();
    264      }
    265    };
    266    const eventOptions = {
    267      signal: this.nextInteractionAbortController.signal,
    268      capture: true,
    269    };
    270    // Register the event listener on the Chrome Event Handler in order to receive the event first.
    271    // When used for the parent process target, `tracedGlobal` is browser.xhtml's window, which doesn't have a chromeEventHandler.
    272    const eventHandler =
    273      this.tracedGlobal.docShell.chromeEventHandler || this.tracedGlobal;
    274    eventHandler.addEventListener("mousedown", listener, eventOptions);
    275    eventHandler.addEventListener("keydown", listener, eventOptions);
    276 
    277    // Significate to the user that the tracer is registered, but not tracing just yet.
    278    let shouldLogToStdout = listeners.size == 0;
    279    for (const l of listeners) {
    280      if (typeof l.onTracingPending == "function") {
    281        shouldLogToStdout |= l.onTracingPending();
    282      }
    283    }
    284    if (shouldLogToStdout) {
    285      this.loggingMethod(this.prefix + NEXT_INTERACTION_MESSAGE + "\n");
    286    }
    287  }
    288 
    289  /**
    290   * Actually really start watching for executions.
    291   *
    292   * This may be delayed when traceOnNextInteraction options is used.
    293   * Otherwise we start tracing as soon as the class instantiates.
    294   */
    295  #startTracing() {
    296    this.isTracing = true;
    297 
    298    this.dbg.onEnterFrame = this.onEnterFrame;
    299 
    300    if (this.traceDOMEvents) {
    301      this.startTracingDOMEvents();
    302    }
    303    // This feature isn't supported on Workers as they aren't interacting with the DOM Tree
    304    if (this.traceDOMMutations?.length > 0 && !isWorker) {
    305      this.startTracingDOMMutations();
    306    }
    307 
    308    // In any case, we consider the tracing as started
    309    this.notifyToggle(true);
    310  }
    311 
    312  startTracingDOMEvents() {
    313    this.debuggerNotificationObserver = new DebuggerNotificationObserver();
    314    this.eventListener = this.eventListener.bind(this);
    315    this.debuggerNotificationObserver.addListener(this.eventListener);
    316    this.debuggerNotificationObserver.connect(this.tracedGlobal);
    317 
    318    // When we are tracing a document, also ensure connecting to all its children iframe globals.
    319    // If we don't, Debugger API would fire onEnterFrame for their JavaScript code,
    320    // but DOM Events wouldn't be notified by DebuggerNotificationObserver.
    321    if (!isWorker && this.tracedGlobal instanceof Ci.nsIDOMWindow) {
    322      const { browserId } = this.tracedGlobal.browsingContext;
    323      // Keep track of any future global
    324      this.dbg.onNewGlobalObject = g => {
    325        try {
    326          const win = g.unsafeDereference();
    327          // only process globals relating to documents, and which are within the debugged tab
    328          if (
    329            win instanceof Ci.nsIDOMWindow &&
    330            win.browsingContext.browserId == browserId
    331          ) {
    332            this.dbg.addDebuggee(g);
    333            this.debuggerNotificationObserver.connect(win);
    334          }
    335        } catch (e) {}
    336      };
    337      // Register all, already existing children
    338      for (const browsingContext of this.tracedGlobal.browsingContext.getAllBrowsingContextsInSubtree()) {
    339        try {
    340          // Only consider children which run in the same process, and exposes their window object
    341          if (browsingContext.window) {
    342            this.dbg.addDebuggee(browsingContext.window);
    343            this.debuggerNotificationObserver.connect(browsingContext.window);
    344          }
    345        } catch (e) {}
    346      }
    347    }
    348 
    349    this.currentDOMEvent = null;
    350  }
    351 
    352  stopTracingDOMEvents() {
    353    if (this.debuggerNotificationObserver) {
    354      this.debuggerNotificationObserver.removeListener(this.eventListener);
    355      this.debuggerNotificationObserver.disconnect(this.tracedGlobal);
    356      this.debuggerNotificationObserver = null;
    357    }
    358    this.currentDOMEvent = null;
    359  }
    360 
    361  startTracingDOMMutations() {
    362    this.tracedGlobal.document.devToolsWatchingDOMMutations = true;
    363 
    364    const eventOptions = {
    365      signal: this.abortController.signal,
    366      capture: true,
    367    };
    368    // When used for the parent process target, `tracedGlobal` is browser.xhtml's window, which doesn't have a chromeEventHandler.
    369    const eventHandler =
    370      this.tracedGlobal.docShell.chromeEventHandler || this.tracedGlobal;
    371    if (this.traceDOMMutations.includes(DOM_MUTATIONS.ADD)) {
    372      eventHandler.addEventListener(
    373        "devtoolschildinserted",
    374        this.#onDOMMutation,
    375        eventOptions
    376      );
    377    }
    378    if (this.traceDOMMutations.includes(DOM_MUTATIONS.ATTRIBUTES)) {
    379      eventHandler.addEventListener(
    380        "devtoolsattrmodified",
    381        this.#onDOMMutation,
    382        eventOptions
    383      );
    384    }
    385    if (this.traceDOMMutations.includes(DOM_MUTATIONS.REMOVE)) {
    386      eventHandler.addEventListener(
    387        "devtoolschildremoved",
    388        this.#onDOMMutation,
    389        eventOptions
    390      );
    391    }
    392  }
    393 
    394  stopTracingDOMMutations() {
    395    this.tracedGlobal.document.devToolsWatchingDOMMutations = false;
    396    // Note that the event listeners are all going to be unregistered via the AbortController.
    397  }
    398 
    399  /**
    400   * Called for any DOM Mutation done in the traced document.
    401   *
    402   * @param {DOM Event} event
    403   */
    404  #onDOMMutation = event => {
    405    // Ignore elements inserted by DevTools, like the inspector's highlighters
    406    if (event.target.isNativeAnonymous) {
    407      return;
    408    }
    409 
    410    let type = "";
    411    switch (event.type) {
    412      case "devtoolschildinserted":
    413        type = DOM_MUTATIONS.ADD;
    414        break;
    415      case "devtoolsattrmodified":
    416        type = DOM_MUTATIONS.ATTRIBUTES;
    417        break;
    418      case "devtoolschildremoved":
    419        type = DOM_MUTATIONS.REMOVE;
    420        break;
    421      default:
    422        throw new Error("Unexpected DOM Mutation event type: " + event.type);
    423    }
    424 
    425    let shouldLogToStdout = true;
    426 
    427    // The depth is the depth of the parent frame, consider the dom mutation as nested to it
    428    const depth = this.depth + 1;
    429 
    430    if (listeners.size > 0) {
    431      shouldLogToStdout = false;
    432      for (const listener of listeners) {
    433        // If any listener return true, also log to stdout
    434        if (typeof listener.onTracingDOMMutation == "function") {
    435          shouldLogToStdout |= listener.onTracingDOMMutation({
    436            depth,
    437            prefix: this.prefix,
    438 
    439            type,
    440            element: event.target,
    441            caller: Components.stack.caller,
    442          });
    443        }
    444      }
    445    }
    446 
    447    if (shouldLogToStdout) {
    448      const padding = "—".repeat(depth + 1);
    449      this.loggingMethod(
    450        this.prefix +
    451          padding +
    452          `[DOM Mutation | ${type}] ` +
    453          objectToString(event.target) +
    454          "\n"
    455      );
    456    }
    457  };
    458 
    459  /**
    460   * Called by DebuggerNotificationObserver interface when a DOM event start being notified
    461   * and after it has been notified.
    462   *
    463   * @param {DebuggerNotification} notification
    464   *        Info about the DOM event. See the related idl file.
    465   */
    466  eventListener(notification) {
    467    // For each event we get two notifications.
    468    // One just before firing the listeners and another one just after.
    469    //
    470    // Update `this.currentDOMEvent` to be refering to the event name
    471    // while the DOM event is being notified. It will be null the rest of the time.
    472    //
    473    // We don't need to maintain a stack of events as that's only consumed by onEnterFrame
    474    // which only cares about the very lastest event being currently trigerring some code.
    475    if (notification.phase == "pre") {
    476      // We get notified about "real" DOM event when type is "domEvent",
    477      // but also when some other DOM APIs are involved.
    478      // notification's type will be "setTimeout" when the setTimeout method is called,
    479      // or "setTimeoutCallback" when the callback passed to setTimeout is called.
    480      // This also work against setInterval/clearTimeout/clearInterval and requestAnimationFrame.
    481      if (notification.type == "domEvent") {
    482        // `targetType` can help distinguish same-name DOM events fired against XHR, window or workers.
    483        const { targetType } = notification;
    484        let { type } = notification.event;
    485        if (!type) {
    486          // In the Worker thread, `notification.event` is an opaque wrapper.
    487          // In other threads it is a Xray wrapper.
    488          // Because of this difference, we have to fallback to use the Debugger.Object API.
    489          type = this.dbg
    490            .makeGlobalObjectReference(notification.global)
    491            .makeDebuggeeValue(notification.event)
    492            .getProperty("type").return;
    493        }
    494        this.currentDOMEvent = `${targetType}.${type}`;
    495      } else {
    496        this.currentDOMEvent = notification.type;
    497      }
    498    } else {
    499      this.currentDOMEvent = null;
    500    }
    501  }
    502 
    503  /**
    504   * Stop observing execution.
    505   *
    506   * @param {string} reason
    507   *        Optional string to justify why the tracer stopped.
    508   */
    509  stopTracing(reason = "") {
    510    // Note that this may be called before `#startTracing()`, but still want to completely shut it down.
    511    if (!this.dbg) {
    512      return;
    513    }
    514 
    515    this.dbg.onEnterFrame = undefined;
    516 
    517    this.dbg.removeAllDebuggees();
    518    this.dbg.onNewGlobalObject = undefined;
    519    this.dbg = null;
    520 
    521    this.depth = 0;
    522 
    523    // Cancel the traceOnNextInteraction event listeners.
    524    if (this.nextInteractionAbortController) {
    525      this.nextInteractionAbortController.abort();
    526      this.nextInteractionAbortController = null;
    527    }
    528 
    529    if (this.traceDOMEvents) {
    530      this.stopTracingDOMEvents();
    531    }
    532    if (this.traceDOMMutations?.length > 0 && !isWorker) {
    533      this.stopTracingDOMMutations();
    534    }
    535 
    536    // Unregister all event listeners
    537    if (this.abortController) {
    538      this.abortController.abort();
    539    }
    540 
    541    this.tracedGlobal = null;
    542    this.isTracing = false;
    543 
    544    this.notifyToggle(false, reason);
    545  }
    546 
    547  /**
    548   * Instantiate a Debugger API instance dedicated to each Tracer instance.
    549   * It will notably be different from the instance used in DevTools.
    550   * This allows to implement tracing independently of DevTools.
    551   */
    552  makeDebugger() {
    553    if (this.traceAllGlobals) {
    554      const dbg = new customLazy.DistinctCompartmentDebugger();
    555      dbg.addAllGlobalsAsDebuggees();
    556 
    557      // addAllGlobalAsAdebuggees will also add the global for this module...
    558      // which we have to prevent tracing!
    559      // eslint-disable-next-line mozilla/reject-globalThis-modification
    560      dbg.removeDebuggee(globalThis);
    561 
    562      // Add any future global being created later
    563      dbg.onNewGlobalObject = g => dbg.addDebuggee(g);
    564      return dbg;
    565    }
    566 
    567    // When this code runs in the worker thread, Cu isn't available
    568    // and we don't have system principal anyway in this context.
    569    const { isSystemPrincipal } =
    570      typeof Cu == "object" ? Cu.getObjectPrincipal(this.tracedGlobal) : {};
    571 
    572    // When debugging the system modules, we have to use a special instance
    573    // of Debugger loaded in a distinct system global.
    574    const dbg = isSystemPrincipal
    575      ? new customLazy.DistinctCompartmentDebugger()
    576      : new customLazy.Debugger();
    577 
    578    // For now, we only trace calls for one particular global at a time.
    579    // See the constructor for its definition.
    580    dbg.addDebuggee(this.tracedGlobal);
    581 
    582    return dbg;
    583  }
    584 
    585  /**
    586   * Notify DevTools and/or the user via stdout that tracing
    587   * has been enabled or disabled.
    588   *
    589   * @param {boolean} state
    590   *        True if we just started tracing, false when it just stopped.
    591   * @param {string} reason
    592   *        Optional string to justify why the tracer stopped.
    593   */
    594  notifyToggle(state, reason) {
    595    let shouldLogToStdout = listeners.size == 0;
    596    for (const listener of listeners) {
    597      if (typeof listener.onTracingToggled == "function") {
    598        shouldLogToStdout |= listener.onTracingToggled(state, reason);
    599      }
    600    }
    601    if (shouldLogToStdout) {
    602      if (state) {
    603        this.loggingMethod(this.prefix + "Start tracing JavaScript\n");
    604      } else {
    605        if (reason) {
    606          reason = ` (reason: ${reason})`;
    607        }
    608        this.loggingMethod(
    609          this.prefix + "Stop tracing JavaScript" + reason + "\n"
    610        );
    611      }
    612    }
    613  }
    614 
    615  /**
    616   * Called by the Debugger API (this.dbg) when a new frame is executed.
    617   *
    618   * @param {Debugger.Frame} frame
    619   *        A descriptor object for the JavaScript frame.
    620   */
    621  onEnterFrame(frame) {
    622    // Safe check, just in case we keep being notified, but the tracer has been stopped
    623    if (!this.dbg) {
    624      return;
    625    }
    626    try {
    627      // If an optional filter is passed, ignore frames which aren't matching the filter string
    628      if (
    629        this.filterFrameSourceUrl &&
    630        !frame.script.source.url?.includes(this.filterFrameSourceUrl)
    631      ) {
    632        return;
    633      }
    634 
    635      // Because of async frame which are popped and entered again on completion of the awaited async task,
    636      // we have to compute the depth from the frame. (and can't use a simple increment on enter/decrement on pop).
    637      const depth = getFrameDepth(frame);
    638 
    639      // Save the current depth for the DOM Mutation handler
    640      this.depth = depth;
    641 
    642      // Ignore the frame if we reached the depth limit (if one is provided)
    643      if (this.maxDepth && depth >= this.maxDepth) {
    644        return;
    645      }
    646 
    647      // When we encounter a frame which was previously popped because of pending on an async task,
    648      // ignore it and only log the following ones.
    649      if (this.pendingAwaitFrames.has(frame)) {
    650        this.pendingAwaitFrames.delete(frame);
    651        return;
    652      }
    653 
    654      // Auto-stop the tracer if we reached the number of max recorded top level frames
    655      if (depth === 0 && this.maxRecords) {
    656        if (this.records >= this.maxRecords) {
    657          this.stopTracing("max-records");
    658          return;
    659        }
    660        this.records++;
    661      }
    662 
    663      const frameId = this.frameId++;
    664      let shouldLogToStdout = true;
    665 
    666      // If there is at least one DevTools debugging this process,
    667      // delegate logging to DevTools actors.
    668      if (listeners.size > 0) {
    669        shouldLogToStdout = false;
    670        const formatedDisplayName = formatDisplayName(frame);
    671        for (const listener of listeners) {
    672          // If any listener return true, also log to stdout
    673          if (typeof listener.onTracingFrame == "function") {
    674            shouldLogToStdout |= listener.onTracingFrame({
    675              frameId,
    676              frame,
    677              depth,
    678              formatedDisplayName,
    679              prefix: this.prefix,
    680              currentDOMEvent: this.currentDOMEvent,
    681            });
    682          }
    683          // Bail out early if any listener stopped tracing as the Frame object
    684          // will be no longer usable by any other code.
    685          if (!this.isTracing) {
    686            return;
    687          }
    688        }
    689      }
    690 
    691      // DevTools may delegate the work to log to stdout,
    692      // but if DevTools are closed, stdout is the only way to log the traces.
    693      if (shouldLogToStdout) {
    694        this.logFrameEnteredToStdout(frame, depth);
    695      }
    696 
    697      if (this.traceSteps) {
    698        // Collect the location notified via onTracingFrame to also avoid redundancy between similar location
    699        // between onEnterFrame and onStep notifications.
    700        let { lineNumber: lastLine, columnNumber: lastColumn } =
    701          frame.script.getOffsetMetadata(frame.offset);
    702 
    703        frame.onStep = () => {
    704          // Spidermonkey steps on many intermediate positions which don't make sense to the user.
    705          // `isStepStart` is close to each statement start, which is meaningful to the user.
    706          const { isStepStart, lineNumber, columnNumber } =
    707            frame.script.getOffsetMetadata(frame.offset);
    708          if (!isStepStart) {
    709            return;
    710          }
    711          // onStep may be called on many instructions related to the same line and colunm.
    712          // Avoid notifying duplicated steps if we stepped on the exact same location.
    713          if (lastLine == lineNumber && lastColumn == columnNumber) {
    714            return;
    715          }
    716          lastLine = lineNumber;
    717          lastColumn = columnNumber;
    718 
    719          shouldLogToStdout = true;
    720          if (listeners.size > 0) {
    721            shouldLogToStdout = false;
    722            for (const listener of listeners) {
    723              // If any listener return true, also log to stdout
    724              if (typeof listener.onTracingFrameStep == "function") {
    725                shouldLogToStdout |= listener.onTracingFrameStep({
    726                  frame,
    727                  depth,
    728                  prefix: this.prefix,
    729                });
    730              }
    731            }
    732          }
    733          if (shouldLogToStdout) {
    734            this.logFrameStepToStdout(frame, depth);
    735          }
    736          // Optionaly pause the frame execution by letting the other event loop to run in between.
    737          if (typeof this.pauseOnStep == "number") {
    738            syncPause(this.pauseOnStep);
    739          }
    740        };
    741      }
    742 
    743      frame.onPop = completion => {
    744        this.depth--;
    745 
    746        // Special case async frames. We are exiting the current frame because of waiting for an async task.
    747        // (this is typically a `await foo()` from an async function)
    748        // This frame should later be "entered" again.
    749        if (completion?.await) {
    750          this.pendingAwaitFrames.add(frame);
    751          return;
    752        }
    753 
    754        if (!this.traceFunctionReturn) {
    755          return;
    756        }
    757 
    758        let why = "";
    759        let rv = undefined;
    760        if (!completion) {
    761          why = FRAME_EXIT_REASONS.TERMINATED;
    762        } else if ("return" in completion) {
    763          why = FRAME_EXIT_REASONS.RETURN;
    764          rv = completion.return;
    765        } else if ("yield" in completion) {
    766          why = FRAME_EXIT_REASONS.YIELD;
    767          rv = completion.yield;
    768        } else if ("await" in completion) {
    769          why = FRAME_EXIT_REASONS.AWAIT;
    770        } else {
    771          why = FRAME_EXIT_REASONS.THROW;
    772          rv = completion.throw;
    773        }
    774 
    775        shouldLogToStdout = true;
    776        if (listeners.size > 0) {
    777          shouldLogToStdout = false;
    778          const formatedDisplayName = formatDisplayName(frame);
    779          for (const listener of listeners) {
    780            // If any listener return true, also log to stdout
    781            if (typeof listener.onTracingFrameExit == "function") {
    782              shouldLogToStdout |= listener.onTracingFrameExit({
    783                frameId,
    784                frame,
    785                depth,
    786                formatedDisplayName,
    787                prefix: this.prefix,
    788                why,
    789                rv,
    790              });
    791            }
    792          }
    793        }
    794        if (shouldLogToStdout) {
    795          this.logFrameExitedToStdout(frame, depth, why, rv);
    796        }
    797      };
    798 
    799      // Optionaly pause the frame execution by letting the other event loop to run in between.
    800      if (typeof this.pauseOnStep == "number") {
    801        syncPause(this.pauseOnStep);
    802      }
    803    } catch (e) {
    804      console.error("Exception while tracing javascript", e);
    805    }
    806  }
    807 
    808  /**
    809   * Display to stdout one given frame execution, which represents a function call.
    810   *
    811   * @param {Debugger.Frame} frame
    812   * @param {number} depth
    813   */
    814  logFrameEnteredToStdout(frame, depth) {
    815    const padding = "—".repeat(depth + 1);
    816 
    817    // If we are tracing DOM events and we are in middle of an event,
    818    // and are logging the topmost frame,
    819    // then log a preliminary dedicated line to mention that event type.
    820    if (this.currentDOMEvent && depth == 0) {
    821      this.loggingMethod(
    822        this.prefix + padding + "DOM | " + this.currentDOMEvent + "\n"
    823      );
    824    }
    825 
    826    let message = `${padding}[${frame.implementation}]—> ${getTerminalHyperLink(
    827      frame
    828    )} - ${formatDisplayName(frame)}`;
    829 
    830    // Log arguments, but only when this feature is enabled as it introduces
    831    // some significant performance and visual overhead.
    832    // Also prevent trying to log function call arguments if we aren't logging a frame
    833    // with arguments (e.g. Debugger evaluation frames, when executing from the console)
    834    if (this.traceValues && frame.arguments) {
    835      message += "(";
    836      for (let i = 0, l = frame.arguments.length; i < l; i++) {
    837        const arg = frame.arguments[i];
    838        // Debugger.Frame.arguments contains either a Debugger.Object or primitive object
    839        if (arg?.unsafeDereference) {
    840          // Special case classes as they can't be easily differentiated in pure JavaScript
    841          if (arg.isClassConstructor) {
    842            message += "class " + arg.name;
    843          } else {
    844            message += objectToString(arg.unsafeDereference());
    845          }
    846        } else {
    847          message += primitiveToString(arg);
    848        }
    849 
    850        if (i < l - 1) {
    851          message += ", ";
    852        }
    853      }
    854      message += ")";
    855    }
    856 
    857    this.loggingMethod(this.prefix + message + "\n");
    858  }
    859 
    860  /**
    861   * Display to stdout one given frame execution, which represents a step within a function execution.
    862   *
    863   * @param {Debugger.Frame} frame
    864   * @param {number} depth
    865   */
    866  logFrameStepToStdout(frame, depth) {
    867    const padding = "—".repeat(depth + 1);
    868 
    869    const message = `${padding}${getTerminalHyperLink(frame)}`;
    870 
    871    this.loggingMethod(this.prefix + message + "\n");
    872  }
    873 
    874  /**
    875   * Display to stdout the exit of a given frame execution, which represents a function return.
    876   *
    877   * @param {Debugger.Frame} frame
    878   * @param {string} why
    879   * @param {number} depth
    880   */
    881  logFrameExitedToStdout(frame, depth, why, rv) {
    882    const padding = "—".repeat(depth + 1);
    883 
    884    let message = `${padding}[${frame.implementation}]<— ${getTerminalHyperLink(
    885      frame
    886    )} - ${formatDisplayName(frame)} ${why}`;
    887 
    888    // Log returned values, but only when this feature is enabled as it introduces
    889    // some significant performance and visual overhead.
    890    if (this.traceValues) {
    891      message += " ";
    892      // Debugger.Frame.arguments contains either a Debugger.Object or primitive object
    893      if (rv?.unsafeDereference) {
    894        // Special case classes as they can't be easily differentiated in pure JavaScript
    895        if (rv.isClassConstructor) {
    896          message += "class " + rv.name;
    897        } else {
    898          message += objectToString(rv.unsafeDereference());
    899        }
    900      } else {
    901        message += primitiveToString(rv);
    902      }
    903    }
    904 
    905    this.loggingMethod(this.prefix + message + "\n");
    906  }
    907 }
    908 
    909 /**
    910 * Return a string description for any arbitrary JS value.
    911 * Used when logging to stdout.
    912 *
    913 * @param {object} obj
    914 *        Any JavaScript object to describe.
    915 * @return String
    916 *         User meaningful descriptor for the object.
    917 */
    918 function objectToString(obj) {
    919  if (Element.isInstance(obj)) {
    920    let message = `<${obj.tagName}`;
    921    if (obj.id) {
    922      message += ` #${obj.id}`;
    923    }
    924    if (obj.className) {
    925      message += ` .${obj.className}`;
    926    }
    927    message += ">";
    928    return message;
    929  } else if (Array.isArray(obj)) {
    930    return `Array(${obj.length})`;
    931  } else if (Event.isInstance(obj)) {
    932    return `Event(${obj.type}) target=${objectToString(obj.target)}`;
    933  } else if (typeof obj === "function") {
    934    return `function ${obj.name || "anonymous"}()`;
    935  }
    936  return primitiveToString(obj);
    937 }
    938 
    939 function primitiveToString(value) {
    940  const type = typeof value;
    941  if (type === "string") {
    942    // Use stringify to escape special characters and display in enclosing quotes.
    943    return JSON.stringify(value);
    944  } else if (value === 0 && 1 / value === -Infinity) {
    945    // -0 is very special and need special threatment.
    946    return "-0";
    947  } else if (type === "bigint") {
    948    return `BigInt(${value})`;
    949  } else if (value && typeof value.toString === "function") {
    950    // Use toString as it allows to stringify Symbols. Converting them to string throws.
    951    return value.toString();
    952  }
    953 
    954  // For all other types/cases, rely on native convertion to string
    955  return String(value);
    956 }
    957 
    958 /**
    959 * Try to describe the current frame we are tracing
    960 *
    961 * This will typically log the name of the method being called.
    962 *
    963 * @param {Debugger.Frame} frame
    964 *        The frame which is currently being executed.
    965 */
    966 function formatDisplayName(frame) {
    967  if (frame.type === "call") {
    968    const callee = frame.callee;
    969    // Anonymous function will have undefined name and displayName.
    970    return "λ " + (callee.name || callee.displayName || "anonymous");
    971  }
    972 
    973  return `(${frame.type})`;
    974 }
    975 
    976 let activeTracer = null;
    977 
    978 /**
    979 * Start tracing JavaScript.
    980 * i.e. log the name of any function being called in JS and its location in source code.
    981 *
    982 * @param {object} options (mandatory)
    983 *        See JavaScriptTracer.startTracing jsdoc.
    984 */
    985 function startTracing(options) {
    986  if (!options) {
    987    throw new Error("startTracing excepts an options object as first argument");
    988  }
    989  if (!activeTracer) {
    990    activeTracer = new JavaScriptTracer(options);
    991  } else {
    992    console.warn(
    993      "Can't start JavaScript tracing, another tracer is still active and we only support one tracer at a time."
    994    );
    995  }
    996 }
    997 
    998 /**
    999 * Stop tracing JavaScript.
   1000 */
   1001 function stopTracing() {
   1002  if (activeTracer) {
   1003    activeTracer.stopTracing();
   1004    activeTracer = null;
   1005  } else {
   1006    console.warn("Can't stop JavaScript Tracing as we were not tracing.");
   1007  }
   1008 }
   1009 
   1010 /**
   1011 * Listen for tracing updates.
   1012 *
   1013 * The listener object may expose the following methods:
   1014 * - onTracingToggled(state)
   1015 *   Where state is a boolean to indicate if tracing has just been enabled of disabled.
   1016 *   It may be immediatelly called if a tracer is already active.
   1017 *
   1018 * - onTracingFrame({ frame, depth, formatedDisplayName, prefix })
   1019 *   Called each time we enter a new JS frame.
   1020 *   - frame is a Debugger.Frame object
   1021 *   - depth is a number and represents the depth of the frame in the call stack
   1022 *   - formatedDisplayName is a string and is a human readable name for the current frame
   1023 *   - prefix is a string to display as a prefix of any logged frame
   1024 *
   1025 * @param {object} listener
   1026 */
   1027 function addTracingListener(listener) {
   1028  listeners.add(listener);
   1029 
   1030  if (
   1031    activeTracer?.isTracing &&
   1032    typeof listener.onTracingToggled == "function"
   1033  ) {
   1034    listener.onTracingToggled(true);
   1035  }
   1036 }
   1037 
   1038 /**
   1039 * Unregister a listener previous registered via addTracingListener
   1040 */
   1041 function removeTracingListener(listener) {
   1042  listeners.delete(listener);
   1043 }
   1044 
   1045 function getFrameDepth(frame) {
   1046  if (typeof frame.depth !== "number") {
   1047    let depth = 0;
   1048    let f = frame;
   1049    while ((f = f.older)) {
   1050      if (f.depth) {
   1051        depth = depth + f.depth + 1;
   1052        break;
   1053      }
   1054      depth++;
   1055    }
   1056    frame.depth = depth;
   1057  }
   1058 
   1059  return frame.depth;
   1060 }
   1061 
   1062 /**
   1063 * Generate a magic string that will be rendered in smart terminals as a URL
   1064 * for the given Frame object. This URL is special as it includes a line and column.
   1065 * This URL can be clicked and Firefox will automatically open the source matching
   1066 * the frame's URL in the currently opened Debugger.
   1067 * Firefox will interpret differently the URLs ending with `/:?\d*:\d+/`.
   1068 *
   1069 * @param {Debugger.Frame} frame
   1070 *        The frame being traced.
   1071 * @return {string}
   1072 *        The URL's magic string.
   1073 */
   1074 function getTerminalHyperLink(frame) {
   1075  const { script } = frame;
   1076  const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset);
   1077 
   1078  // Use a special URL, including line and column numbers which Firefox
   1079  // interprets as to be opened in the already opened DevTool's debugger
   1080  const href = `${script.source.url}:${lineNumber}:${columnNumber}`;
   1081 
   1082  // Use special characters in order to print working hyperlinks right from the terminal
   1083  // See https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
   1084  return `\x1B]8;;${href}\x1B\\${href}\x1B]8;;\x1B\\`;
   1085 }
   1086 
   1087 /**
   1088 * Helper function to synchronously pause the current frame execution
   1089 * for a given duration in ms.
   1090 *
   1091 * @param {number} duration
   1092 */
   1093 function syncPause(duration) {
   1094  let freeze = true;
   1095  const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
   1096  timer.initWithCallback(
   1097    () => {
   1098      freeze = false;
   1099    },
   1100    duration,
   1101    Ci.nsITimer.TYPE_ONE_SHOT
   1102  );
   1103  Services.tm.spinEventLoopUntil("debugger-slow-motion", function () {
   1104    return !freeze;
   1105  });
   1106 }
   1107 
   1108 export const JSTracer = {
   1109  startTracing,
   1110  stopTracing,
   1111  addTracingListener,
   1112  removeTracingListener,
   1113  NEXT_INTERACTION_MESSAGE,
   1114  DOM_MUTATIONS,
   1115  objectToString,
   1116 };