Skip to content

Commit 96a40f2

Browse files
authored
🐛 fix(execute): adopt CPython subprocess stream handling (#3715)
Subprocess output reading could deadlock on Windows during parallel test execution or when handling large amounts of output. The root cause was a chicken-and-egg problem where reader threads waited for the subprocess to close its pipes, but the subprocess wouldn't be terminated until after the readers exited. 🔒 This manifested as timeouts in CI when tox tried to clean up long-running backend processes. The fix adopts CPython's approach to subprocess stream handling. On Unix, we switched from `select.select()` to the `selectors` module with proper EOF detection and interrupt handling. On Windows, we use overlapped I/O with non-blocking polling, mirroring CPython's implementation. Most importantly, we reordered the shutdown sequence in `pep517_backend.py` to terminate subprocesses before stopping reader threads, ensuring pipes close and pending I/O operations complete naturally. ⚡ This eliminates arbitrary timeouts and race conditions in the process cleanup logic. The implementation now handles EINTR signals gracefully and reads larger chunks (32KB instead of 1KB) for better performance with high-volume output. **References:** - CPython's subprocess implementation: https://github.com/python/cpython/blob/main/Lib/subprocess.py - Windows overlapped I/O: https://docs.microsoft.com/en-us/windows/win32/fileio/synchronous-and-asynchronous-i-o - Python `selectors` module: https://docs.python.org/3/library/selectors.html - `_overlapped` module: https://github.com/python/cpython/blob/main/Modules/overlapped.c
1 parent f00c24d commit 96a40f2

7 files changed

Lines changed: 270 additions & 87 deletions

File tree

docs/changelog/3715.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Adopt CPython's subprocess stream handling to fix deadlocks and improve performance when reading subprocess output

src/tox/execute/local_sub_process/__init__.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,10 +227,6 @@ def __enter__(self) -> ExecuteStatus:
227227
self._read_stderr.__enter__()
228228
self._read_stdout = ReadViaThread(stdout.send(process), self.out_handler, name=f"out-{pid}", drain=drain)
229229
self._read_stdout.__enter__()
230-
231-
if sys.platform == "win32": # explicit check for mypy: # pragma: win32 cover
232-
process.stderr.read = self._read_stderr._drain_stream # noqa: SLF001 # ty: ignore[invalid-assignment] # monkey-patching drain onto Popen stream
233-
process.stdout.read = self._read_stdout._drain_stream # noqa: SLF001 # ty: ignore[invalid-assignment] # monkey-patching drain onto Popen stream
234230
return status
235231

236232
def __exit__(

src/tox/execute/local_sub_process/read_via_thread.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""A reader that drain a stream via its file no on a background thread."""
1+
"""A reader that drains a stream via its file descriptor, following CPython's subprocess approach."""
22

33
from __future__ import annotations
44

@@ -17,7 +17,7 @@
1717
from typing_extensions import Self
1818

1919

20-
WAIT_GENERAL = 0.05 # stop thread join every so often (give chance to a signal interrupt)
20+
WAIT_GENERAL = 0.05
2121

2222

2323
class ReadViaThread(ABC):
@@ -38,10 +38,11 @@ def __exit__(
3838
exc_val: BaseException | None,
3939
exc_tb: TracebackType | None,
4040
) -> None:
41-
self.stop.set() # signal thread to stop
42-
while self.thread.is_alive(): # wait until it stops
41+
self.stop.set()
42+
while self.thread.is_alive():
4343
self.thread.join(WAIT_GENERAL)
44-
self._drain_stream() # read anything left
44+
if self._on_exit_drain:
45+
self._drain_stream()
4546

4647
@abstractmethod
4748
def _read_stream(self) -> None:
Lines changed: 70 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,93 @@
1-
"""On UNIX we use select.select to ensure we drain in a non-blocking fashion."""
1+
"""On UNIX we use selectors to drain streams efficiently, following CPython's subprocess implementation."""
22

33
from __future__ import annotations
44

5+
import contextlib # pragma: win32 no cover
56
import errno # pragma: win32 no cover
67
import os # pragma: win32 no cover
7-
import select # pragma: win32 no cover
8-
from typing import TYPE_CHECKING
8+
import selectors # pragma: win32 no cover
9+
from typing import TYPE_CHECKING, Any
910

1011
from .read_via_thread import ReadViaThread # pragma: win32 no cover
1112

1213
if TYPE_CHECKING:
1314
from collections.abc import Callable
1415

15-
STOP_EVENT_CHECK_PERIODICITY_IN_MS = 0.01 # pragma: win32 no cover
16+
TIMEOUT_FOR_INTERRUPT = 0.05 # pragma: win32 no cover
17+
READ_CHUNK_SIZE = 32768 # pragma: win32 no cover
1618

1719

1820
class ReadViaThreadUnix(ReadViaThread): # pragma: win32 no cover
1921
def __init__(self, file_no: int, handler: Callable[[bytes], int], name: str, drain: bool) -> None: # noqa: FBT001
2022
super().__init__(file_no, handler, name, drain)
2123

2224
def _read_stream(self) -> None:
23-
while not self.stop.is_set():
24-
# we need to drain the stream, but periodically give chance for the thread to break if the stop event has
25-
# been set (this is so that an interrupt can be handled)
26-
if self._read_available() is None: # pragma: no branch
27-
break # pragma: no cover
25+
selector = selectors.DefaultSelector()
26+
try:
27+
selector.register(self.file_no, selectors.EVENT_READ)
28+
except (OSError, ValueError): # pragma: no cover
29+
return
30+
31+
try:
32+
self._read_until_eof(selector)
33+
finally:
34+
selector.close()
35+
36+
def _read_until_eof(self, selector: selectors.DefaultSelector) -> None:
37+
while selector.get_map() and not self.stop.is_set():
38+
try:
39+
ready = selector.select(timeout=TIMEOUT_FOR_INTERRUPT)
40+
except (InterruptedError, OSError) as exception:
41+
if isinstance(exception, OSError) and exception.errno != errno.EINTR:
42+
raise
43+
continue
44+
45+
if not ready:
46+
continue
47+
48+
for key, _ in ready:
49+
self._read_chunk(selector, key)
2850

2951
def _drain_stream(self) -> None:
30-
# no block just poll
31-
while True:
32-
if self._read_available(timeout=0) is not True: # pragma: no branch
33-
break # pragma: no cover
52+
selector = selectors.DefaultSelector()
53+
try:
54+
selector.register(self.file_no, selectors.EVENT_READ)
55+
except (OSError, ValueError): # pragma: no cover
56+
return
3457

35-
def _read_available(self, timeout: float = STOP_EVENT_CHECK_PERIODICITY_IN_MS) -> bool | None:
3658
try:
37-
ready, __, ___ = select.select([self.file_no], [], [], timeout)
38-
if ready:
39-
data = os.read(self.file_no, 1024) # read up to 1024 characters
40-
# If the end of the file referred to by fd has been reached, an empty bytes object is returned.
41-
if data:
42-
self.handler(data)
43-
return True
44-
except OSError as exception: # pragma: no cover
45-
# Bad file descriptor or Input/output error
46-
if exception.errno in {errno.EBADF, errno.EIO}:
47-
return None
48-
raise
59+
while selector.get_map():
60+
try:
61+
ready = selector.select(timeout=0)
62+
except (InterruptedError, OSError) as exception:
63+
if isinstance(exception, OSError) and exception.errno != errno.EINTR: # pragma: no cover
64+
raise # pragma: no cover
65+
continue
66+
67+
if not ready:
68+
break
69+
70+
for key, _ in ready:
71+
self._read_chunk(selector, key)
72+
finally:
73+
selector.close()
74+
75+
def _read_chunk(self, selector: selectors.DefaultSelector, key: selectors.SelectorKey) -> None:
76+
try:
77+
data = os.read(key.fd, READ_CHUNK_SIZE)
78+
except OSError as exception:
79+
if exception.errno == errno.EINTR:
80+
return
81+
if exception.errno not in {errno.EBADF, errno.EIO}: # pragma: no cover
82+
raise # pragma: no cover
83+
data = b""
84+
85+
if data:
86+
self.handler(data)
4987
else:
50-
return False
88+
self._safe_unregister(selector, key.fileobj)
89+
90+
@staticmethod
91+
def _safe_unregister(selector: selectors.DefaultSelector, fileobj: Any) -> None:
92+
with contextlib.suppress(KeyError, ValueError):
93+
selector.unregister(fileobj)
Lines changed: 45 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,65 @@
1-
"""On Windows we use overlapped mechanism, borrowing it from asyncio (but without the event loop)."""
1+
"""On Windows we use overlapped I/O for efficient real-time stream reading."""
22

33
from __future__ import annotations # pragma: win32 cover
44

5-
import _overlapped # pragma: win32 cover # noqa: PLC2701 # ty: ignore[unresolved-import] # no typeshed stubs
6-
import logging # pragma: win32 cover
7-
from asyncio.windows_utils import (
8-
BUFSIZE,
9-
) # pragma: win32 cover
10-
from time import sleep # pragma: win32 cover
5+
import _overlapped # type: ignore[import-untyped] # pragma: win32 cover # noqa: PLC2701
6+
import time # pragma: win32 cover
117
from typing import TYPE_CHECKING
128

139
from .read_via_thread import ReadViaThread # pragma: win32 cover
1410

1511
if TYPE_CHECKING:
1612
from collections.abc import Callable
1713

14+
READ_CHUNK_SIZE = 32768 # pragma: win32 cover
15+
POLL_INTERVAL = 0.05 # pragma: win32 cover
16+
ERROR_IO_INCOMPLETE = 996 # pragma: win32 cover
17+
1818

1919
class ReadViaThreadWindows(ReadViaThread): # pragma: win32 cover
2020
def __init__(self, file_no: int, handler: Callable[[bytes], int], name: str, drain: bool) -> None: # noqa: FBT001
2121
super().__init__(file_no, handler, name, drain)
22-
self.closed = False
23-
self._ov: _overlapped.Overlapped | None = None
24-
self._waiting_for_read = False
2522

2623
def _read_stream(self) -> None:
27-
keep_reading = True
28-
while keep_reading: # try to read at least once
29-
wait = self._read_batch()
30-
if wait is None:
31-
break
32-
if wait is True:
33-
sleep(0.01) # sleep for 10ms if there was no data to read and try again
34-
keep_reading = not self.stop.is_set()
24+
try:
25+
while not self.stop.is_set():
26+
ov = _overlapped.Overlapped(0)
27+
try:
28+
ov.ReadFile(self.file_no, READ_CHUNK_SIZE)
29+
except OSError:
30+
break
31+
32+
while True:
33+
try:
34+
data = ov.getresult(False) # noqa: FBT003
35+
break
36+
except OSError as exception:
37+
if getattr(exception, "winerror", None) != ERROR_IO_INCOMPLETE:
38+
return
39+
time.sleep(POLL_INTERVAL)
40+
41+
if not data:
42+
break
43+
self.handler(data)
44+
except OSError: # pragma: no cover
45+
pass
3546

3647
def _drain_stream(self) -> None:
37-
wait: bool | None = self.closed
38-
while wait is False:
39-
wait = self._read_batch()
48+
try:
49+
while True:
50+
ov = _overlapped.Overlapped(0)
51+
try:
52+
ov.ReadFile(self.file_no, READ_CHUNK_SIZE)
53+
except OSError:
54+
break
55+
56+
try:
57+
data = ov.getresult(True) # noqa: FBT003
58+
except OSError:
59+
break
4060

41-
def _read_batch(self) -> bool | None:
42-
""":returns: None means error can no longer read, True wait for result, False try again"""
43-
if self._waiting_for_read is False:
44-
self._ov = _overlapped.Overlapped(0) # can use it only once to read a batch
45-
try: # read up to BUFSIZE at a time
46-
self._ov.ReadFile(self.file_no, BUFSIZE)
47-
self._waiting_for_read = True
48-
except OSError:
49-
self.closed = True
50-
return None
51-
try: # wait=False to not block and give chance for the stop check
52-
data = self._ov.getresult(False) # noqa: FBT003 # ty: ignore[unresolved-attribute] # https://github.com/astral-sh/ty/issues/160
53-
except OSError as exception:
54-
# 996 (0x3E4) Overlapped I/O event is not in a signaled state.
55-
# 995 (0x3E3) The I/O operation has been aborted because of either a thread exit or an application request.
56-
win_error = getattr(exception, "winerror", None)
57-
if win_error == 996: # noqa: PLR2004
58-
return True
59-
if win_error != 995: # noqa: PLR2004
60-
logging.error("failed to read %r", exception) # noqa: TRY400
61-
return None
62-
else:
63-
self._ov = None
64-
self._waiting_for_read = False
65-
if data:
61+
if not data:
62+
break
6663
self.handler(data)
67-
else:
68-
return None
69-
return False
64+
except OSError: # pragma: no cover
65+
pass

src/tox/execute/pep517_backend.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,12 @@ def _handler(into: bytearray, content: bytes) -> None:
8686
def close(self) -> None:
8787
if self._local_execute is not None: # pragma: no branch
8888
execute, _status = self._local_execute
89-
execute.__exit__(None, None, None)
9089
if execute.process is not None and execute.process.returncode is None: # pragma: no cover
9190
try: # pragma: no cover
9291
execute.process.wait(timeout=0.1) # pragma: no cover
9392
except TimeoutExpired: # pragma: no cover
9493
execute.process.terminate() # pragma: no cover # if does not stop on its own kill it
94+
execute.__exit__(None, None, None)
9595
self._local_execute = None
9696
self.is_alive = False
9797

0 commit comments

Comments
 (0)