Sending Signals to asyncio Subprocesses¶
asyncio.create_subprocess_exec and create_subprocess_shell return a Process with terminate(), kill() and send_signal(), and stopping a child looks like one call. In practice the signal often reaches the wrong process, or none. Measured on Python 3.14 on Linux: calling terminate() on a process started with create_subprocess_shell("sleep …; echo done") ended the shell with return code -15, while its sleep child kept running. Starting it with start_new_session=True and signalling the whole process group with os.killpg stopped both. Wrapping communicate() in asyncio.timeout(0.5) raised TimeoutError and left the child still running, with returncode still None. A child that ignored SIGTERM was still alive after a 2-second grace period and needed SIGKILL (return code -9); a child that handled SIGTERM cleaned up and exited with 0 in 0.20 s. And signalling a process that had already been reaped raised ProcessLookupError. This guide builds a stop routine that handles all of these.
Prerequisites¶
- Python 3.11+ on Linux or macOS; process groups are a POSIX feature.
- Subprocess basics, from running subprocesses with asyncio.create_subprocess_exec.
- The topic overview, Subprocesses & File I/O.
1. Know which process receives the signal¶
Process.terminate() sends SIGTERM to exactly one PID: the process asyncio started. With create_subprocess_shell, that process is /bin/sh, and the command's own processes are its children:
proc = await asyncio.create_subprocess_shell("sleep 30; echo done")
proc.terminate()
await proc.wait() # returncode -15: the shell died
Measured: the shell exited with -15, and its sleep child — read from /proc/<pid>/task/<pid>/children before signalling — was still running afterwards, now orphaned and reparented. The same happens with any wrapper: a shell script that starts the real program, npm run, uv run. Prefer create_subprocess_exec with an argument list, which starts the program itself and gives you its PID; when a shell or wrapper is unavoidable, signal its whole process group.
Verify: after stopping a child in a test, none of its descendants remain, checked by PID rather than by matching command lines.
2. Signal process groups for shells and wrappers¶
Start the child in a new session, which makes it the leader of a new process group; then send signals to the group:
proc = await asyncio.create_subprocess_shell(
"./run-worker.sh", start_new_session=True,
)
def signal_group(proc, sig):
try:
os.killpg(proc.pid, sig) # the group ID equals the leader's PID
except ProcessLookupError:
pass # the whole group has already exited
Measured: os.killpg(proc.pid, signal.SIGTERM) ended the shell with -15 and its sleep child as well. A new session also detaches the child from the terminal, so a Ctrl-C in the parent's terminal no longer reaches it directly — the parent becomes responsible for forwarding signals, which is usually what a supervisor wants. Use the group form for everything started with start_new_session=True, even when you expect a single process, so that anything it spawns is covered. Children that start their own sessions escape the group; killing subprocess trees on cancellation handles those.
Verify: a test that starts a wrapper with a long-running grandchild and stops it leaves no process of that group alive.
3. Kill on timeout and cancellation explicitly¶
A timeout or cancellation stops your wait, not the process:
proc = await asyncio.create_subprocess_exec("convert", src, dst, stdout=asyncio.subprocess.PIPE)
try:
async with asyncio.timeout(0.5):
out, _ = await proc.communicate()
except TimeoutError:
... # the child is still running here
Measured: after the TimeoutError, the child was still alive and proc.returncode was None. The same applies when the task awaiting the child is cancelled, for example by a TaskGroup or a client disconnect. Every path out of the wait has to stop and reap the child:
async def run_with_deadline(*argv, timeout: float, grace: float = 2.0) -> bytes:
proc = await asyncio.create_subprocess_exec(*argv, stdout=asyncio.subprocess.PIPE,
start_new_session=True)
try:
async with asyncio.timeout(timeout):
out, _ = await proc.communicate()
return out
finally:
if proc.returncode is None:
await stop(proc, grace) # step 4; runs on timeout and cancellation
Deadlines for subprocesses in general are covered in timing out subprocesses in asyncio; this page is about the signals that end them.
Verify: after a timeout or a cancellation in a test, the child's PID no longer exists and returncode is set.
4. Escalate from SIGTERM to SIGKILL¶
Ask politely first, then insist. SIGTERM gives the child a chance to flush, remove temporary files and exit cleanly; SIGKILL cannot be caught or ignored:
async def stop(proc: asyncio.subprocess.Process, grace: float = 2.0):
if proc.returncode is not None:
return
signal_group(proc, signal.SIGTERM)
try:
async with asyncio.timeout(grace):
await proc.wait()
except TimeoutError:
signal_group(proc, signal.SIGKILL)
await proc.wait() # reap it; otherwise it lingers as a zombie
Measured with a child that ignored SIGTERM: after the 2-second grace it was still alive, SIGKILL ended it, and wait() returned -9 at 2.00 s. A child with a SIGTERM handler that slept 0.2 s to simulate cleanup printed cleaned up and exited with 0 after 0.20 s, well inside the grace period. The await proc.wait() after SIGKILL is not optional: an exited child that has not been waited for remains in the process table.
Verify: a child that ignores SIGTERM is gone within the grace period plus a few milliseconds, and its return code is negative SIGKILL.
5. Handle the races around exit¶
A child can exit at any moment, including between your check and your signal. Measured: calling terminate() on a process whose wait() had already returned raised ProcessLookupError. Treat that error as success in stop routines — the goal state has been reached — as signal_group above does. Two further details complete the routine:
async def stop_all(procs, grace=2.0):
await asyncio.gather(*(stop(p, grace) for p in procs)) # stop in parallel, bounded by one grace
def forward(sig, procs):
for p in procs:
if p.returncode is None:
signal_group(p, sig) # e.g. SIGHUP to reload children
loop.add_signal_handler(signal.SIGHUP, forward, signal.SIGHUP, procs)
Stopping children in parallel keeps total shutdown time at one grace period rather than one per child. Forward signals the parent receives, such as SIGHUP for configuration reload, to children that should see them, since a new session no longer receives terminal signals. For the parent's own shutdown sequence, see handling SIGTERM in asyncio services.
Verify: the stop routine is idempotent — calling it twice, or after the child exits, raises nothing — and stopping many children takes about one grace period.
Verification¶
Subprocesses are stopped reliably when:
- Shells and wrappers start in a new session and are signalled with
os.killpg. - Timeouts and cancellations stop and reap the child in a
finallyblock. - SIGTERM is followed by SIGKILL after a bounded grace period, then
wait(). ProcessLookupErroris treated as already stopped.
Diagnostic Hook: when stray worker processes accumulate on a host, check how they were started and stopped. A terminate() aimed at a shell ended the shell and left its child running here, and a timeout around communicate() left the child running with returncode still None.
Pitfalls & edge cases¶
terminate()on a shell. Measured: the command's own process kept running.- Timeouts as kills. Measured: the child outlived the
TimeoutError. - No SIGKILL fallback. A child ignoring SIGTERM would never exit.
- Finding children with
pgrep -f. It can match the shell running your own command; use PIDs.
Frequently Asked Questions¶
Why doesn't terminate() stop my asyncio shell subprocess?
It signals the shell, not the command it started. The shell exited with -15 and its child kept running. Use start_new_session=True and os.killpg.
Does asyncio.timeout kill a subprocess?
No. After TimeoutError around communicate(), the child was still running with returncode None. Stop and reap it in a finally block.
How do I kill an asyncio subprocess that ignores SIGTERM?
Send SIGTERM, wait up to a grace period with asyncio.timeout around proc.wait(), then send SIGKILL and wait again. It ended with -9 after the 2 s grace.
Why does terminate() raise ProcessLookupError?
The process has already exited and been reaped. Treat it as success in a stop routine.
Related¶
- Subprocesses & File I/O — up to the topic overview.
- Running subprocesses with a PTY — children that expect a terminal.
- Network I/O & Protocol Handling — the section overview.