Shells, build tools and test runners spawn grandchildren that survive if you only kill the direct child.
Killing a process does not kill its children. If you start sh -c "make && ./server" and kill the shell on timeout, make or server keep running as orphans, and if they hold the stdout pipe open, communicate() keeps waiting for them.
On POSIX, put the child in its own process group and signal the whole group:
start_new_session=Truerunssetsid()in the child: a new session and process group, detached from your terminal.process_group=0(3.11+) only creates a new process group, keeping the session.
Then os.killpg(p.pid, signal.SIGTERM) (the group id equals the leader's pid) reaches every process in the group, including grandchildren, unless they deliberately moved to another group.
Side effects: a child in a new session no longer receives Ctrl+C from your terminal, so forward signals yourself if you need that behaviour.
On Windows, use creationflags=subprocess.CREATE_NEW_PROCESS_GROUP and send CTRL_BREAK_EVENT, or rely on Job Objects (via third-party libraries) to kill trees.
The third-party psutil library can also walk children(recursive=True) and kill each, a portable fallback.
Example
import os, signal, subprocess, time
# The shell starts a grandchild (sleep) that outlives a plain kill.
cmd = "sleep 30 & echo grandchild $!; wait"
p = subprocess.Popen(["sh", "-c", cmd], stdout=subprocess.PIPE, text=True,
start_new_session=True)
gpid = int(p.stdout.readline().split()[1])
print(os.getpgid(gpid) == p.pid) # True: same group as the shell
os.killpg(p.pid, signal.SIGTERM) # signal the whole group
p.wait()
t = time.perf_counter()
print(repr(p.stdout.read())) # '' -> EOF: no process holds the pipe
print(f"{time.perf_counter() - t:.1f}s") # 0.0s (p.kill() alone: blocks ~30s)
p.stdout.close()Remember
- Killing a process leaves its children running
- start_new_session=True or process_group=0 puts the child in its own group
- os.killpg(pid, sig) signals the whole tree
Watch out for
- Using shell=True with timeout and expecting the timeout to stop everything the shell started.