/* 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"; const promise = require("devtools/shared/deprecated-sync-thenables"); const DevToolsUtils = require("devtools/shared/DevToolsUtils"); const { getStack, callFunctionWithAsyncStack, } = require("devtools/shared/platform/stack"); const EventEmitter = require("devtools/shared/event-emitter"); const { ThreadStateTypes, UnsolicitedNotifications, UnsolicitedPauses, } = require("./constants"); loader.lazyRequireGetter( this, "Authentication", "devtools/shared/security/auth" ); loader.lazyRequireGetter( this, "DebuggerSocket", "devtools/shared/security/socket", true ); loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); loader.lazyRequireGetter( this, "RootFront", "devtools/shared/fronts/root", true ); loader.lazyRequireGetter( this, "ObjectClient", "devtools/shared/client/object-client" ); loader.lazyRequireGetter(this, "Front", "devtools/shared/protocol", true); /** * Creates a client for the remote debugging protocol server. This client * provides the means to communicate with the server and exchange the messages * required by the protocol in a traditional JavaScript API. */ function DebuggerClient(transport) { this._transport = transport; this._transport.hooks = this; this._pendingRequests = new Map(); this._activeRequests = new Map(); this._eventsEnabled = true; this.traits = {}; this.request = this.request.bind(this); this.localTransport = this._transport.onOutputStreamReady === undefined; /* * As the first thing on the connection, expect a greeting packet from * the connection's root actor. */ this.mainRoot = null; this.expectReply("root", packet => { this.mainRoot = new RootFront(this, packet); // Root Front is a special case, managing itself as it doesn't have any parent. // It will register itself to DebuggerClient as a Pool via Front._poolMap. this.mainRoot.manage(this.mainRoot); this.emit("connected", packet.applicationType, packet.traits); }); } /** * A declarative helper for defining methods that send requests to the server. * * @param packetSkeleton * The form of the packet to send. Can specify fields to be filled from * the parameters by using the |arg| function. * @param before * The function to call before sending the packet. Is passed the packet, * and the return value is used as the new packet. The |this| context is * the instance of the client object we are defining a method for. * @param after * The function to call after the response is received. It is passed the * response, and the return value is considered the new response that * will be passed to the callback. The |this| context is the instance of * the client object we are defining a method for. * @return Request * The `Request` object that is a Promise object and resolves once * we receive the response. (See request method for more details) */ DebuggerClient.requester = function(packetSkeleton, config = {}) { const { before, after } = config; return DevToolsUtils.makeInfallible(function(...args) { let outgoingPacket = { to: packetSkeleton.to || this.actor, }; let maxPosition = -1; for (const k of Object.keys(packetSkeleton)) { if (packetSkeleton[k] instanceof DebuggerClient.Argument) { const { position } = packetSkeleton[k]; outgoingPacket[k] = packetSkeleton[k].getArgument(args); maxPosition = Math.max(position, maxPosition); } else { outgoingPacket[k] = packetSkeleton[k]; } } if (before) { outgoingPacket = before.call(this, outgoingPacket); } return this.request( outgoingPacket, DevToolsUtils.makeInfallible(response => { if (after) { const { from } = response; response = after.call(this, response); if (!response.from) { response.from = from; } } // The callback is always the last parameter. const thisCallback = args[maxPosition + 1]; if (thisCallback) { thisCallback(response); } return response; }, "DebuggerClient.requester request callback") ); }, "DebuggerClient.requester"); }; function arg(pos) { return new DebuggerClient.Argument(pos); } exports.arg = arg; DebuggerClient.Argument = function(position) { this.position = position; }; DebuggerClient.Argument.prototype.getArgument = function(params) { if (!(this.position in params)) { throw new Error("Bad index into params: " + this.position); } return params[this.position]; }; // Expose these to save callers the trouble of importing DebuggerSocket DebuggerClient.socketConnect = function(options) { // Defined here instead of just copying the function to allow lazy-load return DebuggerSocket.connect(options); }; DevToolsUtils.defineLazyGetter(DebuggerClient, "Authenticators", () => { return Authentication.Authenticators; }); DevToolsUtils.defineLazyGetter(DebuggerClient, "AuthenticationResult", () => { return Authentication.AuthenticationResult; }); DebuggerClient.prototype = { /** * Connect to the server and start exchanging protocol messages. * * @param onConnected function * If specified, will be called when the greeting packet is * received from the debugging server. * * @return Promise * Resolves once connected with an array whose first element * is the application type, by default "browser", and the second * element is the traits object (help figure out the features * and behaviors of the server we connect to. See RootActor). */ connect: function(onConnected) { const deferred = promise.defer(); this.once("connected", (applicationType, traits) => { this.traits = traits; if (onConnected) { onConnected(applicationType, traits); } deferred.resolve([applicationType, traits]); }); this._transport.ready(); return deferred.promise; }, /** * Shut down communication with the debugging server. * * @param onClosed function * If specified, will be called when the debugging connection * has been closed. This parameter is deprecated - please use * the returned Promise. * @return Promise * Resolves after the underlying transport is closed. */ close: function(onClosed) { const deferred = promise.defer(); if (onClosed) { deferred.promise.then(onClosed); } // Disable detach event notifications, because event handlers will be in a // cleared scope by the time they run. this._eventsEnabled = false; const cleanup = () => { if (this._transport) { this._transport.close(); } this._transport = null; }; // If the connection is already closed, // there is no need to detach client // as we won't be able to send any message. if (this._closed) { cleanup(); deferred.resolve(); return deferred.promise; } this.once("closed", deferred.resolve); cleanup(); return deferred.promise; }, /** * Release an object actor. * * @param string actor * The actor ID to send the request to. */ release: DebuggerClient.requester({ to: arg(0), type: "release", }), /** * Send a request to the debugging server. * * @param packet object * A JSON packet to send to the debugging server. * @param onResponse function * If specified, will be called with the JSON response packet when * debugging server responds. * @return Request * This object emits a number of events to allow you to respond to * different parts of the request lifecycle. * It is also a Promise object, with a `then` method, that is resolved * whenever a JSON or a Bulk response is received; and is rejected * if the response is an error. * Note: This return value can be ignored if you are using JSON alone, * because the callback provided in |onResponse| will be bound to the * "json-reply" event automatically. * * Events emitted: * * json-reply: The server replied with a JSON packet, which is * passed as event data. * * bulk-reply: The server replied with bulk data, which you can read * using the event data object containing: * * actor: Name of actor that received the packet * * type: Name of actor's method that was 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. */ request: function(packet, onResponse) { if (!this.mainRoot) { throw Error("Have not yet received a hello packet from the server."); } const type = packet.type || ""; if (!packet.to) { throw Error("'" + type + "' request packet has no destination."); } // The onResponse callback might modify the response, so we need to call // it and resolve the promise with its result if it's truthy. const safeOnResponse = response => { if (!onResponse) { return response; } return onResponse(response) || response; }; if (this._closed) { const msg = "'" + type + "' request packet to " + "'" + packet.to + "' " + "can't be sent as the connection is closed."; const resp = { error: "connectionClosed", message: msg }; return promise.reject(safeOnResponse(resp)); } const request = new Request(packet); request.format = "json"; request.stack = getStack(); // Implement a Promise like API on the returned object // that resolves/rejects on request response const deferred = promise.defer(); function listenerJson(resp) { removeRequestListeners(); resp = safeOnResponse(resp); if (resp.error) { deferred.reject(resp); } else { deferred.resolve(resp); } } function listenerBulk(resp) { removeRequestListeners(); deferred.resolve(safeOnResponse(resp)); } const removeRequestListeners = () => { request.off("json-reply", listenerJson); request.off("bulk-reply", listenerBulk); }; request.on("json-reply", listenerJson); request.on("bulk-reply", listenerBulk); this._sendOrQueueRequest(request); request.then = deferred.promise.then.bind(deferred.promise); return request; }, /** * Transmit streaming data via a bulk request. * * This method initiates the bulk send process by queuing up the header data. * The caller receives eventual access to a stream for writing. * * Since this opens up more options for how the server might respond (it could * send back either JSON or bulk data), and the returned Request object emits * events for different stages of the request process that you may want to * react to. * * @param request Object * This is modeled after the format of JSON packets above, but does not * actually contain the data, but is instead just a routing header: * * 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 sent * @return Request * This object emits a number of events to allow you to respond to * different parts of the request lifecycle. * * Events emitted: * * bulk-send-ready: Ready to send bulk data to the server, using the * event data object containing: * * stream: This output stream should only be used directly if * you can ensure that you will write exactly |length| * bytes and will not close the stream when writing is * complete * * done: If you use the stream directly (instead of |copyFrom| * 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 |copyFrom|, resolving is taken care of for * you when copying completes. * * copyFrom: A helper function for getting your data onto the * stream that meets the stream handling requirements * above, and has the following signature: * @param input nsIAsyncInputStream * The stream to copy from. * @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. * * json-reply: The server replied with a JSON packet, which is * passed as event data. * * bulk-reply: The server replied with bulk data, which you can read * using the event data object containing: * * actor: Name of actor that received the packet * * type: Name of actor's method that was 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. */ startBulkRequest: function(request) { if (!this.traits.bulk) { throw Error("Server doesn't support bulk transfers"); } if (!this.mainRoot) { throw Error("Have not yet received a hello packet from the server."); } if (!request.type) { throw Error("Bulk packet is missing the required 'type' field."); } if (!request.actor) { throw Error("'" + request.type + "' bulk packet has no destination."); } if (!request.length) { throw Error("'" + request.type + "' bulk packet has no length."); } request = new Request(request); request.format = "bulk"; this._sendOrQueueRequest(request); return request; }, /** * If a new request can be sent immediately, do so. Otherwise, queue it. */ _sendOrQueueRequest(request) { const actor = request.actor; if (!this._activeRequests.has(actor)) { this._sendRequest(request); } else { this._queueRequest(request); } }, /** * Send a request. * @throws Error if there is already an active request in flight for the same * actor. */ _sendRequest(request) { const actor = request.actor; this.expectReply(actor, request); if (request.format === "json") { this._transport.send(request.request); return; } this._transport.startBulkSend(request.request).then((...args) => { request.emit("bulk-send-ready", ...args); }); }, /** * Queue a request to be sent later. Queues are only drained when an in * flight request to a given actor completes. */ _queueRequest(request) { const actor = request.actor; const queue = this._pendingRequests.get(actor) || []; queue.push(request); this._pendingRequests.set(actor, queue); }, /** * Attempt the next request to a given actor (if any). */ _attemptNextRequest(actor) { if (this._activeRequests.has(actor)) { return; } const queue = this._pendingRequests.get(actor); if (!queue) { return; } const request = queue.shift(); if (queue.length === 0) { this._pendingRequests.delete(actor); } this._sendRequest(request); }, /** * Arrange to hand the next reply from |actor| to the handler bound to * |request|. * * DebuggerClient.prototype.request / startBulkRequest usually takes care of * establishing the handler for a given request, but in rare cases (well, * greetings from new root actors, is the only case at the moment) we must be * prepared for a "reply" that doesn't correspond to any request we sent. */ expectReply: function(actor, request) { if (this._activeRequests.has(actor)) { throw Error("clashing handlers for next reply from " + actor); } // If a handler is passed directly (as it is with the handler for the root // actor greeting), create a dummy request to bind this to. if (typeof request === "function") { const handler = request; request = new Request(); request.on("json-reply", handler); } this._activeRequests.set(actor, request); }, // Transport hooks. /** * Called by DebuggerTransport to dispatch incoming packets as appropriate. * * @param packet object * The incoming packet. */ onPacket: function(packet) { if (!packet.from) { DevToolsUtils.reportException( "onPacket", new Error( "Server did not specify an actor, dropping packet: " + JSON.stringify(packet) ) ); return; } // Check for "forwardingCancelled" here instead of using a front to handle it. // This is necessary because we might receive this event while the client is closing, // and the fronts have already been removed by that point. if ( this.mainRoot && packet.from == this.mainRoot.actorID && packet.type == "forwardingCancelled" ) { this.purgeRequests(packet.prefix); return; } // support older browsers for Fx69+ for using the old thread client if (!this.traits.hasThreadFront && packet.from.includes("context")) { this.sendToDeprecatedThreadClient(packet); return; } // If we have a registered Front for this actor, let it handle the packet // and skip all the rest of this unpleasantness. const front = this.getActor(packet.from); if (front) { front.onPacket(packet); return; } let activeRequest; // See if we have a handler function waiting for a reply from this // actor. (Don't count unsolicited notifications or pauses as // replies.) if ( this._activeRequests.has(packet.from) && !(packet.type in UnsolicitedNotifications) ) { activeRequest = this._activeRequests.get(packet.from); this._activeRequests.delete(packet.from); } // If there is a subsequent request for the same actor, hand it off to the // transport. Delivery of packets on the other end is always async, even // in the local transport case. this._attemptNextRequest(packet.from); // Only try to notify listeners on events, not responses to requests // that lack a packet type. if (packet.type) { this.emit(packet.type, packet); } if (activeRequest) { const emitReply = () => activeRequest.emit("json-reply", packet); if (activeRequest.stack) { callFunctionWithAsyncStack( emitReply, activeRequest.stack, "DevTools RDP" ); } else { emitReply(); } } }, // support older browsers for Fx69+ // The code duplication here is intentional until we drop support for // these versions. Once that happens this code can be deleted. sendToDeprecatedThreadClient(packet) { const deprecatedThreadClient = this.getActor(packet.from); if (deprecatedThreadClient && packet.type) { const type = packet.type; if (deprecatedThreadClient.events.includes(type)) { deprecatedThreadClient.emit(type, packet); // we ignore the rest, as the client is expected to handle this packet. return; } } let activeRequest; // See if we have a handler function waiting for a reply from this // actor. (Don't count unsolicited notifications or pauses as // replies.) if ( this._activeRequests.has(packet.from) && !( packet.type == ThreadStateTypes.paused && packet.why.type in UnsolicitedPauses ) ) { activeRequest = this._activeRequests.get(packet.from); this._activeRequests.delete(packet.from); } // If there is a subsequent request for the same actor, hand it off to the // transport. Delivery of packets on the other end is always async, even // in the local transport case. this._attemptNextRequest(packet.from); // Packets that indicate thread state changes get special treatment. if ( packet.type in ThreadStateTypes && deprecatedThreadClient && typeof deprecatedThreadClient._onThreadState == "function" ) { deprecatedThreadClient._onThreadState(packet); } // Only try to notify listeners on events, not responses to requests // that lack a packet type. if (packet.type) { this.emit(packet.type, packet); } if (activeRequest) { activeRequest.emit("json-reply", packet); } }, /** * 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: function(packet) { const { actor } = packet; if (!actor) { DevToolsUtils.reportException( "onBulkPacket", new Error( "Server did not specify an actor, dropping bulk packet: " + JSON.stringify(packet) ) ); return; } // See if we have a handler function waiting for a reply from this // actor. if (!this._activeRequests.has(actor)) { return; } const activeRequest = this._activeRequests.get(actor); this._activeRequests.delete(actor); // If there is a subsequent request for the same actor, hand it off to the // transport. Delivery of packets on the other end is always async, even // in the local transport case. this._attemptNextRequest(actor); activeRequest.emit("bulk-reply", packet); }, /** * 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: function() { if (this._closed) { return; } this._closed = true; this.emit("closed"); this.purgeRequests(); // The |_pools| array on the client-side currently is used only by // protocol.js to store active fronts, mirroring the actor pools found in // the server. So, read all usages of "pool" as "protocol.js front". // // In the normal case where we shutdown cleanly, the toolbox tells each tool // to close, and they each call |destroy| on any fronts they were using. // When |destroy| or |cleanup| is called on a protocol.js front, it also // removes itself from the |_pools| array. Once the toolbox has shutdown, // the connection is closed, and we reach here. All fronts (should have // been) |destroy|ed, so |_pools| should empty. // // If the connection instead aborts unexpectedly, we may end up here with // all fronts used during the life of the connection. So, we call |cleanup| // on them clear their state, reject pending requests, and remove themselves // from |_pools|. This saves the toolbox from hanging indefinitely, in case // it waits for some server response before shutdown that will now never // arrive. for (const pool of this._pools) { pool.cleanup(); } }, /** * Purge pending and active requests in this client. * * @param prefix string (optional) * If a prefix is given, only requests for actor IDs that start with the prefix * will be cleaned up. This is useful when forwarding of a portion of requests * is cancelled on the server. */ purgeRequests(prefix = "") { const reject = function(type, request) { // Server can send packets on its own and client only pass a callback // to expectReply, so that there is no request object. let msg; if (request.request) { msg = "'" + request.request.type + "' " + type + " request packet" + " to '" + request.actor + "' " + "can't be sent as the connection just closed."; } else { msg = "server side packet can't be received as the connection just closed."; } const packet = { error: "connectionClosed", message: msg }; request.emit("json-reply", packet); }; let pendingRequestsToReject = []; this._pendingRequests.forEach((requests, actor) => { if (!actor.startsWith(prefix)) { return; } this._pendingRequests.delete(actor); pendingRequestsToReject = pendingRequestsToReject.concat(requests); }); pendingRequestsToReject.forEach(request => reject("pending", request)); let activeRequestsToReject = []; this._activeRequests.forEach((request, actor) => { if (!actor.startsWith(prefix)) { return; } this._activeRequests.delete(actor); activeRequestsToReject = activeRequestsToReject.concat(request); }); activeRequestsToReject.forEach(request => reject("active", request)); }, /** * Search for all requests in process for this client, including those made via * protocol.js and wait all of them to complete. Since the requests seen when this is * first called may in turn trigger more requests, we keep recursing through this * function until there is no more activity. * * This is a fairly heavy weight process, so it's only meant to be used in tests. * * @return Promise * Resolved when all requests have settled. */ waitForRequestsToSettle() { let requests = []; // Gather all pending and active requests in this client // The request object supports a Promise API for completion (it has .then()) this._pendingRequests.forEach(requestsForActor => { // Each value is an array of pending requests requests = requests.concat(requestsForActor); }); this._activeRequests.forEach(requestForActor => { // Each value is a single active request requests = requests.concat(requestForActor); }); // protocol.js // Use a Set because some fronts (like domwalker) seem to have multiple parents. const fronts = new Set(); const poolsToVisit = [...this._pools]; // With protocol.js, each front can potentially have it's own pools containing child // fronts, forming a tree. Descend through all the pools to locate all child fronts. while (poolsToVisit.length) { const pool = poolsToVisit.shift(); // `_pools` contains either Front's or Pool's, we only want to collect Fronts here. // Front inherits from Pool which exposes `poolChildren`. if (pool instanceof Front) { fronts.add(pool); } for (const child of pool.poolChildren()) { poolsToVisit.push(child); } } // For each front, wait for its requests to settle for (const front of fronts) { if (front.hasRequests()) { requests.push(front.waitForRequestsToSettle()); } } // Abort early if there are no requests if (!requests.length) { return Promise.resolve(); } return DevToolsUtils.settleAll(requests) .catch(() => { // One of the requests might have failed, but ignore that situation here and pipe // both success and failure through the same path. The important part is just that // we waited. }) .then(() => { // Repeat, more requests may have started in response to those we just waited for return this.waitForRequestsToSettle(); }); }, /** * Actor lifetime management, echos the server's actor pools. */ __pools: null, get _pools() { if (this.__pools) { return this.__pools; } this.__pools = new Set(); return this.__pools; }, addActorPool: function(pool) { this._pools.add(pool); }, removeActorPool: function(pool) { this._pools.delete(pool); }, getActor: function(actorID) { const pool = this.poolFor(actorID); return pool ? pool.get(actorID) : null; }, poolFor: function(actorID) { for (const pool of this._pools) { if (pool.has(actorID)) { return pool; } } return null; }, /** * Currently attached addon. */ activeAddon: null, /** * Creates an object client for this DebuggerClient and the grip in parameter, * @param {Object} grip: The grip to create the ObjectClient for. * @returns {ObjectClient} */ createObjectClient: function(grip) { return new ObjectClient(this, grip); }, get transport() { return this._transport; }, }; EventEmitter.decorate(DebuggerClient.prototype); class Request extends EventEmitter { constructor(request) { super(); this.request = request; } get actor() { return this.request.to || this.request.actor; } } module.exports = { arg, DebuggerClient, };