Add per_test_timeout_secs spec field, respect it for QEMU

The -i flag was added to runtests in change
Ic30b40881fc9561764f38cd574072df09a686f5f.

Testrunner doesn't yet have an equivalent flag, so for now we only
apply this flag to QEMU.

Bug: 39351
Change-Id: Ie138267827ae76b2b891379e0cf91f86279235a4
diff --git a/recipe_modules/testing/api.py b/recipe_modules/testing/api.py
index 732cb9b..763cca6 100644
--- a/recipe_modules/testing/api.py
+++ b/recipe_modules/testing/api.py
@@ -544,7 +544,7 @@
   def __init__(self, api, shards, build_artifacts, debug_symbol_gcs_bucket,
                pool, swarming_output_dir, timeout_secs,
                swarming_expiration_timeout_secs, swarming_io_timeout_secs,
-               max_attempts, collect_timeout):
+               max_attempts, collect_timeout, per_test_timeout_secs):
     self._api = api
     self._pool = pool
     self._original_build_artifacts = build_artifacts
@@ -556,6 +556,7 @@
     self._swarming_io_timeout_secs = swarming_io_timeout_secs
     self._max_attempts = max_attempts
     self._collect_timeout = collect_timeout
+    self._per_test_timeout_secs = per_test_timeout_secs
     self._uses_legacy_qemu = {
         shard.name: (not self._api.experimental.ssh_into_qemu
                      and self._api.emu.is_emulator_type(shard.device_type))
@@ -618,14 +619,15 @@
     test_list_path = self._create_test_list(shard)
     runtests_file_bootfs_path = 'infra/shard.run'
     runcmds_path = self._api.path['cleanup'].join('runcmds-%s' % shard.name)
+    runtests_cmd_parts = [
+        'runtests', '-o', self._api.testing.results_dir_on_target, '-f',
+        '/boot/%s' % runtests_file_bootfs_path
+    ]
+    if self._per_test_timeout_secs:
+      runtests_cmd_parts.extend(['-i', '%d' % self._per_test_timeout_secs])
     self._api.testing._create_runcmds_script(
         device_type=shard.device_type,
-        test_cmds=[
-            'runtests -o %s -f /boot/%s' % (
-                self._api.testing.results_dir_on_target,
-                runtests_file_bootfs_path,
-            )
-        ],
+        test_cmds=[' '.join(runtests_cmd_parts)],
         output_path=runcmds_path,
     )
 
@@ -732,6 +734,7 @@
     else:
       dimensions.update(shard.dimensions)
 
+    # TODO(fxb/39351): Respect per_test_timeout_secs
     if shard.targets_fuchsia:
       botanist_cmd = [
           './botanist',
@@ -885,8 +888,15 @@
               spec.test.swarming_expiration_timeout_secs),
           max_attempts=spec.test.max_attempts,
           collect_timeout_secs=spec.test.collect_timeout_secs,
+          per_test_timeout_secs=spec.test.per_test_timeout_secs,
       )
     else:
+      runtests_cmd_parts = [
+          'runtests', '-o', self.results_dir_on_target, spec.test.runtests_args
+      ]
+      if spec.test.per_test_timeout_secs:
+        runtests_cmd_parts.extend(
+            ['-i', '%d' % spec.test.per_test_timeout_secs])
       all_results = [
           self.deprecated_test(
               build_artifacts=build_artifacts,
@@ -898,12 +908,7 @@
               swarming_expiration_timeout_secs=(
                   spec.test.swarming_expiration_timeout_secs),
               pave=spec.test.pave,
-              test_cmds=[
-                  'runtests -o %s %s' % (
-                      self.results_dir_on_target,
-                      spec.test.runtests_args,
-                  ),
-              ],
+              test_cmds=[' '.join(runtests_cmd_parts)],
               device_type=spec.test.device_type,
               requires_secrets=spec.test.requires_secrets,
           )
@@ -1623,6 +1628,7 @@
       swarming_io_timeout_secs,
       max_attempts,
       collect_timeout_secs,
+      per_test_timeout_secs,
       timeout_secs=40 * 60,
   ):
     """Tests a Fuchsia build by sharding.
@@ -1638,6 +1644,8 @@
       max_attempts (int): Maximum number of attempts before marking a shard
         as failed.
       collect_timeout_secs (int): Amount of time to wait for tasks to complete.
+      per_test_timeout_secs (int): Any test that executes for longer than this
+        will be considered failed.
       timeout_secs (int): The amount of seconds to wait for the tests to execute
         before giving up.
 
@@ -1668,6 +1676,7 @@
         swarming_io_timeout_secs=swarming_io_timeout_secs,
         max_attempts=max_attempts,
         collect_timeout=collect_timeout,
+        per_test_timeout_secs=per_test_timeout_secs,
     )
     return self._test_runner.run_tests()
 
diff --git a/recipe_modules/testing/examples/full.expected/isolated_tests_x64_with_secrets.json b/recipe_modules/testing/examples/full.expected/isolated_tests_x64_with_secrets.json
index e8accd8..5e68baa 100644
--- a/recipe_modules/testing/examples/full.expected/isolated_tests_x64_with_secrets.json
+++ b/recipe_modules/testing/examples/full.expected/isolated_tests_x64_with_secrets.json
@@ -254,7 +254,7 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "mkdir /tmp/infra-test-output\nwaitfor class=block topo=/dev/sys/pci/00:06.0/virtio-block/block timeout=60000\nmount /dev/sys/pci/00:06.0/virtio-block/block /tmp/infra-test-output\nruntests -o /tmp/infra-test-output \numount /tmp/infra-test-output\ndm poweroff",
+      "mkdir /tmp/infra-test-output\nwaitfor class=block topo=/dev/sys/pci/00:06.0/virtio-block/block timeout=60000\nmount /dev/sys/pci/00:06.0/virtio-block/block /tmp/infra-test-output\nruntests -o /tmp/infra-test-output  -i 1\numount /tmp/infra-test-output\ndm poweroff",
       "[CLEANUP]/runcmds"
     ],
     "infra_step": true,
@@ -263,7 +263,7 @@
       "@@@STEP_LOG_LINE@runcmds@mkdir /tmp/infra-test-output@@@",
       "@@@STEP_LOG_LINE@runcmds@waitfor class=block topo=/dev/sys/pci/00:06.0/virtio-block/block timeout=60000@@@",
       "@@@STEP_LOG_LINE@runcmds@mount /dev/sys/pci/00:06.0/virtio-block/block /tmp/infra-test-output@@@",
-      "@@@STEP_LOG_LINE@runcmds@runtests -o /tmp/infra-test-output @@@",
+      "@@@STEP_LOG_LINE@runcmds@runtests -o /tmp/infra-test-output  -i 1@@@",
       "@@@STEP_LOG_LINE@runcmds@umount /tmp/infra-test-output@@@",
       "@@@STEP_LOG_LINE@runcmds@dm poweroff@@@",
       "@@@STEP_LOG_END@runcmds@@@"
diff --git a/recipe_modules/testing/examples/full.expected/test_in_shards_single_attempt.json b/recipe_modules/testing/examples/full.expected/test_in_shards_single_attempt.json
index 755c962..cfb8d4d 100644
--- a/recipe_modules/testing/examples/full.expected/test_in_shards_single_attempt.json
+++ b/recipe_modules/testing/examples/full.expected/test_in_shards_single_attempt.json
@@ -312,7 +312,7 @@
       "--json-output",
       "/path/to/tmp/json",
       "copy",
-      "mkdir /tmp/infra-test-output\nwaitfor class=block topo=/dev/sys/pci/00:06.0/virtio-block/block timeout=60000\nmount /dev/sys/pci/00:06.0/virtio-block/block /tmp/infra-test-output\nruntests -o /tmp/infra-test-output -f /boot/infra/shard.run\numount /tmp/infra-test-output\ndm poweroff",
+      "mkdir /tmp/infra-test-output\nwaitfor class=block topo=/dev/sys/pci/00:06.0/virtio-block/block timeout=60000\nmount /dev/sys/pci/00:06.0/virtio-block/block /tmp/infra-test-output\nruntests -o /tmp/infra-test-output -f /boot/infra/shard.run -i 1\numount /tmp/infra-test-output\ndm poweroff",
       "[CLEANUP]/runcmds-multiplied:fuchsia-0000"
     ],
     "infra_step": true,
@@ -322,7 +322,7 @@
       "@@@STEP_LOG_LINE@runcmds-multiplied:fuchsia-0000@mkdir /tmp/infra-test-output@@@",
       "@@@STEP_LOG_LINE@runcmds-multiplied:fuchsia-0000@waitfor class=block topo=/dev/sys/pci/00:06.0/virtio-block/block timeout=60000@@@",
       "@@@STEP_LOG_LINE@runcmds-multiplied:fuchsia-0000@mount /dev/sys/pci/00:06.0/virtio-block/block /tmp/infra-test-output@@@",
-      "@@@STEP_LOG_LINE@runcmds-multiplied:fuchsia-0000@runtests -o /tmp/infra-test-output -f /boot/infra/shard.run@@@",
+      "@@@STEP_LOG_LINE@runcmds-multiplied:fuchsia-0000@runtests -o /tmp/infra-test-output -f /boot/infra/shard.run -i 1@@@",
       "@@@STEP_LOG_LINE@runcmds-multiplied:fuchsia-0000@umount /tmp/infra-test-output@@@",
       "@@@STEP_LOG_LINE@runcmds-multiplied:fuchsia-0000@dm poweroff@@@",
       "@@@STEP_LOG_END@runcmds-multiplied:fuchsia-0000@@@"
diff --git a/recipe_modules/testing/examples/full.py b/recipe_modules/testing/examples/full.py
index 263ec0c..3f5b339 100644
--- a/recipe_modules/testing/examples/full.py
+++ b/recipe_modules/testing/examples/full.py
@@ -74,12 +74,18 @@
             kind=bool,
             help='Whether to call the deprecated_test_async method',
             default=False),
+    'per_test_timeout_secs':
+        Property(
+            kind=int,
+            help='Passed through to spec field Fuchsia.Test.per_test_timeout_secs',
+            default=0),
 }
 
 
 def RunSteps(api, gcs_bucket, build_artifact_hash, device_type, pave,
              requires_secrets, test_in_shards, upload_to_catapult,
-             collect_timeout_secs, debug_symbol_gcs_bucket, test_async):
+             collect_timeout_secs, debug_symbol_gcs_bucket, test_async,
+             per_test_timeout_secs):
   upload_results = bool(gcs_bucket)
   build_artifacts = api.build.BuildArtifacts.download(api, build_artifact_hash)
   build_artifacts.isolate(api)
@@ -91,7 +97,8 @@
       requires_secrets=requires_secrets,
       test_in_shards=test_in_shards,
       upload_to_catapult=upload_to_catapult,
-      collect_timeout_secs=collect_timeout_secs)
+      collect_timeout_secs=collect_timeout_secs,
+      per_test_timeout_secs=per_test_timeout_secs)
   spec = Fuchsia(
       test=test_spec,
       debug_symbol_gcs_bucket=debug_symbol_gcs_bucket,
@@ -147,6 +154,7 @@
       'isolated_tests_x64_with_secrets',
       properties={
           'requires_secrets': True,
+          'per_test_timeout_secs': 1,
       },
   )
   yield api.testing.test(
@@ -638,6 +646,7 @@
       clear_default_steps=True,
       properties={
           'test_in_shards': True,
+          'per_test_timeout_secs': 1,
       },
       steps=[
           api.testing.shards_step_data(shards=[
diff --git a/recipe_proto/infra/fuchsia.proto b/recipe_proto/infra/fuchsia.proto
index 1bd562c..49b9e6b 100644
--- a/recipe_proto/infra/fuchsia.proto
+++ b/recipe_proto/infra/fuchsia.proto
@@ -192,5 +192,9 @@
     // A default service account to attach to test tasks; used for shards that do
     // not specify one themselves.
     string default_service_account = 15;
+
+    // Any test that executes for longer than this will be considered failed.
+    // 0 means no timeout.
+    uint32 per_test_timeout_secs = 16;
   }
 }