Skip to content

Commit e2d689f

Browse files
committed
Use subprocess's timeout feature instead of reinventing the wheel
subprocess's APIs in 3.3+ support passing timeout to calls, such as .communicate(..), .wait(..), etc. Pass `kill_after_timeout` to those APIs and remove the watchdog handler code as it's not needed once `timeout=` is used. This enables `kill_after_timeout` on Windows platforms by side-effect as upstream implements `timeout` for all supported platforms. Signed-off-by: Enji Cooper <yaneurabeya@gmail.com>
1 parent da54523 commit e2d689f

2 files changed

Lines changed: 36 additions & 87 deletions

File tree

git/cmd.py

Lines changed: 29 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import logging
1414
import os
1515
import re
16-
import signal
1716
import subprocess
1817
from subprocess import DEVNULL, PIPE, Popen
1918
import sys
@@ -110,7 +109,7 @@ def handle_process_output(
110109
stderr_handler: Union[None, Callable[[AnyStr], None], Callable[[List[AnyStr]], None]],
111110
finalizer: Union[None, Callable[[Union[Popen, "Git.AutoInterrupt"]], None]] = None,
112111
decode_streams: bool = True,
113-
kill_after_timeout: Union[None, float] = None,
112+
kill_after_timeout: Optional[float, int] = None,
114113
) -> None:
115114
R"""Register for notifications to learn that process output is ready to read, and
116115
dispatch lines to the respective line handlers.
@@ -139,7 +138,7 @@ def handle_process_output(
139138
- decoding must happen later, such as for :class:`~git.diff.Diff`\s.
140139
141140
:param kill_after_timeout:
142-
:class:`float` or ``None``, Default = ``None``
141+
:class:``int``, ``float``, or ``None`` (block indefinitely), Default = ``None``.
143142
144143
To specify a timeout in seconds for the git command, after which the process
145144
should be killed.
@@ -326,16 +325,22 @@ class _AutoInterrupt:
326325
raise.
327326
"""
328327

329-
__slots__ = ("proc", "args", "status")
328+
__slots__ = ("proc", "args", "status", "timeout")
330329

331330
# If this is non-zero it will override any status code during _terminate, used
332331
# to prevent race conditions in testing.
333332
_status_code_if_terminate: int = 0
334333

335-
def __init__(self, proc: Union[None, subprocess.Popen], args: Any) -> None:
334+
def __init__(
335+
self,
336+
proc: subprocess.Popen | None,
337+
args: Any,
338+
timeout: Optional[float, int] = None,
339+
) -> None:
336340
self.proc = proc
337341
self.args = args
338342
self.status: Union[int, None] = None
343+
self.timeout = timeout
339344

340345
def _terminate(self) -> None:
341346
"""Terminate the underlying process."""
@@ -365,15 +370,16 @@ def _terminate(self) -> None:
365370
# Try to kill it.
366371
try:
367372
proc.terminate()
368-
status = proc.wait() # Ensure the process goes away.
373+
status = proc.wait(timeout=self.timeout) # Ensure the process goes away.
369374

370375
self.status = self._status_code_if_terminate or status
371-
except (OSError, AttributeError) as ex:
376+
except (OSError, AttributeError, subprocess.TimeoutExpired) as ex:
372377
# On interpreter shutdown (notably on Windows), parts of the stdlib used by
373378
# subprocess can already be torn down (e.g. `subprocess._winapi` becomes None),
374379
# which can cause AttributeError during terminate(). In that case, we prefer
375380
# to silently ignore to avoid noisy "Exception ignored in: __del__" messages.
376381
_logger.info("Ignored error while terminating process: %r", ex)
382+
377383
# END exception handling
378384

379385
def __del__(self) -> None:
@@ -400,7 +406,7 @@ def wait(self, stderr: Union[None, str, bytes] = b"") -> int:
400406
stderr_b = force_bytes(data=stderr, encoding="utf-8")
401407
status: Union[int, None]
402408
if self.proc is not None:
403-
status = self.proc.wait()
409+
status = self.proc.wait(timeout=self.timeout)
404410
p_stderr = self.proc.stderr
405411
else: # Assume the underlying proc was killed earlier or never existed.
406412
status = self.status
@@ -1106,7 +1112,7 @@ def execute(
11061112
as_process: bool = False,
11071113
output_stream: Union[None, BinaryIO] = None,
11081114
stdout_as_string: bool = True,
1109-
kill_after_timeout: Union[None, float] = None,
1115+
kill_after_timeout: Optional[float, int] = None,
11101116
with_stdout: bool = True,
11111117
universal_newlines: bool = False,
11121118
shell: Union[None, bool] = None,
@@ -1156,18 +1162,10 @@ def execute(
11561162
should be killed. This will have no effect if `as_process` is set to
11571163
``True``. It is set to ``None`` by default and will let the process run
11581164
until the timeout is explicitly specified. Uses of this feature should be
1159-
carefully considered, due to the following limitations:
1165+
carefully considered, due to the following limitation:
11601166
1161-
1. This feature is not supported at all on Windows.
1162-
2. Effectiveness may vary by operating system. ``ps --ppid`` is used to
1163-
enumerate child processes, which is available on most GNU/Linux systems
1164-
but not most others.
1165-
3. Deeper descendants do not receive signals, though they may sometimes
1167+
1. Deeper descendants do not receive signals, though they may sometimes
11661168
terminate as a consequence of their parent processes being killed.
1167-
4. `kill_after_timeout` uses ``SIGKILL``, which can have negative side
1168-
effects on a repository. For example, stale locks in case of
1169-
:manpage:`git-gc(1)` could render the repository incapable of accepting
1170-
changes until the lock is manually removed.
11711169
11721170
:param with_stdout:
11731171
If ``True``, default ``True``, we open stdout on the created process.
@@ -1252,15 +1250,7 @@ def execute(
12521250
if inline_env is not None:
12531251
env.update(inline_env)
12541252

1255-
if sys.platform == "win32":
1256-
if kill_after_timeout is not None:
1257-
raise GitCommandError(
1258-
redacted_command,
1259-
'"kill_after_timeout" feature is not supported on Windows.',
1260-
)
1261-
cmd_not_found_exception = OSError
1262-
else:
1263-
cmd_not_found_exception = FileNotFoundError
1253+
cmd_not_found_exception = OSError if sys.platform == "win32" else FileNotFoundError
12641254
# END handle
12651255

12661256
stdout_sink = PIPE if with_stdout else getattr(subprocess, "DEVNULL", None) or open(os.devnull, "wb")
@@ -1303,66 +1293,14 @@ def execute(
13031293
if as_process:
13041294
return self.AutoInterrupt(proc, command)
13051295

1306-
if sys.platform != "win32" and kill_after_timeout is not None:
1307-
# Help mypy figure out this is not None even when used inside communicate().
1308-
timeout = kill_after_timeout
1309-
1310-
def kill_process(pid: int) -> None:
1311-
"""Callback to kill a process.
1312-
1313-
This callback implementation would be ineffective and unsafe on Windows.
1314-
"""
1315-
p = Popen(["ps", "--ppid", str(pid)], stdout=PIPE)
1316-
child_pids = []
1317-
if p.stdout is not None:
1318-
for line in p.stdout:
1319-
if len(line.split()) > 0:
1320-
local_pid = (line.split())[0]
1321-
if local_pid.isdigit():
1322-
child_pids.append(int(local_pid))
1323-
try:
1324-
os.kill(pid, signal.SIGKILL)
1325-
for child_pid in child_pids:
1326-
try:
1327-
os.kill(child_pid, signal.SIGKILL)
1328-
except OSError:
1329-
pass
1330-
# Tell the main routine that the process was killed.
1331-
kill_check.set()
1332-
except OSError:
1333-
# It is possible that the process gets completed in the duration
1334-
# after timeout happens and before we try to kill the process.
1335-
pass
1336-
return
1337-
1338-
def communicate() -> Tuple[AnyStr, AnyStr]:
1339-
watchdog.start()
1340-
out, err = proc.communicate()
1341-
watchdog.cancel()
1342-
if kill_check.is_set():
1343-
err = 'Timeout: the command "%s" did not complete in %d secs.' % (
1344-
" ".join(redacted_command),
1345-
timeout,
1346-
)
1347-
if not universal_newlines:
1348-
err = err.encode(defenc)
1349-
return out, err
1350-
1351-
# END helpers
1352-
1353-
kill_check = threading.Event()
1354-
watchdog = threading.Timer(timeout, kill_process, args=(proc.pid,))
1355-
else:
1356-
communicate = proc.communicate
1357-
13581296
# Wait for the process to return.
13591297
status = 0
13601298
stdout_value: Union[str, bytes] = b""
13611299
stderr_value: Union[str, bytes] = b""
13621300
newline = "\n" if universal_newlines else b"\n"
13631301
try:
13641302
if output_stream is None:
1365-
stdout_value, stderr_value = communicate()
1303+
stdout_value, stderr_value = proc.communicate(timeout=kill_after_timeout)
13661304
# Strip trailing "\n".
13671305
if stdout_value is not None and stdout_value.endswith(newline) and strip_newline_in_stdout: # type: ignore[arg-type]
13681306
stdout_value = stdout_value[:-1]
@@ -1380,8 +1318,17 @@ def communicate() -> Tuple[AnyStr, AnyStr]:
13801318
# Strip trailing "\n".
13811319
if stderr_value is not None and stderr_value.endswith(newline): # type: ignore[arg-type]
13821320
stderr_value = stderr_value[:-1]
1383-
status = proc.wait()
1321+
status = proc.wait(timeout=kill_after_timeout)
13841322
# END stdout handling
1323+
except subprocess.TimeoutExpired as err:
1324+
_logger.info(
1325+
"error: process killed because it timed out. kill_after_timeout=%s seconds",
1326+
kill_after_timeout,
1327+
)
1328+
proc.terminate()
1329+
stdout_value, stderr_value = proc.communicate()
1330+
if with_exceptions:
1331+
raise GitCommandError(redacted_command, proc.returncode, stderr_value, stdout_value) from err
13851332
finally:
13861333
if proc.stdout is not None:
13871334
proc.stdout.close()

git/remote.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
"""Module implementing a remote object allowing easy access to git remotes."""
77

8+
from __future__ import annotations
9+
810
__all__ = ["RemoteProgress", "PushInfo", "FetchInfo", "Remote"]
911

1012
import contextlib
@@ -873,7 +875,7 @@ def _get_fetch_info_from_stderr(
873875
self,
874876
proc: "Git.AutoInterrupt",
875877
progress: Union[Callable[..., Any], RemoteProgress, None],
876-
kill_after_timeout: Union[None, float] = None,
878+
kill_after_timeout: Optional[float, int] = None,
877879
) -> IterableList["FetchInfo"]:
878880
progress = to_progress_instance(progress)
879881

@@ -944,7 +946,7 @@ def _get_push_info(
944946
self,
945947
proc: "Git.AutoInterrupt",
946948
progress: Union[Callable[..., Any], RemoteProgress, None],
947-
kill_after_timeout: Union[None, float] = None,
949+
kill_after_timeout: Optional[float, int] = None,
948950
) -> PushInfoList:
949951
progress = to_progress_instance(progress)
950952

@@ -1002,7 +1004,7 @@ def fetch(
10021004
refspec: Union[str, List[str], None] = None,
10031005
progress: Union[RemoteProgress, None, "UpdateProgress"] = None,
10041006
verbose: bool = True,
1005-
kill_after_timeout: Union[None, float] = None,
1007+
kill_after_timeout: Optional[float, int] = None,
10061008
allow_unsafe_protocols: bool = False,
10071009
allow_unsafe_options: bool = False,
10081010
**kwargs: Any,
@@ -1082,7 +1084,7 @@ def pull(
10821084
self,
10831085
refspec: Union[str, List[str], None] = None,
10841086
progress: Union[RemoteProgress, "UpdateProgress", None] = None,
1085-
kill_after_timeout: Union[None, float] = None,
1087+
kill_after_timeout: Optional[float, int] = None,
10861088
allow_unsafe_protocols: bool = False,
10871089
allow_unsafe_options: bool = False,
10881090
**kwargs: Any,
@@ -1136,7 +1138,7 @@ def push(
11361138
self,
11371139
refspec: Union[str, List[str], None] = None,
11381140
progress: Union[RemoteProgress, "UpdateProgress", Callable[..., RemoteProgress], None] = None,
1139-
kill_after_timeout: Union[None, float] = None,
1141+
kill_after_timeout: Optional[float, int] = None,
11401142
allow_unsafe_protocols: bool = False,
11411143
allow_unsafe_options: bool = False,
11421144
**kwargs: Any,

0 commit comments

Comments
 (0)