GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — Use subprocess timeout by ngie-eign · Pull Request #2127 · gitpython-developers/GitPython

Use subprocess timeout #2127

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

Sign up for GitHub

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
ngie-eign wants to merge 2 commits into gitpython-developers:main from ngie-eign:use-subprocess-timeout

Uh oh!

There was an error while loading. Please reload this page.

Closed

Use subprocess timeout #2127

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
2 commits
Select commit Hold shift + click to select a range
e2d689f
Use subprocess's timeout feature instead of reinventing the wheel
ngie-eign Apr 18, 2026
ec130d6
fixup! Use subprocess's timeout feature instead of reinventing the wheel
ngie-eign Apr 22, 2026
File filter
Filter by extension

Clear filters
Conversations
Failed to load comments.
Loading

Uh oh!

There was an error while loading. Please reload this page.

Jump to
Jump to file
Failed to load files.
Loading

Uh oh!

There was an error while loading. Please reload this page.

Diff view
Diff view

Some comments aren't visible on the classic Files Changed page.

There are no files selected for viewing

118 changes: 36 additions & 82 deletions 118 git/cmd.py
Show comments
View file
Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import logging
import os
import re
import signal
import subprocess
from subprocess import DEVNULL, PIPE, Popen
import sys
Expand Down Expand Up @@ -110,7 +109,7 @@ def handle_process_output(
stderr_handler: Union[None, Callable[[AnyStr], None], Callable[[List[AnyStr]], None]],
finalizer: Union[None, Callable[[Union[Popen, "Git.AutoInterrupt"]], None]] = None,
decode_streams: bool = True,
kill_after_timeout: Union[None, float] = None,
kill_after_timeout: Optional[Union[float, int]] = None,
) -> None:
R"""Register for notifications to learn that process output is ready to read, and
dispatch lines to the respective line handlers.
Expand Down Expand Up @@ -139,7 +138,7 @@ def handle_process_output(
- decoding must happen later, such as for :class:`~git.diff.Diff`\s.

:param kill_after_timeout:
:class:`float` or ``None``, Default = ``None``
:class:``int``, ``float``, or ``None`` (block indefinitely), Default = ``None``.

To specify a timeout in seconds for the git command, after which the process
should be killed.
Expand Down Expand Up @@ -326,16 +325,22 @@ class _AutoInterrupt:
raise.
"""

__slots__ = ("proc", "args", "status")
__slots__ = ("proc", "args", "status", "timeout")

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

def __init__(self, proc: Union[None, subprocess.Popen], args: Any) -> None:
def __init__(
self,
proc: subprocess.Popen | None,
args: Any,
timeout: Optional[Union[float, int]] = None,
) -> None:
self.proc = proc
self.args = args
self.status: Union[int, None] = None
self.timeout = timeout
Comment thread
Comment on lines +334 to +343

Copilot AI Apr 22, 2026

Copy link

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_AutoInterrupt now has a timeout attribute and uses it in wait(timeout=...), but no callers pass a value (the only construction is self.AutoInterrupt(proc, command)). As a result, timeout stays None and the new timeout-aware wait()/_terminate() behavior is never applied. If the intent is to enforce kill_after_timeout for as_process=True flows (e.g., via handle_process_output), wire the timeout through when constructing AutoInterrupt or set it before calling _terminate()/wait().

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Copilot uses AI. Check for mistakes.

def _terminate(self) -> None:
"""Terminate the underlying process."""
Expand Down Expand Up @@ -365,15 +370,21 @@ def _terminate(self) -> None:
# Try to kill it.
try:
proc.terminate()
status = proc.wait() # Ensure the process goes away.
status = proc.wait(timeout=self.timeout) # Gracefully wait for the process to terminate.

self.status = self._status_code_if_terminate or status
except subprocess.TimeoutExpired:
_logger.warning("Process did not complete successfully in %s seconds; will forcefully kill", exc_info=True)
proc.kill()
status = proc.wait() # Ensure the process goes away by blocking with `timeout=None`.
self.status = self._status_code_if_terminate or status
except (OSError, AttributeError) as ex:
# On interpreter shutdown (notably on Windows), parts of the stdlib used by
# subprocess can already be torn down (e.g. `subprocess._winapi` becomes None),
# which can cause AttributeError during terminate(). In that case, we prefer
# to silently ignore to avoid noisy "Exception ignored in: __del__" messages.
_logger.info("Ignored error while terminating process: %r", ex)
Comment thread
Comment on lines 371 to 386

Copilot AI Apr 22, 2026

Copy link

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In _terminate(), if proc.wait(timeout=self.timeout) raises TimeoutExpired, the exception is logged and ignored, potentially leaving the child process running and leaked. Consider escalating to proc.kill() (or another stronger termination strategy) after a terminate+wait timeout, and/or ensuring the process is reaped.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Copilot uses AI. Check for mistakes.

# END exception handling

def __del__(self) -> None:
Expand All @@ -383,7 +394,7 @@ def __getattr__(self, attr: str) -> Any:
return getattr(self.proc, attr)

# TODO: Bad choice to mimic `proc.wait()` but with different args.
def wait(self, stderr: Union[None, str, bytes] = b"") -> int:
def wait(self, stderr: Optional[Union[str, bytes]] = b"", timeout: Optional[Union[int, float]] = None) -> int:
"""Wait for the process and return its status code.

:param stderr:
Expand All @@ -400,7 +411,8 @@ def wait(self, stderr: Union[None, str, bytes] = b"") -> int:
stderr_b = force_bytes(data=stderr, encoding="utf-8")
status: Union[int, None]
if self.proc is not None:
status = self.proc.wait()
timeout = self.timeout if timeout is None else timeout
status = self.proc.wait(timeout=timeout)
p_stderr = self.proc.stderr
else: # Assume the underlying proc was killed earlier or never existed.
status = self.status
Expand Down Expand Up @@ -1106,7 +1118,7 @@ def execute(
as_process: bool = False,
output_stream: Union[None, BinaryIO] = None,
stdout_as_string: bool = True,
kill_after_timeout: Union[None, float] = None,
kill_after_timeout: Optional[Union[float, int]] = None,
with_stdout: bool = True,
universal_newlines: bool = False,
shell: Union[None, bool] = None,
Expand Down Expand Up @@ -1156,18 +1168,10 @@ def execute(
should be killed. This will have no effect if `as_process` is set to
``True``. It is set to ``None`` by default and will let the process run
until the timeout is explicitly specified. Uses of this feature should be
carefully considered, due to the following limitations:
carefully considered, due to the following limitation:

1. This feature is not supported at all on Windows.
2. Effectiveness may vary by operating system. ``ps --ppid`` is used to
enumerate child processes, which is available on most GNU/Linux systems
but not most others.
3. Deeper descendants do not receive signals, though they may sometimes
1. Deeper descendants do not receive signals, though they may sometimes
terminate as a consequence of their parent processes being killed.
4. `kill_after_timeout` uses ``SIGKILL``, which can have negative side
effects on a repository. For example, stale locks in case of
:manpage:`git-gc(1)` could render the repository incapable of accepting
changes until the lock is manually removed.

:param with_stdout:
If ``True``, default ``True``, we open stdout on the created process.
Expand Down Expand Up @@ -1252,15 +1256,7 @@ def execute(
if inline_env is not None:
env.update(inline_env)

if sys.platform == "win32":
if kill_after_timeout is not None:
raise GitCommandError(
redacted_command,
'"kill_after_timeout" feature is not supported on Windows.',
)
cmd_not_found_exception = OSError
else:
cmd_not_found_exception = FileNotFoundError
cmd_not_found_exception = OSError if sys.platform == "win32" else FileNotFoundError
# END handle

stdout_sink = PIPE if with_stdout else getattr(subprocess, "DEVNULL", None) or open(os.devnull, "wb")
Expand Down Expand Up @@ -1303,66 +1299,14 @@ def execute(
if as_process:
return self.AutoInterrupt(proc, command)

if sys.platform != "win32" and kill_after_timeout is not None:
# Help mypy figure out this is not None even when used inside communicate().
timeout = kill_after_timeout

def kill_process(pid: int) -> None:
"""Callback to kill a process.

This callback implementation would be ineffective and unsafe on Windows.
"""
p = Popen(["ps", "--ppid", str(pid)], stdout=PIPE)
child_pids = []
if p.stdout is not None:
for line in p.stdout:
if len(line.split()) > 0:
local_pid = (line.split())[0]
if local_pid.isdigit():
child_pids.append(int(local_pid))
try:
os.kill(pid, signal.SIGKILL)
for child_pid in child_pids:
try:
os.kill(child_pid, signal.SIGKILL)
except OSError:
pass
# Tell the main routine that the process was killed.
kill_check.set()
except OSError:
# It is possible that the process gets completed in the duration
# after timeout happens and before we try to kill the process.
pass
return

def communicate() -> Tuple[AnyStr, AnyStr]:
watchdog.start()
out, err = proc.communicate()
watchdog.cancel()
if kill_check.is_set():
err = 'Timeout: the command "%s" did not complete in %d secs.' % (
" ".join(redacted_command),
timeout,
)
if not universal_newlines:
err = err.encode(defenc)
return out, err

# END helpers

kill_check = threading.Event()
watchdog = threading.Timer(timeout, kill_process, args=(proc.pid,))
else:
communicate = proc.communicate

# Wait for the process to return.
status = 0
stdout_value: Union[str, bytes] = b""
stderr_value: Union[str, bytes] = b""
newline = "\n" if universal_newlines else b"\n"
try:
if output_stream is None:
stdout_value, stderr_value = communicate()
stdout_value, stderr_value = proc.communicate(timeout=kill_after_timeout)
Comment thread
ngie-eign marked this conversation as resolved.
Resolved

Uh oh!

There was an error while loading. Please reload this page.

# Strip trailing "\n".
Comment thread
ngie-eign marked this conversation as resolved.
Resolved

Uh oh!

There was an error while loading. Please reload this page.

Comment thread
ngie-eign marked this conversation as resolved.
Resolved

Uh oh!

There was an error while loading. Please reload this page.

if stdout_value is not None and stdout_value.endswith(newline) and strip_newline_in_stdout: # type: ignore[arg-type]
stdout_value = stdout_value[:-1]
Expand All @@ -1380,8 +1324,18 @@ def communicate() -> Tuple[AnyStr, AnyStr]:
# Strip trailing "\n".
if stderr_value is not None and stderr_value.endswith(newline): # type: ignore[arg-type]
stderr_value = stderr_value[:-1]
status = proc.wait()
status = proc.wait(timeout=kill_after_timeout)
# END stdout handling
except subprocess.TimeoutExpired as err:
_logger.info(
"error: process killed because it timed out. kill_after_timeout=%s seconds",
kill_after_timeout,
)
with contextlib.suppress(subprocess.TimeoutExpired):
proc.kill()
stdout_value, stderr_value = proc.communicate(timeout=0.1)
if with_exceptions:
raise GitCommandError(redacted_command, proc.returncode, stderr_value, stdout_value) from err
finally:
if proc.stdout is not None:
proc.stdout.close()
Expand Down
12 changes: 7 additions & 5 deletions 12 git/remote.py
Show comments
View file
Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

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

from __future__ import annotations

__all__ = ["RemoteProgress", "PushInfo", "FetchInfo", "Remote"]

import contextlib
Expand Down Expand Up @@ -873,7 +875,7 @@ def _get_fetch_info_from_stderr(
self,
proc: "Git.AutoInterrupt",
progress: Union[Callable[..., Any], RemoteProgress, None],
kill_after_timeout: Union[None, float] = None,
kill_after_timeout: Optional[Union[float, int]] = None,
) -> IterableList["FetchInfo"]:
progress = to_progress_instance(progress)

Expand Down Expand Up @@ -944,7 +946,7 @@ def _get_push_info(
self,
proc: "Git.AutoInterrupt",
progress: Union[Callable[..., Any], RemoteProgress, None],
kill_after_timeout: Union[None, float] = None,
kill_after_timeout: Optional[Union[float, int]] = None,
) -> PushInfoList:
progress = to_progress_instance(progress)

Expand Down Expand Up @@ -1002,7 +1004,7 @@ def fetch(
refspec: Union[str, List[str], None] = None,
progress: Union[RemoteProgress, None, "UpdateProgress"] = None,
verbose: bool = True,
kill_after_timeout: Union[None, float] = None,
kill_after_timeout: Optional[Union[float, int]] = None,
allow_unsafe_protocols: bool = False,
allow_unsafe_options: bool = False,
**kwargs: Any,
Expand Down Expand Up @@ -1082,7 +1084,7 @@ def pull(
self,
refspec: Union[str, List[str], None] = None,
progress: Union[RemoteProgress, "UpdateProgress", None] = None,
kill_after_timeout: Union[None, float] = None,
kill_after_timeout: Optional[Union[float, int]] = None,
allow_unsafe_protocols: bool = False,
allow_unsafe_options: bool = False,
**kwargs: Any,
Expand Down Expand Up @@ -1136,7 +1138,7 @@ def push(
self,
refspec: Union[str, List[str], None] = None,
progress: Union[RemoteProgress, "UpdateProgress", Callable[..., RemoteProgress], None] = None,
kill_after_timeout: Union[None, float] = None,
kill_after_timeout: Optional[Union[float, int]] = None,
allow_unsafe_protocols: bool = False,
allow_unsafe_options: bool = False,
**kwargs: Any,
Expand Down
Loading
Oops, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

Add this suggestion to a batch that can be applied as a single commit. This suggestion is invalid because no changes were made to the code. Suggestions cannot be applied while the pull request is closed. Suggestions cannot be applied while viewing a subset of changes. Only one suggestion per line can be applied in a batch. Add this suggestion to a batch that can be applied as a single commit. Applying suggestions on deleted lines is not supported. You must change the existing code in this line in order to create a valid suggestion. Outdated suggestions cannot be applied. This suggestion has been applied or marked resolved. Suggestions cannot be applied from pending reviews. Suggestions cannot be applied on multi-line comments. Suggestions cannot be applied while the pull request is queued to merge. Suggestion cannot be applied right now. Please check back later.
You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.