server.sys.mjs (13179B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", 9 Command: "chrome://remote/content/marionette/message.sys.mjs", 10 DebuggerTransport: "chrome://remote/content/marionette/transport.sys.mjs", 11 error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", 12 GeckoDriver: "chrome://remote/content/marionette/driver.sys.mjs", 13 Log: "chrome://remote/content/shared/Log.sys.mjs", 14 MarionettePrefs: "chrome://remote/content/marionette/prefs.sys.mjs", 15 Message: "chrome://remote/content/marionette/message.sys.mjs", 16 PollPromise: "chrome://remote/content/shared/Sync.sys.mjs", 17 Response: "chrome://remote/content/marionette/message.sys.mjs", 18 }); 19 20 ChromeUtils.defineLazyGetter(lazy, "logger", () => 21 lazy.Log.get(lazy.Log.TYPES.MARIONETTE) 22 ); 23 ChromeUtils.defineLazyGetter(lazy, "ServerSocket", () => { 24 return Components.Constructor( 25 "@mozilla.org/network/server-socket;1", 26 "nsIServerSocket", 27 "initSpecialConnection" 28 ); 29 }); 30 31 const { KeepWhenOffline, LoopbackOnly } = Ci.nsIServerSocket; 32 33 const PROTOCOL_VERSION = 3; 34 35 /** 36 * Bootstraps Marionette and handles incoming client connections. 37 * 38 * Starting the Marionette server will open a TCP socket sporting the 39 * debugger transport interface on the provided `port`. For every 40 * new connection, a {@link TCPConnection} is created. 41 */ 42 export class TCPListener { 43 /** 44 * @param {number} port 45 * Port for server to listen to. 46 */ 47 constructor(port) { 48 this.port = port; 49 this.socket = null; 50 this.conns = new Set(); 51 this.nextConnID = 0; 52 this.alive = false; 53 } 54 55 /** 56 * Function produces a {@link GeckoDriver}. 57 * 58 * Determines the application to initialise the driver with. 59 * 60 * @returns {GeckoDriver} 61 * A driver instance. 62 */ 63 driverFactory() { 64 return new lazy.GeckoDriver(this); 65 } 66 67 async setAcceptConnections(value) { 68 if (value) { 69 if (!this.socket) { 70 await lazy.PollPromise( 71 (resolve, reject) => { 72 try { 73 const flags = KeepWhenOffline | LoopbackOnly; 74 const backlog = 1; 75 this.socket = new lazy.ServerSocket(this.port, flags, backlog); 76 resolve(); 77 } catch (e) { 78 lazy.logger.debug( 79 `Could not bind to port ${this.port} (${e.name})` 80 ); 81 reject(); 82 } 83 }, 84 { interval: 250, timeout: 5000 } 85 ); 86 87 // Since PollPromise doesn't throw when timeout expires, 88 // we can end up in the situation when the socket is undefined. 89 if (!this.socket) { 90 throw new Error(`Could not bind to port ${this.port}`); 91 } 92 93 this.port = this.socket.port; 94 95 this.socket.asyncListen(this); 96 lazy.logger.info(`Listening on port ${this.port}`); 97 } 98 } else if (this.socket) { 99 // Note that closing the server socket will not close currently active 100 // connections. 101 this.socket.close(); 102 this.socket = null; 103 lazy.logger.info(`Stopped listening on port ${this.port}`); 104 } 105 } 106 107 /** 108 * Bind this listener to {@link #port} and start accepting incoming 109 * socket connections on {@link #onSocketAccepted}. 110 * 111 * The marionette.port preference will be populated with the value 112 * of {@link #port}. 113 */ 114 async start() { 115 if (this.alive) { 116 return; 117 } 118 119 // Start socket server and listening for connection attempts 120 await this.setAcceptConnections(true); 121 lazy.MarionettePrefs.port = this.port; 122 this.alive = true; 123 } 124 125 async stop() { 126 if (!this.alive) { 127 return; 128 } 129 130 // Shutdown server socket, and no longer listen for new connections 131 await this.setAcceptConnections(false); 132 this.alive = false; 133 } 134 135 onSocketAccepted(serverSocket, clientSocket) { 136 let input = clientSocket.openInputStream(0, 0, 0); 137 let output = clientSocket.openOutputStream(0, 0, 0); 138 let transport = new lazy.DebuggerTransport(input, output); 139 140 // Only allow a single active WebDriver session at a time 141 const hasActiveSession = [...this.conns].find( 142 conn => !!conn.driver.currentSession 143 ); 144 if (hasActiveSession) { 145 lazy.logger.warn( 146 "Connection attempt denied because an active session has been found" 147 ); 148 149 // Ideally we should stop the server to listen for new connection 150 // attempts, but the current architecture doesn't allow us to do that. 151 // As such just close the transport if no further connections are allowed. 152 transport.close(); 153 return; 154 } 155 156 let conn = new TCPConnection( 157 this.nextConnID++, 158 transport, 159 this.driverFactory.bind(this) 160 ); 161 conn.onclose = this.onConnectionClosed.bind(this); 162 this.conns.add(conn); 163 164 lazy.logger.debug( 165 `Accepted connection ${conn.id} ` + 166 `from ${clientSocket.host}:${clientSocket.port}` 167 ); 168 conn.sayHello(); 169 transport.ready(); 170 } 171 172 onConnectionClosed(conn) { 173 lazy.logger.debug(`Closed connection ${conn.id}`); 174 this.conns.delete(conn); 175 } 176 } 177 178 /** 179 * Marionette client connection. 180 * 181 * Dispatches packets received to their correct service destinations 182 * and sends back the service endpoint's return values. 183 * 184 * @param {number} connID 185 * Unique identifier of the connection this dispatcher should handle. 186 * @param {DebuggerTransport} transport 187 * Debugger transport connection to the client. 188 * @param {function(): GeckoDriver} driverFactory 189 * Factory function that produces a {@link GeckoDriver}. 190 */ 191 export class TCPConnection { 192 constructor(connID, transport, driverFactory) { 193 this.id = connID; 194 this.conn = transport; 195 196 // transport hooks are TCPConnection#onPacket 197 // and TCPConnection#onClosed 198 this.conn.hooks = this; 199 200 // callback for when connection is closed 201 this.onclose = null; 202 203 // last received/sent message ID 204 this.lastID = 0; 205 206 this.driver = driverFactory(); 207 } 208 209 #log(msg) { 210 let dir = msg.origin == lazy.Message.Origin.Client ? "->" : "<-"; 211 lazy.logger.debug(`${this.id} ${dir} ${msg.toString()}`); 212 } 213 214 /** 215 * Debugger transport callback that cleans up 216 * after a connection is closed. 217 */ 218 onClosed() { 219 this.driver.deleteSession(); 220 if (this.onclose) { 221 this.onclose(this); 222 } 223 } 224 225 /** 226 * Callback that receives data packets from the client. 227 * 228 * If the message is a Response, we look up the command previously 229 * issued to the client and run its callback, if any. In case of 230 * a Command, the corresponding is executed. 231 * 232 * @param {Array.<number, number, ?, ?>} data 233 * A four element array where the elements, in sequence, signifies 234 * message type, message ID, method name or error, and parameters 235 * or result. 236 */ 237 onPacket(data) { 238 // unable to determine how to respond 239 if (!Array.isArray(data)) { 240 let e = new TypeError( 241 "Unable to unmarshal packet data: " + JSON.stringify(data) 242 ); 243 lazy.error.report(e); 244 return; 245 } 246 247 // return immediately with any error trying to unmarshal message 248 let msg; 249 try { 250 msg = lazy.Message.fromPacket(data); 251 msg.origin = lazy.Message.Origin.Client; 252 this.#log(msg); 253 } catch (e) { 254 let resp = this.createResponse(data[1]); 255 resp.sendError(e); 256 return; 257 } 258 259 // execute new command 260 if (msg instanceof lazy.Command) { 261 (async () => { 262 await this.execute(msg); 263 })(); 264 } else { 265 lazy.logger.fatal("Cannot process messages other than Command"); 266 } 267 } 268 269 /** 270 * Executes a Marionette command and sends back a response when it 271 * has finished executing. 272 * 273 * If the command implementation sends the response itself by calling 274 * <code>resp.send()</code>, the response is guaranteed to not be 275 * sent twice. 276 * 277 * Errors thrown in commands are marshaled and sent back, and if they 278 * are not {@link WebDriverError} instances, they are additionally 279 * propagated and reported to {@link Components.utils.reportError}. 280 * 281 * @param {Command} cmd 282 * Command to execute. 283 */ 284 async execute(cmd) { 285 let resp = this.createResponse(cmd.id); 286 let sendResponse = () => resp.sendConditionally(resp => !resp.sent); 287 let sendError = resp.sendError.bind(resp); 288 289 await this.dispatch(cmd, resp) 290 .then(sendResponse, sendError) 291 .catch(lazy.error.report); 292 } 293 294 /** 295 * Dispatches command to appropriate Marionette service. 296 * 297 * @param {Command} cmd 298 * Command to run. 299 * @param {Response} resp 300 * Mutable response where the command's return value will be 301 * assigned. 302 * 303 * @throws {Error} 304 * A command's implementation may throw at any time. 305 */ 306 async dispatch(cmd, resp) { 307 const startTime = ChromeUtils.now(); 308 309 let fn = this.driver.commands[cmd.name]; 310 if (typeof fn == "undefined") { 311 throw new lazy.error.UnknownCommandError(cmd.name); 312 } 313 314 if (cmd.name != "WebDriver:NewSession") { 315 lazy.assert.session(this.driver.currentSession); 316 } 317 318 let rv = await fn.bind(this.driver)(cmd); 319 320 // Bug 1819029: Some older commands cannot return a response wrapped within 321 // a value field because it would break compatibility with geckodriver and 322 // Marionette client. It's unlikely that we are going to fix that. 323 // 324 // Warning: No more commands should be added to this list! 325 const commandsNoValueResponse = [ 326 "Marionette:Quit", 327 "WebDriver:FindElements", 328 "WebDriver:FindElementsFromShadowRoot", 329 "WebDriver:CloseChromeWindow", 330 "WebDriver:CloseWindow", 331 "WebDriver:FullscreenWindow", 332 "WebDriver:GetCookies", 333 "WebDriver:GetElementRect", 334 "WebDriver:GetTimeouts", 335 "WebDriver:GetWindowHandles", 336 "WebDriver:GetWindowRect", 337 "WebDriver:MaximizeWindow", 338 "WebDriver:MinimizeWindow", 339 "WebDriver:NewSession", 340 "WebDriver:NewWindow", 341 "WebDriver:SetWindowRect", 342 ]; 343 344 if (rv != null) { 345 // By default the Response' constructor sets the body to `{ value: null }`. 346 // As such we only want to override the value if it's neither `null` nor 347 // `undefined`. 348 if (commandsNoValueResponse.includes(cmd.name)) { 349 resp.body = rv; 350 } else { 351 resp.body.value = rv; 352 } 353 } 354 355 if (Services.profiler?.IsActive()) { 356 ChromeUtils.addProfilerMarker( 357 "Marionette: Command", 358 { startTime, category: "Remote-Protocol" }, 359 `${cmd.name} (${cmd.id})` 360 ); 361 } 362 } 363 364 /** 365 * Fail-safe creation of a new instance of {@link Response}. 366 * 367 * @param {number} msgID 368 * Message ID to respond to. If it is not a number, -1 is used. 369 * 370 * @returns {Response} 371 * Response to the message with `msgID`. 372 */ 373 createResponse(msgID) { 374 if (typeof msgID != "number") { 375 msgID = -1; 376 } 377 return new lazy.Response(msgID, this.send.bind(this)); 378 } 379 380 sendError(err, cmdID) { 381 let resp = new lazy.Response(cmdID, this.send.bind(this)); 382 resp.sendError(err); 383 } 384 385 /** 386 * When a client connects we send across a JSON Object defining the 387 * protocol level. 388 * 389 * This is the only message sent by Marionette that does not follow 390 * the regular message format. 391 */ 392 sayHello() { 393 let whatHo = { 394 applicationType: "gecko", 395 marionetteProtocol: PROTOCOL_VERSION, 396 }; 397 this.sendRaw(whatHo); 398 } 399 400 /** 401 * Delegates message to client based on the provided `cmdID`. 402 * The message is sent over the debugger transport socket. 403 * 404 * The command ID is a unique identifier assigned to the client's request 405 * that is used to distinguish the asynchronous responses. 406 * 407 * Whilst responses to commands are synchronous and must be sent in the 408 * correct order. 409 * 410 * @param {Message} msg 411 * The command or response to send. 412 */ 413 send(msg) { 414 msg.origin = lazy.Message.Origin.Server; 415 if (msg instanceof lazy.Response) { 416 this.sendToClient(msg); 417 } else { 418 lazy.logger.fatal("Cannot send messages other than Response"); 419 } 420 } 421 422 // Low-level methods: 423 424 /** 425 * Send given response to the client over the debugger transport socket. 426 * 427 * @param {Response} resp 428 * The response to send back to the client. 429 */ 430 sendToClient(resp) { 431 this.sendMessage(resp); 432 } 433 434 /** 435 * Marshal message to the Marionette message format and send it. 436 * 437 * @param {Message} msg 438 * The message to send. 439 */ 440 sendMessage(msg) { 441 this.#log(msg); 442 let payload = msg.toPacket(); 443 this.sendRaw(payload); 444 } 445 446 /** 447 * Send the given payload over the debugger transport socket to the 448 * connected client. 449 * 450 * @param {Record<string, ?>} payload 451 * The payload to ship. 452 */ 453 sendRaw(payload) { 454 this.conn.send(payload); 455 } 456 457 toString() { 458 return `[object TCPConnection ${this.id}]`; 459 } 460 }