Skip to content

Commit 3d1cfe1

Browse files
codexByron
authored andcommitted
fix: stop stalled remote commands when their timeout expires (#2276)
`Remote.fetch(kill_after_timeout=...)` can hang indefinitely when Git stops writing to stderr: `AutoInterrupt._terminate()` closes the buffered stream while its pump thread holds the read lock, before signalling the process. `Remote.pull()` and `Remote.push()` share the same path. Killing only Git or its direct children also leaves HTTP(S) helpers holding stderr open; Apple Git launches the network helper through an intermediate Git process. Move output stream closure after process termination, preserving stdin EOF before waiting for exit. Extract the existing POSIX watchdog process lookup into `_kill_process()` and collect descendants before sending `SIGKILL`, reusing it for both command and remote timeouts. Store one timeout diagnostic for `AutoInterrupt.wait()` without synchronously re-entering user callbacks. Share one monotonic deadline between stdout and stderr rather than allowing each join the full timeout. Ordinary `AutoInterrupt` cleanup retains `SIGTERM`. Add a bounded loopback-server regression for fetch, pull, and push over `git://`, HTTP, and HTTPS. All nine cases failed before the fix, taking about five seconds until the server released the stalled connection, and now pass in 6.66 seconds total with a 0.5-second command timeout. Extend the existing `ps` fallback test to cover grandchildren and exclude unrelated processes. Git behavior reference: the local Git source baseline is `v2.56.0-rc1`. `transport-helper.c:get_helper()` sets `helper->err = 0`, inheriting stderr; `connect.c:git_connect()` starts SSH transport children; `run-command.c` handles inherited descriptors and only signals children marked for cleanup. Runtime reproduction used Apple Git `2.54.0 (Apple Git-157)`. Validation: command, cleanup, and command-deprecation tests: 119 passed, 1 skipped. Remote tests: 39 passed, 1 failed; `TestRemote.test_base` fails identically with the unchanged `git/cmd.py` on this machine because `refs/remotes/daemon_origin/new_branch` is missing. Ruff lint/format and `git diff --check` pass. Pinned mypy and basedpyright pass with no issues. Python 3.8 focused timeout/cleanup regressions: 16 passed. Codex commit review identified that post-termination `stdin.close()` can flush buffered writes into a broken pipe, raising `BrokenPipeError` and skipping stdout/stderr cleanup. Reproduced with `git hash-object --stdin` and added a regression. Suppress that expected broken pipe while closing stdin so output streams are still closed. Both cleanup tests pass on Python 3.12; Python 3.8 cleanup plus all stalled-remote regressions: 11 passed. Ruff, mypy, and basedpyright pass after this correction. A further commit review reproduced ordinary cleanup hanging when a child ignores `SIGTERM` and waits for stdin EOF. Preserve input closure before termination, suppressing a broken pipe if timeout signalling already killed the reader, and retain delayed output closure. A bounded subprocess regression failed before this correction and now exits normally without its safety kill. Python 3.8 cleanup plus stalled-remote regressions: 12 passed; mypy, basedpyright, Ruff, and `git diff --check` pass. Commit review also reproduced waiting on undrained output after stdin EOF, and an unconditional post-timeout pump join waiting for a blocked callback. Signal before output closure but reap afterward, retaining the original nonblocking pipe cleanup order. Remove the added unconditional pump joins. Treat `ValueError` from an already closed stream as expected cleanup, while preserving failures from handlers. Both bounded regressions failed before these corrections and pass afterward. Python 3.8 focused timeout/cleanup coverage: 19 passed. Type checks and Ruff pass. Further review reproduced timeout aborting before termination when both process lookup utilities are unavailable, and regression setup inheriting global commit signing. Make descendant enumeration best-effort on `OSError`, still sending `SIGKILL` to known processes, and disable signing for the synthetic regression commit. The missing-tools regression failed before the correction and passes afterward. Python 3.8 focused tests: 20 passed; type checks and Ruff pass. PR CI's Windows Python 3.14/3.15 jobs found `signal.SIGKILL` unavailable in Windows stubs after extracting the helper. Reproduced with `mypy --platform win32 --python-version 3.14`; guard the POSIX signalling loop with the same platform condition used by its callers. Windows mypy, native mypy, basedpyright, Ruff, and `git diff --check` pass afterward. Copilot review noted that synchronously emitting a timeout to a blocked stderr handler still hangs, and that `Git.execute()` documented only direct-child signalling. Store the timeout diagnostic on `AutoInterrupt` for `wait()` rather than invoking either callback, and extend callback coverage to stderr. Both stdout/stderr cases failed before this correction and pass afterward with the timeout message intact. Update the public descendant limitation to describe detachment and processes spawned after enumeration. Python 3.8 focused timeout and cleanup coverage: 21 passed. Native and Windows mypy, basedpyright, Ruff, and `git diff --check` pass. Commit review reproduced a partial push losing its timeout after at least one porcelain result when progress stderr contained no other error. Preserve the stored timeout in `Remote._get_push_info()` when assigning `PushInfoList.error`, so `raise_if_error()` still raises. The partial-result regression failed before this correction and passes afterward. Python 3.8 focused timeout/cleanup tests: 22 passed. Native/Windows mypy, basedpyright, Ruff lint/format and diff checks pass. Cygwin CI ran all new stalled-remote cases and exposed that its `ps` rejects POSIX `-A`/`-o` options, leaving fetch/pull HTTP(S) helpers alive. Use Cygwin's supported `ps -ef` and select the UID-prefixed PID/PPID columns. Extend the existing descendant parser test with representative Cygwin rows; it failed before this fix and now passes. Cygwin upstream `winsup/utils/ps.cc` defines that format and supports `-e`/`-f`. Native/Windows type checks and Ruff pass. Cygwin rerun: all nine stalled-remote regressions pass. The POSIX parser parameter alone failed because it inherited `sys.platform == "cygwin"`; explicitly simulate Linux or Cygwin for each parser parameter. Both parser cases, basedpyright, Ruff, and diff checks pass. Cygwin reported 1581 passed with that single test failure before this fixture correction. Final Copilot feedback found partial push timeouts emitting an empty warning labelled as fetching. Warn only when stderr is captured and label it as pushing, preserving the stored timeout in `PushInfoList.error`. The enhanced partial-push regression failed before the correction and now passes; Ruff, mypy, and diff checks pass. The preceding head passed all 50 CI checks. Copilot review identified that output handling can time out after the child has already exited successfully. In that case `AutoInterrupt.wait()` previously returned zero despite its stored timeout marker. Normalize a successful or unknown status to failure when that marker is present, preserving any existing nonzero child status and reporting the timeout diagnostic. Extend the blocked stdout/stderr regression to children that print and exit immediately; both new cases failed before this correction and now raise with a nonzero status. Python 3.8 focused timeout/cleanup suite: 24 passed. Native and Windows-targeted mypy, basedpyright, Ruff lint/format, and `git diff --check` pass. Further Copilot feedback identified that a blocked output handler can outlive an already-reaped child, leaving an obsolete PID in its `Popen` object. Check `poll()` before enumerating or signalling the process tree so handler timeouts still fail without attempting to kill an exited child's potentially recycled PID. Both exited-child callback regressions now assert that `_kill_process()` is never called; both failed before the guard and pass afterward. Python 3.8 focused timeout/cleanup suite: 24 passed. Native and Windows-targeted mypy, basedpyright, Ruff lint/format, and `git diff --check` pass.
1 parent b5d6a79 commit 3d1cfe1

5 files changed

Lines changed: 283 additions & 84 deletions

File tree

‎git/cmd.py‎

Lines changed: 94 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import sys
2020
from textwrap import dedent
2121
import threading
22+
import time
2223
import warnings
2324

2425
from git.compat import defenc, force_bytes, safe_decode
@@ -99,6 +100,45 @@
99100
## @{
100101

101102

103+
def _kill_process(pid: int) -> bool:
104+
"""Kill a POSIX process and its descendants, returning whether it was killed."""
105+
# Collect descendants before signalling, while their parent PIDs still identify them.
106+
pids = [pid]
107+
for parent_pid in pids:
108+
try:
109+
try:
110+
p = Popen(["pgrep", "-P", str(parent_pid)], stdout=PIPE)
111+
except FileNotFoundError:
112+
# POSIX ps does not support selecting by parent PID.
113+
ps_args = ["ps", "-ef"] if sys.platform == "cygwin" else ["ps", "-A", "-o", "pid=", "-o", "ppid="]
114+
with Popen(ps_args, stdout=PIPE) as p:
115+
if p.stdout is not None:
116+
for line in p.stdout:
117+
fields = line.split()
118+
if sys.platform == "cygwin":
119+
fields = fields[1:3] # ps -ef starts with UID, PID, PPID.
120+
if len(fields) == 2 and all(field.isdigit() for field in fields):
121+
if int(fields[1]) == parent_pid:
122+
pids.append(int(fields[0]))
123+
else:
124+
with p:
125+
if p.stdout is not None:
126+
for line in p.stdout:
127+
if line.strip().isdigit():
128+
pids.append(int(line))
129+
except OSError as ex:
130+
_logger.info("Unable to enumerate child processes: %r", ex)
131+
killed = False
132+
if sys.platform != "win32":
133+
for process_pid in pids:
134+
try:
135+
os.kill(process_pid, signal.SIGKILL)
136+
killed = killed or process_pid == pid
137+
except OSError:
138+
pass
139+
return killed
140+
141+
102142
def handle_process_output(
103143
process: Union["Git.AutoInterrupt", Popen],
104144
stdout_handler: Union[
@@ -165,7 +205,7 @@ def pump_stream(
165205

166206
except Exception as ex:
167207
_logger.error(f"Pumping {name!r} of cmd({remove_password_if_present(cmdline)}) failed due to: {ex!r}")
168-
if "I/O operation on closed file" not in str(ex):
208+
if not (isinstance(ex, ValueError) and stream.closed):
169209
# Only reraise if the error was not due to the stream closing.
170210
raise CommandError([f"<{name}-pump>"] + remove_password_if_present(cmdline), ex) from ex
171211
finally:
@@ -200,27 +240,23 @@ def pump_stream(
200240
threads.append(t)
201241

202242
# FIXME: Why join? Will block if stdin needs feeding...
243+
deadline = None if kill_after_timeout is None else time.monotonic() + kill_after_timeout
203244
for t in threads:
204-
t.join(timeout=kill_after_timeout)
245+
t.join(timeout=None if deadline is None else max(0, deadline - time.monotonic()))
205246
if t.is_alive():
206247
if isinstance(process, Git.AutoInterrupt):
248+
if sys.platform != "win32" and process.proc is not None and process.proc.poll() is None:
249+
_kill_process(process.proc.pid)
207250
process._terminate()
208251
else: # Don't want to deal with the other case.
209252
raise RuntimeError(
210253
"Thread join() timed out in cmd.handle_process_output()."
211254
f" kill_after_timeout={kill_after_timeout} seconds"
212255
)
213-
if stderr_handler:
214-
error_str: Union[str, bytes] = (
215-
f"error: process killed because it timed out. kill_after_timeout={kill_after_timeout} seconds"
216-
)
217-
if not decode_streams and isinstance(p_stderr, BinaryIO):
218-
# Assume stderr_handler needs binary input.
219-
error_str = cast(str, error_str)
220-
error_str = error_str.encode()
221-
# We ignore typing on the next line because mypy does not like the way
222-
# we inferred that stderr takes str or bytes.
223-
stderr_handler(error_str) # type: ignore[arg-type]
256+
process._timeout_error = (
257+
f"error: process killed because it timed out. kill_after_timeout={kill_after_timeout} seconds"
258+
)
259+
break
224260

225261
if finalizer:
226262
finalizer(process)
@@ -326,7 +362,7 @@ class _AutoInterrupt:
326362
raise.
327363
"""
328364

329-
__slots__ = ("proc", "args", "status")
365+
__slots__ = ("proc", "args", "status", "_timeout_error")
330366

331367
# If this is non-zero it will override any status code during _terminate, used
332368
# to prevent race conditions in testing.
@@ -336,6 +372,7 @@ def __init__(self, proc: Union[None, subprocess.Popen], args: Any) -> None:
336372
self.proc = proc
337373
self.args = args
338374
self.status: Union[int, None] = None
375+
self._timeout_error: Optional[str] = None
339376

340377
def _terminate(self) -> None:
341378
"""Terminate the underlying process."""
@@ -344,37 +381,45 @@ def _terminate(self) -> None:
344381

345382
proc = self.proc
346383
self.proc = None
347-
if proc.stdin:
348-
proc.stdin.close()
349-
if proc.stdout:
350-
proc.stdout.close()
351-
if proc.stderr:
352-
proc.stderr.close()
353-
# Did the process finish already so we have a return code?
354384
try:
355-
if proc.poll() is not None:
356-
self.status = self._status_code_if_terminate or proc.poll()
385+
if proc.stdin:
386+
# A timed-out process may already have exited before input is flushed.
387+
with contextlib.suppress(BrokenPipeError):
388+
proc.stdin.close()
389+
# Did the process finish already so we have a return code?
390+
try:
391+
if proc.poll() is not None:
392+
self.status = self._status_code_if_terminate or proc.poll()
393+
return
394+
except OSError as ex:
395+
_logger.info("Ignored error after process had died: %r", ex)
396+
397+
# It can be that nothing really exists anymore...
398+
if os is None or getattr(os, "kill", None) is None:
357399
return
358-
except OSError as ex:
359-
_logger.info("Ignored error after process had died: %r", ex)
360400

361-
# It can be that nothing really exists anymore...
362-
if os is None or getattr(os, "kill", None) is None:
363-
return
401+
# Try to kill it.
402+
try:
403+
proc.terminate()
404+
except (OSError, AttributeError) as ex:
405+
# On interpreter shutdown (notably on Windows), parts of the stdlib used by
406+
# subprocess can already be torn down (e.g. `subprocess._winapi` becomes None),
407+
# which can cause AttributeError during terminate(). In that case, we prefer
408+
# to silently ignore to avoid noisy "Exception ignored in: __del__" messages.
409+
_logger.info("Ignored error while terminating process: %r", ex)
410+
return
411+
# END exception handling
412+
finally:
413+
if proc.stdout:
414+
proc.stdout.close()
415+
if proc.stderr:
416+
proc.stderr.close()
364417

365-
# Try to kill it.
366418
try:
367-
proc.terminate()
368-
status = proc.wait() # Ensure the process goes away.
369-
419+
status = proc.wait()
370420
self.status = self._status_code_if_terminate or status
371421
except (OSError, AttributeError) as ex:
372-
# On interpreter shutdown (notably on Windows), parts of the stdlib used by
373-
# subprocess can already be torn down (e.g. `subprocess._winapi` becomes None),
374-
# which can cause AttributeError during terminate(). In that case, we prefer
375-
# to silently ignore to avoid noisy "Exception ignored in: __del__" messages.
376-
_logger.info("Ignored error while terminating process: %r", ex)
377-
# END exception handling
422+
_logger.info("Ignored error while waiting for terminated process: %r", ex)
378423

379424
def __del__(self) -> None:
380425
self._terminate()
@@ -393,9 +438,8 @@ def wait(self, stderr: Union[None, str, bytes] = b"") -> int:
393438
May deadlock if output or error pipes are used and not handled separately.
394439
395440
:raise git.exc.GitCommandError:
396-
If the return status is not 0.
441+
If the return status is not 0 or output handling timed out.
397442
"""
398-
stderr_b = force_bytes(data=stderr, encoding="utf-8") or b""
399443
status: Union[int, None]
400444
if self.proc is not None:
401445
status = self.proc.wait()
@@ -404,6 +448,11 @@ def wait(self, stderr: Union[None, str, bytes] = b"") -> int:
404448
status = self.status
405449
p_stderr = None
406450

451+
if self._timeout_error is not None:
452+
stderr = self._timeout_error
453+
status = status or 1
454+
stderr_b = force_bytes(data=stderr, encoding="utf-8") or b""
455+
407456
def read_all_from_possibly_closed_stream(stream: Union[IO[bytes], None]) -> bytes:
408457
if stream:
409458
try:
@@ -1381,9 +1430,10 @@ def execute(
13811430
1. This feature is not supported at all on Windows.
13821431
2. Enumerating child processes requires ``pgrep -P``, or a ``ps`` command
13831432
supporting the POSIX ``-A`` and ``-o`` options if ``pgrep`` is not
1384-
installed. Effectiveness may vary on systems without these commands.
1385-
3. Deeper descendants do not receive signals, though they may sometimes
1386-
terminate as a consequence of their parent processes being killed.
1433+
installed (``ps -ef`` on Cygwin). Effectiveness may vary on systems
1434+
without these commands.
1435+
3. Descendants are enumerated before signalling. Processes that detach
1436+
or spawn after enumeration may not receive signals.
13871437
4. `kill_after_timeout` uses ``SIGKILL``, which can have negative side
13881438
effects on a repository. For example, stale locks in case of
13891439
:manpage:`git-gc(1)` could render the repository incapable of accepting
@@ -1537,43 +1587,9 @@ def execute(
15371587
timeout = kill_after_timeout
15381588

15391589
def kill_process(pid: int) -> None:
1540-
"""Callback to kill a process.
1541-
1542-
This callback implementation would be ineffective and unsafe on Windows.
1543-
"""
1544-
child_pids = []
1545-
try:
1546-
p = Popen(["pgrep", "-P", str(pid)], stdout=PIPE)
1547-
except FileNotFoundError:
1548-
# POSIX ps does not support selecting by parent PID.
1549-
with Popen(["ps", "-A", "-o", "pid=", "-o", "ppid="], stdout=PIPE) as p:
1550-
if p.stdout is not None:
1551-
for line in p.stdout:
1552-
fields = line.split()
1553-
if len(fields) == 2 and all(field.isdigit() for field in fields):
1554-
if int(fields[1]) == pid:
1555-
child_pids.append(int(fields[0]))
1556-
else:
1557-
with p:
1558-
if p.stdout is not None:
1559-
for line in p.stdout:
1560-
if line.strip().isdigit():
1561-
child_pids.append(int(line))
1562-
try:
1563-
os.kill(pid, signal.SIGKILL)
1564-
for child_pid in child_pids:
1565-
try:
1566-
os.kill(child_pid, signal.SIGKILL)
1567-
except OSError:
1568-
pass
1569-
# Tell the main routine that the process was killed.
1590+
if _kill_process(pid):
15701591
assert kill_check is not None
15711592
kill_check.set()
1572-
except OSError:
1573-
# It is possible that the process gets completed in the duration
1574-
# after timeout happens and before we try to kill the process.
1575-
pass
1576-
return
15771593

15781594
def make_timeout_error() -> Union[str, bytes]:
15791595
err = f'Timeout: the command "{" ".join(redacted_command)}" did not complete in {timeout:g} secs.'

‎git/remote.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -988,8 +988,9 @@ def stdout_handler(line: str) -> None:
988988
# even if there is an output).
989989
if not output:
990990
raise
991-
elif stderr_text:
992-
_logger.warning("Error lines received while fetching: %s", stderr_text)
991+
elif stderr_text or proc._timeout_error:
992+
if stderr_text:
993+
_logger.warning("Error lines received while pushing: %s", stderr_text)
993994
output.error = e
994995

995996
return output

‎test/test_autointerrupt.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
from subprocess import PIPE
2+
import sys
3+
import threading
4+
5+
import pytest
6+
17
from git.cmd import Git
28

39

@@ -31,3 +37,36 @@ def test_autointerrupt_terminate_ignores_attributeerror():
3137

3238
# Ensure the reference is cleared to avoid repeated attempts.
3339
assert ai.proc is None
40+
41+
42+
def test_autointerrupt_terminate_closes_buffered_stdin():
43+
ai = Git().hash_object("--stdin", as_process=True, istream=PIPE)
44+
proc = ai.proc
45+
proc.stdin.write(b"buffered input")
46+
try:
47+
ai._terminate()
48+
assert proc.stdin.closed
49+
assert proc.stdout.closed
50+
assert proc.stderr.closed
51+
finally:
52+
proc.stdout.close()
53+
proc.stderr.close()
54+
55+
56+
@pytest.mark.skipif(sys.platform == "win32", reason="requires POSIX SIGTERM handling")
57+
def test_autointerrupt_terminate_closes_pipes_before_waiting():
58+
script = (
59+
"import signal, sys; signal.signal(signal.SIGTERM, signal.SIG_IGN); "
60+
"print('ready', flush=True); sys.stdin.read(); sys.stdout.buffer.write(b'x' * 1000000)"
61+
)
62+
ai = Git().execute([sys.executable, "-c", script], as_process=True, istream=PIPE)
63+
proc = ai.proc
64+
assert proc.stdout.readline() == b"ready\n"
65+
watchdog = threading.Timer(5, proc.kill)
66+
watchdog.start()
67+
try:
68+
ai._terminate()
69+
assert proc.returncode != -9, "cleanup waited for exit with undrained output pipes"
70+
finally:
71+
watchdog.cancel()
72+
watchdog.join()

0 commit comments

Comments
 (0)