summaryrefslogtreecommitdiffstats
path: root/remote/marionette/server.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'remote/marionette/server.sys.mjs')
-rw-r--r--remote/marionette/server.sys.mjs460
1 files changed, 460 insertions, 0 deletions
diff --git a/remote/marionette/server.sys.mjs b/remote/marionette/server.sys.mjs
new file mode 100644
index 0000000000..36e7a9d639
--- /dev/null
+++ b/remote/marionette/server.sys.mjs
@@ -0,0 +1,460 @@
+/* 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.<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}]`;
+ }
+}