daemon: autospawn pins the daemon's cgroup to whichever client first found a cold socket #261
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#261
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
spawn_daemon_child(crates/felis-client-core/src/spawn.rs) starts the daemon with a bareCommand::spawn(), so the daemon is an ordinary child of whichever client first finds a coldsocket. cgroup membership is inherited at fork and is unaffected by reparenting, so the daemon
keeps that client's cgroup for its whole life, and so does every PTY it later spawns.
Observed on a NixOS host running niri. The session's first client ran outside an
app-*.scope,so the daemon landed in the compositor's own cgroup and stayed there:
Which means the compositor's cgroup holds every shell in the session:
The damage is not felis's own. The kernel OOM killer picked a
nix-eval-jobsworker in thatcgroup, systemd applied
niri.service's defaultOOMPolicy=stop, and the entire graphicalsession went down with it. Symmetrically, any
MemoryMax=or systemd-oomd policy the usersets on the compositor now lands on their shells, and any policy meant for shells lands on
the compositor.
Clients do get their own scopes (
app-niri-felis-8923.scope), but each holds only the thinfelis-clientprocess, so that isolation buys nothing.The landing cgroup is decided by whichever client happens to start first, so it is not
reproducible from one session to the next either.
Proposal
Let the service manager start the daemon instead of forking it from a client. foot does this
with a socket-activated pair, which is small enough to quote in full:
The daemon then lands in
app.sliceno matter who triggered it, andfelis-daemon servegrows a mode that takes its listener as an inherited fd rather than binding the path itself.
This also removes work rather than adding it: with systemd owning the socket there is no cold
socket to detect, no
RetryPolicy::DAEMON_BOOTwait, and no "must not spawn over a fulldaemon" reasoning in
spawn.rs/dial.rs, because there is no longer a race to lose.The fork path still has to stay for platforms with no service manager to ask. macOS wants the
launchd equivalent (
Socketsin the plist); Windows keeps what it has.Related: #119, #41.
Plan (rev 23, reviewed by pi/sol: PASS)
The "Implementation scope" section is what the implementing PR ships; the rest is the analysis and the record of rejected alternatives.
#261 — design proposal, rev 23 (rev 21 = PASS; rev 22–23 narrow the implementation scope): best-effort hand-off to the user manager, fork as the fallback
Problem (verified against the tree)
spawn_daemon_child(crates/felis-client-core/src/spawn.rs) and the SSH relay'sspawn_persistent_daemon(crates/felis-daemon/src/relay.rs) forkfelis-daemon servefrom whicheverprocess first finds a cold socket. cgroup membership is inherited at fork and survives reparenting, so the
daemon and every PTY child live in that launcher's cgroup for the daemon's lifetime. Observed: the daemon
in
niri.service; an OOM kill of one shell child triggered the compositor'sOOMPolicy=stopand endedthe graphical session. The landing cgroup is also non-deterministic (first launcher wins).
The failure is systemd's:
OOMPolicy=acts on the cgroup a unit owns, and only a unit of the systemduser manager (
niri.service, a compositor, asystemd-run --user --scopewrapper) carries thestopdefault that ended the session. A logind login session (
session-N.scope) reportsOOMPolicy=continue(verified); no other init system reproduces it. The fix is scoped to where the failure exists.
Decision
The auto-spawn contract does not change.
cli.md"Auto-spawning" stays as it is:sessions spawnand window launches start a cold daemon, every other dial refuses (
control-surfaces.md"A session verbnever spawns a daemon implicitly"), and the carrier never decides. What changes is how the spawning
forms start the daemon on Linux: when the launcher itself lives under the user manager (or the user
lingers), it first asks the manager to run the launcher's own
felis-daemonbinary as a transientservice; if that does not produce a ready daemon, for any reason, it forks exactly as today.
Best effort, fork fallback — a maintainer decision, recorded here. Revs 10–17 held the rule "never
fork past a present manager" and paid for it with a filesystem-identity probe run twice, a
version-gated
$regime, a cgroup-namespace test, typed errors for masked units, non-UTF-8 pathencodings, and a decision tree of a dozen leaves — each one a real case, each one rarer than the last.
The fallback replaces all of them: a hand-off that fails leaves the user exactly where every felis
release so far has left them, with one warning line in the client log naming the reason, and with the
daemon reporting
supervisor: none(§4). What the hand-off must guarantee is only the positive case:when it reports success, the daemon it started is the launcher's binary, bound on the launcher's
socket, in
app.slicewithOOMPolicy=continue. That is guaranteed by construction: the launcher passesits own absolute program path and its own resolved socket path, and
Type=notifymakessystemd-run'sexit
0mean "that daemon bound that socket" at that moment. What the launcher does not claim iswho is serving the socket when it finally connects: a managed daemon can exit after
READY=1and anolder client's fork can take the path in between, and the preface identifies a protocol, not a
process. So the launcher records only what it did —
HandOff::{Skipped, ManagerStarted, ConnectedToConcurrent, Forked}— for the log, and the daemon is the sole authority on its supervisor (§4): thelauncher never reports "managed". The negative cases are not classified either.
Socket activation (revs 1–6) and a shipped unit file (revs 7–9) stay rejected: the first starts a
daemon on every connect (the per-verb contract cannot survive it) and needs fd adoption,
LISTEN_*scrubbing and a bind-race guard felis cannot own; the second pins
ExecStartto one installed binary,losing today's sibling-binary guarantee for
nix run, source builds and SSH relays, and needs everyHome Manager activation transition guarded.
Implementation scope (rev 23) — what ships for #261, and what is recorded but deferred
The sections below are the full analysis and stay as the record. What the implementing PR
ships is the core only:
connect_or_spawn_daemon(felis-client-core), on acold socket, if the client's own cgroup (
/proc/self/cgroup, the0::line) containsuser@<uid>.service/, run the §1systemd-runline with the siblingfelis-daemonand theresolved socket path (absolute; a relative
--socket/FELIS_SOCKETis anchored withcurrent_dir().joinfirst — the onefelis-transportchange).systemd-runruns with--quietand both stdio streams captured (
Command::output()), never inherited: its "Running asunit…" line and its failure diagnostics must not reach a point verb's stdout/stderr (
cli.md"Output classes": a
sessions spawn --format jsonemits its result object and nothing else).Exit
0→ connect with the existingDAEMON_BOOTretry. Non-zero → one check beforeforking:
systemctl --user show -p ActiveState --value <unit>(captured likewise);activeoractivatingmeans a concurrent launcher's start job holds the name, so wait for the socket with a15 s budget (
TimeoutStartSec; a newRetryPolicy::MANAGED_BOOT) and connect — without this, twowindows opened together under
niri.servicewould have the loser fork, and a fork that wins theStartupLockrace leaves the daemon in the compositor's cgroup, which is #261 again. Anythingelse —
systemd-runmissing, any other non-zero, a cold socket after exit0or after thebudget, a
$in the path on systemd < 254 — → today'sspawn_daemon_child, unchanged except forenv_remove("NOTIFY_SOCKET"). Onewarnline in the client log, carrying the captured stderr,when a hand-off was attempted and fell back (the GUI client has the tee; the headless front
door's fallback is silent for now, see deferred). ~80 lines.
servesendsREADY=1after the bind (~15 lines);NOTIFY_SOCKETjoinsENV_DENYLIST; the relay'sspawn_persistent_daemongains the sameenv_remove("NOTIFY_SOCKET")as the client fork (one line: a relay running under a notify unit must not lend its endpoint to
the daemon it forks, now that
serveacts on the variable). The relay's launch path is otherwiseuntouched.
cli.md"Auto-spawning": one paragraph (on Linux, a client running under the systemduser manager starts the daemon as a transient
felis-daemon-<hash>.servicefrom its own binary;otherwise a detached child; the table is unchanged) and the
--socketrelative-path note;session-lifecycle.md"Daemon updates": the decision record (§ Decision, § Alternatives rejected,"Revisit if"), "Crash recovery" (control-group kill under the manager), "Logs tee…" (journal);
terminal-identity.mddenylist;update-felis.md(systemctl stop|restartrefused, drain withfelis daemon stop);CHANGELOG.md;skills/felis/SKILL.mdone sentence; the source commentsand scripts that assume the fork (Doc cascade list). A spec REQ for the new behavior.
systemd-runargv (--quiet, every-p,--socket,$handling); stubsystemd-runexit 0 → no fork; exit 1 + stubsystemctlactive+ socketgoes live within the budget → connected, no fork; exit 1 +
active+ still cold → fork; exit 1 +inactive/not-found→ fork; a stub that writes to stdout and stderr → nothing on the caller'sstdout/stderr, stderr text in the
warnline; the fork child (client and relay) lacksNOTIFY_SOCKET. Daemon:READY=1sent after bind / not on failure / no-op without the variable.Integration (opt-in
FELIS_SYSTEMD_TESTS=1): one launch lands inapp.slice/felis-daemon-<hash>.servicewithOOMPolicy=continue;daemon stop --forceunloadsit; a second launch works; two launchers started concurrently on a cold socket → one daemon,
in
app.slice, both connected;sessions spawn --format jsonthrough a cold socket under themanager → stdout is exactly the result object.
Why this is enough for #261. The reported failure needs a launcher inside a user-manager unit;
that is exactly the condition in (1), and on that path the daemon now lands in its own unit with
OOMPolicy=continue. Every other launcher (SSH relay undersession-N.scope, tty logins, macOS,Windows, non-systemd) has no such coupling and keeps forking.
Recorded here, deliberately not shipped (each a follow-up issue only if a real need appears):
felis --hostruns the relay under the remote sshd's session scope(
OOMPolicy=continue); it never had the #261 coupling, sospawn_persistent_daemonstays as itis and the shared
felis-transportlauncher is not built. Revisit if a remote host startsterminals from a user-manager unit.
user@<uid>.servicealready dies at logout today; asking lingeronly mattered for session-scope launchers, which now simply fork. Dropped.
supervisorindaemon status(minor 11) and thedoctordetail. Diagnosis issystemctl --user list-units 'felis-daemon-*'plus thewarnline. Revisit if users cannot tellwhich daemon they have.
logging::notefor the headless front door.felis sessions spawnfrom a cold socket underthe manager is rare (a window is the usual first dial) and its fallback is a fork, not an error.
name=systemd), theHandOffoutcome classification (the log line says"attempted and fell back" or nothing), the two non-UTF-8 / sandbox cases (fork).
0. When the manager is asked at all — the lifetime rule
A user manager lives only as long as the user has a session unless
loginctl enable-lingeris set:the last logout stops
user@<uid>.serviceand every service under it. A daemon handed to the managerdies at logout. On a desktop that is what happens today too (a daemon forked inside
niri.servicedieswhen that unit stops), but on a host reached only over SSH — the remote-attach case, where the
felis-daemon relayunder an SSH login is the launcher — the forked daemon survives the login(
KillUserProcesses=no, the systemd default, which distributions may change) and a managed one wouldnot. That host also has no #261 failure (session scopes are
OOMPolicy=continue). So:/proc/self/cgroup, the0::line on the unified hierarchy, else thename=systemdline on a legacy cgroup-v1 host (one helper,systemd_cgroup_path(), shared with§4) — contains the component
user@<uid>.service/— a service, a scope,init.scope, anything the user manager owns — → askthe manager (§2);
loginctl show-user <uid> -p Linger --valueisyes→ ask the manager (it outlives the login);session-N.scope, no linger; or a cgroup line that names neither — a cgroupnamespace, a container — and no linger) → fork, today's behavior and today's survival across
logout.
attach-over-ssh.mdsaysloginctl enable-lingerputs the remote daemon under the manager.The cgroup line is the launcher's, read once;
loginctlis one short-lived process on the cold path,and its absence or failure counts as
no. A cgroup namespace that hidesuser@<uid>.service(theround-17 case) therefore forks; that is the fallback doing its job, not a hole to close.
1. The transient unit
<program>: the absolute path today's fork wouldexec— the client's siblingfelis-daemon, therelay's
current_exe(); a bare name is resolved on the launcher'sPATHfirst (the manager'sPATHis not the launcher's).
<socket>: the launcher's resolved path, made absolute by lexical anchoring(
current_dir().join, no canonicalization) — a step that moves intofelis-transport'sSocketPathso client, relay, unit name and retry loop see one path; a relative
--socket/FELIS_SOCKETis keptas typed today and a managed daemon would resolve it against the manager's cwd. Always passed: the
daemon must not re-derive the socket from the manager's
XDG_RUNTIME_DIR. Paths areOsStr; ifeither is not valid UTF-8, D-Bus cannot carry it and
systemd-runfails → fallback.<unit>=felis-daemon-<fnv1a64(<socket>) as 16 hex>: one unit per socket path, so a--socketdaemon and the default daemon coexist under the manager as they do today.
--expand-environment=nowhensystemd-run --versionreports ≥ 254 (verified on 261:$intact inarguments); before that,
$in<socket>is doubled ($$→$, verified) and<program>is passedverbatim (argv[0] is never expanded, verified).
--service-type=notify:servesendsREADY=1on$NOTIFY_SOCKETright after its bindsucceeds (a
UnixDatagramwrite, ~15 lines, nounsafe;@-prefixed abstract names map to aleading NUL; absent variable → no-op, which is every fork). A set variable whose send fails is a
startup error: the daemon exits rather than serve a socket the manager will tear down at the
timeout.
systemd-runthen blocks until the start job completes: exit0means bound and ready.TimeoutStartSec=15s, not the 90 s default: aservethat never reachesREADY=1is broken,and every launcher waiting on the same start job would otherwise sit for the full default.
OOMPolicy=continueis the load-bearing line (the service defaultstopwould reproduce thefailure one level down).
Slice=app.sliceexplicit though default.RefuseManualStop=yes: the unit holds every session;felis daemon stopwith itsrefuse/
--force/--when-emptypostures is the only way down (systemctl --user stop|restartisrefused, exit 4, verified;
systemctl --user killand the daemon's own exit are not manual stops).--collect: a unit that ends inactive or failed is unloaded at once, so a failed start leavesnothing to
reset-failedand the name is reusable (verified: kill →not-found→ start again).KillModedefault (control-group): a daemon crash under the manager also ends children thatignored the PTY hangup (
session-lifecycle.md"Crash recovery" gains the sentence).KillMode=processwas tried and rejected: a surviving child keeps the unit loaded with a non-empty cgroup and the next
start fails "already loaded" until it exits (verified).
Restart=, no[Install], no file. Nothing starts at login; "no daemon" stays a meaningfuldiagnosis for the read-side verbs.
~). For PTY childrenthis is already the design: a local create's
env_basereplaces the daemon environment (REQ-912),and
SHELLis read from that base first. TheEnvBaseSource::Birthfallback (pre-minor-4 clients,over-cap snapshots) now resolves to the manager environment (
HOME,SHELL,PATH,LOGNAME,XDG_RUNTIME_DIR);ipc.mdnames that baseline.NOTIFY_SOCKETjoinsENV_DENYLIST(and soRESERVED_ENV_KEYS): theBirthpath must not hand a shell the manager's notify endpoint. The forkbranch does
env_remove("NOTIFY_SOCKET")on its child for the mirror reason (a launcher insideanother
Type=notifyunit must not lend its endpoint to the forked daemon)./dev/null; the tee into the per-userlog file stays the record either way, and the journal never produces the
EPIPEthat motivatednulling.
2. One launcher in
felis-transport, used by the client and the relayspawn_daemon_childmoves fromfelis-client-coretofelis-transportasDaemonLauncher::launch(socket, program, detach, probe) -> Result<(HandOff, C), SpawnError>—probeis a caller-supplied
async Fn(&Path) -> Result<C, _>that performs the caller's own readiness checkand returns the caller's own connection type:
felis-client-corepasses its typedconnect_carrier+ preface (and keeps theCarrierConnectionit gets back), the relay passes a rawconnect that yields the socket halves it pumps (so the client-to-daemon handshake still runs
end-to-end through it, exactly as today, and no throwaway handshake is added). "Ready" therefore
means whatever the caller's probe means — a completed preface for the client, a connected socket for
the relay — and the launcher classifies
HandOfffrom the probe's success, never from a connectionit opened itself. The
Erristoday's fork/spawn error, propagated exactly as
spawn_daemon_childandspawn_persistent_daemonpropagate it now; the
Oknames what the launcher did, one variant per distinct action:Skipped(reason)(lifetime rule, non-Linux: forked without asking),ManagerStarted(systemd-runexited
0, nothing forked),ConnectedToConcurrent(the launcher'ssystemd-rundid not start thedaemon, but a live daemon was found on the socket — another launcher's unit or an older client's
fork — and nothing was forked),
Forked(reason)(systemd-runwas tried, no daemon appeared, thelauncher forked). The variant is fixed by how step 4 was entered and what it found, not by the
step alone:
ConnectedToConcurrentSkipped(reason)systemd-run0, probe liveManagerStartedsystemd-run0, probe coldConnectedToConcurrentsystemd-run0, probe coldForked(reason)ConnectedToConcurrentConnectedToConcurrentForked(reason)Err(SpawnError)so a fork that no manager was asked for is
Skipped, a fork after a manager was asked isForked,and only
Forkedwarns,beside
RetryPolicy::DAEMON_BOOTandSocketPath(the workspace contract already lists transport as"shared by client autospawn and the relay";
felis-daemondepends onfelis-transportat runtime andon
felis-client-coreonly as a dev-dependency, so the relay can share it only there).felis-client-corekeeps the connect-or-spawn policy (connect_or_spawn_daemon) and calls thelauncher; the relay calls it instead of its private copy, passing its
stderr(inherit)+process_group(0)detach choices for the fork branch. Tests inject the unit prefix, the tool names(
systemd-run,systemctl,loginctl), and the cgroup line. On a cold socket, Linux only:not_under_manager/no_linger.XDG_RUNTIME_DIRleft as the launcher has it; if thelauncher's
XDG_RUNTIME_DIRdoes not name the manager the tools fail and step 4 follows).0→ connect and complete the preface with the existingDAEMON_BOOTretry. Success →ManagerStarted. A cold socket after a successful start (the manager ran something that isnot serving this path: a sandbox, a chroot, a different tree) → step 4, reason
managed_start_not_reachable.systemctl --user show -p ActiveState --value <unit>:activeoractivating→ a concurrent launcher's start job holds the name; connect with a retry budget ofTimeoutStartSec(15 s, a newRetryPolicy::MANAGED_BOOT), success →ConnectedToConcurrent. Otherwise,or if the socket is still cold after the budget, → step 4 with reason
start_failedandsystemd-run's stderr and exit status attached. (An old client that forked concurrently and wonthe bind is found here too: the connect attempt in step 4 sees a live socket and simply connects,
as any launcher does on a live socket.)
have bound it meanwhile: live →
ConnectedToConcurrent, nothing forked) — today'sCommand::spawnwith today's detach details plus
env_removeofNOTIFY_SOCKET,INVOCATION_ID,JOURNAL_STREAM;a spawn error is the
Erras today. OtherwiseForked(reason), and the reason is written once atwarn—daemon hand-off to systemd failed (<reason>: <stderr>); forked— throughfelis_transport::logging::note, a new entry point thatappends one line to the per-user client log whether or not tracing is initialized: the GUI
client has the tee running, but the short-lived
felisfront door opens no log and disablestracing for machine output, and the relay opens none and inherits stderr, so the warning would
otherwise vanish exactly on the headless and remote paths.
noteopens the same file the teeuses (
cli.md"Log files"), appends, closes; on the relay host it is that host's client log. ASkippedreason is logged atdebugonly (it is the normal case on SSH hosts). Nothing is printedto stderr: a window launch has no visible console, the read-side verbs never reach this code, and
the point verbs' stderr is reserved for error objects.
macOS, Windows, non-systemd Linux: step 4 directly, unchanged (non-goals "Cross-platform constraints":
uniform intent — the daemon's lifetime and resource policy are not those of the window that happened
to start it — met on each OS by what that OS has; launchd's transient jobs are the same shape if a
macOS need appears; none is known).
3. What does not change
The auto-spawn table,
RemoteSpawn,relay --no-spawn,Dialinfelis-cli, exit codes, thedaemon stoppostures,StartupLock,socket_is_live,DAEMON_BOOT, the executable each launcherstarts,
nix/package.nix,nix/hm-module.nix. Sessions surviving logout on lingering or non-systemdhosts.
4. The daemon reports its own supervisor;
doctoronly relays itThe daemon is the authority on how it is running, and it knows at startup. The test is exact, not
"under the user manager": a fallback daemon forked from inside
niri.serviceinherits that unit'scgroup and its
INVOCATION_ID, so either would misreport the very placement this change exists todiagnose.
servecomputes the unit name its own socket path maps to (felis-daemon-<fnv1a64>— thesame function the launcher uses, in
felis-transport) and reads the last component of its0::cgroup path (
systemd_cgroup_path(), §0: unified0::else legacyname=systemd): equal (with.service) →Supervisor::Systemd { unit }; anything else →Supervisor::None. The unit prefix is aDaemonLauncher/serveparameter with one production value; a test that needs a private prefixpasses the same one to both sides.
INVOCATION_IDis not consulted. The fork branch nonetheless stripsINVOCATION_IDandJOURNAL_STREAMalongsideNOTIFY_SOCKET(a forked daemon is not that unit's process; the journalstream fd is not inherited anyway).
OpsStatusReplygainsoptional string supervisor_unitat minor 11 (PROTOCOL_MINOR10 → 11; aMINOR_LEDGERentry and theipc.mdledger row; the field joinsOpsStatusReply'sMinorGatedmetadata so
FrameWriterrefuses it below 11, and the daemon emits it only when the connection'seffective minor is ≥ 11 — the send-side contract,
ipc.md"Protocol minor"). Absent therefore means"none, or a peer below 11", and the CLI says which:
felis daemon statusrenderssupervisor: systemd (felis-daemon-….service)/supervisor: none/supervisor: (daemon predates minor 11)(JSON:"supervisor": {"kind": "systemd", "unit": "…"}/{"kind": "none"}/ key omitted;the CLI JSON schema is regenerated,
just schema).doctor'sdaemonrow today readsWelcomeonly; it gains an
Ops::Statusround-trip when the effective minor is ≥ 5 (the Status minor) andappends the same phrase to its detail, or nothing below that. A round-trip that fails after a
successful
Welcome(the daemon dropped the connection, an invalid reply) keeps the row'sWelcome-derived status and appendssupervisor: unknown (<error>)to the detail — theconnectivity finding stands, the supervisor finding is reported as unobtainable; the row never
becomes
failfor that alone, so the exit code is unchanged (0, or1only if some other checkfailed; never
2,doctor's standing rule). The same over--host. Nosystemctlindoctor;--hostworks because the remote daemon answers for itself. The reason a hand-off did not happen is a
launch-time event and lives in the client log (§2 step 4), not in a diagnosis reconstructed afterwards.
5. Migration
None beyond the existing update procedure. An in-process daemon keeps serving; every dial connects to
it. After
felis daemon stop --when-emptyand the last session's exit, the next spawning dial handsthe daemon to the manager. Nothing is installed, so nothing is uninstalled; a downgrade forks again.
6. Verification
felis-transport, stubsystemd-run/systemctl/loginctlonPATH, injectablecgroup line, a listening stub daemon or none): cgroup under
[email protected](service, scope,init.scope) →systemd-runinvoked; session scope + lingerno→ forked without invoking it;session scope + linger
yes→ invoked;0::/(namespace) + lingerno→ forked;systemd-runexit 0 + live socket →
ManagerStarted; exit 0 + cold socket →Forked(managed_start_not_reachable);exit 1 +
active+ socket becomes live within the budget →ConnectedToConcurrent; exit 1 +active+ still cold →Forked(start_failed); exit 1 +not-found+ socket live at step 4 →ConnectedToConcurrent, nothing spawned; exit 1 +not-found+ cold →Forked(start_failed);systemd-runabsent →Forked; session scope + lingerno→Skipped(no_linger); a fork thatfails to spawn →
Err(the existing typed error); a legacyname=systemdcgroup line under[email protected]→ asks; argv carries--socket <anchored path>, every-p,--expand-environment=nounder astubbed ≥ 254
--versionand$$doubling under a stubbed 253; a relative socket is anchored inargv, unit hash and dial alike; the fork child has no
NOTIFY_SOCKET; two unit names for twosockets; the
warnline lands in the client log exactly once perFailedwith tracinguninitialized (the front-door and relay shape) and with it initialized (the GUI shape); the fork
child has none of
NOTIFY_SOCKET,INVOCATION_ID,JOURNAL_STREAM.READY=1written to a testUnixDatagramafter the bind, not on bind failure, nothingwithout the variable, startup error on a dead endpoint;
Supervisorfrom an injected cgroup line:leaf = own unit name →
Systemd(from a0::line and from a legacyname=systemdline alike),leaf
niri.servicewithINVOCATION_IDset →None, leaf = another socket's unit →None;doctorwith a stub daemon that answersWelcomethen drops → row keeps its status, detail sayssupervisor: unknown, exit0;OpsStatusReply.supervisor_unitround-trips at minor 11 and isrefused by
FrameWriterat 10 (theMinorGatedtable test), the daemon omits it on a minor-10connection; the denylist test table gains
NOTIFY_SOCKET.FELIS_SYSTEMD_TESTS=1, a reachable user manager; recorded indocs/reference/testing.mdbeside the other opt-in switches): the production prefix with a tempsocket (its hash is the isolation; the path contains
$), so the daemon's exact-leaf detectionsees its own name; launch →
/proc/<pid>/cgroupunderapp.slice/<unit>,ActiveState=active,OOMPolicy=continue,RefuseManualStop=yesread back,daemon statussayssupervisor: systemd (<unit>); a launcher run inside asystemd-run --user --scopewrapper (so itcarries
INVOCATION_IDand a manager cgroup) withsystemd-runshadowed by a failing stub → theforked daemon reports
supervisor: none; a managed daemon killed afterREADY=1while a second,unmanaged daemon binds the path → the connected daemon reports
noneand the launcher's log saysManagerStarted(the two are allowed to disagree; the daemon is the truth);systemctl --user stop <unit>refused, daemon still serving;felis daemon stop --force→ unitnot-found; two launchers concurrently on the cold socket → one daemonPID, one unit, both connected; an unmanaged daemon already on the socket → both launchers connect to
it, no unit; a
<program>exiting beforeREADY=1→ fallback fork observed (supervisor: none),unit gone; a symlinked
<program>→Managed. Cleanup:daemon stop --forceon every daemon.run_stdio_relaywith the stubs — session scope + linger off forks (theremote-attach host keeps today's behavior), linger on hands off.
Alternatives rejected (recorded in
session-lifecycle.mdbeside "Daemon updates")and redesigns the auto-spawn table so every dial may start a daemon (
plan-261-rev6).see Decision. Revisit if a fallback fork is observed reproducing #261 in a real deployment; the
client log's reason line is the evidence to collect.
systemd-run --user --scopearound the fork: a scope has no main process, cannot report startfailure, and a
nohup'd grandchild holds the fixed name for good.StartTransientUnit: needs a D-Bus client in the daemon;non-goals say felis never links one.
systemd-runis that client, present wherever a manager is.doctorclassifies the unit withsystemctl: a probe of a different kind from the rest ofdoctor, reconstructing a launch-time event after the fact, and blind over--host; the daemon'sself-report (§4) answers the same question from the authority.
Doc cascade (implementing PR)
docs/reference/spec.md: a new REQ — on Linux, a spawning dial whose process runs under thesystemd user manager, or whose user lingers, first asks the manager to run the daemon as a transient
service in
app.slicewithOOMPolicy=continuefrom the launcher's own binary on the launcher'ssocket, and forks as elsewhere when that does not yield a reachable daemon; the daemon reports its
supervisor in
StatusReply. Sourced tocli.md"Auto-spawning" / "Daemon status" andsession-lifecycle.md.docs/reference/cli.md: "Auto-spawning" (the paragraph above;--socketrow: a relative path isanchored to the caller's cwd), "Daemon status" (
supervisorrow and JSON key), "Doctor" (daemonrow detail), "Log files" (the hand-off
warnline).docs/reference/ipc.mdStatus/StatusReply(supervisor_unit, minor 11), the minor ledger row(minor 11), the SSH-relay paragraph
(≈73) and the autospawn note (≈1400) that say "forks";
docs/reference/testing.md(
FELIS_SYSTEMD_TESTS);docs/reference/workspace.md(felis-transportowns the launcher);docs/reference/terminal-identity.md(denylist:NOTIFY_SOCKET).docs/explanation/architecture/session-lifecycle.md: "Daemon updates" decision record (why themanager, why transient, why best-effort,
OOMPolicy=continue,Type=notify,RefuseManualStop,KillMode, the lifetime rule, "Revisit if"); "Crash recovery" (control-group kill under themanager); "Working directory" (managed daemon cwd =
~); "Logs tee into a file…" (journal).docs/explanation/architecture/control-surfaces.md≈237–240 (launcher in transport, policy inclient-core) and ≈580;
overview.md"Lifecycle" step 1 and ≈273–280;ipc.md(explanation) "Thetwo environment fields" (
Birth→ manager environment) and ≈484–485;non-goals.md"Cross-platform constraints" (the launcher as a platform shim);
security-model.md≈378.docs/how-to/attach-over-ssh.md(linger),update-felis.md(systemctl stop|restartrefused bydesign; drain with
felis daemon stop),install.md(nothing to install; the unit's name).crates/felis-daemon/src/lib.rs≈169,serve.rs≈66,crates/felis-protocol/src/preface.rs≈459,crates/felis-transport/src/logging.rs≈2,
nix/make-macos-app.sh≈13–16,nix/hm-module.nix≈71,.agents/skills/add-config-key/SKILL.md≈72;
.agents/skills/perf-trace/scripts/samply-profile.shand.agents/skills/producer-traffic-debug/scripts/drive.shstartfelis-daemon serve --socket <tmp>themselves (samply launch mode and
RUST_LOG/stderr capture both assumed the plain fork)..agents/skills/perf-trace/SKILL.md≈125 (samply no longer follows a fork; the script starts thedaemon itself, above);
docs/reference/control-surfaces.md≈36 (daemon statusmaps toaccounting and supervisor reporting).
CHANGELOG.md;skills/felis/SKILL.md(daemon statusrow; ≈211 relative--socket; "on Linux thedaemon is usually a transient systemd user service
felis-daemon-<hash>"). Sweep terms:fork,autospawn,spawn_daemon_child,connect_or_spawn_daemon,nulled,inherit,environment,cwd.Implementation order (the rev 23 scope; the deferred items are not in it)
READY=1after the bind;NOTIFY_SOCKETinENV_DENYLIST;env_remove("NOTIFY_SOCKET")in the relay's fork.
felis-transport: relative-socket anchoring inSocketPath;RetryPolicy::MANAGED_BOOT.felis-client-core: the hand-off inconnect_or_spawn_daemon(cgroup predicate,systemd-runwith captured stdio, the active/activating wait, fork fallback with
env_remove), unit tests.FELIS_SYSTEMD_TESTS=1.record as "Revisit if".