Raxol.Core.ProcessGroup (Raxol Core v2.7.0)

Copy Markdown View Source

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

Types

Why a signal could not be delivered.

What may safely be signalled.

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

opts()

@type opts() :: [{:shell, String.t() | nil}]
  • :shell -- shell to deliver signals through, shell/0 when absent. Honoured by signal/3 and await_gone/3 only; resolve/1 takes no opts at all, so an injected failing shell simulates a failing KILL without also corrupting the target it is aimed at.

reason()

@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.

target()

@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

await_gone(target, timeout_ms, opts \\ [])

@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.

group_leader?(os_pid)

@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.

is_signallable(target)

(macro)

resolve(os_pid)

@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.

shell()

@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.

signal(target, flag, opts \\ [])

@spec signal(target(), String.t(), opts()) :: :ok | :gone | {:error, reason()}

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.