mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-28 15:23:51 +00:00
7ccbffb9f7
Differential Revision: https://phabricator.services.mozilla.com/D174738
447 lines
12 KiB
JavaScript
447 lines
12 KiB
JavaScript
/* 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",
|
|
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);
|
|
}
|
|
|
|
set acceptConnections(value) {
|
|
if (value) {
|
|
if (!this.socket) {
|
|
try {
|
|
const flags = KeepWhenOffline | LoopbackOnly;
|
|
const backlog = 1;
|
|
this.socket = new lazy.ServerSocket(this.port, flags, backlog);
|
|
} catch (e) {
|
|
throw new Error(`Could not bind to port ${this.port} (${e.name})`);
|
|
}
|
|
|
|
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}.
|
|
*/
|
|
start() {
|
|
if (this.alive) {
|
|
return;
|
|
}
|
|
|
|
// Start socket server and listening for connection attempts
|
|
this.acceptConnections = true;
|
|
lazy.MarionettePrefs.port = this.port;
|
|
this.alive = true;
|
|
}
|
|
|
|
stop() {
|
|
if (!this.alive) {
|
|
return;
|
|
}
|
|
|
|
// Shutdown server socket, and no longer listen for new connections
|
|
this.acceptConnections = 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}]`;
|
|
}
|
|
}
|