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