/* 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/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", Command: "chrome://remote/content/marionette/message.sys.mjs", DebuggerTransport: "chrome://remote/content/marionette/transport.sys.mjs", error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", GeckoDriver: "chrome://remote/content/marionette/driver.sys.mjs", Log: "chrome://remote/content/shared/Log.sys.mjs", MarionettePrefs: "chrome://remote/content/marionette/prefs.sys.mjs", Message: "chrome://remote/content/marionette/message.sys.mjs", PollPromise: "chrome://remote/content/shared/Sync.sys.mjs", Response: "chrome://remote/content/marionette/message.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get(lazy.Log.TYPES.MARIONETTE) ); ChromeUtils.defineLazyGetter(lazy, "ServerSocket", () => { return Components.Constructor( "@mozilla.org/network/server-socket;1", "nsIServerSocket", "initSpecialConnection" ); }); const { KeepWhenOffline, LoopbackOnly } = Ci.nsIServerSocket; const PROTOCOL_VERSION = 3; /** * Bootstraps Marionette and handles incoming client connections. * * Starting the Marionette server will open a TCP socket sporting the * debugger transport interface on the provided `port`. For every * new connection, a {@link TCPConnection} is created. */ export class TCPListener { /** * @param {number} port * Port for server to listen to. */ constructor(port) { this.port = port; this.socket = null; this.conns = new Set(); this.nextConnID = 0; this.alive = false; } /** * Function produces a {@link GeckoDriver}. * * Determines the application to initialise the driver with. * * @returns {GeckoDriver} * A driver instance. */ driverFactory() { return new lazy.GeckoDriver(this); } async setAcceptConnections(value) { if (value) { if (!this.socket) { await lazy.PollPromise( (resolve, reject) => { try { const flags = KeepWhenOffline | LoopbackOnly; const backlog = 1; this.socket = new lazy.ServerSocket(this.port, flags, backlog); resolve(); } catch (e) { lazy.logger.debug( `Could not bind to port ${this.port} (${e.name})` ); reject(); } }, { interval: 250, timeout: 5000 } ); // Since PollPromise doesn't throw when timeout expires, // we can end up in the situation when the socket is undefined. if (!this.socket) { throw new Error(`Could not bind to port ${this.port}`); } this.port = this.socket.port; this.socket.asyncListen(this); lazy.logger.info(`Listening on port ${this.port}`); } } else if (this.socket) { // Note that closing the server socket will not close currently active // connections. this.socket.close(); this.socket = null; lazy.logger.info(`Stopped listening on port ${this.port}`); } } /** * Bind this listener to {@link #port} and start accepting incoming * socket connections on {@link #onSocketAccepted}. * * The marionette.port preference will be populated with the value * of {@link #port}. */ async start() { if (this.alive) { return; } // Start socket server and listening for connection attempts await this.setAcceptConnections(true); lazy.MarionettePrefs.port = this.port; this.alive = true; } async stop() { if (!this.alive) { return; } // Shutdown server socket, and no longer listen for new connections await this.setAcceptConnections(false); this.alive = false; } onSocketAccepted(serverSocket, clientSocket) { let input = clientSocket.openInputStream(0, 0, 0); let output = clientSocket.openOutputStream(0, 0, 0); let transport = new lazy.DebuggerTransport(input, output); // Only allow a single active WebDriver session at a time const hasActiveSession = [...this.conns].find( conn => !!conn.driver.currentSession ); if (hasActiveSession) { lazy.logger.warn( "Connection attempt denied because an active session has been found" ); // Ideally we should stop the server to listen for new connection // attempts, but the current architecture doesn't allow us to do that. // As such just close the transport if no further connections are allowed. transport.close(); return; } let conn = new TCPConnection( this.nextConnID++, transport, this.driverFactory.bind(this) ); conn.onclose = this.onConnectionClosed.bind(this); this.conns.add(conn); lazy.logger.debug( `Accepted connection ${conn.id} ` + `from ${clientSocket.host}:${clientSocket.port}` ); conn.sayHello(); transport.ready(); } onConnectionClosed(conn) { lazy.logger.debug(`Closed connection ${conn.id}`); this.conns.delete(conn); } } /** * Marionette client connection. * * Dispatches packets received to their correct service destinations * and sends back the service endpoint's return values. * * @param {number} connID * Unique identifier of the connection this dispatcher should handle. * @param {DebuggerTransport} transport * Debugger transport connection to the client. * @param {function(): GeckoDriver} driverFactory * Factory function that produces a {@link GeckoDriver}. */ export class TCPConnection { constructor(connID, transport, driverFactory) { this.id = connID; this.conn = transport; // transport hooks are TCPConnection#onPacket // and TCPConnection#onClosed this.conn.hooks = this; // callback for when connection is closed this.onclose = null; // last received/sent message ID this.lastID = 0; this.driver = driverFactory(); } #log(msg) { let dir = msg.origin == lazy.Message.Origin.Client ? "->" : "<-"; lazy.logger.debug(`${this.id} ${dir} ${msg.toString()}`); } /** * Debugger transport callback that cleans up * after a connection is closed. */ onClosed() { this.driver.deleteSession(); if (this.onclose) { this.onclose(this); } } /** * Callback that receives data packets from the client. * * If the message is a Response, we look up the command previously * issued to the client and run its callback, if any. In case of * a Command, the corresponding is executed. * * @param {Array.} data * A four element array where the elements, in sequence, signifies * message type, message ID, method name or error, and parameters * or result. */ onPacket(data) { // unable to determine how to respond if (!Array.isArray(data)) { let e = new TypeError( "Unable to unmarshal packet data: " + JSON.stringify(data) ); lazy.error.report(e); return; } // return immediately with any error trying to unmarshal message let msg; try { msg = lazy.Message.fromPacket(data); msg.origin = lazy.Message.Origin.Client; this.#log(msg); } catch (e) { let resp = this.createResponse(data[1]); resp.sendError(e); return; } // execute new command if (msg instanceof lazy.Command) { (async () => { await this.execute(msg); })(); } else { lazy.logger.fatal("Cannot process messages other than Command"); } } /** * Executes a Marionette command and sends back a response when it * has finished executing. * * If the command implementation sends the response itself by calling * resp.send(), the response is guaranteed to not be * sent twice. * * Errors thrown in commands are marshaled and sent back, and if they * are not {@link WebDriverError} instances, they are additionally * propagated and reported to {@link Components.utils.reportError}. * * @param {Command} cmd * Command to execute. */ async execute(cmd) { let resp = this.createResponse(cmd.id); let sendResponse = () => resp.sendConditionally(resp => !resp.sent); let sendError = resp.sendError.bind(resp); await this.despatch(cmd, resp) .then(sendResponse, sendError) .catch(lazy.error.report); } /** * Despatches command to appropriate Marionette service. * * @param {Command} cmd * Command to run. * @param {Response} resp * Mutable response where the command's return value will be * assigned. * * @throws {Error} * A command's implementation may throw at any time. */ async despatch(cmd, resp) { const startTime = Cu.now(); let fn = this.driver.commands[cmd.name]; if (typeof fn == "undefined") { throw new lazy.error.UnknownCommandError(cmd.name); } if (cmd.name != "WebDriver:NewSession") { lazy.assert.session(this.driver.currentSession); } let rv = await fn.bind(this.driver)(cmd); // Bug 1819029: Some older commands cannot return a response wrapped within // a value field because it would break compatibility with geckodriver and // Marionette client. It's unlikely that we are going to fix that. // // Warning: No more commands should be added to this list! const commandsNoValueResponse = [ "Marionette:Quit", "WebDriver:FindElements", "WebDriver:FindElementsFromShadowRoot", "WebDriver:CloseChromeWindow", "WebDriver:CloseWindow", "WebDriver:FullscreenWindow", "WebDriver:GetCookies", "WebDriver:GetElementRect", "WebDriver:GetTimeouts", "WebDriver:GetWindowHandles", "WebDriver:GetWindowRect", "WebDriver:MaximizeWindow", "WebDriver:MinimizeWindow", "WebDriver:NewSession", "WebDriver:NewWindow", "WebDriver:SetWindowRect", ]; if (rv != null) { // By default the Response' constructor sets the body to `{ value: null }`. // As such we only want to override the value if it's neither `null` nor // `undefined`. if (commandsNoValueResponse.includes(cmd.name)) { resp.body = rv; } else { resp.body.value = rv; } } if (Services.profiler?.IsActive()) { ChromeUtils.addProfilerMarker( "Marionette: Command", { startTime, category: "Remote-Protocol" }, `${cmd.name} (${cmd.id})` ); } } /** * Fail-safe creation of a new instance of {@link Response}. * * @param {number} msgID * Message ID to respond to. If it is not a number, -1 is used. * * @returns {Response} * Response to the message with `msgID`. */ createResponse(msgID) { if (typeof msgID != "number") { msgID = -1; } return new lazy.Response(msgID, this.send.bind(this)); } sendError(err, cmdID) { let resp = new lazy.Response(cmdID, this.send.bind(this)); resp.sendError(err); } /** * When a client connects we send across a JSON Object defining the * protocol level. * * This is the only message sent by Marionette that does not follow * the regular message format. */ sayHello() { let whatHo = { applicationType: "gecko", marionetteProtocol: PROTOCOL_VERSION, }; this.sendRaw(whatHo); } /** * Delegates message to client based on the provided {@code cmdID}. * The message is sent over the debugger transport socket. * * The command ID is a unique identifier assigned to the client's request * that is used to distinguish the asynchronous responses. * * Whilst responses to commands are synchronous and must be sent in the * correct order. * * @param {Message} msg * The command or response to send. */ send(msg) { msg.origin = lazy.Message.Origin.Server; if (msg instanceof lazy.Response) { this.sendToClient(msg); } else { lazy.logger.fatal("Cannot send messages other than Response"); } } // Low-level methods: /** * Send given response to the client over the debugger transport socket. * * @param {Response} resp * The response to send back to the client. */ sendToClient(resp) { this.sendMessage(resp); } /** * Marshal message to the Marionette message format and send it. * * @param {Message} msg * The message to send. */ sendMessage(msg) { this.#log(msg); let payload = msg.toPacket(); this.sendRaw(payload); } /** * Send the given payload over the debugger transport socket to the * connected client. * * @param {Object} payload * The payload to ship. */ sendRaw(payload) { this.conn.send(payload); } toString() { return `[object TCPConnection ${this.id}]`; } }