diff options
Diffstat (limited to 'devtools/server/devtools-server-connection.js')
-rw-r--r-- | devtools/server/devtools-server-connection.js | 608 |
1 files changed, 608 insertions, 0 deletions
diff --git a/devtools/server/devtools-server-connection.js b/devtools/server/devtools-server-connection.js new file mode 100644 index 0000000000..709313b296 --- /dev/null +++ b/devtools/server/devtools-server-connection.js @@ -0,0 +1,608 @@ +/* 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 { Pool } = require("devtools/shared/protocol"); +var DevToolsUtils = require("devtools/shared/DevToolsUtils"); +var { dumpn } = DevToolsUtils; + +loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); +loader.lazyRequireGetter( + this, + "DevToolsServer", + "devtools/server/devtools-server", + true +); + +/** + * Creates a DevToolsServerConnection. + * + * Represents a connection to this debugging global from a client. + * Manages a set of actors and actor pools, allocates actor ids, and + * handles incoming requests. + * + * @param prefix string + * All actor IDs created by this connection should be prefixed + * with prefix. + * @param transport transport + * Packet transport for the debugging protocol. + * @param socketListener SocketListener + * SocketListener which accepted the transport. + * If this is null, the transport is not that was accepted by SocketListener. + */ +function DevToolsServerConnection(prefix, transport, socketListener) { + this._prefix = prefix; + this._transport = transport; + this._transport.hooks = this; + this._nextID = 1; + this._socketListener = socketListener; + + this._actorPool = new Pool(this, "server-connection"); + this._extraPools = [this._actorPool]; + + // Responses to a given actor must be returned the the client + // in the same order as the requests that they're replying to, but + // Implementations might finish serving requests in a different + // order. To keep things in order we generate a promise for each + // request, chained to the promise for the request before it. + // This map stores the latest request promise in the chain, keyed + // by an actor ID string. + this._actorResponses = new Map(); + + /* + * We can forward packets to other servers, if the actors on that server + * all use a distinct prefix on their names. This is a map from prefixes + * to transports: it maps a prefix P to a transport T if T conveys + * packets to the server whose actors' names all begin with P + "/". + */ + this._forwardingPrefixes = new Map(); + + EventEmitter.decorate(this); +} +exports.DevToolsServerConnection = DevToolsServerConnection; + +DevToolsServerConnection.prototype = { + _prefix: null, + get prefix() { + return this._prefix; + }, + + /** + * For a DevToolsServerConnection used in content processes, + * returns the prefix of the connection it originates from, from the parent process. + */ + get parentPrefix() { + this.prefix.replace(/child\d+\//, ""); + }, + + _transport: null, + get transport() { + return this._transport; + }, + + /** + * Message manager used to communicate with the parent process, + * set by child.js. Is only defined for connections instantiated + * within a child process. + */ + parentMessageManager: null, + + close() { + if (this._transport) { + this._transport.close(); + } + }, + + send(packet) { + this.transport.send(packet); + }, + + /** + * Used when sending a bulk reply from an actor. + * @see DebuggerTransport.prototype.startBulkSend + */ + startBulkSend(header) { + return this.transport.startBulkSend(header); + }, + + allocID(prefix) { + return this.prefix + (prefix || "") + this._nextID++; + }, + + /** + * Add a map of actor IDs to the connection. + */ + addActorPool(actorPool) { + this._extraPools.push(actorPool); + }, + + /** + * Remove a previously-added pool of actors to the connection. + * + * @param Pool actorPool + * The Pool instance you want to remove. + */ + removeActorPool(actorPool) { + // When a connection is closed, it removes each of its actor pools. When an + // actor pool is removed, it calls the destroy method on each of its + // actors. Some actors, such as ThreadActor, manage their own actor pools. + // When the destroy method is called on these actors, they manually + // remove their actor pools. Consequently, this method is reentrant. + // + // In addition, some actors, such as ThreadActor, perform asynchronous work + // (in the case of ThreadActor, because they need to resume), before they + // remove each of their actor pools. Since we don't wait for this work to + // be completed, we can end up in this function recursively after the + // connection already set this._extraPools to null. + // + // This is a bug: if the destroy method can perform asynchronous work, + // then we should wait for that work to be completed before setting this. + // _extraPools to null. As a temporary solution, it should be acceptable + // to just return early (if this._extraPools has been set to null, all + // actors pools for this connection should already have been removed). + if (this._extraPools === null) { + return; + } + const index = this._extraPools.lastIndexOf(actorPool); + if (index > -1) { + this._extraPools.splice(index, 1); + } + }, + + /** + * Add an actor to the default actor pool for this connection. + */ + addActor(actor) { + this._actorPool.manage(actor); + }, + + /** + * Remove an actor to the default actor pool for this connection. + */ + removeActor(actor) { + this._actorPool.unmanage(actor); + }, + + /** + * Match the api expected by the protocol library. + */ + unmanage(actor) { + return this.removeActor(actor); + }, + + /** + * Look up an actor implementation for an actorID. Will search + * all the actor pools registered with the connection. + * + * @param actorID string + * Actor ID to look up. + */ + getActor(actorID) { + const pool = this.poolFor(actorID); + if (pool) { + return pool.getActorByID(actorID); + } + + if (actorID === "root") { + return this.rootActor; + } + + return null; + }, + + _getOrCreateActor(actorID) { + try { + const actor = this.getActor(actorID); + if (!actor) { + this.transport.send({ + from: actorID ? actorID : "root", + error: "noSuchActor", + message: "No such actor for ID: " + actorID, + }); + return null; + } + + if (typeof actor !== "object") { + // Pools should now contain only actor instances (i.e. objects) + throw new Error( + `Unexpected actor constructor/function in Pool for actorID "${actorID}".` + ); + } + + return actor; + } catch (error) { + const prefix = `Error occurred while creating actor' ${actorID}`; + this.transport.send(this._unknownError(actorID, prefix, error)); + } + return null; + }, + + poolFor(actorID) { + for (const pool of this._extraPools) { + if (pool.has(actorID)) { + return pool; + } + } + return null; + }, + + _unknownError(from, prefix, error) { + const errorString = prefix + ": " + DevToolsUtils.safeErrorString(error); + reportError(errorString); + dumpn(errorString); + return { + from, + error: "unknownError", + message: errorString, + }; + }, + + _queueResponse: function(from, type, responseOrPromise) { + const pendingResponse = + this._actorResponses.get(from) || Promise.resolve(null); + const responsePromise = pendingResponse + .then(() => { + return responseOrPromise; + }) + .then(response => { + if (!this.transport) { + throw new Error( + `Connection closed, pending response from '${from}', ` + + `type '${type}' failed` + ); + } + + if (!response.from) { + response.from = from; + } + + this.transport.send(response); + }) + .catch(error => { + if (!this.transport) { + throw new Error( + `Connection closed, pending error from '${from}', ` + + `type '${type}' failed` + ); + } + + const prefix = `error occurred while queuing response for '${type}'`; + this.transport.send(this._unknownError(from, prefix, error)); + }); + + this._actorResponses.set(from, responsePromise); + }, + + /** + * This function returns whether the connection was accepted by passed SocketListener. + * + * @param {SocketListener} socketListener + * @return {Boolean} return true if this connection was accepted by socketListener, + * else returns false. + */ + isAcceptedBy(socketListener) { + return this._socketListener === socketListener; + }, + + /* Forwarding packets to other transports based on actor name prefixes. */ + + /* + * Arrange to forward packets to another server. This is how we + * forward debugging connections to child processes. + * + * If we receive a packet for an actor whose name begins with |prefix| + * followed by '/', then we will forward that packet to |transport|. + * + * This overrides any prior forwarding for |prefix|. + * + * @param prefix string + * The actor name prefix, not including the '/'. + * @param transport object + * A packet transport to which we should forward packets to actors + * whose names begin with |(prefix + '/').| + */ + setForwarding(prefix, transport) { + this._forwardingPrefixes.set(prefix, transport); + }, + + /* + * Stop forwarding messages to actors whose names begin with + * |prefix+'/'|. Such messages will now elicit 'noSuchActor' errors. + */ + cancelForwarding(prefix) { + this._forwardingPrefixes.delete(prefix); + + // Notify the client that forwarding in now cancelled for this prefix. + // There could be requests in progress that the client should abort rather leaving + // handing indefinitely. + if (this.rootActor) { + this.send(this.rootActor.forwardingCancelled(prefix)); + } + }, + + sendActorEvent(actorID, eventName, event = {}) { + event.from = actorID; + event.type = eventName; + this.send(event); + }, + + // Transport hooks. + + /** + * Called by DebuggerTransport to dispatch incoming packets as appropriate. + * + * @param packet object + * The incoming packet. + */ + onPacket(packet) { + // If the actor's name begins with a prefix we've been asked to + // forward, do so. + // + // Note that the presence of a prefix alone doesn't indicate that + // forwarding is needed: in DevToolsServerConnection instances in child + // processes, every actor has a prefixed name. + if (this._forwardingPrefixes.size > 0) { + let to = packet.to; + let separator = to.lastIndexOf("/"); + while (separator >= 0) { + to = to.substring(0, separator); + const forwardTo = this._forwardingPrefixes.get( + packet.to.substring(0, separator) + ); + if (forwardTo) { + forwardTo.send(packet); + return; + } + separator = to.lastIndexOf("/"); + } + } + + const actor = this._getOrCreateActor(packet.to); + if (!actor) { + return; + } + + let ret = null; + + // handle "requestTypes" RDP request. + if (packet.type == "requestTypes") { + ret = { + from: actor.actorID, + requestTypes: Object.keys(actor.requestTypes), + }; + } else if (actor.requestTypes?.[packet.type]) { + // Dispatch the request to the actor. + try { + this.currentPacket = packet; + ret = actor.requestTypes[packet.type].bind(actor)(packet, this); + } catch (error) { + // Support legacy errors from old actors such as thread actor which + // throw { error, message } objects. + let errorMessage = error; + if (error?.error && error?.message) { + errorMessage = `"(${error.error}) ${error.message}"`; + } + + const prefix = `error occurred while processing '${packet.type}'`; + this.transport.send( + this._unknownError(actor.actorID, prefix, errorMessage) + ); + } finally { + this.currentPacket = undefined; + } + } else { + ret = { + error: "unrecognizedPacketType", + message: `Actor ${actor.actorID} does not recognize the packet type '${packet.type}'`, + }; + } + + // There will not be a return value if a bulk reply is sent. + if (ret) { + this._queueResponse(packet.to, packet.type, ret); + } + }, + + /** + * Called by the DebuggerTransport to dispatch incoming bulk packets as + * appropriate. + * + * @param packet object + * The incoming packet, which contains: + * * actor: Name of actor that will receive the packet + * * type: Name of actor's method that should be called on receipt + * * length: Size of the data to be read + * * stream: This input stream should only be used directly if you can + * ensure that you will read exactly |length| bytes and will + * not close the stream when reading is complete + * * done: If you use the stream directly (instead of |copyTo| + * below), you must signal completion by resolving / + * rejecting this deferred. If it's rejected, the transport + * will be closed. If an Error is supplied as a rejection + * value, it will be logged via |dumpn|. If you do use + * |copyTo|, resolving is taken care of for you when copying + * completes. + * * copyTo: A helper function for getting your data out of the stream + * that meets the stream handling requirements above, and has + * the following signature: + * @param output nsIAsyncOutputStream + * The stream to copy to. + * @return Promise + * The promise is resolved when copying completes or rejected + * if any (unexpected) errors occur. + * This object also emits "progress" events for each chunk + * that is copied. See stream-utils.js. + */ + onBulkPacket(packet) { + const { actor: actorKey, type } = packet; + + const actor = this._getOrCreateActor(actorKey); + if (!actor) { + return; + } + + // Dispatch the request to the actor. + let ret; + if (actor.requestTypes?.[type]) { + try { + ret = actor.requestTypes[type].call(actor, packet); + } catch (error) { + const prefix = `error occurred while processing bulk packet '${type}'`; + this.transport.send(this._unknownError(actorKey, prefix, error)); + packet.done.reject(error); + } + } else { + const message = `Actor ${actorKey} does not recognize the bulk packet type '${type}'`; + ret = { error: "unrecognizedPacketType", message: message }; + packet.done.reject(new Error(message)); + } + + // If there is a JSON response, queue it for sending back to the client. + if (ret) { + this._queueResponse(actorKey, type, ret); + } + }, + + /** + * Called by DebuggerTransport when the underlying stream is closed. + * + * @param status nsresult + * The status code that corresponds to the reason for closing + * the stream. + */ + onClosed(status) { + dumpn("Cleaning up connection."); + if (!this._actorPool) { + // Ignore this call if the connection is already closed. + return; + } + this._actorPool = null; + + this.emit("closed", status, this.prefix); + + // Use filter in order to create a copy of the extraPools array, + // which might be modified by removeActorPool calls. + // The isTopLevel check ensures that the pools retrieved here will not be + // destroyed by another Pool::destroy. Non top-level pools will be destroyed + // by the recursive Pool::destroy mechanism. + // See test_connection_closes_all_pools.js for practical examples of Pool + // hierarchies. + const topLevelPools = this._extraPools.filter(p => p.isTopPool()); + topLevelPools.forEach(p => p.destroy()); + + this._extraPools = null; + + this.rootActor = null; + this._transport = null; + DevToolsServer._connectionClosed(this); + }, + + dumpPool(pool, output = [], dumpedPools) { + const actorIds = []; + const children = []; + + if (dumpedPools.has(pool)) { + return; + } + dumpedPools.add(pool); + + // TRUE if the pool is a Pool + if (!pool.__poolMap) { + return; + } + + for (const actor of pool.poolChildren()) { + children.push(actor); + actorIds.push(actor.actorID); + } + const label = pool.label || pool.actorID; + + output.push([label, actorIds]); + dump(`- ${label}: ${JSON.stringify(actorIds)}\n`); + children.forEach(childPool => + this.dumpPool(childPool, output, dumpedPools) + ); + }, + + /* + * Debugging helper for inspecting the state of the actor pools. + */ + dumpPools() { + const output = []; + const dumpedPools = new Set(); + + this._extraPools.forEach(pool => this.dumpPool(pool, output, dumpedPools)); + + return output; + }, + + /** + * In a content child process, ask the DevToolsServer in the parent process + * to execute a given module setup helper. + * + * @param module + * The module to be required + * @param setupParent + * The name of the setup helper exported by the above module + * (setup helper signature: function ({mm}) { ... }) + * @return boolean + * true if the setup helper returned successfully + */ + setupInParent({ module, setupParent }) { + if (!this.parentMessageManager) { + return false; + } + + return this.parentMessageManager.sendSyncMessage("debug:setup-in-parent", { + prefix: this.prefix, + module: module, + setupParent: setupParent, + }); + }, + + /** + * Instanciates a protocol.js actor in the parent process, from the content process + * module is the absolute path to protocol.js actor module + * + * @param spawnByActorID string + * The actor ID of the actor that is requesting an actor to be created. + * This is used as a prefix to compute the actor id of the actor created + * in the parent process. + * @param module string + * Absolute path for the actor module to load. + * @param constructor string + * The symbol exported by this module that implements Actor. + * @param args array + * Arguments to pass to its constructor + */ + spawnActorInParentProcess(spawnedByActorID, { module, constructor, args }) { + if (!this.parentMessageManager) { + return null; + } + + const mm = this.parentMessageManager; + + const onResponse = new Promise(done => { + const listener = msg => { + if (msg.json.prefix != this.prefix) { + return; + } + mm.removeMessageListener("debug:spawn-actor-in-parent:actor", listener); + done(msg.json.actorID); + }; + mm.addMessageListener("debug:spawn-actor-in-parent:actor", listener); + }); + + mm.sendAsyncMessage("debug:spawn-actor-in-parent", { + prefix: this.prefix, + module, + constructor, + args, + spawnedByActorID, + }); + + return onResponse; + }, +}; |