diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:35:49 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:35:49 +0000 |
commit | d8bbc7858622b6d9c278469aab701ca0b609cddf (patch) | |
tree | eff41dc61d9f714852212739e6b3738b82a2af87 /devtools/server/tracer | |
parent | Releasing progress-linux version 125.0.3-1~progress7.99u1. (diff) | |
download | firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.tar.xz firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.zip |
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | devtools/server/tracer/moz.build | 2 | ||||
-rw-r--r-- | devtools/server/tracer/tests/browser/WorkerDebugger.tracer.js | 17 | ||||
-rw-r--r-- | devtools/server/tracer/tests/browser/browser_document_tracer.js | 18 | ||||
-rw-r--r-- | devtools/server/tracer/tests/xpcshell/test_tracer.js | 175 | ||||
-rw-r--r-- | devtools/server/tracer/tracer.jsm | 1050 | ||||
-rw-r--r-- | devtools/server/tracer/tracer.sys.mjs | 1088 |
6 files changed, 1225 insertions, 1125 deletions
diff --git a/devtools/server/tracer/moz.build b/devtools/server/tracer/moz.build index 26f7665018..40ded9ba8a 100644 --- a/devtools/server/tracer/moz.build +++ b/devtools/server/tracer/moz.build @@ -4,7 +4,7 @@ # 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/. -DevToolsModules("tracer.jsm") +DevToolsModules("tracer.sys.mjs") XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"] if CONFIG["MOZ_BUILD_APP"] != "mobile/android": diff --git a/devtools/server/tracer/tests/browser/WorkerDebugger.tracer.js b/devtools/server/tracer/tests/browser/WorkerDebugger.tracer.js index bd6e646b3b..ef15fd5cfb 100644 --- a/devtools/server/tracer/tests/browser/WorkerDebugger.tracer.js +++ b/devtools/server/tracer/tests/browser/WorkerDebugger.tracer.js @@ -1,6 +1,6 @@ "use strict"; -/* global global, loadSubScript */ +/* global global */ try { // For some reason WorkerDebuggerGlobalScope.global doesn't expose JS variables @@ -8,8 +8,11 @@ try { const dbg = new Debugger(global); const [debuggee] = dbg.getDebuggees(); - /* global startTracing, stopTracing, addTracingListener, removeTracingListener */ - loadSubScript("resource://devtools/server/tracer/tracer.jsm"); + const { JSTracer } = ChromeUtils.importESModule( + "resource://devtools/server/tracer/tracer.sys.mjs", + { global: "contextual" } + ); + const frames = []; const listener = { onTracingFrame(args) { @@ -19,13 +22,13 @@ try { return true; }, }; - addTracingListener(listener); - startTracing({ global, prefix: "testWorkerPrefix" }); + JSTracer.addTracingListener(listener); + JSTracer.startTracing({ global, prefix: "testWorkerPrefix" }); debuggee.executeInGlobal("foo()"); - stopTracing(); - removeTracingListener(listener); + JSTracer.stopTracing(); + JSTracer.removeTracingListener(listener); // Send the frames to the main thread to do the assertions there. postMessage(JSON.stringify(frames)); diff --git a/devtools/server/tracer/tests/browser/browser_document_tracer.js b/devtools/server/tracer/tests/browser/browser_document_tracer.js index dcf2c9eb4d..d7d351d6dd 100644 --- a/devtools/server/tracer/tests/browser/browser_document_tracer.js +++ b/devtools/server/tracer/tests/browser/browser_document_tracer.js @@ -17,12 +17,10 @@ add_task(async function testTracingWorker() { const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { - const { - addTracingListener, - removeTracingListener, - startTracing, - stopTracing, - } = ChromeUtils.import("resource://devtools/server/tracer/tracer.jsm"); + const { JSTracer } = ChromeUtils.importESModule( + "resource://devtools/server/tracer/tracer.sys.mjs", + { global: "shared" } + ); // We have to fake opening DevTools otherwise DebuggerNotificationObserver wouldn't work // and the tracer wouldn't be able to trace the DOM events. @@ -35,10 +33,10 @@ add_task(async function testTracingWorker() { }, }; info("Register a tracing listener"); - addTracingListener(listener); + JSTracer.addTracingListener(listener); info("Start tracing the iframe"); - startTracing({ global: content, traceDOMEvents: true }); + JSTracer.startTracing({ global: content, traceDOMEvents: true }); info("Dispatch a click event on the iframe"); EventUtils.synthesizeMouseAtCenter( @@ -58,8 +56,8 @@ add_task(async function testTracingWorker() { is(lastFrame.formatedDisplayName, "λ bar"); is(lastFrame.currentDOMEvent, "setTimeoutCallback"); - stopTracing(); - removeTracingListener(listener); + JSTracer.stopTracing(); + JSTracer.removeTracingListener(listener); ChromeUtils.notifyDevToolsClosed(); }); diff --git a/devtools/server/tracer/tests/xpcshell/test_tracer.js b/devtools/server/tracer/tests/xpcshell/test_tracer.js index 0f38052ba5..8435cb8691 100644 --- a/devtools/server/tracer/tests/xpcshell/test_tracer.js +++ b/devtools/server/tracer/tests/xpcshell/test_tracer.js @@ -3,8 +3,10 @@ "use strict"; -const { addTracingListener, removeTracingListener, startTracing, stopTracing } = - ChromeUtils.import("resource://devtools/server/tracer/tracer.jsm"); +const { JSTracer } = ChromeUtils.importESModule( + "resource://devtools/server/tracer/tracer.sys.mjs", + { global: "shared" } +); add_task(async function () { // Because this test uses evalInSandbox, we need to tweak the following prefs @@ -30,13 +32,13 @@ add_task(async function testTracingContentGlobal() { }; info("Register a tracing listener"); - addTracingListener(listener); + JSTracer.addTracingListener(listener); const sandbox = Cu.Sandbox("https://example.com"); Cu.evalInSandbox("function bar() {}; function foo() {bar()};", sandbox); info("Start tracing"); - startTracing({ global: sandbox, prefix: "testContentPrefix" }); + JSTracer.startTracing({ global: sandbox, prefix: "testContentPrefix" }); Assert.equal(toggles.length, 1); Assert.equal(toggles[0], true); @@ -56,7 +58,7 @@ add_task(async function testTracingContentGlobal() { Assert.ok(lastFrame.frame); info("Stop tracing"); - stopTracing(); + JSTracer.stopTracing(); Assert.equal(toggles.length, 2); Assert.equal(toggles[1], false); @@ -65,19 +67,19 @@ add_task(async function testTracingContentGlobal() { Assert.equal(frames.length, 0); info("Start tracing again, and recall code"); - startTracing({ global: sandbox, prefix: "testContentPrefix" }); + JSTracer.startTracing({ global: sandbox, prefix: "testContentPrefix" }); sandbox.foo(); info("New traces are logged"); Assert.equal(frames.length, 2); info("Unregister the listener and recall code"); - removeTracingListener(listener); + JSTracer.removeTracingListener(listener); sandbox.foo(); info("No more traces are logged"); Assert.equal(frames.length, 2); info("Stop tracing"); - stopTracing(); + JSTracer.stopTracing(); }); add_task(async function testTracingJSMGlobal() { @@ -103,10 +105,10 @@ add_task(async function testTracingJSMGlobal() { ); info("Register a tracing listener"); - addTracingListener(listenerSandbox.listener); + JSTracer.addTracingListener(listenerSandbox.listener); info("Start tracing"); - startTracing({ global: null, prefix: "testPrefix" }); + JSTracer.startTracing({ global: null, prefix: "testPrefix" }); Assert.equal(listenerSandbox.toggles.length, 1); Assert.equal(listenerSandbox.toggles[0], true); @@ -131,11 +133,11 @@ add_task(async function testTracingJSMGlobal() { Assert.ok(lastFrame.frame); info("Stop tracing"); - stopTracing(); + JSTracer.stopTracing(); Assert.equal(listenerSandbox.toggles.length, 2); Assert.equal(listenerSandbox.toggles[1], false); - removeTracingListener(listenerSandbox.listener); + JSTracer.removeTracingListener(listenerSandbox.listener); }); add_task(async function testTracingValues() { @@ -153,7 +155,7 @@ add_task(async function testTracingValues() { } info("Start tracing"); - startTracing({ global: sandbox, traceValues: true, loggingMethod }); + JSTracer.startTracing({ global: sandbox, traceValues: true, loggingMethod }); info("Call some code"); sandbox.foo(); @@ -167,7 +169,7 @@ add_task(async function testTracingValues() { ); info("Stop tracing"); - stopTracing(); + JSTracer.stopTracing(); }); add_task(async function testTracingFunctionReturn() { @@ -185,7 +187,11 @@ add_task(async function testTracingFunctionReturn() { } info("Start tracing"); - startTracing({ global: sandbox, traceFunctionReturn: true, loggingMethod }); + JSTracer.startTracing({ + global: sandbox, + traceFunctionReturn: true, + loggingMethod, + }); info("Call some code"); sandbox.foo(); @@ -198,7 +204,7 @@ add_task(async function testTracingFunctionReturn() { Assert.stringContains(logs[4], "λ foo return"); info("Stop tracing"); - stopTracing(); + JSTracer.stopTracing(); }); add_task(async function testTracingFunctionReturnAndValues() { @@ -216,7 +222,7 @@ add_task(async function testTracingFunctionReturnAndValues() { } info("Start tracing"); - startTracing({ + JSTracer.startTracing({ global: sandbox, traceFunctionReturn: true, traceValues: true, @@ -236,7 +242,7 @@ add_task(async function testTracingFunctionReturnAndValues() { Assert.stringContains(logs[6], "λ foo return undefined"); info("Stop tracing"); - stopTracing(); + JSTracer.stopTracing(); }); add_task(async function testTracingStep() { @@ -246,22 +252,23 @@ add_task(async function testTracingStep() { function foo() { bar(); /* line 3 */ second(); /* line 4 */ + dump("foo\\n"); } function bar() { - let res; /* line 7 */ - if (1 === 1) { /* line 8 */ - res = "string"; /* line 9 */ + let res; /* line 8 */ + if (1 === 1) { /* line 9 */ + res = "string"; /* line 10 */ } else { res = "nope" } - return res; /* line 13 */ + return res; /* line 14 */ }; function second() { - let x = 0; /* line 16 */ - for (let i = 0; i < 2; i++) { /* line 17 */ - x++; /* line 18 */ + let x = 0; /* line 17 */ + for (let i = 0; i < 2; i++) { /* line 18 */ + x++; /* line 19 */ } - return null; /* line 20 */ + return null; /* line 21 */ }; foo();`; Cu.evalInSandbox(source, sandbox, null, "file.js", 1); @@ -273,7 +280,7 @@ foo();`; } info("Start tracing"); - startTracing({ + JSTracer.startTracing({ global: sandbox, traceSteps: true, loggingMethod, @@ -287,47 +294,46 @@ foo();`; Assert.stringContains(logs[1], "λ foo"); Assert.stringContains(logs[1], "file.js:3:3"); - // Each "step" only prints the location and nothing more - Assert.stringContains(logs[2], "file.js:3:3"); - - Assert.stringContains(logs[3], "λ bar"); - Assert.stringContains(logs[3], "file.js:6:16"); + Assert.stringContains(logs[2], "λ bar"); + Assert.stringContains(logs[2], "file.js:7:16"); - Assert.stringContains(logs[4], "file.js:8:7"); + // Each "step" only prints the location and nothing more + Assert.stringContains(logs[3], "file.js:9:7"); - Assert.stringContains(logs[5], "file.js:9:5"); + Assert.stringContains(logs[4], "file.js:10:5"); - Assert.stringContains(logs[6], "file.js:13:3"); + Assert.stringContains(logs[5], "file.js:14:3"); - Assert.stringContains(logs[7], "file.js:4:3"); + Assert.stringContains(logs[6], "file.js:4:3"); - Assert.stringContains(logs[8], "λ second"); - Assert.stringContains(logs[8], "file.js:15:19"); + Assert.stringContains(logs[7], "λ second"); + Assert.stringContains(logs[7], "file.js:16:19"); - Assert.stringContains(logs[9], "file.js:16:11"); + Assert.stringContains(logs[8], "file.js:17:11"); // For loop - Assert.stringContains(logs[10], "file.js:17:16"); + Assert.stringContains(logs[9], "file.js:18:16"); - Assert.stringContains(logs[11], "file.js:17:19"); + Assert.stringContains(logs[10], "file.js:18:19"); - Assert.stringContains(logs[12], "file.js:18:5"); + Assert.stringContains(logs[11], "file.js:19:5"); - Assert.stringContains(logs[13], "file.js:17:26"); + Assert.stringContains(logs[12], "file.js:18:26"); - Assert.stringContains(logs[14], "file.js:17:19"); + Assert.stringContains(logs[13], "file.js:18:19"); - Assert.stringContains(logs[15], "file.js:18:5"); + Assert.stringContains(logs[14], "file.js:19:5"); - Assert.stringContains(logs[16], "file.js:17:26"); + Assert.stringContains(logs[15], "file.js:18:26"); - Assert.stringContains(logs[17], "file.js:17:19"); + Assert.stringContains(logs[16], "file.js:18:19"); // End of for loop - Assert.stringContains(logs[18], "file.js:20:3"); + Assert.stringContains(logs[17], "file.js:21:3"); + Assert.stringContains(logs[18], "file.js:5:3"); info("Stop tracing"); - stopTracing(); + JSTracer.stopTracing(); }); add_task(async function testTracingPauseOnStep() { @@ -348,7 +354,7 @@ add_task(async function testTracingPauseOnStep() { } info("Start tracing without pause"); - startTracing({ + JSTracer.startTracing({ global: sandbox, loggingMethod, }); @@ -366,13 +372,13 @@ add_task(async function testTracingPauseOnStep() { Assert.equal(sandbox.counter, 1); info("Stop tracing"); - stopTracing(); + JSTracer.stopTracing(); logs.length = 0; sandbox.counter = 0; info("Start tracing with 0ms pause"); - startTracing({ + JSTracer.startTracing({ global: sandbox, pauseOnStep: 0, loggingMethod, @@ -415,13 +421,13 @@ add_task(async function testTracingPauseOnStep() { Assert.equal(sandbox.counter, 1); info("Stop tracing"); - stopTracing(); + JSTracer.stopTracing(); logs.length = 0; sandbox.counter = 0; info("Start tracing with 250ms pause"); - startTracing({ + JSTracer.startTracing({ global: sandbox, pauseOnStep: 250, loggingMethod, @@ -463,7 +469,7 @@ add_task(async function testTracingPauseOnStep() { Assert.greater(Cu.now() - startTimestamp, 250); info("Stop tracing"); - stopTracing(); + JSTracer.stopTracing(); }); add_task(async function testTracingFilterSourceUrl() { @@ -485,7 +491,7 @@ add_task(async function testTracingFilterSourceUrl() { } info("Start tracing"); - startTracing({ + JSTracer.startTracing({ global: sandbox, filterFrameSourceUrl: "second", loggingMethod, @@ -500,5 +506,60 @@ add_task(async function testTracingFilterSourceUrl() { Assert.stringContains(logs[1], "second.js:1:18"); info("Stop tracing"); - stopTracing(); + JSTracer.stopTracing(); +}); + +add_task(async function testTracingAllGlobals() { + // Test the `traceAllGlobals` flag + + // Create two distinct globals in order to verify that both are traced + const sandbox1 = Cu.Sandbox("https://example.com"); + const sandbox2 = Cu.Sandbox("https://example.com"); + + const source1 = `function foo() { bar(); }`; + Cu.evalInSandbox(source1, sandbox1, null, "sandbox1.js", 1); + + const source2 = `function bar() { }`; + Cu.evalInSandbox(source2, sandbox2, null, "sandbox2.js", 1); + // Expose `bar` from sandbox2 as global in sandbox1, so that `foo` from sandbox1 can call it. + sandbox1.bar = sandbox2.bar; + + // Pass an override method to catch all strings tentatively logged to stdout + // + // But in this test, we have to evaluate it in a special sandbox which will be ignored by the tracer. + // Otherwise, the tracer would do an infinite loop on this loggingMethod. + const ignoredGlobal = new Cu.Sandbox(null, { invisibleToDebugger: true }); + const loggingMethodString = ` + var logs = []; + function loggingMethod(str) { + logs.push(str); + }; + `; + Cu.evalInSandbox( + loggingMethodString, + ignoredGlobal, + null, + "loggin-method.js", + 1 + ); + const { loggingMethod, logs } = ignoredGlobal; + + info("Start tracing on all globals"); + JSTracer.startTracing({ + traceAllGlobals: true, + loggingMethod, + }); + + // Call some code while being careful to not call anything else which may be traced + sandbox1.foo(); + + JSTracer.stopTracing(); + + Assert.equal(logs.length, 4); + Assert.equal(logs[0], "Start tracing JavaScript\n"); + Assert.stringContains(logs[1], "λ foo"); + Assert.stringContains(logs[1], "sandbox1.js:1:18"); + Assert.stringContains(logs[2], "λ bar"); + Assert.stringContains(logs[2], "sandbox2.js:1:18"); + Assert.equal(logs[3], "Stop tracing JavaScript\n"); }); diff --git a/devtools/server/tracer/tracer.jsm b/devtools/server/tracer/tracer.jsm deleted file mode 100644 index 955b25fe3a..0000000000 --- a/devtools/server/tracer/tracer.jsm +++ /dev/null @@ -1,1050 +0,0 @@ -/* 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/. */ - -/** - * This module implements the JavaScript tracer. - * - * It is being used by: - * - any code that want to manually toggle the tracer, typically when debugging code, - * - the tracer actor to start and stop tracing from DevTools UI, - * - the tracing state resource watcher in order to notify DevTools UI about the tracing state. - * - * It will default logging the tracers to the terminal/stdout. - * But if DevTools are opened, it may delegate the logging to the tracer actor. - * It will typically log the traces to the Web Console. - * - * `JavaScriptTracer.onEnterFrame` method is hot codepath and should be reviewed accordingly. - */ - -"use strict"; - -const EXPORTED_SYMBOLS = [ - "startTracing", - "stopTracing", - "addTracingListener", - "removeTracingListener", - "NEXT_INTERACTION_MESSAGE", - "DOM_MUTATIONS", -]; - -const NEXT_INTERACTION_MESSAGE = - "Waiting for next user interaction before tracing (next mousedown or keydown event)"; - -const FRAME_EXIT_REASONS = { - // The function has been early terminated by the Debugger API - TERMINATED: "terminated", - // The function simply ends by returning a value - RETURN: "return", - // The function yields a new value - YIELD: "yield", - // The function await on a promise - AWAIT: "await", - // The function throws an exception - THROW: "throw", -}; - -const DOM_MUTATIONS = { - // Track all DOM Node being added - ADD: "add", - // Track all attributes being modified - ATTRIBUTES: "attributes", - // Track all DOM Node being removed - REMOVE: "remove", -}; - -const listeners = new Set(); - -// Detecting worker is different if this file is loaded via Common JS loader (isWorker global) -// or as a JSM (constructor name) -const isWorker = - globalThis.isWorker || - globalThis.constructor.name == "WorkerDebuggerGlobalScope"; - -// This module can be loaded from the worker thread, where we can't use ChromeUtils. -// So implement custom lazy getters (without XPCOMUtils ESM) from here. -// Worker codepath in DevTools will pass a custom Debugger instance. -const customLazy = { - get Debugger() { - // When this code runs in the worker thread, loaded via `loadSubScript` - // (ex: browser_worker_tracer.js and WorkerDebugger.tracer.js), - // this module runs within the WorkerDebuggerGlobalScope and have immediate access to Debugger class. - if (globalThis.Debugger) { - return globalThis.Debugger; - } - // When this code runs in the worker thread, loaded via `require` - // (ex: from tracer actor module), - // this module no longer has WorkerDebuggerGlobalScope as global, - // but has to use require() to pull Debugger. - if (isWorker) { - return require("Debugger"); - } - const { addDebuggerToGlobal } = ChromeUtils.importESModule( - "resource://gre/modules/jsdebugger.sys.mjs" - ); - // Avoid polluting all Modules global scope by using a Sandox as global. - const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); - const debuggerSandbox = Cu.Sandbox(systemPrincipal); - addDebuggerToGlobal(debuggerSandbox); - delete customLazy.Debugger; - customLazy.Debugger = debuggerSandbox.Debugger; - return customLazy.Debugger; - }, - - get DistinctCompartmentDebugger() { - const { addDebuggerToGlobal } = ChromeUtils.importESModule( - "resource://gre/modules/jsdebugger.sys.mjs" - ); - const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); - const debuggerSandbox = Cu.Sandbox(systemPrincipal, { - // As we may debug the JSM/ESM shared global, we should be using a Debugger - // from another system global. - freshCompartment: true, - }); - addDebuggerToGlobal(debuggerSandbox); - delete customLazy.DistinctCompartmentDebugger; - customLazy.DistinctCompartmentDebugger = debuggerSandbox.Debugger; - return customLazy.DistinctCompartmentDebugger; - }, -}; - -/** - * Start tracing against a given JS global. - * Only code run from that global will be logged. - * - * @param {Object} options - * Object with configurations: - * @param {Object} options.global - * The tracer only log traces related to the code executed within this global. - * When omitted, it will default to the options object's global. - * @param {String} options.prefix - * Optional string logged as a prefix to all traces. - * @param {Debugger} options.dbg - * Optional spidermonkey's Debugger instance. - * This allows devtools to pass a custom instance and ease worker support - * where we can't load jsdebugger.sys.mjs. - * @param {Boolean} options.loggingMethod - * Optional setting to use something else than `dump()` to log traces to stdout. - * This is mostly used by tests. - * @param {Boolean} options.traceDOMEvents - * Optional setting to enable tracing all the DOM events being going through - * dom/events/EventListenerManager.cpp's `EventListenerManager`. - * @param {Array<string>} options.traceDOMMutations - * Optional setting to enable tracing all the DOM mutations. - * This array may contains three strings: - * - "add": trace all new DOM Node being added, - * - "attributes": trace all DOM attribute modifications, - * - "delete": trace all DOM Node being removed. - * @param {Boolean} options.traceValues - * Optional setting to enable tracing all function call values as well, - * as returned values (when we do log returned frames). - * @param {Boolean} options.traceOnNextInteraction - * Optional setting to enable when the tracing should only start when the - * use starts interacting with the page. i.e. on next keydown or mousedown. - * @param {Boolean} options.traceSteps - * Optional setting to enable tracing each frame within a function execution. - * (i.e. not only function call and function returns [when traceFunctionReturn is true]) - * @param {Boolean} options.traceFunctionReturn - * Optional setting to enable when the tracing should notify about frame exit. - * i.e. when a function call returns or throws. - * @param {String} options.filterFrameSourceUrl - * Optional setting to restrict all traces to only a given source URL. - * This is a loose check, so any source whose URL includes the passed string will be traced. - * @param {Number} options.maxDepth - * Optional setting to ignore frames when depth is greater than the passed number. - * @param {Number} options.maxRecords - * Optional setting to stop the tracer after having recorded at least - * the passed number of top level frames. - * @param {Number} options.pauseOnStep - * Optional setting to delay each frame execution for a given amount of time in ms. - */ -class JavaScriptTracer { - constructor(options) { - this.onEnterFrame = this.onEnterFrame.bind(this); - - // DevTools CommonJS Workers modules don't have access to AbortController - if (!isWorker) { - this.abortController = new AbortController(); - } - - // By default, we would trace only JavaScript related to caller's global. - // As there is no way to compute the caller's global default to the global of the - // mandatory options argument. - this.tracedGlobal = options.global || Cu.getGlobalForObject(options); - - // Instantiate a brand new Debugger API so that we can trace independently - // of all other DevTools operations. i.e. we can pause while tracing without any interference. - this.dbg = this.makeDebugger(); - - this.prefix = options.prefix ? `${options.prefix}: ` : ""; - - // List of all async frame which are poped per Spidermonkey API - // but are actually waiting for async operation. - // We should later enter them again when the async task they are being waiting for is completed. - this.pendingAwaitFrames = new Set(); - - this.loggingMethod = options.loggingMethod; - if (!this.loggingMethod) { - // On workers, `dump` can't be called with JavaScript on another object, - // so bind it. - this.loggingMethod = isWorker ? dump.bind(null) : dump; - } - - this.traceDOMEvents = !!options.traceDOMEvents; - - if (options.traceDOMMutations) { - if (!Array.isArray(options.traceDOMMutations)) { - throw new Error("'traceDOMMutations' attribute should be an array"); - } - const acceptedValues = Object.values(DOM_MUTATIONS); - if (!options.traceDOMMutations.every(e => acceptedValues.includes(e))) { - throw new Error( - `'traceDOMMutations' only accept array of strings whose values can be: ${acceptedValues}` - ); - } - this.traceDOMMutations = options.traceDOMMutations; - } - this.traceSteps = !!options.traceSteps; - this.traceValues = !!options.traceValues; - this.traceFunctionReturn = !!options.traceFunctionReturn; - this.maxDepth = options.maxDepth; - this.maxRecords = options.maxRecords; - this.records = 0; - if ("pauseOnStep" in options) { - if (typeof options.pauseOnStep != "number") { - throw new Error("'pauseOnStep' attribute should be a number"); - } - this.pauseOnStep = options.pauseOnStep; - } - if ("filterFrameSourceUrl" in options) { - if (typeof options.filterFrameSourceUrl != "string") { - throw new Error("'filterFrameSourceUrl' attribute should be a string"); - } - this.filterFrameSourceUrl = options.filterFrameSourceUrl; - } - - // An increment used to identify function calls and their returned/exit frames - this.frameId = 0; - - // This feature isn't supported on Workers as they aren't involving user events - if (options.traceOnNextInteraction && !isWorker) { - this.#waitForNextInteraction(); - } else { - this.#startTracing(); - } - } - - // Is actively tracing? - // We typically start tracing from the constructor, unless the "trace on next user interaction" feature is used. - isTracing = false; - - /** - * In case `traceOnNextInteraction` option is used, delay the actual start of tracing until a first user interaction. - */ - #waitForNextInteraction() { - // Use a dedicated Abort Controller as we are going to stop it as soon as we get the first user interaction, - // whereas other listeners would typically wait for tracer stop. - this.nextInteractionAbortController = new AbortController(); - - const listener = () => { - this.nextInteractionAbortController.abort(); - // Avoid tracing if the users asked to stop tracing while we were waiting for the user interaction. - if (this.dbg) { - this.#startTracing(); - } - }; - const eventOptions = { - signal: this.nextInteractionAbortController.signal, - capture: true, - }; - // Register the event listener on the Chrome Event Handler in order to receive the event first. - // When used for the parent process target, `tracedGlobal` is browser.xhtml's window, which doesn't have a chromeEventHandler. - const eventHandler = - this.tracedGlobal.docShell.chromeEventHandler || this.tracedGlobal; - eventHandler.addEventListener("mousedown", listener, eventOptions); - eventHandler.addEventListener("keydown", listener, eventOptions); - - // Significate to the user that the tracer is registered, but not tracing just yet. - let shouldLogToStdout = listeners.size == 0; - for (const l of listeners) { - if (typeof l.onTracingPending == "function") { - shouldLogToStdout |= l.onTracingPending(); - } - } - if (shouldLogToStdout) { - this.loggingMethod(this.prefix + NEXT_INTERACTION_MESSAGE + "\n"); - } - } - - /** - * Actually really start watching for executions. - * - * This may be delayed when traceOnNextInteraction options is used. - * Otherwise we start tracing as soon as the class instantiates. - */ - #startTracing() { - this.isTracing = true; - - this.dbg.onEnterFrame = this.onEnterFrame; - - if (this.traceDOMEvents) { - this.startTracingDOMEvents(); - } - // This feature isn't supported on Workers as they aren't interacting with the DOM Tree - if (this.traceDOMMutations?.length > 0 && !isWorker) { - this.startTracingDOMMutations(); - } - - // In any case, we consider the tracing as started - this.notifyToggle(true); - } - - startTracingDOMEvents() { - this.debuggerNotificationObserver = new DebuggerNotificationObserver(); - this.eventListener = this.eventListener.bind(this); - this.debuggerNotificationObserver.addListener(this.eventListener); - this.debuggerNotificationObserver.connect(this.tracedGlobal); - - this.currentDOMEvent = null; - } - - stopTracingDOMEvents() { - if (this.debuggerNotificationObserver) { - this.debuggerNotificationObserver.removeListener(this.eventListener); - this.debuggerNotificationObserver.disconnect(this.tracedGlobal); - this.debuggerNotificationObserver = null; - } - this.currentDOMEvent = null; - } - - startTracingDOMMutations() { - this.tracedGlobal.document.devToolsWatchingDOMMutations = true; - - const eventOptions = { - signal: this.abortController.signal, - capture: true, - }; - if (this.traceDOMMutations.includes(DOM_MUTATIONS.ADD)) { - this.tracedGlobal.docShell.chromeEventHandler.addEventListener( - "devtoolschildinserted", - this.#onDOMMutation, - eventOptions - ); - } - if (this.traceDOMMutations.includes(DOM_MUTATIONS.ATTRIBUTES)) { - this.tracedGlobal.docShell.chromeEventHandler.addEventListener( - "devtoolsattrmodified", - this.#onDOMMutation, - eventOptions - ); - } - if (this.traceDOMMutations.includes(DOM_MUTATIONS.REMOVE)) { - this.tracedGlobal.docShell.chromeEventHandler.addEventListener( - "devtoolschildremoved", - this.#onDOMMutation, - eventOptions - ); - } - } - - stopTracingDOMMutations() { - this.tracedGlobal.document.devToolsWatchingDOMMutations = false; - // Note that the event listeners are all going to be unregistered via the AbortController. - } - - /** - * Called for any DOM Mutation done in the traced document. - * - * @param {DOM Event} event - */ - #onDOMMutation = event => { - // Ignore elements inserted by DevTools, like the inspector's highlighters - if (event.target.isNativeAnonymous) { - return; - } - - let type = ""; - switch (event.type) { - case "devtoolschildinserted": - type = DOM_MUTATIONS.ADD; - break; - case "devtoolsattrmodified": - type = DOM_MUTATIONS.ATTRIBUTES; - break; - case "devtoolschildremoved": - type = DOM_MUTATIONS.REMOVE; - break; - default: - throw new Error("Unexpected DOM Mutation event type: " + event.type); - } - - let shouldLogToStdout = true; - if (listeners.size > 0) { - shouldLogToStdout = false; - for (const listener of listeners) { - // If any listener return true, also log to stdout - if (typeof listener.onTracingDOMMutation == "function") { - shouldLogToStdout |= listener.onTracingDOMMutation({ - depth: this.depth, - prefix: this.prefix, - - type, - element: event.target, - caller: Components.stack.caller, - }); - } - } - } - - if (shouldLogToStdout) { - const padding = "—".repeat(this.depth + 1); - this.loggingMethod( - this.prefix + - padding + - `[DOM Mutation | ${type}] ` + - objectToString(event.target) + - "\n" - ); - } - }; - - /** - * Called by DebuggerNotificationObserver interface when a DOM event start being notified - * and after it has been notified. - * - * @param {DebuggerNotification} notification - * Info about the DOM event. See the related idl file. - */ - eventListener(notification) { - // For each event we get two notifications. - // One just before firing the listeners and another one just after. - // - // Update `this.currentDOMEvent` to be refering to the event name - // while the DOM event is being notified. It will be null the rest of the time. - // - // We don't need to maintain a stack of events as that's only consumed by onEnterFrame - // which only cares about the very lastest event being currently trigerring some code. - if (notification.phase == "pre") { - // We get notified about "real" DOM event, but also when some particular callbacks are called like setTimeout. - if (notification.type == "domEvent") { - let { type } = notification.event; - if (!type) { - // In the Worker thread, `notification.event` is an opaque wrapper. - // In other threads it is a Xray wrapper. - // Because of this difference, we have to fallback to use the Debugger.Object API. - type = this.dbg - .makeGlobalObjectReference(notification.global) - .makeDebuggeeValue(notification.event) - .getProperty("type").return; - } - this.currentDOMEvent = `DOM | ${type}`; - } else { - this.currentDOMEvent = notification.type; - } - } else { - this.currentDOMEvent = null; - } - } - - /** - * Stop observing execution. - * - * @param {String} reason - * Optional string to justify why the tracer stopped. - */ - stopTracing(reason = "") { - // Note that this may be called before `#startTracing()`, but still want to completely shut it down. - if (!this.dbg) { - return; - } - - this.dbg.onEnterFrame = undefined; - this.dbg.removeAllDebuggees(); - this.dbg.onNewGlobalObject = undefined; - this.dbg = null; - - this.depth = 0; - - // Cancel the traceOnNextInteraction event listeners. - if (this.nextInteractionAbortController) { - this.nextInteractionAbortController.abort(); - this.nextInteractionAbortController = null; - } - - if (this.traceDOMEvents) { - this.stopTracingDOMEvents(); - } - if (this.traceDOMMutations?.length > 0 && !isWorker) { - this.stopTracingDOMMutations(); - } - - // Unregister all event listeners - if (this.abortController) { - this.abortController.abort(); - } - - this.tracedGlobal = null; - this.isTracing = false; - - this.notifyToggle(false, reason); - } - - /** - * Instantiate a Debugger API instance dedicated to each Tracer instance. - * It will notably be different from the instance used in DevTools. - * This allows to implement tracing independently of DevTools. - */ - makeDebugger() { - // When this code runs in the worker thread, Cu isn't available - // and we don't have system principal anyway in this context. - const { isSystemPrincipal } = - typeof Cu == "object" ? Cu.getObjectPrincipal(this.tracedGlobal) : {}; - - // When debugging the system modules, we have to use a special instance - // of Debugger loaded in a distinct system global. - const dbg = isSystemPrincipal - ? new customLazy.DistinctCompartmentDebugger() - : new customLazy.Debugger(); - - // For now, we only trace calls for one particular global at a time. - // See the constructor for its definition. - dbg.addDebuggee(this.tracedGlobal); - - return dbg; - } - - /** - * Notify DevTools and/or the user via stdout that tracing - * has been enabled or disabled. - * - * @param {Boolean} state - * True if we just started tracing, false when it just stopped. - * @param {String} reason - * Optional string to justify why the tracer stopped. - */ - notifyToggle(state, reason) { - let shouldLogToStdout = listeners.size == 0; - for (const listener of listeners) { - if (typeof listener.onTracingToggled == "function") { - shouldLogToStdout |= listener.onTracingToggled(state, reason); - } - } - if (shouldLogToStdout) { - if (state) { - this.loggingMethod(this.prefix + "Start tracing JavaScript\n"); - } else { - if (reason) { - reason = ` (reason: ${reason})`; - } - this.loggingMethod( - this.prefix + "Stop tracing JavaScript" + reason + "\n" - ); - } - } - } - - /** - * Notify DevTools and/or the user via stdout that tracing - * stopped because of an infinite loop. - */ - notifyInfiniteLoop() { - let shouldLogToStdout = listeners.size == 0; - for (const listener of listeners) { - if (typeof listener.onTracingInfiniteLoop == "function") { - shouldLogToStdout |= listener.onTracingInfiniteLoop(); - } - } - if (shouldLogToStdout) { - this.loggingMethod( - this.prefix + - "Looks like an infinite recursion? We stopped the JavaScript tracer, but code may still be running!\n" - ); - } - } - - /** - * Called by the Debugger API (this.dbg) when a new frame is executed. - * - * @param {Debugger.Frame} frame - * A descriptor object for the JavaScript frame. - */ - onEnterFrame(frame) { - // Safe check, just in case we keep being notified, but the tracer has been stopped - if (!this.dbg) { - return; - } - try { - // If an optional filter is passed, ignore frames which aren't matching the filter string - if ( - this.filterFrameSourceUrl && - !frame.script.source.url?.includes(this.filterFrameSourceUrl) - ) { - return; - } - - // Because of async frame which are popped and entered again on completion of the awaited async task, - // we have to compute the depth from the frame. (and can't use a simple increment on enter/decrement on pop). - const depth = getFrameDepth(frame); - - // Save the current depth for the DOM Mutation handler - this.depth = depth; - - // Ignore the frame if we reached the depth limit (if one is provided) - if (this.maxDepth && depth >= this.maxDepth) { - return; - } - - // When we encounter a frame which was previously popped because of pending on an async task, - // ignore it and only log the following ones. - if (this.pendingAwaitFrames.has(frame)) { - this.pendingAwaitFrames.delete(frame); - return; - } - - // Auto-stop the tracer if we reached the number of max recorded top level frames - if (depth === 0 && this.maxRecords) { - if (this.records >= this.maxRecords) { - this.stopTracing("max-records"); - return; - } - this.records++; - } - - // Consider depth > 100 as an infinite recursive loop and stop the tracer. - if (depth == 100) { - this.notifyInfiniteLoop(); - this.stopTracing("infinite-loop"); - return; - } - - const frameId = this.frameId++; - let shouldLogToStdout = true; - - // If there is at least one DevTools debugging this process, - // delegate logging to DevTools actors. - if (listeners.size > 0) { - shouldLogToStdout = false; - const formatedDisplayName = formatDisplayName(frame); - for (const listener of listeners) { - // If any listener return true, also log to stdout - if (typeof listener.onTracingFrame == "function") { - shouldLogToStdout |= listener.onTracingFrame({ - frameId, - frame, - depth, - formatedDisplayName, - prefix: this.prefix, - currentDOMEvent: this.currentDOMEvent, - }); - } - } - } - - // DevTools may delegate the work to log to stdout, - // but if DevTools are closed, stdout is the only way to log the traces. - if (shouldLogToStdout) { - this.logFrameEnteredToStdout(frame, depth); - } - - if (this.traceSteps) { - frame.onStep = () => { - // Spidermonkey steps on many intermediate positions which don't make sense to the user. - // `isStepStart` is close to each statement start, which is meaningful to the user. - const { isStepStart } = frame.script.getOffsetMetadata(frame.offset); - if (!isStepStart) { - return; - } - - shouldLogToStdout = true; - if (listeners.size > 0) { - shouldLogToStdout = false; - for (const listener of listeners) { - // If any listener return true, also log to stdout - if (typeof listener.onTracingFrameStep == "function") { - shouldLogToStdout |= listener.onTracingFrameStep({ - frame, - depth, - prefix: this.prefix, - }); - } - } - } - if (shouldLogToStdout) { - this.logFrameStepToStdout(frame, depth); - } - // Optionaly pause the frame execution by letting the other event loop to run in between. - if (typeof this.pauseOnStep == "number") { - syncPause(this.pauseOnStep); - } - }; - } - - frame.onPop = completion => { - // Special case async frames. We are exiting the current frame because of waiting for an async task. - // (this is typically a `await foo()` from an async function) - // This frame should later be "entered" again. - if (completion?.await) { - this.pendingAwaitFrames.add(frame); - return; - } - - if (!this.traceFunctionReturn) { - return; - } - - let why = ""; - let rv = undefined; - if (!completion) { - why = FRAME_EXIT_REASONS.TERMINATED; - } else if ("return" in completion) { - why = FRAME_EXIT_REASONS.RETURN; - rv = completion.return; - } else if ("yield" in completion) { - why = FRAME_EXIT_REASONS.YIELD; - rv = completion.yield; - } else if ("await" in completion) { - why = FRAME_EXIT_REASONS.AWAIT; - } else { - why = FRAME_EXIT_REASONS.THROW; - rv = completion.throw; - } - - shouldLogToStdout = true; - if (listeners.size > 0) { - shouldLogToStdout = false; - const formatedDisplayName = formatDisplayName(frame); - for (const listener of listeners) { - // If any listener return true, also log to stdout - if (typeof listener.onTracingFrameExit == "function") { - shouldLogToStdout |= listener.onTracingFrameExit({ - frameId, - frame, - depth, - formatedDisplayName, - prefix: this.prefix, - why, - rv, - }); - } - } - } - if (shouldLogToStdout) { - this.logFrameExitedToStdout(frame, depth, why, rv); - } - }; - - // Optionaly pause the frame execution by letting the other event loop to run in between. - if (typeof this.pauseOnStep == "number") { - syncPause(this.pauseOnStep); - } - } catch (e) { - console.error("Exception while tracing javascript", e); - } - } - - /** - * Display to stdout one given frame execution, which represents a function call. - * - * @param {Debugger.Frame} frame - * @param {Number} depth - */ - logFrameEnteredToStdout(frame, depth) { - const padding = "—".repeat(depth + 1); - - // If we are tracing DOM events and we are in middle of an event, - // and are logging the topmost frame, - // then log a preliminary dedicated line to mention that event type. - if (this.currentDOMEvent && depth == 0) { - this.loggingMethod(this.prefix + padding + this.currentDOMEvent + "\n"); - } - - let message = `${padding}[${frame.implementation}]—> ${getTerminalHyperLink( - frame - )} - ${formatDisplayName(frame)}`; - - // Log arguments, but only when this feature is enabled as it introduces - // some significant performance and visual overhead. - // Also prevent trying to log function call arguments if we aren't logging a frame - // with arguments (e.g. Debugger evaluation frames, when executing from the console) - if (this.traceValues && frame.arguments) { - message += "("; - for (let i = 0, l = frame.arguments.length; i < l; i++) { - const arg = frame.arguments[i]; - // Debugger.Frame.arguments contains either a Debugger.Object or primitive object - if (arg?.unsafeDereference) { - // Special case classes as they can't be easily differentiated in pure JavaScript - if (arg.isClassConstructor) { - message += "class " + arg.name; - } else { - message += objectToString(arg.unsafeDereference()); - } - } else { - message += primitiveToString(arg); - } - - if (i < l - 1) { - message += ", "; - } - } - message += ")"; - } - - this.loggingMethod(this.prefix + message + "\n"); - } - - /** - * Display to stdout one given frame execution, which represents a step within a function execution. - * - * @param {Debugger.Frame} frame - * @param {Number} depth - */ - logFrameStepToStdout(frame, depth) { - const padding = "—".repeat(depth + 1); - - const message = `${padding}— ${getTerminalHyperLink(frame)}`; - - this.loggingMethod(this.prefix + message + "\n"); - } - - /** - * Display to stdout the exit of a given frame execution, which represents a function return. - * - * @param {Debugger.Frame} frame - * @param {String} why - * @param {Number} depth - */ - logFrameExitedToStdout(frame, depth, why, rv) { - const padding = "—".repeat(depth + 1); - - let message = `${padding}[${frame.implementation}]<— ${getTerminalHyperLink( - frame - )} - ${formatDisplayName(frame)} ${why}`; - - // Log returned values, but only when this feature is enabled as it introduces - // some significant performance and visual overhead. - if (this.traceValues) { - message += " "; - // Debugger.Frame.arguments contains either a Debugger.Object or primitive object - if (rv?.unsafeDereference) { - // Special case classes as they can't be easily differentiated in pure JavaScript - if (rv.isClassConstructor) { - message += "class " + rv.name; - } else { - message += objectToString(rv.unsafeDereference()); - } - } else { - message += primitiveToString(rv); - } - } - - this.loggingMethod(this.prefix + message + "\n"); - } -} - -/** - * Return a string description for any arbitrary JS value. - * Used when logging to stdout. - * - * @param {Object} obj - * Any JavaScript object to describe. - * @return String - * User meaningful descriptor for the object. - */ -function objectToString(obj) { - if (Element.isInstance(obj)) { - let message = `<${obj.tagName}`; - if (obj.id) { - message += ` #${obj.id}`; - } - if (obj.className) { - message += ` .${obj.className}`; - } - message += ">"; - return message; - } else if (Array.isArray(obj)) { - return `Array(${obj.length})`; - } else if (Event.isInstance(obj)) { - return `Event(${obj.type}) target=${objectToString(obj.target)}`; - } else if (typeof obj === "function") { - return `function ${obj.name || "anonymous"}()`; - } - return obj; -} - -function primitiveToString(value) { - const type = typeof value; - if (type === "string") { - // Use stringify to escape special characters and display in enclosing quotes. - return JSON.stringify(value); - } else if (value === 0 && 1 / value === -Infinity) { - // -0 is very special and need special threatment. - return "-0"; - } else if (type === "bigint") { - return `BigInt(${value})`; - } else if (value && typeof value.toString === "function") { - // Use toString as it allows to stringify Symbols. Converting them to string throws. - return value.toString(); - } - - // For all other types/cases, rely on native convertion to string - return value; -} - -/** - * Try to describe the current frame we are tracing - * - * This will typically log the name of the method being called. - * - * @param {Debugger.Frame} frame - * The frame which is currently being executed. - */ -function formatDisplayName(frame) { - if (frame.type === "call") { - const callee = frame.callee; - // Anonymous function will have undefined name and displayName. - return "λ " + (callee.name || callee.displayName || "anonymous"); - } - - return `(${frame.type})`; -} - -let activeTracer = null; - -/** - * Start tracing JavaScript. - * i.e. log the name of any function being called in JS and its location in source code. - * - * @params {Object} options (mandatory) - * See JavaScriptTracer.startTracing jsdoc. - */ -function startTracing(options) { - if (!options) { - throw new Error("startTracing excepts an options object as first argument"); - } - if (!activeTracer) { - activeTracer = new JavaScriptTracer(options); - } else { - console.warn( - "Can't start JavaScript tracing, another tracer is still active and we only support one tracer at a time." - ); - } -} - -/** - * Stop tracing JavaScript. - */ -function stopTracing() { - if (activeTracer) { - activeTracer.stopTracing(); - activeTracer = null; - } else { - console.warn("Can't stop JavaScript Tracing as we were not tracing."); - } -} - -/** - * Listen for tracing updates. - * - * The listener object may expose the following methods: - * - onTracingToggled(state) - * Where state is a boolean to indicate if tracing has just been enabled of disabled. - * It may be immediatelly called if a tracer is already active. - * - * - onTracingInfiniteLoop() - * Called when the tracer stopped because of an infinite loop. - * - * - onTracingFrame({ frame, depth, formatedDisplayName, prefix }) - * Called each time we enter a new JS frame. - * - frame is a Debugger.Frame object - * - depth is a number and represents the depth of the frame in the call stack - * - formatedDisplayName is a string and is a human readable name for the current frame - * - prefix is a string to display as a prefix of any logged frame - * - * @param {Object} listener - */ -function addTracingListener(listener) { - listeners.add(listener); - - if ( - activeTracer?.isTracing && - typeof listener.onTracingToggled == "function" - ) { - listener.onTracingToggled(true); - } -} - -/** - * Unregister a listener previous registered via addTracingListener - */ -function removeTracingListener(listener) { - listeners.delete(listener); -} - -function getFrameDepth(frame) { - if (typeof frame.depth !== "number") { - let depth = 0; - let f = frame; - while ((f = f.older)) { - depth++; - } - frame.depth = depth; - } - - return frame.depth; -} - -/** - * Generate a magic string that will be rendered in smart terminals as a URL - * for the given Frame object. This URL is special as it includes a line and column. - * This URL can be clicked and Firefox will automatically open the source matching - * the frame's URL in the currently opened Debugger. - * Firefox will interpret differently the URLs ending with `/:?\d*:\d+/`. - * - * @param {Debugger.Frame} frame - * The frame being traced. - * @return {String} - * The URL's magic string. - */ -function getTerminalHyperLink(frame) { - const { script } = frame; - const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset); - - // Use a special URL, including line and column numbers which Firefox - // interprets as to be opened in the already opened DevTool's debugger - const href = `${script.source.url}:${lineNumber}:${columnNumber}`; - - // Use special characters in order to print working hyperlinks right from the terminal - // See https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda - return `\x1B]8;;${href}\x1B\\${href}\x1B]8;;\x1B\\`; -} - -/** - * Helper function to synchronously pause the current frame execution - * for a given duration in ms. - * - * @param {Number} duration - */ -function syncPause(duration) { - let freeze = true; - const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); - timer.initWithCallback( - () => { - freeze = false; - }, - duration, - Ci.nsITimer.TYPE_ONE_SHOT - ); - Services.tm.spinEventLoopUntil("debugger-slow-motion", function () { - return !freeze; - }); -} - -// This JSM may be execute as CommonJS when loaded in the worker thread -if (typeof module == "object") { - module.exports = { - startTracing, - stopTracing, - addTracingListener, - removeTracingListener, - }; -} diff --git a/devtools/server/tracer/tracer.sys.mjs b/devtools/server/tracer/tracer.sys.mjs new file mode 100644 index 0000000000..6fe1334f2b --- /dev/null +++ b/devtools/server/tracer/tracer.sys.mjs @@ -0,0 +1,1088 @@ +/* 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/. */ + +/** + * This module implements the JavaScript tracer. + * + * It is being used by: + * - any code that want to manually toggle the tracer, typically when debugging code, + * - the tracer actor to start and stop tracing from DevTools UI, + * - the tracing state resource watcher in order to notify DevTools UI about the tracing state. + * + * It will default logging the tracers to the terminal/stdout. + * But if DevTools are opened, it may delegate the logging to the tracer actor. + * It will typically log the traces to the Web Console. + * + * `JavaScriptTracer.onEnterFrame` method is hot codepath and should be reviewed accordingly. + */ + +const NEXT_INTERACTION_MESSAGE = + "Waiting for next user interaction before tracing (next mousedown or keydown event)"; + +const FRAME_EXIT_REASONS = { + // The function has been early terminated by the Debugger API + TERMINATED: "terminated", + // The function simply ends by returning a value + RETURN: "return", + // The function yields a new value + YIELD: "yield", + // The function await on a promise + AWAIT: "await", + // The function throws an exception + THROW: "throw", +}; + +const DOM_MUTATIONS = { + // Track all DOM Node being added + ADD: "add", + // Track all attributes being modified + ATTRIBUTES: "attributes", + // Track all DOM Node being removed + REMOVE: "remove", +}; + +const listeners = new Set(); + +// Detecting worker is different if this file is loaded via Common JS loader (isWorker global) +// or as a JSM (constructor name) +const isWorker = + globalThis.isWorker || + globalThis.constructor.name == "WorkerDebuggerGlobalScope"; + +// This module can be loaded from the worker thread, where we can't use ChromeUtils. +// So implement custom lazy getters (without XPCOMUtils ESM) from here. +// Worker codepath in DevTools will pass a custom Debugger instance. +const customLazy = { + get Debugger() { + // When this code runs in the worker thread, loaded via `loadSubScript` + // (ex: browser_worker_tracer.js and WorkerDebugger.tracer.js), + // this module runs within the WorkerDebuggerGlobalScope and have immediate access to Debugger class. + if (globalThis.Debugger) { + return globalThis.Debugger; + } + // When this code runs in the worker thread, loaded via `require` + // (ex: from tracer actor module), + // this module no longer has WorkerDebuggerGlobalScope as global, + // but has to use require() to pull Debugger. + if (isWorker) { + return require("Debugger"); + } + const { addDebuggerToGlobal } = ChromeUtils.importESModule( + "resource://gre/modules/jsdebugger.sys.mjs" + ); + // Avoid polluting all Modules global scope by using a Sandox as global. + const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + const debuggerSandbox = Cu.Sandbox(systemPrincipal); + addDebuggerToGlobal(debuggerSandbox); + delete customLazy.Debugger; + customLazy.Debugger = debuggerSandbox.Debugger; + return customLazy.Debugger; + }, + + get DistinctCompartmentDebugger() { + const { addDebuggerToGlobal } = ChromeUtils.importESModule( + "resource://gre/modules/jsdebugger.sys.mjs", + { global: "contextual" } + ); + const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + const debuggerSandbox = Cu.Sandbox(systemPrincipal, { + // As we may debug the JSM/ESM shared global, we should be using a Debugger + // from another system global. + freshCompartment: true, + }); + addDebuggerToGlobal(debuggerSandbox); + delete customLazy.DistinctCompartmentDebugger; + customLazy.DistinctCompartmentDebugger = debuggerSandbox.Debugger; + return customLazy.DistinctCompartmentDebugger; + }, +}; + +/** + * Start tracing against a given JS global. + * Only code run from that global will be logged. + * + * @param {Object} options + * Object with configurations: + * @param {Object} options.global + * The tracer only log traces related to the code executed within this global. + * When omitted, it will default to the options object's global. + * @param {Boolean} options.traceAllGlobals + * When set to true, this will trace all the globals running in the current thread. + * @param {String} options.prefix + * Optional string logged as a prefix to all traces. + * @param {Boolean} options.loggingMethod + * Optional setting to use something else than `dump()` to log traces to stdout. + * This is mostly used by tests. + * @param {Boolean} options.traceDOMEvents + * Optional setting to enable tracing all the DOM events being going through + * dom/events/EventListenerManager.cpp's `EventListenerManager`. + * @param {Array<string>} options.traceDOMMutations + * Optional setting to enable tracing all the DOM mutations. + * This array may contains three strings: + * - "add": trace all new DOM Node being added, + * - "attributes": trace all DOM attribute modifications, + * - "delete": trace all DOM Node being removed. + * @param {Boolean} options.traceValues + * Optional setting to enable tracing all function call values as well, + * as returned values (when we do log returned frames). + * @param {Boolean} options.traceOnNextInteraction + * Optional setting to enable when the tracing should only start when the + * use starts interacting with the page. i.e. on next keydown or mousedown. + * @param {Boolean} options.traceSteps + * Optional setting to enable tracing each frame within a function execution. + * (i.e. not only function call and function returns [when traceFunctionReturn is true]) + * @param {Boolean} options.traceFunctionReturn + * Optional setting to enable when the tracing should notify about frame exit. + * i.e. when a function call returns or throws. + * @param {String} options.filterFrameSourceUrl + * Optional setting to restrict all traces to only a given source URL. + * This is a loose check, so any source whose URL includes the passed string will be traced. + * @param {Number} options.maxDepth + * Optional setting to ignore frames when depth is greater than the passed number. + * @param {Number} options.maxRecords + * Optional setting to stop the tracer after having recorded at least + * the passed number of top level frames. + * @param {Number} options.pauseOnStep + * Optional setting to delay each frame execution for a given amount of time in ms. + */ +class JavaScriptTracer { + constructor(options) { + this.onEnterFrame = this.onEnterFrame.bind(this); + + // DevTools CommonJS Workers modules don't have access to AbortController + if (!isWorker) { + this.abortController = new AbortController(); + } + + if (options.traceAllGlobals) { + this.traceAllGlobals = true; + if (options.traceOnNextInteraction) { + throw new Error( + "Tracing all globals and waiting for next user interaction are not yet compatible" + ); + } + if (this.traceDOMEvents) { + throw new Error( + "Tracing all globals and DOM Events are not yet compatible" + ); + } + if (options.global) { + throw new Error( + "'global' option should be omitted when using 'traceAllGlobals'" + ); + } + } else { + // By default, we would trace only JavaScript related to caller's global. + // As there is no way to compute the caller's global default to the global of the + // mandatory options argument. + this.tracedGlobal = options.global || Cu.getGlobalForObject(options); + } + + // Instantiate a brand new Debugger API so that we can trace independently + // of all other DevTools operations. i.e. we can pause while tracing without any interference. + this.dbg = this.makeDebugger(); + + this.prefix = options.prefix ? `${options.prefix}: ` : ""; + + // List of all async frame which are poped per Spidermonkey API + // but are actually waiting for async operation. + // We should later enter them again when the async task they are being waiting for is completed. + this.pendingAwaitFrames = new Set(); + + this.loggingMethod = options.loggingMethod; + if (!this.loggingMethod) { + // On workers, `dump` can't be called with JavaScript on another object, + // so bind it. + this.loggingMethod = isWorker ? dump.bind(null) : dump; + } + + this.traceDOMEvents = !!options.traceDOMEvents; + + if (options.traceDOMMutations) { + if (!Array.isArray(options.traceDOMMutations)) { + throw new Error("'traceDOMMutations' attribute should be an array"); + } + const acceptedValues = Object.values(DOM_MUTATIONS); + if (!options.traceDOMMutations.every(e => acceptedValues.includes(e))) { + throw new Error( + `'traceDOMMutations' only accept array of strings whose values can be: ${acceptedValues}` + ); + } + this.traceDOMMutations = options.traceDOMMutations; + } + this.traceSteps = !!options.traceSteps; + this.traceValues = !!options.traceValues; + this.traceFunctionReturn = !!options.traceFunctionReturn; + this.maxDepth = options.maxDepth; + this.maxRecords = options.maxRecords; + this.records = 0; + if ("pauseOnStep" in options) { + if (typeof options.pauseOnStep != "number") { + throw new Error("'pauseOnStep' attribute should be a number"); + } + this.pauseOnStep = options.pauseOnStep; + } + if ("filterFrameSourceUrl" in options) { + if (typeof options.filterFrameSourceUrl != "string") { + throw new Error("'filterFrameSourceUrl' attribute should be a string"); + } + this.filterFrameSourceUrl = options.filterFrameSourceUrl; + } + + // An increment used to identify function calls and their returned/exit frames + this.frameId = 0; + + // This feature isn't supported on Workers as they aren't involving user events + if (options.traceOnNextInteraction && !isWorker) { + this.#waitForNextInteraction(); + } else { + this.#startTracing(); + } + } + + // Is actively tracing? + // We typically start tracing from the constructor, unless the "trace on next user interaction" feature is used. + isTracing = false; + + /** + * In case `traceOnNextInteraction` option is used, delay the actual start of tracing until a first user interaction. + */ + #waitForNextInteraction() { + // Use a dedicated Abort Controller as we are going to stop it as soon as we get the first user interaction, + // whereas other listeners would typically wait for tracer stop. + this.nextInteractionAbortController = new AbortController(); + + const listener = () => { + this.nextInteractionAbortController.abort(); + // Avoid tracing if the users asked to stop tracing while we were waiting for the user interaction. + if (this.dbg) { + this.#startTracing(); + } + }; + const eventOptions = { + signal: this.nextInteractionAbortController.signal, + capture: true, + }; + // Register the event listener on the Chrome Event Handler in order to receive the event first. + // When used for the parent process target, `tracedGlobal` is browser.xhtml's window, which doesn't have a chromeEventHandler. + const eventHandler = + this.tracedGlobal.docShell.chromeEventHandler || this.tracedGlobal; + eventHandler.addEventListener("mousedown", listener, eventOptions); + eventHandler.addEventListener("keydown", listener, eventOptions); + + // Significate to the user that the tracer is registered, but not tracing just yet. + let shouldLogToStdout = listeners.size == 0; + for (const l of listeners) { + if (typeof l.onTracingPending == "function") { + shouldLogToStdout |= l.onTracingPending(); + } + } + if (shouldLogToStdout) { + this.loggingMethod(this.prefix + NEXT_INTERACTION_MESSAGE + "\n"); + } + } + + /** + * Actually really start watching for executions. + * + * This may be delayed when traceOnNextInteraction options is used. + * Otherwise we start tracing as soon as the class instantiates. + */ + #startTracing() { + this.isTracing = true; + + this.dbg.onEnterFrame = this.onEnterFrame; + + if (this.traceDOMEvents) { + this.startTracingDOMEvents(); + } + // This feature isn't supported on Workers as they aren't interacting with the DOM Tree + if (this.traceDOMMutations?.length > 0 && !isWorker) { + this.startTracingDOMMutations(); + } + + // In any case, we consider the tracing as started + this.notifyToggle(true); + } + + startTracingDOMEvents() { + this.debuggerNotificationObserver = new DebuggerNotificationObserver(); + this.eventListener = this.eventListener.bind(this); + this.debuggerNotificationObserver.addListener(this.eventListener); + this.debuggerNotificationObserver.connect(this.tracedGlobal); + + this.currentDOMEvent = null; + } + + stopTracingDOMEvents() { + if (this.debuggerNotificationObserver) { + this.debuggerNotificationObserver.removeListener(this.eventListener); + this.debuggerNotificationObserver.disconnect(this.tracedGlobal); + this.debuggerNotificationObserver = null; + } + this.currentDOMEvent = null; + } + + startTracingDOMMutations() { + this.tracedGlobal.document.devToolsWatchingDOMMutations = true; + + const eventOptions = { + signal: this.abortController.signal, + capture: true, + }; + if (this.traceDOMMutations.includes(DOM_MUTATIONS.ADD)) { + this.tracedGlobal.docShell.chromeEventHandler.addEventListener( + "devtoolschildinserted", + this.#onDOMMutation, + eventOptions + ); + } + if (this.traceDOMMutations.includes(DOM_MUTATIONS.ATTRIBUTES)) { + this.tracedGlobal.docShell.chromeEventHandler.addEventListener( + "devtoolsattrmodified", + this.#onDOMMutation, + eventOptions + ); + } + if (this.traceDOMMutations.includes(DOM_MUTATIONS.REMOVE)) { + this.tracedGlobal.docShell.chromeEventHandler.addEventListener( + "devtoolschildremoved", + this.#onDOMMutation, + eventOptions + ); + } + } + + stopTracingDOMMutations() { + this.tracedGlobal.document.devToolsWatchingDOMMutations = false; + // Note that the event listeners are all going to be unregistered via the AbortController. + } + + /** + * Called for any DOM Mutation done in the traced document. + * + * @param {DOM Event} event + */ + #onDOMMutation = event => { + // Ignore elements inserted by DevTools, like the inspector's highlighters + if (event.target.isNativeAnonymous) { + return; + } + + let type = ""; + switch (event.type) { + case "devtoolschildinserted": + type = DOM_MUTATIONS.ADD; + break; + case "devtoolsattrmodified": + type = DOM_MUTATIONS.ATTRIBUTES; + break; + case "devtoolschildremoved": + type = DOM_MUTATIONS.REMOVE; + break; + default: + throw new Error("Unexpected DOM Mutation event type: " + event.type); + } + + let shouldLogToStdout = true; + if (listeners.size > 0) { + shouldLogToStdout = false; + for (const listener of listeners) { + // If any listener return true, also log to stdout + if (typeof listener.onTracingDOMMutation == "function") { + shouldLogToStdout |= listener.onTracingDOMMutation({ + depth: this.depth, + prefix: this.prefix, + + type, + element: event.target, + caller: Components.stack.caller, + }); + } + } + } + + if (shouldLogToStdout) { + const padding = "—".repeat(this.depth + 1); + this.loggingMethod( + this.prefix + + padding + + `[DOM Mutation | ${type}] ` + + objectToString(event.target) + + "\n" + ); + } + }; + + /** + * Called by DebuggerNotificationObserver interface when a DOM event start being notified + * and after it has been notified. + * + * @param {DebuggerNotification} notification + * Info about the DOM event. See the related idl file. + */ + eventListener(notification) { + // For each event we get two notifications. + // One just before firing the listeners and another one just after. + // + // Update `this.currentDOMEvent` to be refering to the event name + // while the DOM event is being notified. It will be null the rest of the time. + // + // We don't need to maintain a stack of events as that's only consumed by onEnterFrame + // which only cares about the very lastest event being currently trigerring some code. + if (notification.phase == "pre") { + // We get notified about "real" DOM event, but also when some particular callbacks are called like setTimeout. + if (notification.type == "domEvent") { + let { type } = notification.event; + if (!type) { + // In the Worker thread, `notification.event` is an opaque wrapper. + // In other threads it is a Xray wrapper. + // Because of this difference, we have to fallback to use the Debugger.Object API. + type = this.dbg + .makeGlobalObjectReference(notification.global) + .makeDebuggeeValue(notification.event) + .getProperty("type").return; + } + this.currentDOMEvent = `DOM | ${type}`; + } else { + this.currentDOMEvent = notification.type; + } + } else { + this.currentDOMEvent = null; + } + } + + /** + * Stop observing execution. + * + * @param {String} reason + * Optional string to justify why the tracer stopped. + */ + stopTracing(reason = "") { + // Note that this may be called before `#startTracing()`, but still want to completely shut it down. + if (!this.dbg) { + return; + } + + this.dbg.onEnterFrame = undefined; + this.dbg.removeAllDebuggees(); + this.dbg.onNewGlobalObject = undefined; + this.dbg = null; + + this.depth = 0; + + // Cancel the traceOnNextInteraction event listeners. + if (this.nextInteractionAbortController) { + this.nextInteractionAbortController.abort(); + this.nextInteractionAbortController = null; + } + + if (this.traceDOMEvents) { + this.stopTracingDOMEvents(); + } + if (this.traceDOMMutations?.length > 0 && !isWorker) { + this.stopTracingDOMMutations(); + } + + // Unregister all event listeners + if (this.abortController) { + this.abortController.abort(); + } + + this.tracedGlobal = null; + this.isTracing = false; + + this.notifyToggle(false, reason); + } + + /** + * Instantiate a Debugger API instance dedicated to each Tracer instance. + * It will notably be different from the instance used in DevTools. + * This allows to implement tracing independently of DevTools. + */ + makeDebugger() { + if (this.traceAllGlobals) { + const dbg = new customLazy.DistinctCompartmentDebugger(); + dbg.addAllGlobalsAsDebuggees(); + + // addAllGlobalAsAdebuggees will also add the global for this module... + // which we have to prevent tracing! + // eslint-disable-next-line mozilla/reject-globalThis-modification + dbg.removeDebuggee(globalThis); + + // Add any future global being created later + dbg.onNewGlobalObject = g => dbg.addDebuggee(g); + return dbg; + } + + // When this code runs in the worker thread, Cu isn't available + // and we don't have system principal anyway in this context. + const { isSystemPrincipal } = + typeof Cu == "object" ? Cu.getObjectPrincipal(this.tracedGlobal) : {}; + + // When debugging the system modules, we have to use a special instance + // of Debugger loaded in a distinct system global. + const dbg = isSystemPrincipal + ? new customLazy.DistinctCompartmentDebugger() + : new customLazy.Debugger(); + + // For now, we only trace calls for one particular global at a time. + // See the constructor for its definition. + dbg.addDebuggee(this.tracedGlobal); + + return dbg; + } + + /** + * Notify DevTools and/or the user via stdout that tracing + * has been enabled or disabled. + * + * @param {Boolean} state + * True if we just started tracing, false when it just stopped. + * @param {String} reason + * Optional string to justify why the tracer stopped. + */ + notifyToggle(state, reason) { + let shouldLogToStdout = listeners.size == 0; + for (const listener of listeners) { + if (typeof listener.onTracingToggled == "function") { + shouldLogToStdout |= listener.onTracingToggled(state, reason); + } + } + if (shouldLogToStdout) { + if (state) { + this.loggingMethod(this.prefix + "Start tracing JavaScript\n"); + } else { + if (reason) { + reason = ` (reason: ${reason})`; + } + this.loggingMethod( + this.prefix + "Stop tracing JavaScript" + reason + "\n" + ); + } + } + } + + /** + * Notify DevTools and/or the user via stdout that tracing + * stopped because of an infinite loop. + */ + notifyInfiniteLoop() { + let shouldLogToStdout = listeners.size == 0; + for (const listener of listeners) { + if (typeof listener.onTracingInfiniteLoop == "function") { + shouldLogToStdout |= listener.onTracingInfiniteLoop(); + } + } + if (shouldLogToStdout) { + this.loggingMethod( + this.prefix + + "Looks like an infinite recursion? We stopped the JavaScript tracer, but code may still be running!\n" + ); + } + } + + /** + * Called by the Debugger API (this.dbg) when a new frame is executed. + * + * @param {Debugger.Frame} frame + * A descriptor object for the JavaScript frame. + */ + onEnterFrame(frame) { + // Safe check, just in case we keep being notified, but the tracer has been stopped + if (!this.dbg) { + return; + } + try { + // If an optional filter is passed, ignore frames which aren't matching the filter string + if ( + this.filterFrameSourceUrl && + !frame.script.source.url?.includes(this.filterFrameSourceUrl) + ) { + return; + } + + // Because of async frame which are popped and entered again on completion of the awaited async task, + // we have to compute the depth from the frame. (and can't use a simple increment on enter/decrement on pop). + const depth = getFrameDepth(frame); + + // Save the current depth for the DOM Mutation handler + this.depth = depth; + + // Ignore the frame if we reached the depth limit (if one is provided) + if (this.maxDepth && depth >= this.maxDepth) { + return; + } + + // When we encounter a frame which was previously popped because of pending on an async task, + // ignore it and only log the following ones. + if (this.pendingAwaitFrames.has(frame)) { + this.pendingAwaitFrames.delete(frame); + return; + } + + // Auto-stop the tracer if we reached the number of max recorded top level frames + if (depth === 0 && this.maxRecords) { + if (this.records >= this.maxRecords) { + this.stopTracing("max-records"); + return; + } + this.records++; + } + + // Consider depth > 100 as an infinite recursive loop and stop the tracer. + if (depth == 100) { + this.notifyInfiniteLoop(); + this.stopTracing("infinite-loop"); + return; + } + + const frameId = this.frameId++; + let shouldLogToStdout = true; + + // If there is at least one DevTools debugging this process, + // delegate logging to DevTools actors. + if (listeners.size > 0) { + shouldLogToStdout = false; + const formatedDisplayName = formatDisplayName(frame); + for (const listener of listeners) { + // If any listener return true, also log to stdout + if (typeof listener.onTracingFrame == "function") { + shouldLogToStdout |= listener.onTracingFrame({ + frameId, + frame, + depth, + formatedDisplayName, + prefix: this.prefix, + currentDOMEvent: this.currentDOMEvent, + }); + } + // Bail out early if any listener stopped tracing as the Frame object + // will be no longer usable by any other code. + if (!this.isTracing) { + return; + } + } + } + + // DevTools may delegate the work to log to stdout, + // but if DevTools are closed, stdout is the only way to log the traces. + if (shouldLogToStdout) { + this.logFrameEnteredToStdout(frame, depth); + } + + if (this.traceSteps) { + // Collect the location notified via onTracingFrame to also avoid redundancy between similar location + // between onEnterFrame and onStep notifications. + let { lineNumber: lastLine, columnNumber: lastColumn } = + frame.script.getOffsetMetadata(frame.offset); + + frame.onStep = () => { + // Spidermonkey steps on many intermediate positions which don't make sense to the user. + // `isStepStart` is close to each statement start, which is meaningful to the user. + const { isStepStart, lineNumber, columnNumber } = + frame.script.getOffsetMetadata(frame.offset); + if (!isStepStart) { + return; + } + // onStep may be called on many instructions related to the same line and colunm. + // Avoid notifying duplicated steps if we stepped on the exact same location. + if (lastLine == lineNumber && lastColumn == columnNumber) { + return; + } + lastLine = lineNumber; + lastColumn = columnNumber; + + shouldLogToStdout = true; + if (listeners.size > 0) { + shouldLogToStdout = false; + for (const listener of listeners) { + // If any listener return true, also log to stdout + if (typeof listener.onTracingFrameStep == "function") { + shouldLogToStdout |= listener.onTracingFrameStep({ + frame, + depth, + prefix: this.prefix, + }); + } + } + } + if (shouldLogToStdout) { + this.logFrameStepToStdout(frame, depth); + } + // Optionaly pause the frame execution by letting the other event loop to run in between. + if (typeof this.pauseOnStep == "number") { + syncPause(this.pauseOnStep); + } + }; + } + + frame.onPop = completion => { + // Special case async frames. We are exiting the current frame because of waiting for an async task. + // (this is typically a `await foo()` from an async function) + // This frame should later be "entered" again. + if (completion?.await) { + this.pendingAwaitFrames.add(frame); + return; + } + + if (!this.traceFunctionReturn) { + return; + } + + let why = ""; + let rv = undefined; + if (!completion) { + why = FRAME_EXIT_REASONS.TERMINATED; + } else if ("return" in completion) { + why = FRAME_EXIT_REASONS.RETURN; + rv = completion.return; + } else if ("yield" in completion) { + why = FRAME_EXIT_REASONS.YIELD; + rv = completion.yield; + } else if ("await" in completion) { + why = FRAME_EXIT_REASONS.AWAIT; + } else { + why = FRAME_EXIT_REASONS.THROW; + rv = completion.throw; + } + + shouldLogToStdout = true; + if (listeners.size > 0) { + shouldLogToStdout = false; + const formatedDisplayName = formatDisplayName(frame); + for (const listener of listeners) { + // If any listener return true, also log to stdout + if (typeof listener.onTracingFrameExit == "function") { + shouldLogToStdout |= listener.onTracingFrameExit({ + frameId, + frame, + depth, + formatedDisplayName, + prefix: this.prefix, + why, + rv, + }); + } + } + } + if (shouldLogToStdout) { + this.logFrameExitedToStdout(frame, depth, why, rv); + } + }; + + // Optionaly pause the frame execution by letting the other event loop to run in between. + if (typeof this.pauseOnStep == "number") { + syncPause(this.pauseOnStep); + } + } catch (e) { + console.error("Exception while tracing javascript", e); + } + } + + /** + * Display to stdout one given frame execution, which represents a function call. + * + * @param {Debugger.Frame} frame + * @param {Number} depth + */ + logFrameEnteredToStdout(frame, depth) { + const padding = "—".repeat(depth + 1); + + // If we are tracing DOM events and we are in middle of an event, + // and are logging the topmost frame, + // then log a preliminary dedicated line to mention that event type. + if (this.currentDOMEvent && depth == 0) { + this.loggingMethod(this.prefix + padding + this.currentDOMEvent + "\n"); + } + + let message = `${padding}[${frame.implementation}]—> ${getTerminalHyperLink( + frame + )} - ${formatDisplayName(frame)}`; + + // Log arguments, but only when this feature is enabled as it introduces + // some significant performance and visual overhead. + // Also prevent trying to log function call arguments if we aren't logging a frame + // with arguments (e.g. Debugger evaluation frames, when executing from the console) + if (this.traceValues && frame.arguments) { + message += "("; + for (let i = 0, l = frame.arguments.length; i < l; i++) { + const arg = frame.arguments[i]; + // Debugger.Frame.arguments contains either a Debugger.Object or primitive object + if (arg?.unsafeDereference) { + // Special case classes as they can't be easily differentiated in pure JavaScript + if (arg.isClassConstructor) { + message += "class " + arg.name; + } else { + message += objectToString(arg.unsafeDereference()); + } + } else { + message += primitiveToString(arg); + } + + if (i < l - 1) { + message += ", "; + } + } + message += ")"; + } + + this.loggingMethod(this.prefix + message + "\n"); + } + + /** + * Display to stdout one given frame execution, which represents a step within a function execution. + * + * @param {Debugger.Frame} frame + * @param {Number} depth + */ + logFrameStepToStdout(frame, depth) { + const padding = "—".repeat(depth + 1); + + const message = `${padding}— ${getTerminalHyperLink(frame)}`; + + this.loggingMethod(this.prefix + message + "\n"); + } + + /** + * Display to stdout the exit of a given frame execution, which represents a function return. + * + * @param {Debugger.Frame} frame + * @param {String} why + * @param {Number} depth + */ + logFrameExitedToStdout(frame, depth, why, rv) { + const padding = "—".repeat(depth + 1); + + let message = `${padding}[${frame.implementation}]<— ${getTerminalHyperLink( + frame + )} - ${formatDisplayName(frame)} ${why}`; + + // Log returned values, but only when this feature is enabled as it introduces + // some significant performance and visual overhead. + if (this.traceValues) { + message += " "; + // Debugger.Frame.arguments contains either a Debugger.Object or primitive object + if (rv?.unsafeDereference) { + // Special case classes as they can't be easily differentiated in pure JavaScript + if (rv.isClassConstructor) { + message += "class " + rv.name; + } else { + message += objectToString(rv.unsafeDereference()); + } + } else { + message += primitiveToString(rv); + } + } + + this.loggingMethod(this.prefix + message + "\n"); + } +} + +/** + * Return a string description for any arbitrary JS value. + * Used when logging to stdout. + * + * @param {Object} obj + * Any JavaScript object to describe. + * @return String + * User meaningful descriptor for the object. + */ +function objectToString(obj) { + if (Element.isInstance(obj)) { + let message = `<${obj.tagName}`; + if (obj.id) { + message += ` #${obj.id}`; + } + if (obj.className) { + message += ` .${obj.className}`; + } + message += ">"; + return message; + } else if (Array.isArray(obj)) { + return `Array(${obj.length})`; + } else if (Event.isInstance(obj)) { + return `Event(${obj.type}) target=${objectToString(obj.target)}`; + } else if (typeof obj === "function") { + return `function ${obj.name || "anonymous"}()`; + } + return obj; +} + +function primitiveToString(value) { + const type = typeof value; + if (type === "string") { + // Use stringify to escape special characters and display in enclosing quotes. + return JSON.stringify(value); + } else if (value === 0 && 1 / value === -Infinity) { + // -0 is very special and need special threatment. + return "-0"; + } else if (type === "bigint") { + return `BigInt(${value})`; + } else if (value && typeof value.toString === "function") { + // Use toString as it allows to stringify Symbols. Converting them to string throws. + return value.toString(); + } + + // For all other types/cases, rely on native convertion to string + return value; +} + +/** + * Try to describe the current frame we are tracing + * + * This will typically log the name of the method being called. + * + * @param {Debugger.Frame} frame + * The frame which is currently being executed. + */ +function formatDisplayName(frame) { + if (frame.type === "call") { + const callee = frame.callee; + // Anonymous function will have undefined name and displayName. + return "λ " + (callee.name || callee.displayName || "anonymous"); + } + + return `(${frame.type})`; +} + +let activeTracer = null; + +/** + * Start tracing JavaScript. + * i.e. log the name of any function being called in JS and its location in source code. + * + * @params {Object} options (mandatory) + * See JavaScriptTracer.startTracing jsdoc. + */ +function startTracing(options) { + if (!options) { + throw new Error("startTracing excepts an options object as first argument"); + } + if (!activeTracer) { + activeTracer = new JavaScriptTracer(options); + } else { + console.warn( + "Can't start JavaScript tracing, another tracer is still active and we only support one tracer at a time." + ); + } +} + +/** + * Stop tracing JavaScript. + */ +function stopTracing() { + if (activeTracer) { + activeTracer.stopTracing(); + activeTracer = null; + } else { + console.warn("Can't stop JavaScript Tracing as we were not tracing."); + } +} + +/** + * Listen for tracing updates. + * + * The listener object may expose the following methods: + * - onTracingToggled(state) + * Where state is a boolean to indicate if tracing has just been enabled of disabled. + * It may be immediatelly called if a tracer is already active. + * + * - onTracingInfiniteLoop() + * Called when the tracer stopped because of an infinite loop. + * + * - onTracingFrame({ frame, depth, formatedDisplayName, prefix }) + * Called each time we enter a new JS frame. + * - frame is a Debugger.Frame object + * - depth is a number and represents the depth of the frame in the call stack + * - formatedDisplayName is a string and is a human readable name for the current frame + * - prefix is a string to display as a prefix of any logged frame + * + * @param {Object} listener + */ +function addTracingListener(listener) { + listeners.add(listener); + + if ( + activeTracer?.isTracing && + typeof listener.onTracingToggled == "function" + ) { + listener.onTracingToggled(true); + } +} + +/** + * Unregister a listener previous registered via addTracingListener + */ +function removeTracingListener(listener) { + listeners.delete(listener); +} + +function getFrameDepth(frame) { + if (typeof frame.depth !== "number") { + let depth = 0; + let f = frame; + while ((f = f.older)) { + depth++; + } + frame.depth = depth; + } + + return frame.depth; +} + +/** + * Generate a magic string that will be rendered in smart terminals as a URL + * for the given Frame object. This URL is special as it includes a line and column. + * This URL can be clicked and Firefox will automatically open the source matching + * the frame's URL in the currently opened Debugger. + * Firefox will interpret differently the URLs ending with `/:?\d*:\d+/`. + * + * @param {Debugger.Frame} frame + * The frame being traced. + * @return {String} + * The URL's magic string. + */ +function getTerminalHyperLink(frame) { + const { script } = frame; + const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset); + + // Use a special URL, including line and column numbers which Firefox + // interprets as to be opened in the already opened DevTool's debugger + const href = `${script.source.url}:${lineNumber}:${columnNumber}`; + + // Use special characters in order to print working hyperlinks right from the terminal + // See https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda + return `\x1B]8;;${href}\x1B\\${href}\x1B]8;;\x1B\\`; +} + +/** + * Helper function to synchronously pause the current frame execution + * for a given duration in ms. + * + * @param {Number} duration + */ +function syncPause(duration) { + let freeze = true; + const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback( + () => { + freeze = false; + }, + duration, + Ci.nsITimer.TYPE_ONE_SHOT + ); + Services.tm.spinEventLoopUntil("debugger-slow-motion", function () { + return !freeze; + }); +} + +export const JSTracer = { + startTracing, + stopTracing, + addTracingListener, + removeTracingListener, + NEXT_INTERACTION_MESSAGE, + DOM_MUTATIONS, +}; |