Repository: google/mobly

Issue:
Snippet service's `is_alive` returns True even if the service is paused
It is not clearly defined if a paused service should be considered alive.
We should probably make a decision and documents it.


Discussion:
@xpconanfan  check out the given website:
https://mobly.readthedocs.io/en/stable/mobly.controllers.android_device_lib.services.html
Give updates!!
Hi, can you provide more context on how your use case is affected by this issue? It'll help us address this problem better.

Resolving patch:
diff --git a/mobly/controllers/android_device_lib/jsonrpc_client_base.py b/mobly/controllers/android_device_lib/jsonrpc_client_base.py
--- a/mobly/controllers/android_device_lib/jsonrpc_client_base.py
+++ b/mobly/controllers/android_device_lib/jsonrpc_client_base.py
@@ -245,16 +245,27 @@ def connect(self, uid=UNKNOWN_UID, cmd=JsonRpcCommand.INIT):
       self.uid = UNKNOWN_UID
 
   def disconnect(self):
-    """Close the connection to the remote client."""
-    if self._conn:
-      self._conn.close()
-      self._conn = None
+    """Close the connection to the snippet server on the device.
+
+    This is a unilateral disconnect from the client side, without tearing down
+    the snippet server running on the device.
+
+    The connection to the snippet server can be re-established by calling
+    `SnippetClient.restore_app_connection`.
+    """
+    try:
+      if self._conn:
+        self._conn.close()
+        self._conn = None
+    finally:
+      # Always clear the host port as part of the disconnect step.
+      self.clear_host_port()
 
   def clear_host_port(self):
     """Stops the adb port forwarding of the host port used by this client.
     """
     if self.host_port:
-      self._adb.forward(['--remove', 'tcp:%d' % self.host_port])
+      self._ad.adb.forward(['--remove', 'tcp:%d' % self.host_port])
       self.host_port = None
 
   def _client_send(self, msg):
diff --git a/mobly/controllers/android_device_lib/services/snippet_management_service.py b/mobly/controllers/android_device_lib/services/snippet_management_service.py
--- a/mobly/controllers/android_device_lib/services/snippet_management_service.py
+++ b/mobly/controllers/android_device_lib/services/snippet_management_service.py
@@ -125,16 +125,13 @@ def pause(self):
     allocated in `resume`.
     """
     for client in self._snippet_clients.values():
-      self._device.log.debug('Clearing host port %d of SnippetClient<%s>.',
-                             client.host_port, client.package)
-      client.clear_host_port()
+      self._device.log.debug('Pausing SnippetClient<%s>.', client.package)
+      client.disconnect()
 
   def resume(self):
     """Resumes all paused snippet clients."""
     for client in self._snippet_clients.values():
-      # Resume is only applicable if a client is alive and does not have
-      # a host port.
-      if client.is_alive and client.host_port is None:
+      if not client.is_alive:
         self._device.log.debug('Resuming SnippetClient<%s>.', client.package)
         client.restore_app_connection()
       else:
diff --git a/mobly/controllers/android_device_lib/snippet_client.py b/mobly/controllers/android_device_lib/snippet_client.py
--- a/mobly/controllers/android_device_lib/snippet_client.py
+++ b/mobly/controllers/android_device_lib/snippet_client.py
@@ -90,21 +90,7 @@ def __init__(self, package, ad):
 
   @property
   def is_alive(self):
-    """Is the client alive.
-
-    The client is considered alive if there is a connection object held for
-    it. This is an approximation due to the following scenario:
-
-    In the USB disconnect case, the host subprocess that kicked off the
-    snippet  apk would die, but the snippet apk itself would continue
-    running on the device.
-
-    The best approximation we can make is, the connection object has not
-    been explicitly torn down, so the client should be considered alive.
-
-    Returns:
-      True if the client is considered alive, False otherwise.
-    """
+    """Does the client have an active connection to the snippet server."""
     return self._conn is not None
 
   def _get_user_command_string(self):
@@ -237,23 +223,17 @@ def stop_app(self):
     # print the failure stack trace if there was any, and reap it from the
     # process table.
     self.log.debug('Stopping snippet apk %s', self.package)
-    try:
-      # Close the socket connection.
-      self.disconnect()
-      if self._proc:
-        utils.stop_standing_subprocess(self._proc)
+    # Close the socket connection.
+    self.disconnect()
+    if self._proc:
+      utils.stop_standing_subprocess(self._proc)
       self._proc = None
-      out = self._adb.shell(
-          _STOP_CMD.format(
-              snippet_package=self.package,
-              user=self._get_user_command_string())).decode('utf-8')
-      if 'OK (0 tests)' not in out:
-        raise errors.DeviceError(
-            self._ad,
-            'Failed to stop existing apk. Unexpected output: %s' % out)
-    finally:
-      # Always clean up the adb port
-      self.clear_host_port()
+    out = self._adb.shell(
+        _STOP_CMD.format(snippet_package=self.package,
+                         user=self._get_user_command_string())).decode('utf-8')
+    if 'OK (0 tests)' not in out:
+      raise errors.DeviceError(
+          self._ad, 'Failed to stop existing apk. Unexpected output: %s' % out)
 
   def _start_event_client(self):
     """Overrides superclass."""


Test patch:
diff --git a/tests/mobly/controllers/android_device_lib/jsonrpc_client_base_test.py b/tests/mobly/controllers/android_device_lib/jsonrpc_client_base_test.py
--- a/tests/mobly/controllers/android_device_lib/jsonrpc_client_base_test.py
+++ b/tests/mobly/controllers/android_device_lib/jsonrpc_client_base_test.py
@@ -71,6 +71,42 @@ def test_handshake_error(self, mock_create_connection):
         jsonrpc_client_base.ProtocolError.NO_RESPONSE_FROM_HANDSHAKE):
       client.connect()
 
+  def test_disconnect(self):
+    client = FakeRpcClient()
+    mock_conn = mock.MagicMock()
+    client.clear_host_port = mock.MagicMock()
+    client._conn = mock_conn
+    client.disconnect()
+    self.assertIsNone(client._conn)
+    mock_conn.close.assert_called_once_with()
+    client.clear_host_port.assert_called_once_with()
+
+  def test_disconnect_raises(self):
+    client = FakeRpcClient()
+    mock_conn = mock.MagicMock()
+    client.clear_host_port = mock.MagicMock()
+    client._conn = mock_conn
+    # Explicitly making the second side_effect noop to avoid uncaught exception
+    # when `__del__` is called after the test is done, which triggers
+    # `disconnect`.
+    mock_conn.close.side_effect = [Exception('ha'), None]
+    with self.assertRaisesRegex(Exception, 'ha'):
+      client.disconnect()
+    client.clear_host_port.assert_called_once_with()
+
+  def test_clear_host_port_positive(self):
+    client = FakeRpcClient()
+    client.host_port = 1
+    client.clear_host_port()
+    client._ad.adb.forward.assert_called_once_with(['--remove', 'tcp:1'])
+    self.assertIsNone(client.host_port)
+
+  def test_clear_host_port_negative(self):
+    client = FakeRpcClient()
+    client.host_port = None
+    client.clear_host_port()
+    client._ad.adb.forward.assert_not_called()
+
   @mock.patch('socket.create_connection')
   def test_connect_handshake(self, mock_create_connection):
     """Test client handshake
diff --git a/tests/mobly/controllers/android_device_lib/services/snippet_management_service_test.py b/tests/mobly/controllers/android_device_lib/services/snippet_management_service_test.py
--- a/tests/mobly/controllers/android_device_lib/services/snippet_management_service_test.py
+++ b/tests/mobly/controllers/android_device_lib/services/snippet_management_service_test.py
@@ -128,7 +128,7 @@ def test_pause(self, mock_class):
         mock.MagicMock())
     manager.add_snippet_client('foo', MOCK_PACKAGE)
     manager.pause()
-    mock_client.clear_host_port.assert_called_once_with()
+    mock_client.disconnect.assert_called_once_with()
 
   @mock.patch(SNIPPET_CLIENT_CLASS_PATH)
   def test_resume_positive_case(self, mock_class):
@@ -136,30 +136,17 @@ def test_resume_positive_case(self, mock_class):
     manager = snippet_management_service.SnippetManagementService(
         mock.MagicMock())
     manager.add_snippet_client('foo', MOCK_PACKAGE)
-    mock_client.is_alive = True
-    mock_client.host_port = None
+    mock_client.is_alive = False
     manager.resume()
     mock_client.restore_app_connection.assert_called_once_with()
 
   @mock.patch(SNIPPET_CLIENT_CLASS_PATH)
-  def test_resume_alive_with_host_port(self, mock_class):
+  def test_resume_negative_case(self, mock_class):
     mock_client = mock_class.return_value
     manager = snippet_management_service.SnippetManagementService(
         mock.MagicMock())
     manager.add_snippet_client('foo', MOCK_PACKAGE)
     mock_client.is_alive = True
-    mock_client.host_port = 1
-    manager.resume()
-    mock_client.restore_app_connection.assert_not_called()
-
-  @mock.patch(SNIPPET_CLIENT_CLASS_PATH)
-  def test_resume_not_alive_no_host_port(self, mock_class):
-    mock_client = mock_class.return_value
-    manager = snippet_management_service.SnippetManagementService(
-        mock.MagicMock())
-    manager.add_snippet_client('foo', MOCK_PACKAGE)
-    mock_client.is_alive = False
-    mock_client.host_port = None
     manager.resume()
     mock_client.restore_app_connection.assert_not_called()
 
diff --git a/tests/mobly/controllers/android_device_lib/snippet_client_test.py b/tests/mobly/controllers/android_device_lib/snippet_client_test.py
--- a/tests/mobly/controllers/android_device_lib/snippet_client_test.py
+++ b/tests/mobly/controllers/android_device_lib/snippet_client_test.py
@@ -157,8 +157,7 @@ def test_snippet_start_app_and_connect(self, mock_get_port,
     self.assertTrue(client.is_alive)
 
   @mock.patch('socket.create_connection')
-  @mock.patch('mobly.controllers.android_device_lib.snippet_client.'
-              'utils.stop_standing_subprocess')
+  @mock.patch('mobly.utils.stop_standing_subprocess')
   def test_snippet_stop_app(self, mock_stop_standing_subprocess,
                             mock_create_connection):
     adb_proxy = mock.MagicMock()
@@ -167,19 +166,16 @@ def test_snippet_stop_app(self, mock_stop_standing_subprocess,
     client.stop_app()
     self.assertFalse(client.is_alive)
 
-  @mock.patch('socket.create_connection')
-  @mock.patch('mobly.controllers.android_device_lib.snippet_client.'
-              'SnippetClient.disconnect')
-  def test_snippet_stop_app_raises(self, mock_disconnect,
-                                   mock_create_connection):
-    # Explicitly making the second side_effect noop to avoid uncaught exception
-    # when `__del__` is called after the test is done, which triggers
-    # `disconnect`.
-    mock_disconnect.side_effect = [Exception('ha'), None]
+  def test_snippet_stop_app_raises(self):
     adb_proxy = mock.MagicMock()
     adb_proxy.shell.return_value = b'OK (0 tests)'
     client = self._make_client(adb_proxy)
     client.host_port = 1
+    client._conn = mock.MagicMock()
+    # Explicitly making the second side_effect noop to avoid uncaught exception
+    # when `__del__` is called after the test is done, which triggers
+    # `disconnect`.
+    client._conn.close.side_effect = [Exception('ha'), None]
     with self.assertRaisesRegex(Exception, 'ha'):
       client.stop_app()
     adb_proxy.forward.assert_called_once_with(['--remove', 'tcp:1'])