Skip to content

Commit 8c0fbf3

Browse files
committed
fix: honor timeout when a grandchild keeps the pipes open
Running a command with a timeout could hang forever, e.g. local["sh"]["-c", "sleep 30 & wait"](timeout=1). The timeout thread kills the direct child, but run_proc waits in proc.communicate(), which returns only on EOF of the pipes. A grandchild that inherited stdout/stderr keeps them open, so communicate() blocks indefinitely even though the child is already dead and _timed_out is set. communicate() now receives the timeout so the wait is bounded; on expiry the process is killed, reaped, and verify() raises ProcessTimedOut as usual. Popen-like objects that do not accept a timeout argument fall back to the previous behavior. Closes #685
1 parent 500cfb6 commit 8c0fbf3

2 files changed

Lines changed: 44 additions & 3 deletions

File tree

plumbum/commands/processes.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from __future__ import annotations
22

3-
__lazy_modules__ = {"atexit", "contextlib", "heapq", "itertools"}
3+
__lazy_modules__ = {"atexit", "contextlib", "heapq", "itertools", "subprocess"}
44

55
import atexit
66
import contextlib
77
import enum
88
import heapq
99
import itertools
10+
import subprocess
1011
import sys
1112
import time
1213
import typing
@@ -18,7 +19,6 @@
1819
from plumbum.lib import IS_WIN32
1920

2021
if typing.TYPE_CHECKING:
21-
import subprocess
2222
from collections.abc import Callable, Container, Generator
2323
from typing import IO, Literal
2424

@@ -397,6 +397,34 @@ def _terminate_and_reap(proc: PopenWithAddons[Any], grace: float = 1.0) -> None:
397397
proc.wait()
398398

399399

400+
def _communicate(proc: PopenWithAddons[Any], timeout: float | None) -> tuple[Any, Any]:
401+
"""Like ``proc.communicate()``, but does not block past the timeout.
402+
403+
The timeout thread kills *proc* itself, but ``communicate()`` waits for
404+
EOF on the pipes, and a grandchild that inherited them keeps them open --
405+
so the call would block forever even though *proc* is already dead.
406+
Passing the timeout through to ``communicate()`` bounds that wait; the
407+
already-set ``_timed_out`` flag then makes ``verify()`` raise
408+
``ProcessTimedOut`` as usual.
409+
"""
410+
if timeout is None:
411+
return proc.communicate()
412+
try:
413+
# Give the timeout thread a moment to kill the process first, so a
414+
# normally-terminating process still reports its real exit code.
415+
return proc.communicate(timeout=timeout + 1) # type: ignore[call-arg]
416+
except TypeError:
417+
# Popen-like objects (remote/session procs) may not accept a timeout.
418+
return proc.communicate()
419+
except subprocess.TimeoutExpired:
420+
proc._timed_out = True # type: ignore[attr-defined]
421+
with contextlib.suppress(Exception):
422+
proc.kill()
423+
with contextlib.suppress(Exception):
424+
proc.wait()
425+
return b"", b""
426+
427+
400428
# ===================================================================================================
401429
# run_proc
402430
# ===================================================================================================
@@ -426,7 +454,7 @@ def run_proc(
426454
stdout: bytes | str
427455
stderr: bytes | str
428456
try:
429-
stdout, stderr = proc.communicate()
457+
stdout, stderr = _communicate(proc, timeout)
430458
proc._end_time = time.time() # type: ignore[attr-defined]
431459
if not stdout:
432460
stdout = b""

tests/test_local.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,19 @@ def test_timeout(self):
621621
with pytest.raises(ProcessTimedOut):
622622
sleep(3, timeout=1)
623623

624+
@skip_on_windows
625+
def test_timeout_with_surviving_grandchild(self):
626+
# The timeout thread kills the direct child, but a grandchild that
627+
# inherited the pipes keeps them open, so communicate() must not block
628+
# waiting for EOF. See issue #685.
629+
from plumbum.cmd import sh
630+
631+
cmd = sh["-c", "sleep 30 & wait"]
632+
start = time.time()
633+
with pytest.raises(ProcessTimedOut):
634+
cmd(timeout=1)
635+
assert time.time() - start < 15
636+
624637
@skip_on_windows
625638
def test_pipe_stderr(self, capfd):
626639
from plumbum.cmd import cat, head

0 commit comments

Comments
 (0)