Safely signal the OS process group behind a spawned child.
Killing only a child leaves its descendants running: still doing work, still
holding whatever fds they inherited. Killing its process GROUP reaches them.
The catch is that a group kill aimed at the wrong id is unrecoverable -- a
child that is not a group leader shares the BEAM's own group, and
kill -KILL -<pgid> against that takes the VM down with it.
So a target is RESOLVED before it is signalled. resolve/1 answers
{:group, pgid} only when it can show a group under that id exists, and
{:pid, os_pid} (which loses the descendants, and says so) when it cannot.
Why the group is probed rather than read
A process group's id is the pid of its leader, and that pid stays allocated
for as long as the group has ANY member. So "group os_pid answers a signal"
can only be true if the process holding pid os_pid led it.
The obvious alternative -- ask ps for the child's pgid and require
pgid == os_pid -- cannot answer the case that matters most. When a child
backgrounds something and exits, the parent is a corpse and ps cannot see
it, so the pgid read fails and the group is never found. That is exactly the
case where a group is still running and needs reaping, and the ppid-walk
fallback misses it too: a reparented orphan's ppid is already 1. ps is
also absent on busybox.
Why signals go through a shell builtin
Never kill(1). procps-ng's kill -- /usr/bin/kill on Debian and Ubuntu --
takes a negative pid in its own argv slot, does NOTHING with it, and exits 0.
Measured on linux/aarch64 with the group live: System.cmd(kill, ["-KILL", "-57"]) returned {"", 0} and both members were still running.
That is the worst failure available here, a reap that reports success and
reaps nothing. kill -s KILL -- -57 is no way out either: procps has no --
and answers with a usage error. macOS's /bin/kill handles the same argv
correctly, which is why this class of bug is green on a developer machine and
red on Linux CI.
Shell builtins are POSIX about negative pids on both, and any POSIX shell will
do: bash, dash and busybox ash were each measured signalling a real group
through -<pgid> and reporting the same errno text back. shell/0 prefers
bash and settles for sh, so a host with only the latter keeps the group kill
rather than degrading to a bare-pid reap that leaves the descendants running.
Callers
Raxol.Symphony.PortReaper (port children, with an owner-death watcher) and
Raxol.Agent.Interrupt (the shell tool's staged TERM/KILL escalation). Both
used to carry their own copy of this; they disagreed, and the ps-based one
had the corpse-parent hole described above.
This module deliberately does NOT cover zombie-aware liveness. kill -0
answers "is this pid still allocated", and a zombie still holds its pid, so a
caller that needs to distinguish "exited but unreaped" from "running" needs a
ps-based oracle instead -- which is why Interrupt keeps one.
Summary
Functions
Poll until target is gone, giving up after timeout_ms.
True when a process group exists under os_pid -- i.e. the child is its own
group leader and a group kill is safe.
Resolve the safe signal target for a spawned child's OS pid.
The shell signals are delivered through, or nil when this host has none.
Deliver flag ("-0", "-TERM", "-KILL", ...) to target.
Types
@type opts() :: [{:shell, String.t() | nil}]
:shell-- shell to deliver signals through,shell/0when absent. Honoured bysignal/3andawait_gone/3only;resolve/1takes no opts at all, so an injected failing shell simulates a failing KILL without also corrupting the target it is aimed at.
@type reason() :: :unavailable | :refused | :spawn_failed | :unknown
Why a signal could not be delivered.
NONE of these may be reported as success: a caller about to delete the target's working directory is relying on the difference.
:unavailable -- no shell on PATH to signal with, so nothing was verified and
nothing was killed.
:refused -- the kernel rejected the signal (EPERM). The target is
demonstrably still there, and still running.
:spawn_failed -- the shell resolved but could not be run this time.
Transient by nature (a fork that hit EAGAIN under process-table pressure is
the case that matters, and that pressure is exactly when a reaper is most
needed), so callers retry it rather than answering it.
:unknown -- the signal failed with something unrecognised. NOT reported as
:gone: "the message was not one I know" is not evidence that the target
exited.
@type target() :: {:group, pos_integer()} | {:pid, pos_integer()} | :none
What may safely be signalled.
{:group, pgid} when the child leads its own process group, so its
descendants come with it. {:pid, os_pid} when it does not, in which case
only the child itself can be signalled. :none when there is nothing to
signal.
Functions
@spec await_gone(target(), non_neg_integer(), opts()) :: :ok | :timeout | {:error, reason()}
Poll until target is gone, giving up after timeout_ms.
:timeout is a distinct answer from {:error, _}: it means the target is
still there and the caller's grace is spent, which is normally the cue to stop
asking and SIGKILL. An error means the question could not be answered at all.
Returns {:error, reason} immediately rather than waiting out the deadline
for any failure waiting cannot fix. A :spawn_failed IS waited on: giving up
there would abandon the check under process-table pressure, which both causes
it and is when it matters.
@spec group_leader?(pos_integer()) :: boolean()
True when a process group exists under os_pid -- i.e. the child is its own
group leader and a group kill is safe.
@spec resolve(pos_integer()) :: target()
Resolve the safe signal target for a spawned child's OS pid.
{:group, os_pid} when a process group under that id answers a signal, which
can only be true if this child led it. {:pid, os_pid} otherwise -- safe, but
it silently drops the descendants, so it is logged.
Takes no opts, so an injected shell cannot reach it: this decides WHAT gets signalled, and a wrong answer here is the unrecoverable failure.
@spec shell() :: String.t() | nil
The shell signals are delivered through, or nil when this host has none.
Public because resolving it is a full PATH scan and a poll loop must not repeat
one: await_gone/3 ticks every 50ms for a whole grace window, so a
caller resolves once here and threads the answer down through :shell.
bash first, then any POSIX sh. The preference is habit, not requirement:
both things this module asks of the builtin -- that -<pgid> names a process
group, and that a failure carries strerror (see classify/1) -- were
measured identical on Debian's dash and on busybox ash. Settling for sh
matters because those are the hosts with no bash to find, and answering
:unavailable there would give up the group kill on exactly the systems the
moduledoc cites as the reason not to depend on ps.
Deliver flag ("-0", "-TERM", "-KILL", ...) to target.
:gone means nothing signallable is left, which against a GROUP is the
question worth asking: the leader can be gone while an orphaned subprocess
keeps the group alive.
Raises FunctionClauseError for a target outside is_signallable/1, which is
the point -- signalling pid 0 or 1 is not a recoverable mistake.