Skip to main content

shep_core/protocol/request/
process.rs

1//! One sheep's row on the wire, its builder, and everything the row embeds.
2
3use serde::{Deserialize, Serialize};
4
5use crate::config::LevelRule;
6use crate::status::ProcStatus;
7
8use super::DogSource;
9
10// Named by intra-doc links and by nothing rustc compiles, so the
11// import is behind `cfg(doc)` rather than flagged unused.
12#[cfg(doc)]
13use super::{Response, SheepDrift, Smit};
14#[cfg(doc)]
15use crate::config::AppConfig;
16
17/// One process the OS reports as a descendant of a sheep.
18///
19/// Not the set of processes that die with the sheep: this is a parent-pid
20/// walk, where the stop ladder acts on the process group. A lamb that forks
21/// and exits leaves children re-parented to init, out of this list and still
22/// in the group; a `setsid()` grandchild stays in the list and leaves the
23/// group.
24///
25/// `name` is the executable's name (`node`, `sh`), never argv, which carries
26/// credentials and would ride into `shep describe --format json`. Build one
27/// with [`Self::new`].
28// wire format: changing this is a breaking change
29#[non_exhaustive]
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Lamb {
32    /// The lamb's own pid.
33    pub pid: u32,
34    /// The executable's name, as the OS reports it. Never its command line.
35    pub name: String,
36    /// What the sheep named this lamb on its shepherd channel, with a
37    /// [`ChildMessage::LambLabel`](crate::protocol::ChildMessage::LambLabel).
38    ///
39    /// `None` when it sent none, or the shepherd predates labels. Never
40    /// read from the OS, so the no-argv rule above still holds.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub label: Option<String>,
43}
44
45impl Lamb {
46    /// One lamb, with no label.
47    #[must_use]
48    pub fn new(pid: u32, name: impl Into<String>) -> Self {
49        Self {
50            pid,
51            name: name.into(),
52            label: None,
53        }
54    }
55
56    /// This lamb, carrying the sheep's own label for it.
57    #[must_use]
58    pub fn with_label(mut self, label: impl Into<String>) -> Self {
59        self.label = Some(label.into());
60        self
61    }
62}
63
64/// Why a sheep's process most recently stopped existing under this daemon.
65///
66/// Behind [`ProcessInfo::last_exit`]'s own `Option`, so `None` there means
67/// never exited. Ordinarily exactly one of `code`/`signal` is `Some`,
68/// mirroring the OS's `WIFEXITED`/`WIFSIGNALED` split; both `None` together
69/// is legal and means this daemon recorded an exit it could not
70/// characterize.
71// wire format: changing this is a breaking change
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73pub struct ExitInfo {
74    /// The process's own exit code, set on a normal exit (`WIFEXITED`).
75    pub code: Option<i32>,
76    /// The raw unix signal number that ended the process, set when it did
77    /// not exit on its own (`WIFSIGNALED`). An operator's own `shep stop`
78    /// counts: the process still stopped by a signal.
79    ///
80    /// Platform-specific, and never rendered as a name here; that is an
81    /// OS-aware layer's job.
82    pub signal: Option<i32>,
83}
84
85/// Snapshot of one sheep for listings and events
86///
87/// Construct one with [`ProcessInfo::builder`]: `#[non_exhaustive]` forbids
88/// a struct literal outside this crate, though not inside it. The fields
89/// stay `pub`.
90// wire format: changing this is a breaking change. No `Eq`: `cpu_percent` is
91// an `f32`. Paths travel as `String`, since serde's `PathBuf` refuses a
92// non-UTF-8 path and would blank a whole `Reply`. Every added field is an
93// `Option` or a defaulted collection, so a peer built before it sends no key
94// and the empty reading means unknown.
95#[non_exhaustive]
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct ProcessInfo {
98    /// Stable numeric id
99    pub id: u32,
100    /// Sheep name
101    pub name: String,
102    /// Lifecycle status
103    pub status: ProcStatus,
104    /// OS pid while running
105    pub pid: Option<u32>,
106    /// Restart count since registration
107    pub restarts: u32,
108    /// Milliseconds since last successful start
109    pub uptime_ms: u64,
110    /// Fold membership
111    pub fold: Option<String>,
112    /// Names this sheep waits for at a staged start, from its
113    /// `depends_on`. Empty both when the sheep declares none and when the
114    /// peer daemon predates the field.
115    #[serde(default)]
116    pub depends_on: Vec<String>,
117    /// Resolved stdout log path: the app's explicit
118    /// [`AppConfig::out_file`] when it set one, else the daemon-derived
119    /// default. `None` only when the peer daemon predates this field.
120    pub out_file: Option<String>,
121    /// Resolved stderr log path, resolved exactly as [`Self::out_file`]
122    pub err_file: Option<String>,
123    /// Tree CPU as a percentage of one core, over the window since the
124    /// daemon's last periodic sample. `None` when the sheep is not running,
125    /// when it has been up for less than one sampling window, or when the
126    /// peer daemon predates the field; all three render as unknown, never as
127    /// zero. A value over 100 is a tree using more than one core.
128    pub cpu_percent: Option<f32>,
129    /// Tree resident set size in bytes, current as of the reply. `None`
130    /// under the same three conditions as [`Self::cpu_percent`], minus the
131    /// window one: memory needs no baseline.
132    pub memory_bytes: Option<u64>,
133    /// The tree's cumulative CPU-milliseconds, or `None` when the shepherd
134    /// is not sampling this sheep.
135    ///
136    /// Present in one case [`Self::cpu_percent`] is not: a sheep spawned
137    /// since the last periodic tick has a counter already, but no baseline
138    /// to measure it against, so this is `Some` while the percent is still
139    /// `None`.
140    ///
141    /// The counter rather than a rate, so a client polling faster than the
142    /// shepherd's own sampling interval can difference two readings and get
143    /// the mean over its own interval. [`Self::cpu_percent`] cannot serve
144    /// that: it is measured against a baseline the shepherd rewrites on its
145    /// own schedule, so consecutive readings share one and each is a running
146    /// mean over a window that grows and then resets.
147    pub cpu_ms: Option<u64>,
148    /// Set when this entry is a dog, naming where the dog came from;
149    /// `None` for a sheep.
150    ///
151    /// Two cases, not [`Self::cpu_percent`]'s three: "not a dog" is the true
152    /// answer whether the peer predates the field or the entry is a sheep.
153    pub dog: Option<DogSource>,
154    /// The processes the OS reports as descendants of this sheep, or `None`
155    /// when this reply did not walk for them.
156    ///
157    /// `None` covers two cases: this reply is not a `Describe` (only
158    /// `Describe` walks), or the peer daemon predates the field.
159    /// `Some(vec![])` is the third: walked, and this sheep has no children.
160    ///
161    /// Read [`Lamb`]'s own doc before rendering this: the list is not the set
162    /// of processes a stop kills, and any output built from it has to say so.
163    pub lambs: Option<Vec<Lamb>>,
164    /// How this sheep's process most recently stopped existing under this
165    /// daemon. `None` while it has never exited under this daemon, and when
166    /// the peer daemon predates the field.
167    ///
168    /// Sticky across a respawn: it answers why the sheep last stopped, not
169    /// whether it is stopped now, and updates only on the next exit.
170    pub last_exit: Option<ExitInfo>,
171    /// The marker a dog has asked to have painted beside this sheep, or
172    /// `None` when no dog has painted one, which also covers a peer daemon
173    /// that predates the field.
174    ///
175    /// A `String` rather than a [`Smit`]: this is a report, and the
176    /// validation that makes it safe to print happened at the daemon's
177    /// ingress. Every instance of a name shows the same marker, since smits
178    /// are keyed by sheep name.
179    pub smit: Option<String>,
180    /// Which instance slot of its app this sheep occupies, counting from 0.
181    ///
182    /// `None` when the peer daemon predates the field. Not a bare `u32`
183    /// defaulted to 0: an app stocked to four instances would report four
184    /// rows all claiming slot 0.
185    pub instance: Option<u32>,
186    /// Whether this dog has completed a handshake with the shepherd that is
187    /// reporting it, and not been refused since; `None` for a sheep.
188    ///
189    /// `None` on [`Self::dog`]'s two-case terms: a sheep has no connection
190    /// to the shepherd, so "no handshake fact to report" is the true answer
191    /// for a sheep and for a peer that predates the field alike.
192    ///
193    /// `Some(false)` is the one that matters: a dog on a protocol this
194    /// shepherd refuses is alive, which is all [`Self::status`] reports, and
195    /// not doing its job. A fact and not a verdict, though: a dog spawned a
196    /// moment ago has not handshaken yet and is healthy.
197    pub handshook: Option<bool>,
198    /// Whether the reporting shepherd has given up on this dog: restarted it
199    /// once for never answering, watched that not help, and stopped
200    /// restarting it. `None` for a sheep, on [`Self::handshook`]'s terms.
201    ///
202    /// Not derivable from [`Self::handshook`]. `Some(false)` there covers
203    /// both a dog spawned three seconds ago that has not dialled back and one
204    /// this shepherd has permanently stopped restarting; the first needs
205    /// nothing done and the second is an incident.
206    ///
207    /// A fact and not a verdict: it says the shepherd stopped, never why.
208    /// The why is in that dog's own log (`shep bleats <dog>`).
209    pub dog_stale: Option<bool>,
210    /// The [`AppConfig`] field names this sheep's spec differs from a load's
211    /// parked config for, in field-name order. `None` when nothing is parked,
212    /// and when the peer daemon predates the field.
213    ///
214    /// Names only, never values, as [`SheepDrift::fields`] carries them: a
215    /// differing `env` reports `"env"` and stops there. `shep reload`
216    /// promotes a parked config.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub pending: Option<Vec<String>>,
219    /// The [`AppConfig`] field names an operator has set on this sheep that
220    /// its current Flockfile does not declare, in field-name order. `None`
221    /// when there is nothing to report, and when the peer daemon predates
222    /// the field.
223    ///
224    /// Names only, never values, for [`Self::pending`]'s reason:
225    /// [`crate::overrides::AppOverrides::fields`] can hold an `env` value.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub overridden: Option<Vec<String>>,
228    /// The sheep's `max_memory` ceiling in bytes, when it has one.
229    ///
230    /// Additive, like [`Self::instance`] and [`Self::handshook`] before it, so
231    /// neither `PROTOCOL_VERSION` nor `SCHEMA_VERSION` moves: an older payload
232    /// decodes with it absent and an older client ignores it. Lookout's
233    /// `MEM/CEIL` gauge is the only reader; `None` draws an all-tail bar
234    /// rather than guessing a denominator.
235    pub max_memory: Option<u64>,
236    /// How this sheep's own log lines announce their level, from its
237    /// [`AppConfig::level_rules`](crate::config::AppConfig::level_rules).
238    ///
239    /// Empty both when the sheep declares none and when the peer daemon
240    /// predates the field, which read the same way: a client classifying
241    /// this sheep's lines falls back to its own reading of them. The key is
242    /// absent from the payload entirely when the list is empty.
243    // On the listing rather than behind a fetch of its own: a client reads
244    // this on every line it draws, and a listing it already polls cannot go
245    // stale between polls.
246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
247    pub level_rules: Vec<LevelRule>,
248    /// How long the shepherd gives ONE swap of this instance before it
249    /// gives up on it: this instance's own `listen_timeout` plus its
250    /// `graceful_timeout` plus the shepherd's slack, in milliseconds.
251    ///
252    /// `Some` only on the rows of a reload's acceptance
253    /// ([`Response::Reloading`]), and only for an instance that reload will
254    /// try to replace. `None` everywhere else, which covers a listing with
255    /// no reload in flight, an instance a reload is skipping, and a peer
256    /// daemon that predates the field.
257    ///
258    /// Per instance, not per app: a client that inferred it from its own
259    /// copy of the Flockfile would be reading a file the shepherd may have
260    /// moved past. Additive, like [`Self::max_memory`] before it, so
261    /// neither `PROTOCOL_VERSION` nor `SCHEMA_VERSION` moves.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub reload_deadline_ms: Option<u64>,
264    /// `Some(true)` when the sheep's app is disabled: stopped, and refused a
265    /// spawn until `shep enable`. `None` otherwise, which also covers a peer
266    /// daemon that predates the field.
267    ///
268    /// Beside [`Self::status`] rather than a [`ProcStatus`] variant: that enum
269    /// is closed, so an older peer could not decode a new one. A disabled row
270    /// keeps `status: "stopped"`. Never `Some(false)`, so the key is absent
271    /// from every row that is not disabled.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub disabled: Option<bool>,
274    /// Whether this sheep's own log lines carry the shepherd's stamp, from
275    /// its [`AppConfig::log_timestamps`]. `Some(false)` is the only value a
276    /// daemon sets. `None` for a dog, which always stamps, and for a peer
277    /// daemon that predates the field: both read as stamped, which is what
278    /// every daemon before it wrote. Additive, like [`Self::disabled`].
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub log_timestamps: Option<bool>,
281}
282
283/// Orders one flock listing the way every operator-facing surface presents
284/// one: `(name, instance, id)`.
285///
286/// Name first: an id is assigned at registration and a `delete all` plus a
287/// fresh start renumbers the flock, where a name survives. A name is not a
288/// total order on its own, so the id breaks the tie and stays an addressing
289/// key (`shep stop 11`).
290///
291/// A listing whose rows all carry `None` for the slot collapses to
292/// `(name, id)`, since `None` sorts before every `Some`.
293pub fn sort_flock(listing: &mut [ProcessInfo]) {
294    listing.sort_unstable_by(|a, b| {
295        (a.name.as_str(), a.instance, a.id).cmp(&(b.name.as_str(), b.instance, b.id))
296    });
297}
298
299impl ProcessInfo {
300    /// Starts a builder for one sheep's row.
301    ///
302    /// The three arguments are the fields no row can omit and no reader can
303    /// default.
304    ///
305    /// No `#[must_use]`: [`ProcessInfoBuilder`] carries one, which clippy's
306    /// `double_must_use` lint treats as covering this return too.
307    pub fn builder(id: u32, name: impl Into<String>, status: ProcStatus) -> ProcessInfoBuilder {
308        ProcessInfoBuilder {
309            info: Self {
310                id,
311                name: name.into(),
312                status,
313                pid: None,
314                restarts: 0,
315                uptime_ms: 0,
316                fold: None,
317                depends_on: Vec::new(),
318                out_file: None,
319                err_file: None,
320                cpu_percent: None,
321                memory_bytes: None,
322                cpu_ms: None,
323                dog: None,
324                lambs: None,
325                last_exit: None,
326                smit: None,
327                instance: None,
328                handshook: None,
329                dog_stale: None,
330                pending: None,
331                overridden: None,
332                max_memory: None,
333                level_rules: Vec::new(),
334                reload_deadline_ms: None,
335                disabled: None,
336                log_timestamps: None,
337            },
338        }
339    }
340}
341
342/// Builds a [`ProcessInfo`], which is `#[non_exhaustive]` and so cannot be
343/// written as a struct literal outside this crate.
344///
345/// Every setter takes the field's own type, `Option` included, so a caller
346/// already holding `Option<u32>` writes `.pid(entry.pid())` rather than an
347/// `if let` ladder. A setter is skipped, not passed `None`, when a row has
348/// nothing to say about that field; the skipped defaults are the ones a
349/// not-yet-running sheep has.
350#[derive(Debug, Clone)]
351#[must_use = "a builder that is never `build`-ed produces no ProcessInfo"]
352pub struct ProcessInfoBuilder {
353    info: ProcessInfo,
354}
355
356impl ProcessInfoBuilder {
357    /// Sets the OS pid; `None` while the sheep is not running.
358    pub fn pid(mut self, pid: Option<u32>) -> Self {
359        self.info.pid = pid;
360        self
361    }
362
363    /// Sets the restart count since registration.
364    pub fn restarts(mut self, restarts: u32) -> Self {
365        self.info.restarts = restarts;
366        self
367    }
368
369    /// Sets milliseconds since the last successful start.
370    pub fn uptime_ms(mut self, uptime_ms: u64) -> Self {
371        self.info.uptime_ms = uptime_ms;
372        self
373    }
374
375    /// Sets fold membership.
376    pub fn fold(mut self, fold: Option<String>) -> Self {
377        self.info.fold = fold;
378        self
379    }
380
381    /// Sets the names this sheep waits for at a staged start.
382    pub fn depends_on(mut self, depends_on: Vec<String>) -> Self {
383        self.info.depends_on = depends_on;
384        self
385    }
386
387    /// Sets the resolved stdout log path.
388    pub fn out_file(mut self, out_file: Option<String>) -> Self {
389        self.info.out_file = out_file;
390        self
391    }
392
393    /// Sets the resolved stderr log path.
394    pub fn err_file(mut self, err_file: Option<String>) -> Self {
395        self.info.err_file = err_file;
396        self
397    }
398
399    /// Sets tree CPU as a percentage of one core.
400    pub fn cpu_percent(mut self, cpu_percent: Option<f32>) -> Self {
401        self.info.cpu_percent = cpu_percent;
402        self
403    }
404
405    /// Sets tree resident set size in bytes.
406    pub fn memory_bytes(mut self, memory_bytes: Option<u64>) -> Self {
407        self.info.memory_bytes = memory_bytes;
408        self
409    }
410
411    /// Sets the tree's cumulative CPU-milliseconds; `None` when unsampled.
412    pub fn cpu_ms(mut self, cpu_ms: Option<u64>) -> Self {
413        self.info.cpu_ms = cpu_ms;
414        self
415    }
416
417    /// Marks this row a dog and names where the dog came from.
418    pub fn dog(mut self, dog: Option<DogSource>) -> Self {
419        self.info.dog = dog;
420        self
421    }
422
423    /// Sets the sheep's lamb list; `None` when this reply did not walk for one.
424    pub fn lambs(mut self, lambs: Option<Vec<Lamb>>) -> Self {
425        self.info.lambs = lambs;
426        self
427    }
428
429    /// Sets how this sheep's process most recently stopped; `None` while it
430    /// has never exited under this daemon.
431    pub fn last_exit(mut self, last_exit: Option<ExitInfo>) -> Self {
432        self.info.last_exit = last_exit;
433        self
434    }
435
436    /// Sets the marker a dog has painted on this sheep; `None` when none has.
437    pub fn smit(mut self, smit: Option<String>) -> Self {
438        self.info.smit = smit;
439        self
440    }
441
442    /// Sets the instance slot; `None` when the peer daemon predates the field.
443    pub fn instance(mut self, instance: Option<u32>) -> Self {
444        self.info.instance = instance;
445        self
446    }
447
448    /// Sets whether this dog has handshaken with the shepherd; `None` for a
449    /// sheep, which has no handshake to report.
450    pub fn handshook(mut self, handshook: Option<bool>) -> Self {
451        self.info.handshook = handshook;
452        self
453    }
454
455    /// Sets whether the shepherd has given up restarting this dog; `None`
456    /// for a sheep, which is never given up on.
457    pub fn dog_stale(mut self, dog_stale: Option<bool>) -> Self {
458        self.info.dog_stale = dog_stale;
459        self
460    }
461
462    /// Sets the field names a load has parked for this sheep's next spawn;
463    /// `None` when nothing is parked.
464    pub fn pending(mut self, pending: Option<Vec<String>>) -> Self {
465        self.info.pending = pending;
466        self
467    }
468
469    /// Sets the field names an operator has overridden on this sheep;
470    /// `None` when there is nothing to report.
471    pub fn overridden(mut self, overridden: Option<Vec<String>>) -> Self {
472        self.info.overridden = overridden;
473        self
474    }
475
476    /// Sets the sheep's `max_memory` ceiling in bytes; `None` when it has no
477    /// ceiling configured.
478    pub fn max_memory(mut self, max_memory: Option<u64>) -> Self {
479        self.info.max_memory = max_memory;
480        self
481    }
482
483    /// Sets the sheep's declared level rules; empty when it declares none.
484    pub fn level_rules(mut self, level_rules: Vec<LevelRule>) -> Self {
485        self.info.level_rules = level_rules;
486        self
487    }
488
489    /// Sets one swap's own deadline in milliseconds; `None` on every row
490    /// but a reload's, and on a row that reload will not replace.
491    pub fn reload_deadline_ms(mut self, reload_deadline_ms: Option<u64>) -> Self {
492        self.info.reload_deadline_ms = reload_deadline_ms;
493        self
494    }
495
496    /// Sets whether the sheep's app is disabled; `None` when it is not.
497    pub fn disabled(mut self, disabled: Option<bool>) -> Self {
498        self.info.disabled = disabled;
499        self
500    }
501
502    /// Sets whether the sheep's app turns the shepherd's stamp off; `None`
503    /// for a dog, which always stamps.
504    pub fn log_timestamps(mut self, log_timestamps: Option<bool>) -> Self {
505        self.info.log_timestamps = log_timestamps;
506        self
507    }
508
509    /// Finishes the row.
510    #[must_use]
511    pub fn build(self) -> ProcessInfo {
512        self.info
513    }
514}
515
516#[cfg(test)]
517pub(super) fn sample_info() -> ProcessInfo {
518    ProcessInfo {
519        id: 3,
520        name: "web".to_string(),
521        status: ProcStatus::Online,
522        pid: Some(4242),
523        restarts: 1,
524        uptime_ms: 60_000,
525        fold: Some("backend".to_string()),
526        // Left empty: this fixture feeds `reply_wire_snapshots` and
527        // `bus_event_wire_snapshots`, so a non-empty value moves pinned
528        // bytes.
529        depends_on: Vec::new(),
530        out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
531        err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
532        // 12.5: an insta JSON snapshot is stable across platforms only
533        // for a float the binary representation holds exactly.
534        cpu_percent: Some(12.5),
535        memory_bytes: Some(48 * 1024 * 1024),
536        cpu_ms: None,
537        dog: None,
538        lambs: None,
539        last_exit: Some(ExitInfo {
540            code: Some(1),
541            signal: None,
542        }),
543        smit: None,
544        instance: None,
545        handshook: None,
546        dog_stale: None,
547        // Left at the builder's default: this fixture feeds
548        // `reply_wire_snapshots` and `bus_event_wire_snapshots`, so a
549        // `Some(..)` moves pinned bytes.
550        pending: None,
551        overridden: None,
552        max_memory: Some(512 * 1024 * 1024),
553        // Populated where its list-shaped neighbours above are not:
554        // nothing else on the wire pins a `LevelRule`'s field names or a
555        // `LineLevel` spelling, and an empty list would prove neither.
556        level_rules: vec![crate::config::LevelRule {
557            pattern: r"\[ERROR\]".to_string(),
558            level: crate::config::LineLevel::Error,
559        }],
560        // `None` here so the key is absent from the pinned bytes; the
561        // serialized shape of a real one is pinned on its own, by
562        // `a_reload_deadline_is_absent_from_the_wire_until_a_reload_sets_one`.
563        reload_deadline_ms: None,
564        // `None` so the key is absent from the pinned bytes; its serialized
565        // shape is pinned by `a_disabled_flag_is_absent_from_the_wire_until_set`.
566        disabled: None,
567        // `None` for the same reason, its shape pinned by
568        // `a_stamps_off_flag_is_absent_from_the_wire_until_set`.
569        log_timestamps: None,
570    }
571}
572
573#[cfg(test)]
574mod stamps;
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn a_builder_with_nothing_set_is_a_sheep_that_has_not_run() {
582        let info = ProcessInfo::builder(3, "web", ProcStatus::Stopped).build();
583
584        assert_eq!(info.id, 3);
585        assert_eq!(info.name, "web");
586        assert_eq!(info.status, ProcStatus::Stopped);
587        assert_eq!(info.pid, None);
588        assert_eq!(info.restarts, 0);
589        assert_eq!(info.uptime_ms, 0);
590        assert_eq!(info.fold, None);
591        assert_eq!(info.out_file, None);
592        assert_eq!(info.err_file, None);
593        assert_eq!(info.cpu_percent, None);
594        assert_eq!(info.memory_bytes, None);
595        assert_eq!(info.cpu_ms, None);
596        assert_eq!(info.dog, None);
597        assert_eq!(info.lambs, None);
598        assert_eq!(info.last_exit, None);
599    }
600
601    /// The raw counter rides the wire beside the percent. A client polling
602    /// faster than the shepherd's own sampling interval differences two of
603    /// these; `cpu_percent` cannot serve it, because consecutive readings
604    /// share a baseline.
605    #[test]
606    fn a_process_info_carries_the_cpu_counter() {
607        let info = ProcessInfo::builder(3, "web", ProcStatus::Online)
608            .cpu_ms(Some(1_234))
609            .build();
610        assert_eq!(info.cpu_ms, Some(1_234));
611    }
612
613    /// Absent by default, like every other sampled field: a lifecycle verb's
614    /// answer carries no reading.
615    #[test]
616    fn a_process_info_without_a_reading_has_no_counter() {
617        let info = ProcessInfo::builder(3, "web", ProcStatus::Online).build();
618        assert_eq!(info.cpu_ms, None);
619    }
620
621    /// Every field is given a value distinct from every other field's
622    /// default, so a copy-pasted setter body shows up as a mismatch.
623    #[test]
624    fn every_setter_writes_its_own_field_and_no_other() {
625        let built = ProcessInfo::builder(3, "web", ProcStatus::Online)
626            .pid(Some(4242))
627            .restarts(1)
628            .uptime_ms(60_000)
629            .fold(Some("backend".to_string()))
630            .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
631            .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
632            .cpu_percent(Some(12.5))
633            .memory_bytes(Some(48 * 1024 * 1024))
634            .dog(None)
635            .last_exit(Some(ExitInfo {
636                code: Some(1),
637                signal: None,
638            }))
639            .max_memory(Some(512 * 1024 * 1024))
640            .level_rules(vec![crate::config::LevelRule {
641                pattern: r"\[ERROR\]".to_string(),
642                level: crate::config::LineLevel::Error,
643            }])
644            .build();
645
646        // `sample_info()` is a struct literal on purpose: it is the one
647        // place that names every field by hand, so this comparison fails the
648        // day the struct grows a field the builder cannot set.
649        assert_eq!(built, sample_info());
650
651        // `sample_info()`'s `dog` is `None`, the builder's default too, so an
652        // empty `dog` setter body would pass the comparison above. It cannot
653        // be changed: it feeds pinned snapshots.
654        assert_eq!(
655            ProcessInfo::builder(1, "metrics", ProcStatus::Online)
656                .dog(Some(DogSource::BuiltIn))
657                .build()
658                .dog,
659            Some(DogSource::BuiltIn),
660            "an empty `dog` setter body is invisible to the comparison above"
661        );
662
663        // `lambs`, on `dog`'s terms above.
664        assert_eq!(
665            ProcessInfo::builder(1, "web", ProcStatus::Online)
666                .lambs(Some(vec![Lamb::new(4243, "node")]))
667                .build()
668                .lambs,
669            Some(vec![Lamb::new(4243, "node")]),
670            "an empty `lambs` setter body is invisible to the comparison above"
671        );
672
673        // `smit`, on the same terms, and the field a third party writes: an
674        // empty setter body drops every dog's mark.
675        assert_eq!(
676            ProcessInfo::builder(1, "web", ProcStatus::Online)
677                .smit(Some("\u{25b2} main@a1b2c3".to_string()))
678                .build()
679                .smit
680                .as_deref(),
681            Some("\u{25b2} main@a1b2c3"),
682            "an empty `smit` setter body is invisible to the comparison above"
683        );
684
685        // `handshook`, on the same terms.
686        assert_eq!(
687            ProcessInfo::builder(1, "web", ProcStatus::Online)
688                .handshook(Some(false))
689                .build()
690                .handshook,
691            Some(false),
692            "an empty `handshook` setter body is invisible to the comparison above"
693        );
694
695        // `dog_stale`, paired with `handshook`: both default to `None`.
696        assert_eq!(
697            ProcessInfo::builder(1, "web", ProcStatus::Online)
698                .dog_stale(Some(true))
699                .build()
700                .dog_stale,
701            Some(true),
702            "an empty `dog_stale` setter body is invisible to the comparison above"
703        );
704
705        // `pending`, on the same terms.
706        assert_eq!(
707            ProcessInfo::builder(1, "web", ProcStatus::Online)
708                .pending(Some(vec!["env".to_string()]))
709                .build()
710                .pending,
711            Some(vec!["env".to_string()]),
712            "an empty `pending` setter body is invisible to the comparison above"
713        );
714
715        // `overridden`, on the same terms.
716        assert_eq!(
717            ProcessInfo::builder(1, "web", ProcStatus::Online)
718                .overridden(Some(vec!["cwd".to_string()]))
719                .build()
720                .overridden,
721            Some(vec!["cwd".to_string()]),
722            "an empty `overridden` setter body is invisible to the comparison above"
723        );
724    }
725
726    #[test]
727    fn lambs_distinguishes_not_walked_from_walked_and_empty() {
728        let not_walked = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
729        assert_eq!(not_walked.lambs, None);
730
731        let walked_empty = ProcessInfo::builder(1, "web", ProcStatus::Online)
732            .lambs(Some(Vec::new()))
733            .build();
734        assert_eq!(walked_empty.lambs, Some(Vec::new()));
735    }
736
737    #[test]
738    fn a_process_info_without_a_lambs_key_still_deserializes() {
739        let fixture = r#"{
740            "id": 3, "name": "web", "status": "online", "pid": 4242,
741            "restarts": 0, "uptime_ms": 100, "fold": null,
742            "out_file": null, "err_file": null,
743            "cpu_percent": null, "memory_bytes": null, "dog": null
744        }"#;
745        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
746        assert_eq!(info.lambs, None);
747    }
748
749    /// argv holds credentials (`--password=`, `?token=`) and
750    /// `shep describe --format json` is output people paste into issues.
751    #[test]
752    fn a_lamb_is_a_pid_and_an_executable_name() {
753        let lamb = Lamb::new(4243, "node");
754        let json = serde_json::to_string(&lamb).unwrap();
755        assert_eq!(json, r#"{"pid":4243,"name":"node"}"#);
756        assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
757    }
758
759    #[test]
760    fn a_labelled_lamb_carries_the_label_beside_the_name() {
761        let lamb = Lamb::new(4243, "python").with_label("worker 1");
762        let json = serde_json::to_string(&lamb).unwrap();
763        assert_eq!(json, r#"{"pid":4243,"name":"python","label":"worker 1"}"#);
764        assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
765    }
766
767    #[test]
768    fn v1_process_info_without_a_dog_marker_still_deserializes() {
769        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log","cpu_percent":12.5,"memory_bytes":50331648}"#;
770        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
771        assert_eq!(info.dog, None);
772    }
773
774    /// No field here carries `#[serde(default)]`: serde's derive resolves a
775    /// missing key to `None` for a field whose type is syntactically
776    /// `Option<...>`.
777    #[test]
778    fn a_process_info_without_a_last_exit_key_still_deserializes() {
779        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log","cpu_percent":12.5,"memory_bytes":50331648,"dog":null,"lambs":null}"#;
780        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
781        assert_eq!(info.last_exit, None);
782    }
783
784    #[test]
785    fn a_process_info_without_a_smit_key_still_deserializes() {
786        let fixture = r#"{"id":1,"name":"web","status":"online","pid":42,"restarts":0,"uptime_ms":10,"fold":null,"out_file":null,"err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,"lambs":null,"last_exit":null}"#;
787        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
788        assert_eq!(info.smit, None);
789    }
790
791    /// The fixture is a dog's row, where `None` means "render this as it
792    /// rendered before the field existed", never "never handshaken".
793    #[test]
794    fn a_process_info_without_a_handshook_key_still_deserializes() {
795        let fixture = r#"{"id":1,"name":"metrics","status":"online","pid":42,"restarts":0,"uptime_ms":10,"fold":null,"out_file":null,"err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":{"kind":"built_in"},"lambs":null,"last_exit":null,"smit":null,"instance":0}"#;
796        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
797        assert_eq!(info.handshook, None);
798        assert_eq!(info.dog, Some(DogSource::BuiltIn));
799    }
800
801    /// The fixture carries `handshook: false`, the case that matters: `None`
802    /// is "no verdict to report", never "it has not given up".
803    #[test]
804    fn a_process_info_without_a_dog_stale_key_still_deserializes() {
805        let fixture = r#"{"id":1,"name":"metrics","status":"online","pid":42,"restarts":0,"uptime_ms":10,"fold":null,"out_file":null,"err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":{"kind":"built_in"},"lambs":null,"last_exit":null,"smit":null,"instance":0,"handshook":false}"#;
806        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
807        assert_eq!(info.dog_stale, None);
808        assert_eq!(info.handshook, Some(false));
809    }
810
811    #[test]
812    fn a_process_info_carries_its_memory_ceiling_and_defaults_to_none() {
813        let plain = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
814        assert_eq!(
815            plain.max_memory, None,
816            "a sheep with no ceiling reports none"
817        );
818
819        let capped = ProcessInfo::builder(2, "hungry", ProcStatus::Online)
820            .max_memory(Some(52 * 1024 * 1024))
821            .build();
822        assert_eq!(capped.max_memory, Some(54_525_952));
823    }
824
825    /// Absent rather than `null` when there is no reload, so a listing's
826    /// payload is the shape it always was and `SCHEMA_VERSION` stays put.
827    /// Present as a plain number when a reload sets one, which is the only
828    /// place a client reads it.
829    #[test]
830    fn a_reload_deadline_is_absent_from_the_wire_until_a_reload_sets_one() {
831        let quiet = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
832        let json = serde_json::to_string(&quiet).expect("serialize");
833        assert!(
834            !json.contains("reload_deadline_ms"),
835            "a row with no reload carries no key at all: {json}"
836        );
837
838        let reloading = ProcessInfo::builder(1, "web", ProcStatus::Online)
839            .reload_deadline_ms(Some(16_000))
840            .build();
841        let json = serde_json::to_string(&reloading).expect("serialize");
842        assert!(
843            json.contains(r#""reload_deadline_ms":16000"#),
844            "a reload's own row carries the number: {json}"
845        );
846        let back: ProcessInfo = serde_json::from_str(&json).expect("round trip");
847        assert_eq!(back.reload_deadline_ms, Some(16_000));
848    }
849
850    /// A disabled sheep stays `stopped` on the wire and gains a key beside it;
851    /// every other row is the bytes it always was.
852    #[test]
853    fn a_disabled_flag_is_absent_from_the_wire_until_set() {
854        let plain = ProcessInfo::builder(1, "web", ProcStatus::Stopped).build();
855        let json = serde_json::to_string(&plain).expect("serialize");
856        assert!(!json.contains("disabled"), "{json}");
857
858        let parked = ProcessInfo::builder(1, "web", ProcStatus::Stopped)
859            .disabled(Some(true))
860            .build();
861        let json = serde_json::to_string(&parked).expect("serialize");
862        assert!(json.contains(r#""status":"stopped""#), "{json}");
863        assert!(json.contains(r#""disabled":true"#), "{json}");
864        let back: ProcessInfo = serde_json::from_str(&json).expect("round trip");
865        assert_eq!(back.disabled, Some(true));
866    }
867
868    #[test]
869    fn an_older_daemons_process_info_still_decodes() {
870        // Every additive field, asserted off one payload. A payload written
871        // before any of them existed has to decode with each absent rather
872        // than fail the whole envelope, and one fixture cannot drift from
873        // another.
874        let older = r#"{"id":1,"name":"web","status":"online","restarts":0,"uptime_ms":0}"#;
875        let info: ProcessInfo = serde_json::from_str(older).expect("an older payload decodes");
876        assert_eq!(info.max_memory, None);
877        assert_eq!(info.reload_deadline_ms, None);
878        assert_eq!(info.disabled, None);
879        assert_eq!(info.log_timestamps, None);
880    }
881
882    #[test]
883    fn v1_process_info_without_stats_still_deserializes() {
884        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log"}"#;
885        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
886        assert_eq!(info.cpu_percent, None);
887        assert_eq!(info.memory_bytes, None);
888    }
889
890    #[test]
891    fn v1_process_info_without_log_paths_still_deserializes() {
892        // Committed byte fixture from before `out_file`/`err_file` existed.
893        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend"}"#;
894        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
895        assert_eq!(info.id, 3);
896        assert_eq!(info.out_file, None);
897        assert_eq!(info.err_file, None);
898    }
899
900    #[test]
901    fn an_old_client_still_decodes_a_new_process_info() {
902        // `ProcessInfo` carries no `deny_unknown_fields`, unlike the config
903        // types in `crate::config`, so extra keys are ignored.
904        #[derive(Deserialize)]
905        struct V1ProcessInfo {
906            id: u32,
907            fold: Option<String>,
908        }
909
910        let current = serde_json::to_string(&sample_info()).unwrap();
911        let old: V1ProcessInfo = serde_json::from_str(&current).unwrap();
912        assert_eq!(old.id, 3);
913        assert_eq!(old.fold.as_deref(), Some("backend"));
914    }
915
916    /// The fixture cannot agree under either candidate order: by id it is
917    /// `web/1, api/2, web/0`, by name `api, web, web`. The two `web` rows
918    /// are the tiebreak half, seeded out of order.
919    #[test]
920    fn a_listing_sorts_by_name_then_by_id() {
921        let mut listing = vec![
922            ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
923            ProcessInfo::builder(2, "api", ProcStatus::Online).build(),
924            ProcessInfo::builder(0, "web", ProcStatus::Online).build(),
925        ];
926        sort_flock(&mut listing);
927
928        let seen: Vec<(&str, u32)> = listing
929            .iter()
930            .map(|info| (info.name.as_str(), info.id))
931            .collect();
932        assert_eq!(
933            seen,
934            vec![("api", 2), ("web", 0), ("web", 1)],
935            "name first, then id inside a name"
936        );
937    }
938
939    #[test]
940    fn an_instance_slot_survives_a_round_trip_and_defaults_to_absent() {
941        let with = ProcessInfo::builder(1, "web", ProcStatus::Online)
942            .instance(Some(2))
943            .build();
944        assert_eq!(with.instance, Some(2));
945
946        let without = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
947        assert_eq!(
948            without.instance, None,
949            "a row nobody set a slot on says so, rather than claiming slot 0"
950        );
951    }
952
953    #[test]
954    fn a_reply_from_a_daemon_without_the_field_deserializes_as_absent() {
955        let json = r#"{"id":1,"name":"web","status":"online","pid":null,
956            "restarts":0,"uptime_ms":0,"fold":null,"out_file":null,
957            "err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,
958            "lambs":null,"last_exit":null,"smit":null}"#;
959        let info: ProcessInfo = serde_json::from_str(json).expect("older reply still parses");
960        assert_eq!(info.instance, None);
961    }
962
963    #[test]
964    fn sort_flock_orders_by_slot_before_id() {
965        // A reload gave slot 0 a fresh, higher id. Slot order must still win.
966        let mut listing = vec![
967            ProcessInfo::builder(9, "web", ProcStatus::Online)
968                .instance(Some(0))
969                .build(),
970            ProcessInfo::builder(2, "web", ProcStatus::Online)
971                .instance(Some(1))
972                .build(),
973        ];
974        sort_flock(&mut listing);
975        assert_eq!(
976            listing.iter().map(|i| i.id).collect::<Vec<_>>(),
977            vec![9, 2],
978            "slot 0 leads even though its id is higher"
979        );
980    }
981
982    #[test]
983    fn sort_flock_falls_back_to_id_when_no_row_carries_a_slot() {
984        let mut listing = vec![
985            ProcessInfo::builder(5, "web", ProcStatus::Online).build(),
986            ProcessInfo::builder(3, "web", ProcStatus::Online).build(),
987        ];
988        sort_flock(&mut listing);
989        assert_eq!(
990            listing.iter().map(|i| i.id).collect::<Vec<_>>(),
991            vec![3, 5],
992            "an older daemon's listing sorts exactly as it does today"
993        );
994    }
995}