Fix timeout handling in Python tests (#72621)

* Fix timeout handling in Python tests

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix timeout handling in Python tests

* Implement post-review changes

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Don't add --timeout twice

* Update scripts/tests/run_python_test.py

Co-authored-by: Andrei Litvin <andy314@gmail.com>

* Annotate timeout durations as final

* Put back global `timeout` as internal test script timeout in TC_CLCTRL_*

* Handle TimeoutError thrown by Subprocess.wait()

* Fix mypy issue

* Add some slack time in the runner so that the test can handle timeouts by itself before being terminated

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Don't add --timeout to test script on your own, use --timeout script arg as source of run timeout if the 'timeout' key is not defined

* Restore TCs changed before

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update docs

* Fix mypy

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Remove spurious file

* Restore mobile-device-test.py timeout flag

* Adjust TestMatterTestingSupport

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Rework to allow parse_known_args() to be used on the matter test arg parser

* Fix docs

* parse_known_args() returns a tuple

* Warn if script timeout is more than run timeout

* Make main_args names clearer

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix ruff errors

* Fix merge error

* update another rename conflict

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Andrei Litvin <andy314@gmail.com>
diff --git a/docs/testing/python.md b/docs/testing/python.md
index 75f857a..c86bdc0 100644
--- a/docs/testing/python.md
+++ b/docs/testing/python.md
@@ -845,6 +845,7 @@
 #     app-args: <app_arguments>
 #     script-args: <script_arguments>
 #     factory-reset: <true|false>
+#     timeout: <float>   [optional]
 #     quiet: <true|false>
 # === END CI TEST ARGUMENTS ===
 ```
@@ -863,6 +864,13 @@
 
     -   Example: `true`
 
+-   `timeout`: Sets the timeout of the test script. When this timeout expires
+    the test run is considered failed. The value is in seconds.
+
+    -   Example: `700.6`
+    -   Default: Value of test script `--timeout` argument plus slack time,
+        otherwise `matter.testing.defaults.TestingDefaults.DEFAULT_TIMEOUT_S`
+
 -   `quiet`: Sets the verbosity level of the test run. When set to True, the
     test run will be quieter.
 
diff --git a/scripts/tests/run_python_test.py b/scripts/tests/run_python_test.py
index 399a035..23af401 100755
--- a/scripts/tests/run_python_test.py
+++ b/scripts/tests/run_python_test.py
@@ -39,7 +39,9 @@
 import coloredlogs
 from colorama import Fore, Style
 
+from matter.testing.defaults import TestingDefaults
 from matter.testing.metadata import Metadata, MetadataReader
+from matter.testing.runner import matter_test_args_parser
 from matter.testing.tasks import Subprocess
 
 log = logging.getLogger(__name__)
@@ -183,6 +185,27 @@
             self.dump_filename.unlink(missing_ok=True)
 
 
+def run_timeout(run: Metadata) -> float:
+    script_timeout = None
+
+    if run.script_args is not None:
+        p = matter_test_args_parser()
+        (args, _) = p.parse_known_args(shlex.split(run.script_args))
+        script_timeout = args.timeout
+
+    if run.timeout is not None and script_timeout is not None:
+        if run.timeout < script_timeout:
+            log.warning("Run timeout for run '%s' (%f s) will expire earlier than script timeout (%d s)",
+                        run.run, run.timeout, script_timeout)
+
+    if run.timeout is not None:
+        return run.timeout
+    if script_timeout is not None:
+        return script_timeout + TestingDefaults.TEST_RUNNER_SLACK_S
+
+    return TestingDefaults.DEFAULT_TIMEOUT_S
+
+
 @click.command()
 @click.option("--app", type=click.Path(exists=True), default=None,
               help='Path to local application to use, omit to use external apps.')
@@ -266,7 +289,7 @@
         log.info("Executing '%s' '%s'", run.py_script_path.split('/')[-1], run.run)
         main_impl(run.app, run.factory_reset, run.factory_reset_app_only, run.app_args or "", run.app_ready_pattern,
                   run.app_stdin_pipe, run.py_script_path, run.script_args or "", run.script_gdb, ip_packet_capture,
-                  ip_packet_capture_dir, run.quiet, run.run)
+                  ip_packet_capture_dir, run_timeout(run), run.quiet, run.run)
 
 
 class AppRestartMonitor:
@@ -338,7 +361,7 @@
 def main_impl(app: str, factory_reset: bool, factory_reset_app_only: bool, app_args: str,
               app_ready_pattern: str, app_stdin_pipe: str, script: str, script_args: str,
               script_gdb: bool, ip_packet_capture: bool, ip_packet_capture_dir: pathlib.Path,
-              quiet: bool, run_name: str):
+              run_timeout: float, quiet: bool, run_name: str):
 
     app_args = app_args.replace('{SCRIPT_BASE_NAME}', os.path.splitext(os.path.basename(script))[0])
     script_args = script_args.replace('{SCRIPT_BASE_NAME}', os.path.splitext(os.path.basename(script))[0])
@@ -409,7 +432,11 @@
     test_script_process.p.stdin.close()
 
     try:
-        test_script_exit_code = test_script_process.wait()
+        try:
+            test_script_exit_code = test_script_process.wait(run_timeout)
+        except TimeoutError as e:
+            log.exception("%r", e)
+            test_script_exit_code = -1  # Trigger error codepath
 
         if test_script_exit_code != 0:
             log.error("Test script exited with returncode %d", test_script_exit_code)
diff --git a/src/python_testing/matter_testing_infrastructure/matter/testing/defaults.py b/src/python_testing/matter_testing_infrastructure/matter/testing/defaults.py
index e60915b..80202ae 100644
--- a/src/python_testing/matter_testing_infrastructure/matter/testing/defaults.py
+++ b/src/python_testing/matter_testing_infrastructure/matter/testing/defaults.py
@@ -29,3 +29,8 @@
     CONTROLLER_NODE_ID: final = 112233
     DUT_NODE_ID: final = 0x12344321
     TRUST_ROOT_INDEX: final = 1
+
+    # Subprocess handling
+    DEFAULT_TIMEOUT_S: final = 300.0  # Default timeout when waiting for a subprocess to finish its job
+    TERMINATION_TIMEOUT_S: final = 5.0  # Default timeout for subprocess termination
+    TEST_RUNNER_SLACK_S: final = 60  # Slack time to allow a testcase to timeout itself before it's killed
diff --git a/src/python_testing/matter_testing_infrastructure/matter/testing/metadata.py b/src/python_testing/matter_testing_infrastructure/matter/testing/metadata.py
index 3bdaa1e..680a452 100644
--- a/src/python_testing/matter_testing_infrastructure/matter/testing/metadata.py
+++ b/src/python_testing/matter_testing_infrastructure/matter/testing/metadata.py
@@ -33,6 +33,7 @@
     factory_reset: bool = False
     factory_reset_app_only: bool = False
     script_gdb: bool = False
+    timeout: float | None = None
     quiet: bool = False
 
 
@@ -153,6 +154,7 @@
                 app_stdin_pipe=attr.get("app-stdin-pipe"),
                 script_args=attr.get("script-args"),
                 factory_reset=str(attr.get("factory-reset", False)).lower() == 'true',
+                timeout=float(attr["timeout"]) if "timeout" in attr else None,
                 quiet=str(attr.get("quiet", True)).lower() == 'true',
             ))
 
diff --git a/src/python_testing/matter_testing_infrastructure/matter/testing/runner.py b/src/python_testing/matter_testing_infrastructure/matter/testing/runner.py
index 25bcc06..397dcfc 100644
--- a/src/python_testing/matter_testing_infrastructure/matter/testing/runner.py
+++ b/src/python_testing/matter_testing_infrastructure/matter/testing/runner.py
@@ -336,7 +336,8 @@
         default_matter_test_main()
     """
 
-    matter_test_config = parse_matter_test_args()
+    p = matter_test_args_parser()
+    matter_test_config = convert_args_to_matter_config(p.parse_args())
 
     # Find the test class in the test script.
     test_class = _find_test_class()
@@ -942,7 +943,7 @@
         return root_index
 
 
-def parse_matter_test_args(argv: list[str] | None = None):
+def matter_test_args_parser() -> argparse.ArgumentParser:
     parser = argparse.ArgumentParser(description='Matter standalone Python test')
 
     basic_group = parser.add_argument_group(title="Basic arguments", description="Overall test execution arguments")
@@ -1081,7 +1082,4 @@
     args_group.add_argument('--hex-arg', nargs='+', action='append', type=bytes_as_hex_named_arg, metavar="NAME:VALUE",
                             help="Add a named test argument for an octet string in hex (e.g. 0011cafe or 00:11:CA:FE)")
 
-    if not argv:
-        argv = sys.argv[1:]
-
-    return convert_args_to_matter_config(parser.parse_args(argv))
+    return parser
diff --git a/src/python_testing/matter_testing_infrastructure/matter/testing/tasks.py b/src/python_testing/matter_testing_infrastructure/matter/testing/tasks.py
index 5621c22..7d1256c 100644
--- a/src/python_testing/matter_testing_infrastructure/matter/testing/tasks.py
+++ b/src/python_testing/matter_testing_infrastructure/matter/testing/tasks.py
@@ -24,6 +24,8 @@
 from enum import StrEnum
 from typing import BinaryIO, Self
 
+from matter.testing.defaults import TestingDefaults
+
 LOGGER = logging.getLogger(__name__)
 
 
@@ -71,9 +73,6 @@
 class Subprocess(threading.Thread):
     """Run a subprocess in a thread."""
 
-    DEFAULT_TIMEOUT_S: float = 300.0
-    TERMINATION_TIMEOUT_S: float = 5.0
-
     def __init__(self, program: str, *args: str, output_cb: Callable[[bytes, bool], bytes] | None = None,
                  f_stdout: BinaryIO = sys.stdout.buffer, f_stderr: BinaryIO = sys.stderr.buffer) -> None:
         """Initialize the subprocess.
@@ -154,16 +153,16 @@
                 self.returncode = -1
 
             if forwarding_stdout_thread is not None:
-                forwarding_stdout_thread.join(self.TERMINATION_TIMEOUT_S)
+                forwarding_stdout_thread.join(TestingDefaults.TERMINATION_TIMEOUT_S)
                 if forwarding_stdout_thread.is_alive():
                     LOGGER.warning("Forwarding stdout thread did not finish within timeout")
 
             if forwarding_stderr_thread is not None:
-                forwarding_stderr_thread.join(self.TERMINATION_TIMEOUT_S)
+                forwarding_stderr_thread.join(TestingDefaults.TERMINATION_TIMEOUT_S)
                 if forwarding_stderr_thread.is_alive():
                     LOGGER.warning("Forwarding stderr thread did not finish within timeout")
 
-    def start(self, expected_output: str | re.Pattern | None = None, timeout: float = DEFAULT_TIMEOUT_S) -> None:
+    def start(self, expected_output: str | re.Pattern | None = None, timeout: float = TestingDefaults.DEFAULT_TIMEOUT_S) -> None:
         """Start a subprocess and optionally wait for a specific output."""
 
         if expected_output is not None:
@@ -183,7 +182,7 @@
             raise TimeoutError(f"Expected output {expected_output!r} not found within {timeout} seconds")
 
     def send(self, message: str, end: str = "\n", expected_output: str | re.Pattern | None = None,
-             timeout: float = DEFAULT_TIMEOUT_S) -> None:
+             timeout: float = TestingDefaults.DEFAULT_TIMEOUT_S) -> None:
         """Send a message to a process and optionally wait for a response."""
 
         if expected_output is not None:
@@ -205,17 +204,20 @@
             return
 
         self.p.terminate()
-        self.join(self.TERMINATION_TIMEOUT_S)
+        self.join(TestingDefaults.TERMINATION_TIMEOUT_S)
         if not self.is_alive() and self.returncode is not None:
             return
 
         LOGGER.warning("Subprocess or controller thread did not terminate within timeout. Killing the process instead")
         self.p.kill()
-        self.join(self.TERMINATION_TIMEOUT_S)
+        self.join(TestingDefaults.TERMINATION_TIMEOUT_S)
         if self.is_alive() or self.returncode is None:
             LOGGER.warning("Failed to kill subprocess within timeout. We may be leaving a zombie process")
 
-    def wait(self, timeout: float = DEFAULT_TIMEOUT_S) -> int | None:
+    def wait(self, timeout: float = TestingDefaults.DEFAULT_TIMEOUT_S) -> int | None:
         """Wait for the subprocess to finish."""
         self.join(timeout)
+        if self.is_alive():
+            self.terminate()
+            raise TimeoutError(f"Subprocess `{self.program} {shlex.join(self.args)}` did not finish within {timeout} seconds")
         return self.returncode
diff --git a/src/python_testing/test_testing/TestMatterTestingSupport.py b/src/python_testing/test_testing/TestMatterTestingSupport.py
index 7e8b7c7..1976df5 100644
--- a/src/python_testing/test_testing/TestMatterTestingSupport.py
+++ b/src/python_testing/test_testing/TestMatterTestingSupport.py
@@ -27,7 +27,7 @@
 from matter.testing.decorators import async_test_body
 from matter.testing.matter_testing import MatterBaseTest
 from matter.testing.pics import parse_pics, parse_pics_xml
-from matter.testing.runner import default_matter_test_main, parse_matter_test_args
+from matter.testing.runner import convert_args_to_matter_config, default_matter_test_main, matter_test_args_parser
 from matter.testing.taglist_and_topology_test import (TagProblem, build_tree_for_graph, create_device_type_list_for_root,
                                                       create_device_type_lists, find_tag_list_problems, find_tree_roots,
                                                       flat_list_ok, get_all_children, get_direct_children_of_root,
@@ -730,7 +730,8 @@
             "--json-arg", "PIXIT.TEST.JSON:{\"key\":\"value\"}",
         ]
 
-        parsed = parse_matter_test_args(args)
+        p = matter_test_args_parser()
+        parsed = convert_args_to_matter_config(p.parse_args(args))
         asserts.assert_equal(parsed.tests, ["TC_1", "TC_2"])
         asserts.assert_equal(parsed.global_test_params.get("PIXIT.TEST.DEC"), 42)
         asserts.assert_equal(parsed.global_test_params.get("PIXIT.TEST.HEX"), 0x1234)