• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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