summaryrefslogtreecommitdiffstats
path: root/devtools/server/tracer
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
commitd8bbc7858622b6d9c278469aab701ca0b609cddf (patch)
treeeff41dc61d9f714852212739e6b3738b82a2af87 /devtools/server/tracer
parentReleasing progress-linux version 125.0.3-1~progress7.99u1. (diff)
downloadfirefox-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.build2
-rw-r--r--devtools/server/tracer/tests/browser/WorkerDebugger.tracer.js17
-rw-r--r--devtools/server/tracer/tests/browser/browser_document_tracer.js18
-rw-r--r--devtools/server/tracer/tests/xpcshell/test_tracer.js175
-rw-r--r--devtools/server/tracer/tracer.jsm1050
-rw-r--r--devtools/server/tracer/tracer.sys.mjs1088
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,
+};