0243 — A native command stops by one named policy, and the host bounds only what it outlives
Status: Accepted, amended by ADR 0248 and ADR 0255 Date: 2026-10-05
Amends ADR 0224. Invariants I8, I9, I10 and I11.
Context
runNativeCommand stopped every cancelled command the same way: TERM to the whole process group,
killGraceMs (at most 10 s), then KILL. A consuming project needs cooperative cancellation
instead: only the leader is asked to stop, because it is the one process that knows how to roll
its work back, and it may need minutes for it. Signalling the group asks the leader’s own helpers
(a database client, a package manager) to stop under it. That project kept its own process
termination code because the shared one could not express this, which gave the family two
implementations of the same job.
Two more questions came with it. What bounds the group when the host process exits before the grace ends? And how does a long cooperative grace fit an application shutdown budget of seconds?
Decision
One stop policy object replaces killGraceMs. stop: { target, signal, graceMs, killOn }:
target: 'group'sendssignalto every member and waits for the group to leave;'leader'sends it to the leader alone and waits for the leader to exit. Both end in KILL to the whole group. With'leader', the leader’s exit ends the grace at once: what is left in the group are processes the leader started, and nothing in them was asked to cooperate.signalis a closed set of catchable signals (SIGTERMdefault). KILL is what a grace ends in, not a stop signal.graceMsgoes up to one hour. The stop runs on native timers, and an hour leaves a wide margin inside their range; the group probe backs off to 250 ms so a long grace stays cheap.killOnis anAbortSignalwhose abort stops the command with KILL to the whole group at once, also in the middle of a grace. It is how a host that has to stop sooner than the grace bounds the stop.
target and graceMs are required inside the object (I9: the reader sees which stop it is); the
whole policy defaults to { target: 'group', signal: 'SIGTERM', graceMs: 100 }, the previous
behaviour. descendants: 'terminate-after-leader' keeps its meaning and follows the policy: with
target: 'leader' the leader has already exited, so its leftovers get KILL at once.
The pipes stay open through the stop. Cleanup closes the output pipes only after the stop sequence; while it runs, output is read and dropped, never handed to a sink or captured. A leader that writes while it shuts down would otherwise meet a broken pipe and die of it, which turns cooperative cancellation into a kill.
Nothing in this package bounds a group after its host exits by default. The command’s group is detached
into its own session, so it gets no signal when the host dies, and every timer of the grace dies
with the host. A reaper or watchdog process would be a supervisor, which ADR 0224 rules out and
I11 keeps out of the composition. The bound belongs to the process supervisor: systemd’s default
KillMode=control-group stops every process in the unit’s cgroup whatever its process group;
launchd only kills the job’s own process group, which a detached command’s group is not. A host
that shuts down on purpose aborts killOn before it exits.
A resource does not declare its drain time to the application. A composition-time check of a
declared drain against gracePeriodMs would bound nothing: shutdown(), restart() and the
signal binding can each pass a smaller budget at the call, a shutdown is never refused, and the
supervisor’s stop timeout, the bound that really ends the process, is invisible to the
application. The existing mechanism answers the question instead: a resource reads
context.deadlineAt and context.forceDeadlineAt in its drain, and its force aborts the
killOn of the commands it still runs. ADR 0255 later adds an opt-in, per-command owner-loss guard;
the default and the external-supervisor boundary described here remain unchanged.
Consequences
- One stop implementation serves both shapes; a consumer drops its own termination code for
stop: { target: 'leader', … }. - Breaking for callers that pass
killGraceMs; the migration is one mechanical replacement. - A stopped command’s result rejects up to
graceMspluscleanupTimeoutMsafter the abort, the same rule as before with a longer possible grace.
Verification
packages/core/tests/native-command-stop-policy.test.ts runs real fixture processes: a leader-only
signal reaches the leader and not its group member, the leader’s exit ends a one-minute grace,
the member left behind is killed, a leader that ignores the signal is killed with its group when
the grace ends, an abort of killOn ends a one-hour grace, a leader writing 1 MiB through its
grace exits on its own, and terminate-after-leader kills a member with closed stdio without
signalling it first.