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}
265
266/// Orders one flock listing the way every operator-facing surface presents
267/// one: `(name, instance, id)`.
268///
269/// Name first: an id is assigned at registration and a `delete all` plus a
270/// fresh start renumbers the flock, where a name survives. A name is not a
271/// total order on its own, so the id breaks the tie and stays an addressing
272/// key (`shep stop 11`).
273///
274/// A listing whose rows all carry `None` for the slot collapses to
275/// `(name, id)`, since `None` sorts before every `Some`.
276pub fn sort_flock(listing: &mut [ProcessInfo]) {
277    listing.sort_unstable_by(|a, b| {
278        (a.name.as_str(), a.instance, a.id).cmp(&(b.name.as_str(), b.instance, b.id))
279    });
280}
281
282impl ProcessInfo {
283    /// Starts a builder for one sheep's row.
284    ///
285    /// The three arguments are the fields no row can omit and no reader can
286    /// default.
287    ///
288    /// No `#[must_use]`: [`ProcessInfoBuilder`] carries one, which clippy's
289    /// `double_must_use` lint treats as covering this return too.
290    pub fn builder(id: u32, name: impl Into<String>, status: ProcStatus) -> ProcessInfoBuilder {
291        ProcessInfoBuilder {
292            info: Self {
293                id,
294                name: name.into(),
295                status,
296                pid: None,
297                restarts: 0,
298                uptime_ms: 0,
299                fold: None,
300                depends_on: Vec::new(),
301                out_file: None,
302                err_file: None,
303                cpu_percent: None,
304                memory_bytes: None,
305                cpu_ms: None,
306                dog: None,
307                lambs: None,
308                last_exit: None,
309                smit: None,
310                instance: None,
311                handshook: None,
312                dog_stale: None,
313                pending: None,
314                overridden: None,
315                max_memory: None,
316                level_rules: Vec::new(),
317                reload_deadline_ms: None,
318            },
319        }
320    }
321}
322
323/// Builds a [`ProcessInfo`], which is `#[non_exhaustive]` and so cannot be
324/// written as a struct literal outside this crate.
325///
326/// Every setter takes the field's own type, `Option` included, so a caller
327/// already holding `Option<u32>` writes `.pid(entry.pid())` rather than an
328/// `if let` ladder. A setter is skipped, not passed `None`, when a row has
329/// nothing to say about that field; the skipped defaults are the ones a
330/// not-yet-running sheep has.
331#[derive(Debug, Clone)]
332#[must_use = "a builder that is never `build`-ed produces no ProcessInfo"]
333pub struct ProcessInfoBuilder {
334    info: ProcessInfo,
335}
336
337impl ProcessInfoBuilder {
338    /// Sets the OS pid; `None` while the sheep is not running.
339    pub fn pid(mut self, pid: Option<u32>) -> Self {
340        self.info.pid = pid;
341        self
342    }
343
344    /// Sets the restart count since registration.
345    pub fn restarts(mut self, restarts: u32) -> Self {
346        self.info.restarts = restarts;
347        self
348    }
349
350    /// Sets milliseconds since the last successful start.
351    pub fn uptime_ms(mut self, uptime_ms: u64) -> Self {
352        self.info.uptime_ms = uptime_ms;
353        self
354    }
355
356    /// Sets fold membership.
357    pub fn fold(mut self, fold: Option<String>) -> Self {
358        self.info.fold = fold;
359        self
360    }
361
362    /// Sets the names this sheep waits for at a staged start.
363    pub fn depends_on(mut self, depends_on: Vec<String>) -> Self {
364        self.info.depends_on = depends_on;
365        self
366    }
367
368    /// Sets the resolved stdout log path.
369    pub fn out_file(mut self, out_file: Option<String>) -> Self {
370        self.info.out_file = out_file;
371        self
372    }
373
374    /// Sets the resolved stderr log path.
375    pub fn err_file(mut self, err_file: Option<String>) -> Self {
376        self.info.err_file = err_file;
377        self
378    }
379
380    /// Sets tree CPU as a percentage of one core.
381    pub fn cpu_percent(mut self, cpu_percent: Option<f32>) -> Self {
382        self.info.cpu_percent = cpu_percent;
383        self
384    }
385
386    /// Sets tree resident set size in bytes.
387    pub fn memory_bytes(mut self, memory_bytes: Option<u64>) -> Self {
388        self.info.memory_bytes = memory_bytes;
389        self
390    }
391
392    /// Sets the tree's cumulative CPU-milliseconds; `None` when unsampled.
393    pub fn cpu_ms(mut self, cpu_ms: Option<u64>) -> Self {
394        self.info.cpu_ms = cpu_ms;
395        self
396    }
397
398    /// Marks this row a dog and names where the dog came from.
399    pub fn dog(mut self, dog: Option<DogSource>) -> Self {
400        self.info.dog = dog;
401        self
402    }
403
404    /// Sets the sheep's lamb list; `None` when this reply did not walk for one.
405    pub fn lambs(mut self, lambs: Option<Vec<Lamb>>) -> Self {
406        self.info.lambs = lambs;
407        self
408    }
409
410    /// Sets how this sheep's process most recently stopped; `None` while it
411    /// has never exited under this daemon.
412    pub fn last_exit(mut self, last_exit: Option<ExitInfo>) -> Self {
413        self.info.last_exit = last_exit;
414        self
415    }
416
417    /// Sets the marker a dog has painted on this sheep; `None` when none has.
418    pub fn smit(mut self, smit: Option<String>) -> Self {
419        self.info.smit = smit;
420        self
421    }
422
423    /// Sets the instance slot; `None` when the peer daemon predates the field.
424    pub fn instance(mut self, instance: Option<u32>) -> Self {
425        self.info.instance = instance;
426        self
427    }
428
429    /// Sets whether this dog has handshaken with the shepherd; `None` for a
430    /// sheep, which has no handshake to report.
431    pub fn handshook(mut self, handshook: Option<bool>) -> Self {
432        self.info.handshook = handshook;
433        self
434    }
435
436    /// Sets whether the shepherd has given up restarting this dog; `None`
437    /// for a sheep, which is never given up on.
438    pub fn dog_stale(mut self, dog_stale: Option<bool>) -> Self {
439        self.info.dog_stale = dog_stale;
440        self
441    }
442
443    /// Sets the field names a load has parked for this sheep's next spawn;
444    /// `None` when nothing is parked.
445    pub fn pending(mut self, pending: Option<Vec<String>>) -> Self {
446        self.info.pending = pending;
447        self
448    }
449
450    /// Sets the field names an operator has overridden on this sheep;
451    /// `None` when there is nothing to report.
452    pub fn overridden(mut self, overridden: Option<Vec<String>>) -> Self {
453        self.info.overridden = overridden;
454        self
455    }
456
457    /// Sets the sheep's `max_memory` ceiling in bytes; `None` when it has no
458    /// ceiling configured.
459    pub fn max_memory(mut self, max_memory: Option<u64>) -> Self {
460        self.info.max_memory = max_memory;
461        self
462    }
463
464    /// Sets the sheep's declared level rules; empty when it declares none.
465    pub fn level_rules(mut self, level_rules: Vec<LevelRule>) -> Self {
466        self.info.level_rules = level_rules;
467        self
468    }
469
470    /// Sets one swap's own deadline in milliseconds; `None` on every row
471    /// but a reload's, and on a row that reload will not replace.
472    pub fn reload_deadline_ms(mut self, reload_deadline_ms: Option<u64>) -> Self {
473        self.info.reload_deadline_ms = reload_deadline_ms;
474        self
475    }
476
477    /// Finishes the row.
478    #[must_use]
479    pub fn build(self) -> ProcessInfo {
480        self.info
481    }
482}
483
484#[cfg(test)]
485pub(super) fn sample_info() -> ProcessInfo {
486    ProcessInfo {
487        id: 3,
488        name: "web".to_string(),
489        status: ProcStatus::Online,
490        pid: Some(4242),
491        restarts: 1,
492        uptime_ms: 60_000,
493        fold: Some("backend".to_string()),
494        // Left empty: this fixture feeds `reply_wire_snapshots` and
495        // `bus_event_wire_snapshots`, so a non-empty value moves pinned
496        // bytes.
497        depends_on: Vec::new(),
498        out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
499        err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
500        // 12.5: an insta JSON snapshot is stable across platforms only
501        // for a float the binary representation holds exactly.
502        cpu_percent: Some(12.5),
503        memory_bytes: Some(48 * 1024 * 1024),
504        cpu_ms: None,
505        dog: None,
506        lambs: None,
507        last_exit: Some(ExitInfo {
508            code: Some(1),
509            signal: None,
510        }),
511        smit: None,
512        instance: None,
513        handshook: None,
514        dog_stale: None,
515        // Left at the builder's default: this fixture feeds
516        // `reply_wire_snapshots` and `bus_event_wire_snapshots`, so a
517        // `Some(..)` moves pinned bytes.
518        pending: None,
519        overridden: None,
520        max_memory: Some(512 * 1024 * 1024),
521        // Populated where its list-shaped neighbours above are not:
522        // nothing else on the wire pins a `LevelRule`'s field names or a
523        // `LineLevel` spelling, and an empty list would prove neither.
524        level_rules: vec![crate::config::LevelRule {
525            pattern: r"\[ERROR\]".to_string(),
526            level: crate::config::LineLevel::Error,
527        }],
528        // `None` here so the key is absent from the pinned bytes; the
529        // serialized shape of a real one is pinned on its own, by
530        // `a_reload_deadline_is_absent_from_the_wire_until_a_reload_sets_one`.
531        reload_deadline_ms: None,
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    #[test]
540    fn a_builder_with_nothing_set_is_a_sheep_that_has_not_run() {
541        let info = ProcessInfo::builder(3, "web", ProcStatus::Stopped).build();
542
543        assert_eq!(info.id, 3);
544        assert_eq!(info.name, "web");
545        assert_eq!(info.status, ProcStatus::Stopped);
546        assert_eq!(info.pid, None);
547        assert_eq!(info.restarts, 0);
548        assert_eq!(info.uptime_ms, 0);
549        assert_eq!(info.fold, None);
550        assert_eq!(info.out_file, None);
551        assert_eq!(info.err_file, None);
552        assert_eq!(info.cpu_percent, None);
553        assert_eq!(info.memory_bytes, None);
554        assert_eq!(info.cpu_ms, None);
555        assert_eq!(info.dog, None);
556        assert_eq!(info.lambs, None);
557        assert_eq!(info.last_exit, None);
558    }
559
560    /// The raw counter rides the wire beside the percent. A client polling
561    /// faster than the shepherd's own sampling interval differences two of
562    /// these; `cpu_percent` cannot serve it, because consecutive readings
563    /// share a baseline.
564    #[test]
565    fn a_process_info_carries_the_cpu_counter() {
566        let info = ProcessInfo::builder(3, "web", ProcStatus::Online)
567            .cpu_ms(Some(1_234))
568            .build();
569        assert_eq!(info.cpu_ms, Some(1_234));
570    }
571
572    /// Absent by default, like every other sampled field: a lifecycle verb's
573    /// answer carries no reading.
574    #[test]
575    fn a_process_info_without_a_reading_has_no_counter() {
576        let info = ProcessInfo::builder(3, "web", ProcStatus::Online).build();
577        assert_eq!(info.cpu_ms, None);
578    }
579
580    /// Every field is given a value distinct from every other field's
581    /// default, so a copy-pasted setter body shows up as a mismatch.
582    #[test]
583    fn every_setter_writes_its_own_field_and_no_other() {
584        let built = ProcessInfo::builder(3, "web", ProcStatus::Online)
585            .pid(Some(4242))
586            .restarts(1)
587            .uptime_ms(60_000)
588            .fold(Some("backend".to_string()))
589            .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
590            .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
591            .cpu_percent(Some(12.5))
592            .memory_bytes(Some(48 * 1024 * 1024))
593            .dog(None)
594            .last_exit(Some(ExitInfo {
595                code: Some(1),
596                signal: None,
597            }))
598            .max_memory(Some(512 * 1024 * 1024))
599            .level_rules(vec![crate::config::LevelRule {
600                pattern: r"\[ERROR\]".to_string(),
601                level: crate::config::LineLevel::Error,
602            }])
603            .build();
604
605        // `sample_info()` is a struct literal on purpose: it is the one
606        // place that names every field by hand, so this comparison fails the
607        // day the struct grows a field the builder cannot set.
608        assert_eq!(built, sample_info());
609
610        // `sample_info()`'s `dog` is `None`, the builder's default too, so an
611        // empty `dog` setter body would pass the comparison above. It cannot
612        // be changed: it feeds pinned snapshots.
613        assert_eq!(
614            ProcessInfo::builder(1, "metrics", ProcStatus::Online)
615                .dog(Some(DogSource::BuiltIn))
616                .build()
617                .dog,
618            Some(DogSource::BuiltIn),
619            "an empty `dog` setter body is invisible to the comparison above"
620        );
621
622        // `lambs`, on `dog`'s terms above.
623        assert_eq!(
624            ProcessInfo::builder(1, "web", ProcStatus::Online)
625                .lambs(Some(vec![Lamb::new(4243, "node")]))
626                .build()
627                .lambs,
628            Some(vec![Lamb::new(4243, "node")]),
629            "an empty `lambs` setter body is invisible to the comparison above"
630        );
631
632        // `smit`, on the same terms, and the field a third party writes: an
633        // empty setter body drops every dog's mark.
634        assert_eq!(
635            ProcessInfo::builder(1, "web", ProcStatus::Online)
636                .smit(Some("\u{25b2} main@a1b2c3".to_string()))
637                .build()
638                .smit
639                .as_deref(),
640            Some("\u{25b2} main@a1b2c3"),
641            "an empty `smit` setter body is invisible to the comparison above"
642        );
643
644        // `handshook`, on the same terms.
645        assert_eq!(
646            ProcessInfo::builder(1, "web", ProcStatus::Online)
647                .handshook(Some(false))
648                .build()
649                .handshook,
650            Some(false),
651            "an empty `handshook` setter body is invisible to the comparison above"
652        );
653
654        // `dog_stale`, paired with `handshook`: both default to `None`.
655        assert_eq!(
656            ProcessInfo::builder(1, "web", ProcStatus::Online)
657                .dog_stale(Some(true))
658                .build()
659                .dog_stale,
660            Some(true),
661            "an empty `dog_stale` setter body is invisible to the comparison above"
662        );
663
664        // `pending`, on the same terms.
665        assert_eq!(
666            ProcessInfo::builder(1, "web", ProcStatus::Online)
667                .pending(Some(vec!["env".to_string()]))
668                .build()
669                .pending,
670            Some(vec!["env".to_string()]),
671            "an empty `pending` setter body is invisible to the comparison above"
672        );
673
674        // `overridden`, on the same terms.
675        assert_eq!(
676            ProcessInfo::builder(1, "web", ProcStatus::Online)
677                .overridden(Some(vec!["cwd".to_string()]))
678                .build()
679                .overridden,
680            Some(vec!["cwd".to_string()]),
681            "an empty `overridden` setter body is invisible to the comparison above"
682        );
683    }
684
685    #[test]
686    fn lambs_distinguishes_not_walked_from_walked_and_empty() {
687        let not_walked = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
688        assert_eq!(not_walked.lambs, None);
689
690        let walked_empty = ProcessInfo::builder(1, "web", ProcStatus::Online)
691            .lambs(Some(Vec::new()))
692            .build();
693        assert_eq!(walked_empty.lambs, Some(Vec::new()));
694    }
695
696    #[test]
697    fn a_process_info_without_a_lambs_key_still_deserializes() {
698        let fixture = r#"{
699            "id": 3, "name": "web", "status": "online", "pid": 4242,
700            "restarts": 0, "uptime_ms": 100, "fold": null,
701            "out_file": null, "err_file": null,
702            "cpu_percent": null, "memory_bytes": null, "dog": null
703        }"#;
704        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
705        assert_eq!(info.lambs, None);
706    }
707
708    /// argv holds credentials (`--password=`, `?token=`) and
709    /// `shep describe --format json` is output people paste into issues.
710    #[test]
711    fn a_lamb_is_a_pid_and_an_executable_name() {
712        let lamb = Lamb::new(4243, "node");
713        let json = serde_json::to_string(&lamb).unwrap();
714        assert_eq!(json, r#"{"pid":4243,"name":"node"}"#);
715        assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
716    }
717
718    #[test]
719    fn a_labelled_lamb_carries_the_label_beside_the_name() {
720        let lamb = Lamb::new(4243, "python").with_label("worker 1");
721        let json = serde_json::to_string(&lamb).unwrap();
722        assert_eq!(json, r#"{"pid":4243,"name":"python","label":"worker 1"}"#);
723        assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
724    }
725
726    #[test]
727    fn v1_process_info_without_a_dog_marker_still_deserializes() {
728        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}"#;
729        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
730        assert_eq!(info.dog, None);
731    }
732
733    /// No field here carries `#[serde(default)]`: serde's derive resolves a
734    /// missing key to `None` for a field whose type is syntactically
735    /// `Option<...>`.
736    #[test]
737    fn a_process_info_without_a_last_exit_key_still_deserializes() {
738        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}"#;
739        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
740        assert_eq!(info.last_exit, None);
741    }
742
743    #[test]
744    fn a_process_info_without_a_smit_key_still_deserializes() {
745        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}"#;
746        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
747        assert_eq!(info.smit, None);
748    }
749
750    /// The fixture is a dog's row, where `None` means "render this as it
751    /// rendered before the field existed", never "never handshaken".
752    #[test]
753    fn a_process_info_without_a_handshook_key_still_deserializes() {
754        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}"#;
755        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
756        assert_eq!(info.handshook, None);
757        assert_eq!(info.dog, Some(DogSource::BuiltIn));
758    }
759
760    /// The fixture carries `handshook: false`, the case that matters: `None`
761    /// is "no verdict to report", never "it has not given up".
762    #[test]
763    fn a_process_info_without_a_dog_stale_key_still_deserializes() {
764        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}"#;
765        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
766        assert_eq!(info.dog_stale, None);
767        assert_eq!(info.handshook, Some(false));
768    }
769
770    #[test]
771    fn a_process_info_carries_its_memory_ceiling_and_defaults_to_none() {
772        let plain = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
773        assert_eq!(
774            plain.max_memory, None,
775            "a sheep with no ceiling reports none"
776        );
777
778        let capped = ProcessInfo::builder(2, "hungry", ProcStatus::Online)
779            .max_memory(Some(52 * 1024 * 1024))
780            .build();
781        assert_eq!(capped.max_memory, Some(54_525_952));
782    }
783
784    /// Absent rather than `null` when there is no reload, so a listing's
785    /// payload is the shape it always was and `SCHEMA_VERSION` stays put.
786    /// Present as a plain number when a reload sets one, which is the only
787    /// place a client reads it.
788    #[test]
789    fn a_reload_deadline_is_absent_from_the_wire_until_a_reload_sets_one() {
790        let quiet = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
791        let json = serde_json::to_string(&quiet).expect("serialize");
792        assert!(
793            !json.contains("reload_deadline_ms"),
794            "a row with no reload carries no key at all: {json}"
795        );
796
797        let reloading = ProcessInfo::builder(1, "web", ProcStatus::Online)
798            .reload_deadline_ms(Some(16_000))
799            .build();
800        let json = serde_json::to_string(&reloading).expect("serialize");
801        assert!(
802            json.contains(r#""reload_deadline_ms":16000"#),
803            "a reload's own row carries the number: {json}"
804        );
805        let back: ProcessInfo = serde_json::from_str(&json).expect("round trip");
806        assert_eq!(back.reload_deadline_ms, Some(16_000));
807    }
808
809    #[test]
810    fn an_older_daemons_process_info_still_decodes() {
811        // Every additive field, asserted off one payload. A payload written
812        // before any of them existed has to decode with each absent rather
813        // than fail the whole envelope, and one fixture cannot drift from
814        // another.
815        let older = r#"{"id":1,"name":"web","status":"online","restarts":0,"uptime_ms":0}"#;
816        let info: ProcessInfo = serde_json::from_str(older).expect("an older payload decodes");
817        assert_eq!(info.max_memory, None);
818        assert_eq!(info.reload_deadline_ms, None);
819    }
820
821    #[test]
822    fn v1_process_info_without_stats_still_deserializes() {
823        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"}"#;
824        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
825        assert_eq!(info.cpu_percent, None);
826        assert_eq!(info.memory_bytes, None);
827    }
828
829    #[test]
830    fn v1_process_info_without_log_paths_still_deserializes() {
831        // Committed byte fixture from before `out_file`/`err_file` existed.
832        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend"}"#;
833        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
834        assert_eq!(info.id, 3);
835        assert_eq!(info.out_file, None);
836        assert_eq!(info.err_file, None);
837    }
838
839    #[test]
840    fn an_old_client_still_decodes_a_new_process_info() {
841        // `ProcessInfo` carries no `deny_unknown_fields`, unlike the config
842        // types in `crate::config`, so extra keys are ignored.
843        #[derive(Deserialize)]
844        struct V1ProcessInfo {
845            id: u32,
846            fold: Option<String>,
847        }
848
849        let current = serde_json::to_string(&sample_info()).unwrap();
850        let old: V1ProcessInfo = serde_json::from_str(&current).unwrap();
851        assert_eq!(old.id, 3);
852        assert_eq!(old.fold.as_deref(), Some("backend"));
853    }
854
855    /// The fixture cannot agree under either candidate order: by id it is
856    /// `web/1, api/2, web/0`, by name `api, web, web`. The two `web` rows
857    /// are the tiebreak half, seeded out of order.
858    #[test]
859    fn a_listing_sorts_by_name_then_by_id() {
860        let mut listing = vec![
861            ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
862            ProcessInfo::builder(2, "api", ProcStatus::Online).build(),
863            ProcessInfo::builder(0, "web", ProcStatus::Online).build(),
864        ];
865        sort_flock(&mut listing);
866
867        let seen: Vec<(&str, u32)> = listing
868            .iter()
869            .map(|info| (info.name.as_str(), info.id))
870            .collect();
871        assert_eq!(
872            seen,
873            vec![("api", 2), ("web", 0), ("web", 1)],
874            "name first, then id inside a name"
875        );
876    }
877
878    #[test]
879    fn an_instance_slot_survives_a_round_trip_and_defaults_to_absent() {
880        let with = ProcessInfo::builder(1, "web", ProcStatus::Online)
881            .instance(Some(2))
882            .build();
883        assert_eq!(with.instance, Some(2));
884
885        let without = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
886        assert_eq!(
887            without.instance, None,
888            "a row nobody set a slot on says so, rather than claiming slot 0"
889        );
890    }
891
892    #[test]
893    fn a_reply_from_a_daemon_without_the_field_deserializes_as_absent() {
894        let json = r#"{"id":1,"name":"web","status":"online","pid":null,
895            "restarts":0,"uptime_ms":0,"fold":null,"out_file":null,
896            "err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,
897            "lambs":null,"last_exit":null,"smit":null}"#;
898        let info: ProcessInfo = serde_json::from_str(json).expect("older reply still parses");
899        assert_eq!(info.instance, None);
900    }
901
902    #[test]
903    fn sort_flock_orders_by_slot_before_id() {
904        // A reload gave slot 0 a fresh, higher id. Slot order must still win.
905        let mut listing = vec![
906            ProcessInfo::builder(9, "web", ProcStatus::Online)
907                .instance(Some(0))
908                .build(),
909            ProcessInfo::builder(2, "web", ProcStatus::Online)
910                .instance(Some(1))
911                .build(),
912        ];
913        sort_flock(&mut listing);
914        assert_eq!(
915            listing.iter().map(|i| i.id).collect::<Vec<_>>(),
916            vec![9, 2],
917            "slot 0 leads even though its id is higher"
918        );
919    }
920
921    #[test]
922    fn sort_flock_falls_back_to_id_when_no_row_carries_a_slot() {
923        let mut listing = vec![
924            ProcessInfo::builder(5, "web", ProcStatus::Online).build(),
925            ProcessInfo::builder(3, "web", ProcStatus::Online).build(),
926        ];
927        sort_flock(&mut listing);
928        assert_eq!(
929            listing.iter().map(|i| i.id).collect::<Vec<_>>(),
930            vec![3, 5],
931            "an older daemon's listing sorts exactly as it does today"
932        );
933    }
934}