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