daemon: autospawn pins the daemon's cgroup to whichever client first found a cold socket #261

Closed
opened 2026-09-12 15:52:05 +09:00 by natsukium · 1 comment
Owner

spawn_daemon_child (crates/felis-client-core/src/spawn.rs) starts the daemon with a bare
Command::spawn(), so the daemon is an ordinary child of whichever client first finds a cold
socket. 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:

$ cat /proc/7071/cgroup
0::/user.slice/user-1000.slice/[email protected]/session.slice/niri.service

Which means the compositor's cgroup holds every shell in the session:

6428 niri            <- the compositor itself
7071 felis-daemon
7084 fish / 8936 fish / 15062 fish / 68210 fish
20014 nix-fast-build
53202 nix

The damage is not felis's own. The kernel OOM killer picked a nix-eval-jobs worker in that
cgroup, systemd applied niri.service's default OOMPolicy=stop, and the entire graphical
session went down with it. Symmetrically, any MemoryMax= or systemd-oomd policy the user
sets 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 thin
felis-client process, 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:

# foot-server.socket
[Socket]
ListenStream=%t/foot.sock

[Unit]
PartOf=graphical-session.target
ConditionEnvironment=WAYLAND_DISPLAY

[Install]
WantedBy=graphical-session.target
# foot-server.service
[Service]
ExecStart=/.../bin/foot --server=3
UnsetEnvironment=LISTEN_PID LISTEN_FDS LISTEN_FDNAMES
NonBlocking=true

[Unit]
Requires=%N.socket

The daemon then lands in app.slice no matter who triggered it, and felis-daemon serve
grows 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_BOOT wait, and no "must not spawn over a full
daemon" 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 (Sockets in the plist); Windows keeps what it has.

Related: #119, #41.

`spawn_daemon_child` (`crates/felis-client-core/src/spawn.rs`) starts the daemon with a bare `Command::spawn()`, so the daemon is an ordinary child of whichever client first finds a cold socket. 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: ``` $ cat /proc/7071/cgroup 0::/user.slice/user-1000.slice/[email protected]/session.slice/niri.service ``` Which means the compositor's cgroup holds every shell in the session: ``` 6428 niri <- the compositor itself 7071 felis-daemon 7084 fish / 8936 fish / 15062 fish / 68210 fish 20014 nix-fast-build 53202 nix ``` The damage is not felis's own. The kernel OOM killer picked a `nix-eval-jobs` worker in that cgroup, systemd applied `niri.service`'s default `OOMPolicy=stop`, and the entire graphical session went down with it. Symmetrically, any `MemoryMax=` or systemd-oomd policy the user sets 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 thin `felis-client` process, 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: ```ini # foot-server.socket [Socket] ListenStream=%t/foot.sock [Unit] PartOf=graphical-session.target ConditionEnvironment=WAYLAND_DISPLAY [Install] WantedBy=graphical-session.target ``` ```ini # foot-server.service [Service] ExecStart=/.../bin/foot --server=3 UnsetEnvironment=LISTEN_PID LISTEN_FDS LISTEN_FDNAMES NonBlocking=true [Unit] Requires=%N.socket ``` The daemon then lands in `app.slice` no matter who triggered it, and `felis-daemon serve` grows 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_BOOT` wait, and no "must not spawn over a full daemon" 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 (`Sockets` in the plist); Windows keeps what it has. Related: #119, #41.
Author
Owner

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's
spawn_persistent_daemon (crates/felis-daemon/src/relay.rs) fork felis-daemon serve from whichever
process 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's OOMPolicy=stop and ended
the 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 systemd
user manager (niri.service, a compositor, a systemd-run --user --scope wrapper) carries the stop
default that ended the session. A logind login session (session-N.scope) reports OOMPolicy=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 spawn
and window launches start a cold daemon, every other dial refuses (control-surfaces.md "A session verb
never 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-daemon binary as a transient
service; 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 path
encodings, 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.slice with OOMPolicy=continue. That is guaranteed by construction: the launcher passes
its own absolute program path and its own resolved socket path, and Type=notify makes systemd-run's
exit 0 mean "that daemon bound that socket" at that moment. What the launcher does not claim is
who is serving the socket when it finally connects: a managed daemon can exit after READY=1 and an
older 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): the
launcher 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 ExecStart to one installed binary,
losing today's sibling-binary guarantee for nix run, source builds and SSH relays, and needs every
Home 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:

  1. Client side, Linux, one condition. In connect_or_spawn_daemon (felis-client-core), on a
    cold socket, if the client's own cgroup (/proc/self/cgroup, the 0:: line) contains
    user@<uid>.service/, run the §1 systemd-run line with the sibling felis-daemon and the
    resolved socket path (absolute; a relative --socket/FELIS_SOCKET is anchored with
    current_dir().join first — the one felis-transport change). systemd-run runs with --quiet
    and both stdio streams captured (Command::output()), never inherited: its "Running as
    unit…" line and its failure diagnostics must not reach a point verb's stdout/stderr (cli.md
    "Output classes": a sessions spawn --format json emits its result object and nothing else).
    Exit 0 → connect with the existing DAEMON_BOOT retry. Non-zero → one check before
    forking
    : systemctl --user show -p ActiveState --value <unit> (captured likewise); active or
    activating means a concurrent launcher's start job holds the name, so wait for the socket with a
    15 s budget (TimeoutStartSec; a new RetryPolicy::MANAGED_BOOT) and connect — without this, two
    windows opened together under niri.service would have the loser fork, and a fork that wins the
    StartupLock race leaves the daemon in the compositor's cgroup, which is #261 again. Anything
    else — systemd-run missing, any other non-zero, a cold socket after exit 0 or after the
    budget, a $ in the path on systemd < 254 — → today's spawn_daemon_child, unchanged except for
    env_remove("NOTIFY_SOCKET"). One warn line 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.
  2. Daemon side. serve sends READY=1 after the bind (~15 lines); NOTIFY_SOCKET joins
    ENV_DENYLIST; the relay's spawn_persistent_daemon gains the same env_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 serve acts on the variable). The relay's launch path is otherwise
    untouched.
  3. Docs. cli.md "Auto-spawning": one paragraph (on Linux, a client running under the systemd
    user manager starts the daemon as a transient felis-daemon-<hash>.service from its own binary;
    otherwise a detached child; the table is unchanged) and the --socket relative-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.md denylist; update-felis.md (systemctl stop|restart refused, drain with
    felis daemon stop); CHANGELOG.md; skills/felis/SKILL.md one sentence; the source comments
    and scripts that assume the fork (Doc cascade list). A spec REQ for the new behavior.
  4. Tests. Unit: the cgroup predicate; the systemd-run argv (--quiet, every -p, --socket,
    $ handling); stub systemd-run exit 0 → no fork; exit 1 + stub systemctl active + socket
    goes 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's
    stdout/stderr, stderr text in the warn line; the fork child (client and relay) lacks
    NOTIFY_SOCKET. Daemon: READY=1 sent after bind / not on failure / no-op without the variable.
    Integration (opt-in FELIS_SYSTEMD_TESTS=1): one launch lands in
    app.slice/felis-daemon-<hash>.service with OOMPolicy=continue; daemon stop --force unloads
    it; a second launch works; two launchers started concurrently on a cold socket → one daemon,
    in app.slice, both connected
    ; sessions spawn --format json through a cold socket under the
    manager → 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 under session-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):

  • The relay. felis --host runs the relay under the remote sshd's session scope
    (OOMPolicy=continue); it never had the #261 coupling, so spawn_persistent_daemon stays as it
    is and the shared felis-transport launcher is not built. Revisit if a remote host starts
    terminals from a user-manager unit.
  • Linger. A launcher under user@<uid>.service already dies at logout today; asking linger
    only mattered for session-scope launchers, which now simply fork. Dropped.
  • supervisor in daemon status (minor 11) and the doctor detail. Diagnosis is
    systemctl --user list-units 'felis-daemon-*' plus the warn line. Revisit if users cannot tell
    which daemon they have.
  • logging::note for the headless front door. felis sessions spawn from a cold socket under
    the manager is rare (a window is the usual first dial) and its fallback is a fork, not an error.
  • cgroup v1 (name=systemd), the HandOff outcome 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-linger is set:
the last logout stops user@<uid>.service and every service under it. A daemon handed to the manager
dies at logout. On a desktop that is what happens today too (a daemon forked inside niri.service dies
when that unit stops), but on a host reached only over SSH — the remote-attach case, where the
felis-daemon relay under 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 would
not. That host also has no #261 failure (session scopes are OOMPolicy=continue). So:

  • the launcher's own cgroup — /proc/self/cgroup, the 0:: line on the unified hierarchy, else the
    name=systemd line 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 — → ask
    the manager
    (§2);
  • loginctl show-user <uid> -p Linger --value is yesask the manager (it outlives the login);
  • otherwise (a logind session-N.scope, no linger; or a cgroup line that names neither — a cgroup
    namespace, a container — and no linger) → fork, today's behavior and today's survival across
    logout. attach-over-ssh.md says loginctl enable-linger puts the remote daemon under the manager.

The cgroup line is the launcher's, read once; loginctl is one short-lived process on the cold path,
and its absence or failure counts as no. A cgroup namespace that hides user@<uid>.service (the
round-17 case) therefore forks; that is the fallback doing its job, not a hole to close.

1. The transient unit

systemd-run --user --unit=<unit> --description='felis daemon on <socket>' \
  --service-type=notify --collect [--expand-environment=no] \
  -p OOMPolicy=continue -p Slice=app.slice -p RefuseManualStop=yes -p TimeoutStartSec=15s \
  <program> serve --socket <socket>
  • <program>: the absolute path today's fork would exec — the client's sibling felis-daemon, the
    relay's current_exe(); a bare name is resolved on the launcher's PATH first (the manager's PATH
    is not the launcher's). <socket>: the launcher's resolved path, made absolute by lexical anchoring
    (current_dir().join, no canonicalization) — a step that moves into felis-transport's SocketPath
    so client, relay, unit name and retry loop see one path; a relative --socket/FELIS_SOCKET is kept
    as 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 are OsStr; if
    either is not valid UTF-8, D-Bus cannot carry it and systemd-run fails → fallback.
  • <unit> = felis-daemon-<fnv1a64(<socket>) as 16 hex>: one unit per socket path, so a --socket
    daemon and the default daemon coexist under the manager as they do today.
  • --expand-environment=no when systemd-run --version reports ≥ 254 (verified on 261: $ intact in
    arguments); before that, $ in <socket> is doubled ($$$, verified) and <program> is passed
    verbatim (argv[0] is never expanded, verified).
  • --service-type=notify: serve sends READY=1 on $NOTIFY_SOCKET right after its bind
    succeeds (a UnixDatagram write, ~15 lines, no unsafe; @-prefixed abstract names map to a
    leading 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-run then blocks until the start job completes: exit 0 means bound and ready.
  • TimeoutStartSec=15s, not the 90 s default: a serve that never reaches READY=1 is broken,
    and every launcher waiting on the same start job would otherwise sit for the full default.
  • OOMPolicy=continue is the load-bearing line (the service default stop would reproduce the
    failure one level down). Slice=app.slice explicit though default.
  • RefuseManualStop=yes: the unit holds every session; felis daemon stop with its
    refuse/--force/--when-empty postures is the only way down (systemctl --user stop|restart is
    refused, exit 4, verified; systemctl --user kill and 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 leaves
    nothing to reset-failed and the name is reusable (verified: kill → not-found → start again).
  • KillMode default (control-group): a daemon crash under the manager also ends children that
    ignored the PTY hangup (session-lifecycle.md "Crash recovery" gains the sentence). KillMode=process
    was 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).
  • No Restart=, no [Install], no file. Nothing starts at login; "no daemon" stays a meaningful
    diagnosis for the read-side verbs.
  • The daemon runs with the manager's environment; its cwd is the manager's (~). For PTY children
    this is already the design: a local create's env_base replaces the daemon environment (REQ-912),
    and SHELL is read from that base first. The EnvBaseSource::Birth fallback (pre-minor-4 clients,
    over-cap snapshots) now resolves to the manager environment (HOME, SHELL, PATH, LOGNAME,
    XDG_RUNTIME_DIR); ipc.md names that baseline. NOTIFY_SOCKET joins ENV_DENYLIST (and so
    RESERVED_ENV_KEYS): the Birth path must not hand a shell the manager's notify endpoint. The fork
    branch does env_remove("NOTIFY_SOCKET") on its child for the mirror reason (a launcher inside
    another Type=notify unit must not lend its endpoint to the forked daemon).
  • The managed daemon's stdio goes to the journal instead of /dev/null; the tee into the per-user
    log file stays the record either way, and the journal never produces the EPIPE that motivated
    nulling.

2. One launcher in felis-transport, used by the client and the relay

spawn_daemon_child moves from felis-client-core to felis-transport as
DaemonLauncher::launch(socket, program, detach, probe) -> Result<(HandOff, C), SpawnError>probe
is a caller-supplied async Fn(&Path) -> Result<C, _> that performs the caller's own readiness check
and returns the caller's own connection type: felis-client-core passes its typed
connect_carrier + preface (and keeps the CarrierConnection it gets back), the relay passes a raw
connect 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 HandOff from the probe's success, never from a connection
it opened itself. The Err is
today's fork/spawn error, propagated exactly as spawn_daemon_child and spawn_persistent_daemon
propagate it now; the Ok names what the launcher did, one variant per distinct action:
Skipped(reason) (lifetime rule, non-Linux: forked without asking), ManagerStarted (systemd-run
exited 0, nothing forked), ConnectedToConcurrent (the launcher's systemd-run did not start the
daemon, 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-run was tried, no daemon appeared, the
launcher forked). The variant is fixed by how step 4 was entered and what it found, not by the
step alone:

entered from probe before forking outcome log
step 1 (lifetime rule) or non-Linux live ConnectedToConcurrent info
step 1 (lifetime rule) or non-Linux cold → forked Skipped(reason) debug
step 2, systemd-run 0, probe live ManagerStarted info
step 2, systemd-run 0, probe cold live on re-probe ConnectedToConcurrent info
step 2, systemd-run 0, probe cold cold → forked Forked(reason) warn
step 3, unit active, probe live in budget ConnectedToConcurrent info
step 3, otherwise live on re-probe ConnectedToConcurrent info
step 3, otherwise cold → forked Forked(reason) warn
any fork whose spawn fails Err(SpawnError) as today

so a fork that no manager was asked for is Skipped, a fork after a manager was asked is Forked,
and only Forked warns,
beside RetryPolicy::DAEMON_BOOT and SocketPath (the workspace contract already lists transport as
"shared by client autospawn and the relay"; felis-daemon depends on felis-transport at runtime and
on felis-client-core only as a dev-dependency, so the relay can share it only there).
felis-client-core keeps the connect-or-spawn policy (connect_or_spawn_daemon) and calls the
launcher; 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:

  1. Lifetime rule (§0) says fork → step 4 with reason not_under_manager / no_linger.
  2. Ask. Run the §1 command line (with XDG_RUNTIME_DIR left as the launcher has it; if the
    launcher's XDG_RUNTIME_DIR does not name the manager the tools fail and step 4 follows).
    • Exit 0 → connect and complete the preface with the existing DAEMON_BOOT retry. Success →
      ManagerStarted. A cold socket after a successful start (the manager ran something that is
      not serving this path: a sandbox, a chroot, a different tree) → step 4, reason
      managed_start_not_reachable.
    • Non-zero → step 3.
  3. Someone else may have won. systemctl --user show -p ActiveState --value <unit>: active or
    activating → a concurrent launcher's start job holds the name; connect with a retry budget of
    TimeoutStartSec (15 s, a new RetryPolicy::MANAGED_BOOT), success → ConnectedToConcurrent. Otherwise,
    or if the socket is still cold after the budget, → step 4 with reason start_failed and
    systemd-run's stderr and exit status attached. (An old client that forked concurrently and won
    the 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.)
  4. Fork — after one more connect attempt on the socket (a concurrent launcher of either kind may
    have bound it meanwhile: live → ConnectedToConcurrent, nothing forked) — today's Command::spawn
    with today's detach details plus env_remove of NOTIFY_SOCKET, INVOCATION_ID, JOURNAL_STREAM;
    a spawn error is the Err as today. Otherwise Forked(reason), and the reason is written once at
    warndaemon hand-off to systemd failed (<reason>: <stderr>); forked — through felis_transport::logging::note, a new entry point that
    appends 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 felis front door opens no log and disables
    tracing for machine output, and the relay opens none and inherits stderr, so the warning would
    otherwise vanish exactly on the headless and remote paths. note opens the same file the tee
    uses (cli.md "Log files"), appends, closes; on the relay host it is that host's client log. A
    Skipped reason is logged at debug only (it is the normal case on SSH hosts). Nothing is printed
    to 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, Dial in felis-cli, exit codes, the
daemon stop postures, StartupLock, socket_is_live, DAEMON_BOOT, the executable each launcher
starts, nix/package.nix, nix/hm-module.nix. Sessions surviving logout on lingering or non-systemd
hosts.

4. The daemon reports its own supervisor; doctor only relays it

The 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.service inherits that unit's
cgroup and its INVOCATION_ID, so either would misreport the very placement this change exists to
diagnose. serve computes the unit name its own socket path maps to (felis-daemon-<fnv1a64> — the
same function the launcher uses, in felis-transport) and reads the last component of its 0::
cgroup path (systemd_cgroup_path(), §0: unified 0:: else legacy name=systemd): equal (with
.service) → Supervisor::Systemd { unit }; anything else → Supervisor::None. The unit prefix is a
DaemonLauncher/serve parameter with one production value; a test that needs a private prefix
passes the same one to both sides.
INVOCATION_ID is not consulted. The fork branch nonetheless strips INVOCATION_ID and
JOURNAL_STREAM alongside NOTIFY_SOCKET (a forked daemon is not that unit's process; the journal
stream fd is not inherited anyway).

OpsStatusReply gains optional string supervisor_unit at minor 11 (PROTOCOL_MINOR 10 → 11; a
MINOR_LEDGER entry and the ipc.md ledger row; the field joins OpsStatusReply's MinorGated
metadata so FrameWriter refuses it below 11, and the daemon emits it only when the connection's
effective 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 status renders
supervisor: 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's daemon row today reads Welcome
only; it gains an Ops::Status round-trip when the effective minor is ≥ 5 (the Status minor) and
appends 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's
Welcome-derived status and appends supervisor: unknown (<error>) to the detail — the
connectivity finding stands, the supervisor finding is reported as unobtainable; the row never
becomes fail for that alone, so the exit code is unchanged (0, or 1 only if some other check
failed; never 2, doctor's standing rule). The same over --host. No systemctl in doctor; --host
works 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-empty and the last session's exit, the next spawning dial hands
the daemon to the manager. Nothing is installed, so nothing is uninstalled; a downgrade forks again.

6. Verification

  • Unit tests (felis-transport, stub systemd-run/systemctl/loginctl on PATH, injectable
    cgroup line, a listening stub daemon or none): cgroup under [email protected] (service, scope,
    init.scope) → systemd-run invoked; session scope + linger no → forked without invoking it;
    session scope + linger yes → invoked; 0::/ (namespace) + linger no → forked; systemd-run
    exit 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-run absent → Forked; session scope + linger noSkipped(no_linger); a fork that
    fails to spawn → Err (the existing typed error); a legacy name=systemd cgroup line under
    [email protected] → asks; argv carries --socket <anchored path>, every -p, --expand-environment=no under a
    stubbed ≥ 254 --version and $$ doubling under a stubbed 253; a relative socket is anchored in
    argv, unit hash and dial alike; the fork child has no NOTIFY_SOCKET; two unit names for two
    sockets; the warn line lands in the client log exactly once per Failed with tracing
    uninitialized (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.
  • Daemon: READY=1 written to a test UnixDatagram after the bind, not on bind failure, nothing
    without the variable, startup error on a dead endpoint; Supervisor from an injected cgroup line:
    leaf = own unit name → Systemd (from a 0:: line and from a legacy name=systemd line alike),
    leaf niri.service with INVOCATION_ID set → None, leaf = another socket's unit → None;
    doctor with a stub daemon that answers Welcome then drops → row keeps its status, detail says
    supervisor: unknown, exit 0; OpsStatusReply.supervisor_unit round-trips at minor 11 and is
    refused by FrameWriter at 10 (the MinorGated table test), the daemon omits it on a minor-10
    connection; the denylist test table gains NOTIFY_SOCKET.
  • Integration (opt-in FELIS_SYSTEMD_TESTS=1, a reachable user manager; recorded in
    docs/reference/testing.md beside the other opt-in switches): the production prefix with a temp
    socket (its hash is the isolation; the path contains $), so the daemon's exact-leaf detection
    sees its own name; launch → /proc/<pid>/cgroup under app.slice/<unit>,
    ActiveState=active, OOMPolicy=continue, RefuseManualStop=yes read back, daemon status says
    supervisor: systemd (<unit>); a launcher run inside a systemd-run --user --scope wrapper (so it
    carries INVOCATION_ID and a manager cgroup) with systemd-run shadowed by a failing stub → the
    forked daemon reports supervisor: none; a managed daemon killed after READY=1 while a second,
    unmanaged daemon binds the path → the connected daemon reports none and the launcher's log says
    ManagerStarted (the two are allowed to disagree; the daemon is the truth); systemctl --user stop <unit> refused, daemon still serving; felis daemon stop --force → unit not-found; two launchers concurrently on the cold socket → one daemon
    PID, one unit, both connected; an unmanaged daemon already on the socket → both launchers connect to
    it, no unit; a <program> exiting before READY=1 → fallback fork observed (supervisor: none),
    unit gone; a symlinked <program>Managed. Cleanup: daemon stop --force on every daemon.
  • Relay: the launcher through run_stdio_relay with the stubs — session scope + linger off forks (the
    remote-attach host keeps today's behavior), linger on hands off.
  • Existing spawn tests stay green.

Alternatives rejected (recorded in session-lifecycle.md beside "Daemon updates")

  • Socket activation, shipped unit file (Decision). Revisit if felis adopts idle-daemon exit
    and redesigns the auto-spawn table so every dial may start a daemon (plan-261-rev6).
  • Never fork past a present manager (revs 10–17): correct in principle, unbounded in practice —
    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 --scope around the fork: a scope has no main process, cannot report start
    failure, and a nohup'd grandchild holds the fixed name for good.
  • Daemon relocates itself via D-Bus StartTransientUnit: needs a D-Bus client in the daemon;
    non-goals say felis never links one. systemd-run is that client, present wherever a manager is.
  • doctor classifies the unit with systemctl: a probe of a different kind from the rest of
    doctor, reconstructing a launch-time event after the fact, and blind over --host; the daemon's
    self-report (§4) answers the same question from the authority.
  • Document only. The failure ends the whole graphical session.

Doc cascade (implementing PR)

  • docs/reference/spec.md: a new REQ — on Linux, a spawning dial whose process runs under the
    systemd user manager, or whose user lingers, first asks the manager to run the daemon as a transient
    service in app.slice with OOMPolicy=continue from the launcher's own binary on the launcher's
    socket, and forks as elsewhere when that does not yield a reachable daemon; the daemon reports its
    supervisor in StatusReply. Sourced to cli.md "Auto-spawning" / "Daemon status" and
    session-lifecycle.md.
  • docs/reference/cli.md: "Auto-spawning" (the paragraph above; --socket row: a relative path is
    anchored to the caller's cwd), "Daemon status" (supervisor row and JSON key), "Doctor" (daemon
    row detail), "Log files" (the hand-off warn line).
  • docs/reference/ipc.md Status/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-transport owns the launcher);
    docs/reference/terminal-identity.md (denylist: NOTIFY_SOCKET).
  • docs/explanation/architecture/session-lifecycle.md: "Daemon updates" decision record (why the
    manager, why transient, why best-effort, OOMPolicy=continue, Type=notify, RefuseManualStop,
    KillMode, the lifetime rule, "Revisit if"); "Crash recovery" (control-group kill under the
    manager); "Working directory" (managed daemon cwd = ~); "Logs tee into a file…" (journal).
  • docs/explanation/architecture/control-surfaces.md ≈237–240 (launcher in transport, policy in
    client-core) and ≈580; overview.md "Lifecycle" step 1 and ≈273–280; ipc.md (explanation) "The
    two 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|restart refused by
    design; drain with felis daemon stop), install.md (nothing to install; the unit's name).
  • Source/test comments and scripts that assume the fork: 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.sh and
    .agents/skills/producer-traffic-debug/scripts/drive.sh start felis-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 the
    daemon itself, above); docs/reference/control-surfaces.md ≈36 (daemon status maps to
    accounting and supervisor reporting).
  • CHANGELOG.md; skills/felis/SKILL.md (daemon status row; ≈211 relative --socket; "on Linux the
    daemon 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)

  1. Daemon: READY=1 after the bind; NOTIFY_SOCKET in ENV_DENYLIST; env_remove("NOTIFY_SOCKET")
    in the relay's fork.
  2. felis-transport: relative-socket anchoring in SocketPath; RetryPolicy::MANAGED_BOOT.
  3. felis-client-core: the hand-off in connect_or_spawn_daemon (cgroup predicate, systemd-run
    with captured stdio, the active/activating wait, fork fallback with env_remove), unit tests.
  4. Integration test behind FELIS_SYSTEMD_TESTS=1.
  5. Docs cascade for the shipped core, spec REQ, CHANGELOG; the deferred list stays in the decision
    record as "Revisit if".
## 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's `spawn_persistent_daemon` (`crates/felis-daemon/src/relay.rs`) fork `felis-daemon serve` from whichever process 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's `OOMPolicy=stop` and ended the 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 systemd user manager (`niri.service`, a compositor, a `systemd-run --user --scope` wrapper) carries the `stop` default that ended the session. A logind login session (`session-N.scope`) reports `OOMPolicy=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 spawn` and window launches start a cold daemon, every other dial refuses (`control-surfaces.md` "A session verb never 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-daemon` binary** as a transient service; **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 path encodings, 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.slice` with `OOMPolicy=continue`. That is guaranteed by construction: the launcher passes its own absolute program path and its own resolved socket path, and `Type=notify` makes `systemd-run`'s exit `0` mean "that daemon bound that socket" at that moment. What the launcher does *not* claim is who is serving the socket when it finally connects: a managed daemon can exit after `READY=1` and an older 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): the launcher 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 `ExecStart` to one installed binary, losing today's sibling-binary guarantee for `nix run`, source builds and SSH relays, and needs every Home 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:** 1. **Client side, Linux, one condition.** In `connect_or_spawn_daemon` (`felis-client-core`), on a cold socket, if the client's own cgroup (`/proc/self/cgroup`, the `0::` line) contains `user@<uid>.service/`, run the §1 `systemd-run` line with the sibling `felis-daemon` and the resolved socket path (absolute; a relative `--socket`/`FELIS_SOCKET` is anchored with `current_dir().join` first — the one `felis-transport` change). `systemd-run` runs with `--quiet` and **both stdio streams captured** (`Command::output()`), never inherited: its "Running as unit…" line and its failure diagnostics must not reach a point verb's stdout/stderr (`cli.md` "Output classes": a `sessions spawn --format json` emits its result object and nothing else). Exit `0` → connect with the existing `DAEMON_BOOT` retry. Non-zero → **one check before forking**: `systemctl --user show -p ActiveState --value <unit>` (captured likewise); `active` or `activating` means a concurrent launcher's start job holds the name, so wait for the socket with a 15 s budget (`TimeoutStartSec`; a new `RetryPolicy::MANAGED_BOOT`) and connect — without this, two windows opened together under `niri.service` would have the loser fork, and a fork that wins the `StartupLock` race leaves the daemon in the compositor's cgroup, which is #261 again. Anything else — `systemd-run` missing, any other non-zero, a cold socket after exit `0` or after the budget, a `$` in the path on systemd < 254 — → today's `spawn_daemon_child`, unchanged except for `env_remove("NOTIFY_SOCKET")`. One `warn` line 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. 2. **Daemon side.** `serve` sends `READY=1` after the bind (~15 lines); `NOTIFY_SOCKET` joins `ENV_DENYLIST`; the relay's `spawn_persistent_daemon` gains the same `env_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 `serve` acts on the variable). The relay's launch path is otherwise untouched. 3. **Docs.** `cli.md` "Auto-spawning": one paragraph (on Linux, a client running under the systemd user manager starts the daemon as a transient `felis-daemon-<hash>.service` from its own binary; otherwise a detached child; the table is unchanged) and the `--socket` relative-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.md` denylist; `update-felis.md` (`systemctl stop|restart` refused, drain with `felis daemon stop`); `CHANGELOG.md`; `skills/felis/SKILL.md` one sentence; the source comments and scripts that assume the fork (Doc cascade list). A spec REQ for the new behavior. 4. **Tests.** Unit: the cgroup predicate; the `systemd-run` argv (`--quiet`, every `-p`, `--socket`, `$` handling); stub `systemd-run` exit 0 → no fork; exit 1 + stub `systemctl` `active` + socket goes 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's stdout/stderr, stderr text in the `warn` line; the fork child (client and relay) lacks `NOTIFY_SOCKET`. Daemon: `READY=1` sent after bind / not on failure / no-op without the variable. Integration (opt-in `FELIS_SYSTEMD_TESTS=1`): one launch lands in `app.slice/felis-daemon-<hash>.service` with `OOMPolicy=continue`; `daemon stop --force` unloads it; a second launch works; **two launchers started concurrently on a cold socket → one daemon, in `app.slice`, both connected**; `sessions spawn --format json` through a cold socket under the manager → 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 under `session-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):** - **The relay.** `felis --host` runs the relay under the remote sshd's session scope (`OOMPolicy=continue`); it never had the #261 coupling, so `spawn_persistent_daemon` stays as it is and the shared `felis-transport` launcher is not built. *Revisit if* a remote host starts terminals from a user-manager unit. - **Linger.** A launcher under `user@<uid>.service` already dies at logout today; asking linger only mattered for session-scope launchers, which now simply fork. Dropped. - **`supervisor` in `daemon status` (minor 11) and the `doctor` detail.** Diagnosis is `systemctl --user list-units 'felis-daemon-*'` plus the `warn` line. *Revisit if* users cannot tell which daemon they have. - **`logging::note` for the headless front door.** `felis sessions spawn` from a cold socket under the manager is rare (a window is the usual first dial) and its fallback is a fork, not an error. - **cgroup v1 (`name=systemd`)**, **the `HandOff` outcome 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-linger` is set: the last logout stops `user@<uid>.service` and every service under it. A daemon handed to the manager dies at logout. On a desktop that is what happens today too (a daemon forked inside `niri.service` dies when that unit stops), but on a host reached only over SSH — the remote-attach case, where the `felis-daemon relay` under 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 would not. That host also has no #261 failure (session scopes are `OOMPolicy=continue`). So: - the launcher's own cgroup — `/proc/self/cgroup`, the `0::` line on the unified hierarchy, else the `name=systemd` line 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 — → **ask the manager** (§2); - `loginctl show-user <uid> -p Linger --value` is `yes` → **ask the manager** (it outlives the login); - otherwise (a logind `session-N.scope`, no linger; or a cgroup line that names neither — a cgroup namespace, a container — and no linger) → **fork**, today's behavior and today's survival across logout. `attach-over-ssh.md` says `loginctl enable-linger` puts the remote daemon under the manager. The cgroup line is the launcher's, read once; `loginctl` is one short-lived process on the cold path, and its absence or failure counts as `no`. A cgroup namespace that hides `user@<uid>.service` (the round-17 case) therefore forks; that is the fallback doing its job, not a hole to close. #### 1. The transient unit ``` systemd-run --user --unit=<unit> --description='felis daemon on <socket>' \ --service-type=notify --collect [--expand-environment=no] \ -p OOMPolicy=continue -p Slice=app.slice -p RefuseManualStop=yes -p TimeoutStartSec=15s \ <program> serve --socket <socket> ``` - `<program>`: the absolute path today's fork would `exec` — the client's sibling `felis-daemon`, the relay's `current_exe()`; a bare name is resolved on the launcher's `PATH` first (the manager's `PATH` is not the launcher's). `<socket>`: the launcher's resolved path, made absolute by lexical anchoring (`current_dir().join`, no canonicalization) — a step that moves into `felis-transport`'s `SocketPath` so client, relay, unit name and retry loop see one path; a relative `--socket`/`FELIS_SOCKET` is kept as 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 are `OsStr`; if either is not valid UTF-8, D-Bus cannot carry it and `systemd-run` fails → fallback. - `<unit>` = `felis-daemon-<fnv1a64(<socket>) as 16 hex>`: one unit per socket path, so a `--socket` daemon and the default daemon coexist under the manager as they do today. - `--expand-environment=no` when `systemd-run --version` reports ≥ 254 (verified on 261: `$` intact in arguments); before that, `$` in `<socket>` is doubled (`$$` → `$`, verified) and `<program>` is passed verbatim (argv[0] is never expanded, verified). - **`--service-type=notify`**: `serve` sends `READY=1` on `$NOTIFY_SOCKET` right after its bind succeeds (a `UnixDatagram` write, ~15 lines, no `unsafe`; `@`-prefixed abstract names map to a leading 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-run` then blocks until the start job completes: exit `0` means bound and ready. - **`TimeoutStartSec=15s`**, not the 90 s default: a `serve` that never reaches `READY=1` is broken, and every launcher waiting on the same start job would otherwise sit for the full default. - **`OOMPolicy=continue`** is the load-bearing line (the service default `stop` would reproduce the failure one level down). **`Slice=app.slice`** explicit though default. - **`RefuseManualStop=yes`**: the unit holds every session; `felis daemon stop` with its refuse/`--force`/`--when-empty` postures is the only way down (`systemctl --user stop|restart` is refused, exit 4, verified; `systemctl --user kill` and 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 leaves nothing to `reset-failed` and the name is reusable (verified: kill → `not-found` → start again). - **`KillMode` default (`control-group`)**: a daemon crash under the manager also ends children that ignored the PTY hangup (`session-lifecycle.md` "Crash recovery" gains the sentence). `KillMode=process` was 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). - **No `Restart=`, no `[Install]`, no file.** Nothing starts at login; "no daemon" stays a meaningful diagnosis for the read-side verbs. - The daemon runs with the **manager's environment**; its cwd is the manager's (`~`). For PTY children this is already the design: a local create's `env_base` replaces the daemon environment (REQ-912), and `SHELL` is read from that base first. The `EnvBaseSource::Birth` fallback (pre-minor-4 clients, over-cap snapshots) now resolves to the manager environment (`HOME`, `SHELL`, `PATH`, `LOGNAME`, `XDG_RUNTIME_DIR`); `ipc.md` names that baseline. **`NOTIFY_SOCKET` joins `ENV_DENYLIST`** (and so `RESERVED_ENV_KEYS`): the `Birth` path must not hand a shell the manager's notify endpoint. The fork branch does `env_remove("NOTIFY_SOCKET")` on its child for the mirror reason (a launcher inside another `Type=notify` unit must not lend its endpoint to the forked daemon). - The managed daemon's stdio goes to the journal instead of `/dev/null`; the tee into the per-user log file stays the record either way, and the journal never produces the `EPIPE` that motivated nulling. #### 2. One launcher in `felis-transport`, used by the client and the relay `spawn_daemon_child` moves from `felis-client-core` to `felis-transport` as `DaemonLauncher::launch(socket, program, detach, probe) -> Result<(HandOff, C), SpawnError>` — `probe` is a caller-supplied `async Fn(&Path) -> Result<C, _>` that performs the caller's own readiness check and returns the caller's own connection type: `felis-client-core` passes its typed `connect_carrier` + preface (and keeps the `CarrierConnection` it gets back), the relay passes a raw connect 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 `HandOff` from the probe's success, never from a connection it opened itself. The `Err` is today's fork/spawn error, propagated exactly as `spawn_daemon_child` and `spawn_persistent_daemon` propagate it now; the `Ok` names what the launcher did, one variant per distinct action: `Skipped(reason)` (lifetime rule, non-Linux: forked without asking), `ManagerStarted` (`systemd-run` exited `0`, nothing forked), `ConnectedToConcurrent` (the launcher's `systemd-run` did not start the daemon, 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-run` was tried, no daemon appeared, the launcher forked). The variant is fixed by *how step 4 was entered and what it found*, not by the step alone: | entered from | probe before forking | outcome | log | | ----------------------------------------- | -------------------- | ----------------------- | ------- | | step 1 (lifetime rule) or non-Linux | live | `ConnectedToConcurrent` | info | | step 1 (lifetime rule) or non-Linux | cold → forked | `Skipped(reason)` | debug | | step 2, `systemd-run` 0, probe live | — | `ManagerStarted` | info | | step 2, `systemd-run` 0, probe cold | live on re-probe | `ConnectedToConcurrent` | info | | step 2, `systemd-run` 0, probe cold | cold → forked | `Forked(reason)` | warn | | step 3, unit active, probe live in budget | — | `ConnectedToConcurrent` | info | | step 3, otherwise | live on re-probe | `ConnectedToConcurrent` | info | | step 3, otherwise | cold → forked | `Forked(reason)` | warn | | any fork whose spawn fails | — | `Err(SpawnError)` | as today | so a fork that no manager was asked for is `Skipped`, a fork after a manager was asked is `Forked`, and only `Forked` warns, beside `RetryPolicy::DAEMON_BOOT` and `SocketPath` (the workspace contract already lists transport as "shared by client autospawn and the relay"; `felis-daemon` depends on `felis-transport` at runtime and on `felis-client-core` only as a dev-dependency, so the relay can share it only there). `felis-client-core` keeps the connect-or-spawn *policy* (`connect_or_spawn_daemon`) and calls the launcher; 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: 1. **Lifetime rule** (§0) says fork → step 4 with reason `not_under_manager` / `no_linger`. 2. **Ask.** Run the §1 command line (with `XDG_RUNTIME_DIR` left as the launcher has it; if the launcher's `XDG_RUNTIME_DIR` does not name the manager the tools fail and step 4 follows). - Exit `0` → connect and complete the preface with the existing `DAEMON_BOOT` retry. Success → `ManagerStarted`. A cold socket after a successful start (the manager ran something that is not serving this path: a sandbox, a chroot, a different tree) → step 4, reason `managed_start_not_reachable`. - Non-zero → step 3. 3. **Someone else may have won.** `systemctl --user show -p ActiveState --value <unit>`: `active` or `activating` → a concurrent launcher's start job holds the name; connect with a retry budget of `TimeoutStartSec` (15 s, a new `RetryPolicy::MANAGED_BOOT`), success → `ConnectedToConcurrent`. Otherwise, or if the socket is still cold after the budget, → step 4 with reason `start_failed` and `systemd-run`'s stderr and exit status attached. (An old client that forked concurrently and won the 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.) 4. **Fork** — after one more connect attempt on the socket (a concurrent launcher of either kind may have bound it meanwhile: live → `ConnectedToConcurrent`, nothing forked) — today's `Command::spawn` with today's detach details plus `env_remove` of `NOTIFY_SOCKET`, `INVOCATION_ID`, `JOURNAL_STREAM`; a spawn error is the `Err` as today. Otherwise `Forked(reason)`, and the reason is written once at `warn` — `daemon hand-off to systemd failed (<reason>: <stderr>); forked` — through `felis_transport::logging::note`, a new entry point that appends 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 `felis` front door opens no log and disables tracing for machine output, and the relay opens none and inherits stderr, so the warning would otherwise vanish exactly on the headless and remote paths. `note` opens the same file the tee uses (`cli.md` "Log files"), appends, closes; on the relay host it is that host's client log. A `Skipped` reason is logged at `debug` only (it is the normal case on SSH hosts). Nothing is printed to 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`, `Dial` in `felis-cli`, exit codes, the `daemon stop` postures, `StartupLock`, `socket_is_live`, `DAEMON_BOOT`, the executable each launcher starts, `nix/package.nix`, `nix/hm-module.nix`. Sessions surviving logout on lingering or non-systemd hosts. #### 4. The daemon reports its own supervisor; `doctor` only relays it The 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.service` inherits that unit's cgroup *and* its `INVOCATION_ID`, so either would misreport the very placement this change exists to diagnose. `serve` computes the unit name its own socket path maps to (`felis-daemon-<fnv1a64>` — the same function the launcher uses, in `felis-transport`) and reads the last component of its `0::` cgroup path (`systemd_cgroup_path()`, §0: unified `0::` else legacy `name=systemd`): equal (with `.service`) → `Supervisor::Systemd { unit }`; anything else → `Supervisor::None`. The unit prefix is a `DaemonLauncher`/`serve` parameter with one production value; a test that needs a private prefix passes the same one to both sides. `INVOCATION_ID` is not consulted. The fork branch nonetheless strips `INVOCATION_ID` and `JOURNAL_STREAM` alongside `NOTIFY_SOCKET` (a forked daemon is not that unit's process; the journal stream fd is not inherited anyway). `OpsStatusReply` gains `optional string supervisor_unit` at **minor 11** (`PROTOCOL_MINOR` 10 → 11; a `MINOR_LEDGER` entry and the `ipc.md` ledger row; the field joins `OpsStatusReply`'s `MinorGated` metadata so `FrameWriter` refuses it below 11, and the daemon emits it only when the connection's effective 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 status` renders `supervisor: 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`'s `daemon` row today reads `Welcome` only; it gains an `Ops::Status` round-trip when the effective minor is ≥ 5 (the Status minor) and appends 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's `Welcome`-derived status and appends `supervisor: unknown (<error>)` to the detail — the connectivity finding stands, the supervisor finding is reported as unobtainable; the row never becomes `fail` for that alone, so the exit code is unchanged (`0`, or `1` only if some other check failed; never `2`, `doctor`'s standing rule). The same over `--host`. No `systemctl` in `doctor`; `--host` works 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-empty` and the last session's exit, the next spawning dial hands the daemon to the manager. Nothing is installed, so nothing is uninstalled; a downgrade forks again. #### 6. Verification - Unit tests (`felis-transport`, stub `systemd-run`/`systemctl`/`loginctl` on `PATH`, injectable cgroup line, a listening stub daemon or none): cgroup under `[email protected]` (service, scope, `init.scope`) → `systemd-run` invoked; session scope + linger `no` → forked without invoking it; session scope + linger `yes` → invoked; `0::/` (namespace) + linger `no` → forked; `systemd-run` exit 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-run` absent → `Forked`; session scope + linger `no` → `Skipped(no_linger)`; a fork that fails to spawn → `Err` (the existing typed error); a legacy `name=systemd` cgroup line under `[email protected]` → asks; argv carries `--socket <anchored path>`, every `-p`, `--expand-environment=no` under a stubbed ≥ 254 `--version` and `$$` doubling under a stubbed 253; a relative socket is anchored in argv, unit hash and dial alike; the fork child has no `NOTIFY_SOCKET`; two unit names for two sockets; the `warn` line lands in the client log exactly once per `Failed` with tracing uninitialized (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`. - Daemon: `READY=1` written to a test `UnixDatagram` after the bind, not on bind failure, nothing without the variable, startup error on a dead endpoint; `Supervisor` from an injected cgroup line: leaf = own unit name → `Systemd` (from a `0::` line and from a legacy `name=systemd` line alike), leaf `niri.service` with `INVOCATION_ID` set → `None`, leaf = another socket's unit → `None`; `doctor` with a stub daemon that answers `Welcome` then drops → row keeps its status, detail says `supervisor: unknown`, exit `0`; `OpsStatusReply.supervisor_unit` round-trips at minor 11 and is refused by `FrameWriter` at 10 (the `MinorGated` table test), the daemon omits it on a minor-10 connection; the denylist test table gains `NOTIFY_SOCKET`. - Integration (opt-in `FELIS_SYSTEMD_TESTS=1`, a reachable user manager; recorded in `docs/reference/testing.md` beside the other opt-in switches): the production prefix with a temp socket (its hash is the isolation; the path contains `$`), so the daemon's exact-leaf detection sees its own name; launch → `/proc/<pid>/cgroup` under `app.slice/<unit>`, `ActiveState=active`, `OOMPolicy=continue`, `RefuseManualStop=yes` read back, `daemon status` says `supervisor: systemd (<unit>)`; a launcher run inside a `systemd-run --user --scope` wrapper (so it carries `INVOCATION_ID` and a manager cgroup) with `systemd-run` shadowed by a failing stub → the forked daemon reports `supervisor: none`; a managed daemon killed after `READY=1` while a second, unmanaged daemon binds the path → the connected daemon reports `none` and the launcher's log says `ManagerStarted` (the two are allowed to disagree; the daemon is the truth); `systemctl --user stop <unit>` refused, daemon still serving; `felis daemon stop --force` → unit `not-found`; two launchers concurrently on the cold socket → one daemon PID, one unit, both connected; an unmanaged daemon already on the socket → both launchers connect to it, no unit; a `<program>` exiting before `READY=1` → fallback fork observed (`supervisor: none`), unit gone; a symlinked `<program>` → `Managed`. Cleanup: `daemon stop --force` on every daemon. - Relay: the launcher through `run_stdio_relay` with the stubs — session scope + linger off forks (the remote-attach host keeps today's behavior), linger on hands off. - Existing spawn tests stay green. ### Alternatives rejected (recorded in `session-lifecycle.md` beside "Daemon updates") - **Socket activation**, **shipped unit file** (Decision). *Revisit if* felis adopts idle-daemon exit and redesigns the auto-spawn table so every dial may start a daemon (`plan-261-rev6`). - **Never fork past a present manager** (revs 10–17): correct in principle, unbounded in practice — 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 --scope` around the fork**: a scope has no main process, cannot report start failure, and a `nohup`'d grandchild holds the fixed name for good. - **Daemon relocates itself via D-Bus `StartTransientUnit`**: needs a D-Bus client in the daemon; non-goals say felis never links one. `systemd-run` is that client, present wherever a manager is. - **`doctor` classifies the unit with `systemctl`**: a probe of a different kind from the rest of `doctor`, reconstructing a launch-time event after the fact, and blind over `--host`; the daemon's self-report (§4) answers the same question from the authority. - **Document only.** The failure ends the whole graphical session. ### Doc cascade (implementing PR) - `docs/reference/spec.md`: a new REQ — on Linux, a spawning dial whose process runs under the systemd user manager, or whose user lingers, first asks the manager to run the daemon as a transient service in `app.slice` with `OOMPolicy=continue` from the launcher's own binary on the launcher's socket, and forks as elsewhere when that does not yield a reachable daemon; the daemon reports its supervisor in `StatusReply`. Sourced to `cli.md` "Auto-spawning" / "Daemon status" and `session-lifecycle.md`. - `docs/reference/cli.md`: "Auto-spawning" (the paragraph above; `--socket` row: a relative path is anchored to the caller's cwd), "Daemon status" (`supervisor` row and JSON key), "Doctor" (daemon row detail), "Log files" (the hand-off `warn` line). - `docs/reference/ipc.md` `Status`/`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-transport` owns the launcher); `docs/reference/terminal-identity.md` (denylist: `NOTIFY_SOCKET`). - `docs/explanation/architecture/session-lifecycle.md`: "Daemon updates" decision record (why the manager, why transient, why best-effort, `OOMPolicy=continue`, `Type=notify`, `RefuseManualStop`, `KillMode`, the lifetime rule, "Revisit if"); "Crash recovery" (control-group kill under the manager); "Working directory" (managed daemon cwd = `~`); "Logs tee into a file…" (journal). - `docs/explanation/architecture/control-surfaces.md` ≈237–240 (launcher in transport, policy in client-core) and ≈580; `overview.md` "Lifecycle" step 1 and ≈273–280; `ipc.md` (explanation) "The two 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|restart` refused by design; drain with `felis daemon stop`), `install.md` (nothing to install; the unit's name). - Source/test comments and scripts that assume the fork: `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.sh` and `.agents/skills/producer-traffic-debug/scripts/drive.sh` start `felis-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 the daemon itself, above); `docs/reference/control-surfaces.md` ≈36 (`daemon status` maps to accounting *and* supervisor reporting). - `CHANGELOG.md`; `skills/felis/SKILL.md` (`daemon status` row; ≈211 relative `--socket`; "on Linux the daemon 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) 1. Daemon: `READY=1` after the bind; `NOTIFY_SOCKET` in `ENV_DENYLIST`; `env_remove("NOTIFY_SOCKET")` in the relay's fork. 2. `felis-transport`: relative-socket anchoring in `SocketPath`; `RetryPolicy::MANAGED_BOOT`. 3. `felis-client-core`: the hand-off in `connect_or_spawn_daemon` (cgroup predicate, `systemd-run` with captured stdio, the active/activating wait, fork fallback with `env_remove`), unit tests. 4. Integration test behind `FELIS_SYSTEMD_TESTS=1`. 5. Docs cascade for the shipped core, spec REQ, CHANGELOG; the deferred list stays in the decision record as "Revisit if".
Sign in to join this conversation.
No description provided.