diff options
Diffstat (limited to '')
-rw-r--r-- | devtools/shared/protocol/Front.js | 410 |
1 files changed, 410 insertions, 0 deletions
diff --git a/devtools/shared/protocol/Front.js b/devtools/shared/protocol/Front.js new file mode 100644 index 0000000000..1298f3a075 --- /dev/null +++ b/devtools/shared/protocol/Front.js @@ -0,0 +1,410 @@ +/* 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"; + +var { settleAll } = require("resource://devtools/shared/DevToolsUtils.js"); +var EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +var { Pool } = require("resource://devtools/shared/protocol/Pool.js"); +var { + getStack, + callFunctionWithAsyncStack, +} = require("resource://devtools/shared/platform/stack.js"); + +/** + * Base class for client-side actor fronts. + * + * @param [DevToolsClient|null] conn + * The conn must either be DevToolsClient or null. Must have + * addActorPool, removeActorPool, and poolFor. + * conn can be null if the subclass provides a conn property. + * @param [Target|null] target + * If we are instantiating a target-scoped front, this is a reference to the front's + * Target instance, otherwise this is null. + * @param [Front|null] parentFront + * The parent front. This is only available if the Front being initialized is a child + * of a parent front. + * @constructor + */ +class Front extends Pool { + constructor(conn = null, targetFront = null, parentFront = null) { + super(conn); + if (!conn) { + throw new Error("Front without conn"); + } + this.actorID = null; + // The targetFront attribute represents the debuggable context. Only target-scoped + // fronts and their children fronts will have the targetFront attribute set. + this.targetFront = targetFront; + // The parentFront attribute points to its parent front. Only children of + // target-scoped fronts will have the parentFront attribute set. + this.parentFront = parentFront; + this._requests = []; + + // Front listener functions registered via `watchFronts` + this._frontCreationListeners = null; + this._frontDestructionListeners = null; + + // List of optional listener for each event, that is processed immediatly on packet + // receival, before emitting event via EventEmitter on the Front. + // These listeners are register via Front.before function. + // Map(Event Name[string] => Event Listener[function]) + this._beforeListeners = new Map(); + + // This flag allows to check if the `initialize` method has resolved. + // Used to avoid notifying about initialized fronts in `watchFronts`. + this._initializeResolved = false; + } + + /** + * Return the parent front. + */ + getParent() { + return this.parentFront && this.parentFront.actorID + ? this.parentFront + : null; + } + + destroy() { + // Prevent destroying twice if a `forwardCancelling` event has already been received + // and already called `baseFrontClassDestroy` + this.baseFrontClassDestroy(); + + // Keep `clearEvents` out of baseFrontClassDestroy as we still want TargetMixin to be + // able to emit `target-destroyed` after we called baseFrontClassDestroy from DevToolsClient.purgeRequests. + this.clearEvents(); + } + + // This method is also called from `DevToolsClient`, when a connector is destroyed + // and we should: + // - reject all pending request made to the remote process/target/thread. + // - avoid trying to do new request against this remote context. + // - unmanage this front, so that DevToolsClient.getFront no longer returns it. + // + // When a connector is destroyed a `forwardCancelling` RDP event is sent by the server. + // This is done in a distinct method from `destroy` in order to do all that immediately, + // even if `Front.destroy` is overloaded by an async method. + baseFrontClassDestroy() { + // Reject all outstanding requests, they won't make sense after + // the front is destroyed. + while (this._requests.length) { + const { deferred, to, type, stack } = this._requests.shift(); + // Note: many tests are ignoring `Connection closed` promise rejections, + // via PromiseTestUtils.allowMatchingRejectionsGlobally. + // Do not update the message without updating the tests. + const msg = + "Connection closed, pending request to " + + to + + ", type " + + type + + " failed" + + "\n\nRequest stack:\n" + + stack.formattedStack; + deferred.reject(new Error(msg)); + } + + if (this.actorID) { + super.destroy(); + this.actorID = null; + } + this._isDestroyed = true; + + this.targetFront = null; + this.parentFront = null; + this._frontCreationListeners = null; + this._frontDestructionListeners = null; + this._beforeListeners = null; + } + + async manage(front, form, ctx) { + if (!front.actorID) { + throw new Error( + "Can't manage front without an actor ID.\n" + + "Ensure server supports " + + front.typeName + + "." + ); + } + + if (front.parentFront && front.parentFront !== this) { + throw new Error( + `${this.actorID} (${this.typeName}) can't manage ${front.actorID} + (${front.typeName}) since it has a different parentFront ${ + front.parentFront + ? front.parentFront.actorID + "(" + front.parentFront.typeName + ")" + : "<no parentFront>" + }` + ); + } + + super.manage(front); + + if (typeof front.initialize == "function") { + await front.initialize(); + } + front._initializeResolved = true; + + // Ensure calling form() *before* notifying about this front being just created. + // We exprect the front to be fully initialized, especially via its form attributes. + // But do that *after* calling manage() so that the front is already registered + // in Pools and can be fetched by its ID, in case a child actor, created in form() + // tries to get a reference to its parent via the actor ID. + if (form) { + front.form(form, ctx); + } + + // Call listeners registered via `watchFronts` method + // (ignore if this front has been destroyed) + if (this._frontCreationListeners) { + this._frontCreationListeners.emit(front.typeName, front); + } + } + + async unmanage(front) { + super.unmanage(front); + + // Call listeners registered via `watchFronts` method + if (this._frontDestructionListeners) { + this._frontDestructionListeners.emit(front.typeName, front); + } + } + + /* + * Listen for the creation and/or destruction of fronts matching one of the provided types. + * + * @param {String} typeName + * Actor type to watch. + * @param {Function} onAvailable (optional) + * Callback fired when a front has been just created or was already available. + * The function is called with one arguments, the front. + * @param {Function} onDestroy (optional) + * Callback fired in case of front destruction. + * The function is called with the same argument than onAvailable. + */ + watchFronts(typeName, onAvailable, onDestroy) { + if (this.isDestroyed()) { + // The front was already destroyed, bail out. + console.error( + `Tried to call watchFronts for the '${typeName}' type on an ` + + `already destroyed front '${this.typeName}'.` + ); + return; + } + + if (onAvailable) { + // First fire the callback on fronts with the correct type and which have + // been initialized. If initialize() is still in progress, the front will + // be emitted via _frontCreationListeners shortly after. + for (const front of this.poolChildren()) { + if (front.typeName == typeName && front._initializeResolved) { + onAvailable(front); + } + } + + if (!this._frontCreationListeners) { + this._frontCreationListeners = new EventEmitter(); + } + // Then register the callback for fronts instantiated in the future + this._frontCreationListeners.on(typeName, onAvailable); + } + + if (onDestroy) { + if (!this._frontDestructionListeners) { + this._frontDestructionListeners = new EventEmitter(); + } + this._frontDestructionListeners.on(typeName, onDestroy); + } + } + + /** + * Stop listening for the creation and/or destruction of a given type of fronts. + * See `watchFronts()` for documentation of the arguments. + */ + unwatchFronts(typeName, onAvailable, onDestroy) { + if (this.isDestroyed()) { + // The front was already destroyed, bail out. + console.error( + `Tried to call unwatchFronts for the '${typeName}' type on an ` + + `already destroyed front '${this.typeName}'.` + ); + return; + } + + if (onAvailable && this._frontCreationListeners) { + this._frontCreationListeners.off(typeName, onAvailable); + } + if (onDestroy && this._frontDestructionListeners) { + this._frontDestructionListeners.off(typeName, onDestroy); + } + } + + /** + * Register an event listener that will be called immediately on packer receival. + * The given callback is going to be called before emitting the event via EventEmitter + * API on the Front. Event emitting will be delayed if the callback is async. + * Only one such listener can be registered per type of event. + * + * @param String type + * Event emitted by the actor to intercept. + * @param Function callback + * Function that will process the event. + */ + before(type, callback) { + if (this._beforeListeners.has(type)) { + throw new Error( + `Can't register multiple before listeners for "${type}".` + ); + } + this._beforeListeners.set(type, callback); + } + + toString() { + return "[Front for " + this.typeName + "/" + this.actorID + "]"; + } + + /** + * Update the actor from its representation. + * Subclasses should override this. + */ + form(form) {} + + /** + * Send a packet on the connection. + */ + send(packet) { + if (packet.to) { + this.conn._transport.send(packet); + } else { + packet.to = this.actorID; + // The connection might be closed during the promise resolution + if (this.conn && this.conn._transport) { + this.conn._transport.send(packet); + } + } + } + + /** + * Send a two-way request on the connection. + */ + request(packet) { + const deferred = Promise.withResolvers(); + // Save packet basics for debugging + const { to, type } = packet; + this._requests.push({ + deferred, + to: to || this.actorID, + type, + stack: getStack(), + }); + this.send(packet); + return deferred.promise; + } + + /** + * Handler for incoming packets from the client's actor. + */ + onPacket(packet) { + if (this.isDestroyed()) { + // If the Front was already destroyed, all the requests have been purged + // and rejected with detailed error messages in baseFrontClassDestroy. + return; + } + + // Pick off event packets + const type = packet.type || undefined; + if (this._clientSpec.events && this._clientSpec.events.has(type)) { + const event = this._clientSpec.events.get(packet.type); + let args; + try { + args = event.request.read(packet, this); + } catch (ex) { + console.error("Error reading event: " + packet.type); + console.exception(ex); + throw ex; + } + // Check for "pre event" callback to be processed before emitting events on fronts + // Use event.name instead of packet.type to use specific event name instead of RDP + // packet's type. + const beforeEvent = this._beforeListeners.get(event.name); + if (beforeEvent) { + const result = beforeEvent.apply(this, args); + // Check to see if the beforeEvent returned a promise -- if so, + // wait for their resolution before emitting. Otherwise, emit synchronously. + if (result && typeof result.then == "function") { + result.then(() => { + super.emit(event.name, ...args); + ChromeUtils.addProfilerMarker( + "DevTools:RDP Front", + null, + `${this.typeName}.${event.name}` + ); + }); + return; + } + } + + super.emit(event.name, ...args); + ChromeUtils.addProfilerMarker( + "DevTools:RDP Front", + null, + `${this.typeName}.${event.name}` + ); + return; + } + + // Remaining packets must be responses. + if (this._requests.length === 0) { + const msg = + "Unexpected packet " + this.actorID + ", " + JSON.stringify(packet); + const err = Error(msg); + console.error(err); + throw err; + } + + const { deferred, stack } = this._requests.shift(); + callFunctionWithAsyncStack( + () => { + if (packet.error) { + let message; + if (packet.error && packet.message) { + message = + "Protocol error (" + packet.error + "): " + packet.message; + } else { + message = packet.error; + } + message += " from: " + this.actorID; + if (packet.fileName) { + const { fileName, columnNumber, lineNumber } = packet; + message += ` (${fileName}:${lineNumber}:${columnNumber})`; + } + const packetError = new Error(message); + deferred.reject(packetError); + } else { + deferred.resolve(packet); + } + }, + stack, + "DevTools RDP" + ); + } + + hasRequests() { + return !!this._requests.length; + } + + /** + * Wait for all current requests from this front to settle. This is especially useful + * for tests and other utility environments that may not have events or mechanisms to + * await the completion of requests without this utility. + * + * @return Promise + * Resolved when all requests have settled. + */ + waitForRequestsToSettle() { + return settleAll(this._requests.map(({ deferred }) => deferred.promise)); + } +} + +exports.Front = Front; |