Bug 1817834 - [devtools] Allow to start and stop JavaScript tracing from privileged codebases. r=devtools-reviewers,nchevobbe

For now, you could only toggle tracing from the debugger interface.

Having an API would help debugging tests by start'n stop tracing from precise locations in code.
This would also ease passing parameters to have configurable traces.

With the following snipper you can toggle tracing without devtools opened by logging traces to stdout:
```
{
  const { startTracing, stopTracing } = ChromeUtils.importESModule(
    "resource://devtools/server/tracer/tracer.sys.mjs"
  );
  startTracing({ prefix: "prefix" });
    function bar() {}; function foo() {bar()}; foo();
  stopTracing();
}
```
It will log this to the terminal:
```
[prefix] Start tracing JavaScript
[prefix] —[interpreter]—> debugger eval code @ 6:43 - λ foo
[prefix] ——[interpreter]—> debugger eval code @ 6:24 - λ bar
[prefix] Stop tracing JavaScript
```

This typically allows to use traces on automation.

Note that if tracing is toggled by DevTools, it will integrate into this new ES Module
and will be able to log the traces to the web console.

Differential Revision: https://phabricator.services.mozilla.com/D174696
This commit is contained in:
Alexandre Poirot 2023-04-12 17:56:30 +00:00
parent 387376d911
commit 9e6a57e8ce
11 changed files with 750 additions and 137 deletions

View File

@ -8,6 +8,14 @@ const {
TYPES: { TRACING_STATE },
} = require("resource://devtools/server/actors/resources/index.js");
// Bug 1827382, as this module can be used from the worker thread,
// the following JSM may be loaded by the worker loader until
// we have proper support for ESM from workers.
const {
addTracingListener,
removeTracingListener,
} = require("resource://devtools/server/tracer/tracer.jsm");
class TracingStateWatcher {
/**
* Start watching for tracing state changes for a given target actor.
@ -20,27 +28,28 @@ class TracingStateWatcher {
* This will be called for each resource.
*/
async watch(targetActor, { onAvailable }) {
this.targetActor = targetActor;
this.onAvailable = onAvailable;
// Ensure dispatching the existing state, only if a tracer is enabled.
const tracerActor = targetActor.getTargetScopedActor("tracer");
if (!tracerActor || !tracerActor.isTracing()) {
return;
}
this.onTracingToggled(true, tracerActor.getLogMethod());
this.tracingListener = {
onTracingToggled: this.onTracingToggled.bind(this),
};
addTracingListener(this.tracingListener);
}
/**
* Stop watching for tracing state
*/
destroy() {}
destroy() {
removeTracingListener(this.tracingListener);
}
// Emit a TRACING_STATE resource with:
// enabled = true|false
// logMethod = console|stdout
// When Javascript tracing is enabled or disabled.
onTracingToggled(enabled, logMethod) {
onTracingToggled(enabled) {
const tracerActor = this.targetActor.getTargetScopedActor("tracer");
const logMethod = tracerActor?.getLogMethod() | "stdout";
this.onAvailable([
{
resourceType: TRACING_STATE,

View File

@ -4,6 +4,16 @@
"use strict";
// Bug 1827382, as this module can be used from the worker thread,
// the following JSM may be loaded by the worker loader until
// we have proper support for ESM from workers.
const {
startTracing,
stopTracing,
addTracingListener,
removeTracingListener,
} = require("resource://devtools/server/tracer/tracer.jsm");
const { Actor } = require("resource://devtools/shared/protocol.js");
const { tracerSpec } = require("resource://devtools/shared/specs/tracer.js");
@ -38,8 +48,6 @@ class TracerActor extends Actor {
this.targetActor.actorID
);
this.onEnterFrame = this.onEnterFrame.bind(this);
this.throttledConsoleMessages = [];
this.throttleLogMessages = throttle(
this.flushConsoleMessages.bind(this),
@ -47,8 +55,8 @@ class TracerActor extends Actor {
);
}
isTracing() {
return !!this.dbg;
destroy() {
this.stopTracing();
}
getLogMethod() {
@ -57,120 +65,98 @@ class TracerActor extends Actor {
startTracing(logMethod = LOG_METHODS.STDOUT) {
this.logMethod = logMethod;
// If we are already recording traces, only change the log method and notify about
// the new log method to the client.
if (!this.isTracing()) {
// Instantiate a brand new Debugger API so that we can trace independently
// of all other debugger operations. i.e. we can pause while tracing without any interference.
this.dbg = this.targetActor.makeDebugger();
this.depth = 0;
if (this.logMethod == LOG_METHODS.STDOUT) {
dump("Start tracing JavaScript\n");
}
this.dbg.onEnterFrame = this.onEnterFrame;
this.dbg.enable();
}
const tracingStateWatcher = getResourceWatcher(
this.targetActor,
TYPES.TRACING_STATE
);
if (tracingStateWatcher) {
tracingStateWatcher.onTracingToggled(true, logMethod);
}
this.tracingListener = {
onTracingFrame: this.onTracingFrame.bind(this),
onTracingInfiniteLoop: this.onTracingInfiniteLoop.bind(this),
};
addTracingListener(this.tracingListener);
startTracing({
global: this.targetActor.window || this.targetActor.workerGlobal,
});
}
stopTracing() {
if (!this.isTracing()) {
if (!this.tracingListener) {
return;
}
if (this.logMethod == LOG_METHODS.STDOUT) {
dump("Stop tracing JavaScript\n");
}
this.dbg.onEnterFrame = undefined;
this.dbg.disable();
this.dbg = null;
this.depth = 0;
const tracingStateWatcher = getResourceWatcher(
this.targetActor,
TYPES.TRACING_STATE
);
if (tracingStateWatcher) {
tracingStateWatcher.onTracingToggled(false);
}
stopTracing();
removeTracingListener(this.tracingListener);
this.logMethod = null;
this.tracingListener = null;
}
onEnterFrame(frame) {
// Safe check, just in case we keep being notified, but the tracer has been stopped
if (!this.dbg) {
return;
onTracingInfiniteLoop() {
if (this.logMethod == LOG_METHODS.STDOUT) {
return true;
}
try {
if (this.depth == 100) {
const message =
"Looks like an infinite recursion? We stopped the JavaScript tracer, but code may still be running!";
if (this.logMethod == LOG_METHODS.STDOUT) {
dump(message + "\n");
} else if (this.logMethod == LOG_METHODS.CONSOLE) {
this.throttledConsoleMessages.push({
arguments: [message],
styles: [],
level: "logTrace",
chromeContext: this.isChromeContext,
timeStamp: ChromeUtils.dateNow(),
});
this.throttleLogMessages();
}
this.stopTracing();
return;
}
const { script } = frame;
const { lineNumber, columnNumber } = script.getOffsetMetadata(
frame.offset
);
if (this.logMethod == LOG_METHODS.STDOUT) {
const padding = "—".repeat(this.depth + 1);
const message = `${padding}[${frame.implementation}]—> ${
script.source.url
} @ ${lineNumber}:${columnNumber} - ${formatDisplayName(frame)}`;
dump(message + "\n");
} else if (this.logMethod == LOG_METHODS.CONSOLE) {
const args = [
"—".repeat(this.depth + 1),
frame.implementation,
"⟶",
formatDisplayName(frame),
];
// Create a message object that fits Console Message Watcher expectations
this.throttledConsoleMessages.push({
filename: script.source.url,
lineNumber,
columnNumber,
arguments: args,
styles: CONSOLE_ARGS_STYLES,
level: "logTrace",
chromeContext: this.isChromeContext,
timeStamp: ChromeUtils.dateNow(),
sourceId: script.source.id,
});
this.throttleLogMessages();
}
this.depth++;
frame.onPop = () => {
this.depth--;
};
} catch (e) {
console.error("Exception while tracing javascript", e);
const consoleMessageWatcher = getResourceWatcher(
this.targetActor,
TYPES.CONSOLE_MESSAGE
);
if (!consoleMessageWatcher) {
return true;
}
const message =
"Looks like an infinite recursion? We stopped the JavaScript tracer, but code may still be running!";
consoleMessageWatcher.emitMessages([
{
arguments: [message],
styles: [],
level: "logTrace",
chromeContext: this.isChromeContext,
timeStamp: ChromeUtils.dateNow(),
},
]);
return false;
}
/**
* Called by JavaScriptTracer class when a new JavaScript frame is executed.
*
* @param {Debugger.Frame} frame
* A descriptor object for the JavaScript frame.
* @param {Number} depth
* Represents the depth of the frame in the call stack.
* @param {String} formatedDisplayName
* A human readable name for the current frame.
* @param {String} prefix
* A string to be displayed as a prefix of any logged frame.
* @return {Boolean}
* Return true, if the JavaScriptTracer should log the frame to stdout.
*/
onTracingFrame({ frame, depth, formatedDisplayName, prefix }) {
if (this.logMethod == LOG_METHODS.STDOUT) {
// By returning true, we let JavaScriptTracer class log the message to stdout.
return true;
}
const { script } = frame;
const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset);
const args = [
prefix + "—".repeat(depth + 1),
frame.implementation,
"⟶",
formatedDisplayName,
];
// Create a message object that fits Console Message Watcher expectations
this.throttledConsoleMessages.push({
filename: script.source.url,
lineNumber,
columnNumber,
arguments: args,
styles: CONSOLE_ARGS_STYLES,
level: "logTrace",
chromeContext: this.isChromeContext,
sourceId: script.source.id,
timeStamp: ChromeUtils.dateNow(),
});
this.throttleLogMessages();
return false;
}
/**
@ -193,21 +179,3 @@ class TracerActor extends Actor {
}
}
exports.TracerActor = TracerActor;
/**
* 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})`;
}

View File

@ -12,6 +12,7 @@ DIRS += [
"performance",
"socket",
"startup",
"tracer",
]
if CONFIG["MOZ_BUILD_APP"] != "mobile/android":

View File

@ -0,0 +1,14 @@
# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
# vim: set filetype=python:
# 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/.
DevToolsModules("tracer.jsm")
XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.ini"]
if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"]
with Files("**"):
BUG_COMPONENT = ("DevTools", "General")

View File

@ -0,0 +1,10 @@
"use strict";
/* eslint-disable no-unused-vars */
function bar() {}
function foo() {
bar();
}
postMessage("evaled");

View File

@ -0,0 +1,36 @@
"use strict";
/* global global, loadSubScript */
try {
// For some reason WorkerDebuggerGlobalScope.global doesn't expose JS variables
// and we can't call via global.foo(). Instead we have to go throught the Debugger API.
const dbg = new Debugger(global);
const [debuggee] = dbg.getDebuggees();
/* global startTracing, stopTracing, addTracingListener, removeTracingListener */
loadSubScript("resource://devtools/server/tracer/tracer.jsm");
const frames = [];
const listener = {
onTracingFrame(args) {
frames.push(args);
// Return true, to also log the trace to stdout
return true;
},
};
addTracingListener(listener);
startTracing({ global, prefix: "testWorkerPrefix" });
debuggee.executeInGlobal("foo()");
stopTracing();
removeTracingListener(listener);
// Send the frames to the main thread to do the assertions there.
postMessage(JSON.stringify(frames));
} catch (e) {
dump(
"Exception while running debugger test script: " + e + "\n" + e.stack + "\n"
);
}

View File

@ -0,0 +1,8 @@
[DEFAULT]
tags = devtools
subsuite = devtools
[browser_worker_tracer.js]
support-files =
Worker.tracer.js
WorkerDebugger.tracer.js

View File

@ -0,0 +1,68 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const wdm = Cc["@mozilla.org/dom/workers/workerdebuggermanager;1"].getService(
Ci.nsIWorkerDebuggerManager
);
const BASE_URL =
"chrome://mochitests/content/browser/devtools/server/tracer/tests/browser/";
const WORKER_URL = BASE_URL + "Worker.tracer.js";
const WORKER_DEBUGGER_URL = BASE_URL + "WorkerDebugger.tracer.js";
add_task(async function testTracingWorker() {
const onDbg = waitForWorkerDebugger(WORKER_URL);
info("Instantiate a regular worker");
const worker = new Worker(WORKER_URL);
info("Wait for worker to reply back");
await new Promise(r => (worker.onmessage = r));
info("Wait for WorkerDebugger to be instantiated");
const dbg = await onDbg;
const onDebuggerScriptSentFrames = new Promise(resolve => {
const listener = {
onMessage(msg) {
dbg.removeListener(listener);
resolve(JSON.parse(msg));
},
};
dbg.addListener(listener);
});
info("Evaluate a Worker Debugger test script");
dbg.initialize(WORKER_DEBUGGER_URL);
info("Wait for frames to be notified by the debugger script");
const frames = await onDebuggerScriptSentFrames;
is(frames.length, 3);
// There is a third frame which relates to the usage of Debugger.Object.executeInGlobal
// which we ignore as that's a test side effect.
const lastFrame = frames.at(-1);
const beforeLastFrame = frames.at(-2);
is(beforeLastFrame.depth, 1);
is(beforeLastFrame.formatedDisplayName, "λ foo");
is(beforeLastFrame.prefix, "testWorkerPrefix: ");
ok(beforeLastFrame.frame);
is(lastFrame.depth, 2);
is(lastFrame.formatedDisplayName, "λ bar");
is(lastFrame.prefix, "testWorkerPrefix: ");
ok(lastFrame.frame);
});
function waitForWorkerDebugger(url, dbgUrl) {
return new Promise(function(resolve) {
wdm.addListener({
onRegister(dbg) {
if (dbg.url !== url) {
return;
}
ok(true, "Debugger with url " + url + " should be registered.");
wdm.removeListener(this);
resolve(dbg);
},
});
});
}

View File

@ -0,0 +1,143 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const {
addTracingListener,
removeTracingListener,
startTracing,
stopTracing,
} = ChromeUtils.import("resource://devtools/server/tracer/tracer.jsm");
add_task(async function() {
// Because this test uses evalInSandbox, we need to tweak the following prefs
Services.prefs.setBoolPref(
"security.allow_parent_unrestricted_js_loads",
true
);
registerCleanupFunction(() => {
Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads");
});
});
add_task(async function testTracingContentGlobal() {
const toggles = [];
const frames = [];
const listener = {
onTracingToggled(state) {
toggles.push(state);
},
onTracingFrame(frameInfo) {
frames.push(frameInfo);
},
};
info("Register a tracing listener");
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" });
Assert.equal(toggles.length, 1);
Assert.equal(toggles[0], true);
info("Call some code");
sandbox.foo();
Assert.equal(frames.length, 2);
const lastFrame = frames.pop();
const beforeLastFrame = frames.pop();
Assert.equal(beforeLastFrame.depth, 0);
Assert.equal(beforeLastFrame.formatedDisplayName, "λ foo");
Assert.equal(beforeLastFrame.prefix, "testContentPrefix: ");
Assert.ok(beforeLastFrame.frame);
Assert.equal(lastFrame.depth, 1);
Assert.equal(lastFrame.formatedDisplayName, "λ bar");
Assert.equal(lastFrame.prefix, "testContentPrefix: ");
Assert.ok(lastFrame.frame);
info("Stop tracing");
stopTracing();
Assert.equal(toggles.length, 2);
Assert.equal(toggles[1], false);
info("Recall code after stop, no more traces are logged");
sandbox.foo();
Assert.equal(frames.length, 0);
info("Start tracing again, and recall code");
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);
sandbox.foo();
info("No more traces are logged");
Assert.equal(frames.length, 2);
info("Stop tracing");
stopTracing();
});
add_task(async function testTracingJSMGlobal() {
// We have to register the listener code in a sandbox, i.e. in a distinct global
// so that we aren't creating traces when tracer calls it. (and cause infinite loops)
const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
const listenerSandbox = Cu.Sandbox(systemPrincipal);
Cu.evalInSandbox(
"new " +
function() {
globalThis.toggles = [];
globalThis.frames = [];
globalThis.listener = {
onTracingToggled(state) {
globalThis.toggles.push(state);
},
onTracingFrame(frameInfo) {
globalThis.frames.push(frameInfo);
},
};
},
listenerSandbox
);
info("Register a tracing listener");
addTracingListener(listenerSandbox.listener);
info("Start tracing");
startTracing({ global: null, prefix: "testPrefix" });
Assert.equal(listenerSandbox.toggles.length, 1);
Assert.equal(listenerSandbox.toggles[0], true);
info("Call some code");
function bar() {}
function foo() {
bar();
}
foo();
// Note that the tracer will record the two Assert.equal and the info calls.
// So only assert the last two frames.
const lastFrame = listenerSandbox.frames.at(-1);
const beforeLastFrame = listenerSandbox.frames.at(-2);
Assert.equal(beforeLastFrame.depth, 0);
Assert.equal(beforeLastFrame.formatedDisplayName, "λ foo");
Assert.equal(beforeLastFrame.prefix, "testPrefix: ");
Assert.ok(beforeLastFrame.frame);
Assert.equal(lastFrame.depth, 1);
Assert.equal(lastFrame.formatedDisplayName, "λ bar");
Assert.equal(lastFrame.prefix, "testPrefix: ");
Assert.ok(lastFrame.frame);
info("Stop tracing");
stopTracing();
Assert.equal(listenerSandbox.toggles.length, 2);
Assert.equal(listenerSandbox.toggles[1], false);
removeTracingListener(listenerSandbox.listener);
});

View File

@ -0,0 +1,6 @@
[DEFAULT]
tags = devtools
firefox-appdir = browser
skip-if = toolkit == 'android'
[test_tracer.js]

View File

@ -0,0 +1,350 @@
/* 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",
];
const listeners = new Set();
// 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, this module runs within
// the WorkerDebuggerGlobalScope and have immediate access to Debugger class.
// (while we can't use ChromeUtils.importESModule)
if (globalThis.Debugger) {
return globalThis.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.
*/
class JavaScriptTracer {
constructor(options) {
this.onEnterFrame = this.onEnterFrame.bind(this);
// 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.
const global = 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(global);
this.depth = 0;
this.prefix = options.prefix ? `${options.prefix}: ` : "";
this.dbg.onEnterFrame = this.onEnterFrame;
this.notifyToggle(true);
}
stopTracing() {
if (!this.isTracing()) {
return;
}
this.dbg.onEnterFrame = undefined;
this.dbg.removeAllDebuggees();
this.dbg.onNewGlobalObject = undefined;
this.dbg = null;
this.depth = 0;
this.options = null;
this.notifyToggle(false);
}
isTracing() {
return !!this.dbg;
}
/**
* 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.
*
* @param {Object} global
* The global to trace.
*/
makeDebugger(global) {
// 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(global) : {};
// 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();
// By default, only track the global passed as argument
dbg.addDebuggee(global);
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.
*/
notifyToggle(state) {
let shouldLogToStdout = listeners.size == 0;
for (const listener of listeners) {
if (typeof listener.onTracingToggled == "function") {
shouldLogToStdout |= listener.onTracingToggled(state);
}
}
if (shouldLogToStdout) {
if (state) {
dump(this.prefix + "Start tracing JavaScript\n");
} else {
dump(this.prefix + "Stop tracing JavaScript\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) {
dump(
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 (this.depth == 100) {
this.notifyInfiniteLoop();
this.stopTracing();
return;
}
const formatedDisplayName = formatDisplayName(frame);
let shouldLogToStdout = true;
// If there is at least one DevTools debugging this process,
// delegate logging to DevTools actors.
if (listeners.size > 0) {
shouldLogToStdout = false;
for (const listener of listeners) {
// If any listener return true, also log to stdout
if (typeof listener.onTracingFrame == "function") {
shouldLogToStdout |= listener.onTracingFrame({
frame,
depth: this.depth,
formatedDisplayName,
prefix: this.prefix,
});
}
}
}
// 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) {
const { script } = frame;
const { lineNumber, columnNumber } = script.getOffsetMetadata(
frame.offset
);
const padding = "—".repeat(this.depth + 1);
const message = `${padding}[${frame.implementation}]—> ${script.source.url} @ ${lineNumber}:${columnNumber} - ${formatedDisplayName}`;
dump(this.prefix + message + "\n");
}
this.depth++;
frame.onPop = () => {
this.depth--;
};
} catch (e) {
console.error("Exception while tracing javascript", e);
}
}
}
/**
* 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()) {
listener.onTracingToggled(true);
}
}
/**
* Unregister a listener previous registered via addTracingListener
*/
function removeTracingListener(listener) {
listeners.delete(listener);
}
// This JSM may be execute as CommonJS when loaded in the worker thread
if (typeof module == "object") {
module.exports = {
startTracing,
stopTracing,
addTracingListener,
removeTracingListener,
};
}