Repository: google/mobly

Issue:
Snippet crashes on phones that print extra instrumentation output.
Some phones print extra information prior to instrumentation output. On such phones, the snippet crashes.

Comments:

Patch:
diff --git a/mobly/controllers/android_device_lib/snippet_client.py b/mobly/controllers/android_device_lib/snippet_client.py
index 4eb15e8..769140f 100644
--- a/mobly/controllers/android_device_lib/snippet_client.py
+++ b/mobly/controllers/android_device_lib/snippet_client.py
@@ -1,11 +1,11 @@
 # Copyright 2016 Google Inc.
-# 
+#
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
 # You may obtain a copy of the License at
-# 
+#
 #     http://www.apache.org/licenses/LICENSE-2.0
-# 
+#
 # Unless required by applicable law or agreed to in writing, software
 # distributed under the License is distributed on an "AS IS" BASIS,
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -42,7 +42,7 @@ class Error(Exception):
     pass
 
 
-class ProtocolVersionError(Error):
+class ProtocolVersionError(jsonrpc_client_base.AppStartError):
     """Raised when the protocol reported by the snippet is unknown."""
 
 
@@ -92,7 +92,7 @@ class SnippetClient(jsonrpc_client_base.JsonRpcClientBase):
         # just warn and retry as v0.
         # TODO(adorokhine): delete this in Mobly 1.6 when snippet v0 support is
         # removed.
-        line = self._read_line()
+        line = self._read_protocol_line()
         if line in ('INSTRUMENTATION_RESULT: shortMsg=Process crashed.',
                     'INSTRUMENTATION_RESULT: shortMsg='
                     'java.lang.IllegalArgumentException'):
@@ -185,7 +185,8 @@ class SnippetClient(jsonrpc_client_base.JsonRpcClientBase):
     def _connect_to_v0(self):
         self.device_port = self.host_port
         self._adb.forward(
-            ['tcp:%d' % self.host_port, 'tcp:%d' % self.device_port])
+            ['tcp:%d' % self.host_port,
+             'tcp:%d' % self.device_port])
         start_time = time.time()
         expiration_time = start_time + _APP_START_WAIT_TIME_V0
         while time.time() < expiration_time:
@@ -203,19 +204,46 @@ class SnippetClient(jsonrpc_client_base.JsonRpcClientBase):
             '%s failed to start on %s.' % (self.package, self._adb.serial))
 
     def _connect_to_v1(self):
-        line = self._read_line()
+        line = self._read_protocol_line()
         match = re.match('^SNIPPET SERVING, PORT ([0-9]+)$', line)
         if not match:
-            raise ProtocolVersionError(line)
+            raise jsonrpc_client_base.AppStartError(line)
         self.device_port = int(match.group(1))
 
         # Forward the device port to a new host port, and connect to that port
         self.host_port = utils.get_available_host_port()
         self._adb.forward(
-            ['tcp:%d' % self.host_port, 'tcp:%d' % self.device_port])
+            ['tcp:%d' % self.host_port,
+             'tcp:%d' % self.device_port])
         self.connect()
 
-    def _read_line(self):
-        line = self._proc.stdout.readline().rstrip()
-        self.log.debug('Read line from instrumentation output: "%s"', line)
-        return line
+    def _read_protocol_line(self):
+        """Reads the next line of instrumentation output relevant to snippets.
+
+        This method will skip over lines that don't start with 'SNIPPET' or
+        'INSTRUMENTATION_RESULT'.
+
+        Returns:
+            (str) Next line of snippet-related instrumentation output, stripped.
+
+        Raises:
+            jsonrpc_client_base.AppStartError: If EOF is reached without any
+                                               protocol lines being read.
+        """
+        while True:
+            line = self._proc.stdout.readline().decode('utf-8')
+            if not line:
+                raise jsonrpc_client_base.AppStartError(
+                    'Unexpected EOF waiting for app to start')
+            # readline() uses an empty string to mark EOF, and a single newline
+            # to mark regular empty lines in the output. Don't move the strip()
+            # call above the truthiness check, or this method will start
+            # considering any blank output line to be EOF.
+            line = line.strip()
+            if (line.startswith('INSTRUMENTATION_RESULT:') or
+                    line.startswith('SNIPPET ')):
+                self.log.debug(
+                    'Accepted line from instrumentation output: "%s"', line)
+                return line
+            self.log.debug('Discarded line from instrumentation output: "%s"',
+                           line)


Test patch:
diff --git a/tests/mobly/controllers/android_device_lib/snippet_client_test.py b/tests/mobly/controllers/android_device_lib/snippet_client_test.py
index 911f53e..244be5f 100755
--- a/tests/mobly/controllers/android_device_lib/snippet_client_test.py
+++ b/tests/mobly/controllers/android_device_lib/snippet_client_test.py
@@ -1,11 +1,11 @@
 # Copyright 2017 Google Inc.
-# 
+#
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
 # You may obtain a copy of the License at
-# 
+#
 #     http://www.apache.org/licenses/LICENSE-2.0
-# 
+#
 # Unless required by applicable law or agreed to in writing, software
 # distributed under the License is distributed on an "AS IS" BASIS,
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -120,10 +120,129 @@ class SnippetClientTest(jsonrpc_client_test_base.JsonRpcClientTestBase):
         with self.assertRaisesRegexp(jsonrpc_client_base.ApiError, '1'):
             callback.getAll('eventName')
 
+    @mock.patch('socket.create_connection')
+    @mock.patch('mobly.controllers.android_device_lib.snippet_client.'
+                'utils.start_standing_subprocess')
+    @mock.patch('mobly.controllers.android_device_lib.snippet_client.'
+                'utils.get_available_host_port')
+    def test_snippet_start_app_and_connect_v1(self, mock_get_port,
+                                              mock_start_standing_subprocess,
+                                              mock_create_connection):
+        self.setup_mock_socket_file(mock_create_connection)
+        self._setup_mock_instrumentation_cmd(
+            mock_start_standing_subprocess,
+            resp_lines=[
+                b'SNIPPET START, PROTOCOL 1 0\n',
+                b'SNIPPET SERVING, PORT 123\n',
+            ])
+        client = self._make_client()
+        client.start_app_and_connect()
+        self.assertEqual(123, client.device_port)
+
+    @mock.patch('socket.create_connection')
+    @mock.patch('mobly.controllers.android_device_lib.snippet_client.'
+                'utils.start_standing_subprocess')
+    @mock.patch('mobly.controllers.android_device_lib.snippet_client.'
+                'utils.get_available_host_port')
+    def test_snippet_start_app_and_connect_v0(self, mock_get_port,
+                                              mock_start_standing_subprocess,
+                                              mock_create_connection):
+        mock_get_port.return_value = 456
+        self.setup_mock_socket_file(mock_create_connection)
+        self._setup_mock_instrumentation_cmd(
+            mock_start_standing_subprocess,
+            resp_lines=[b'INSTRUMENTATION_RESULT: shortMsg=Process crashed.\n'])
+        client = self._make_client()
+        client.start_app_and_connect()
+        self.assertEqual(456, client.device_port)
+
+    @mock.patch('mobly.controllers.android_device_lib.snippet_client.'
+                'utils.start_standing_subprocess')
+    def test_snippet_start_app_and_connect_unknown_protocol(
+            self, mock_start_standing_subprocess):
+        self._setup_mock_instrumentation_cmd(
+            mock_start_standing_subprocess,
+            resp_lines=[b'SNIPPET START, PROTOCOL 99 0\n'])
+        client = self._make_client()
+        with self.assertRaises(snippet_client.ProtocolVersionError):
+            client.start_app_and_connect()
+
+    @mock.patch('socket.create_connection')
+    @mock.patch('mobly.controllers.android_device_lib.snippet_client.'
+                'utils.start_standing_subprocess')
+    @mock.patch('mobly.controllers.android_device_lib.snippet_client.'
+                'utils.get_available_host_port')
+    def test_snippet_start_app_and_connect_v1_header_junk(
+            self, mock_get_port, mock_start_standing_subprocess,
+            mock_create_connection):
+        self.setup_mock_socket_file(mock_create_connection)
+        self._setup_mock_instrumentation_cmd(
+            mock_start_standing_subprocess,
+            resp_lines=[
+                b'This is some header junk\n',
+                b'Some phones print arbitrary output\n',
+                b'SNIPPET START, PROTOCOL 1 0\n',
+                b'Maybe in the middle too\n',
+                b'SNIPPET SERVING, PORT 123\n',
+            ])
+        client = self._make_client()
+        client.start_app_and_connect()
+        self.assertEqual(123, client.device_port)
+
+    @mock.patch('socket.create_connection')
+    @mock.patch('mobly.controllers.android_device_lib.snippet_client.'
+                'utils.start_standing_subprocess')
+    @mock.patch('mobly.controllers.android_device_lib.snippet_client.'
+                'utils.get_available_host_port')
+    def test_snippet_start_app_and_connect_v0_header_junk(
+            self, mock_get_port, mock_start_standing_subprocess,
+            mock_create_connection):
+        mock_get_port.return_value = 456
+        self.setup_mock_socket_file(mock_create_connection)
+        self._setup_mock_instrumentation_cmd(
+            mock_start_standing_subprocess,
+            resp_lines=[
+                b'This is some header junk\n',
+                b'Some phones print arbitrary output\n',
+                b'\n',
+                b'INSTRUMENTATION_RESULT: shortMsg=Process crashed.\n',
+            ])
+        client = self._make_client()
+        client.start_app_and_connect()
+        self.assertEqual(456, client.device_port)
+
+    @mock.patch('socket.create_connection')
+    @mock.patch('mobly.controllers.android_device_lib.snippet_client.'
+                'utils.start_standing_subprocess')
+    @mock.patch('mobly.controllers.android_device_lib.snippet_client.'
+                'utils.get_available_host_port')
+    def test_snippet_start_app_and_connect_no_valid_line(
+            self, mock_get_port, mock_start_standing_subprocess,
+            mock_create_connection):
+        mock_get_port.return_value = 456
+        self.setup_mock_socket_file(mock_create_connection)
+        self._setup_mock_instrumentation_cmd(
+            mock_start_standing_subprocess,
+            resp_lines=[
+                b'This is some header junk\n',
+                b'Some phones print arbitrary output\n',
+                b'',  # readline uses '' to mark EOF
+            ])
+        client = self._make_client()
+        with self.assertRaisesRegexp(
+                jsonrpc_client_base.AppStartError,
+                'Unexpected EOF waiting for app to start'):
+            client.start_app_and_connect()
+
     def _make_client(self, adb_proxy=MockAdbProxy()):
         return snippet_client.SnippetClient(
             package=MOCK_PACKAGE_NAME, adb_proxy=adb_proxy)
 
+    def _setup_mock_instrumentation_cmd(self, mock_start_standing_subprocess,
+                                        resp_lines):
+        mock_proc = mock_start_standing_subprocess()
+        mock_proc.stdout.readline.side_effect = resp_lines
+
 
 if __name__ == "__main__":
     unittest.main()