Repository: google/mobly
Pull request #952: Add timeout support for FastbootProxy
Author: login: otakusaikou
Labels: name: improvement
Created: 2025-01-16T01:12:15Z | Merged: 2025-03-16T18:54:37Z

TEST=unit tests with tox

<!-- Reviewable:start -->
- - -
This change is [<img src="https://reviewable.io/review_button.svg" height="34" align="absmiddle" alt="Reviewable"/>](https://reviewable.io/reviews/google/mobly/952)
<!-- Reviewable:end -->



Diff:
diff --git a/mobly/controllers/android_device_lib/fastboot.py b/mobly/controllers/android_device_lib/fastboot.py
index e4aab8e9..ad212141 100644
--- a/mobly/controllers/android_device_lib/fastboot.py
+++ b/mobly/controllers/android_device_lib/fastboot.py
@@ -13,33 +13,42 @@
 # limitations under the License.
 
 import logging
-from subprocess import Popen, PIPE
+from subprocess import PIPE
 
 from mobly import utils
 
+# The default fastboot command timeout settings.
+DEFAULT_TIMEOUT_SEC = 180
+
 # Command to use for running fastboot commands.
 FASTBOOT = 'fastboot'
 
 
-def exe_cmd(*cmds):
-  """Executes commands in a new shell. Directing stderr to PIPE.
+def exe_cmd(*cmds, timeout):
+  """Executes commands in a new shell. Directing stderr to PIPE, with timeout.
 
   This is fastboot's own exe_cmd because of its peculiar way of writing
   non-error info to stderr.
 
   Args:
     cmds: A sequence of commands and arguments.
+    timeout: The number of seconds to wait before timing out.
 
   Returns:
-    The output of the command run.
+    The output of the command run, in bytes.
 
   Raises:
-    Exception: An error occurred during the command execution.
+    Exception: An error occurred during the command execution or
+      the command timed out.
   """
   cmd = ' '.join(cmds)
-  proc = Popen(cmd, stdout=PIPE, stderr=PIPE, shell=True)
-  (out, err) = proc.communicate()
-  ret = proc.returncode
+  (ret, out, err) = utils.run_command(
+      cmd=cmd,
+      stdout=PIPE,
+      stderr=PIPE,
+      shell=True,
+      timeout=timeout,
+  )
   logging.debug(
       'cmd: %s, stdout: %s, stderr: %s, ret: %s',
       utils.cli_cmd_to_string(cmds),
@@ -69,16 +78,18 @@ def fastboot_str(self):
       return '{} -s {}'.format(FASTBOOT, self.serial)
     return FASTBOOT
 
-  def _exec_fastboot_cmd(self, name, arg_str):
-    return exe_cmd(' '.join((self.fastboot_str(), name, arg_str)))
+  def _exec_fastboot_cmd(self, name, arg_str, timeout):
+    return exe_cmd(
+        ' '.join((self.fastboot_str(), name, arg_str)), timeout=timeout
+    )
 
-  def args(self, *args):
-    return exe_cmd(' '.join((self.fastboot_str(),) + args))
+  def args(self, *args, timeout=DEFAULT_TIMEOUT_SEC):
+    return exe_cmd(' '.join((self.fastboot_str(),) + args), timeout=timeout)
 
   def __getattr__(self, name):
-    def fastboot_call(*args):
+    def fastboot_call(*args, timeout=DEFAULT_TIMEOUT_SEC):
       clean_name = name.replace('_', '-')
       arg_str = ' '.join(str(elem) for elem in args)
-      return self._exec_fastboot_cmd(clean_name, arg_str)
+      return self._exec_fastboot_cmd(clean_name, arg_str, timeout=timeout)
 
     return fastboot_call
diff --git a/tests/mobly/controllers/android_device_lib/fastboot_test.py b/tests/mobly/controllers/android_device_lib/fastboot_test.py
index 10bc9533..5fe93829 100644
--- a/tests/mobly/controllers/android_device_lib/fastboot_test.py
+++ b/tests/mobly/controllers/android_device_lib/fastboot_test.py
@@ -13,28 +13,26 @@
 # limitations under the License.
 
 import unittest
+from subprocess import PIPE
 from unittest import mock
 
 from mobly.controllers.android_device_lib import fastboot
 
 
 class FastbootTest(unittest.TestCase):
-  """Unit tests for mobly.controllers.android_device_lib.adb."""
+  """Unit tests for mobly.controllers.android_device_lib.fastboot."""
 
   def setUp(self):
     fastboot.FASTBOOT = 'fastboot'
 
-  @mock.patch('mobly.controllers.android_device_lib.fastboot.Popen')
+  @mock.patch('mobly.utils.run_command')
   @mock.patch('logging.debug')
   def test_fastboot_commands_and_results_are_logged_to_debug_log(
-      self, mock_debug_logger, mock_popen
+      self, mock_debug_logger, mock_run_command
   ):
     expected_stdout = 'stdout'
     expected_stderr = b'stderr'
-    mock_popen.return_value.communicate = mock.Mock(
-        return_value=(expected_stdout, expected_stderr)
-    )
-    mock_popen.return_value.returncode = 123
+    mock_run_command.return_value = (123, expected_stdout, expected_stderr)
 
     fastboot.FastbootProxy().fake_command('extra', 'flags')
 
@@ -46,80 +44,121 @@ def test_fastboot_commands_and_results_are_logged_to_debug_log(
         123,
     )
 
-  @mock.patch('mobly.controllers.android_device_lib.fastboot.Popen')
-  def test_fastboot_without_serial(self, mock_popen):
+  @mock.patch('mobly.utils.run_command')
+  def test_fastboot_without_serial(self, mock_run_command):
     expected_stdout = 'stdout'
     expected_stderr = b'stderr'
-    mock_popen.return_value.communicate = mock.Mock(
-        return_value=(expected_stdout, expected_stderr)
-    )
-    mock_popen.return_value.returncode = 123
+    mock_run_command.return_value = (123, expected_stdout, expected_stderr)
 
     fastboot.FastbootProxy().fake_command('extra', 'flags')
 
-    mock_popen.assert_called_with(
-        'fastboot fake-command extra flags',
-        stdout=mock.ANY,
-        stderr=mock.ANY,
+    mock_run_command.assert_called_with(
+        cmd='fastboot fake-command extra flags',
+        stdout=PIPE,
+        stderr=PIPE,
         shell=True,
+        timeout=180,
     )
 
-  @mock.patch('mobly.controllers.android_device_lib.fastboot.Popen')
-  def test_fastboot_with_serial(self, mock_popen):
+  @mock.patch('mobly.utils.run_command')
+  def test_fastboot_with_serial(self, mock_run_command):
     expected_stdout = 'stdout'
     expected_stderr = b'stderr'
-    mock_popen.return_value.communicate = mock.Mock(
-        return_value=(expected_stdout, expected_stderr)
-    )
-    mock_popen.return_value.returncode = 123
+    mock_run_command.return_value = (123, expected_stdout, expected_stderr)
 
     fastboot.FastbootProxy('ABC').fake_command('extra', 'flags')
 
-    mock_popen.assert_called_with(
-        'fastboot -s ABC fake-command extra flags',
-        stdout=mock.ANY,
-        stderr=mock.ANY,
+    mock_run_command.assert_called_with(
+        cmd='fastboot -s ABC fake-command extra flags',
+        stdout=PIPE,
+        stderr=PIPE,
         shell=True,
+        timeout=180,
     )
 
-  @mock.patch('mobly.controllers.android_device_lib.fastboot.Popen')
-  def test_fastboot_update_serial(self, mock_popen):
+  @mock.patch('mobly.utils.run_command')
+  def test_fastboot_update_serial(self, mock_run_command):
     expected_stdout = 'stdout'
     expected_stderr = b'stderr'
-    mock_popen.return_value.communicate = mock.Mock(
-        return_value=(expected_stdout, expected_stderr)
-    )
-    mock_popen.return_value.returncode = 123
+    mock_run_command.return_value = (123, expected_stdout, expected_stderr)
 
     fut = fastboot.FastbootProxy('ABC')
     fut.fake_command('extra', 'flags')
     fut.serial = 'XYZ'
     fut.fake_command('extra', 'flags')
 
-    mock_popen.assert_called_with(
-        'fastboot -s XYZ fake-command extra flags',
-        stdout=mock.ANY,
-        stderr=mock.ANY,
+    mock_run_command.assert_called_with(
+        cmd='fastboot -s XYZ fake-command extra flags',
+        stdout=PIPE,
+        stderr=PIPE,
         shell=True,
+        timeout=180,
     )
 
-  @mock.patch('mobly.controllers.android_device_lib.fastboot.Popen')
-  def test_fastboot_use_customized_fastboot(self, mock_popen):
+  @mock.patch('mobly.utils.run_command')
+  def test_fastboot_use_customized_fastboot(self, mock_run_command):
     expected_stdout = 'stdout'
     expected_stderr = b'stderr'
-    mock_popen.return_value.communicate = mock.Mock(
-        return_value=(expected_stdout, expected_stderr)
-    )
-    mock_popen.return_value.returncode = 123
+    mock_run_command.return_value = (123, expected_stdout, expected_stderr)
+
     fastboot.FASTBOOT = 'my_fastboot'
 
     fastboot.FastbootProxy('ABC').fake_command('extra', 'flags')
 
-    mock_popen.assert_called_with(
-        'my_fastboot -s ABC fake-command extra flags',
-        stdout=mock.ANY,
-        stderr=mock.ANY,
+    mock_run_command.assert_called_with(
+        cmd='my_fastboot -s ABC fake-command extra flags',
+        stdout=PIPE,
+        stderr=PIPE,
+        shell=True,
+        timeout=180,
+    )
+
+  @mock.patch('mobly.utils.run_command')
+  def test_fastboot_with_custom_timeout(self, mock_run_command):
+    expected_stdout = 'stdout'
+    expected_stderr = b'stderr'
+    mock_run_command.return_value = (123, expected_stdout, expected_stderr)
+
+    fastboot.FastbootProxy().fake_command('extra', 'flags', timeout=120)
+
+    mock_run_command.assert_called_with(
+        cmd='fastboot fake-command extra flags',
+        stdout=PIPE,
+        stderr=PIPE,
+        shell=True,
+        timeout=120,
+    )
+
+  @mock.patch('mobly.utils.run_command')
+  def test_fastboot_args(self, mock_run_command):
+    expected_stdout = 'stdout'
+    expected_stderr = b'stderr'
+    mock_run_command.return_value = (123, expected_stdout, expected_stderr)
+
+    fastboot.FastbootProxy().args('-w', timeout=180)
+
+    mock_run_command.assert_called_with(
+        cmd='fastboot -w',
+        stdout=PIPE,
+        stderr=PIPE,
+        shell=True,
+        timeout=180,
+    )
+
+  @mock.patch('mobly.utils.run_command')
+  def test_fastboot_args_with_custom_timeout(self, mock_run_command):
+    expected_stdout = 'stdout'
+    expected_stderr = b'stderr'
+    mock_run_command.return_value = (123, expected_stdout, expected_stderr)
+
+    fastboot.FastbootProxy().args('-w', timeout=20)
+
+    mock_run_command.assert_called_with(
+        cmd='fastboot -w',
+        stdout=PIPE,
+        stderr=PIPE,
         shell=True,
+        timeout=20,
     )
 
 


Reviews:
user: login: xpconanfan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-13T06:05:00Z
user: login: otakusaikou
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T06:32:48Z
user: login: xpconanfan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-13T23:51:36Z
user: login: otakusaikou
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T00:06:42Z
user: login: xpconanfan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-14T16:25:28Z
user: login: otakusaikou
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T17:27:54Z
user: login: otakusaikou
body: Branch updated
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T23:00:47Z
user: login: xpconanfan
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-15T01:05:41Z
user: login: xpconanfan
body: Fix CI
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-15T01:06:28Z
user: login: xpconanfan
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-15T18:10:45Z

Review comments:
diff_hunk: @@ -61,19 +67,24 @@ class FastbootProxy:
   >> fb.devices() # will return the console output of "fastboot devices".
   """
 
-  def __init__(self, serial=''):
+  def __init__(self, serial='', timeout=1200):
path: mobly/controllers/android_device_lib/fastboot.py
user: login: xpconanfan
body: `timeout` doesn't need to be a constructor value.

See how `adb` handles this and follow the same pattern.
created_at: 2025-03-13T06:05:00Z
author_association: COLLABORATOR
diff_hunk: @@ -61,19 +67,24 @@ class FastbootProxy:
   >> fb.devices() # will return the console output of "fastboot devices".
   """
 
-  def __init__(self, serial=''):
+  def __init__(self, serial='', timeout=1200):
path: mobly/controllers/android_device_lib/fastboot.py
user: login: otakusaikou
body: updated by following the recommendation
created_at: 2025-03-13T06:32:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,33 +13,42 @@
 # limitations under the License.
 
 import logging
-from subprocess import Popen, PIPE
+from subprocess import PIPE
 
 from mobly import utils
 
+# The default fastboot command timeout settings.
+DEFAULT_TIMEOUT_SEC = 1200
path: mobly/controllers/android_device_lib/fastboot.py
user: login: xpconanfan
body: Why does the default need to be so long?
created_at: 2025-03-13T23:51:35Z
author_association: COLLABORATOR
diff_hunk: @@ -13,33 +13,42 @@
 # limitations under the License.
 
 import logging
-from subprocess import Popen, PIPE
+from subprocess import PIPE
 
 from mobly import utils
 
+# The default fastboot command timeout settings.
+DEFAULT_TIMEOUT_SEC = 1200
path: mobly/controllers/android_device_lib/fastboot.py
user: login: otakusaikou
body: From my experience, as there wasn't any "wait for fastboot" function, so we used to just run fastboot command, including "waiting device back from other state", such as:
- adb reboot bootloader -> run fastboot command -> 20-30s -> command run
- device bootloader crash and bootloader ab fallback triggered -> 3-6 mins (boot and retry) -> fallback to other slot -> command run

Hence I would suggest not to set this too short (as it was previously "infinite"), if set this to sth like 10s, I think we may break many existing test scripts. (10 ~ 20mins looks suitable, 20 mins was the one that I've been using internally and didn't see issues)
created_at: 2025-03-14T00:06:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,33 +13,42 @@
 # limitations under the License.
 
 import logging
-from subprocess import Popen, PIPE
+from subprocess import PIPE
 
 from mobly import utils
 
+# The default fastboot command timeout settings.
+DEFAULT_TIMEOUT_SEC = 1200
path: mobly/controllers/android_device_lib/fastboot.py
user: login: xpconanfan
body: But most fastboot cmds are short?
We should set a value reasonable for most cmds so we don't waste time when the operation is stuck for real.
20min is way too long.

For cmds that require more time, the caller should explicitly extend the timeout. We could do a large scale change for internal scripts.
created_at: 2025-03-14T16:25:28Z
author_association: COLLABORATOR
diff_hunk: @@ -13,33 +13,42 @@
 # limitations under the License.
 
 import logging
-from subprocess import Popen, PIPE
+from subprocess import PIPE
 
 from mobly import utils
 
+# The default fastboot command timeout settings.
+DEFAULT_TIMEOUT_SEC = 1200
path: mobly/controllers/android_device_lib/fastboot.py
user: login: otakusaikou
body: updated to 3 mins
created_at: 2025-03-14T17:27:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: otakusaikou
created_at: 2025-01-16T02:23:32Z
body: Hello @xpconanfan, would you please help review this PR? Thanks.
author_association: CONTRIBUTOR
user: login: xpconanfan
created_at: 2025-03-12T23:49:48Z
body: Please resolve the conflict if you still wish to proceed with this PR.
Otherwise please close this
author_association: COLLABORATOR
user: login: xpconanfan
created_at: 2025-03-12T23:58:52Z
body: Please do not rename or move the function into the class.
Let's just focus on adding a timeout option 
author_association: COLLABORATOR
user: login: otakusaikou
created_at: 2025-03-13T01:11:38Z
body: conflict resolved and keep the original exec function name
author_association: CONTRIBUTOR
user: login: xpconanfan
created_at: 2025-03-14T18:42:12Z
body: Please rebase the branch 
author_association: COLLABORATOR
user: login: xpconanfan
created_at: 2025-03-15T01:06:06Z
body: CI is failing, pls fix
author_association: COLLABORATOR
user: login: otakusaikou
created_at: 2025-03-15T13:02:51Z
body: Fixed the format check issue by running pyink tool locally to reformat fastboot_test.py.
author_association: CONTRIBUTOR