0088 — Adopting a live server into supervision
Neither launchd nor systemd can restart-supervise a process it did not start, so "adopt this running server" is not a thing any OS offers, and no phux verb...
Full source summary
Neither launchd nor systemd can restart-supervise a process it did not start, so "adopt this running server" is not a thing any OS offers, and no phux verb can pretend otherwise. phux service install --adopt therefore transfers the supervision, not the process: it writes the unit and arms it instead of loading it, so the incumbent keeps every pane, nothing crash-loops on a socket someone else holds, and the supervisor takes over the first time a server starts after that one exits.
Status: Accepted
Date: 2026-08-15
Builds on: ADR-0055 (phux service install), ADR-0080 (the corrected restart
policy and profile scoping), ADR-0083 (in-place unit reconcile)
Context
phux service install refuses when a server already holds the socket
(phux-67wg). The refusal is correct — the supervised process would fail
bind(2) on every start, forever — but it leaves a user who has a live server
full of shells and agents with exactly one route to supervision: stop
everything, then install. For a terminal multiplexer the panes are the
product, so that is the wrong trade, and phux-m3ot asked for a route that does
not make it.
The bead’s own design note proposed reusing ADR-0032’s graceful in-place handoff. That reuse is not available, and the reason matters more than the verdict:
ADR-0032 hands off by execve. The old server clears FD_CLOEXEC on the
PTY masters and the listener and execs the new binary, so the fds survive
because the process does. execve preserves the pid and the parent — which
is the one property adoption needs to change. An external process can already
ask for that handoff (phux upgrade sends Command::Upgrade), but it can only
say “re-exec yourself”, never “hand off to me”, and afterwards launchd or
systemd still does not own the result.
A process the supervisor did not start cannot be given fds by it.
blob.listener_fd and PaneBlob::master_fd are integers meaningful only
inside the exec’d image’s own descriptor table. A server launchd or systemd
spawned independently has an unrelated table, and phux has no SCM_RIGHTS
anywhere in the tree and no socket activation in either renderer. Getting live
panes into an independently-spawned process needs a descriptor-passing channel
phux does not have.
And neither supervisor can adopt a pid regardless. launchd offers no API to
place an existing process under a job. systemd can enclose existing pids in a
transient scope, but systemd.scope(5) has no Restart= — scopes track
processes, they do not restart them. So even the platform that appears to
support adoption cannot supply the thing supervision is for. This is not a
launchd-versus-systemd asymmetry to paper over; it is the same answer on both,
which is what lets one verb behave identically on both.
Decision
Adopt the supervision, not the process. --adopt writes the same unit a
plain install writes, from the same flags — so a listener the operator asks for
here is in the unit, and nothing is recovered from the incumbent — and then
stops short of the one step that would collide: it does not ask the init system
to start anything.
Arming is a real state on both platforms, not a simulation. launchd
bootstraps every plist in ~/Library/LaunchAgents when the user’s domain comes
up, so writing the file is arming it. systemd needs the WantedBy= link,
which is what enable without --now writes. Both leave the unit committed
and inert, and both start it at the next login.
The hand-over is completed by the auto-spawn path. An armed unit alone is a
promise that never keeps itself: the moment the incumbent exits, the next
phux command forks a fresh unsupervised server and the host is back where it
started. So ensure_server, having probed and found nothing accepting, gives
an armed unit first refusal — asking the init system to start it instead of
forking — and clears the arming. This is deliberately not a general “prefer the
supervisor whenever a unit exists” rule, which would resurrect a server the
user stopped on purpose and contradict ADR-0080.
Armed is recorded, not inferred. A marker under the profile’s state
directory names the armed unit. An unloaded unit and a unit whose supervised
server was deliberately stopped are indistinguishable on disk and to launchctl print; recording the state that was entered removes the guess and gives phux service status something true to say. uninstall revokes it, and the marker is
swept on sight in two cases: when the unit it names has vanished (the adoption
can never complete), and when the hand-over it describes is already done —
whether this process completed it or status observed the init system running
the unit, which is how a marker that outlived a login is retired rather than
repeated.
A flag, not a verb. ADR-0083 took the opposite route for reconcile
because it resolves nothing and conflicts with every install flag. --adopt
is the reverse: it wants all of them, renders the identical unit, and differs
only in whether the unit is loaded. Over a socket with no live server it
degenerates to a plain install, so it is always safe to pass — the flag reads
“never stop a running server to install”, and that is exactly what it means.
What it does not do, in the command’s own words. The running process stays
unsupervised for the rest of its life. --adopt says so on stdout, next to the
two commands that would hand over immediately and the panes that would cost.
Printing “installed” and stopping would leave the user believing the one thing
this whole path exists to prevent.
Tradeoffs
- The live server is never restart-managed. A crash before the hand-over is not caught by anything, exactly as before the install.
- Supervision begins at an event the user does not schedule. Predictable and reported, but “installed” and “in force” are now separated in time, which is one more state to hold — the same trade ADR-0083 made on macOS, now on both platforms.
- The marker is state outside the unit, so a hand-edited or externally removed state directory silently reverts adoption to “at next login”.
- Live pane transfer between two processes stays unbuilt. phux-m3ot’s acceptance criteria about pane pids surviving a handoff are not met, because no handoff occurs; the panes survive by not being touched.
Alternatives
Build the SCM_RIGHTS handoff. The only design that satisfies the bead
literally: the supervised newcomer connects, the incumbent passes its listener
and every PTY master as ancillary data, the newcomer rebuilds each pane by
replaying the snapshot it already knows how to build. Rejected for now, not
forever — it needs a descriptor-passing channel, a handoff request verb with a
target, a parameterized upgrade so listener config can change, and an
environment-substitution story, none of which exist. It is the correct
post-1.0 shape and this decision does not close it off.
Let install take this path automatically when a server is live. Rejected
on ADR-0083’s grounds: install’s contract must not become “sometimes I load
the unit and sometimes I do not”, decided by state the user cannot see.
Install and let it crash-loop until the incumbent exits. Self-healing on
paper. In practice it is phux-67wg exactly — a failed start every 30s, a
doctor crash-loop report that names the wrong cause, and on systemd a unit
that hits StartLimitBurst and gives up permanently.
Stop the incumbent with SHUTDOWN and install normally. Clean, correct,
and precisely the cost the user came here to avoid.