From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- devtools/server/tracer/tracer.jsm | 364 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 devtools/server/tracer/tracer.jsm (limited to 'devtools/server/tracer/tracer.jsm') diff --git a/devtools/server/tracer/tracer.jsm b/devtools/server/tracer/tracer.jsm new file mode 100644 index 0000000000..44bf83104d --- /dev/null +++ b/devtools/server/tracer/tracer.jsm @@ -0,0 +1,364 @@ +/* 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); + // 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 + const urlLink = `\x1B]8;;${href}\x1B\\${href}\x1B]8;;\x1B\\`; + + const message = `${padding}[${ + frame.implementation + }]—> ${urlLink} - ${formatDisplayName(frame)}`; + + 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() && + typeof listener.onTracingToggled == "function" + ) { + 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, + }; +} -- cgit v1.2.3