daemon: hand an auto-spawned daemon to the systemd user manager #271

Merged
natsukium merged 6 commits from issue-261 into main 2026-09-13 11:13:53 +09:00
Owner

Closes #261.

Implements the plan reviewed in #261 (comment) (rev 23, "Implementation scope" only).

What changes

  • felis-daemon: sends READY=1 on $NOTIFY_SOCKET right after the socket is bound; NOTIFY_SOCKET joins ENV_DENYLIST so PTY children never inherit it; the relay's fork scrubs it too.
  • felis-transport: a relative --socket / $FELIS_SOCKET is anchored to the current directory at resolution, so the path handed to the manager is absolute. Adds RetryPolicy::MANAGED_BOOT (15 s).
  • felis-client-core: on Linux, when the launcher's own cgroup is under user@<uid>.service/, connect_or_spawn_daemon first asks the systemd user manager to run the sibling felis-daemon as a transient notify service (felis-daemon-<fnv1a64(socket)>, app.slice, OOMPolicy=continue, RefuseManualStop=yes, TimeoutStartSec=15s, --expand-environment=no on systemd ≥ 254). Helper calls are bounded (3 s probes, 20 s start) and kill_on_drop. A non-zero exit spends exactly one systemctl --user show -p ActiveState query: active/activating means a concurrent launcher won, so the loser waits for the socket instead of forking. Everything else falls back to today's detached fork (with NOTIFY_SOCKET removed) and one WARN line in client.log. Non-Linux, non-systemd, and session-scope launchers (SSH, tty) are unchanged.
  • felis-cli: opt-in integration test (FELIS_SYSTEMD_TESTS=1) against a live user manager: hand-off placement read back from the unit, sessions spawn --format json output stays clean, concurrent launchers converge on one daemon; RAII cleanup of the transient unit.
  • docs: cli.md Auto-spawning / --socket / Log files, decision record in session-lifecycle.md (rationale, rejected alternatives, deferred list as Revisit-if), non-goals.md platform shims, spec.md REQ-009a, testing.md, security-model.md, skills/felis/SKILL.md, CHANGELOG.

Deferred per the plan (recorded as Revisit-if triggers): relay hand-off, daemon status supervisor field, doctor detail, linger, cgroup v1.

Verification

  • just check gates green on this host (fmt, clippy -D warnings, nextest 3523 passed / 16 skipped, deny, prose-check on the range, skill-check, proto-compat).
  • FELIS_SYSTEMD_TESTS=1 cargo nextest run -p felis-cli --test cli_systemd_handoff --test-threads=1: 2 passed on systemd 261 (launcher cgroup …/[email protected]/session.slice/niri.service); no transient units left behind.
  • Stub-based Linux unit tests cover the cgroup predicate, unit naming (published FNV-1a vector), argv / $ doubling / version gate, every ActiveState branch incl. the single-query invariant, the bounded-helper kill, and stdio isolation (test binary re-executed as a subprocess with noisy stubs).
  • Reviewed by pi/sol: round 1 BLOCKED (unbounded helper calls) → fixed; round 2 PASS; the four round-2 non-blocking findings are also addressed.
Closes #261. Implements the plan reviewed in https://git.natsukium.com/natsukium/felis/issues/261#issuecomment-2622 (rev 23, "Implementation scope" only). ## What changes - **felis-daemon**: sends `READY=1` on `$NOTIFY_SOCKET` right after the socket is bound; `NOTIFY_SOCKET` joins `ENV_DENYLIST` so PTY children never inherit it; the relay's fork scrubs it too. - **felis-transport**: a relative `--socket` / `$FELIS_SOCKET` is anchored to the current directory at resolution, so the path handed to the manager is absolute. Adds `RetryPolicy::MANAGED_BOOT` (15 s). - **felis-client-core**: on Linux, when the launcher's own cgroup is under `user@<uid>.service/`, `connect_or_spawn_daemon` first asks the systemd user manager to run the sibling `felis-daemon` as a transient notify service (`felis-daemon-<fnv1a64(socket)>`, `app.slice`, `OOMPolicy=continue`, `RefuseManualStop=yes`, `TimeoutStartSec=15s`, `--expand-environment=no` on systemd ≥ 254). Helper calls are bounded (3 s probes, 20 s start) and `kill_on_drop`. A non-zero exit spends exactly one `systemctl --user show -p ActiveState` query: `active`/`activating` means a concurrent launcher won, so the loser waits for the socket instead of forking. Everything else falls back to today's detached fork (with `NOTIFY_SOCKET` removed) and one `WARN` line in `client.log`. Non-Linux, non-systemd, and session-scope launchers (SSH, tty) are unchanged. - **felis-cli**: opt-in integration test (`FELIS_SYSTEMD_TESTS=1`) against a live user manager: hand-off placement read back from the unit, `sessions spawn --format json` output stays clean, concurrent launchers converge on one daemon; RAII cleanup of the transient unit. - **docs**: `cli.md` Auto-spawning / `--socket` / Log files, decision record in `session-lifecycle.md` (rationale, rejected alternatives, deferred list as Revisit-if), `non-goals.md` platform shims, `spec.md` REQ-009a, `testing.md`, `security-model.md`, `skills/felis/SKILL.md`, CHANGELOG. Deferred per the plan (recorded as Revisit-if triggers): relay hand-off, `daemon status` supervisor field, doctor detail, linger, cgroup v1. ## Verification - `just check` gates green on this host (fmt, clippy `-D warnings`, nextest 3523 passed / 16 skipped, deny, prose-check on the range, skill-check, proto-compat). - `FELIS_SYSTEMD_TESTS=1 cargo nextest run -p felis-cli --test cli_systemd_handoff --test-threads=1`: 2 passed on systemd 261 (launcher cgroup `…/[email protected]/session.slice/niri.service`); no transient units left behind. - Stub-based Linux unit tests cover the cgroup predicate, unit naming (published FNV-1a vector), argv / `$` doubling / version gate, every `ActiveState` branch incl. the single-query invariant, the bounded-helper kill, and stdio isolation (test binary re-executed as a subprocess with noisy stubs). - Reviewed by pi/sol: round 1 BLOCKED (unbounded helper calls) → fixed; round 2 PASS; the four round-2 non-blocking findings are also addressed.
scripts/prose_check.py previously classified crates/felis-protocol/src/generated
as Rust, which failed comment-length checks on prost-generated code that is
wiped and regenerated by buf generate. It also failed to recognize .proto
files located under crates/felis-protocol/proto/.

Update classify() to ignore src/generated and recognize all .proto files,
default --all to git ls-files when no files are passed, and add a
prose-check-all recipe to justfile.
docs/explanation/architecture/session-lifecycle.md and AGENTS.md
carried em-dashes that violate the repository prose norms. Replace them
with colons, semicolons, or parentheses.
Markdown files under .agents/skills/ carried em-dashes and history
narration that violated repository prose norms. Replace them with
colons, semicolons, and parentheses, and rephrase history references
in the present tense.
crates/felis-protocol/proto/felis.proto carried em-dashes and comment
blocks longer than 5 lines that violated prose norms. Replace em-dashes
with colons, semicolons, and parentheses, trim comment blocks to <= 5
lines keeping invariants and why-nots, and regenerate prost wire types
with buf generate.
Prune comments longer than 5 lines to concise invariants and why-nots,
and replace em-dashes with colons or parentheses per repository prose
norms.
Prune comments longer than 5 lines to concise invariants and why-nots,
and replace em-dashes with colons or parentheses per repository prose
norms.
Prune comments longer than 5 lines to concise invariants and why-nots,
and replace em-dashes with colons or parentheses per repository prose
norms.
Prune comments longer than 5 lines to concise invariants and why-nots,
and replace em-dashes with colons or parentheses per repository prose
norms.
Prune comments longer than 5 lines to concise invariants and why-nots,
condense module-level doc essays, and replace em-dashes with colons or
parentheses per repository prose norms.
Prune comments longer than 5 lines to concise invariants and why-nots,
and replace em-dashes with colons or parentheses per repository prose
norms.
Prune comments longer than 5 lines to concise invariants and why-nots,
and replace em-dashes with colons or parentheses per repository prose
norms.
Prune comments longer than 5 lines to concise invariants and why-nots,
and replace em-dashes with colons or parentheses per repository prose
norms.
Prune comments longer than 5 lines to concise invariants and why-nots,
and replace em-dashes with colons, semicolons, or parentheses per
repository prose norms.
Separate the doc comment from the implementation comment with a blank
line to avoid exceeding comment block length limits.
Prune comments longer than 5 lines to concise invariants and why-nots,
and replace em-dashes with colons, semicolons, or parentheses per
repository prose norms.
Prune comments longer than 5 lines to concise invariants and why-nots,
and replace em-dashes with colons, semicolons, or parentheses per
repository prose norms.
Run treefmt across modified files to align wrapping and trailing
whitespace with repository formatter settings.
Shortening the module doc comment removed the list-like syntax that
originally triggered clippy::doc_lazy_continuation, leaving the lint
expectation unfulfilled under clippy -D warnings.
Doc comments on config structs serve as descriptions in the generated
JSON schema; regenerate felis-config.schema.json to match the revised
comments.
felis-cli: update help page snapshots after doc comment revisions
All checks were successful
bench / Criterion regression gate (pull_request) Successful in 2m43s
darwin / detect relevant changes (pull_request) Successful in 5s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 2m0s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m29s
pr / wire schema is compatible with the base (pull_request) Successful in 8s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 56s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 4s
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 50s
pr / nix flake check (pull_request) Successful in 42s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
windows / cargo nextest (Windows) (pull_request) Successful in 30m11s
windows / frontend smoke (Windows) (pull_request) Successful in 6m2s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
ce1e891d95
Updating doc comments on CLI flags and subcommands changed clap's
generated --help output; accept the updated help page snapshot.
An auto-spawned daemon inherits the cgroup of whichever client first
found a cold socket, so a compositor unit's OOMPolicy=stop ends the
whole graphical session when one shell child is OOM-killed. Handing the
daemon to the systemd user manager instead needs the manager to know
when the daemon is up, and Type=notify is the only start type that
reports a bind rather than a fork.

A failed send fails startup: a daemon the manager never hears from is
torn down at the start timeout anyway, and serving a socket in that
window would strand the sessions created into it.

NOTIFY_SOCKET joins the denylist, and the relay's fork clears it, for
the mirror reason: a child holding the endpoint can report its parent's
unit ready, or stopped, in the daemon's place.
A `--socket` or FELIS_SOCKET path was carried as typed, which is fine
while the only process that resolves it is the one that typed it. The
systemd hand-off gives the path to the user manager, whose working
directory is the user's home, so a relative path would name a different
socket there than the client dials, and the unit name derived from it
would differ per caller directory.

Lexical, never canonicalized: the daemon stamps the path as
FELIS_SOCKET, and a resolved symlink would hand children a name the
user never typed.
cgroup membership is inherited at fork and survives reparenting, so the
daemon and every PTY child live for the daemon's lifetime in the cgroup
of whichever client first found a cold socket. Under the systemd user
manager that cgroup belongs to a unit: a compositor's OOMPolicy=stop
turns an OOM kill of one shell child into the end of the graphical
session, and which unit a daemon lands in is decided by a race.

The launcher now asks the manager to run its own felis-daemon binary on
its own socket as a transient service in app.slice with
OOMPolicy=continue. Best effort: a hand-off that fails for any reason
forks exactly as every felis release so far has, leaving one warning
line in the client log, so no new failure mode is introduced on hosts
where systemd-run, the manager, or D-Bus is not what the launcher
assumed. Only the positive case is guaranteed, and Type=notify is what
guarantees it: exit 0 means that binary bound that socket.

A non-zero exit is checked against the unit's ActiveState before
forking. Without it two windows opened together would have the loser
fork a daemon into its own cgroup, which is the placement this change
exists to avoid.

Both stdio streams are captured: systemd-run's progress line on the
stdout of `sessions spawn --format json` would break the one-result
output contract.

Every call to the manager is bounded and its child killed on drop: a
wedged user bus would otherwise hold a cold window open with no
deadline of its own, since TimeoutStartSec bounds only a service the
manager did manage to create. A breached bound is classified as a
failed attempt rather than an absent manager, so it still spends its
one ActiveState query before falling back.
The hand-off's unit tests reach only its pure parts: whether a launcher
sits under the user manager, the unit name, the argv. What the change
is actually for — the daemon landing in app.slice with
OOMPolicy=continue, two launchers on one cold socket ending on one
daemon, and systemd-run's output staying off a JSON verb's stdout —
needs a live manager, which CI runners do not have. Hence opt-in
through FELIS_SYSTEMD_TESTS=1, with a unique socket path per test so
the units never collide with the developer's own daemon.
docs: record how an auto-spawned daemon reaches its cgroup
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 5s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m57s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m41s
pr / wire schema is compatible with the base (pull_request) Successful in 14s
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
bench / Criterion regression gate (pull_request) Failing after 2m40s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 50s
pr / nix flake check (pull_request) Successful in 45s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m29s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 5s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Failing after 17s
windows / cargo nextest (Windows) (pull_request) Has been cancelled
cef0d815d5
The auto-spawn contract is unchanged, but where a cold daemon lands now
depends on the launcher's own placement, which every page that claimed
an unconditional fork got wrong. The decision record carries the
rationale, the rejected alternatives (socket activation, a shipped unit
file, refusing to fork past a present manager), and the deferred list as
its revisit triggers.

The `systemctl --user stop` refusal is the change a user is most likely
to meet first, so it appears wherever a stop is documented: the CLI
reference, the update guide, and the shipped skill.
natsukium force-pushed issue-261 from cef0d815d5
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 5s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m57s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m41s
pr / wire schema is compatible with the base (pull_request) Successful in 14s
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
bench / Criterion regression gate (pull_request) Failing after 2m40s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 50s
pr / nix flake check (pull_request) Successful in 45s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m29s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 5s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Failing after 17s
windows / cargo nextest (Windows) (pull_request) Has been cancelled
to b5316411b4
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 2m36s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 1m0s
pr / nix flake check (pull_request) Successful in 11s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m35s
pr / wire schema is compatible with the base (pull_request) Successful in 16s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m22s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Failing after 17s
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m53s
windows / detect relevant changes (pull_request) Successful in 4s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Failing after 7m14s
windows / frontend smoke (Windows) (pull_request) Has been skipped
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
2026-09-13 02:10:03 +09:00
Compare
windows: fix clippy
All checks were successful
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 2m10s
darwin / detect relevant changes (pull_request) Successful in 3s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 51s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m4s
pr / nix flake check (pull_request) Successful in 31s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m43s
pr / wire schema is compatible with the base (pull_request) Successful in 13s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 52s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 3s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 3m34s
windows / cargo clippy (Windows cross) (pull_request) Successful in 13s
windows / frontend smoke (Windows) (pull_request) Successful in 1m38s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
63de43fa60
natsukium deleted branch issue-261 2026-09-13 11:13:53 +09:00
Sign in to join this conversation.
No description provided.