Suppose a Python script needs to launch an external command. This can be done using the subprocess module in one of two ways:
- either use the “convenience” function
subprocess.run() - or use the more flexible
Popeninterface
Stopping a subprocess on timeout
The “convenience” function subprocess.run() allows to do quite a number of useful things, such as capturing output, checking the external command’s return code or setting a timeout, among others.
If we are simply interested in stopping the execution of the external command after a given timeout has been reached, it is sufficient to subprocess.run() the command and catch the TimeoutExpired exception if it is raised:
1
2
3
4
5
6
7
8
9
import subprocess
cmd = ['/path/to/cmd', 'arg1', 'arg2'] # the external command to run
timeout_s = 10 # how many seconds to wait
try:
p = subprocess.run(cmd, timeout=timeout_s)
except subprocess.TimeoutExpired:
print(f'Timeout for {cmd} ({timeout_s}s) expired')
Stopping a subprocess and its children on timeout
The situation gets more complicated when the external command may launch one or several child processes. In order to be able to stop the child processes as well as the parent, it is necessary to use the Popen constructor.
Note: The following only applies to UNIX-like operating systems. (Read: it won’t work on Windows.)
The reason for using the Popen constructor for this scenario is that it can be instructed to launch a new session for the external command. Then, the whole process group belonging to the external command can be terminated on timeout. A process group is simply a group of processes that can be controlled at once (via signals), while a session is a collection of process groups. Here are the official definitions, taken from the POSIX.1-2008 standard:
3.296 Process Group - A collection of processes that permits the signaling of related processes. Each process in the system is a member of a process group that is identified by a process group ID. A newly created process joins the process group of its creator.
3.343 Session - A collection of process groups established for job control purposes. Each process group is a member of a session. A process is considered to be a member of the session of which its process group is a member. A newly created process joins the session of its creator. A process can alter its session membership; see
setsid(). There can be multiple process groups in the same session.
The reason for using a session instead of a process group
Reading the above definitions, one may wonder why should we bother with creating a new session instead of simply using a new process group for the external command. That’s an excellent question! It is technically possible, but not advisable. In order to create a process group, we’d need to call os.setpgrp() (which uses the setpgrp() system call). However, there are two problems with this approach:
-
setpgrp()is marked as obsolete and may be removed in future versions (check themanpage); - the only way to call
os.setpgrp()from within thePopenconstructor is to pass it to thepreexec_fnparameter, which is not thread-safe.
The Python documentation for Popen() states the following:
Warning: The
preexec_fnparameter is not safe to use in the presence of threads in your application. The child process could deadlock beforeexecis called. If you must use it, keep it trivial! Minimize the number of libraries you call into.
In the note following the warning, it is mentioned that:
The
start_new_sessionparameter can take the place of a previously common use ofpreexec_fnto callos.setsid()in the child.
The workaround, therefore, is to simply create a new session by setting the start_new_session argument of the Popen constructor to True. According to the Python documentation, it is the equivalent of using preexec_fn=os.setsid (based on the setsid() system call), but without the un-thread-safe warning.
Implementation
With all the above explanations, the implementation is straight-forward:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import os
import signal
import subprocess
import sys
cmd = ['/path/to/cmd', 'arg1', 'arg2'] # the external command to run
timeout_s = 10 # how many seconds to wait
try:
p = subprocess.Popen(cmd, start_new_session=True)
p.wait(timeout=timeout_s)
except subprocess.TimeoutExpired:
print(f'Timeout for {cmd} ({timeout_s}s) expired', file=sys.stderr)
print('Terminating the whole process group...', file=sys.stderr)
os.killpg(os.getpgid(p.pid), signal.SIGTERM)
The Popen interface is different than that of the convenience subprocess.run() function. The timeout needs to be specified in Popen.wait(). If you want to capture stdout and stderr, you need to pass them to the Popen constructor as subprocess.PIPE and then use Popen.communicate(). Regardless of the differences, whatever can be done with subprocess.run() can also be achieved with the Popen constructor.
When the timeout set in Popen.wait() has elapsed, a TimeoutExpired exception is raised. Then, in line 15, we send a SIGTERM to the whole process group (os.killpg()) of the external command (os.getpgid(p.pid)).
That’s it. Happy infanticide! (Err… I was referring to child processes
)
Further reading
-
subprocess(Python documentation) -
signal(Python documentation) - Signals (Wikipedia)
- POSIX.1-2008 standard
- POSIX.1-2008 definitions
Comments powered by Disqus.