Skip to content

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

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.

Stopping subprocesses, Python 3.14 on Linux A grid of 6 rows by 2 columns. Stopping subprocesses, Python 3.14 on Linux case result shell + terminate() shell rc -15; its child still running start_new_session=True + os.killpg(SIGTERM) shell and child both stopped asyncio.timeout(0.5) around communicate() TimeoutError; child still running, returncode None child ignores SIGTERM, 2 s grace killed with SIGKILL, rc -9 child handles SIGTERM cleaned up, rc 0 after 0.20 s terminate() after wait() returned ProcessLookupError Child processes checked by PID through /proc.

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.

Stopping a child with escalation A sequence of 5 messages between 2 participants. Stopping a child with escalation parent child group killpg(SIGTERM) handler: clean up exit 0 after 0.20 s if still alive after 2 s: killpg(SIGKILL) rc -9, reaped by wait() Always end with wait(), so the process is reaped.

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.

Time from stop request to reaped child 2 horizontal bars comparing child handles SIGTERM (rc 0) with the others. Time from stop request to reaped child child handles SIGTERM (rc 0) 0.20 s child ignores SIGTERM (rc -9) 2.00 s The grace period bounds shutdown even for uncooperative children.

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 finally block.
  • SIGTERM is followed by SIGKILL after a bounded grace period, then wait().
  • ProcessLookupError is 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.