From 91dc0966648db3dc19e507e371971da28d7bcf41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 6 Mar 2026 14:10:49 -0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix(subprocess):=20add=20tim?= =?UTF-8?q?eout=20to=20interpreter=20probing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows CI, the subprocess spawned to probe a candidate Python interpreter can hang indefinitely — triggered by Windows Store stubs, antivirus holds, or pipe I/O race conditions. This caused ~18 flaky timeout failures across 9 different tests in tox over the last 30 days, almost exclusively on windows-2025 runners. The root cause is process.communicate() being called with no timeout in _run_subprocess. Adding a 5s timeout and killing the process on expiry allows discovery to skip unresponsive interpreters and continue. --- src/python_discovery/_cached_py_info.py | 7 +++++-- tests/test_cached_py_info.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/python_discovery/_cached_py_info.py b/src/python_discovery/_cached_py_info.py index 1750abd..29f9462 100644 --- a/src/python_discovery/_cached_py_info.py +++ b/src/python_discovery/_cached_py_info.py @@ -15,7 +15,7 @@ from contextlib import contextmanager from pathlib import Path from shlex import quote -from subprocess import Popen # noqa: S404 +from subprocess import Popen, TimeoutExpired # noqa: S404 from typing import TYPE_CHECKING, Final from ._cache import NoOpCache @@ -206,8 +206,11 @@ def _run_subprocess( encoding="utf-8", errors="backslashreplace", ) - out, err = process.communicate() + out, err = process.communicate(timeout=5) code = process.returncode + except TimeoutExpired: + process.kill() + out, err, code = "", "timed out", -1 except OSError as os_error: out, err, code = "", os_error.strerror, os_error.errno if code != 0: diff --git a/tests/test_cached_py_info.py b/tests/test_cached_py_info.py index 91b2d7e..06bbc83 100644 --- a/tests/test_cached_py_info.py +++ b/tests/test_cached_py_info.py @@ -5,6 +5,7 @@ import os import sys from pathlib import Path +from subprocess import TimeoutExpired from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -110,6 +111,17 @@ def test_run_subprocess_with_cookies(mocker: MockerFixture) -> None: assert mock_stdout.write.call_count == 2 +def test_run_subprocess_timeout(mocker: MockerFixture) -> None: + mock_process = MagicMock() + mock_process.communicate.side_effect = TimeoutExpired(cmd="python", timeout=30) + mocker.patch("python_discovery._cached_py_info.Popen", return_value=mock_process) + failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ)) + assert failure is not None + assert "timed out" in str(failure) + assert result is None + mock_process.kill.assert_called_once() + + def test_run_subprocess_nonzero_exit(mocker: MockerFixture) -> None: mock_process = MagicMock() mock_process.communicate.return_value = ("some output", "some error") From 443b43c8291bbca16a89d27b461cd60f4fa1841d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 6 Mar 2026 14:29:45 -0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20fix(types):=20use=20keyword?= =?UTF-8?q?=20arg=20for=20pytest.skip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ty on 3.8 reports too-many-positional-arguments for pytest.skip(). --- tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4e7bad1..ee7c847 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,4 +26,5 @@ def _ensure_py_info_cache_empty(session_cache: DiskCache) -> Generator[None]: def _skip_if_test_in_system(session_cache: DiskCache) -> None: current = PythonInfo.current(session_cache) if current.system_executable is not None: # pragma: no cover - pytest.skip("test not valid if run under system") + msg = "test not valid if run under system" + raise pytest.skip.Exception(msg)