Skip to content

Commit 2e5b13f

Browse files
authored
Merge pull request #2189 from pick7/codex/output-stream-timeout
Honor kill_after_timeout with output streams
2 parents fcd35cf + 484b3df commit 2e5b13f

2 files changed

Lines changed: 52 additions & 12 deletions

File tree

git/cmd.py

Lines changed: 28 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,33 @@ 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 = f'Timeout: the command "{" ".join(redacted_command)}" did not complete in {timeout:g} secs.'
1477+
return err if universal_newlines else err.encode(defenc)
1478+
14701479
def communicate() -> Tuple[AnyStr, AnyStr]:
1480+
assert watchdog is not None
1481+
assert kill_check is not None
14711482
watchdog.start()
14721483
out, err = proc.communicate()
14731484
watchdog.cancel()
14741485
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)
1486+
err = make_timeout_error()
14811487
return out, err
14821488

14831489
# END helpers
14841490

14851491
kill_check = threading.Event()
14861492
watchdog = threading.Timer(timeout, kill_process, args=(proc.pid,))
1493+
timeout_error = make_timeout_error
14871494
else:
14881495
communicate = proc.communicate
14891496

@@ -1504,15 +1511,24 @@ def communicate() -> Tuple[AnyStr, AnyStr]:
15041511
status = proc.returncode
15051512
else:
15061513
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()
1514+
if watchdog is not None:
1515+
watchdog.start()
1516+
try:
1517+
if proc.stdout is not None:
1518+
stream_copy(proc.stdout, output_stream, max_chunk_size)
1519+
stdout_value = proc.stdout.read()
1520+
if proc.stderr is not None:
1521+
stderr_value = proc.stderr.read()
1522+
status = proc.wait()
1523+
finally:
1524+
if watchdog is not None:
1525+
watchdog.cancel()
15121526
# Strip trailing "\n".
15131527
if stderr_value is not None and stderr_value.endswith(newline): # type: ignore[arg-type]
15141528
stderr_value = stderr_value[:-1]
1515-
status = proc.wait()
1529+
if kill_check is not None and kill_check.is_set():
1530+
assert timeout_error is not None
1531+
stderr_value = timeout_error()
15161532
# END stdout handling
15171533
finally:
15181534
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)