|
1 | 1 | import asyncio |
2 | 2 | from asyncio.subprocess import SubprocessStreamProtocol |
| 3 | +import contextlib |
3 | 4 | import functools |
4 | 5 | import os |
5 | 6 | import pathlib |
| 7 | +import psutil |
| 8 | +import signal |
6 | 9 | import subprocess |
7 | 10 |
|
8 | 11 | from typing import Dict, List, Optional |
9 | 12 |
|
10 | 13 | from ceph_devstack import logger, VERBOSE |
11 | 14 |
|
| 15 | +_TERMINATE_TIMEOUT = 3.0 |
| 16 | +_KILL_TIMEOUT = 1.0 |
12 | 17 |
|
13 | | -class LoggingStreamProtocol(asyncio.subprocess.SubprocessStreamProtocol): |
14 | | - def __init__(self, limit, loop, log_level): |
15 | | - self.log_level = log_level |
16 | | - super().__init__(limit=limit, loop=loop) |
17 | 18 |
|
18 | | - def pipe_data_received(self, fd, data): |
19 | | - logger.log( |
20 | | - self.log_level, |
21 | | - (data.decode() if isinstance(data, bytes) else str(data)).rstrip("\n"), |
22 | | - ) |
23 | | - super().pipe_data_received(fd, data) |
| 19 | +class Subprocess(asyncio.subprocess.Process): |
| 20 | + async def _close_transport(self) -> None: |
| 21 | + transport = getattr(self, "_transport", None) |
| 22 | + if transport is not None and not transport.is_closing(): |
| 23 | + transport.close() |
| 24 | + |
| 25 | + async def _wait_for_exit(self, timeout: float) -> None: |
| 26 | + with contextlib.suppress(asyncio.TimeoutError, asyncio.CancelledError): |
| 27 | + await asyncio.wait_for(asyncio.shield(super().wait()), timeout=timeout) |
| 28 | + |
| 29 | + async def _terminate(self) -> None: |
| 30 | + if self.returncode is not None: |
| 31 | + await self._close_transport() |
| 32 | + return |
| 33 | + self.signal_children(signal.SIGTERM, recursive=True) |
| 34 | + with contextlib.suppress(ProcessLookupError): |
| 35 | + self.kill() |
| 36 | + await self._wait_for_exit(_TERMINATE_TIMEOUT) |
| 37 | + if self.returncode is None: |
| 38 | + self.signal_children(signal.SIGKILL, recursive=True) |
| 39 | + with contextlib.suppress(ProcessLookupError): |
| 40 | + self.kill() |
| 41 | + await self._wait_for_exit(_KILL_TIMEOUT) |
| 42 | + await self._close_transport() |
| 43 | + |
| 44 | + async def wait(self) -> int: |
| 45 | + try: |
| 46 | + return await super().wait() |
| 47 | + except asyncio.CancelledError: |
| 48 | + await self._terminate() |
| 49 | + raise |
| 50 | + |
| 51 | + async def communicate(self, input=None): |
| 52 | + try: |
| 53 | + return await super().communicate(input) |
| 54 | + except asyncio.CancelledError: |
| 55 | + await self._terminate() |
| 56 | + raise |
| 57 | + |
| 58 | + def child_pids(self, recursive=True): |
| 59 | + if self.pid is None: |
| 60 | + return [] |
| 61 | + return [ |
| 62 | + child.pid |
| 63 | + for child in psutil.Process(self.pid).children(recursive=recursive) |
| 64 | + ] |
| 65 | + |
| 66 | + def signal_children(self, signal: signal.Signals, recursive=True): |
| 67 | + for pid in self.child_pids(recursive=recursive): |
| 68 | + with contextlib.suppress(ProcessLookupError): |
| 69 | + os.kill(pid, signal) |
| 70 | + with contextlib.suppress(ProcessLookupError): |
| 71 | + os.killpg(pid, signal) |
24 | 72 |
|
25 | 73 |
|
26 | 74 | class Command: |
@@ -57,37 +105,28 @@ def run(self) -> subprocess.Popen: |
57 | 105 | proc.wait() |
58 | 106 | return proc |
59 | 107 |
|
60 | | - async def arun(self) -> asyncio.subprocess.Process: |
| 108 | + async def arun(self) -> Subprocess: |
61 | 109 | logger.log(VERBOSE, self._make_log_msg()) |
62 | 110 | loop = asyncio.get_running_loop() |
63 | | - protocol_factory: ( |
64 | | - functools.partial[SubprocessStreamProtocol] |
65 | | - | functools.partial[LoggingStreamProtocol] |
66 | | - ) |
| 111 | + kwargs = dict(self.kwargs) |
67 | 112 | if self.stream_output: |
68 | | - protocol_factory = functools.partial( |
69 | | - LoggingStreamProtocol, |
70 | | - limit=2**16, |
71 | | - loop=loop, |
72 | | - log_level=VERBOSE, |
73 | | - ) |
74 | | - else: |
75 | | - protocol_factory = functools.partial( |
76 | | - asyncio.subprocess.SubprocessStreamProtocol, |
77 | | - limit=2**16, |
78 | | - loop=loop, |
79 | | - ) |
| 113 | + # Inherit stdout/stderr so long-running commands (e.g. dnf builddep) |
| 114 | + # are not blocked when their output exceeds the StreamReader limit. |
| 115 | + kwargs["stdout"] = None |
| 116 | + kwargs["stderr"] = None |
| 117 | + protocol_factory = functools.partial( |
| 118 | + SubprocessStreamProtocol, |
| 119 | + limit=2**16, |
| 120 | + loop=loop, |
| 121 | + ) |
80 | 122 | transport, protocol = await loop.subprocess_exec( |
81 | 123 | protocol_factory, |
82 | 124 | *self.args, |
83 | 125 | env=self.env, |
84 | | - **self.kwargs, |
85 | | - ) |
86 | | - return asyncio.subprocess.Process( |
87 | | - transport, |
88 | | - protocol, |
89 | | - loop, |
| 126 | + start_new_session=True, |
| 127 | + **kwargs, |
90 | 128 | ) |
| 129 | + return Subprocess(transport, protocol, loop) |
91 | 130 |
|
92 | 131 | def __str__(self): |
93 | 132 | return " ".join(self.args) |
0 commit comments