Skip to content

Commit 59efde9

Browse files
committed
fix: honor timeout with output streams
1 parent 07e8055 commit 59efde9

2 files changed

Lines changed: 55 additions & 12 deletions

File tree

git/cmd.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,6 +1435,10 @@ def execute(
14351435
if as_process:
14361436
return self.AutoInterrupt(proc, command)
14371437

1438+
watchdog: Optional[threading.Timer] = None
1439+
kill_check: Optional[threading.Event] = None
1440+
timeout_error: Optional[Callable[[], Union[str, bytes]]] = None
1441+
14381442
if sys.platform != "win32" and kill_after_timeout is not None:
14391443
# Help mypy figure out this is not None even when used inside communicate().
14401444
timeout = kill_after_timeout
@@ -1460,30 +1464,36 @@ def kill_process(pid: int) -> None:
14601464
except OSError:
14611465
pass
14621466
# Tell the main routine that the process was killed.
1467+
assert kill_check is not None
14631468
kill_check.set()
14641469
except OSError:
14651470
# It is possible that the process gets completed in the duration
14661471
# after timeout happens and before we try to kill the process.
14671472
pass
14681473
return
14691474

1475+
def make_timeout_error() -> Union[str, bytes]:
1476+
err = 'Timeout: the command "%s" did not complete in %d secs.' % (
1477+
" ".join(redacted_command),
1478+
timeout,
1479+
)
1480+
return err if universal_newlines else err.encode(defenc)
1481+
14701482
def communicate() -> Tuple[AnyStr, AnyStr]:
1483+
assert watchdog is not None
1484+
assert kill_check is not None
14711485
watchdog.start()
14721486
out, err = proc.communicate()
14731487
watchdog.cancel()
14741488
if kill_check.is_set():
1475-
err = 'Timeout: the command "%s" did not complete in %d secs.' % (
1476-
" ".join(redacted_command),
1477-
timeout,
1478-
)
1479-
if not universal_newlines:
1480-
err = err.encode(defenc)
1489+
err = make_timeout_error()
14811490
return out, err
14821491

14831492
# END helpers
14841493

14851494
kill_check = threading.Event()
14861495
watchdog = threading.Timer(timeout, kill_process, args=(proc.pid,))
1496+
timeout_error = make_timeout_error
14871497
else:
14881498
communicate = proc.communicate
14891499

@@ -1504,15 +1514,24 @@ def communicate() -> Tuple[AnyStr, AnyStr]:
15041514
status = proc.returncode
15051515
else:
15061516
max_chunk_size = max_chunk_size if max_chunk_size and max_chunk_size > 0 else io.DEFAULT_BUFFER_SIZE
1507-
if proc.stdout is not None:
1508-
stream_copy(proc.stdout, output_stream, max_chunk_size)
1509-
stdout_value = proc.stdout.read()
1510-
if proc.stderr is not None:
1511-
stderr_value = proc.stderr.read()
1517+
if watchdog is not None:
1518+
watchdog.start()
1519+
try:
1520+
if proc.stdout is not None:
1521+
stream_copy(proc.stdout, output_stream, max_chunk_size)
1522+
stdout_value = proc.stdout.read()
1523+
if proc.stderr is not None:
1524+
stderr_value = proc.stderr.read()
1525+
status = proc.wait()
1526+
finally:
1527+
if watchdog is not None:
1528+
watchdog.cancel()
15121529
# Strip trailing "\n".
15131530
if stderr_value is not None and stderr_value.endswith(newline): # type: ignore[arg-type]
15141531
stderr_value = stderr_value[:-1]
1515-
status = proc.wait()
1532+
if kill_check is not None and kill_check.is_set():
1533+
assert timeout_error is not None
1534+
stderr_value = timeout_error()
15161535
# END stdout handling
15171536
finally:
15181537
if proc.stdout is not None:

test/test_git.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import subprocess
1818
import sys
1919
import tempfile
20+
import time
2021
from unittest import skipUnless
2122

2223
if sys.version_info >= (3, 8):
@@ -288,6 +289,29 @@ def test_it_output_stream_with_stdout_is_false(self):
288289
)
289290
self.assertEqual(temp_stream.tell(), 0)
290291

292+
@skipUnless(sys.platform != "win32", "kill_after_timeout is not supported on Windows")
293+
def test_it_honors_kill_after_timeout_with_output_stream(self):
294+
output_stream = io.BytesIO()
295+
command = [
296+
sys.executable,
297+
"-c",
298+
"import sys, time; sys.stdout.write('started\\n'); sys.stdout.flush(); sys.stdout.close(); time.sleep(60)",
299+
]
300+
301+
started = time.monotonic()
302+
status, _, stderr = self.git.execute(
303+
command,
304+
output_stream=output_stream,
305+
kill_after_timeout=0.1,
306+
with_exceptions=False,
307+
with_extended_output=True,
308+
)
309+
310+
self.assertLess(time.monotonic() - started, 5)
311+
self.assertNotEqual(status, 0)
312+
self.assertEqual(output_stream.getvalue(), b"started\n")
313+
self.assertIn("Timeout: the command", stderr)
314+
291315
def test_it_executes_git_without_stdout_redirect(self):
292316
returncode, stdout, stderr = self.git.execute(
293317
["git", "version"],

0 commit comments

Comments
 (0)