Skip to main content

procctl/
lib.rs

1//! **Producer helper for the yah process-control channel** — the two lines a
2//! workload writes so its supervisor stops having to guess at it.
3//!
4//! ## What the channel is
5//!
6//! A supervisor watching a process from outside can ask exactly two questions:
7//! is the pid alive, and is the port open. Both are proxies. A process that has
8//! finished booting, one still replaying a WAL, and one wedged on a lock answer
9//! them identically — so the real answer has always been somewhere in stdout,
10//! and reading it means grepping a log tail for a sentence nobody agreed on.
11//!
12//! W315's rule: **any process built to run under a yah camp SHOULD expose a
13//! control channel.** One verb, `status`, answering with a status document:
14//!
15//! ```json
16//! {"state":"running","pid":71455,"uptime_secs":41,"detail":"3 windows open"}
17//! ```
18//!
19//! `state` is the only required field, and its vocabulary is *exactly*
20//! [`kamaji_proto::WorkloadState`] — `pending | starting | running | draining |
21//! exited | failed`. That is the whole compatibility story: the supervisor
22//! already answers a `Probe` verb in these words, so a workload reporting in
23//! the same words is believed verbatim instead of run through a translation
24//! table that rots the first time either side gains a state. Build with the
25//! `kamaji` feature and the `From` impls between the two enums are exhaustive
26//! matches — adding a state to either side stops compiling until both agree.
27//!
28//! ## Using it
29//!
30//! ```no_run
31//! use procctl::{ProcState, ProcStatus};
32//!
33//! # fn windows_open() -> usize { 3 }
34//! # fn booted() -> bool { true }
35//! // Hold the guard for as long as the process should answer. Dropping it
36//! // stops the listener and unlinks the socket.
37//! let _control = procctl::serve_env(|| {
38//!     if booted() {
39//!         ProcStatus::new(ProcState::Running)
40//!             .with_detail(format!("{} windows open", windows_open()))
41//!     } else {
42//!         ProcStatus::new(ProcState::Starting)
43//!     }
44//! })?;
45//! # Ok::<(), std::io::Error>(())
46//! ```
47//!
48//! [`serve_env`] returns `Ok(None)` when `YAH_CONTROL_SOCK` is unset, which is
49//! the contract: the same binary runs unchanged outside a camp, and a process
50//! MUST NOT fail for the variable's absence.
51//!
52//! The closure runs on the listener thread, on demand, once per request. It
53//! must not block for long and must not panic — a panic there takes the
54//! listener down with it, which reads to the supervisor as a process that
55//! stopped answering.
56//!
57//! ## What this crate deliberately is not
58//!
59//! It is **not required**. The protocol is one newline-delimited JSON verb
60//! precisely so the producer side is implementable in twenty lines, with no
61//! dependency, in any language, by someone whose actual job that day is their
62//! own app. A Bun script or a Python daemon emitting the same line is a
63//! first-class conforming producer. This crate is ergonomics for the Rust
64//! case, not a gate — and that is also why it does not reach for
65//! `kamaji-proto`'s postcard wire, which would make conforming mean linking a
66//! Rust crate (W315 §"Why newline-JSON").
67//!
68//! The `client` feature adds the consumer half (async, tokio) for supervisors.
69//! Producers should leave it off; the default build is std-only.
70//!
71//! @arch:see(.yah/docs/working/W315-process-control-channel.md)
72
73use std::path::{Path, PathBuf};
74
75use serde::{Deserialize, Serialize};
76
77mod serve;
78pub use serve::{serve_at, serve_env, ControlServer};
79
80#[cfg(feature = "client")]
81mod client;
82#[cfg(feature = "client")]
83pub use client::{fetch, fetch_at, ReadyOutcome, wait_ready};
84
85/// Environment variable naming the control socket a supervised process should
86/// bind. Absent → the process is not running under a supervisor that wants a
87/// control channel, and MUST NOT fail for its absence.
88pub const CONTROL_SOCK_ENV: &str = "YAH_CONTROL_SOCK";
89
90/// Conventional HTTP path for the status document on a process that already
91/// serves HTTP. Not enforced — `[process.control] http_path` overrides it —
92/// but a service with no reason to differ should use this one.
93pub const DEFAULT_HTTP_PATH: &str = "/_yah/status";
94
95/// The one verb the channel requires.
96pub const STATUS_CMD: &str = "status";
97
98/// Lifecycle vocabulary of a supervised process.
99///
100/// Deliberately identical to [`kamaji_proto::WorkloadState`] on the wire. Kept
101/// as a separate type rather than a re-export so the default build of this
102/// crate — the one a workload links — carries no supervisor protocol at all.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum ProcState {
106    /// Accepted, nothing started yet.
107    Pending,
108    /// Started, not yet serving — booting, migrating, warming a cache.
109    Starting,
110    /// Serving. This is the only state that counts as ready.
111    Running,
112    /// Shutting down gracefully.
113    Draining,
114    /// Exited cleanly.
115    Exited,
116    /// Exited with a failure, or reported itself unrecoverable.
117    Failed,
118}
119
120impl ProcState {
121    /// Whether a process in this state is ready to be used.
122    ///
123    /// `Starting` is deliberately *not* ready: the entire point of the channel
124    /// is to distinguish "the port is open" from "I am serving".
125    pub fn is_ready(self) -> bool {
126        matches!(self, ProcState::Running)
127    }
128
129    /// Whether this state is terminal — no amount of further polling changes
130    /// it, so a readiness wait should fail fast rather than burn its timeout.
131    pub fn is_terminal(self) -> bool {
132        matches!(self, ProcState::Exited | ProcState::Failed)
133    }
134
135    /// The wire token, which is also what an operator reads in a log line.
136    pub fn as_str(self) -> &'static str {
137        match self {
138            ProcState::Pending => "pending",
139            ProcState::Starting => "starting",
140            ProcState::Running => "running",
141            ProcState::Draining => "draining",
142            ProcState::Exited => "exited",
143            ProcState::Failed => "failed",
144        }
145    }
146}
147
148impl std::fmt::Display for ProcState {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.write_str(self.as_str())
151    }
152}
153
154// ── kamaji bridge ────────────────────────────────────────────────────────────
155//
156// Exhaustive on purpose: no `_` arm, no `#[non_exhaustive]` escape. If either
157// vocabulary gains a state, this stops compiling, which is the only mechanism
158// that keeps "believed verbatim" true a year from now.
159
160#[cfg(feature = "kamaji")]
161impl From<ProcState> for kamaji_proto::WorkloadState {
162    fn from(s: ProcState) -> Self {
163        match s {
164            ProcState::Pending => kamaji_proto::WorkloadState::Pending,
165            ProcState::Starting => kamaji_proto::WorkloadState::Starting,
166            ProcState::Running => kamaji_proto::WorkloadState::Running,
167            ProcState::Draining => kamaji_proto::WorkloadState::Draining,
168            ProcState::Exited => kamaji_proto::WorkloadState::Exited,
169            ProcState::Failed => kamaji_proto::WorkloadState::Failed,
170        }
171    }
172}
173
174// The reverse direction (`WorkloadState -> ProcState`) is deliberately absent.
175// `WorkloadState` is `#[non_exhaustive]`, so a match on it from outside its
176// crate needs a wildcard arm — and a wildcard is exactly the translation-table
177// rot W315 refuses: a state added there would silently become whatever the
178// wildcard picked. Nothing needs that direction anyway; the flow is workload →
179// supervisor.
180
181/// A workload's self-description. Only [`Self::state`] is required.
182///
183/// Field-for-field the same document `yah-cloud`'s `proc_control` client
184/// parses; the two types are separate only because neither side may force its
185/// dependencies on the other.
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct ProcStatus {
188    /// Lifecycle state, in kamaji's vocabulary.
189    pub state: ProcState,
190    /// Redundant convenience mirror of `state == running`, accepted from
191    /// producers that emit it. Never trusted over `state` — a document
192    /// claiming `{"state":"starting","ready":true}` is a producer bug, and
193    /// believing the optimistic half of it is how a supervisor reports a
194    /// half-booted process as up.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub ready: Option<bool>,
197    /// Process id. Stamped by [`ControlServer`] when the producer leaves it
198    /// unset — the helper knows its own pid and cannot get it wrong.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub pid: Option<u32>,
201    /// Seconds since the process considered itself started. Stamped by
202    /// [`ControlServer`] (from when the control server was started) when the
203    /// producer leaves it unset.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub uptime_secs: Option<u64>,
206    /// Build/version string, for an operator staring at two of these.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub version: Option<String>,
209    /// One human line elaborating on `state` — "replaying WAL 3/7",
210    /// "waiting for GPU". This is the field that replaces log-grepping.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub detail: Option<String>,
213    /// Named addresses the process serves — `{"http":"http://127.0.0.1:4325"}`.
214    /// A portless process may legitimately name a non-URL surface here.
215    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
216    pub endpoints: std::collections::BTreeMap<String, String>,
217    /// Numeric gauges the process wants surfaced. Free-form on purpose: this
218    /// is a status channel, not a metrics pipeline.
219    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
220    pub metrics: std::collections::BTreeMap<String, f64>,
221}
222
223impl ProcStatus {
224    /// A document reporting `state` and nothing else.
225    pub fn new(state: ProcState) -> Self {
226        Self {
227            state,
228            ready: None,
229            pid: None,
230            uptime_secs: None,
231            version: None,
232            detail: None,
233            endpoints: Default::default(),
234            metrics: Default::default(),
235        }
236    }
237
238    /// The one human line that replaces log-grepping.
239    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
240        self.detail = Some(detail.into());
241        self
242    }
243
244    /// Build/version string. `env!("CARGO_PKG_VERSION")` is the usual argument.
245    pub fn with_version(mut self, version: impl Into<String>) -> Self {
246        self.version = Some(version.into());
247        self
248    }
249
250    /// Override the pid the server would otherwise stamp — for a producer that
251    /// supervises something other than itself.
252    pub fn with_pid(mut self, pid: u32) -> Self {
253        self.pid = Some(pid);
254        self
255    }
256
257    /// Override the uptime the server would otherwise stamp.
258    pub fn with_uptime_secs(mut self, secs: u64) -> Self {
259        self.uptime_secs = Some(secs);
260        self
261    }
262
263    /// Name an address this process serves. A portless process may legitimately
264    /// name a non-URL surface (`"gui" -> "winit://main"`).
265    pub fn with_endpoint(mut self, name: impl Into<String>, addr: impl Into<String>) -> Self {
266        self.endpoints.insert(name.into(), addr.into());
267        self
268    }
269
270    /// Surface a numeric gauge.
271    pub fn with_metric(mut self, name: impl Into<String>, value: f64) -> Self {
272        self.metrics.insert(name.into(), value);
273        self
274    }
275
276    /// Ready iff the *state* says so. See [`Self::ready`] for why the
277    /// producer-supplied boolean does not get a vote.
278    pub fn is_ready(&self) -> bool {
279        self.state.is_ready()
280    }
281
282    /// One-line rendering for an operator-facing note or log line.
283    pub fn summary(&self) -> String {
284        let mut s = self.state.as_str().to_string();
285        if let Some(detail) = &self.detail {
286            s.push_str(" — ");
287            s.push_str(detail);
288        }
289        if let Some(v) = &self.version {
290            s.push_str(&format!(" (v{v})"));
291        }
292        s
293    }
294}
295
296/// The socket path this process was told to bind, or `None` outside a camp.
297///
298/// Producers that want to log the path (or decline the channel for their own
299/// reasons) can read it without going through [`serve_env`].
300pub fn control_sock_path() -> Option<PathBuf> {
301    match std::env::var_os(CONTROL_SOCK_ENV) {
302        Some(v) if !v.is_empty() => Some(PathBuf::from(v)),
303        _ => None,
304    }
305}
306
307/// A leftover socket *file* makes `bind` fail with `EADDRINUSE` even when
308/// nothing is listening, so a stale one has to go. Removing it blindly would
309/// stomp a live predecessor, so probe first: a connect that is *refused* proves
310/// no one is on the other end.
311fn clear_stale_socket(path: &Path) -> std::io::Result<()> {
312    if !path.exists() {
313        return Ok(());
314    }
315    match std::os::unix::net::UnixStream::connect(path) {
316        Ok(_) => Err(std::io::Error::new(
317            std::io::ErrorKind::AddrInUse,
318            format!(
319                "{} is already bound by a live listener — refusing to unlink it",
320                path.display()
321            ),
322        )),
323        // Refused (or anything else non-connectable) means the file outlived
324        // its process. Safe to unlink.
325        Err(_) => std::fs::remove_file(path),
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    /// If this drifts, a workload's own report can no longer be handed to the
334    /// supervisor verbatim — the entire compatibility claim of W315.
335    #[test]
336    fn the_state_vocabulary_is_exactly_kamajis() {
337        for (state, wire) in [
338            (ProcState::Pending, "\"pending\""),
339            (ProcState::Starting, "\"starting\""),
340            (ProcState::Running, "\"running\""),
341            (ProcState::Draining, "\"draining\""),
342            (ProcState::Exited, "\"exited\""),
343            (ProcState::Failed, "\"failed\""),
344        ] {
345            assert_eq!(serde_json::to_string(&state).unwrap(), wire);
346            assert_eq!(serde_json::from_str::<ProcState>(wire).unwrap(), state);
347            assert_eq!(format!("\"{state}\""), wire, "Display must match the wire");
348        }
349    }
350
351    #[test]
352    fn state_is_the_only_required_field() {
353        let s: ProcStatus = serde_json::from_str(r#"{"state":"running"}"#).unwrap();
354        assert!(s.is_ready());
355        assert_eq!(s.pid, None);
356        assert!(s.endpoints.is_empty());
357    }
358
359    /// A producer that contradicts itself must not be believed on the
360    /// optimistic half — that is precisely how a half-booted process gets
361    /// reported as up, which is the failure this channel exists to end.
362    #[test]
363    fn a_ready_flag_never_overrides_a_not_running_state() {
364        let s: ProcStatus = serde_json::from_str(r#"{"state":"starting","ready":true}"#).unwrap();
365        assert_eq!(s.ready, Some(true), "the claim is preserved verbatim");
366        assert!(!s.is_ready(), "but state decides");
367    }
368
369    #[test]
370    fn absent_optionals_are_not_emitted() {
371        let json = serde_json::to_string(&ProcStatus::new(ProcState::Running)).unwrap();
372        assert_eq!(json, r#"{"state":"running"}"#);
373    }
374
375    #[test]
376    fn the_builder_composes_a_full_document() {
377        let s = ProcStatus::new(ProcState::Starting)
378            .with_detail("replaying WAL 3/7")
379            .with_version("0.8.23")
380            .with_pid(71455)
381            .with_uptime_secs(41)
382            .with_endpoint("gui", "winit://main")
383            .with_metric("fps", 59.9);
384        assert_eq!(s.summary(), "starting — replaying WAL 3/7 (v0.8.23)");
385        let round: ProcStatus = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
386        assert_eq!(round, s);
387    }
388
389    #[test]
390    fn terminal_and_ready_are_disjoint_and_only_running_is_ready() {
391        for st in [
392            ProcState::Pending,
393            ProcState::Starting,
394            ProcState::Draining,
395            ProcState::Exited,
396            ProcState::Failed,
397        ] {
398            assert!(!st.is_ready(), "{st} must not be ready");
399        }
400        assert!(ProcState::Running.is_ready());
401        assert!(ProcState::Exited.is_terminal() && ProcState::Failed.is_terminal());
402        assert!(!ProcState::Starting.is_terminal());
403    }
404
405    #[test]
406    fn a_live_listener_is_never_unlinked() {
407        let tmp = tempfile::tempdir().unwrap();
408        let sock = tmp.path().join("live.sock");
409        let _listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
410        let err = clear_stale_socket(&sock).unwrap_err();
411        assert_eq!(err.kind(), std::io::ErrorKind::AddrInUse);
412        assert!(sock.exists(), "the live socket must survive");
413    }
414
415    #[test]
416    fn a_dead_socket_file_is_unlinked() {
417        let tmp = tempfile::tempdir().unwrap();
418        let sock = tmp.path().join("dead.sock");
419        {
420            let _l = std::os::unix::net::UnixListener::bind(&sock).unwrap();
421        }
422        assert!(sock.exists(), "dropping a listener leaves the file behind");
423        clear_stale_socket(&sock).unwrap();
424        assert!(!sock.exists());
425    }
426
427    /// The claim is that these are the same six states under the same six
428    /// names. Assert it against `WorkloadState`'s own `Debug`, so a rename on
429    /// either side fails here rather than in a supervisor six months later.
430    #[cfg(feature = "kamaji")]
431    #[test]
432    fn every_proc_state_maps_onto_the_kamaji_state_of_the_same_name() {
433        for st in [
434            ProcState::Pending,
435            ProcState::Starting,
436            ProcState::Running,
437            ProcState::Draining,
438            ProcState::Exited,
439            ProcState::Failed,
440        ] {
441            let via: kamaji_proto::WorkloadState = st.into();
442            assert_eq!(
443                format!("{via:?}").to_lowercase(),
444                st.as_str(),
445                "{st} must map onto the kamaji state of the same name"
446            );
447        }
448    }
449}