-
-
Notifications
You must be signed in to change notification settings - Fork 970
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.
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
base: main
Are you sure you want to change the base?
Use subprocess timeout #2127
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -13,7 +13,6 @@ | |||||||||||||||||||||
| import logging | ||||||||||||||||||||||
| import os | ||||||||||||||||||||||
| import re | ||||||||||||||||||||||
| import signal | ||||||||||||||||||||||
| import subprocess | ||||||||||||||||||||||
| from subprocess import DEVNULL, PIPE, Popen | ||||||||||||||||||||||
| import sys | ||||||||||||||||||||||
|
|
@@ -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: float | int | None = None, | ||||||||||||||||||||||
| ) -> None: | ||||||||||||||||||||||
| R"""Register for notifications to learn that process output is ready to read, and | ||||||||||||||||||||||
| dispatch lines to the respective line handlers. | ||||||||||||||||||||||
|
|
@@ -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. | ||||||||||||||||||||||
|
|
@@ -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: float | int | None = None, | ||||||||||||||||||||||
| ) -> None: | ||||||||||||||||||||||
| self.proc = proc | ||||||||||||||||||||||
| self.args = args | ||||||||||||||||||||||
| self.status: Union[int, None] = None | ||||||||||||||||||||||
| self.timeout = timeout | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def _terminate(self) -> None: | ||||||||||||||||||||||
| """Terminate the underlying process.""" | ||||||||||||||||||||||
|
|
@@ -365,7 +370,7 @@ 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) # Ensure the process goes away. | ||||||||||||||||||||||
|
||||||||||||||||||||||
| status = proc.wait(timeout=self.timeout) # Ensure the process goes away. | |
| try: | |
| status = proc.wait(timeout=self.timeout) # Ensure the process goes away. | |
| except subprocess.TimeoutExpired: | |
| proc.kill() | |
| status = proc.wait() |
Copilot
AI
Apr 19, 2026
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.
This change is intended to enable kill_after_timeout via the built-in timeout= support, including on Windows (per PR description), but Git.execute still rejects kill_after_timeout on Windows and the docstring still claims it is unsupported. Either the Windows guard/docs should be updated/removed, or the PR description should be adjusted to match the actual behavior.
Copilot
AI
Apr 19, 2026
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.
There are existing timeout tests for remote operations, but this change introduces new timeout behavior in Git.execute itself (e.g., communicate(timeout=...) / wait(timeout=...), handling of TimeoutExpired, behavior with output_stream). Consider adding/adjusting unit tests that exercise Git.execute(kill_after_timeout=...) directly, including the output_stream branch, to prevent regressions.
Copilot
AI
Apr 19, 2026
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.
proc.communicate(timeout=kill_after_timeout) can raise subprocess.TimeoutExpired, but that exception is not handled here. That means the process may be left running and callers will see an unexpected exception rather than the documented/previous behavior of killing the process and surfacing a GitCommandError with useful stderr. Consider catching TimeoutExpired, terminating/killing the process, collecting any remaining output, and then raising GitCommandError (or otherwise translating the timeout) consistently.
Copilot
AI
Apr 19, 2026
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.
When output_stream is provided, the timeout is only applied to proc.wait(...) after stream_copy(...) finishes. If the child process hangs (or blocks writing) stream_copy can block indefinitely and kill_after_timeout will never be enforced. To preserve timeout semantics, consider using communicate(timeout=...) in this branch (writing captured stdout to output_stream), or implementing a non-blocking/selector-based copy loop that enforces the deadline while reading.
Copilot
AI
Apr 19, 2026
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 the output_stream branch, if with_stdout=False then proc.stdout is None and stdout_value remains the initial b"". In the non-output_stream branch, communicate() would return None for stdout in that case. Consider explicitly setting stdout_value = None when proc.stdout is None to keep return values consistent across branches.
| stdout_value = proc.stdout.read() | |
| if proc.stderr is not None: | |
| stderr_value = proc.stderr.read() | |
| stdout_value = proc.stdout.read() | |
| else: | |
| stdout_value = None | |
| if proc.stderr is not None: | |
| stderr_value = proc.stderr.read() | |
| else: | |
| stderr_value = None |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -113,7 +113,7 @@ def test_deletes_dir_with_readonly_files(self, tmp_path): | |||||||||||||||||||||||
| sys.platform == "cygwin", | ||||||||||||||||||||||||
| reason="Cygwin can't set the permissions that make the test meaningful.", | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
| def test_avoids_changing_permissions_outside_tree(self, tmp_path): | ||||||||||||||||||||||||
| def test_avoids_changing_permissions_outside_tree(self, tmp_path, request): | ||||||||||||||||||||||||
| # Automatically works on Windows, but on Unix requires either special handling | ||||||||||||||||||||||||
| # or refraining from attempting to fix PermissionError by making chmod calls. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
@@ -125,9 +125,32 @@ def test_avoids_changing_permissions_outside_tree(self, tmp_path): | |||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| dir2 = tmp_path / "dir2" | ||||||||||||||||||||||||
| dir2.mkdir() | ||||||||||||||||||||||||
| (dir2 / "symlink").symlink_to(dir1 / "file") | ||||||||||||||||||||||||
| symlink = dir2 / "symlink" | ||||||||||||||||||||||||
| symlink.symlink_to(dir1 / "file") | ||||||||||||||||||||||||
| dir2.chmod(stat.S_IRUSR | stat.S_IXUSR) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def preen_dir2(): | ||||||||||||||||||||||||
| """Don't leave unwritable directories behind. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| pytest has difficulties cleaning up after the fact on some platforms, | ||||||||||||||||||||||||
| e.g., macOS, and whines incessantly until the issue is resolved--regardless | ||||||||||||||||||||||||
| of the pytest session. | ||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||
| rwx = stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | ||||||||||||||||||||||||
| if not dir2.exists(): | ||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
| if symlink.exists(): | ||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||
| # Try lchmod first, if the platform supports it. | ||||||||||||||||||||||||
| symlink.lchmod(rwx) | ||||||||||||||||||||||||
| except NotImplementedError: | ||||||||||||||||||||||||
| # The platform (probably win32) doesn't support lchmod; fall back to chmod. | ||||||||||||||||||||||||
|
Comment on lines
+144
to
+147
|
||||||||||||||||||||||||
| # Try lchmod first, if the platform supports it. | |
| symlink.lchmod(rwx) | |
| except NotImplementedError: | |
| # The platform (probably win32) doesn't support lchmod; fall back to chmod. | |
| # Try to chmod the symlink itself, if the platform supports it. | |
| if hasattr(os, "lchmod"): | |
| os.lchmod(symlink, rwx) | |
| else: | |
| symlink.chmod(rwx, follow_symlinks=False) | |
| except (AttributeError, NotImplementedError, TypeError): | |
| # Fall back to chmod if symlink-specific chmod is unavailable. |
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.
The reST markup in the
kill_after_timeoutparameter doc is inconsistent (:class:int,float, or ...). Consider using literal markup consistently (e.g.,int,float,None) to avoid broken generated docs.