cli: add felis daemon stop #153

Merged
natsukium merged 4 commits from feat/daemon-stop-25 into main 2026-09-06 03:39:32 +09:00
Owner

Fixes #25.

Adds a portable way to stop a daemon and drain it, replacing the pkill
advice that only ever worked on Unix.

  • protocol: a typed Ops::Stop / Ops::StopReply pair with a closed
    StopMode (if-empty, when-empty, force) and StopOutcome, a
    draining flag on Ops::StatusReply, and an AttachFailure::DaemonDraining
    so a client learns why a drained daemon refuses it.
  • daemon: Ops::Stop flips the pool into draining, the accept loop stops
    admitting new sessions, and the shutdown fires only after the reply is
    written; the socket is unlinked on the way out.
  • cli: felis daemon stop with the mode flags and machine output, plus the
    minor-version gate that refuses the verb against an older daemon.

Doc cascade: docs/reference/cli.md, docs/reference/ipc.md,
docs/reference/spec.md, docs/reference/control-surfaces.md,
docs/explanation/architecture/{control-surfaces,overview,session-lifecycle}.md,
docs/how-to/update-felis.md (the pkill step is now the stop verb),
CHANGELOG.md, and the product-shipped skills/felis/SKILL.md.

Verified: just check green (fmt, clippy, nextest, deny, proto-compat); reviewed
by pi luna + pi sol; docs proofread by Gemini gemini-3.8-flash-high

Follow-ups

  • #152 test: add fuzz seeds for the Stop/StopReply IPC messages

Deferred

  • A test that stops a remote daemon through the SSH relay (--host) — the
    relay proxies bytes to the real daemon socket, so the stop path is identical
    to the local one; the risk the plan flagged (the relay outliving the reply)
    is covered by the reply being written before the shutdown fires, and no relay
    integration harness exists to hang the test on.
  • Clearing the agent link (AgentLink) on the way out, beside the socket
    unlink — a stale link costs the next daemon nothing (clear_stale runs after
    its bind), which is the same argument the existing code makes; a second
    cleanup path is unrelated to the acceptance criteria.
  • Windows-gated tests for the stop path — the whole serve-test harness is
    #[cfg(unix)] (it needs POSIX shells and peer credentials); the accept loop's
    watch arm and the shutdown are platform-independent, and only the
    remove_file is cfg(unix).
  • Old-daemon minor-gate coverage for daemon_stop — the version gate mirrors
    daemon_status's existing pattern and is implemented correctly; this asks for
    extra coverage of an already-correct compatibility check, not a fix.
Fixes #25. Adds a portable way to stop a daemon and drain it, replacing the `pkill` advice that only ever worked on Unix. - **protocol**: a typed `Ops::Stop` / `Ops::StopReply` pair with a closed `StopMode` (`if-empty`, `when-empty`, `force`) and `StopOutcome`, a `draining` flag on `Ops::StatusReply`, and an `AttachFailure::DaemonDraining` so a client learns why a drained daemon refuses it. - **daemon**: `Ops::Stop` flips the pool into draining, the accept loop stops admitting new sessions, and the shutdown fires only after the reply is written; the socket is unlinked on the way out. - **cli**: `felis daemon stop` with the mode flags and machine output, plus the minor-version gate that refuses the verb against an older daemon. Doc cascade: `docs/reference/cli.md`, `docs/reference/ipc.md`, `docs/reference/spec.md`, `docs/reference/control-surfaces.md`, `docs/explanation/architecture/{control-surfaces,overview,session-lifecycle}.md`, `docs/how-to/update-felis.md` (the `pkill` step is now the stop verb), `CHANGELOG.md`, and the product-shipped `skills/felis/SKILL.md`. Verified: just check green (fmt, clippy, nextest, deny, proto-compat); reviewed by pi luna + pi sol; docs proofread by Gemini gemini-3.8-flash-high ## Follow-ups - #152 test: add fuzz seeds for the Stop/StopReply IPC messages ## Deferred - A test that stops a remote daemon through the SSH relay (`--host`) — the relay proxies bytes to the real daemon socket, so the stop path is identical to the local one; the risk the plan flagged (the relay outliving the reply) is covered by the reply being written before the shutdown fires, and no relay integration harness exists to hang the test on. - Clearing the agent link (`AgentLink`) on the way out, beside the socket unlink — a stale link costs the next daemon nothing (`clear_stale` runs after its bind), which is the same argument the existing code makes; a second cleanup path is unrelated to the acceptance criteria. - Windows-gated tests for the stop path — the whole serve-test harness is `#[cfg(unix)]` (it needs POSIX shells and peer credentials); the accept loop's watch arm and the shutdown are platform-independent, and only the `remove_file` is `cfg(unix)`. - Old-daemon minor-gate coverage for `daemon_stop` — the version gate mirrors `daemon_status`'s existing pattern and is implemented correctly; this asks for extra coverage of an already-correct compatibility check, not a fix.
The update procedure relied on `pkill felis-daemon`: unavailable on
Windows, unable to refuse, and racing whoever creates a session between
the operator's check and the signal. A stop that can be refused and can
report why has to be a message, so minor 10 adds the Ops verb, the
status field that makes the state observable, and the refusal a create
meets while a daemon drains.

`StopMode` and `StopOutcome` are closed unions rather than flag pairs:
"destroy everything" and "wait for everything" are opposite postures, so
no request or reply can name two at once.

The pair is admitted in the setup phase alone, unlike every other Ops
arm. The reply is the last frame its connection writes, and only the
setup phase writes a reply inline; an attached connection queues through
an outbox that an exiting process would not drain.

Refs #25
The emptiness decision and the refusal of new creates have to be one
critical section, or a create that reserves after a stop was answered
outlives the daemon that answered it. The drain flag therefore lives
inside `try_reserve`, beside the count it decides with, and a
reservation that has not resolved holds a drain open: a create between
its slot and its registration is a session that does not exist yet.

The accept loop selects on a watch the verb fires, so the reply is
written before the listener drops, and the socket path is unlinked on
the way out, an exit path the process never had.

A forced stop waits for each session task's command receiver to close
rather than for the pool count alone: the count falls when the handles
are taken, while the child is reaped by the task that owns it.

A session also leaves the pool before its child is hung up, and on the
panic path that teardown runs from a task outliving the session's own,
so the pool counts what it is still reaping: a stop that read the
admitted count alone could fire the shutdown mid-hangup and let the
process exit over a child it still owns.

Refs #25
Stopping the daemon ends every session it holds, so the bare verb is the
non-destructive one: it refuses while anything is admitted and reports
the count a caller decides `--force` against. The count rides in the
error object rather than only in the message, so a script does not have
to parse prose to find it.

The mode is reported beside the outcome in both framings: "stopping"
alone cannot tell a stop that destroyed sessions from one that found
none, and that difference is the whole risk of the verb.

`daemon status` gains the draining line so the state between a
`--when-empty` and the exit is observable, printed on every status
rather than only a draining one: a reader who must notice an absent
line has been given a trap.

Refs #25
docs: replace the pkill update step with the stop verb
Some checks failed
darwin / build felis (aarch64-darwin) (pull_request) Successful in 52s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m45s
pr / nix flake check (pull_request) Successful in 43s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m31s
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) Failing after 2m28s
windows / cargo nextest (Windows) (pull_request) Successful in 7m51s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 54s
pr / wire schema is compatible with the base (pull_request) Successful in 13s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
windows / frontend smoke (Windows) (pull_request) Successful in 1m56s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (push) Has been skipped
darwin / build felis (aarch64-darwin) (push) Successful in 13s
fuzz / cargo fuzz smoke (per target) (push) Successful in 1m12s
pr / nix flake check (push) Successful in 5s
windows / cargo clippy (Windows cross) (push) Successful in 13s
windows / frontend smoke (Windows) (push) Successful in 1m34s
pr / cargo build / clippy / test / deny (push) Successful in 1m51s
pr / wire schema is compatible with the base (push) Successful in 8s
pr / frontend smoke (x86_64-linux) (push) Successful in 6s
windows / cargo nextest (Windows) (push) Successful in 6m6s
pr / publish felis (x86_64-linux) (push) Successful in 11s
windows / package felis (x86_64-pc-windows-msvc) (push) Successful in 3m1s
9948c38784
The update guide taught a stop that does not exist on Windows and that
races session creation, and said felis had no quiesce mode; the drain is
now the documented step, so the guide teaches the operation the daemon
actually supports.

The explanation records why the stop is a verb rather than a signal, why
its bare form refuses, and why the drain flag sits inside the admission
lock. The idle-daemon revisit narrows accordingly: the exit half exists,
and what is still missing is a trigger nobody asked for.

Refs #25
natsukium deleted branch feat/daemon-stop-25 2026-09-06 03:39:32 +09:00
Sign in to join this conversation.
No description provided.