1# Copyright 2022 Google Inc. 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14"""Snippet Client V2 for Interacting with Snippet Server on Android Device.""" 15 16import dataclasses 17import enum 18import json 19import re 20import socket 21from typing import Dict, Union 22 23from mobly import utils 24from mobly.controllers.android_device_lib import adb 25from mobly.controllers.android_device_lib import callback_handler_v2 26from mobly.controllers.android_device_lib import errors as android_device_lib_errors 27from mobly.snippet import client_base 28from mobly.snippet import errors 29 30# The package of the instrumentation runner used for mobly snippet 31_INSTRUMENTATION_RUNNER_PACKAGE = ( 32 'com.google.android.mobly.snippet.SnippetRunner' 33) 34 35# The command template to start the snippet server 36_LAUNCH_CMD = ( 37 '{shell_cmd} am instrument {user} -w -e action start' 38 ' {instrument_options}' 39 f' {{snippet_package}}/{_INSTRUMENTATION_RUNNER_PACKAGE}' 40) 41 42# The command template to stop the snippet server 43_STOP_CMD = ( 44 'am instrument {user} -w -e action stop {snippet_package}/' 45 f'{_INSTRUMENTATION_RUNNER_PACKAGE}' 46) 47 48# The default timeout for running `_STOP_CMD`. 49_STOP_CMD_TIMEOUT_SEC = 30 50 51# Major version of the launch and communication protocol being used by this 52# client. 53# Incrementing this means that compatibility with clients using the older 54# version is broken. Avoid breaking compatibility unless there is no other 55# choice. 56_PROTOCOL_MAJOR_VERSION = 1 57 58# Minor version of the launch and communication protocol. 59# Increment this when new features are added to the launch and communication 60# protocol that are backwards compatible with the old protocol and don't break 61# existing clients. 62_PROTOCOL_MINOR_VERSION = 0 63 64# Test that uses UiAutomation requires the shell session to be maintained while 65# test is in progress. However, this requirement does not hold for the test that 66# deals with device disconnection (Once device disconnects, the shell session 67# that started the instrument ends, and UiAutomation fails with error: 68# "UiAutomation not connected"). To keep the shell session and redirect 69# stdin/stdout/stderr, use "setsid" or "nohup" while launching the 70# instrumentation test. Because these commands may not be available in every 71# Android system, try to use it only if at least one exists. 72_SETSID_COMMAND = 'setsid' 73 74_NOHUP_COMMAND = 'nohup' 75 76# UID of the 'unknown' JSON RPC session. Will cause creation of a new session 77# in the snippet server. 78UNKNOWN_UID = -1 79 80# Maximum time to wait for the socket to open on the device. 81_SOCKET_CONNECTION_TIMEOUT = 60 82 83# Maximum time to wait for a response message on the socket. 84_SOCKET_READ_TIMEOUT = 60 * 10 85 86# The default timeout for callback handlers returned by this client 87_CALLBACK_DEFAULT_TIMEOUT_SEC = 60 * 2 88 89 90@dataclasses.dataclass 91class Config: 92 """A configuration class. 93 94 Attributes: 95 am_instrument_options: The Android am instrument options used for 96 controlling the `onCreate` process of the app under test. Note that this 97 should only be used for controlling the app launch process, options for 98 other purposes may not take effect and you should use snippet RPCs. This 99 is because Mobly snippet runner changes the subsequent instrumentation 100 process. 101 user_id: The user id under which to launch the snippet process. 102 """ 103 104 am_instrument_options: Dict[str, str] = dataclasses.field( 105 default_factory=dict 106 ) 107 user_id: Union[int, None] = None 108 109 110class ConnectionHandshakeCommand(enum.Enum): 111 """Commands to send to the server when sending the handshake request. 112 113 After creating the socket connection, the client must send a handshake request 114 to the server. When receiving the handshake request, the server will prepare 115 to communicate with the client. According to the command in the request, 116 the server will create a new session or reuse the current session. 117 118 INIT: Initiates a new session and makes a connection with this session. 119 CONTINUE: Makes a connection with the current session. 120 """ 121 122 INIT = 'initiate' 123 CONTINUE = 'continue' 124 125 126class SnippetClientV2(client_base.ClientBase): 127 """Snippet client V2 for interacting with snippet server on Android Device. 128 129 For a description of the launch protocols, see the documentation in 130 mobly-snippet-lib, SnippetRunner.java. 131 132 We only list the public attributes introduced in this class. See base class 133 documentation for other public attributes and communication protocols. 134 135 Attributes: 136 host_port: int, the host port used for communicating with the snippet 137 server. 138 device_port: int, the device port listened by the snippet server. 139 uid: int, the uid of the server session with which this client communicates. 140 Default is `UNKNOWN_UID` and it will be set to a positive number after 141 the connection to the server is made successfully. 142 """ 143 144 def __init__(self, package, ad, config=None): 145 """Initializes the instance of Snippet Client V2. 146 147 Args: 148 package: str, see base class. 149 ad: AndroidDevice, the android device object associated with this client. 150 config: Config, the configuration object. See the docstring of the 151 `Config` class for supported configurations. 152 """ 153 super().__init__(package=package, device=ad) 154 self.host_port = None 155 self.device_port = None 156 self.uid = UNKNOWN_UID 157 self._adb = ad.adb 158 self._user_id = None if config is None else config.user_id 159 self._proc = None 160 self._client = None # keep it to prevent close errors on connect failure 161 self._conn = None 162 self._event_client = None 163 self._config = config or Config() 164 165 @property 166 def user_id(self): 167 """The user id to use for this snippet client. 168 169 All the operations of the snippet client should be used for a particular 170 user. For more details, see the Android documentation of testing 171 multiple users. 172 173 Thus this value is cached and, once set, does not change through the 174 lifecycles of this snippet client object. This caching also reduces the 175 number of adb calls needed. 176 177 Although for now self._user_id won't be modified once set, we use 178 `property` to avoid issuing adb commands in the constructor. 179 180 Returns: 181 An integer of the user id. 182 """ 183 if self._user_id is None: 184 self._user_id = self._adb.current_user_id 185 return self._user_id 186 187 @property 188 def is_alive(self): 189 """Does the client have an active connection to the snippet server.""" 190 return self._conn is not None 191 192 def before_starting_server(self): 193 """Performs the preparation steps before starting the remote server. 194 195 This function performs following preparation steps: 196 * Validate that the Mobly Snippet app is available on the device. 197 * Disable hidden api blocklist if necessary and possible. 198 199 Raises: 200 errors.ServerStartPreCheckError: if the server app is not installed 201 for the current user. 202 """ 203 self._validate_snippet_app_on_device() 204 self._disable_hidden_api_blocklist() 205 206 def _validate_snippet_app_on_device(self): 207 """Validates the Mobly Snippet app is available on the device. 208 209 To run as an instrumentation test, the Mobly Snippet app must already be 210 installed and instrumented on the Android device. 211 212 Raises: 213 errors.ServerStartPreCheckError: if the server app is not installed 214 for the current user. 215 """ 216 # Validate that the Mobly Snippet app is installed for the current user. 217 out = self._adb.shell(f'pm list package --user {self.user_id}') 218 if not utils.grep(f'^package:{self.package}$', out): 219 raise errors.ServerStartPreCheckError( 220 self._device, 221 f'{self.package} is not installed for user {self.user_id}.', 222 ) 223 224 # Validate that the app is instrumented. 225 out = self._adb.shell('pm list instrumentation') 226 matched_out = utils.grep( 227 f'^instrumentation:{self.package}/{_INSTRUMENTATION_RUNNER_PACKAGE}', 228 out, 229 ) 230 if not matched_out: 231 raise errors.ServerStartPreCheckError( 232 self._device, 233 f'{self.package} is installed, but it is not instrumented.', 234 ) 235 match = re.search( 236 r'^instrumentation:(.*)\/(.*) \(target=(.*)\)$', matched_out[0] 237 ) 238 target_name = match.group(3) 239 # Validate that the instrumentation target is installed if it's not the 240 # same as the snippet package. 241 if target_name != self.package: 242 out = self._adb.shell(f'pm list package --user {self.user_id}') 243 if not utils.grep(f'^package:{target_name}$', out): 244 raise errors.ServerStartPreCheckError( 245 self._device, 246 f'Instrumentation target {target_name} is not installed for user ' 247 f'{self.user_id}.', 248 ) 249 250 def _disable_hidden_api_blocklist(self): 251 """If necessary and possible, disables hidden api blocklist.""" 252 sdk_version = int(self._device.build_info['build_version_sdk']) 253 if self._device.is_rootable and sdk_version >= 28: 254 self._device.adb.shell( 255 'settings put global hidden_api_blacklist_exemptions "*"' 256 ) 257 258 def start_server(self): 259 """Starts the server on the remote device. 260 261 This function starts the snippet server with adb command, checks the 262 protocol version of the server, parses device port from the server 263 output and sets it to self.device_port. 264 265 Raises: 266 errors.ServerStartProtocolError: if the protocol reported by the server 267 startup process is unknown. 268 errors.ServerStartError: if failed to start the server or process the 269 server output. 270 """ 271 persists_shell_cmd = self._get_persisting_command() 272 self.log.debug( 273 'Snippet server for package %s is using protocol %d.%d', 274 self.package, 275 _PROTOCOL_MAJOR_VERSION, 276 _PROTOCOL_MINOR_VERSION, 277 ) 278 option_str = self._get_instrument_options_str() 279 cmd = _LAUNCH_CMD.format( 280 shell_cmd=persists_shell_cmd, 281 user=self._get_user_command_string(), 282 snippet_package=self.package, 283 instrument_options=option_str, 284 ) 285 self._proc = self._run_adb_cmd(cmd) 286 287 # Check protocol version and get the device port 288 line = self._read_protocol_line() 289 match = re.match('^SNIPPET START, PROTOCOL ([0-9]+) ([0-9]+)$', line) 290 if not match or int(match.group(1)) != _PROTOCOL_MAJOR_VERSION: 291 raise errors.ServerStartProtocolError(self._device, line) 292 293 line = self._read_protocol_line() 294 match = re.match('^SNIPPET SERVING, PORT ([0-9]+)$', line) 295 if not match: 296 raise errors.ServerStartProtocolError(self._device, line) 297 self.device_port = int(match.group(1)) 298 299 def _run_adb_cmd(self, cmd): 300 """Starts a long-running adb subprocess and returns it immediately.""" 301 adb_cmd = [adb.ADB] 302 if self._adb.serial: 303 adb_cmd += ['-s', self._adb.serial] 304 adb_cmd += ['shell', cmd] 305 return utils.start_standing_subprocess(adb_cmd, shell=False) 306 307 def _get_persisting_command(self): 308 """Returns the path of a persisting command if available.""" 309 for command in [_SETSID_COMMAND, _NOHUP_COMMAND]: 310 try: 311 if command in self._adb.shell(['which', command]).decode('utf-8'): 312 return command 313 except adb.AdbError: 314 continue 315 316 self.log.warning( 317 'No %s and %s commands available to launch instrument ' 318 'persistently, tests that depend on UiAutomator and ' 319 'at the same time perform USB disconnections may fail.', 320 _SETSID_COMMAND, 321 _NOHUP_COMMAND, 322 ) 323 return '' 324 325 def _get_instrument_options_str(self): 326 self.log.debug( 327 'Got am instrument options in snippet client for package %s: %s', 328 self.package, 329 self._config.am_instrument_options, 330 ) 331 if not self._config.am_instrument_options: 332 return '' 333 334 return ' '.join( 335 f'-e {k} {v}' for k, v in self._config.am_instrument_options.items() 336 ) 337 338 def _get_user_command_string(self): 339 """Gets the appropriate command argument for specifying device user ID. 340 341 By default, this client operates within the current user. We 342 don't add the `--user {ID}` argument when Android's SDK is below 24, 343 where multi-user support is not well implemented. 344 345 Returns: 346 A string of the command argument section to be formatted into 347 adb commands. 348 """ 349 sdk_version = int(self._device.build_info['build_version_sdk']) 350 if sdk_version < 24: 351 return '' 352 return f'--user {self.user_id}' 353 354 def _read_protocol_line(self): 355 """Reads the next line of instrumentation output relevant to snippets. 356 357 This method will skip over lines that don't start with 'SNIPPET ' or 358 'INSTRUMENTATION_RESULT:'. 359 360 Returns: 361 A string for the next line of snippet-related instrumentation output, 362 stripped. 363 364 Raises: 365 errors.ServerStartError: If EOF is reached without any protocol lines 366 being read. 367 """ 368 while True: 369 line = self._proc.stdout.readline().decode('utf-8') 370 if not line: 371 raise errors.ServerStartError( 372 self._device, 'Unexpected EOF when waiting for server to start.' 373 ) 374 375 # readline() uses an empty string to mark EOF, and a single newline 376 # to mark regular empty lines in the output. Don't move the strip() 377 # call above the truthiness check, or this method will start 378 # considering any blank output line to be EOF. 379 line = line.strip() 380 if line.startswith('INSTRUMENTATION_RESULT:') or line.startswith( 381 'SNIPPET ' 382 ): 383 self.log.debug('Accepted line from instrumentation output: "%s"', line) 384 return line 385 386 self.log.debug('Discarded line from instrumentation output: "%s"', line) 387 388 def make_connection(self): 389 """Makes a connection to the snippet server on the remote device. 390 391 This function makes a persistent connection to the server. This connection 392 will be used for all the RPCs, and must be closed when deconstructing. 393 394 To connect to the Android device, it first forwards the device port to a 395 host port. Then, it creates a socket connection to the server on the device. 396 Finally, it sends a handshake request to the server, which requests the 397 server to prepare for the communication with the client. 398 399 This function uses self.host_port for communicating with the server. If 400 self.host_port is 0 or None, this function finds an available host port to 401 make the connection and set self.host_port to the found port. 402 """ 403 self._forward_device_port() 404 self.create_socket_connection() 405 self.send_handshake_request() 406 407 def _forward_device_port(self): 408 """Forwards the device port to a host port.""" 409 if self.host_port and self.host_port in adb.list_occupied_adb_ports(): 410 raise errors.Error( 411 self._device, 412 f'Cannot forward to host port {self.host_port} because adb has' 413 ' forwarded another device port to it.', 414 ) 415 416 host_port = self.host_port or 0 417 # Example stdout: b'12345\n' 418 stdout = self._adb.forward([f'tcp:{host_port}', f'tcp:{self.device_port}']) 419 self.host_port = int(stdout.strip()) 420 421 def create_socket_connection(self): 422 """Creates a socket connection to the server. 423 424 After creating the connection successfully, it sets two attributes: 425 * `self._conn`: the created socket object, which will be used when it needs 426 to close the connection. 427 * `self._client`: the socket file, which will be used to send and receive 428 messages. 429 430 This function only creates a socket connection without sending any message 431 to the server. 432 """ 433 try: 434 self.log.debug( 435 'Snippet client is creating socket connection to the snippet server ' 436 'of %s through host port %d.', 437 self.package, 438 self.host_port, 439 ) 440 self._conn = socket.create_connection( 441 ('localhost', self.host_port), _SOCKET_CONNECTION_TIMEOUT 442 ) 443 except ConnectionRefusedError as err: 444 # Retry using '127.0.0.1' for IPv4 enabled machines that only resolve 445 # 'localhost' to '[::1]'. 446 self.log.debug( 447 'Failed to connect to localhost, trying 127.0.0.1: %s', str(err) 448 ) 449 self._conn = socket.create_connection( 450 ('127.0.0.1', self.host_port), _SOCKET_CONNECTION_TIMEOUT 451 ) 452 453 self._conn.settimeout(_SOCKET_READ_TIMEOUT) 454 self._client = self._conn.makefile(mode='brw') 455 456 def send_handshake_request( 457 self, uid=UNKNOWN_UID, cmd=ConnectionHandshakeCommand.INIT 458 ): 459 """Sends a handshake request to the server to prepare for the communication. 460 461 Through the handshake response, this function checks whether the server 462 is ready for the communication. If ready, it sets `self.uid` to the 463 server session id. Otherwise, it sets `self.uid` to `UNKNOWN_UID`. 464 465 Args: 466 uid: int, the uid of the server session to continue. It will be ignored 467 if the `cmd` requires the server to create a new session. 468 cmd: ConnectionHandshakeCommand, the handshake command Enum for the 469 server, which requires the server to create a new session or use the 470 current session. 471 472 Raises: 473 errors.ProtocolError: something went wrong when sending the handshake 474 request. 475 """ 476 request = json.dumps({'cmd': cmd.value, 'uid': uid}) 477 self.log.debug('Sending handshake request %s.', request) 478 self._client_send(request) 479 response = self._client_receive() 480 481 if not response: 482 raise errors.ProtocolError( 483 self._device, errors.ProtocolError.NO_RESPONSE_FROM_HANDSHAKE 484 ) 485 486 response = self._decode_socket_response_bytes(response) 487 488 result = json.loads(response) 489 if result['status']: 490 self.uid = result['uid'] 491 else: 492 self.uid = UNKNOWN_UID 493 494 def check_server_proc_running(self): 495 """See base class. 496 497 This client does nothing at this stage. 498 """ 499 500 def send_rpc_request(self, request): 501 """Sends an RPC request to the server and receives a response. 502 503 Args: 504 request: str, the request to send the server. 505 506 Returns: 507 The string of the RPC response. 508 509 Raises: 510 errors.Error: if failed to send the request or receive a response. 511 errors.ProtocolError: if received an empty response from the server. 512 UnicodeError: if failed to decode the received response. 513 """ 514 self._client_send(request) 515 response = self._client_receive() 516 if not response: 517 raise errors.ProtocolError( 518 self._device, errors.ProtocolError.NO_RESPONSE_FROM_SERVER 519 ) 520 return self._decode_socket_response_bytes(response) 521 522 def _client_send(self, message): 523 """Sends an RPC message through the connection. 524 525 Args: 526 message: str, the message to send. 527 528 Raises: 529 errors.Error: if a socket error occurred during the send. 530 """ 531 try: 532 self._client.write(f'{message}\n'.encode('utf8')) 533 self._client.flush() 534 except socket.error as e: 535 raise errors.Error( 536 self._device, 537 f'Encountered socket error "{e}" sending RPC message "{message}"', 538 ) from e 539 540 def _client_receive(self): 541 """Receives the server's response of an RPC message. 542 543 Returns: 544 Raw bytes of the response. 545 546 Raises: 547 errors.Error: if a socket error occurred during the read. 548 """ 549 try: 550 return self._client.readline() 551 except socket.error as e: 552 raise errors.Error( 553 self._device, f'Encountered socket error "{e}" reading RPC response' 554 ) from e 555 556 def _decode_socket_response_bytes(self, response): 557 """Returns a string decoded from the socket response bytes. 558 559 Args: 560 response: bytes, the response to be decoded. 561 562 Returns: 563 The string decoded from the given bytes. 564 565 Raises: 566 UnicodeError: if failed to decode the given bytes using encoding utf8. 567 """ 568 try: 569 return str(response, encoding='utf8') 570 except UnicodeError: 571 self.log.error( 572 'Failed to decode socket response bytes using encoding utf8: %s', 573 response, 574 ) 575 raise 576 577 def handle_callback(self, callback_id, ret_value, rpc_func_name): 578 """Creates the callback handler object. 579 580 If the client doesn't have an event client, it will start an event client 581 before creating a callback handler. 582 583 Args: 584 callback_id: see base class. 585 ret_value: see base class. 586 rpc_func_name: see base class. 587 588 Returns: 589 The callback handler object. 590 """ 591 if self._event_client is None: 592 self._create_event_client() 593 return callback_handler_v2.CallbackHandlerV2( 594 callback_id=callback_id, 595 event_client=self._event_client, 596 ret_value=ret_value, 597 method_name=rpc_func_name, 598 device=self._device, 599 rpc_max_timeout_sec=_SOCKET_READ_TIMEOUT, 600 default_timeout_sec=_CALLBACK_DEFAULT_TIMEOUT_SEC, 601 ) 602 603 def _create_event_client(self): 604 """Creates a separate client to the same session for propagating events. 605 606 As the server is already started by the snippet server on which this 607 function is called, the created event client connects to the same session 608 as the snippet server. It also reuses the same host port and device port. 609 """ 610 self._event_client = SnippetClientV2(package=self.package, ad=self._device) 611 self._event_client.make_connection_with_forwarded_port( 612 self.host_port, 613 self.device_port, 614 self.uid, 615 ConnectionHandshakeCommand.CONTINUE, 616 ) 617 618 def make_connection_with_forwarded_port( 619 self, 620 host_port, 621 device_port, 622 uid=UNKNOWN_UID, 623 cmd=ConnectionHandshakeCommand.INIT, 624 ): 625 """Makes a connection to the server with the given forwarded port. 626 627 This process assumes that a device port has already been forwarded to a 628 host port, and it only makes a connection to the snippet server based on 629 the forwarded port. This is typically used by clients that share the same 630 snippet server, e.g. the snippet client and its event client. 631 632 Args: 633 host_port: int, the host port which has already been forwarded. 634 device_port: int, the device port listened by the snippet server. 635 uid: int, the uid of the server session to continue. It will be ignored 636 if the `cmd` requires the server to create a new session. 637 cmd: ConnectionHandshakeCommand, the handshake command Enum for the 638 server, which requires the server to create a new session or use the 639 current session. 640 """ 641 self.host_port = host_port 642 self.device_port = device_port 643 self._counter = self._id_counter() 644 self.create_socket_connection() 645 self.send_handshake_request(uid, cmd) 646 647 def stop(self): 648 """Releases all the resources acquired in `initialize`. 649 650 This function releases following resources: 651 * Close the socket connection. 652 * Stop forwarding the device port to host. 653 * Stop the standing server subprocess running on the host side. 654 * Stop the snippet server running on the device side. 655 * Stop the event client and set `self._event_client` to None. 656 657 Raises: 658 android_device_lib_errors.DeviceError: if the server exited with errors on 659 the device side. 660 """ 661 self.log.debug('Stopping snippet package %s.', self.package) 662 self.close_connection() 663 self._stop_server() 664 self._destroy_event_client() 665 self.log.debug('Snippet package %s stopped.', self.package) 666 667 def close_connection(self): 668 """Closes the connection to the snippet server on the device. 669 670 This function closes the socket connection and stops forwarding the device 671 port to host. 672 """ 673 try: 674 if self._conn: 675 self._conn.close() 676 self._conn = None 677 finally: 678 # Always clear the host port as part of the close step 679 self._stop_port_forwarding() 680 681 def _stop_port_forwarding(self): 682 """Stops the adb port forwarding used by this client.""" 683 if self.host_port: 684 self._device.adb.forward(['--remove', f'tcp:{self.host_port}']) 685 self.host_port = None 686 687 def _stop_server(self): 688 """Releases all the resources acquired in `start_server`. 689 690 Raises: 691 android_device_lib_errors.DeviceError: if the server exited with errors on 692 the device side. 693 """ 694 # Although killing the snippet server would abort this subprocess anyway, we 695 # want to call stop_standing_subprocess() to perform a health check, 696 # print the failure stack trace if there was any, and reap it from the 697 # process table. Note that it's much more important to ensure releasing all 698 # the allocated resources on the host side than on the remote device side. 699 700 # Stop the standing server subprocess running on the host side. 701 if self._proc: 702 utils.stop_standing_subprocess(self._proc) 703 self._proc = None 704 705 # Send the stop signal to the server running on the device side. 706 out = self._adb.shell( 707 _STOP_CMD.format( 708 snippet_package=self.package, user=self._get_user_command_string() 709 ), 710 timeout=_STOP_CMD_TIMEOUT_SEC, 711 ).decode('utf-8') 712 713 if 'OK (0 tests)' not in out: 714 raise android_device_lib_errors.DeviceError( 715 self._device, 716 f'Failed to stop existing apk. Unexpected output: {out}.', 717 ) 718 719 def _destroy_event_client(self): 720 """Releases all the resources acquired in `_create_event_client`.""" 721 if self._event_client: 722 # Without cleaning host_port of event_client first, the close_connection 723 # will try to stop the port forwarding, which should only be stopped by 724 # the corresponding snippet client. 725 self._event_client.host_port = None 726 self._event_client.device_port = None 727 self._event_client.close_connection() 728 self._event_client = None 729 730 def restore_server_connection(self, port=None): 731 """Restores the server after the device got reconnected. 732 733 Instead of creating a new instance of the client: 734 - Uses the given port (or find a new available host port if none is 735 given). 736 - Tries to connect to the remote server with the selected port. 737 738 Args: 739 port: int, if given, this is the host port from which to connect to the 740 remote device port. If not provided, find a new available port as host 741 port. 742 743 Raises: 744 errors.ServerRestoreConnectionError: when failed to restore the connection 745 to the snippet server. 746 """ 747 try: 748 # If self.host_port is None, self._make_connection finds a new available 749 # port. 750 self.host_port = port 751 self._make_connection() 752 except Exception as e: 753 # Log the original error and raise ServerRestoreConnectionError. 754 self.log.error('Failed to re-connect to the server.') 755 raise errors.ServerRestoreConnectionError( 756 self._device, 757 ( 758 f'Failed to restore server connection for {self.package} at ' 759 f'host port {self.host_port}, device port {self.device_port}.' 760 ), 761 ) from e 762 763 # Because the previous connection was lost, update self._proc 764 self._proc = None 765 self._restore_event_client() 766 767 def _restore_event_client(self): 768 """Restores the previously created event client or creates a new one. 769 770 This function restores the connection of the previously created event 771 client, or creates a new client and makes a connection if it didn't 772 exist before. 773 774 The event client to restore reuses the same host port and device port 775 with the client on which function is called. 776 """ 777 if self._event_client: 778 self._event_client.make_connection_with_forwarded_port( 779 self.host_port, self.device_port 780 ) 781 782 def help(self, print_output=True): 783 """Calls the help RPC, which returns the list of RPC calls available. 784 785 This RPC should normally be used in an interactive console environment 786 where the output should be printed instead of returned. Otherwise, 787 newlines will be escaped, which will make the output difficult to read. 788 789 Args: 790 print_output: bool, for whether the output should be printed. 791 792 Returns: 793 A string containing the help output otherwise None if `print_output` 794 wasn't set. 795 """ 796 help_text = self._rpc('help') 797 if print_output: 798 print(help_text) 799 else: 800 return help_text 801