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