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
Popen
interface
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 theman
page); - the only way to call
os.setpgrp()
from within thePopen
constructor is to pass it to thepreexec_fn
parameter, which is not thread-safe.
The Python documentation for Popen()
states the following:
Warning: The
preexec_fn
parameter is not safe to use in the presence of threads in your application. The child process could deadlock beforeexec
is 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_session
parameter can take the place of a previously common use ofpreexec_fn
to 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.