/* 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/. */ "use strict"; // protocol.js uses objects as exceptions in order to define // error packets. /* eslint-disable no-throw-literal */ const { Actor } = require("resource://devtools/shared/protocol/Actor.js"); const { Pool } = require("resource://devtools/shared/protocol/Pool.js"); const { threadSpec } = require("resource://devtools/shared/specs/thread.js"); const { createValueGrip, } = require("resource://devtools/server/actors/object/utils.js"); const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); const Debugger = require("Debugger"); const { assert, dumpn, reportException } = DevToolsUtils; const { getAvailableEventBreakpoints, eventBreakpointForNotification, eventsRequireNotifications, firstStatementBreakpointId, makeEventBreakpointMessage, } = require("resource://devtools/server/actors/utils/event-breakpoints.js"); const { WatchpointMap, } = require("resource://devtools/server/actors/utils/watchpoint-map.js"); const { logEvent, } = require("resource://devtools/server/actors/utils/logEvent.js"); const Targets = require("devtools/server/actors/targets/index"); loader.lazyRequireGetter( this, "EnvironmentActor", "resource://devtools/server/actors/environment.js", true ); loader.lazyRequireGetter( this, "BreakpointActorMap", "resource://devtools/server/actors/utils/breakpoint-actor-map.js", true ); loader.lazyRequireGetter( this, "PauseScopedObjectActor", "resource://devtools/server/actors/pause-scoped.js", true ); loader.lazyRequireGetter( this, "EventLoop", "resource://devtools/server/actors/utils/event-loop.js", true ); loader.lazyRequireGetter( this, ["FrameActor", "getSavedFrameParent", "isValidSavedFrame"], "resource://devtools/server/actors/frame.js", true ); loader.lazyRequireGetter( this, "HighlighterEnvironment", "resource://devtools/server/actors/highlighters.js", true ); loader.lazyRequireGetter( this, "PausedDebuggerOverlay", "resource://devtools/server/actors/highlighters/paused-debugger.js", true ); const PROMISE_REACTIONS = new WeakMap(); function cacheReactionsForFrame(frame) { if (frame.asyncPromise) { const reactions = frame.asyncPromise.getPromiseReactions(); const existingReactions = PROMISE_REACTIONS.get(frame.asyncPromise); if ( reactions.length && (!existingReactions || reactions.length > existingReactions.length) ) { PROMISE_REACTIONS.set(frame.asyncPromise, reactions); } } } function createStepForReactionTracking(onStep) { return function () { cacheReactionsForFrame(this); return onStep ? onStep.apply(this, arguments) : undefined; }; } const getAsyncParentFrame = frame => { if (!frame.asyncPromise) { return null; } // We support returning Frame actors for frames that are suspended // at an 'await', and here we want to walk upward to look for the first // frame that will be resumed when the current frame's promise resolves. let reactions = PROMISE_REACTIONS.get(frame.asyncPromise) || frame.asyncPromise.getPromiseReactions(); while (true) { // We loop here because we may have code like: // // async function inner(){ debugger; } // // async function outer() { // await Promise.resolve().then(() => inner()); // } // // where we can see that when `inner` resolves, we will resume from // `outer`, even though there is a layer of promises between, and // that layer could be any number of promises deep. if (!(reactions[0] instanceof Debugger.Object)) { break; } reactions = reactions[0].getPromiseReactions(); } if (reactions[0] instanceof Debugger.Frame) { return reactions[0]; } return null; }; const RESTARTED_FRAMES = new WeakSet(); // Thread actor possible states: const STATES = { // Before ThreadActor.attach is called: DETACHED: "detached", // After the actor is destroyed: EXITED: "exited", // States possible in between DETACHED AND EXITED: // Default state, when the thread isn't paused, RUNNING: "running", // When paused on any type of breakpoint, or, when the client requested an interrupt. PAUSED: "paused", }; exports.STATES = STATES; // Possible values for the `why.type` attribute in "paused" event const PAUSE_REASONS = { ALREADY_PAUSED: "alreadyPaused", INTERRUPTED: "interrupted", // Associated with why.onNext attribute MUTATION_BREAKPOINT: "mutationBreakpoint", // Associated with why.mutationType and why.message attributes DEBUGGER_STATEMENT: "debuggerStatement", EXCEPTION: "exception", XHR: "XHR", EVENT_BREAKPOINT: "eventBreakpoint", RESUME_LIMIT: "resumeLimit", }; exports.PAUSE_REASONS = PAUSE_REASONS; class ThreadActor extends Actor { /** * Creates a ThreadActor. * * ThreadActors manage execution/inspection of debuggees. * * @param {TargetActor} targetActor * This `ThreadActor`'s parent actor. i.e. one of the many Target actors. */ constructor(targetActor) { super(targetActor.conn, threadSpec); // This attribute is used by various other actors to find the target actor this.targetActor = targetActor; this._state = STATES.DETACHED; this._options = { skipBreakpoints: false, }; this._gripDepth = 0; this._targetActorClosed = false; this._observingNetwork = false; this._shouldShowPauseOverlay = true; this._frameActors = []; this._xhrBreakpoints = []; this._dbg = null; this._threadLifetimePool = null; this._activeEventPause = null; this._pauseOverlay = null; this._priorPause = null; this._activeEventBreakpoints = new Set(); this._frameActorMap = new WeakMap(); this._debuggerSourcesSeen = new WeakSet(); // A Set of URLs string to watch for when new sources are found by // the debugger instance. this._onLoadBreakpointURLs = new Set(); // A WeakMap from Debugger.Frame to an exception value which will be ignored // when deciding to pause if the value is thrown by the frame. When we are // pausing on exceptions then we only want to pause when the youngest frame // throws a particular exception, instead of for all older frames as well. this._handledFrameExceptions = new WeakMap(); this._watchpointsMap = new WatchpointMap(this); this.breakpointActorMap = new BreakpointActorMap(this); this._nestedEventLoop = new EventLoop({ thread: this, }); this.onNewSourceEvent = this.onNewSourceEvent.bind(this); this.createCompletionGrip = this.createCompletionGrip.bind(this); this.onDebuggerStatement = this.onDebuggerStatement.bind(this); this.onNewScript = this.onNewScript.bind(this); this.objectGrip = this.objectGrip.bind(this); this.pauseObjectGrip = this.pauseObjectGrip.bind(this); this._onOpeningRequest = this._onOpeningRequest.bind(this); this._onNewDebuggee = this._onNewDebuggee.bind(this); this._onExceptionUnwind = this._onExceptionUnwind.bind(this); this._eventBreakpointListener = this._eventBreakpointListener.bind(this); this._onWindowReady = this._onWindowReady.bind(this); this._onWillNavigate = this._onWillNavigate.bind(this); this._onNavigate = this._onNavigate.bind(this); this.targetActor.on("window-ready", this._onWindowReady); this.targetActor.on("will-navigate", this._onWillNavigate); this.targetActor.on("navigate", this._onNavigate); this._firstStatementBreakpoint = null; this._debuggerNotificationObserver = new DebuggerNotificationObserver(); } // Used by the ObjectActor to keep track of the depth of grip() calls. _gripDepth = null; get dbg() { if (!this._dbg) { this._dbg = this.targetActor.dbg; // Keep the debugger disabled until a client attaches. if (this._state === STATES.DETACHED) { this._dbg.disable(); } else { this._dbg.enable(); } } return this._dbg; } // Current state of the thread actor: // - detached: state, before ThreadActor.attach is called, // - exited: state, after the actor is destroyed, // States possible in between these two states: // - running: default state, when the thread isn't paused, // - paused: state, when paused on any type of breakpoint, or, when the client requested an interrupt. get state() { return this._state; } // XXX: soon to be equivalent to !isDestroyed once the thread actor is initialized on target creation. get attached() { return this.state == STATES.RUNNING || this.state == STATES.PAUSED; } get threadLifetimePool() { if (!this._threadLifetimePool) { this._threadLifetimePool = new Pool(this.conn, "thread"); this._threadLifetimePool.objectActors = new WeakMap(); } return this._threadLifetimePool; } getThreadLifetimeObject(raw) { return this.threadLifetimePool.objectActors.get(raw); } createValueGrip(value) { return createValueGrip(value, this.threadLifetimePool, this.objectGrip); } get sourcesManager() { return this.targetActor.sourcesManager; } get breakpoints() { return this.targetActor.breakpoints; } get youngestFrame() { if (this.state != STATES.PAUSED) { return null; } return this.dbg.getNewestFrame(); } get shouldSkipAnyBreakpoint() { return ( // Disable all types of breakpoints if: // - the user explicitly requested it via the option this._options.skipBreakpoints || // - or when we are evaluating some javascript via the console actor and disableBreaks // has been set to true (which happens for most evaluating except the console input) this.insideClientEvaluation?.disableBreaks ); } isPaused() { return this._state === STATES.PAUSED; } lastPausedPacket() { return this._priorPause; } /** * Remove all debuggees and clear out the thread's sources. */ clearDebuggees() { if (this._dbg) { this.dbg.removeAllDebuggees(); } } /** * Destroy the debugger and put the actor in the exited state. * * As part of destroy, we: clean up listeners, debuggees and * clear actor pools associated with the lifetime of this actor. */ destroy() { dumpn("in ThreadActor.prototype.destroy"); if (this._state == STATES.PAUSED) { this.doResume(); } this.removeAllWatchpoints(); this._xhrBreakpoints = []; this._updateNetworkObserver(); this._activeEventBreakpoints = new Set(); this._debuggerNotificationObserver.removeListener( this._eventBreakpointListener ); for (const global of this.dbg.getDebuggees()) { try { this._debuggerNotificationObserver.disconnect( global.unsafeDereference() ); } catch (e) {} } this.targetActor.off("window-ready", this._onWindowReady); this.targetActor.off("will-navigate", this._onWillNavigate); this.targetActor.off("navigate", this._onNavigate); this.sourcesManager.off("newSource", this.onNewSourceEvent); this.clearDebuggees(); this._threadLifetimePool.destroy(); this._threadLifetimePool = null; this._dbg = null; this._state = STATES.EXITED; super.destroy(); } /** * Tells if the thread actor has been initialized/attached on target creation * by the server codebase. (And not late, from the frontend, by the TargetMixinFront class) */ isAttached() { return !!this.alreadyAttached; } // Request handlers attach(options) { // Note that the client avoids trying to call attach if already attached. // But just in case, avoid any possible duplicate call to attach. if (this.alreadyAttached) { return; } if (this.state === STATES.EXITED) { throw { error: "exited", message: "threadActor has exited", }; } if (this.state !== STATES.DETACHED) { throw { error: "wrongState", message: "Current state is " + this.state, }; } this.dbg.onDebuggerStatement = this.onDebuggerStatement; this.dbg.onNewScript = this.onNewScript; this.dbg.onNewDebuggee = this._onNewDebuggee; this.sourcesManager.on("newSource", this.onNewSourceEvent); this.reconfigure(options); // Switch state from DETACHED to RUNNING this._state = STATES.RUNNING; this.alreadyAttached = true; this.dbg.enable(); if (Services.obs) { // Set a wrappedJSObject property so |this| can be sent via the observer service // for the xpcshell harness. this.wrappedJSObject = this; Services.obs.notifyObservers(this, "devtools-thread-ready"); } } toggleEventLogging(logEventBreakpoints) { this._options.logEventBreakpoints = logEventBreakpoints; return this._options.logEventBreakpoints; } get pauseOverlay() { if (this._pauseOverlay) { return this._pauseOverlay; } const env = new HighlighterEnvironment(); env.initFromTargetActor(this.targetActor); const highlighter = new PausedDebuggerOverlay(env, { resume: () => this.resume(null), stepOver: () => this.resume({ type: "next" }), }); this._pauseOverlay = highlighter; return highlighter; } _canShowOverlay() { // Only attempt to show on overlay on WindowGlobal targets, which displays a document. // Workers and content processes can't display any overlay. if (this.targetActor.targetType != Targets.TYPES.FRAME) { return false; } const { window } = this.targetActor; // The CanvasFrameAnonymousContentHelper class we're using to create the paused overlay // need to have access to a documentElement. // We might have access to a non-chrome window getter that is a Sandox (e.g. in the // case of ContentProcessTargetActor). if (!window?.document?.documentElement) { return false; } // Ignore privileged document (top level window, special about:* pages, …). if (window.isChromeWindow) { return false; } return true; } async showOverlay() { if ( !this._shouldShowPauseOverlay || !this.isPaused() || !this._canShowOverlay() ) { return; } const reason = this._priorPause.why.type; await this.pauseOverlay.isReady; // we might not be paused anymore. if (!this.isPaused()) { return; } this.pauseOverlay.show(reason); } hideOverlay() { if (this._canShowOverlay() && this._pauseOverlay) { this.pauseOverlay.hide(); } } /** * Tell the thread to automatically add a breakpoint on the first line of * a given file, when it is first loaded. * * This is currently only used by the xpcshell test harness, and unless * we decide to expand the scope of this feature, we should keep it that way. */ setBreakpointOnLoad(urls) { this._onLoadBreakpointURLs = new Set(urls); } _findXHRBreakpointIndex(p, m) { return this._xhrBreakpoints.findIndex( ({ path, method }) => path === p && method === m ); } // We clear the priorPause field when a breakpoint is added or removed // at the same location because we are no longer worried about pausing twice // at that location (e.g. debugger statement, stepping). _maybeClearPriorPause(location) { if (!this._priorPause) { return; } const { where } = this._priorPause.frame; if (where.line === location.line && where.column === location.column) { this._priorPause = null; } } async setBreakpoint(location, options) { // Automatically initialize the thread actor if it wasn't yet done. // Note that ideally, it should rather be done via reconfigure/thread configuration. if (this._state === STATES.DETACHED) { this.attach({}); this.addAllSources(); } let actor = this.breakpointActorMap.get(location); // Avoid resetting the exact same breakpoint twice if (actor && JSON.stringify(actor.options) == JSON.stringify(options)) { return; } if (!actor) { actor = this.breakpointActorMap.getOrCreateBreakpointActor(location); } actor.setOptions(options); this._maybeClearPriorPause(location); if (location.sourceUrl) { // There can be multiple source actors for a URL if there are multiple // inline sources on an HTML page. const sourceActors = this.sourcesManager.getSourceActorsByURL( location.sourceUrl ); for (const sourceActor of sourceActors) { await sourceActor.applyBreakpoint(actor); } } else { const sourceActor = this.sourcesManager.getSourceActorById( location.sourceId ); if (sourceActor) { await sourceActor.applyBreakpoint(actor); } } } removeBreakpoint(location) { const actor = this.breakpointActorMap.getOrCreateBreakpointActor(location); this._maybeClearPriorPause(location); actor.delete(); } removeAllXHRBreakpoints() { this._xhrBreakpoints = []; return this._updateNetworkObserver(); } removeXHRBreakpoint(path, method) { const index = this._findXHRBreakpointIndex(path, method); if (index >= 0) { this._xhrBreakpoints.splice(index, 1); } return this._updateNetworkObserver(); } setXHRBreakpoint(path, method) { // request.path is a string, // If requested url contains the path, then we pause. const index = this._findXHRBreakpointIndex(path, method); if (index === -1) { this._xhrBreakpoints.push({ path, method }); } return this._updateNetworkObserver(); } getAvailableEventBreakpoints() { return getAvailableEventBreakpoints( this.targetActor.window || this.targetActor.workerGlobal ); } getActiveEventBreakpoints() { return Array.from(this._activeEventBreakpoints); } /** * Add event breakpoints to the list of active event breakpoints * * @param {Array} ids: events to add (e.g. ["event.mouse.click","event.mouse.mousedown"]) */ addEventBreakpoints(ids) { this.setActiveEventBreakpoints( this.getActiveEventBreakpoints().concat(ids) ); } /** * Remove event breakpoints from the list of active event breakpoints * * @param {Array} ids: events to remove (e.g. ["event.mouse.click","event.mouse.mousedown"]) */ removeEventBreakpoints(ids) { this.setActiveEventBreakpoints( this.getActiveEventBreakpoints().filter(eventBp => !ids.includes(eventBp)) ); } /** * Set the the list of active event breakpoints * * @param {Array} ids: events to add breakpoint for (e.g. ["event.mouse.click","event.mouse.mousedown"]) */ setActiveEventBreakpoints(ids) { this._activeEventBreakpoints = new Set(ids); if (eventsRequireNotifications(ids)) { this._debuggerNotificationObserver.addListener( this._eventBreakpointListener ); } else { this._debuggerNotificationObserver.removeListener( this._eventBreakpointListener ); } if (this._activeEventBreakpoints.has(firstStatementBreakpointId())) { this._ensureFirstStatementBreakpointInitialized(); this._firstStatementBreakpoint.hit = frame => this._pauseAndRespondEventBreakpoint( frame, firstStatementBreakpointId() ); } else if (this._firstStatementBreakpoint) { // Disabling the breakpoint disables the feature as much as we need it // to. We do not bother removing breakpoints from the scripts themselves // here because the breakpoints will be a no-op if `hit` is `null`, and // if we wanted to remove them, we'd need a way to iterate through them // all, which would require us to hold strong references to them, which // just isn't needed. Plus, if the user disables and then re-enables the // feature again later, the breakpoints will still be there to work. this._firstStatementBreakpoint.hit = null; } } _ensureFirstStatementBreakpointInitialized() { if (this._firstStatementBreakpoint) { return; } this._firstStatementBreakpoint = { hit: null }; for (const script of this.dbg.findScripts()) { this._maybeTrackFirstStatementBreakpoint(script); } } _maybeTrackFirstStatementBreakpointForNewGlobal(global) { if (this._firstStatementBreakpoint) { for (const script of this.dbg.findScripts({ global })) { this._maybeTrackFirstStatementBreakpoint(script); } } } _maybeTrackFirstStatementBreakpoint(script) { if ( // If the feature is not enabled yet, there is nothing to do. !this._firstStatementBreakpoint || // WASM files don't have a first statement. script.format !== "js" || // All "top-level" scripts are non-functions, whether that's because // the script is a module, a global script, or an eval or what. script.isFunction ) { return; } const bps = script.getPossibleBreakpoints(); // Scripts aren't guaranteed to have a step start if for instance the // file contains only function declarations, so in that case we try to // fall back to whatever we can find. let meta = bps.find(bp => bp.isStepStart) || bps[0]; if (!meta) { // We've tried to avoid using `getAllColumnOffsets()` because the set of // locations included in this list is very under-defined, but for this // usecase it's not the end of the world. Maybe one day we could have an // "onEnterFrame" that was scoped to a specific script to avoid this. meta = script.getAllColumnOffsets()[0]; } if (!meta) { // Not certain that this is actually possible, but including for sanity // so that we don't throw unexpectedly. return; } script.setBreakpoint(meta.offset, this._firstStatementBreakpoint); } _onNewDebuggee(global) { this._maybeTrackFirstStatementBreakpointForNewGlobal(global); try { this._debuggerNotificationObserver.connect(global.unsafeDereference()); } catch (e) {} } _updateNetworkObserver() { // Workers don't have access to `Services` and even if they did, network // requests are all dispatched to the main thread, so there would be // nothing here to listen for. We'll need to revisit implementing // XHR breakpoints for workers. if (isWorker) { return false; } if (this._xhrBreakpoints.length && !this._observingNetwork) { this._observingNetwork = true; Services.obs.addObserver( this._onOpeningRequest, "http-on-opening-request" ); } else if (this._xhrBreakpoints.length === 0 && this._observingNetwork) { this._observingNetwork = false; Services.obs.removeObserver( this._onOpeningRequest, "http-on-opening-request" ); } return true; } _onOpeningRequest(subject) { if (this.shouldSkipAnyBreakpoint) { return; } const channel = subject.QueryInterface(Ci.nsIHttpChannel); const url = channel.URI.asciiSpec; const requestMethod = channel.requestMethod; let causeType = Ci.nsIContentPolicy.TYPE_OTHER; if (channel.loadInfo) { causeType = channel.loadInfo.externalContentPolicyType; } const isXHR = causeType === Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST || causeType === Ci.nsIContentPolicy.TYPE_FETCH; if (!isXHR) { // We currently break only if the request is either fetch or xhr return; } let shouldPause = false; for (const { path, method } of this._xhrBreakpoints) { if (method !== "ANY" && method !== requestMethod) { continue; } if (url.includes(path)) { shouldPause = true; break; } } if (shouldPause) { const frame = this.dbg.getNewestFrame(); // If there is no frame, this request was dispatched by logic that isn't // primarily JS, so pausing the event loop wouldn't make sense. // This covers background requests like loading the initial page document, // or loading favicons. This also includes requests dispatched indirectly // from workers. We'll need to handle them separately in the future. if (frame) { this._pauseAndRespond(frame, { type: PAUSE_REASONS.XHR }); } } } reconfigure(options = {}) { if (this.state == STATES.EXITED) { throw { error: "wrongState", }; } this._options = { ...this._options, ...options }; if ("observeAsmJS" in options) { this.dbg.allowUnobservedAsmJS = !options.observeAsmJS; } if ("observeWasm" in options) { this.dbg.allowUnobservedWasm = !options.observeWasm; } if ("pauseOverlay" in options) { this._shouldShowPauseOverlay = !!options.pauseOverlay; } if ( "pauseWorkersUntilAttach" in options && this.targetActor.pauseWorkersUntilAttach ) { this.targetActor.pauseWorkersUntilAttach(options.pauseWorkersUntilAttach); } if (options.breakpoints) { for (const breakpoint of Object.values(options.breakpoints)) { this.setBreakpoint(breakpoint.location, breakpoint.options); } } if (options.eventBreakpoints) { this.setActiveEventBreakpoints(options.eventBreakpoints); } // Only consider this options if an explicit boolean value is passed. if (typeof this._options.shouldPauseOnDebuggerStatement == "boolean") { this.setPauseOnDebuggerStatement( this._options.shouldPauseOnDebuggerStatement ); } this.setPauseOnExceptions(this._options.pauseOnExceptions); } _eventBreakpointListener(notification) { if (this._state === STATES.PAUSED || this._state === STATES.DETACHED) { return; } const eventBreakpoint = eventBreakpointForNotification( this.dbg, notification ); if (!this._activeEventBreakpoints.has(eventBreakpoint)) { return; } if (notification.phase === "pre" && !this._activeEventPause) { this._activeEventPause = this._captureDebuggerHooks(); this.dbg.onEnterFrame = this._makeEventBreakpointEnterFrame(eventBreakpoint); } else if (notification.phase === "post" && this._activeEventPause) { this._restoreDebuggerHooks(this._activeEventPause); this._activeEventPause = null; } else if (!notification.phase && !this._activeEventPause) { const frame = this.dbg.getNewestFrame(); if (frame) { if (this.sourcesManager.isFrameBlackBoxed(frame)) { return; } this._pauseAndRespondEventBreakpoint(frame, eventBreakpoint); } } } _makeEventBreakpointEnterFrame(eventBreakpoint) { return frame => { if (this.sourcesManager.isFrameBlackBoxed(frame)) { return undefined; } this._restoreDebuggerHooks(this._activeEventPause); this._activeEventPause = null; return this._pauseAndRespondEventBreakpoint(frame, eventBreakpoint); }; } _pauseAndRespondEventBreakpoint(frame, eventBreakpoint) { if (this.shouldSkipAnyBreakpoint) { return undefined; } if (this._options.logEventBreakpoints) { return logEvent({ threadActor: this, frame, level: "logPoint", expression: `[_event]`, bindings: { _event: frame.arguments[0] }, }); } return this._pauseAndRespond(frame, { type: PAUSE_REASONS.EVENT_BREAKPOINT, breakpoint: eventBreakpoint, message: makeEventBreakpointMessage(eventBreakpoint), }); } _captureDebuggerHooks() { return { onEnterFrame: this.dbg.onEnterFrame, onStep: this.dbg.onStep, onPop: this.dbg.onPop, }; } _restoreDebuggerHooks(hooks) { this.dbg.onEnterFrame = hooks.onEnterFrame; this.dbg.onStep = hooks.onStep; this.dbg.onPop = hooks.onPop; } /** * Pause the debuggee, by entering a nested event loop, and return a 'paused' * packet to the client. * * @param Debugger.Frame frame * The newest debuggee frame in the stack. * @param object reason * An object with a 'type' property containing the reason for the pause. * @param function onPacket * Hook to modify the packet before it is sent. Feel free to return a * promise. */ _pauseAndRespond(frame, reason, onPacket = k => k) { try { const packet = this._paused(frame); if (!packet) { return undefined; } const { sourceActor, line, column } = this.sourcesManager.getFrameLocation(frame); packet.why = reason; if (!sourceActor) { // If the frame location is in a source that not pass the 'isHiddenSource' // check and thus has no actor, we do not bother pausing. return undefined; } packet.frame.where = { actor: sourceActor.actorID, line, column, }; const pkt = onPacket(packet); this._priorPause = pkt; this.emit("paused", pkt); this.showOverlay(); } catch (error) { reportException("DBG-SERVER", error); this.conn.send({ error: "unknownError", message: error.message + "\n" + error.stack, }); return undefined; } try { this._nestedEventLoop.enter(); } catch (e) { reportException("TA__pauseAndRespond", e); } if (this._requestedFrameRestart) { return null; } // If the parent actor has been closed, terminate the debuggee script // instead of continuing. Executing JS after the content window is gone is // a bad idea. return this._targetActorClosed ? null : undefined; } _makeOnEnterFrame() { return frame => { if (this._requestedFrameRestart) { return null; } // Continue forward until we get to a valid step target. const { onStep, onPop } = this._makeSteppingHooks({ steppingType: "next", }); if (this.sourcesManager.isFrameBlackBoxed(frame)) { return undefined; } frame.onStep = onStep; frame.onPop = onPop; return undefined; }; } _makeOnPop({ pauseAndRespond, steppingType }) { const thread = this; return function (completion) { if (thread._requestedFrameRestart === this) { return thread.restartFrame(this); } // onPop is called when we temporarily leave an async/generator if (steppingType != "finish" && (completion.await || completion.yield)) { thread.suspendedFrame = this; thread.dbg.onEnterFrame = undefined; return undefined; } // Note that we're popping this frame; we need to watch for // subsequent step events on its caller. this.reportedPop = true; // Cache the frame so that the onPop and onStep hooks are cleared // on the next pause. thread.suspendedFrame = this; if ( steppingType != "finish" && !thread.sourcesManager.isFrameBlackBoxed(this) ) { const pauseAndRespValue = pauseAndRespond(this, packet => thread.createCompletionGrip(packet, completion) ); // If the requested frame to restart differs from this frame, we don't // need to restart it at this point. if (thread._requestedFrameRestart === this) { return thread.restartFrame(this); } return pauseAndRespValue; } thread._attachSteppingHooks(this, "next", completion); return undefined; }; } restartFrame(frame) { this._requestedFrameRestart = null; this._priorPause = null; if ( frame.type !== "call" || frame.script.isGeneratorFunction || frame.script.isAsyncFunction ) { return undefined; } RESTARTED_FRAMES.add(frame); const completion = frame.callee.apply(frame.this, frame.arguments); return completion; } hasMoved(frame, newType) { const newLocation = this.sourcesManager.getFrameLocation(frame); if (!this._priorPause) { return true; } // Recursion/Loops makes it okay to resume and land at // the same breakpoint or debugger statement. // It is not okay to transition from a breakpoint to debugger statement // or a step to a debugger statement. const { type } = this._priorPause.why; // Conditional breakpoint are doing something weird as they are using "breakpoint" type // unless they throw in which case they will be "breakpointConditionThrown". if ( type == newType || (type == "breakpointConditionThrown" && newType == "breakpoint") ) { return true; } const { line, column } = this._priorPause.frame.where; return line !== newLocation.line || column !== newLocation.column; } _makeOnStep({ pauseAndRespond, startFrame, completion }) { const thread = this; return function () { if (thread._validFrameStepOffset(this, startFrame, this.offset)) { return pauseAndRespond(this, packet => thread.createCompletionGrip(packet, completion) ); } return undefined; }; } _validFrameStepOffset(frame, startFrame, offset) { const meta = frame.script.getOffsetMetadata(offset); // Continue if: // 1. the location is not a valid breakpoint position // 2. the source is blackboxed // 3. we have not moved since the last pause if ( !meta.isBreakpoint || this.sourcesManager.isFrameBlackBoxed(frame) || !this.hasMoved(frame) ) { return false; } // Pause if: // 1. the frame has changed // 2. the location is a step position. return frame !== startFrame || meta.isStepStart; } atBreakpointLocation(frame) { const location = this.sourcesManager.getFrameLocation(frame); return !!this.breakpointActorMap.get(location); } createCompletionGrip(packet, completion) { if (!completion) { return packet; } const createGrip = value => createValueGrip(value, this._pausePool, this.objectGrip); packet.why.frameFinished = {}; if (completion.hasOwnProperty("return")) { packet.why.frameFinished.return = createGrip(completion.return); } else if (completion.hasOwnProperty("yield")) { packet.why.frameFinished.return = createGrip(completion.yield); } else if (completion.hasOwnProperty("throw")) { packet.why.frameFinished.throw = createGrip(completion.throw); } return packet; } /** * Define the JS hook functions for stepping. */ _makeSteppingHooks({ steppingType, startFrame, completion }) { // Bind these methods and state because some of the hooks are called // with 'this' set to the current frame. Rather than repeating the // binding in each _makeOnX method, just do it once here and pass it // in to each function. const steppingHookState = { pauseAndRespond: (frame, onPacket = k => k) => this._pauseAndRespond( frame, { type: PAUSE_REASONS.RESUME_LIMIT }, onPacket ), startFrame: startFrame || this.youngestFrame, steppingType, completion, }; return { onEnterFrame: this._makeOnEnterFrame(steppingHookState), onPop: this._makeOnPop(steppingHookState), onStep: this._makeOnStep(steppingHookState), }; } /** * Handle attaching the various stepping hooks we need to attach when we * receive a resume request with a resumeLimit property. * * @param Object { resumeLimit } * The values received over the RDP. * @returns A promise that resolves to true once the hooks are attached, or is * rejected with an error packet. */ async _handleResumeLimit({ resumeLimit, frameActorID }) { const steppingType = resumeLimit.type; if ( !["break", "step", "next", "finish", "restart"].includes(steppingType) ) { return Promise.reject({ error: "badParameterType", message: "Unknown resumeLimit type", }); } let frame = this.youngestFrame; if (frameActorID) { frame = this._framesPool.getActorByID(frameActorID).frame; if (!frame) { throw new Error("Frame should exist in the frames pool."); } } if (steppingType === "restart") { if ( frame.type !== "call" || frame.script.isGeneratorFunction || frame.script.isAsyncFunction ) { return undefined; } this._requestedFrameRestart = frame; } return this._attachSteppingHooks(frame, steppingType, undefined); } _attachSteppingHooks(frame, steppingType, completion) { // If we are stepping out of the onPop handler, we want to use "next" mode // so that the parent frame's handlers behave consistently. if (steppingType === "finish" && frame.reportedPop) { steppingType = "next"; } // If there are no more frames on the stack, use "step" mode so that we will // pause on the next script to execute. const stepFrame = this._getNextStepFrame(frame); if (!stepFrame) { steppingType = "step"; } const { onEnterFrame, onPop, onStep } = this._makeSteppingHooks({ steppingType, completion, startFrame: frame, }); if (steppingType === "step" || steppingType === "restart") { this.dbg.onEnterFrame = onEnterFrame; } if (stepFrame) { switch (steppingType) { case "step": case "break": case "next": if (stepFrame.script) { if (!this.sourcesManager.isFrameBlackBoxed(stepFrame)) { stepFrame.onStep = onStep; } } // eslint-disable-next-line no-fallthrough case "finish": stepFrame.onStep = createStepForReactionTracking(stepFrame.onStep); // eslint-disable-next-line no-fallthrough case "restart": stepFrame.onPop = onPop; break; } } return true; } /** * Clear the onStep and onPop hooks for all frames on the stack. */ _clearSteppingHooks() { if (this.suspendedFrame) { this.suspendedFrame.onStep = undefined; this.suspendedFrame.onPop = undefined; this.suspendedFrame = undefined; } let frame = this.youngestFrame; if (frame?.onStack) { while (frame) { frame.onStep = undefined; frame.onPop = undefined; frame = frame.older; } } } /** * Handle a protocol request to resume execution of the debuggee. */ async resume(resumeLimit, frameActorID) { if (this._state !== STATES.PAUSED) { return { error: "wrongState", message: "Can't resume when debuggee isn't paused. Current state is '" + this._state + "'", state: this._state, }; } // In case of multiple nested event loops (due to multiple debuggers open in // different tabs or multiple devtools clients connected to the same tab) // only allow resumption in a LIFO order. if (!this._nestedEventLoop.isTheLastPausedThreadActor()) { return { error: "wrongOrder", message: "trying to resume in the wrong order.", }; } try { if (resumeLimit) { await this._handleResumeLimit({ resumeLimit, frameActorID }); } else { this._clearSteppingHooks(); } this.doResume({ resumeLimit }); return {}; } catch (error) { return error instanceof Error ? { error: "unknownError", message: DevToolsUtils.safeErrorString(error), } : // It is a known error, and the promise was rejected with an error // packet. error; } } /** * Only resume and notify necessary observers. This should be used in cases * when we do not want to notify the front end of a resume, for example when * we are shutting down. */ doResume() { this._state = STATES.RUNNING; // Drop the actors in the pause actor pool. this._pausePool.destroy(); this._pausePool = null; this._pauseActor = null; this._nestedEventLoop.exit(); // Tell anyone who cares of the resume (as of now, that's the xpcshell harness and // devtools-startup.js when handling the --wait-for-jsdebugger flag) this.emit("resumed"); this.hideOverlay(); } /** * Set the debugging hook to pause on exceptions if configured to do so. * * Note that this is also called when evaluating conditional breakpoints. * * @param {Boolean} doPause * Should watch for pause or not. `_onExceptionUnwind` function will * then be notified about new caught or uncaught exception being fired. */ setPauseOnExceptions(doPause) { if (doPause) { this.dbg.onExceptionUnwind = this._onExceptionUnwind; } else { this.dbg.onExceptionUnwind = undefined; } } /** * Set the debugging hook to pause on debugger statement if configured to do so. * * Note that the thread actor will pause on exception by default. * This method has to be called with a falsy value to disable it. * * @param {Boolean} doPause * Controls whether we should or should not pause on debugger statement. */ setPauseOnDebuggerStatement(doPause) { this.dbg.onDebuggerStatement = doPause ? this.onDebuggerStatement : undefined; } isPauseOnExceptionsEnabled() { return this.dbg.onExceptionUnwind == this._onExceptionUnwind; } /** * Helper method that returns the next frame when stepping. */ _getNextStepFrame(frame) { const endOfFrame = frame.reportedPop; const stepFrame = endOfFrame ? frame.older || getAsyncParentFrame(frame) : frame; if (!stepFrame || !stepFrame.script) { return null; } // Skips a frame that has been restarted. if (RESTARTED_FRAMES.has(stepFrame)) { return this._getNextStepFrame(stepFrame.older); } return stepFrame; } frames(start, count) { if (this.state !== STATES.PAUSED) { return { error: "wrongState", message: "Stack frames are only available while the debuggee is paused.", }; } // Find the starting frame... let frame = this.youngestFrame; const walkToParentFrame = () => { if (!frame) { return; } const currentFrame = frame; frame = null; if (!(currentFrame instanceof Debugger.Frame)) { frame = getSavedFrameParent(this, currentFrame); } else if (currentFrame.older) { frame = currentFrame.older; } else if ( this._options.shouldIncludeSavedFrames && currentFrame.olderSavedFrame ) { frame = currentFrame.olderSavedFrame; if (frame && !isValidSavedFrame(this, frame)) { frame = null; } } else if ( this._options.shouldIncludeAsyncLiveFrames && currentFrame.asyncPromise ) { const asyncFrame = getAsyncParentFrame(currentFrame); if (asyncFrame) { frame = asyncFrame; } } }; let i = 0; while (frame && i < start) { walkToParentFrame(); i++; } // Return count frames, or all remaining frames if count is not defined. const frames = []; for (; frame && (!count || i < start + count); i++, walkToParentFrame()) { // SavedFrame instances don't have direct Debugger.Source object. If // there is an active Debugger.Source that represents the SaveFrame's // source, it will have already been created in the server. if (frame instanceof Debugger.Frame) { this.sourcesManager.createSourceActor(frame.script.source); } if (RESTARTED_FRAMES.has(frame)) { continue; } const frameActor = this._createFrameActor(frame, i); frames.push(frameActor); } return { frames }; } addAllSources() { // Compare the sources we find with the source URLs which have been loaded // in debuggee realms. Count the number of sources associated with each // URL so that we can detect if an HTML file has had some inline sources // collected but not all. const urlMap = {}; for (const url of this.dbg.findSourceURLs()) { if (url !== "self-hosted") { if (!urlMap[url]) { urlMap[url] = { count: 0, sources: [] }; } urlMap[url].count++; } } const sources = this.dbg.findSources(); for (const source of sources) { this._addSource(source); // The following check should match the filtering done by `findSourceURLs`: // https://searchfox.org/mozilla-central/rev/ac7a567f036e1954542763f4722fbfce041fb752/js/src/debugger/Debugger.cpp#2406-2409 // Otherwise we may populate `urlMap` incorrectly and resurrect sources that weren't GCed, // and spawn duplicated SourceActors/Debugger.Source for the same actual source. // `findSourceURLs` uses !introductionScript check as that allows to identify