Skip to content

Commit 91dc096

Browse files
committed
πŸ› fix(subprocess): add timeout to interpreter probing
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.
1 parent 1ee6488 commit 91dc096

File tree

2 files changed

+17
-2
lines changed

2 files changed

+17
-2
lines changed

β€Žsrc/python_discovery/_cached_py_info.pyβ€Ž

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from contextlib import contextmanager
1616
from pathlib import Path
1717
from shlex import quote
18-
from subprocess import Popen # noqa: S404
18+
from subprocess import Popen, TimeoutExpired # noqa: S404
1919
from typing import TYPE_CHECKING, Final
2020

2121
from ._cache import NoOpCache
@@ -206,8 +206,11 @@ def _run_subprocess(
206206
encoding="utf-8",
207207
errors="backslashreplace",
208208
)
209-
out, err = process.communicate()
209+
out, err = process.communicate(timeout=5)
210210
code = process.returncode
211+
except TimeoutExpired:
212+
process.kill()
213+
out, err, code = "", "timed out", -1
211214
except OSError as os_error:
212215
out, err, code = "", os_error.strerror, os_error.errno
213216
if code != 0:

β€Žtests/test_cached_py_info.pyβ€Ž

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import sys
77
from pathlib import Path
8+
from subprocess import TimeoutExpired
89
from typing import TYPE_CHECKING
910
from unittest.mock import MagicMock, patch
1011

@@ -110,6 +111,17 @@ def test_run_subprocess_with_cookies(mocker: MockerFixture) -> None:
110111
assert mock_stdout.write.call_count == 2
111112

112113

114+
def test_run_subprocess_timeout(mocker: MockerFixture) -> None:
115+
mock_process = MagicMock()
116+
mock_process.communicate.side_effect = TimeoutExpired(cmd="python", timeout=30)
117+
mocker.patch("python_discovery._cached_py_info.Popen", return_value=mock_process)
118+
failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ))
119+
assert failure is not None
120+
assert "timed out" in str(failure)
121+
assert result is None
122+
mock_process.kill.assert_called_once()
123+
124+
113125
def test_run_subprocess_nonzero_exit(mocker: MockerFixture) -> None:
114126
mock_process = MagicMock()
115127
mock_process.communicate.return_value = ("some output", "some error")

0 commit comments

Comments
Β (0)