[v0.1/P1] Add portable daemon stop and drain operations #25
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#25
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Parent: #12 (P1.6). Related to #10.
Why
The update guide relies on
pkill felis-daemon, which is Unix-only, races new session creation, and can destroy every session accidentally. The daemon needs a portable typed control path.Scope
felis daemon stopover the normal IPC carrier.--forcefor explicit destruction and stop.--when-emptyto enter draining state, reject new creates, and exit after the last session.Acceptance criteria
pkill.skills/felis, and changelog are updated.Triage plan (2026-09-03)
Source-grounded triage against
mainat69076d42, reviewed through seven rounds of an independent reviewer (pisol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12. Where a "Review amendments" section below conflicts with an earlier section, the amendment is the decision.Claim check
Accurate.
pkill.docs/how-to/update-felis.md:58-61(pkill felis-daemon) and:92(ssh user@remote pkill felis-daemon). The race is acknowledged in the same guide,:48-52: "Nothing stops you starting a new session between the check and the restart: felis has no quiesce mode".CHANGELOG.md:493,:903teach the same.crates/felis-daemon/src/main.rs:31-34: "Runs until the process is killed: there is no signal handling, and an abrupt death closes the PTY masters, which SIGHUPs the children". The daemon builds and is CI-gated on Windows (memory: 2026-09-03 Windows runtime gate green;felis-ptyConPTY backend perCLAUDE.md), where there is nopkilland no SIGTERM.crates/felis-cli/src/cli_daemon.rs:20-33:DaemonOphas onlyStatus.OpsMsg(felis.proto:1143-1160) has no stop arm.docs/explanation/architecture/session-lifecycle.md:471-475lists idle-daemon exit as a revisit item ("the daemon process has no idle self-exit").crates/felis-daemon/src/pool.rs:483-490try_reserve(max)checkssessions.len() + reservedunder the pool mutex, andserve.rs:485-489calls it underpool.lock().await. Adrainingflag checked insidetry_reservemakes "refuse new creates" and "count is zero" one critical section.crates/felis-cli/src/main.rs:348-448:--host,--ssh-arg,--socketresolve throughconn::resolvefor every verb includingdaemon status(:434), sofelis --host x daemon stopneeds no new plumbing.serve.rs:222-246serve_unix_with_factoryruns anacceptloop forever;:241notes the socket file has "no shutdown hook to clear it from".Verdict
accept. Principle 1 test: is there a dedicated tool that does this as well or better?
pkill/killdoes it worse (non-portable, races creation, cannot refuse, cannot drain) and does not exist on one supported build target; the OS's process tools cannot express "stop when empty" for a process that owns other processes. The verb is typed data over the existing IPC surface, which is the sanctioned extension surface. The default-refuse posture matches the read-side "no silent resurrection" stance already recorded for the CLI.One addition to scope:
daemon statusshould reportdrainingso the state is observable (acceptance criterion 3); coordinate the field with #26.Approach
Protocol
OpsMsgarms:OpsStop { StopMode mode }whereStopModeis a three-arm oneof (IfEmpty {}default,Force {},WhenEmpty {}) andOpsStopReply { oneof outcome { Stopping {}; Refused { uint32 sessions }; Draining { uint32 sessions } } }. A secondStopwhile draining answersDrainingagain (idempotent);Forcewhile draining escalates.AttachFailure::DAEMON_DRAININGfor aCreaterefused while draining (and theOps::Spawnrefusal after #20); older-peer rule followssession_limit_refusal(serve.rs:1022-1028): sendSPAWN_FAILEDwith the reason indetail.OpsStatusReply.draining: bool(with #26).Daemon
pool.rs:draining: boolonSessionPool;try_reservereturnsNonewhen set (:483-490), and the caller distinguishes "full" from "draining" by reading the flag under the same lock (theok_or_else(|| guard.admitted())atserve.rs:488becomes a small enum).serve.rs:DaemonCapsor the serve entry gains ashutdown: tokio::sync::watch::Sender<bool>created inserve_unix_with_factory; the accept loop (:246) selects on it and, on fire, drops the listener, unlinks the socket path (closing the:241gap), and returns.route_opshandlesStop:IfEmpty→ under the pool lock, iflen() + reserved == 0setdrainingand fire, elseRefused { sessions };Force→ destroy every session via the existingOpsDestroypath (SessionCmddestroy), then fire;WhenEmpty→ setdraining, and the reaper (post-exit grace path insession_task/poolremove,pool.rs:514) fires shutdown when the pool becomes empty. The reply is written before the shutdown fires so the CLI reads it; the process exits after the accept loop returns and the session tasks are joined (force) or already gone (when-empty).cfg(windows)named-pipe variant infelis-transport; the samewatcharm applies; no unlink.main.rsdoc comment (:31-34) updated: "no signal handling" stays true; the typed stop is the lifecycle path.Client core / CLI
connector.rs:daemon_stop(mode) -> StopOutcomeas aCorrelatedRequest; gate on the effective minor asdaemon_statusdoes (connector.rs:84message pattern).cli_daemon.rs:DaemonOp::Stop { force: bool, when_empty: bool (conflicts_with force), output: PointFormat }; exit0stopping/draining,1refused (typedrefusedkind withsessionsin the object),2unreachable/too old. Human output states the mode taken and the count.Dial::Ops(conn.rs:44), never autospawn.cli_completions.rs,cli_mangen.rs); regenerate whateverjustrecipe snapshots them.Docs cascade
docs/reference/ipc.mdOps section + ledger/2.0 base + theAttachFailurelist (:740+);docs/reference/cli.mdnew "Daemon stop" subsection under "Daemon status" (:253) and the verb table (:76);docs/reference/control-surfaces.md:33row;docs/reference/spec.mdnew REQ beside REQ-1102 (:262);docs/explanation/architecture/control-surfaces.md:132-160("Diagnostic verbs" → daemon verbs; record why default-refuse, why--forceis a flag not a verb, why no signal handler was added instead: not portable, cannot refuse);docs/explanation/architecture/session-lifecycle.md"Daemon updates" and:460-478(draining is the first half of idle-daemon exit; keep that revisit);docs/how-to/update-felis.md:41-61,:88-95(replace bothpkillblocks; the drain becomesfelis daemon stop --when-empty, then the next launch autospawns the new binary);skills/felis/SKILL.md:213-237(new section or extend status);CHANGELOG.md(CLI + wire + update procedure).Tests
serve/tests.rsbeside:4432: stop with sessions →Refused { sessions: n }and the pool untouched;--force→ pool empty andserve_unix_with_factoryreturns;--when-empty→ a subsequentCreategetsDAEMON_DRAINING, and after the last session's post-exit grace the serve future completes and the socket path is gone.IfEmptystop against an empty pool; assert exactly one of {all creates succeed and stop isRefused, stop isStoppingand every create isDAEMON_DRAINING}; never a session registered afterStopping.--force --when-emptyis a clap conflict (exit 2).Dependencies
drainingbelongs in that object, not the pool. Tracker step 8 (after #14, #23) holds.drainingfield inStatusReply.Ops::Spawnmust consult the same flag; land #25 after #20 or add the check in both.Risk/effort
M. Main risk: exit ordering on the daemon (reply flushed before the listener drops; force-destroy waiting on PTY children that ignore SIGHUP; a Windows named-pipe listener that does not wake on the watch). Second: the
--hostpath, where the relay (relay.rs) must survive long enough to carry the reply.Labels
Keep
priority/P1,release/v0.1.0: the update procedure is user-facing and thepkillinstruction is wrong on a supported target.Review amendments (round 3)
SessionSlot(pool.rs:399-408) is an atomic counter released on drop with no notification, sodrainingalone cannot settle in-flight creates. Change:reservedbecomes pool state under the mutex plus aNotify;SessionSlot::dropand registration both re-evaluate draining and notify.WhenEmptycompletes only whensessions.len() == 0 && reserved == 0.Forcefirst setsdraining(no new reservations), then destroys every registered session and waits for every outstanding reservation to resolve (register, then be destroyed like the others; or drop), then awaits every owner task's completion handle (#20) before the accept loop returns and the process exits.IfEmptycounts reservations as non-empty. Tests: a create paused between reserve and register whileWhenEmptyis issued, resolved both ways (success → the session is drained and the daemon exits after it ends; failure → the drop wakes the drain and the daemon exits);Forceduring a paused create never leaves a child alive after exit.felis daemon stop#153