diff options
Diffstat (limited to 'devtools/server/actors/thread.js')
-rw-r--r-- | devtools/server/actors/thread.js | 2324 |
1 files changed, 2324 insertions, 0 deletions
diff --git a/devtools/server/actors/thread.js b/devtools/server/actors/thread.js new file mode 100644 index 0000000000..ecf79abfe5 --- /dev/null +++ b/devtools/server/actors/thread.js @@ -0,0 +1,2324 @@ +/* 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"); + +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 parent TargetActor + * This |ThreadActor|'s parent actor. i.e. one of the many Target actors. + * @param aGlobal object [optional] + * An optional (for content debugging only) reference to the content + * window. + */ + constructor(parent, global) { + super(parent.conn, threadSpec); + + this._state = STATES.DETACHED; + this._parent = parent; + this.global = global; + this._options = { + skipBreakpoints: false, + }; + this._gripDepth = 0; + this._parentClosed = false; + this._observingNetwork = false; + 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._parent.on("window-ready", this._onWindowReady); + this._parent.on("will-navigate", this._onWillNavigate); + this._parent.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._parent.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._parent.sourcesManager; + } + + get breakpoints() { + return this._parent.breakpoints; + } + + get youngestFrame() { + if (this.state != STATES.PAUSED) { + return null; + } + return this.dbg.getNewestFrame(); + } + + get skipBreakpointsOption() { + return ( + this._options.skipBreakpoints || + (this.insideClientEvaluation && this.insideClientEvaluation.eager) + ); + } + + 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._parent.off("window-ready", this._onWindowReady); + this._parent.off("will-navigate", this._onWillNavigate); + this._parent.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(); + + // Notify the parent that we've finished attaching. If this is a worker + // thread which was paused until attaching, this will allow content to + // begin executing. + if (this._parent.onThreadAttached) { + this._parent.onThreadAttached(); + } + 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._parent); + const highlighter = new PausedDebuggerOverlay(env, { + resume: () => this.resume(null), + stepOver: () => this.resume({ type: "next" }), + }); + this._pauseOverlay = highlighter; + return highlighter; + } + + _canShowOverlay() { + const { window } = this._parent; + + // 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.isPaused() && + this._canShowOverlay() && + this._parent.on && + this.pauseOverlay + ) { + 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) { + 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(); + } + + 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._parent.window); + } + getActiveEventBreakpoints() { + return Array.from(this._activeEventBreakpoints); + } + + /** + * Add event breakpoints to the list of active event breakpoints + * + * @param {Array<String>} 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<String>} 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<String>} 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.skipBreakpointsOption) { + 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 ( + "pauseWorkersUntilAttach" in options && + this._parent.pauseWorkersUntilAttach + ) { + this._parent.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); + } + + this.maybePauseOnExceptions(); + } + + _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.skipBreakpointsOption) { + 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._parentClosed ? null : undefined; + } + + _makeOnEnterFrame({ pauseAndRespond }) { + 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; + + if (type == newType) { + return true; + } + + const { line, column } = this._priorPause.frame.where; + return line !== newLocation.line || column !== newLocation.column; + } + + _makeOnStep({ pauseAndRespond, startFrame, steppingType, 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({ resumeLimit } = {}) { + 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. + */ + maybePauseOnExceptions() { + if (this._options.pauseOnExceptions) { + this.dbg.onExceptionUnwind = this._onExceptionUnwind; + } else { + this.dbg.onExceptionUnwind = undefined; + } + } + + /** + * 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 <script>'s + // loaded from the HTML page. This boolean will be defined only when the <script> tag + // is added by Javascript code at runtime. + // https://searchfox.org/mozilla-central/rev/3d03a3ca09f03f06ef46a511446537563f62a0c6/devtools/docs/user/debugger-api/debugger.source/index.rst#113 + if (!source.introductionScript && urlMap[source.url]) { + urlMap[source.url].count--; + urlMap[source.url].sources.push(source); + } + } + + // Resurrect any URLs for which not all sources are accounted for. + for (const [url, data] of Object.entries(urlMap)) { + if (data.count > 0) { + this._resurrectSource(url, data.sources); + } + } + } + + sources(request) { + this.addAllSources(); + + // No need to flush the new source packets here, as we are sending the + // list of sources out immediately and we don't need to invoke the + // overhead of an RDP packet for every source right now. Let the default + // timeout flush the buffered packets. + + return this.sourcesManager.iter().map(s => s.form()); + } + + /** + * Disassociate all breakpoint actors from their scripts and clear the + * breakpoint handlers. This method can be used when the thread actor intends + * to keep the breakpoint store, but needs to clear any actual breakpoints, + * e.g. due to a page navigation. This way the breakpoint actors' script + * caches won't hold on to the Debugger.Script objects leaking memory. + */ + disableAllBreakpoints() { + for (const bpActor of this.breakpointActorMap.findActors()) { + bpActor.removeScripts(); + } + } + + removeAllWatchpoints() { + for (const actor of this.threadLifetimePool.poolChildren()) { + if (actor.typeName == "obj") { + actor.removeWatchpoints(); + } + } + } + + addWatchpoint(objActor, data) { + this._watchpointsMap.add(objActor, data); + } + + removeWatchpoint(objActor, property) { + this._watchpointsMap.remove(objActor, property); + } + + getWatchpoint(obj, property) { + return this._watchpointsMap.get(obj, property); + } + + /** + * Handle a protocol request to pause the debuggee. + */ + interrupt(when) { + if (this.state == STATES.EXITED) { + return { type: "exited" }; + } else if (this.state == STATES.PAUSED) { + // TODO: return the actual reason for the existing pause. + this.emit("paused", { + why: { type: PAUSE_REASONS.ALREADY_PAUSED }, + }); + return {}; + } else if (this.state != STATES.RUNNING) { + return { + error: "wrongState", + message: "Received interrupt request in " + this.state + " state.", + }; + } + try { + // If execution should pause just before the next JavaScript bytecode is + // executed, just set an onEnterFrame handler. + if (when == "onNext") { + const onEnterFrame = frame => { + this._pauseAndRespond(frame, { + type: PAUSE_REASONS.INTERRUPTED, + onNext: true, + }); + }; + this.dbg.onEnterFrame = onEnterFrame; + return {}; + } + + // If execution should pause immediately, just put ourselves in the paused + // state. + const packet = this._paused(); + if (!packet) { + return { error: "notInterrupted" }; + } + packet.why = { type: PAUSE_REASONS.INTERRUPTED, onNext: false }; + + // Send the response to the interrupt request now (rather than + // returning it), because we're going to start a nested event loop + // here. + this.conn.send({ from: this.actorID, type: "interrupt" }); + this.emit("paused", packet); + + // Start a nested event loop. + this._nestedEventLoop.enter(); + + // We already sent a response to this request, don't send one + // now. + return null; + } catch (e) { + reportException("DBG-SERVER", e); + return { error: "notInterrupted", message: e.toString() }; + } + } + + _paused(frame) { + // We don't handle nested pauses correctly. Don't try - if we're + // paused, just continue running whatever code triggered the pause. + // We don't want to actually have nested pauses (although we + // have nested event loops). If code runs in the debuggee during + // a pause, it should cause the actor to resume (dropping + // pause-lifetime actors etc) and then repause when complete. + + if (this.state === STATES.PAUSED) { + return undefined; + } + + this._state = STATES.PAUSED; + + // Clear stepping hooks. + this.dbg.onEnterFrame = undefined; + this._requestedFrameRestart = null; + this._clearSteppingHooks(); + + // Create the actor pool that will hold the pause actor and its + // children. + assert(!this._pausePool, "No pause pool should exist yet"); + this._pausePool = new Pool(this.conn, "pause"); + + // Give children of the pause pool a quick link back to the + // thread... + this._pausePool.threadActor = this; + + // Create the pause actor itself... + assert(!this._pauseActor, "No pause actor should exist yet"); + this._pauseActor = new PauseActor(this._pausePool); + this._pausePool.manage(this._pauseActor); + + // Update the list of frames. + this._updateFrames(); + + // Send off the paused packet and spin an event loop. + const packet = { + actor: this._pauseActor.actorID, + }; + + if (frame) { + packet.frame = this._createFrameActor(frame); + } + + return packet; + } + + /** + * Expire frame actors for frames that are no longer on the current stack. + */ + _updateFrames() { + // Create the actor pool that will hold the still-living frames. + const framesPool = new Pool(this.conn, "frames"); + const frameList = []; + + for (const frameActor of this._frameActors) { + if (frameActor.frame.onStack) { + framesPool.manage(frameActor); + frameList.push(frameActor); + } + } + + // Remove the old frame actor pool, this will expire + // any actors that weren't added to the new pool. + if (this._framesPool) { + this._framesPool.destroy(); + } + + this._frameActors = frameList; + this._framesPool = framesPool; + } + + _createFrameActor(frame, depth) { + let actor = this._frameActorMap.get(frame); + if (!actor || actor.isDestroyed()) { + actor = new FrameActor(frame, this, depth); + this._frameActors.push(actor); + this._framesPool.manage(actor); + + this._frameActorMap.set(frame, actor); + } + return actor; + } + + /** + * Create and return an environment actor that corresponds to the provided + * Debugger.Environment. + * @param Debugger.Environment environment + * The lexical environment we want to extract. + * @param object pool + * The pool where the newly-created actor will be placed. + * @return The EnvironmentActor for environment or undefined for host + * functions or functions scoped to a non-debuggee global. + */ + createEnvironmentActor(environment, pool) { + if (!environment) { + return undefined; + } + + if (environment.actor) { + return environment.actor; + } + + const actor = new EnvironmentActor(environment, this); + pool.manage(actor); + environment.actor = actor; + + return actor; + } + + /** + * Create a grip for the given debuggee object. + * + * @param value Debugger.Object + * The debuggee object value. + * @param pool Pool + * The actor pool where the new object actor will be added. + */ + objectGrip(value, pool) { + if (!pool.objectActors) { + pool.objectActors = new WeakMap(); + } + + if (pool.objectActors.has(value)) { + return pool.objectActors.get(value).form(); + } + + if (this.threadLifetimePool.objectActors.has(value)) { + return this.threadLifetimePool.objectActors.get(value).form(); + } + + const actor = new PauseScopedObjectActor( + value, + { + thread: this, + getGripDepth: () => this._gripDepth, + incrementGripDepth: () => this._gripDepth++, + decrementGripDepth: () => this._gripDepth--, + createValueGrip: v => { + if (this._pausePool) { + return createValueGrip(v, this._pausePool, this.pauseObjectGrip); + } + + return createValueGrip(v, this.threadLifetimePool, this.objectGrip); + }, + createEnvironmentActor: (e, p) => this.createEnvironmentActor(e, p), + promote: () => this.threadObjectGrip(actor), + isThreadLifetimePool: () => + actor.getParent() !== this.threadLifetimePool, + }, + this.conn + ); + pool.manage(actor); + pool.objectActors.set(value, actor); + return actor.form(); + } + + /** + * Create a grip for the given debuggee object with a pause lifetime. + * + * @param value Debugger.Object + * The debuggee object value. + */ + pauseObjectGrip(value) { + if (!this._pausePool) { + throw new Error("Object grip requested while not paused."); + } + + return this.objectGrip(value, this._pausePool); + } + + /** + * Extend the lifetime of the provided object actor to thread lifetime. + * + * @param actor object + * The object actor. + */ + threadObjectGrip(actor) { + this.threadLifetimePool.manage(actor); + this.threadLifetimePool.objectActors.set(actor.obj, actor); + } + + _onWindowReady({ isTopLevel, isBFCache, window }) { + // Note that this code relates to the disabling of Debugger API from will-navigate listener. + // And should only be triggered when the target actor doesn't follow WindowGlobal lifecycle. + // i.e. when the Thread Actor manages more than one top level WindowGlobal. + if (isTopLevel && this.state != STATES.DETACHED) { + this.sourcesManager.reset(); + this.clearDebuggees(); + this.dbg.enable(); + // Update the global no matter if the debugger is on or off, + // otherwise the global will be wrong when enabled later. + this.global = window; + } + + // Refresh the debuggee list when a new window object appears (top window or + // iframe). + if (this.attached) { + this.dbg.addDebuggees(); + } + + // BFCache navigations reuse old sources, so send existing sources to the + // client instead of waiting for onNewScript debugger notifications. + if (isBFCache) { + this.addAllSources(); + } + } + + _onWillNavigate({ isTopLevel }) { + if (!isTopLevel) { + return; + } + + // Proceed normally only if the debuggee is not paused. + if (this.state == STATES.PAUSED) { + // If we were paused while navigating to a new page, + // we resume previous page execution, so that the document can be sucessfully unloaded. + // And we disable the Debugger API, so that we do not hit any breakpoint or trigger any + // thread actor feature. We will re-enable it just before the next page starts loading, + // from window-ready listener. That's for when the target doesn't follow WindowGlobal + // lifecycle. + // When the target follows the WindowGlobal lifecycle, we will stiff resume and disable + // this thread actor. It will soon be destroyed. And a new target will pick up + // the next WindowGlobal and spawn a new Debugger API, via ThreadActor.attach(). + this.doResume(); + this.dbg.disable(); + } + + this.removeAllWatchpoints(); + this.disableAllBreakpoints(); + this.dbg.onEnterFrame = undefined; + } + + _onNavigate() { + if (this.state == STATES.RUNNING) { + this.dbg.enable(); + } + } + + // JS Debugger API hooks. + pauseForMutationBreakpoint( + mutationType, + targetNode, + ancestorNode, + action = "" // "add" or "remove" + ) { + if ( + !["subtreeModified", "nodeRemoved", "attributeModified"].includes( + mutationType + ) + ) { + throw new Error("Unexpected mutation breakpoint type"); + } + + const frame = this.dbg.getNewestFrame(); + if (!frame) { + return undefined; + } + + if ( + this.skipBreakpointsOption || + this.sourcesManager.isFrameBlackBoxed(frame) + ) { + return undefined; + } + + const global = (targetNode.ownerDocument || targetNode).defaultView; + assert(global && this.dbg.hasDebuggee(global)); + + const targetObj = this.dbg + .makeGlobalObjectReference(global) + .makeDebuggeeValue(targetNode); + + let ancestorObj = null; + if (ancestorNode) { + ancestorObj = this.dbg + .makeGlobalObjectReference(global) + .makeDebuggeeValue(ancestorNode); + } + + return this._pauseAndRespond( + frame, + { + type: PAUSE_REASONS.MUTATION_BREAKPOINT, + mutationType, + message: `DOM Mutation: '${mutationType}'`, + }, + pkt => { + // We have to add this here because `_pausePool` is `null` beforehand. + pkt.why.nodeGrip = this.objectGrip(targetObj, this._pausePool); + pkt.why.ancestorGrip = ancestorObj + ? this.objectGrip(ancestorObj, this._pausePool) + : null; + pkt.why.action = action; + return pkt; + } + ); + } + + /** + * A function that the engine calls when a debugger statement has been + * executed in the specified frame. + * + * @param frame Debugger.Frame + * The stack frame that contained the debugger statement. + */ + onDebuggerStatement(frame) { + // Don't pause if + // 1. we have not moved since the last pause + // 2. breakpoints are disabled + // 3. the source is blackboxed + // 4. there is a breakpoint at the same location + if ( + !this.hasMoved(frame, "debuggerStatement") || + this.skipBreakpointsOption || + this.sourcesManager.isFrameBlackBoxed(frame) || + this.atBreakpointLocation(frame) + ) { + return undefined; + } + + return this._pauseAndRespond(frame, { + type: PAUSE_REASONS.DEBUGGER_STATEMENT, + }); + } + + skipBreakpoints(skip) { + this._options.skipBreakpoints = skip; + return { skip }; + } + + // Bug 1686485 is meant to remove usages of this request + // in favor direct call to `reconfigure` + pauseOnExceptions(pauseOnExceptions, ignoreCaughtExceptions) { + this.reconfigure({ + pauseOnExceptions, + ignoreCaughtExceptions, + }); + return {}; + } + + /** + * A function that the engine calls when an exception has been thrown and has + * propagated to the specified frame. + * + * @param youngestFrame Debugger.Frame + * The youngest remaining stack frame. + * @param value object + * The exception that was thrown. + */ + _onExceptionUnwind(youngestFrame, value) { + // Ignore any reported exception if we are already paused + if (this.isPaused()) { + return undefined; + } + + let willBeCaught = false; + for (let frame = youngestFrame; frame != null; frame = frame.older) { + if (frame.script.isInCatchScope(frame.offset)) { + willBeCaught = true; + break; + } + } + + if (willBeCaught && this._options.ignoreCaughtExceptions) { + return undefined; + } + + if ( + this._handledFrameExceptions.has(youngestFrame) && + this._handledFrameExceptions.get(youngestFrame) === value + ) { + return undefined; + } + + // NS_ERROR_NO_INTERFACE exceptions are a special case in browser code, + // since they're almost always thrown by QueryInterface functions, and + // handled cleanly by native code. + if (!isWorker && value == Cr.NS_ERROR_NO_INTERFACE) { + return undefined; + } + + // Don't pause on exceptions thrown while inside an evaluation being done on + // behalf of the client. + if (this.insideClientEvaluation) { + return undefined; + } + + if ( + this.skipBreakpointsOption || + this.sourcesManager.isFrameBlackBoxed(youngestFrame) + ) { + return undefined; + } + + // Now that we've decided to pause, ignore this exception if it's thrown by + // any older frames. + for (let frame = youngestFrame.older; frame != null; frame = frame.older) { + this._handledFrameExceptions.set(frame, value); + } + + try { + const packet = this._paused(youngestFrame); + if (!packet) { + return undefined; + } + + packet.why = { + type: PAUSE_REASONS.EXCEPTION, + exception: createValueGrip(value, this._pausePool, this.objectGrip), + }; + this.emit("paused", packet); + + this._nestedEventLoop.enter(); + } catch (e) { + reportException("TA_onExceptionUnwind", e); + } + + return undefined; + } + + /** + * A function that the engine calls when a new script has been loaded. + * + * @param script Debugger.Script + * The source script that has been loaded into a debuggee compartment. + */ + onNewScript(script) { + this._addSource(script.source); + + this._maybeTrackFirstStatementBreakpoint(script); + } + + /** + * A function called when there's a new source from a thread actor's sources. + * Emits `newSource` on the thread actor. + * + * @param {SourceActor} source + */ + onNewSourceEvent(source) { + // When this target is supported by the Watcher Actor, + // and we listen to SOURCE, we avoid emitting the newSource RDP event + // as it would be duplicated with the Resource/watchResources API. + // Could probably be removed once bug 1680280 is fixed. + if (!this._shouldEmitNewSource) { + return; + } + + // Bug 1516197: New sources are likely detected due to either user + // interaction on the page, or devtools requests sent to the server. + // We use executeSoon because we don't want to block those operations + // by sending packets in the middle of them. + DevToolsUtils.executeSoon(() => { + if (this.isDestroyed()) { + return; + } + this.emit("newSource", { + source: source.form(), + }); + }); + } + + // API used by the Watcher Actor to disable the newSource events + // Could probably be removed once bug 1680280 is fixed. + _shouldEmitNewSource = true; + disableNewSourceEvents() { + this._shouldEmitNewSource = false; + } + + /** + * Filtering function to filter out sources for which we don't want to notify/create + * source actors + * + * @param {Debugger.Source} source + * The source to accept or ignore + * @param Boolean + * True, if we want to create a source actor. + */ + _acceptSource(source) { + // We have some spurious source created by ExtensionContent.sys.mjs when debugging tabs. + // These sources are internal stuff injected by WebExt codebase to implement content + // scripts. We can't easily ignore them from Debugger API, so ignore them + // when debugging a tab (i.e. browser-element). As we still want to debug them + // from the browser toolbox. + if ( + this._parent.sessionContext.type == "browser-element" && + source.url.endsWith("ExtensionContent.sys.mjs") + ) { + return false; + } + + return true; + } + + /** + * Add the provided source to the server cache. + * + * @param aSource Debugger.Source + * The source that will be stored. + */ + _addSource(source) { + if (!this._acceptSource(source)) { + return; + } + + // Preloaded WebExtension content scripts may be cached internally by + // ExtensionContent.jsm and ThreadActor would ignore them on a page reload + // because it finds them in the _debuggerSourcesSeen WeakSet, + // and so we also need to be sure that there is still a source actor for the source. + let sourceActor; + if ( + this._debuggerSourcesSeen.has(source) && + this.sourcesManager.hasSourceActor(source) + ) { + sourceActor = this.sourcesManager.getSourceActor(source); + sourceActor.resetDebuggeeScripts(); + } else { + sourceActor = this.sourcesManager.createSourceActor(source); + } + + const sourceUrl = sourceActor.url; + if (this._onLoadBreakpointURLs.has(sourceUrl)) { + // Immediately set a breakpoint on first line + // (note that this is only used by `./mach xpcshell-test --jsdebugger`) + this.setBreakpoint({ sourceUrl, line: 1 }, {}); + // But also query asynchronously the first really breakable line + // as the first may not be valid and won't break. + (async () => { + const [firstLine] = await sourceActor.getBreakableLines(); + if (firstLine != 1) { + this.setBreakpoint({ sourceUrl, line: firstLine }, {}); + } + })(); + } + + const bpActors = this.breakpointActorMap + .findActors() + .filter( + actor => + actor.location.sourceUrl && actor.location.sourceUrl == sourceUrl + ); + + for (const actor of bpActors) { + sourceActor.applyBreakpoint(actor); + } + + this._debuggerSourcesSeen.add(source); + } + + /** + * Create a new source by refetching the specified URL and instantiating all + * sources that were found in the result. + * + * @param url The URL string to fetch. + * @param existingInlineSources The inline sources for the URL the debugger knows about + * already, and that we shouldn't re-create (only used when + * url content type is text/html). + */ + async _resurrectSource(url, existingInlineSources) { + let { content, contentType, sourceMapURL } = + await this.sourcesManager.urlContents( + url, + /* partial */ false, + /* canUseCache */ true + ); + + // Newlines in all sources should be normalized. Do this with HTML content + // to simplify the comparisons below. + content = content.replace(/\r\n?|\u2028|\u2029/g, "\n"); + + if (contentType == "text/html") { + // HTML files can contain any number of inline sources. We have to find + // all the inline sources and their start line without running any of the + // scripts on the page. The approach used here is approximate. + if (!this._parent.window) { + return; + } + + // Find the offsets in the HTML at which inline scripts might start. + const scriptTagMatches = content.matchAll(/<script[^>]*>/gi); + const scriptStartOffsets = [...scriptTagMatches].map( + rv => rv.index + rv[0].length + ); + + // Find the script tags in this HTML page by parsing a new document from + // the contentand looking for its script elements. + const document = new DOMParser().parseFromString(content, "text/html"); + + // For each inline source found, see if there is a start offset for what + // appears to be a script tag, whose contents match the inline source. + [...document.scripts].forEach(script => { + const text = script.innerText; + + // We only want to handle inline scripts + if (script.src) { + return; + } + + // Don't create source for empty script tag + if (!text.trim()) { + return; + } + + const scriptStartOffsetIndex = scriptStartOffsets.findIndex( + offset => content.substring(offset, offset + text.length) == text + ); + // Bail if we couldn't find the start offset for the script + if (scriptStartOffsetIndex == -1) { + return; + } + + const scriptStartOffset = scriptStartOffsets[scriptStartOffsetIndex]; + // Remove the offset from the array to mitigate any issue we might with scripts + // sharing the same text content. + scriptStartOffsets.splice(scriptStartOffsetIndex, 1); + + const allLineBreaks = [ + ...content.substring(0, scriptStartOffset).matchAll("\n"), + ]; + const startLine = 1 + allLineBreaks.length; + const startColumn = + scriptStartOffset - + (allLineBreaks.length ? allLineBreaks.at(-1).index - 1 : 0); + + // Don't create a source if we already found one for this script + if ( + existingInlineSources.find( + source => + source.startLine == startLine && source.startColumn == startColumn + ) + ) { + return; + } + + try { + const global = this.dbg.getDebuggees()[0]; + this._addSource( + global.createSource({ + text, + url, + startLine, + startColumn, + isScriptElement: true, + }) + ); + } catch (e) { + // Ignore parse errors. + } + }); + + // If no scripts were found, we might have an inaccurate content type and + // the file is actually JavaScript. Fall through and add the entire file + // as the source. + if (document.scripts.length) { + return; + } + } + + // Other files should only contain javascript, so add the file contents as + // the source itself. + try { + const global = this.dbg.getDebuggees()[0]; + this._addSource( + global.createSource({ + text: content, + url, + startLine: 1, + sourceMapURL, + }) + ); + } catch (e) { + // Ignore parse errors. + } + } + + dumpThread() { + return { + pauseOnExceptions: this._options.pauseOnExceptions, + ignoreCaughtExceptions: this._options.ignoreCaughtExceptions, + logEventBreakpoints: this._options.logEventBreakpoints, + skipBreakpoints: this.skipBreakpointsOption, + breakpoints: this.breakpointActorMap.listKeys(), + }; + } + + // NOTE: dumpPools is defined in the Thread actor to avoid + // adding it to multiple target specs and actors. + dumpPools() { + return this.conn.dumpPools(); + } + + logLocation(prefix, frame) { + const loc = this.sourcesManager.getFrameLocation(frame); + dump(`${prefix} (${loc.line}, ${loc.column})\n`); + } +} + +exports.ThreadActor = ThreadActor; + +/** + * Creates a PauseActor. + * + * PauseActors exist for the lifetime of a given debuggee pause. Used to + * scope pause-lifetime grips. + * + * @param {Pool} pool: The actor pool created for this pause. + */ +function PauseActor(pool) { + this.pool = pool; +} + +PauseActor.prototype = { + typeName: "pause", +}; + +// Utility functions. + +/** + * Unwrap a global that is wrapped in a |Debugger.Object|, or if the global has + * become a dead object, return |undefined|. + * + * @param Debugger.Object wrappedGlobal + * The |Debugger.Object| which wraps a global. + * + * @returns {Object|undefined} + * Returns the unwrapped global object or |undefined| if unwrapping + * failed. + */ +exports.unwrapDebuggerObjectGlobal = wrappedGlobal => { + try { + // Because of bug 991399 we sometimes get nuked window references here. We + // just bail out in that case. + // + // Note that addon sandboxes have a DOMWindow as their prototype. So make + // sure that we can touch the prototype too (whatever it is), in case _it_ + // is it a nuked window reference. We force stringification to make sure + // that any dead object proxies make themselves known. + const global = wrappedGlobal.unsafeDereference(); + Object.getPrototypeOf(global) + ""; + return global; + } catch (e) { + return undefined; + } +}; |