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 — fix: stop stalled remote commands when their timeout expires (#2276) · gitpython-developers/GitPython@7823f6b

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
  • git
    • cmd.py
    • remote.py
  • test
    • test_autointerrupt.py
    • test_git.py
    • test_remote.py
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎git/cmd.py‎

Copy file name to clipboardExpand all lines: git/cmd.py
+90-76Lines changed: 90 additions & 76 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:
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()
@@ -395,6 +440,8 @@ def wait(self, stderr: Union[None, str, bytes] = b"") -> int:
395440
:raise git.exc.GitCommandError:
396441
If the return status is not 0.
397442
"""
443+
if self._timeout_error is not None:
444+
stderr = self._timeout_error
398445
stderr_b = force_bytes(data=stderr, encoding="utf-8") or b""
399446
status: Union[int, None]
400447
if self.proc is not None:
@@ -1381,9 +1428,10 @@ def execute(
13811428
1. This feature is not supported at all on Windows.
13821429
2. Enumerating child processes requires ``pgrep -P``, or a ``ps`` command
13831430
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.
1431+
installed (``ps -ef`` on Cygwin). Effectiveness may vary on systems
1432+
without these commands.
1433+
3. Descendants are enumerated before signalling. Processes that detach
1434+
or spawn after enumeration may not receive signals.
13871435
4. `kill_after_timeout` uses ``SIGKILL``, which can have negative side
13881436
effects on a repository. For example, stale locks in case of
13891437
:manpage:`git-gc(1)` could render the repository incapable of accepting
@@ -1537,43 +1585,9 @@ def execute(
15371585
timeout = kill_after_timeout
15381586

15391587
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.
1588+
if _kill_process(pid):
15701589
assert kill_check is not None
15711590
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
15771591

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

‎git/remote.py‎

Copy file name to clipboardExpand all lines: git/remote.py
+1-1Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -988,7 +988,7 @@ def stdout_handler(line: str) -> None:
988988
# even if there is an output).
989989
if not output:
990990
raise
991-
elif stderr_text:
991+
elif stderr_text or proc._timeout_error:
992992
_logger.warning("Error lines received while fetching: %s", stderr_text)
993993
output.error = e
994994

Collapse file

‎test/test_autointerrupt.py‎

Copy file name to clipboardExpand all lines: test/test_autointerrupt.py
+39Lines 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 (0)
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.