diff options
Diffstat (limited to 'remote/marionette/server.sys.mjs')
-rw-r--r-- | remote/marionette/server.sys.mjs | 462 |
1 files changed, 462 insertions, 0 deletions
diff --git a/remote/marionette/server.sys.mjs b/remote/marionette/server.sys.mjs new file mode 100644 index 0000000000..49d8055ea6 --- /dev/null +++ b/remote/marionette/server.sys.mjs @@ -0,0 +1,462 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +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", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); +XPCOMUtils.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.<number, number, ?, ?>} 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 + * <code>resp.send()</code>, 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<string, ?>} payload + * The payload to ship. + */ + sendRaw(payload) { + this.conn.send(payload); + } + + toString() { + return `[object TCPConnection ${this.id}]`; + } +} |