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