Skip to content

Commit 678c422

Browse files
committed
pythongh-103911: Add exec_timeout parameter to subprocess.Popen
Add a new exec_timeout parameter to subprocess.Popen (POSIX only) that allows setting a timeout for the child process to execute (exec() to succeed). This addresses the issue where Popen can hang indefinitely while waiting for the child process errpipe_read, which can happen when exec() is delayed by ptrace tools, high-latency filesystems, or other system-level interference. The implementation uses the selectors module with non-blocking I/O on errpipe_read to wait for data with a timeout. If the timeout expires, TimeoutExpired is raised and the child process is killed with SIGKILL. https://claude.ai/code/session_01Bn3z7ZV4zehn77WPEZ9kuL
1 parent 7ca9e7a commit 678c422

3 files changed

Lines changed: 88 additions & 9 deletions

File tree

Lib/subprocess.py

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,12 @@ class Popen:
807807
encoding and errors: Text mode encoding and error handling to use for
808808
file objects stdin, stdout and stderr.
809809
810+
exec_timeout (POSIX only): Maximum time in seconds to wait for the
811+
child process to execute (i.e., for exec() to succeed). If the
812+
timeout expires, TimeoutExpired is raised and the child process
813+
is killed. This is useful in environments where exec() can be
814+
delayed by system tools like ptrace or high-latency filesystems.
815+
810816
Attributes:
811817
stdin, stdout, stderr, pid, returncode
812818
"""
@@ -820,7 +826,7 @@ def __init__(self, args, bufsize=-1, executable=None,
820826
restore_signals=True, start_new_session=False,
821827
pass_fds=(), *, user=None, group=None, extra_groups=None,
822828
encoding=None, errors=None, text=None, umask=-1, pipesize=-1,
823-
process_group=None):
829+
process_group=None, exec_timeout=None):
824830
"""Create new Popen instance."""
825831
if not _can_fork_exec:
826832
raise OSError(
@@ -866,6 +872,16 @@ def __init__(self, args, bufsize=-1, executable=None,
866872
raise ValueError("creationflags is only supported on Windows "
867873
"platforms")
868874

875+
if _mswindows and exec_timeout is not None:
876+
raise ValueError("exec_timeout is not supported on Windows "
877+
"platforms")
878+
879+
if exec_timeout is not None:
880+
if not isinstance(exec_timeout, (int, float)):
881+
raise TypeError("exec_timeout must be a number or None")
882+
if exec_timeout < 0:
883+
raise ValueError("exec_timeout must be non-negative")
884+
869885
self.args = args
870886
self.stdin = None
871887
self.stdout = None
@@ -1042,7 +1058,8 @@ def __init__(self, args, bufsize=-1, executable=None,
10421058
errread, errwrite,
10431059
restore_signals,
10441060
gid, gids, uid, umask,
1045-
start_new_session, process_group)
1061+
start_new_session, process_group,
1062+
exec_timeout)
10461063
except:
10471064
# Cleanup if the child failed starting.
10481065
for f in filter(None, (self.stdin, self.stdout, self.stderr)):
@@ -1455,7 +1472,8 @@ def _execute_child(self, args, executable, preexec_fn, close_fds,
14551472
unused_restore_signals,
14561473
unused_gid, unused_gids, unused_uid,
14571474
unused_umask,
1458-
unused_start_new_session, unused_process_group):
1475+
unused_start_new_session, unused_process_group,
1476+
unused_exec_timeout):
14591477
"""Execute program (MS Windows version)"""
14601478

14611479
assert not pass_fds, "pass_fds not supported on Windows."
@@ -1829,7 +1847,8 @@ def _execute_child(self, args, executable, preexec_fn, close_fds,
18291847
errread, errwrite,
18301848
restore_signals,
18311849
gid, gids, uid, umask,
1832-
start_new_session, process_group):
1850+
start_new_session, process_group,
1851+
exec_timeout):
18331852
"""Execute program (POSIX version)"""
18341853

18351854
if isinstance(args, (str, bytes)):
@@ -1937,11 +1956,36 @@ def _execute_child(self, args, executable, preexec_fn, close_fds,
19371956
# Wait for exec to fail or succeed; possibly raising an
19381957
# exception (limited in size)
19391958
errpipe_data = bytearray()
1940-
while True:
1941-
part = os.read(errpipe_read, 50000)
1942-
errpipe_data += part
1943-
if not part or len(errpipe_data) > 50000:
1944-
break
1959+
if exec_timeout is not None:
1960+
endtime = _time() + exec_timeout
1961+
# Use non-blocking reads with select for timeout support
1962+
os.set_blocking(errpipe_read, False)
1963+
with _PopenSelector() as selector:
1964+
selector.register(errpipe_read, selectors.EVENT_READ)
1965+
while True:
1966+
timeout = endtime - _time()
1967+
if timeout <= 0:
1968+
# Timeout expired - kill the child and raise
1969+
try:
1970+
os.kill(self.pid, signal.SIGKILL)
1971+
except ProcessLookupError:
1972+
pass # Child already exited
1973+
raise TimeoutExpired(self.args, exec_timeout)
1974+
ready = selector.select(timeout)
1975+
if ready:
1976+
try:
1977+
part = os.read(errpipe_read, 50000)
1978+
except BlockingIOError:
1979+
continue # No data yet, select again
1980+
errpipe_data += part
1981+
if not part or len(errpipe_data) > 50000:
1982+
break
1983+
else:
1984+
while True:
1985+
part = os.read(errpipe_read, 50000)
1986+
errpipe_data += part
1987+
if not part or len(errpipe_data) > 50000:
1988+
break
19451989
finally:
19461990
# be sure the FD is closed no matter what
19471991
os.close(errpipe_read)

Lib/test/test_subprocess.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3628,6 +3628,28 @@ def test_vfork_used_when_expected(self):
36283628
# Ensure neither vfork() or clone(..., flags=...|CLONE_VFORK|...).
36293629
self.assertNotRegex(non_vfork_result.event_bytes, br"(?i)vfork")
36303630

3631+
def test_exec_timeout_basic(self):
3632+
"""Test that exec_timeout doesn't interfere with normal operation."""
3633+
# A generous timeout should not affect a quick command
3634+
p = subprocess.Popen([sys.executable, "-c", "pass"],
3635+
exec_timeout=60)
3636+
p.wait()
3637+
self.assertEqual(p.returncode, 0)
3638+
3639+
def test_exec_timeout_invalid_type(self):
3640+
"""Test that exec_timeout raises TypeError for invalid types."""
3641+
with self.assertRaises(TypeError) as cm:
3642+
subprocess.Popen([sys.executable, "-c", "pass"],
3643+
exec_timeout="invalid")
3644+
self.assertIn("exec_timeout must be a number", str(cm.exception))
3645+
3646+
def test_exec_timeout_negative(self):
3647+
"""Test that exec_timeout raises ValueError for negative values."""
3648+
with self.assertRaises(ValueError) as cm:
3649+
subprocess.Popen([sys.executable, "-c", "pass"],
3650+
exec_timeout=-1)
3651+
self.assertIn("exec_timeout must be non-negative", str(cm.exception))
3652+
36313653

36323654
@unittest.skipUnless(mswindows, "Windows specific tests")
36333655
class Win32ProcessTestCase(BaseTestCase):
@@ -3883,6 +3905,14 @@ def test_kill_dead(self):
38833905
def test_terminate_dead(self):
38843906
self._kill_dead_process('terminate')
38853907

3908+
def test_exec_timeout_not_supported(self):
3909+
"""Test that exec_timeout raises ValueError on Windows."""
3910+
with self.assertRaises(ValueError) as cm:
3911+
subprocess.Popen([sys.executable, "-c", "pass"],
3912+
exec_timeout=60)
3913+
self.assertIn("exec_timeout is not supported on Windows",
3914+
str(cm.exception))
3915+
38863916
class MiscTests(unittest.TestCase):
38873917

38883918
class RecordingPopen(subprocess.Popen):
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Add ``exec_timeout`` parameter to :class:`subprocess.Popen` (POSIX only).
2+
This allows setting a timeout for the child process to execute (exec() to
3+
succeed). If the timeout expires, :exc:`subprocess.TimeoutExpired` is raised
4+
and the child process is killed. This is useful in environments where exec()
5+
can be delayed by system tools like ptrace or high-latency filesystems.

0 commit comments

Comments
 (0)