shep_core/protocol/request.rs
1//! RPC frames: requests, responses, envelopes, and structured errors
2
3use core::fmt;
4
5use serde::{Deserialize, Deserializer, Serialize};
6
7use crate::config::AppConfig;
8use crate::status::ProcStatus;
9
10/// Client's opening frame
11// wire format: changing this is a breaking change
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct Hello {
14 /// Client crate version (semver string)
15 pub client_version: String,
16 /// [`crate::protocol::PROTOCOL_VERSION`] the client speaks
17 pub protocol: u32,
18}
19
20/// Daemon's handshake answer
21// wire format: changing this is a breaking change
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct HelloAck {
24 /// Daemon crate version
25 pub daemon_version: String,
26 /// Protocol version the daemon speaks
27 pub protocol: u32,
28 /// Daemon pid
29 pub pid: u32,
30}
31
32/// Serializable selector (mirror of [`crate::selector::ProcessSelector`];
33/// regex travels as its source string)
34// wire format: changing this is a breaking change
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
37pub enum SelectorSpec {
38 /// Every sheep
39 All,
40 /// By id
41 Id(u32),
42 /// By exact name
43 Name(String),
44 /// By regex source
45 Regex(String),
46 /// By fold name
47 Fold(String),
48 // Both field names are part of the wire contract, and the byte shape is
49 // pinned by `request_wire_v2`, so renaming either breaks that snapshot
50 // rather than sliding through unnoticed.
51 /// By app name and instance slot
52 ///
53 /// Added in protocol 2, and the reason that version exists: a daemon
54 /// built against protocol 1 has no such variant and refuses a client
55 /// that sends one at the handshake, rather than failing to decode it
56 /// later. On the wire this is `{"kind":"instance","value":{"name":
57 /// "web","slot":2}}`, following the enum's own `kind`/`value` tagging.
58 Instance {
59 /// The app name
60 name: String,
61 /// The instance slot, counting from 0
62 slot: u32,
63 },
64}
65
66/// A short marker a dog attaches to a sheep for `shep flock` to paint.
67///
68/// shep stores one and prints it. It does not parse it, has no opinion about
69/// what it means, and never will: `▲ main@a1b2c3` is a deploy tool's
70/// sentence, not shep's, and keeping it that way is what makes this a general
71/// mechanism rather than one feature's field.
72///
73/// # The rule
74///
75/// A smit is non-empty once whitespace is discounted, at most
76/// [`Self::MAX_CHARS`] characters, and carries no [`char::is_control`]
77/// character — `\u{1b}` included, which `is_control` already covers and which
78/// is named separately in this doc anyway, because it is the one an attacker
79/// reaches for and a reader should not have to know the classification to see
80/// that it is handled.
81///
82/// Refused, never repaired. The text is stored exactly as it arrived: shep
83/// does not trim it, strip from it, or otherwise hand back something the
84/// publisher did not send. `crate::kv`'s key grammar and value cap set the
85/// same precedent for the same kind of value, and the publisher here is a
86/// program, so a refusal it can see and fix beats mangling it cannot.
87///
88/// # Why the cap counts characters
89///
90/// [`Self::MAX_CHARS`] is a count of `char`s, not of bytes and not of display
91/// columns. Bytes would refuse a legitimate CJK smit at roughly a third of
92/// its apparent length. Display columns are what a table renderer measures,
93/// but they depend on the terminal and on a width table this parser has no
94/// business carrying. Characters are the honest thing a validator can promise
95/// cheaply. 48 is measured against the reference smit `▲ main@a1b2c3` at
96/// thirteen: room for a long branch name, without letting one column swallow
97/// the table.
98///
99/// # Why validation lives here rather than at the renderer
100///
101/// `shep`'s own `output::width::sanitize_cell` deliberately KEEPS a
102/// well-formed CSI sequence, because shep's colouring is made of them, so it
103/// is not a guard against a third party's string. Refusing here means `shep
104/// flock`, `shep describe`, `--format json`, the lookout, the MCP tool schema
105/// and every bus subscriber are safe by construction instead of six places
106/// each remembering.
107///
108/// `Debug` is derived, and that is the deliberate decision (IR-41): a smit
109/// carries no environment and no secret. It is a string a dog asked to have
110/// painted in public, so redacting it would hide the thing an operator is
111/// debugging.
112///
113/// # Example
114/// ```
115/// use shep_core::protocol::Smit;
116///
117/// assert_eq!("▲ main@a1b2c3".parse::<Smit>()?.as_str(), "▲ main@a1b2c3");
118/// assert!("\u{1b}[2Jgone".parse::<Smit>().is_err()); // no escapes
119/// # Ok::<(), shep_core::protocol::SmitError>(())
120/// ```
121// wire format: changing this is a breaking change
122#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
123pub struct Smit(String);
124
125impl Smit {
126 /// The longest a smit may be, in characters. See the type doc for why
127 /// characters rather than bytes or display columns.
128 pub const MAX_CHARS: usize = 48;
129
130 /// The marker as text, exactly as its publisher sent it.
131 #[must_use]
132 pub fn as_str(&self) -> &str {
133 &self.0
134 }
135}
136
137impl fmt::Display for Smit {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 f.write_str(&self.0)
140 }
141}
142
143impl core::str::FromStr for Smit {
144 type Err = SmitError;
145
146 /// # Errors
147 /// - [`SmitError::Empty`] — nothing but whitespace.
148 /// - [`SmitError::TooLong`] — over [`Self::MAX_CHARS`] characters.
149 /// - [`SmitError::Unprintable`] — a control character, `\u{1b}` included.
150 fn from_str(text: &str) -> Result<Self, Self::Err> {
151 if text.trim().is_empty() {
152 return Err(SmitError::Empty);
153 }
154 let chars = text.chars().count();
155 if chars > Self::MAX_CHARS {
156 return Err(SmitError::TooLong { chars });
157 }
158 if text.chars().any(char::is_control) {
159 return Err(SmitError::Unprintable);
160 }
161 Ok(Self(text.to_string()))
162 }
163}
164
165/// Hand-written rather than derived, and that is the whole security property.
166///
167/// A derived impl would accept anything a `String` accepts, so a smit
168/// carrying `\u{1b}[2J` would reach the daemon's memory and every listing
169/// built from it. `docs/dogs.md` tells dog authors to speak this wire
170/// directly, so a dog written in another language never runs
171/// [`core::str::FromStr`] — the daemon has to validate what it decodes, not
172/// trust that it was constructed properly.
173impl<'de> Deserialize<'de> for Smit {
174 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
175 // String, not &str: a non-borrowing deserializer cannot always borrow
176 let text = String::deserialize(deserializer)?;
177 text.parse().map_err(serde::de::Error::custom)
178 }
179}
180
181/// Why a string is not a [`Smit`].
182///
183/// `#[non_exhaustive]`: shep-core is a published library and a further
184/// refusal — a grapheme-cluster cap, say, or a bidi-override rule — must not
185/// break an out-of-tree consumer's `match` (IR-20).
186#[non_exhaustive]
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum SmitError {
189 /// Over [`Smit::MAX_CHARS`] characters; carries the count that was sent.
190 TooLong {
191 /// How many characters the string held.
192 chars: usize,
193 },
194 /// A control character, `\u{1b}` included — the sequence that would let a
195 /// third party's string drive an operator's terminal.
196 Unprintable,
197 /// Empty, or nothing but whitespace.
198 Empty,
199}
200
201impl fmt::Display for SmitError {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 match self {
204 Self::TooLong { chars } => write!(
205 f,
206 "a smit is at most {} characters; this one is {chars}",
207 Smit::MAX_CHARS
208 ),
209 Self::Unprintable => {
210 f.write_str("a smit may not contain a control character, an escape included")
211 }
212 Self::Empty => f.write_str("a smit may not be empty"),
213 }
214 }
215}
216
217impl core::error::Error for SmitError {}
218
219/// One RPC request
220// wire format: changing existing variants is a breaking change
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222#[serde(tag = "kind", rename_all = "snake_case")]
223#[non_exhaustive]
224pub enum Request {
225 /// Liveness check
226 Ping,
227 /// Full flock listing
228 ListFlock,
229 /// Detailed info for matching sheep
230 Describe {
231 /// Which sheep
232 selector: SelectorSpec,
233 },
234 /// Register + start apps
235 Start {
236 /// App configs — the daemon MUST re-normalize (peer input is
237 /// untrusted); failures return [`RpcErrorCode::InvalidConfig`]
238 apps: Vec<AppConfig>,
239 },
240 /// Ask which of `apps` name a sheep the flock already has under a
241 /// different config
242 ///
243 /// Read-only: nothing is registered, started, or changed. [`Self::Start`]
244 /// on an already-registered name adds instances rather than reconciling
245 /// config, which is what `shep stock` relies on; this is how a caller
246 /// finds out that an edit it just read from a Flockfile is one `Start`
247 /// will not apply, instead of the edit vanishing without a word.
248 ///
249 /// Answers [`Response::Drifted`] with one [`SheepDrift`] per app that is
250 /// both registered and different. An app the flock does not have is
251 /// absent from the answer, not reported as unchanged: `Start` will
252 /// register it, so there is nothing to warn about.
253 ConfigDrift {
254 /// The configs to compare against, exactly as [`Self::Start`] would
255 /// carry them. The daemon MUST re-normalize (peer input is
256 /// untrusted, and an unnormalized config would report every default
257 /// it has not spelled out as a difference); failures return
258 /// [`RpcErrorCode::InvalidConfig`].
259 apps: Vec<AppConfig>,
260 },
261 /// Stop matching sheep (stay registered)
262 Stop {
263 /// Which sheep
264 selector: SelectorSpec,
265 },
266 /// Restart matching sheep
267 Restart {
268 /// Which sheep
269 selector: SelectorSpec,
270 },
271 /// Replace each matching sheep with a fresh instance of the same app, one
272 /// instance of an app at a time, so the app has a window in which it can
273 /// stay reachable across the swap
274 Reload {
275 /// Which sheep. No default anywhere in the stack — a reload replaces
276 /// running processes, so the operator names the target, exactly as
277 /// `stop`/`restart`/`delete` do (see `shep reload`).
278 selector: SelectorSpec,
279 },
280 /// Stop + deregister matching sheep
281 Delete {
282 /// Which sheep
283 selector: SelectorSpec,
284 },
285 /// Set how many instances one app runs (see `shep stock`).
286 ///
287 /// # Why a name and not a selector
288 ///
289 /// Every other verb here takes a [`SelectorSpec`], and this one
290 /// deliberately does not. `instances` is a per-app number and instance
291 /// slots are allocated against the same-name group
292 /// (`shep_daemon::assemble::instance_slots`), so a selector matching two
293 /// apps would have to mean either "four of each" or "four in total", and
294 /// neither reading is more obviously right than the other. A name has one
295 /// meaning.
296 ///
297 /// # Why absolute and not a delta
298 ///
299 /// There is no `+N`/`-N` form and there will not be one. An absolute count
300 /// is idempotent — run it twice, get the same flock — where two operators
301 /// sending `+2` against the same app get a number neither of them asked
302 /// for. This project's own trace notes also record a crash on pm2's
303 /// relative-remove path, and those notes exist so shep does not reproduce
304 /// what they record.
305 Scale {
306 /// The app's name, exactly as its config spells it. Not a selector: no
307 /// `all`, no regex, no `fold:`.
308 name: String,
309 /// How many instances the app has when this returns. `0` is refused
310 /// with [`RpcErrorCode::InvalidConfig`] — `normalize` rejects
311 /// `instances == 0` for every other path into the daemon, and `shep
312 /// delete` is the verb for removing an app.
313 count: u32,
314 },
315 /// Attach a short marker to `sheep` for `shep flock` to paint, or clear
316 /// it with `None`.
317 ///
318 /// By NAME rather than a selector, for [`Self::Scale`]'s reason (see its
319 /// own doc above): a smit belongs to a sheep, not to one of its
320 /// instances, and every instance of that name shows it — including one
321 /// spawned after the smit was painted.
322 ///
323 /// Held in memory and scoped to the connection that sent it. When that
324 /// connection closes, for any reason, the smits it painted go with it. A
325 /// publisher therefore republishes rather than publishing on change.
326 ///
327 /// shep does not parse it and has no opinion about what it means.
328 SetSmit {
329 /// Which sheep.
330 sheep: String,
331 /// The marker, or `None` to clear it.
332 smit: Option<Smit>,
333 },
334 /// Reopen every matched sheep's log files, for an external rotator that
335 /// has renamed them (`create`-mode rotation)
336 Reopen {
337 /// Which sheep
338 selector: SelectorSpec,
339 },
340 /// Empty every matched sheep's log files: flush what is still pending,
341 /// then truncate the recorded paths
342 Flush {
343 /// Which sheep. No default anywhere in the stack — this destroys
344 /// log data, so the operator names the target (see `shep flush`).
345 selector: SelectorSpec,
346 },
347 /// Send a named action to every matched sheep over its shepherd channel
348 /// and report what each app says back (see `shep trigger`).
349 Trigger {
350 /// Which sheep. No default anywhere in the stack, matching
351 /// `stop`/`restart`/`reload`/`delete`/`flush`: an operator names the
352 /// target rather than trigger an action against the whole flock by
353 /// accident.
354 selector: SelectorSpec,
355 /// The action name. Free-form — the daemon never declares, parses,
356 /// or validates it; an app that does not recognize the name is
357 /// expected to say so in its own reply rather than stay silent.
358 action: String,
359 /// Argument text for the action, passed through to the app
360 /// verbatim. One opaque string, not structured data: the daemon
361 /// holds no schema for it, matching the shepherd channel's own
362 /// `action` message this ultimately becomes.
363 params: Option<String>,
364 },
365 /// Deliver one signal to every matched sheep's OWN process — never its
366 /// process group (see `shep signal`).
367 Signal {
368 /// Which sheep. No default anywhere in the stack, matching every
369 /// other verb that reaches a running process: an operator names the
370 /// target rather than signal the whole flock by accident.
371 selector: SelectorSpec,
372 /// The signal's name, as
373 /// [`OperatorSignal`](crate::signals::OperatorSignal) spells it — the
374 /// `SIG` prefix and the case are both optional.
375 ///
376 /// A `String` rather than the enum, for the reason
377 /// [`AppConfig::kill_signal`](crate::config::AppConfig::kill_signal)
378 /// is one: the wire stays plain text a person can read in a capture,
379 /// and the daemon re-validates regardless, because peer input is
380 /// untrusted. A name outside the grammar answers
381 /// [`RpcErrorCode::InvalidConfig`].
382 signal: String,
383 },
384 /// Write one line to every matched sheep's stdin (see `shep whisper`).
385 SendLine {
386 /// Which sheep. No default, matching every other verb that reaches a
387 /// running process.
388 selector: SelectorSpec,
389 /// The line, WITHOUT its terminator — the shepherd appends exactly one
390 /// `\n` when it writes. Carrying the terminator here would leave "did
391 /// the caller include one" as a question every hop has to re-answer,
392 /// and a caller that included two would send an empty line the app
393 /// never asked for.
394 ///
395 /// A line containing an embedded newline is refused
396 /// ([`RpcErrorCode::InvalidConfig`]): it would deliver two commands
397 /// where the operator typed one.
398 line: String,
399 },
400 /// Write the muster roll now, bypassing the snapshot writer's debounce
401 SaveRoll,
402 /// Assemble the flock from the muster roll on disk: start every app the
403 /// roll recorded running, leaving every app the flock already has exactly
404 /// as it stands
405 Muster,
406 /// Ask for one dog's `[dog.<name>]` section, as the dog itself parses it
407 DogConfig {
408 /// The dog's name — the config key, not a selector
409 name: String,
410 },
411 /// Start one dog now, marking it as coming from `source`
412 EnableDog {
413 /// The dog's name
414 name: String,
415 /// Where its binary comes from
416 source: DogSource,
417 },
418 /// Stop and deregister one dog
419 ///
420 /// Answers [`Response::Deleted`], the same reply `Delete` gives: disabling
421 /// deregisters exactly as `Delete` does, so this is the same fact and not
422 /// a coincidence of shape. A variant of its own (`DogDisabled`, say) would
423 /// carry nothing `Deleted` does not.
424 DisableDog {
425 /// The dog's name
426 name: String,
427 },
428 /// Ask whether this daemon could hand its flock to a successor in place,
429 /// rather than stopping it and starting it again (`shep daemon reload`).
430 ///
431 /// Read-only, and nothing here triggers a handover. The trigger is a
432 /// signal and always was: a socket request cannot be the trigger, because
433 /// the case that most needs a reload is the one where the daemon refuses
434 /// the client at the handshake. What travels over the socket is the
435 /// DECISION, for a reason a signal cannot serve (spec H3a) -- a signal
436 /// carries no reply, so a daemon that took one, refused, and fell back to
437 /// its own graceful stop would leave the client polling for a successor
438 /// nobody started, with the flock down and staying down.
439 ///
440 /// Answers [`Response::HandoverFitness`]. Every refusal is a feature the
441 /// running daemon cannot yet carry, not an error: the caller falls back
442 /// to a stop-and-start, which is correct behaviour rather than a degraded
443 /// one, and prints the reason to the operator who asked for the reload.
444 ///
445 /// # Why this variant does not move `PROTOCOL_VERSION`
446 ///
447 /// An older daemon cannot deserialize a variant it has never seen, which
448 /// is normally what a bump is for. It is never sent to one. `daemon
449 /// reload` is an exempt verb, so it connects to a mismatched daemon
450 /// deliberately, learns the daemon's crate version from the handshake,
451 /// and takes the stop arm for anything predating the handover without
452 /// ever asking. shep-cli's `commands::daemon` holds that gate and a test
453 /// of its own pins it.
454 HandoverFitness,
455 /// Graceful daemon shutdown
456 KillDaemon,
457 /// Subscribe this connection to bus topics (glob patterns)
458 Subscribe {
459 /// Topic globs, e.g. `process.*`
460 topics: Vec<String>,
461 },
462}
463
464/// Where a dog came from: this binary, or one an operator adopted.
465///
466/// The one thing an operator wants when a dog misbehaves, which is why it
467/// is a column rather than a detail. Carried on [`ProcessInfo::dog`], so a
468/// listing distinguishes the two populations without a second request.
469///
470/// `#[non_exhaustive]`: a future source — a dog fetched from a registry,
471/// say — must not need a protocol version bump (IR-20).
472// wire format: changing existing variants is a breaking change
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474#[serde(tag = "kind", rename_all = "snake_case")]
475#[non_exhaustive]
476pub enum DogSource {
477 /// An argv branch of the shep binary itself (`shep dog <name>`).
478 BuiltIn,
479 /// A binary an operator adopted, run at the daemon's own trust level.
480 Adopted {
481 /// The binary's path, exactly as the operator gave it to `adopt`.
482 path: String,
483 },
484}
485
486/// One process the OS reports as a descendant of a sheep.
487///
488/// # What this is not
489///
490/// It is **not** the set of processes that die with the sheep, and nothing here
491/// should be read as promising that. The list is built by walking the OS's
492/// parent-pid links; the stop ladder acts on the process GROUP, and the two
493/// units diverge in both directions — a lamb that forks and exits leaves its
494/// own children re-parented to init, out of this list and still in the group,
495/// while a `setsid()` grandchild stays in this list and leaves the group.
496/// shep-daemon's `limits` module doc has the full account, and it is the
497/// authority; this is a pointer to it, not a second copy free to drift.
498///
499/// # Why a name and not a command line
500///
501/// `name` is the executable's name as the OS reports it (`node`, `sh`,
502/// `python3`), never its argument vector. A process's argv routinely carries
503/// credentials — a `--password=` flag, a URL with a token in the query string —
504/// and this field rides in `shep describe --format json`, which is output
505/// people paste into bug reports. A pid alone would be safe too, and was
506/// considered; it was rejected because a tree of bare integers sends the
507/// operator to `ps`, which is the work the tree exists to save.
508///
509/// # Why no memory figure
510///
511/// The sheep's own row already reports its whole tree's resident size
512/// ([`ProcessInfo::memory_bytes`]), and a per-lamb breakdown is a profiler's
513/// job. `deferred.md`'s note on this struct's growth asks for exactly this
514/// restraint.
515///
516/// `#[non_exhaustive]`: shep-core is a published library, this type is new, and
517/// the two obvious next fields (a parent pid, so a deep tree can be nested
518/// rather than flattened; a start time) would otherwise be breaking additions
519/// (IR-20). Build one with [`Self::new`].
520// wire format: changing this is a breaking change
521#[non_exhaustive]
522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
523pub struct Lamb {
524 /// The lamb's own pid.
525 pub pid: u32,
526 /// The executable's name, as the OS reports it. Never its command line.
527 pub name: String,
528}
529
530impl Lamb {
531 /// One lamb.
532 ///
533 /// A plain constructor rather than a builder, unlike [`ProcessInfo`]: both
534 /// fields are required and neither is optional or derived, which is the
535 /// case a builder buys nothing for.
536 #[must_use]
537 pub fn new(pid: u32, name: impl Into<String>) -> Self {
538 Self {
539 pid,
540 name: name.into(),
541 }
542 }
543}
544
545/// Why a sheep's process most recently stopped existing under this daemon.
546///
547/// Not shep-daemon's own `ExitOutcome` reused directly: that type lives
548/// behind the spawn-runner seam (`ProcessRunner::wait`, in shep-daemon's
549/// `runner` module) and is free to grow with whatever the real runner needs
550/// to observe next without dragging a breaking wire change behind it — this
551/// one only ever grows on its own say-so. The two happen to carry the same
552/// two fields today because the runner's own observation IS the honest exit
553/// outcome; shep-daemon converts one into the other at the point it records
554/// it (`Actor::handle_exited`), rather than this crate depending on
555/// shep-daemon's internals to reuse its type.
556///
557/// A struct, not two flat `Option<i32>` fields on [`ProcessInfo`] directly:
558/// with two flat fields, "this sheep has never exited under this daemon"
559/// and "it exited, killed by a signal this daemon did not name" are both
560/// all-`None`, and a reader cannot tell those apart. Nested behind
561/// [`ProcessInfo::last_exit`]'s own `Option`, that ambiguity moves up a
562/// level where it belongs: `None` there means "never exited"; `Some` means
563/// "exited, and here is what this daemon knows about it" — which itself
564/// mirrors the OS's own exited-normally/killed-by-signal split
565/// (`WIFEXITED`/`WIFSIGNALED`): ordinarily exactly one of `code`/`signal` is
566/// `Some`. A reader must not assume both can never be `None` together,
567/// though — that would still mean "this daemon recorded an exit; it could
568/// not characterize how" rather than "this sheep never exited", and this
569/// type does not forbid it.
570///
571/// No `#[non_exhaustive]`, unlike every other struct on this wire: those
572/// grow because a discriminator does (`ProcessInfo`'s own doc lists its
573/// four so far); `code`/`signal` is already the complete
574/// exited-normally/killed-by-signal split the runner exposes, this crate
575/// has no libc to derive a richer wait-status decomposition from even if it
576/// wanted one, and there is no forecast next field. Adding the attribute
577/// back later is a compatible change the day a real need appears (IR-16
578/// style); carrying it now on nothing but "might grow" is the exact
579/// speculative case IR-20 warns against.
580// wire format: changing this is a breaking change
581#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
582pub struct ExitInfo {
583 /// The process's own exit code, set on a normal exit (`WIFEXITED`).
584 pub code: Option<i32>,
585 /// The raw unix signal number that ended the process, set when it did
586 /// not exit on its own (`WIFSIGNALED`) — an operator's own `shep stop`
587 /// or `shep delete` included: the process still genuinely stopped by a
588 /// signal, and that stays true information even though shep, not a
589 /// crash, is what asked for it. Raw and platform-specific for the same
590 /// reason [`crate::signals::OperatorSignal`] carries no such accessor —
591 /// see that type's own module doc — so rendering this as a name
592 /// (`SIGKILL` rather than `9`) is a job for whichever OS-aware layer
593 /// reads it, never for this crate.
594 pub signal: Option<i32>,
595}
596
597/// Snapshot of one sheep for listings and events
598// wire format: changing this is a breaking change
599//
600// `out_file`/`err_file` are `Option<String>`, and both halves of that are
601// deliberate:
602//
603// String, not PathBuf. Every path already on this wire travels as a string
604// (`AppConfig::script`, `cwd`, `out_file`, `err_file`, all of which ride in
605// `Request::Start`), so this matches the established representation. It is
606// also the safer failure mode: serde's `PathBuf` impl REFUSES a non-UTF-8
607// path, and that refusal is not local — it aborts the whole `Reply`, so one
608// sheep with an odd log path would blank the entire `ListFlock` for every
609// other sheep. Lossy conversion daemon-side degrades exactly one field of
610// one sheep instead.
611//
612// Option, not a bare String. Semantically the daemon always resolves both
613// paths, so a required field is tempting. But the handshake only compares
614// `PROTOCOL_VERSION` (see shep-daemon's `server.rs`), and adding these
615// fields deliberately does NOT bump it — the evolution rule in this
616// module's parent says additive fields keep the version. A daemon built
617// before this field and a client built after it therefore both announce
618// protocol 1 and connect happily, and that daemon's replies carry no
619// `out_file` key at all. A required `String` would fail to deserialize
620// there, so a new client could not list against an old daemon. `None`
621// means precisely "this peer predates the field" — which readers must
622// render as unknown, NOT as "this sheep has no log file".
623//
624// `cpu_percent`/`memory_bytes` are optional for that same skew reason and
625// for one of their own: a sheep that is not running has no resource use to
626// report, and one that has been up for less than a sampling window has no
627// honest CPU figure. All three cases render as unknown, never as zero.
628//
629// No `Eq`, which every other wire struct in this module derives:
630// `cpu_percent` is an `f32` and floats are only partially ordered. Nothing
631// compares a `ProcessInfo` for total equality — `assert_eq!` needs only
632// `PartialEq`, and no listing is keyed on, hashed by, or sorted by a whole
633// row.
634/// `#[non_exhaustive]`: this struct grows fields over time with no hand-edit
635/// sweep needed across OUT-OF-TREE callers — it forbids a struct literal
636/// outside this crate, not inside it. `sample_info()` and
637/// [`ProcessInfoBuilder`] both still name every field and both still need
638/// updating the day a field is added; what the attribute buys is that
639/// nothing downstream does. `deferred.md`'s own `ProcessInfo` entry defers
640/// SPLITTING it into several smaller types, not growing it — this attribute
641/// plus [`ProcessInfo::builder`] is "deliberately the opposite of forcing
642/// the split early," which is what makes a field like `last_exit` cheap to
643/// add for a concrete operator need, not a reason to withhold one. Use
644/// [`ProcessInfo::builder`] to construct one; the fields stay `pub`, so
645/// reading them and assigning to them are both unchanged.
646#[non_exhaustive]
647#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
648pub struct ProcessInfo {
649 /// Stable numeric id
650 pub id: u32,
651 /// Sheep name
652 pub name: String,
653 /// Lifecycle status
654 pub status: ProcStatus,
655 /// OS pid while running
656 pub pid: Option<u32>,
657 /// Restart count since registration
658 pub restarts: u32,
659 /// Milliseconds since last successful start
660 pub uptime_ms: u64,
661 /// Fold membership
662 pub fold: Option<String>,
663 /// Resolved stdout log path: the app's explicit
664 /// [`AppConfig::out_file`] when it set one, else the daemon-derived
665 /// default. `None` only when the peer daemon predates this field.
666 pub out_file: Option<String>,
667 /// Resolved stderr log path, resolved exactly as [`Self::out_file`]
668 pub err_file: Option<String>,
669 /// Tree CPU as a percentage of one core, over the window since the
670 /// daemon's last periodic sample. `None` when the sheep is not running,
671 /// when it has been up for less than one sampling window, or when the
672 /// peer daemon predates this field — all three of which a reader
673 /// renders as unknown, never as zero.
674 ///
675 /// A value over 100 is a tree using more than one core, not a bug.
676 pub cpu_percent: Option<f32>,
677 /// Tree resident set size in bytes, current as of the reply. `None`
678 /// under the same three conditions as [`Self::cpu_percent`], minus the
679 /// window one — memory needs no baseline.
680 pub memory_bytes: Option<u64>,
681 /// Set when this entry is a dog, naming where the dog came from;
682 /// `None` for a sheep.
683 ///
684 /// Unlike [`Self::cpu_percent`], `None` here does not need to enumerate
685 /// three cases. A daemon built before dogs existed has none, so "not a
686 /// dog" is the true answer whether this peer predates the field or the
687 /// entry is genuinely a sheep — there is no resource-usage-style claim
688 /// a stale zero could get wrong. Do not "fix" this into three cases.
689 pub dog: Option<DogSource>,
690 /// The processes the OS reports as descendants of this sheep, or `None`
691 /// when this reply did not walk for them.
692 ///
693 /// `None` covers two cases and is deliberately not a third: this reply is
694 /// not a `Describe` (only `Describe` walks — the walk costs a second pass
695 /// over the machine's process table, and a flock listing is the thing an
696 /// operator leaves running in a loop), or the peer daemon predates the
697 /// field. `Some(vec![])` is the third case, and the one that means what it
698 /// looks like: walked, and this sheep has no children.
699 ///
700 /// Read [`Lamb`]'s own doc before rendering this. The list is a parent-pid
701 /// walk and is NOT the set of processes a stop kills; any output built from
702 /// it has to say so where the operator will see it.
703 pub lambs: Option<Vec<Lamb>>,
704 /// How this sheep's process most recently stopped existing under this
705 /// daemon. `None` while it has never exited under this daemon — either
706 /// it has not been started yet, or it is still on its very first run —
707 /// and also when the peer daemon predates this field, the same skew
708 /// rule [`Self::out_file`] documents for itself.
709 ///
710 /// Sticky across a respawn, deliberately: this is the daemon's answer
711 /// to "why did it last stop", not "is it stopped right now" — `status`
712 /// and `pid` already answer that, and a sheep back `Online` after a
713 /// crash still has a true story to tell about the crash that restarted
714 /// it. It updates only on the next exit, never cleared by one starting
715 /// back up.
716 pub last_exit: Option<ExitInfo>,
717 /// The marker a dog has asked to have painted beside this sheep, or
718 /// `None` when no dog has painted one — which also covers a peer daemon
719 /// that predates the field, the same skew rule [`Self::out_file`]
720 /// documents for itself.
721 ///
722 /// A `String` rather than a [`Smit`], deliberately: a client decoding a
723 /// listing from a daemon that already validated the text should not have
724 /// to re-run the parser, and [`ProcessInfo`] is a report rather than an
725 /// input. The validation that makes this safe to print happened at the
726 /// daemon's ingress — see [`Smit`] for why there and not at the renderer.
727 ///
728 /// Every instance of a name shows the same marker: smits are keyed by
729 /// sheep name, not by instance id.
730 pub smit: Option<String>,
731 /// Which instance slot of its app this sheep occupies, counting from 0.
732 ///
733 /// `None` when the peer daemon predates the field, the same skew rule
734 /// [`Self::out_file`] documents for itself. Deliberately not a bare
735 /// `u32` defaulted to 0: an app stocked to four instances would then
736 /// report four rows all claiming slot 0, which is the silently-wrong
737 /// zero [`Self::dog`] warns against. A reader that finds `None` should
738 /// render exactly what it rendered before this field existed.
739 pub instance: Option<u32>,
740}
741
742/// Orders one flock listing the way every operator-facing surface presents
743/// one: by name, then by instance slot, then by id.
744///
745/// # Why name first
746///
747/// An id is assigned at registration, so ordering by it sorts the flock by
748/// an accident of history rather than by anything an operator is looking
749/// for. It is not stable either: a `delete all` followed by a fresh start
750/// moved a real thirteen-app flock from ids 0-10 to 11-21 with nothing
751/// about the apps having changed. A name is what an operator scans a long
752/// listing for, and it survives that churn.
753///
754/// # Why id breaks the tie
755///
756/// A name is unique to an APP, not to a sheep: an app stocked to four
757/// instances puts four rows under one name. Name alone is therefore not a
758/// total order, and an unstable sort would let those four shuffle between
759/// refreshes — visible in `shep flock` and worse in `shep lookout`, which
760/// repolls every two seconds. The id keeps its other job unchanged: it is
761/// still how an operator addresses one instance at `shep stop 11`. It stops
762/// being a sort key and stays an addressing key.
763///
764/// This is the ONLY ordering rule in shep, and the daemon's own
765/// `snapshot_all` calls this function rather than restating it. The order is
766/// `(name, instance, id)`: [`ProcessInfo::instance`] now carries the slot a
767/// row occupies, so a reload that hands slot 0 a fresh id no longer moves
768/// that row out of place. A listing whose rows all carry `None` (an older
769/// peer daemon, or a row with no slot to report) collapses to the same
770/// `(name, id)` order this function used before the field existed, because
771/// `None` sorts before every `Some` and every row in that listing shares it.
772pub fn sort_flock(listing: &mut [ProcessInfo]) {
773 listing.sort_unstable_by(|a, b| {
774 (a.name.as_str(), a.instance, a.id).cmp(&(b.name.as_str(), b.instance, b.id))
775 });
776}
777
778impl ProcessInfo {
779 /// Starts a builder for one sheep's row.
780 ///
781 /// The three required arguments are the three fields no row can omit and
782 /// no reader can default: which sheep this is, what it is called, and
783 /// what state it is in. Everything else is optional, derived, or
784 /// meaningfully absent, which is exactly the shape a builder is for —
785 /// a nine-argument `new` would put `Option<String>, Option<String>,
786 /// Option<f32>, Option<u64>` next to each other at every call site and
787 /// invite a silent transposition the type system could not catch.
788 ///
789 /// No `#[must_use]` here: [`ProcessInfoBuilder`] already carries one,
790 /// which clippy's `double_must_use` lint treats as covering this
791 /// function's return too.
792 pub fn builder(id: u32, name: impl Into<String>, status: ProcStatus) -> ProcessInfoBuilder {
793 ProcessInfoBuilder {
794 info: Self {
795 id,
796 name: name.into(),
797 status,
798 pid: None,
799 restarts: 0,
800 uptime_ms: 0,
801 fold: None,
802 out_file: None,
803 err_file: None,
804 cpu_percent: None,
805 memory_bytes: None,
806 dog: None,
807 lambs: None,
808 last_exit: None,
809 smit: None,
810 instance: None,
811 },
812 }
813 }
814}
815
816/// Builds a [`ProcessInfo`], which is `#[non_exhaustive]` and so cannot be
817/// written as a struct literal outside this crate.
818///
819/// Every setter takes the field's own type, `Option` included, rather than
820/// the unwrapped value. That is deliberate and it is the difference between a
821/// straight port and a rewrite: the daemon already holds `Option<u32>` for a
822/// pid and `Option<f32>` for a CPU reading, so `.pid(entry.pid())` carries
823/// across unchanged where `.pid(u32)` would put an `if let` ladder at every
824/// call site. A setter is skipped, not passed `None`, when a row genuinely
825/// has nothing to say about that field.
826///
827/// Defaults for the skipped fields are the ones a not-yet-running sheep has:
828/// no pid, no uptime, no restarts, no resource reading, not a dog, never
829/// exited.
830#[derive(Debug, Clone)]
831#[must_use = "a builder that is never `build`-ed produces no ProcessInfo"]
832pub struct ProcessInfoBuilder {
833 info: ProcessInfo,
834}
835
836impl ProcessInfoBuilder {
837 /// Sets the OS pid; `None` while the sheep is not running.
838 pub fn pid(mut self, pid: Option<u32>) -> Self {
839 self.info.pid = pid;
840 self
841 }
842
843 /// Sets the restart count since registration.
844 pub fn restarts(mut self, restarts: u32) -> Self {
845 self.info.restarts = restarts;
846 self
847 }
848
849 /// Sets milliseconds since the last successful start.
850 pub fn uptime_ms(mut self, uptime_ms: u64) -> Self {
851 self.info.uptime_ms = uptime_ms;
852 self
853 }
854
855 /// Sets fold membership.
856 pub fn fold(mut self, fold: Option<String>) -> Self {
857 self.info.fold = fold;
858 self
859 }
860
861 /// Sets the resolved stdout log path.
862 pub fn out_file(mut self, out_file: Option<String>) -> Self {
863 self.info.out_file = out_file;
864 self
865 }
866
867 /// Sets the resolved stderr log path.
868 pub fn err_file(mut self, err_file: Option<String>) -> Self {
869 self.info.err_file = err_file;
870 self
871 }
872
873 /// Sets tree CPU as a percentage of one core.
874 pub fn cpu_percent(mut self, cpu_percent: Option<f32>) -> Self {
875 self.info.cpu_percent = cpu_percent;
876 self
877 }
878
879 /// Sets tree resident set size in bytes.
880 pub fn memory_bytes(mut self, memory_bytes: Option<u64>) -> Self {
881 self.info.memory_bytes = memory_bytes;
882 self
883 }
884
885 /// Marks this row a dog and names where the dog came from.
886 pub fn dog(mut self, dog: Option<DogSource>) -> Self {
887 self.info.dog = dog;
888 self
889 }
890
891 /// Sets the sheep's lamb list; `None` when this reply did not walk for one.
892 pub fn lambs(mut self, lambs: Option<Vec<Lamb>>) -> Self {
893 self.info.lambs = lambs;
894 self
895 }
896
897 /// Sets how this sheep's process most recently stopped; `None` while it
898 /// has never exited under this daemon.
899 pub fn last_exit(mut self, last_exit: Option<ExitInfo>) -> Self {
900 self.info.last_exit = last_exit;
901 self
902 }
903
904 /// Sets the marker a dog has painted on this sheep; `None` when none has.
905 pub fn smit(mut self, smit: Option<String>) -> Self {
906 self.info.smit = smit;
907 self
908 }
909
910 /// Sets the instance slot; `None` when the peer daemon predates the field.
911 pub fn instance(mut self, instance: Option<u32>) -> Self {
912 self.info.instance = instance;
913 self
914 }
915
916 /// Finishes the row.
917 #[must_use]
918 pub fn build(self) -> ProcessInfo {
919 self.info
920 }
921}
922
923/// What happened when the daemon tried to deliver one sheep's triggered
924/// action.
925///
926/// `#[non_exhaustive]`: a future outcome — distinguishing a malformed reply
927/// from a well-formed one, say, or a second trigger already in flight for
928/// the same sheep — must not need a protocol version bump (IR-20).
929// wire format: changing existing variants is a breaking change
930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
931#[serde(tag = "kind", rename_all = "snake_case")]
932#[non_exhaustive]
933pub enum ActionOutcome {
934 /// The app answered on the shepherd channel.
935 Replied {
936 /// The reply body, exactly as the app sent it.
937 body: String,
938 },
939 /// The sheep had no reachable shepherd channel for the daemon to
940 /// deliver the action over.
941 NoChannel,
942 /// The sheep is a reload drainee — mid-swap, on its way out — and the
943 /// daemon skipped it rather than deliver the action to a process
944 /// already being replaced.
945 Skipped,
946 /// The daemon delivered the action, but no reply arrived before the
947 /// app's configured action timeout elapsed.
948 TimedOut,
949}
950
951/// One matched sheep's row in a `Trigger` reply.
952///
953/// `EmptiedFile` (`crates/shep-cli/src/output/rows.rs`) is the precedent for
954/// a non-`ProcessInfo` row: a reply body has nowhere to live on
955/// [`ProcessInfo`], and [`Self::outcome`] is per-row rather than a
956/// whole-request refusal because spec §9's selector grammar (`all`,
957/// `/regex/`, `fold:`) makes a mixed flock the normal case — the same reason
958/// `Reopen`/`Flush` report per-item failure inside a success rather than
959/// failing the whole request.
960// wire format: changing this is a breaking change
961#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
962pub struct ActionReply {
963 /// The sheep's stable id.
964 pub id: u32,
965 /// The sheep's name.
966 pub name: String,
967 /// What happened when the daemon tried to deliver the action.
968 pub outcome: ActionOutcome,
969}
970
971/// What happened when the shepherd tried to deliver one signal.
972///
973/// `#[non_exhaustive]`: a future outcome — a sheep refused because it is a dog,
974/// say, or a delivery held while a stop ladder runs — must not need a protocol
975/// version bump (IR-20).
976// wire format: changing existing variants is a breaking change
977#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
978#[serde(tag = "kind", rename_all = "snake_case")]
979#[non_exhaustive]
980pub enum SignalOutcome {
981 /// The kernel accepted the signal for this sheep's pid.
982 ///
983 /// Says the signal was delivered, not that the app did anything with it.
984 /// A signal the app blocks, ignores, or has no handler for is `Delivered`
985 /// exactly like one it acts on — there is nothing on this path that could
986 /// tell the difference, and pretending otherwise would be the dishonest
987 /// half of an honest report.
988 Delivered,
989 /// The sheep is registered but has no live process to signal — stopped,
990 /// errored, or waiting out a restart backoff.
991 NotRunning,
992 /// The kernel refused the delivery; carries its reason (`ESRCH` for a
993 /// process reaped between the lookup and the syscall, `EPERM` for one this
994 /// daemon may not signal).
995 Failed {
996 /// The refusal, as the OS worded it.
997 reason: String,
998 },
999}
1000
1001/// One matched sheep's row in a `Signal` reply.
1002///
1003/// Shaped exactly like [`ActionReply`] and for the same reason: spec §9's
1004/// selector grammar (`all`, `/regex/`, `fold:`) makes a mixed flock the normal
1005/// case, so a per-row outcome beats a whole-request refusal that would leave
1006/// the operator unable to tell which half was taken.
1007// wire format: changing this is a breaking change
1008#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1009pub struct SignalReply {
1010 /// The sheep's stable id.
1011 pub id: u32,
1012 /// The sheep's name.
1013 pub name: String,
1014 /// What happened when the shepherd tried to deliver the signal.
1015 pub outcome: SignalOutcome,
1016}
1017
1018/// What happened when the shepherd tried to write one line to a sheep's stdin.
1019///
1020/// `#[non_exhaustive]`: a future outcome — a sheep refused because its pipe is
1021/// backed up, say — must not need a protocol version bump (IR-20).
1022// wire format: changing existing variants is a breaking change
1023#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1024#[serde(tag = "kind", rename_all = "snake_case")]
1025#[non_exhaustive]
1026pub enum LineOutcome {
1027 /// The line was written to the pipe and flushed.
1028 ///
1029 /// Says the bytes left the shepherd, not that the app read them. A pipe
1030 /// holds 64 KiB before it blocks, so a short line to an app that never
1031 /// reads its stdin is `Sent` — which is honest, because there is nothing
1032 /// on this path that could tell the difference and a supervisor inventing
1033 /// one would be guessing.
1034 Sent,
1035 /// The sheep has no stdin pipe: its config does not set `stdin = true`, or
1036 /// it is not running.
1037 ///
1038 /// One outcome for two causes, deliberately. The row is read to answer
1039 /// "why did my line not arrive", and both answers are "there is no pipe
1040 /// here"; splitting them would put the operator in front of a distinction
1041 /// with the same fix behind it. A sheep that is not running is visible as
1042 /// such in `shep flock`, which is where that question belongs.
1043 NoStdin,
1044 /// The shepherd had a pipe and did not confirm a write to it; carries
1045 /// why.
1046 ///
1047 /// Three shapes reach it: the write failed (the far end is gone —
1048 /// normally the app exiting between the lookup and the write), the line
1049 /// arrived to find the sheep's queue already full, or the write did not
1050 /// finish inside the shepherd's own bound. The reason names which,
1051 /// because the operator's next move differs.
1052 ///
1053 /// # The last shape does not promise the line was never written
1054 ///
1055 /// "Did not confirm", not "could not write", and the difference is the
1056 /// operator's whole decision about retrying. A write that timed out is a
1057 /// write the shepherd stopped WAITING for: the bytes may be part-written
1058 /// into a pipe the app is not draining, and they land in full the moment
1059 /// it does. There is no way to take them back — abandoning a write
1060 /// halfway would leave a partial line in the pipe, which is worse than a
1061 /// slow one.
1062 ///
1063 /// What the shepherd does do is drop a line still QUEUED behind that one
1064 /// once its caller has given up, so retrying a `sendline` cannot pile
1065 /// duplicates up behind a wedged pipe and deliver them together later.
1066 /// The first line of a retry sequence is the one that can still arrive
1067 /// late; treat a retry as a second command, not a repeat of the first.
1068 NotWritten {
1069 /// What went wrong, in plain English.
1070 reason: String,
1071 },
1072}
1073
1074/// One matched sheep's row in a `SendLine` reply.
1075///
1076/// Same shape and same argument as [`ActionReply`] and [`SignalReply`]: spec
1077/// §9's selector grammar makes a mixed flock the normal case, so an outcome
1078/// per row beats a whole-request refusal.
1079// wire format: changing this is a breaking change
1080#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1081pub struct LineReply {
1082 /// The sheep's stable id.
1083 pub id: u32,
1084 /// The sheep's name.
1085 pub name: String,
1086 /// What happened.
1087 pub outcome: LineOutcome,
1088}
1089
1090/// A dog's `[dog.<name>]` config section, carried as TOML text.
1091///
1092/// This travels over the socket rather than the child's environment for
1093/// exactly one reason: a dog's section routinely holds webhook credentials
1094/// (a Discord or Slack URL with a bearer token embedded), and the socket
1095/// path keeps that out of the process table and out of crash dumps. A
1096/// derived `Debug` on [`Response`] would undo that the moment something
1097/// logs a reply — see the manual `Debug` below, which prints only a length.
1098///
1099/// `#[serde(transparent)]` makes the wire representation identical to a
1100/// bare `String`: this newtype changes nothing about
1101/// [`crate::protocol::PROTOCOL_VERSION`] or the pinned snapshot fixtures.
1102#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
1103#[serde(transparent)]
1104pub struct DogSectionToml(String);
1105
1106impl DogSectionToml {
1107 /// The TOML text, empty when the file has no such section.
1108 #[must_use]
1109 pub fn as_str(&self) -> &str {
1110 &self.0
1111 }
1112}
1113
1114impl From<String> for DogSectionToml {
1115 fn from(toml: String) -> Self {
1116 Self(toml)
1117 }
1118}
1119
1120impl core::ops::Deref for DogSectionToml {
1121 type Target = str;
1122
1123 fn deref(&self) -> &str {
1124 &self.0
1125 }
1126}
1127
1128/// Debug does not print the section body (IR-41) — see the type doc for why.
1129/// Exact-string-tested below (`dog_section_toml_debug_does_not_leak`) so a
1130/// future `#[derive(Debug)]` fails that test instead of silently reopening
1131/// the leak.
1132impl fmt::Debug for DogSectionToml {
1133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1134 write!(f, "DogSectionToml(<{} bytes>)", self.0.len())
1135 }
1136}
1137
1138/// One registered sheep whose stored config differs from a caller's copy:
1139/// the answer [`Request::ConfigDrift`] is asking for
1140///
1141/// Field NAMES only, never their values. This is built to be printed at an
1142/// operator, and [`AppConfig::env`](crate::config::AppConfig::env) carries
1143/// secrets, so a differing `env` reports `"env"` and nothing more (IR-41).
1144/// `Debug` is derived for that reason: there is nothing here to redact.
1145// wire format: changing field names is a breaking change
1146//
1147// `#[non_exhaustive]`: shep-core is a published library, an out-of-tree
1148// consumer can match or construct this exhaustively today, and a third field
1149// (which side is newer, say) would break them with no version bump to say
1150// so (IR-20). [`SheepDrift::new`] is how the daemon builds one.
1151#[non_exhaustive]
1152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1153pub struct SheepDrift {
1154 /// The sheep's name. Both configs share it by construction: it is what
1155 /// matched them to each other.
1156 pub name: String,
1157 /// The [`AppConfig`] fields that differ, in
1158 /// field-name order. Never empty: a sheep with nothing to report is left
1159 /// out of the answer entirely.
1160 pub fields: Vec<String>,
1161}
1162
1163impl SheepDrift {
1164 /// Builds one sheep's report.
1165 ///
1166 /// No builder, unlike [`ProcessInfo`]: both fields are required and
1167 /// neither can be defaulted, so there is no optional surface for one to
1168 /// spare a caller.
1169 #[must_use]
1170 pub fn new(name: impl Into<String>, fields: Vec<String>) -> Self {
1171 Self {
1172 name: name.into(),
1173 fields,
1174 }
1175 }
1176}
1177
1178/// One RPC response (pairs with [`Request`] variants)
1179///
1180/// Ten variants carry a bare `Vec<ProcessInfo>` (`Flock`, `Described`,
1181/// `Started`, `Stopped`, `Restarted`, `Reloading`, `Scaled`, `Reopened`,
1182/// `Flushed`, `Mustered`), and that repetition is intentional — do not
1183/// collapse them into one. Each names which request it answers, which is what
1184/// lets a variant diverge later without a protocol bump: `Reloading` already
1185/// means an acceptance rather than a result, `Scaled` already means only the
1186/// survivors on a scale-down rather than every matched row, and `Mustered`
1187/// already means "every sheep of every restored app" rather than "what this
1188/// call started". A single `Listing(Vec<ProcessInfo>)` would have to
1189/// relitigate all three as a breaking change.
1190// wire format: changing existing variants is a breaking change
1191//
1192// `large_enum_variant` allowed, not fixed: `DogStarted` holds a whole
1193// `ProcessInfo` inline where every other variant holds a `Vec` of them, and
1194// adding `smit` to that struct is what pushed the spread past the lint's
1195// threshold. Clippy's remedy is to box the payload, which would be a source
1196// break for every `Response::DogStarted(info)` in and out of this workspace
1197// — for nothing: a `Response` is built once per reply and serialized
1198// immediately, so the size it occupies on one stack frame in between is not
1199// a cost anybody pays. The wire shape is identical either way.
1200#[allow(clippy::large_enum_variant)]
1201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1202#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
1203#[non_exhaustive]
1204pub enum Response {
1205 /// Answer to `Ping`
1206 Pong,
1207 /// Answer to `ListFlock`
1208 Flock(Vec<ProcessInfo>),
1209 /// Answer to `Describe`
1210 Described(Vec<ProcessInfo>),
1211 /// Answer to `Start`
1212 Started(Vec<ProcessInfo>),
1213 /// Answer to `ConfigDrift`: one entry per app that is registered under a
1214 /// config different from the one asked about, and no entry for anything
1215 /// else. An empty vector means every app asked about either matches or
1216 /// is not registered at all.
1217 Drifted(Vec<SheepDrift>),
1218 /// Answer to `Stop`
1219 Stopped(Vec<ProcessInfo>),
1220 /// Answer to `Restart`
1221 Restarted(Vec<ProcessInfo>),
1222 /// Answer to `Reload` — an ACCEPTANCE, not a result, and the only reply
1223 /// in this enum carrying a flock listing that names one rather than
1224 /// finished work. [`Self::ShuttingDown`] is an acceptance too, sent
1225 /// before the daemon actually goes down, but it carries nothing.
1226 ///
1227 /// One instance costs a readiness wait plus a drain in the worst case, so
1228 /// a clustered app outlasts any deadline a client is allowed to ask for.
1229 /// The daemon therefore answers as soon as the reload is accepted, with
1230 /// the matched sheep as they stood at that moment, and the swaps report
1231 /// themselves on the bus — `process.reload`, `process.reloaded`,
1232 /// `process.reload_abandoned`. A matched sheep with nothing to replace is
1233 /// listed here as the no-op success it is, so this carries the same
1234 /// matches `Describe` would.
1235 Reloading(Vec<ProcessInfo>),
1236 // The order is cited as [`sort_flock`]'s shared rule rather than restated
1237 // as this reply's own, so the two cannot drift apart.
1238 /// Answer to `Scale` — the app's instances that will REMAIN, one row
1239 /// each, by name, then by instance slot, then by id ([`sort_flock`]).
1240 /// Every row shares one name here, so in practice that is slot order,
1241 /// with the id breaking a tie only where two rows report the same slot.
1242 ///
1243 /// Scaling up, these are the instances that exist, the new ones included,
1244 /// and the answer is complete.
1245 ///
1246 /// Scaling down, these are the survivors and the departing instances are
1247 /// deliberately absent, even though they are still running their kill
1248 /// ladders as this reply is written. The operator asked for a number; this
1249 /// is that number of rows. Listing the departing ones as well would answer
1250 /// a `scale web 2` with four rows, which is the one thing the reply must
1251 /// not do. The departures report themselves on the bus as `process.delete`
1252 /// — the same split `Reloading` already makes between an acceptance and
1253 /// the swaps that follow it.
1254 Scaled(Vec<ProcessInfo>),
1255 /// Answer to `SetSmit` — every instance of the named sheep, one row
1256 /// each, each carrying the smit as it now stands.
1257 ///
1258 /// Its own variant rather than one of the ten above, on this enum's own
1259 /// stated terms: each of them names which request it answers so that one
1260 /// can diverge later without a protocol bump. A future `SetSmit` reply
1261 /// that also reported which connection holds the mark would have nowhere
1262 /// to go if this shared `Scaled`.
1263 SmitPainted(Vec<ProcessInfo>),
1264 /// Answer to `Delete` — ids removed
1265 Deleted(Vec<u32>),
1266 /// Answer to `Reopen` — every matched sheep, running or not. A sheep with
1267 /// no live log pump has nothing to reopen and is reported as a success,
1268 /// so this carries the same matches `Describe` would.
1269 Reopened(Vec<ProcessInfo>),
1270 /// Answer to `Flush` — one row per matched sheep, running or not, exactly
1271 /// as [`Self::Reopened`].
1272 ///
1273 /// One row per SHEEP, not per file emptied. Several sheep can share one
1274 /// log path (`merge_logs`, or an explicit `out_file` on a multi-instance
1275 /// app), and the daemon truncates each distinct path once — but the
1276 /// selector names sheep, so the answer names sheep, and the count here
1277 /// matches what `Describe` would return for the same selector.
1278 Flushed(Vec<ProcessInfo>),
1279 /// Answer to `Trigger` — one [`ActionReply`] row per matched sheep,
1280 /// carrying what each one answered rather than a flock listing:
1281 /// `ProcessInfo` has nowhere to hold a reply body.
1282 Triggered(Vec<ActionReply>),
1283 /// Answer to `Signal` — one [`SignalReply`] row per matched sheep.
1284 ///
1285 /// Not a flock listing: what a caller wants back is per-instance delivery,
1286 /// and [`ProcessInfo`] has nowhere to hold it. Same reasoning, and the
1287 /// same row-shaped answer, as [`Self::Triggered`].
1288 Signalled(Vec<SignalReply>),
1289 /// Answer to `SendLine` — one [`LineReply`] row per matched sheep.
1290 SentLine(Vec<LineReply>),
1291 /// Answer to `SaveRoll`
1292 RollSaved {
1293 /// Absolute path of the roll the daemon wrote
1294 path: String,
1295 /// How many apps that roll records
1296 apps: u32,
1297 },
1298 /// Answer to `Muster` — every sheep of every app the roll restored, not
1299 /// only the ones this call spawned.
1300 ///
1301 /// The distinction is the whole point of the reply. Assembling a flock
1302 /// that is already assembled starts nothing, so a listing of what this
1303 /// call spawned would be empty there — indistinguishable from an empty
1304 /// roll, which is the one outcome an operator needs to tell apart.
1305 Mustered(Vec<ProcessInfo>),
1306 /// Answer to `DogConfig` — the dog's own section, rendered back to TOML.
1307 ///
1308 /// `toml` is [`DogSectionToml`], not a bare `String`: this text
1309 /// routinely carries webhook credentials, and the newtype's manual
1310 /// `Debug` keeps them out of a `{:?}`-formatted `Response` — see that
1311 /// type's docs for why the section travels over the socket at all.
1312 DogSection {
1313 /// The `[dog.<name>]` table as TOML text, empty when the file has
1314 /// no such section
1315 toml: DogSectionToml,
1316 },
1317 /// Answer to `EnableDog` — the dog as it stands now
1318 DogStarted(ProcessInfo),
1319 /// Answer to `HandoverFitness`: `None` when the whole flock can be
1320 /// carried across a daemon handover, and otherwise the sentence saying
1321 /// which sheep cannot be and why.
1322 ///
1323 /// A rendered sentence rather than a structured reason, deliberately. The
1324 /// set of things a handover cannot yet carry is exactly the set of things
1325 /// that phase has not built, so it changes with every phase that widens
1326 /// it, and a wire enum would make each of those a protocol change for a
1327 /// string the client does nothing with but print. The daemon owns the
1328 /// wording because the daemon owns the gate.
1329 HandoverFitness {
1330 /// Why the flock cannot be handed over in place, or `None` when it
1331 /// can.
1332 refusal: Option<String>,
1333 },
1334 /// Answer to `Subscribe`
1335 Subscribed,
1336 /// Answer to `KillDaemon`
1337 ShuttingDown,
1338}
1339
1340/// A request frame
1341// wire format: changing this is a breaking change
1342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1343pub struct Envelope {
1344 /// Per-connection request id
1345 pub id: u64,
1346 /// Client-imposed deadline (daemon aborts work past it)
1347 pub deadline_ms: Option<u64>,
1348 /// The request
1349 pub body: Request,
1350}
1351
1352/// A reply frame
1353///
1354/// `result` uses serde's stock `Result` representation — the wire carries
1355/// `{"Ok": ...}` / `{"Err": ...}` (capitalized keys). Deliberate, pinned by
1356/// snapshot: stock serde beats a custom enum the client would convert anyway.
1357// wire format: changing this is a breaking change
1358#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1359pub struct Reply {
1360 /// Echoes [`Envelope::id`]
1361 pub id: u64,
1362 /// The outcome
1363 pub result: Result<Response, RpcError>,
1364}
1365
1366/// Handshake outcome: `HelloAck` or a typed refusal (spec §6 —
1367/// version skew is an error, not silence). Same `Ok`/`Err` wire shape
1368/// as [`Reply::result`]; refusals use [`RpcErrorCode::ProtocolMismatch`].
1369pub type HelloReply = Result<HelloAck, RpcError>;
1370
1371/// Structured RPC failure
1372// wire format: changing this is a breaking change
1373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1374pub struct RpcError {
1375 /// Machine-readable code
1376 pub code: RpcErrorCode,
1377 /// Human-readable message (plain English, no theme)
1378 pub message: String,
1379 /// The daemon's own crate version, when it chose to name it.
1380 ///
1381 /// Set on a [`RpcErrorCode::ProtocolMismatch`] refusal, where it is the
1382 /// only place a client can learn it: the refusal reports the daemon's
1383 /// PROTOCOL, and [`HelloAck::daemon_version`] never arrives. `shep
1384 /// daemon reload` picks its mechanism by version, and a protocol bump is
1385 /// exactly when that choice matters.
1386 ///
1387 /// `None` on every other error, and on any refusal from a daemon built
1388 /// before this field existed — which no upgrade can change, so a reader
1389 /// must treat `None` as "unknown" and take the conservative path.
1390 ///
1391 /// Additive by construction: absent on the wire rather than `null`, and
1392 /// ignored by a client too old to know it, so
1393 /// [`crate::protocol::PROTOCOL_VERSION`] does not move for it.
1394 #[serde(default, skip_serializing_if = "Option::is_none")]
1395 pub daemon_version: Option<String>,
1396}
1397
1398/// Machine-readable RPC error codes
1399// wire format: changing existing variants is a breaking change
1400#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1401#[serde(rename_all = "snake_case")]
1402#[non_exhaustive]
1403pub enum RpcErrorCode {
1404 /// Selector matched nothing
1405 NotFound,
1406 /// Config failed validation daemon-side
1407 InvalidConfig,
1408 /// Spawn failed (exec error, permissions)
1409 SpawnFailed,
1410 /// Handshake protocol version mismatch
1411 ProtocolMismatch,
1412 /// Unexpected daemon-side failure
1413 Internal,
1414 /// The request's deadline expired before the daemon finished it
1415 DeadlineExceeded,
1416}
1417
1418impl RpcErrorCode {
1419 /// Every variant, for code that needs to iterate them all.
1420 ///
1421 /// `#[non_exhaustive]` forces a `_` arm on any match written outside
1422 /// this crate, which would silently swallow a variant added here and
1423 /// never updated there (shep-cli's exit-code mapping test is the
1424 /// motivating case — see `crates/shep-cli/src/exit.rs`). Downstream
1425 /// crates should iterate `ALL` instead of hand-writing their own list
1426 /// that the compiler can't check.
1427 ///
1428 /// Kept honest by a private `assert_all_lists_every_variant` fn right
1429 /// below: read that doc for how a forgotten variant is caught here,
1430 /// where `#[non_exhaustive]` has no effect.
1431 pub const ALL: [Self; 6] = [
1432 Self::NotFound,
1433 Self::InvalidConfig,
1434 Self::SpawnFailed,
1435 Self::ProtocolMismatch,
1436 Self::Internal,
1437 Self::DeadlineExceeded,
1438 ];
1439
1440 /// Never called; exists purely so this crate fails to build if a
1441 /// variant is added to [`RpcErrorCode`] without also adding it to
1442 /// [`Self::ALL`].
1443 ///
1444 /// `#[non_exhaustive]` only forces a wildcard arm on matches written
1445 /// *outside* this crate — inside the crate that defines the enum, a
1446 /// match with no `_` arm is still checked for exhaustiveness (E0004),
1447 /// so a new variant breaks this build until it gets an arm here. Each
1448 /// arm indexes a fixed literal position into [`Self::ALL`], so growing
1449 /// the enum without growing the array is caught too: rustc denies an
1450 /// out-of-bounds constant array index by default.
1451 #[allow(dead_code)]
1452 const fn assert_all_lists_every_variant(code: Self) -> Self {
1453 match code {
1454 Self::NotFound => Self::ALL[0],
1455 Self::InvalidConfig => Self::ALL[1],
1456 Self::SpawnFailed => Self::ALL[2],
1457 Self::ProtocolMismatch => Self::ALL[3],
1458 Self::Internal => Self::ALL[4],
1459 Self::DeadlineExceeded => Self::ALL[5],
1460 }
1461 }
1462}
1463
1464#[cfg(test)]
1465mod tests {
1466 use super::*;
1467 use crate::config::AppConfig;
1468 use crate::protocol::PROTOCOL_VERSION;
1469 use crate::status::ProcStatus;
1470
1471 fn sample_info() -> ProcessInfo {
1472 ProcessInfo {
1473 id: 3,
1474 name: "web".to_string(),
1475 status: ProcStatus::Online,
1476 pid: Some(4242),
1477 restarts: 1,
1478 uptime_ms: 60_000,
1479 fold: Some("backend".to_string()),
1480 out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
1481 err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
1482 // 12.5 rather than a rounder-looking 12.3: an insta JSON
1483 // snapshot is only stable across platforms for a float the
1484 // binary representation holds exactly.
1485 cpu_percent: Some(12.5),
1486 memory_bytes: Some(48 * 1024 * 1024),
1487 dog: None,
1488 lambs: None,
1489 // `restarts: 1` above already says this sheep crashed once and
1490 // came back; a code rather than `None` is the honest exit that
1491 // caused it, not a fact this fixture invents.
1492 last_exit: Some(ExitInfo {
1493 code: Some(1),
1494 signal: None,
1495 }),
1496 smit: None,
1497 instance: None,
1498 }
1499 }
1500
1501 /// fails if the builder's defaults drift from what a registered-but-not-yet
1502 /// running sheep actually looks like. A builder that quietly defaulted
1503 /// `uptime_ms` to something non-zero, or `restarts` to 1, would put a wrong
1504 /// number in front of an operator with nothing to compare it against.
1505 #[test]
1506 fn a_builder_with_nothing_set_is_a_sheep_that_has_not_run() {
1507 let info = ProcessInfo::builder(3, "web", ProcStatus::Stopped).build();
1508
1509 assert_eq!(info.id, 3);
1510 assert_eq!(info.name, "web");
1511 assert_eq!(info.status, ProcStatus::Stopped);
1512 assert_eq!(info.pid, None);
1513 assert_eq!(info.restarts, 0);
1514 assert_eq!(info.uptime_ms, 0);
1515 assert_eq!(info.fold, None);
1516 assert_eq!(info.out_file, None);
1517 assert_eq!(info.err_file, None);
1518 assert_eq!(info.cpu_percent, None);
1519 assert_eq!(info.memory_bytes, None);
1520 assert_eq!(info.dog, None);
1521 assert_eq!(info.lambs, None);
1522 assert_eq!(info.last_exit, None);
1523 }
1524
1525 /// fails if any setter writes a field other than its own — the failure a
1526 /// twelve-field builder is most likely to ship, and one no individual
1527 /// round-trip test would catch. Every field is given a value distinct from
1528 /// every other field's default, so a copy-pasted setter body shows up as a
1529 /// mismatch rather than as a coincidence.
1530 #[test]
1531 fn every_setter_writes_its_own_field_and_no_other() {
1532 let built = ProcessInfo::builder(3, "web", ProcStatus::Online)
1533 .pid(Some(4242))
1534 .restarts(1)
1535 .uptime_ms(60_000)
1536 .fold(Some("backend".to_string()))
1537 .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
1538 .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
1539 .cpu_percent(Some(12.5))
1540 .memory_bytes(Some(48 * 1024 * 1024))
1541 .dog(None)
1542 .last_exit(Some(ExitInfo {
1543 code: Some(1),
1544 signal: None,
1545 }))
1546 .build();
1547
1548 // `sample_info()` is still a struct literal, on purpose: it is the one
1549 // place in the workspace that names every field by hand, so this
1550 // comparison fails the day the struct grows a field the builder cannot
1551 // set. That is the point of comparing against it rather than against
1552 // another builder call.
1553 assert_eq!(built, sample_info());
1554
1555 // `dog` is the one field the comparison above cannot speak for, and it
1556 // is the field the whole dogs subsystem reads. `sample_info()`'s `dog`
1557 // is `None`, which is also the builder's default, so a `dog` setter with
1558 // an EMPTY BODY passes the assert_eq! above and passes it for the wrong
1559 // reason. `sample_info()` cannot be changed to `Some(..)` to fix that —
1560 // it feeds `reply_wire_snapshots` and `bus_event_wire_snapshots`, so
1561 // altering it moves pinned bytes. So the field gets its own line, with a
1562 // value nothing defaults to.
1563 assert_eq!(
1564 ProcessInfo::builder(1, "metrics", ProcStatus::Online)
1565 .dog(Some(DogSource::BuiltIn))
1566 .build()
1567 .dog,
1568 Some(DogSource::BuiltIn),
1569 "an empty `dog` setter body is invisible to the comparison above"
1570 );
1571
1572 // `lambs` is the second field the comparison above cannot speak for,
1573 // for the identical reason `dog` is the first: `sample_info()`'s value
1574 // is `None`, which is also the builder's default, so an EMPTY `lambs`
1575 // setter body passes the `assert_eq!` above. And `sample_info()` still
1576 // cannot be changed to a `Some(..)` — it feeds `reply_wire_snapshots`
1577 // and `bus_event_wire_snapshots`, so altering it moves pinned bytes.
1578 assert_eq!(
1579 ProcessInfo::builder(1, "web", ProcStatus::Online)
1580 .lambs(Some(vec![Lamb::new(4243, "node")]))
1581 .build()
1582 .lambs,
1583 Some(vec![Lamb::new(4243, "node")]),
1584 "an empty `lambs` setter body is invisible to the comparison above"
1585 );
1586
1587 // `smit` is the third, on the same terms, and it is the field a
1588 // third party writes — so an empty setter body here would silently
1589 // drop every dog's mark rather than merely lose a decoration.
1590 assert_eq!(
1591 ProcessInfo::builder(1, "web", ProcStatus::Online)
1592 .smit(Some("\u{25b2} main@a1b2c3".to_string()))
1593 .build()
1594 .smit
1595 .as_deref(),
1596 Some("\u{25b2} main@a1b2c3"),
1597 "an empty `smit` setter body is invisible to the comparison above"
1598 );
1599 }
1600
1601 /// fails if `lambs` collapses to a bare `Vec`. The three states are the point:
1602 /// a peer that predates the field and a reply that did not walk the tree are
1603 /// both `None`, and a sheep that really has no children is `Some(vec![])`. A
1604 /// `Vec` would render the first two as "this sheep has no lambs", which is a
1605 /// claim neither of them makes.
1606 #[test]
1607 fn lambs_distinguishes_not_walked_from_walked_and_empty() {
1608 let not_walked = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
1609 assert_eq!(not_walked.lambs, None);
1610
1611 let walked_empty = ProcessInfo::builder(1, "web", ProcStatus::Online)
1612 .lambs(Some(Vec::new()))
1613 .build();
1614 assert_eq!(walked_empty.lambs, Some(Vec::new()));
1615 }
1616
1617 /// fails if a `ProcessInfo` from a daemon that predates the field stops
1618 /// deserializing. That is the whole reason the field is optional and the reason
1619 /// `PROTOCOL_VERSION` does not move for it — an old daemon's reply carries no
1620 /// `lambs` key at all, and a required field there would mean a new client could
1621 /// not list against an old daemon.
1622 #[test]
1623 fn a_process_info_without_a_lambs_key_still_deserializes() {
1624 let fixture = r#"{
1625 "id": 3, "name": "web", "status": "online", "pid": 4242,
1626 "restarts": 0, "uptime_ms": 100, "fold": null,
1627 "out_file": null, "err_file": null,
1628 "cpu_percent": null, "memory_bytes": null, "dog": null
1629 }"#;
1630 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1631 assert_eq!(info.lambs, None);
1632 }
1633
1634 /// fails if a lamb stops carrying its name, or starts carrying a command line.
1635 /// The name is `sysinfo`'s executable name, never argv — argv routinely holds
1636 /// credentials (`--password=`, `?token=`) and `shep describe --format json` is
1637 /// output people paste into issues.
1638 #[test]
1639 fn a_lamb_is_a_pid_and_an_executable_name() {
1640 let lamb = Lamb::new(4243, "node");
1641 let json = serde_json::to_string(&lamb).unwrap();
1642 assert_eq!(json, r#"{"pid":4243,"name":"node"}"#);
1643 assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
1644 }
1645
1646 /// fails if `DogSource` loses its `tag = "kind"` or its snake_case
1647 /// rename, and fails if `Adopted`'s `path` is renamed — any of the three
1648 /// changes one of these two strings while every type-level test in this
1649 /// module keeps passing. The marker is what the CLI splits two tables on
1650 /// and what the metrics dog reports a health gauge from, so a silent
1651 /// rename here is a silently empty dogs table.
1652 #[test]
1653 fn a_dog_source_serializes_snake_case_under_its_kind() {
1654 assert_eq!(
1655 serde_json::to_string(&DogSource::BuiltIn).unwrap(),
1656 r#"{"kind":"built_in"}"#
1657 );
1658 let adopted = DogSource::Adopted {
1659 path: "/usr/local/bin/shep-otel".to_string(),
1660 };
1661 let wire = r#"{"kind":"adopted","path":"/usr/local/bin/shep-otel"}"#;
1662 assert_eq!(serde_json::to_string(&adopted).unwrap(), wire);
1663 assert_eq!(serde_json::from_str::<DogSource>(wire).unwrap(), adopted);
1664 }
1665
1666 /// fails if `dog` stops being optional. A daemon built before dogs
1667 /// sends a reply with no such key and still announces protocol 1, so a
1668 /// required field would make a current client unable to list against it
1669 /// at all — the same skew rule `out_file` and `cpu_percent` are pinned
1670 /// under, and the same committed-byte-fixture proof.
1671 #[test]
1672 fn v1_process_info_without_a_dog_marker_still_deserializes() {
1673 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}"#;
1674 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1675 assert_eq!(info.dog, None);
1676 }
1677
1678 /// fails if `last_exit` stops being optional. A daemon built before this
1679 /// field sends a reply with no such key and still announces protocol 1 —
1680 /// the same skew rule every other field added after `Hello`/`HelloAck`
1681 /// were fixed is pinned under.
1682 ///
1683 /// This is also the empirical proof of a subtle point: none of
1684 /// `ProcessInfo`'s fields carry `#[serde(default)]`, and there is
1685 /// no container-level one either, yet the doc comments on `out_file` and
1686 /// `cpu_percent` both claim "`None` only when the peer daemon predates
1687 /// this field" as though one existed. Serde's `Deserialize` derive
1688 /// special-cases a field whose type is syntactically `Option<...>`: a
1689 /// missing key resolves to `None` without `#[serde(default)]` doing
1690 /// anything, because the derive macro recognizes the `Option` wrapper
1691 /// itself and generates that fallback for it. Those doc comments were
1692 /// right; they just named the wrong mechanism, or none. This test pins
1693 /// the real one for `last_exit` specifically — with `dog` and `lambs`
1694 /// present but `last_exit` genuinely absent from the JSON below — rather
1695 /// than leaving it as an inference from `v1_process_info_without_a_dog_
1696 /// marker_still_deserializes` above.
1697 #[test]
1698 fn a_process_info_without_a_last_exit_key_still_deserializes() {
1699 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}"#;
1700 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1701 assert_eq!(info.last_exit, None);
1702 }
1703
1704 /// fails if a `Signal` frame stops carrying the signal name as plain text, or
1705 /// if the outcome rows stop distinguishing their three cases. The name travels
1706 /// as a `String` on purpose (`AppConfig::kill_signal` does the same): the wire
1707 /// stays readable and the daemon re-validates, which it has to do anyway
1708 /// because peer input is untrusted.
1709 #[test]
1710 fn a_signal_request_and_its_reply_round_trip() {
1711 let request = Request::Signal {
1712 selector: SelectorSpec::Name("web".to_string()),
1713 signal: "SIGHUP".to_string(),
1714 };
1715 let json = serde_json::to_string(&request).unwrap();
1716 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1717
1718 let reply = Response::Signalled(vec![
1719 SignalReply {
1720 id: 1,
1721 name: "web".to_string(),
1722 outcome: SignalOutcome::Delivered,
1723 },
1724 SignalReply {
1725 id: 2,
1726 name: "web".to_string(),
1727 outcome: SignalOutcome::NotRunning,
1728 },
1729 SignalReply {
1730 id: 3,
1731 name: "api".to_string(),
1732 outcome: SignalOutcome::Failed {
1733 reason: "no such process".to_string(),
1734 },
1735 },
1736 ]);
1737 let json = serde_json::to_string(&reply).unwrap();
1738 assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1739 // The three tags, spelled out: a variant renamed in Rust changes these
1740 // strings mechanically, compiles clean, and breaks a client matching on
1741 // them with nothing to say why.
1742 assert!(json.contains(r#""kind":"delivered""#), "{json}");
1743 assert!(json.contains(r#""kind":"not_running""#), "{json}");
1744 assert!(json.contains(r#""kind":"failed""#), "{json}");
1745 }
1746
1747 /// fails if `Scale` grows a selector. It takes an app NAME, and that is the
1748 /// design: `instances` is a per-app number and instance slots are allocated
1749 /// per name-group, so `shep stock /web.*/ 4` would have to mean either four
1750 /// each or four total and there is no reading of it that is not a guess.
1751 #[test]
1752 fn a_scale_request_names_one_app_and_a_count() {
1753 let request = Request::Scale {
1754 name: "web".to_string(),
1755 count: 4,
1756 };
1757 let json = serde_json::to_string(&request).unwrap();
1758 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1759 assert!(json.contains(r#""kind":"scale""#), "{json}");
1760 assert!(json.contains(r#""name":"web""#), "{json}");
1761 // No `selector` key at all — the shape that says this verb is not one of
1762 // the selector-taking family.
1763 assert!(!json.contains("selector"), "{json}");
1764 }
1765
1766 /// fails if `Scaled` stops being distinguishable from the eight other replies
1767 /// carrying a bare `Vec<ProcessInfo>`. Each of those names which request it
1768 /// answers precisely so it can diverge later without a protocol bump — the
1769 /// enum's own doc says not to collapse them, and this is the test that notices.
1770 #[test]
1771 fn a_scaled_reply_carries_its_own_tag() {
1772 let json = serde_json::to_string(&Response::Scaled(vec![])).unwrap();
1773 assert_eq!(json, r#"{"kind":"scaled","data":[]}"#);
1774 }
1775
1776 /// fails if the three outcomes stop being tellable apart on the wire, or if
1777 /// `NotWritten` stops carrying its reason. That reason is the only thing that
1778 /// distinguishes "the app is not reading its stdin" from "the pipe broke", and
1779 /// the operator's next move differs between them.
1780 #[test]
1781 fn a_send_line_request_and_its_reply_round_trip() {
1782 let request = Request::SendLine {
1783 selector: SelectorSpec::Name("repl".to_string()),
1784 line: "reload-config".to_string(),
1785 };
1786 let json = serde_json::to_string(&request).unwrap();
1787 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1788
1789 let reply = Response::SentLine(vec![
1790 LineReply {
1791 id: 1,
1792 name: "repl".to_string(),
1793 outcome: LineOutcome::Sent,
1794 },
1795 LineReply {
1796 id: 2,
1797 name: "web".to_string(),
1798 outcome: LineOutcome::NoStdin,
1799 },
1800 LineReply {
1801 id: 3,
1802 name: "stuck".to_string(),
1803 outcome: LineOutcome::NotWritten {
1804 reason: "the app did not read its stdin within 2s".to_string(),
1805 },
1806 },
1807 ]);
1808 let json = serde_json::to_string(&reply).unwrap();
1809 assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1810 assert!(json.contains(r#""kind":"sent""#), "{json}");
1811 assert!(json.contains(r#""kind":"no_stdin""#), "{json}");
1812 assert!(json.contains("did not read its stdin"), "{json}");
1813 }
1814
1815 /// fails if a newline can ride inside the line. The wire carries ONE line and
1816 /// the writer appends the terminator, so an embedded newline would deliver two
1817 /// commands where the operator typed one — the shape that turns a typo into an
1818 /// unintended second instruction to a REPL.
1819 #[test]
1820 fn a_line_carrying_a_newline_is_still_one_field_on_the_wire() {
1821 let request = Request::SendLine {
1822 selector: SelectorSpec::All,
1823 line: "a\nb".to_string(),
1824 };
1825 let json = serde_json::to_string(&request).unwrap();
1826 // Escaped, not literal: the frame stays one JSON object. Rejecting it is
1827 // the daemon's job (see `shep whisper`), not serde's, and this pins that
1828 // the wire itself does not quietly split it.
1829 assert!(json.contains(r#""line":"a\nb""#), "{json}");
1830 assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1831 }
1832
1833 #[test]
1834 fn request_wire_snapshots() {
1835 let requests = vec![
1836 Envelope {
1837 id: 1,
1838 deadline_ms: Some(5000),
1839 body: Request::Ping,
1840 },
1841 Envelope {
1842 id: 2,
1843 deadline_ms: None,
1844 body: Request::ListFlock,
1845 },
1846 Envelope {
1847 id: 3,
1848 deadline_ms: None,
1849 body: Request::Stop {
1850 selector: SelectorSpec::Name("web".to_string()),
1851 },
1852 },
1853 Envelope {
1854 id: 4,
1855 deadline_ms: None,
1856 body: Request::Start {
1857 apps: vec![AppConfig::minimal("web", "./srv")],
1858 },
1859 },
1860 // `All` rather than a named sheep: it is the selector `shep
1861 // reopen` sends when given no argument, and the one a signal can
1862 // ever mean, so it is the row worth pinning.
1863 Envelope {
1864 id: 5,
1865 deadline_ms: None,
1866 body: Request::Reopen {
1867 selector: SelectorSpec::All,
1868 },
1869 },
1870 // Deliberately the same selector as the row above, so the two
1871 // log-plane rows differ by their `kind` and by nothing else: a
1872 // `Flush` that serialized under `reopen`'s tag — the shape a
1873 // copy-pasted variant takes — shows up here as two identical
1874 // objects rather than as a diff a reader has to compare field by
1875 // field. `shep flush` demands an explicit selector, so `all` is
1876 // not a default here the way it is for `reopen`; it is simply the
1877 // widest thing an operator can type.
1878 Envelope {
1879 id: 6,
1880 deadline_ms: None,
1881 body: Request::Flush {
1882 selector: SelectorSpec::All,
1883 },
1884 },
1885 // The same selector as the `stop` row above, for the reason the
1886 // pair above share theirs: `reload` is the third verb that
1887 // demands an explicit selector and replaces what it matches, so
1888 // the variant it would be copy-pasted from is `stop`. Serialized
1889 // under `stop`'s tag it shows up here as two identical objects
1890 // rather than as a diff a reader has to compare field by field.
1891 Envelope {
1892 id: 7,
1893 deadline_ms: None,
1894 body: Request::Reload {
1895 selector: SelectorSpec::Name("web".to_string()),
1896 },
1897 },
1898 // `action`/`params` here match the spec's own §9 example
1899 // (`trigger web set-log-level debug`) and channel.rs's
1900 // with-params fixture verbatim, so a reader tracing a trigger
1901 // from the CLI through the client↔daemon wire to the fd-3 wire
1902 // sees the same two strings at every hop rather than three
1903 // unrelated examples.
1904 Envelope {
1905 id: 8,
1906 deadline_ms: None,
1907 body: Request::Trigger {
1908 selector: SelectorSpec::Name("web".to_string()),
1909 action: "set-log-level".to_string(),
1910 params: Some("debug".to_string()),
1911 },
1912 },
1913 // The first fieldless verb added since `Ping`/`ListFlock`, and
1914 // pinned for that reason: a fieldless variant serializes as a
1915 // bare `{"kind":"..."}` with no `selector` key at all, so a
1916 // reader comparing this row against `stop`'s sees the whole
1917 // difference between the two shapes in one place.
1918 Envelope {
1919 id: 9,
1920 deadline_ms: None,
1921 body: Request::SaveRoll,
1922 },
1923 // Paired with the `save_roll` row above so the two halves of the
1924 // roll — the direction that writes it and the direction that
1925 // assembles from it — sit next to each other, differing by their
1926 // `kind` and by nothing else.
1927 Envelope {
1928 id: 10,
1929 deadline_ms: None,
1930 body: Request::Muster,
1931 },
1932 // The three dog verbs together, in the order an operator meets
1933 // them: ask for a section, start a dog, stop one. Adjacent on
1934 // purpose — `enable_dog` and `disable_dog` differ by their
1935 // `kind` and by `source`, so a `DisableDog` accidentally given
1936 // `EnableDog`'s tag shows up here as two near-identical objects
1937 // rather than as a diff a reader has to compare field by field.
1938 Envelope {
1939 id: 11,
1940 deadline_ms: None,
1941 body: Request::DogConfig {
1942 name: "bark".to_string(),
1943 },
1944 },
1945 Envelope {
1946 id: 12,
1947 deadline_ms: None,
1948 body: Request::EnableDog {
1949 name: "metrics".to_string(),
1950 source: DogSource::BuiltIn,
1951 },
1952 },
1953 Envelope {
1954 id: 13,
1955 deadline_ms: None,
1956 body: Request::DisableDog {
1957 name: "metrics".to_string(),
1958 },
1959 },
1960 // Grouped and adjacent on purpose: `Id`, `Regex` and `Fold` are
1961 // three newtypes over three different inner types, and the wire
1962 // tells them apart only by their own `kind` tag — a `Fold` that
1963 // serialized under `regex`'s tag is a `shep restart fold:api`
1964 // that silently becomes a regex match, which is a wrong set of
1965 // sheep restarted and not an error anyone sees.
1966 Envelope {
1967 id: 14,
1968 deadline_ms: None,
1969 body: Request::Describe {
1970 selector: SelectorSpec::Id(7),
1971 },
1972 },
1973 Envelope {
1974 id: 15,
1975 deadline_ms: None,
1976 body: Request::Describe {
1977 selector: SelectorSpec::Regex("^web-".to_string()),
1978 },
1979 },
1980 Envelope {
1981 id: 16,
1982 deadline_ms: None,
1983 body: Request::Describe {
1984 selector: SelectorSpec::Fold("api".to_string()),
1985 },
1986 },
1987 // `SIGHUP` rather than `SIGTERM`: TERM is what the stop ladder
1988 // already sends, so a fixture using it could not tell a `signal`
1989 // frame from a stop's. HUP is the signal this verb exists for.
1990 Envelope {
1991 id: 17,
1992 deadline_ms: None,
1993 body: Request::Signal {
1994 selector: SelectorSpec::Name("web".to_string()),
1995 signal: "SIGHUP".to_string(),
1996 },
1997 },
1998 // The one verb in this enum whose body has no `selector` key at
1999 // all — a reader comparing this row against `stop`'s sees the
2000 // whole difference in one place.
2001 Envelope {
2002 id: 18,
2003 deadline_ms: None,
2004 body: Request::Scale {
2005 name: "web".to_string(),
2006 count: 4,
2007 },
2008 },
2009 // `SelectorSpec::All` rather than a named sheep, mirroring the
2010 // `reopen`/`flush` rows above: it is the widest thing an operator
2011 // can type, and the line carries no terminator on the wire — the
2012 // shepherd appends it — so a fixture with one proves that half of
2013 // the contract too.
2014 Envelope {
2015 id: 19,
2016 deadline_ms: None,
2017 body: Request::SendLine {
2018 selector: SelectorSpec::All,
2019 line: "reload-config".to_string(),
2020 },
2021 },
2022 // The second verb here with no `selector` key, and the only one
2023 // whose payload a third party writes. Both halves of its
2024 // `Option` are pinned — a paint and a clear — because a dog
2025 // author reading this fixture needs the clear frame's exact
2026 // shape and would otherwise have to guess `null`.
2027 Envelope {
2028 id: 20,
2029 deadline_ms: None,
2030 body: Request::SetSmit {
2031 sheep: "web".to_string(),
2032 smit: Some(
2033 "\u{25b2} main@a1b2c3"
2034 .parse()
2035 .expect("the reference smit is valid"),
2036 ),
2037 },
2038 },
2039 Envelope {
2040 id: 21,
2041 deadline_ms: None,
2042 body: Request::SetSmit {
2043 sheep: "web".to_string(),
2044 smit: None,
2045 },
2046 },
2047 // An EMPTY `apps`, unlike `start`'s row above. The two carry the
2048 // identical payload type, so a second `AppConfig` blob here would
2049 // pin nothing `start`'s blob does not already pin, at fifty lines
2050 // of snapshot. What is genuinely this row's own is the tag and
2051 // the key the list travels under, and an empty list shows both.
2052 Envelope {
2053 id: 22,
2054 deadline_ms: None,
2055 body: Request::ConfigDrift { apps: Vec::new() },
2056 },
2057 // The only STRUCT-shaped `SelectorSpec` variant, and the one
2058 // whose serialized shape moved `PROTOCOL_VERSION` from 1 to 2.
2059 // Every other selector on this wire is a unit or a newtype, both
2060 // already pinned by the rows above, so this row is the only place
2061 // `"kind":"instance"` and the `slot` key are held to anything.
2062 // Without it, renaming the field or flattening the variant turned
2063 // nothing red on the exact type the version bump was for.
2064 Envelope {
2065 id: 23,
2066 deadline_ms: None,
2067 body: Request::Restart {
2068 selector: SelectorSpec::Instance {
2069 name: "web".to_string(),
2070 slot: 2,
2071 },
2072 },
2073 },
2074 // The one request in this enum that an older daemon must never
2075 // be sent, so the one whose exact tag matters most: shep-cli
2076 // gates it on the daemon's crate version, and a rename here
2077 // would be a variant nothing on either side recognises.
2078 Envelope {
2079 id: 24,
2080 deadline_ms: None,
2081 body: Request::HandoverFitness,
2082 },
2083 ];
2084 insta::assert_json_snapshot!("request_wire_v2", requests);
2085 }
2086
2087 #[test]
2088 fn reply_wire_snapshots() {
2089 let replies = vec![
2090 Reply {
2091 id: 1,
2092 result: Ok(Response::Pong),
2093 },
2094 Reply {
2095 id: 2,
2096 result: Ok(Response::Flock(vec![sample_info()])),
2097 },
2098 Reply {
2099 id: 3,
2100 result: Err(RpcError {
2101 code: RpcErrorCode::NotFound,
2102 message: "no sheep matches `web`".to_string(),
2103 daemon_version: None,
2104 }),
2105 },
2106 // Unlike `Reopened`/`Flushed`/`Reloading` above (all wire-identical
2107 // to `Flock`, just under a different `kind` tag, so pinning `Flock`
2108 // once already covers their shape), `Triggered` carries a genuinely
2109 // different row — `ActionReply` is not a `ProcessInfo` — so it earns
2110 // its own entry. `Replied` is the struct-shaped variant of
2111 // `ActionOutcome`, and so the one worth pinning here: the three
2112 // unit variants serialize as bare `{"kind":"..."}`, a shape already
2113 // proven by every fieldless variant elsewhere on this wire.
2114 Reply {
2115 id: 4,
2116 result: Ok(Response::Triggered(vec![ActionReply {
2117 id: 3,
2118 name: "web".to_string(),
2119 outcome: ActionOutcome::Replied {
2120 body: "ok".to_string(),
2121 },
2122 }])),
2123 },
2124 // The only struct-shaped `Response` variant, so the one worth
2125 // pinning here: every other variant on this wire is a newtype
2126 // over a Vec or a unit, both shapes already proven above.
2127 Reply {
2128 id: 5,
2129 result: Ok(Response::RollSaved {
2130 path: "/home/ada/.shep/flock.json".to_string(),
2131 apps: 2,
2132 }),
2133 },
2134 // `sample_info()` above pins the absent marker (a sheep's
2135 // `"dog": null`); this row is the only place the present one is
2136 // pinned, and `Adopted` rather than `BuiltIn` because it is the
2137 // variant carrying a payload — the unit variant's shape is
2138 // already proven by every fieldless variant on this wire.
2139 Reply {
2140 id: 6,
2141 result: Ok(Response::Flock(vec![ProcessInfo {
2142 id: 7,
2143 name: "otel".to_string(),
2144 dog: Some(DogSource::Adopted {
2145 path: "/usr/local/bin/shep-otel".to_string(),
2146 }),
2147 ..sample_info()
2148 }])),
2149 },
2150 // The opaque blob, pinned as a blob: the daemon renders a TOML
2151 // table into a string and never a typed structure, so what this
2152 // row proves is that the section crosses the wire as text.
2153 Reply {
2154 id: 7,
2155 result: Ok(Response::DogSection {
2156 toml: "port = 9615\n".to_string().into(),
2157 }),
2158 },
2159 // The only `Response` variant carrying a BARE `ProcessInfo`
2160 // rather than a `Vec` of them: `enable` starts exactly one dog,
2161 // and a one-element list would invite a reader to wonder when it
2162 // holds two.
2163 Reply {
2164 id: 8,
2165 result: Ok(Response::DogStarted(ProcessInfo {
2166 id: 4,
2167 name: "metrics".to_string(),
2168 dog: Some(DogSource::BuiltIn),
2169 ..sample_info()
2170 })),
2171 },
2172 // The existing comment on the `Triggered` row is right that pinning
2173 // `Flock` once already proves the `Vec<ProcessInfo>` SHAPE — but
2174 // it does not prove any of these variants' own `kind` tags, and
2175 // three of them are not `Vec<ProcessInfo>`-shaped at all
2176 // (`Deleted` is a `Vec<u32>`, `Subscribed` and `ShuttingDown`
2177 // carry nothing). Each row below therefore carries the smallest
2178 // body that shows its wire shape — empty where empty is legal,
2179 // `Deleted`'s two ids where the shape needs elements: what is
2180 // being pinned here is the tag, and a body repeated eight times
2181 // would bury it.
2182 Reply {
2183 id: 9,
2184 result: Ok(Response::Described(vec![])),
2185 },
2186 Reply {
2187 id: 10,
2188 result: Ok(Response::Started(vec![])),
2189 },
2190 Reply {
2191 id: 11,
2192 result: Ok(Response::Stopped(vec![])),
2193 },
2194 Reply {
2195 id: 12,
2196 result: Ok(Response::Restarted(vec![])),
2197 },
2198 Reply {
2199 id: 13,
2200 result: Ok(Response::Reloading(vec![])),
2201 },
2202 Reply {
2203 id: 14,
2204 result: Ok(Response::Deleted(vec![7, 8])),
2205 },
2206 Reply {
2207 id: 15,
2208 result: Ok(Response::Reopened(vec![])),
2209 },
2210 Reply {
2211 id: 16,
2212 result: Ok(Response::Flushed(vec![])),
2213 },
2214 Reply {
2215 id: 17,
2216 result: Ok(Response::Mustered(vec![])),
2217 },
2218 Reply {
2219 id: 18,
2220 result: Ok(Response::Subscribed),
2221 },
2222 Reply {
2223 id: 19,
2224 result: Ok(Response::ShuttingDown),
2225 },
2226 // `Signalled`, mirroring the `Triggered` row above: three rows,
2227 // one per `SignalOutcome` variant, so a reader sees the whole
2228 // shape of the reply in one pinned fixture rather than one row
2229 // that happens to hit `Delivered` and leaves the other two tags
2230 // unproven.
2231 Reply {
2232 id: 20,
2233 result: Ok(Response::Signalled(vec![
2234 SignalReply {
2235 id: 1,
2236 name: "web".to_string(),
2237 outcome: SignalOutcome::Delivered,
2238 },
2239 SignalReply {
2240 id: 2,
2241 name: "web".to_string(),
2242 outcome: SignalOutcome::NotRunning,
2243 },
2244 SignalReply {
2245 id: 3,
2246 name: "api".to_string(),
2247 outcome: SignalOutcome::Failed {
2248 reason: "no such process".to_string(),
2249 },
2250 },
2251 ])),
2252 },
2253 Reply {
2254 id: 21,
2255 result: Ok(Response::Scaled(vec![sample_info()])),
2256 },
2257 // `SentLine`, mirroring the `Signalled` row above: three rows, one
2258 // per `LineOutcome` variant, so a reader sees the whole shape of
2259 // the reply in one pinned fixture rather than one row that
2260 // happens to hit `Sent` and leaves the other two tags unproven.
2261 Reply {
2262 id: 22,
2263 result: Ok(Response::SentLine(vec![
2264 LineReply {
2265 id: 1,
2266 name: "repl".to_string(),
2267 outcome: LineOutcome::Sent,
2268 },
2269 LineReply {
2270 id: 2,
2271 name: "web".to_string(),
2272 outcome: LineOutcome::NoStdin,
2273 },
2274 LineReply {
2275 id: 3,
2276 name: "stuck".to_string(),
2277 outcome: LineOutcome::NotWritten {
2278 reason: "the app did not read its stdin within 2s".to_string(),
2279 },
2280 },
2281 ])),
2282 },
2283 // A `Described` row with a real lamb tree. The `null` shape is pinned
2284 // on every other row here; this is the one that pins what a walked
2285 // sheep serializes as, which is the shape a `describe` consumer
2286 // actually parses.
2287 Reply {
2288 id: 23,
2289 result: Ok(Response::Described(vec![
2290 ProcessInfo::builder(3, "web", ProcStatus::Online)
2291 .pid(Some(4242))
2292 .lambs(Some(vec![Lamb::new(4243, "node"), Lamb::new(4244, "sh")]))
2293 .build(),
2294 ])),
2295 },
2296 // `sample_info()` pins `last_exit`'s "exited normally" shape
2297 // (`code` set, `signal` absent) on every row above; this is the
2298 // only place the other one — killed by a signal, `code` absent
2299 // — is pinned. `SIGTERM`'s raw number (15) rather than a
2300 // symbolic one, because [`ExitInfo::signal`]'s own doc says this
2301 // crate carries no name for it; naming one is a job for
2302 // whichever OS-aware layer renders this field.
2303 Reply {
2304 id: 24,
2305 result: Ok(Response::Flock(vec![
2306 ProcessInfo::builder(5, "worker", ProcStatus::Stopped)
2307 .restarts(1)
2308 .last_exit(Some(ExitInfo {
2309 code: None,
2310 signal: Some(15),
2311 }))
2312 .build(),
2313 ])),
2314 },
2315 // The one row that pins a smit on the wire. `sample_info()`
2316 // carries none, deliberately (see `every_setter_writes_its_own_
2317 // field_and_no_other` for why it cannot), so without this row
2318 // the field is pinned only in its absent shape — and the absent
2319 // shape is not the one a dog's reader has to parse.
2320 Reply {
2321 id: 25,
2322 result: Ok(Response::SmitPainted(vec![
2323 ProcessInfo::builder(3, "web", ProcStatus::Online)
2324 .pid(Some(4242))
2325 .smit(Some("\u{25b2} main@a1b2c3".to_string()))
2326 .build(),
2327 ])),
2328 },
2329 // Two entries in one reply, and each is the shape the other is
2330 // not: a sheep drifting in one field and a sheep drifting in
2331 // several. `env` is deliberately one of them, because reporting
2332 // it as a bare NAME is the whole security property of this row
2333 // (IR-41) and a fixture is where an out-of-tree reader learns
2334 // that no value ever travels with it.
2335 Reply {
2336 id: 26,
2337 result: Ok(Response::Drifted(vec![
2338 SheepDrift::new("web", vec!["cwd".to_string()]),
2339 SheepDrift::new(
2340 "api",
2341 vec!["args".to_string(), "env".to_string(), "script".to_string()],
2342 ),
2343 ])),
2344 },
2345 // `sample_info()` pins `instance`'s absent shape (`None`, an old
2346 // peer or a single-instance app); every row above reuses it, so
2347 // without this row the present shape (`Some(2)`, a live slot on
2348 // a scaled app) is never on the wire at all.
2349 Reply {
2350 id: 27,
2351 result: Ok(Response::Flock(vec![
2352 ProcessInfo::builder(9, "web", ProcStatus::Online)
2353 .pid(Some(5150))
2354 .instance(Some(2))
2355 .build(),
2356 ])),
2357 },
2358 // Both shapes of the handover answer, because the difference
2359 // between them is a `null` and a caller that read the key's
2360 // presence rather than its value would pass on one and refuse
2361 // every flock on the other.
2362 Reply {
2363 id: 28,
2364 result: Ok(Response::HandoverFitness { refusal: None }),
2365 },
2366 Reply {
2367 id: 29,
2368 result: Ok(Response::HandoverFitness {
2369 refusal: Some("sheep 'web' has a shepherd channel".to_string()),
2370 }),
2371 },
2372 ];
2373 insta::assert_json_snapshot!("reply_wire_v2", replies);
2374 }
2375
2376 /// fails if the new field breaks an older peer, on the same terms as
2377 /// `last_exit` and `lambs` before it. A daemon that predates smits sends
2378 /// no `smit` key, and this decoding to `None` rather than erroring is
2379 /// what keeps `PROTOCOL_VERSION` at 2 rather than needing another bump.
2380 #[test]
2381 fn a_process_info_without_a_smit_key_still_deserializes() {
2382 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}"#;
2383 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2384 assert_eq!(info.smit, None);
2385 }
2386
2387 /// fails if the daemon accepts a smit it should refuse. [`Smit`] must
2388 /// validate on the way IN, not only in `FromStr`: `docs/dogs.md` tells
2389 /// dog authors to speak this wire directly, so a dog written in another
2390 /// language never runs our parser.
2391 #[test]
2392 fn a_smit_is_validated_when_it_is_deserialized_not_only_when_parsed() {
2393 for bad in [
2394 r#""\u001b[2Jgone""#.to_string(), // an escape
2395 r#""a\nb""#.to_string(), // a newline
2396 r#""""#.to_string(), // empty
2397 r#"" ""#.to_string(), // whitespace
2398 format!(r#""{}""#, "x".repeat(Smit::MAX_CHARS + 1)), // too long
2399 ] {
2400 assert!(
2401 serde_json::from_str::<Smit>(&bad).is_err(),
2402 "a daemon must refuse this on the wire: {bad}"
2403 );
2404 }
2405 assert!(serde_json::from_str::<Smit>(r#""\u25b2 main@a1b2c3""#).is_ok());
2406 }
2407
2408 /// fails if a smit stops travelling as a bare JSON string. It is a
2409 /// newtype with a hand-written `Deserialize`, and the pair only agrees
2410 /// with itself if the serialize side stays transparent — a `Smit` that
2411 /// serialized as `{"0":"..."}` would round-trip through nothing.
2412 #[test]
2413 fn a_smit_travels_as_a_bare_string() {
2414 let smit: Smit = "\u{25b2} main@a1b2c3".parse().expect("valid");
2415 let json = serde_json::to_string(&smit).unwrap();
2416 assert_eq!(json, "\"\u{25b2} main@a1b2c3\"");
2417 assert_eq!(serde_json::from_str::<Smit>(&json).unwrap(), smit);
2418 }
2419
2420 /// fails if the cap starts counting bytes or display columns. Forty-eight
2421 /// CJK characters are 144 bytes and roughly 96 columns, and all three
2422 /// numbers disagree — a byte cap would refuse this legitimate smit at a
2423 /// third of its apparent length.
2424 #[test]
2425 fn a_smit_is_capped_in_characters_not_bytes() {
2426 let cjk = "\u{7f8a}".repeat(Smit::MAX_CHARS);
2427 assert_eq!(cjk.len(), Smit::MAX_CHARS * 3);
2428 assert!(cjk.parse::<Smit>().is_ok(), "{cjk}");
2429 assert_eq!(
2430 "x".repeat(Smit::MAX_CHARS + 1).parse::<Smit>(),
2431 Err(SmitError::TooLong {
2432 chars: Smit::MAX_CHARS + 1
2433 })
2434 );
2435 }
2436
2437 /// fails if a smit is repaired rather than refused. Trimming or stripping
2438 /// would hand an operator a mark its publisher never sent, and would put
2439 /// shep in the business of editing a string it has agreed not to
2440 /// understand.
2441 #[test]
2442 fn a_smit_is_stored_exactly_as_it_arrived() {
2443 let padded: Smit = " main@a1b2c3 ".parse().expect("valid");
2444 assert_eq!(padded.as_str(), " main@a1b2c3 ");
2445 assert_eq!(padded.to_string(), " main@a1b2c3 ");
2446 }
2447
2448 #[test]
2449 fn v1_fixture_still_deserializes() {
2450 // Committed byte fixture from protocol v1 — if this breaks, bump
2451 // PROTOCOL_VERSION and record it in the CHANGELOG (IR-35).
2452 let fixture = r#"{"id":7,"deadline_ms":null,"body":{"kind":"stop","selector":{"kind":"name","value":"web"}}}"#;
2453 let env: Envelope = serde_json::from_str(fixture).unwrap();
2454 assert_eq!(env.id, 7);
2455 assert!(matches!(
2456 env.body,
2457 Request::Stop { selector: SelectorSpec::Name(ref n) } if n == "web"
2458 ));
2459 }
2460
2461 #[test]
2462 fn hello_handshake_shape() {
2463 let hello = Hello {
2464 client_version: "0.1.0".to_string(),
2465 protocol: PROTOCOL_VERSION,
2466 };
2467 let json = serde_json::to_string(&hello).unwrap();
2468 assert_eq!(json, r#"{"client_version":"0.1.0","protocol":2}"#);
2469 }
2470
2471 #[test]
2472 fn hello_reply_carries_typed_skew_error() {
2473 let refusal: HelloReply = Err(RpcError {
2474 code: RpcErrorCode::ProtocolMismatch,
2475 message: "daemon speaks protocol 1, client sent 2".to_string(),
2476 daemon_version: None,
2477 });
2478 let json = serde_json::to_string(&refusal).unwrap();
2479 assert_eq!(
2480 json,
2481 r#"{"Err":{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}}"#
2482 );
2483 let back: HelloReply = serde_json::from_str(&json).unwrap();
2484 assert_eq!(back, refusal);
2485 }
2486
2487 #[test]
2488 fn v1_reply_fixture_still_deserializes() {
2489 // Committed byte fixture, protocol v1 (IR-35).
2490 let ok = r#"{"id":1,"result":{"Ok":{"kind":"pong"}}}"#;
2491 let reply: Reply = serde_json::from_str(ok).unwrap();
2492 assert!(matches!(reply.result, Ok(Response::Pong)));
2493 let err = r#"{"id":2,"result":{"Err":{"code":"not_found","message":"no sheep"}}}"#;
2494 let reply: Reply = serde_json::from_str(err).unwrap();
2495 assert_eq!(reply.result.unwrap_err().code, RpcErrorCode::NotFound);
2496 }
2497
2498 #[test]
2499 fn v1_hello_ack_fixture_still_deserializes() {
2500 let fixture = r#"{"Ok":{"daemon_version":"0.1.0","protocol":1,"pid":4242}}"#;
2501 let ack: HelloReply = serde_json::from_str(fixture).unwrap();
2502 assert_eq!(ack.unwrap().pid, 4242);
2503 }
2504
2505 /// fails if the two fields stop being optional. A daemon built before
2506 /// them sends a reply with no such keys, and both peers still announce
2507 /// protocol 1 — a required field would make a current client unable to
2508 /// list against that daemon at all.
2509 #[test]
2510 fn v1_process_info_without_stats_still_deserializes() {
2511 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"}"#;
2512 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2513 assert_eq!(info.cpu_percent, None);
2514 assert_eq!(info.memory_bytes, None);
2515 }
2516
2517 #[test]
2518 fn v1_process_info_without_log_paths_still_deserializes() {
2519 // Committed byte fixture from before `out_file`/`err_file` existed
2520 // (IR-35). The handshake only compares PROTOCOL_VERSION, which this
2521 // addition deliberately did not bump, so a daemon built at this
2522 // vintage still connects to a current client and sends exactly these
2523 // bytes. Absent keys must land as `None`, not as a decode error.
2524 let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend"}"#;
2525 let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2526 assert_eq!(info.id, 3);
2527 assert_eq!(info.out_file, None);
2528 assert_eq!(info.err_file, None);
2529 }
2530
2531 #[test]
2532 fn an_old_client_still_decodes_a_new_process_info() {
2533 // The other skew direction: a client built before the fields reads a
2534 // current daemon's reply. `ProcessInfo` carries no
2535 // `deny_unknown_fields` (unlike the config types in
2536 // `crate::config`), so the two extra keys are ignored rather than
2537 // refused — which is what makes this addition version-preserving.
2538 #[derive(Deserialize)]
2539 struct V1ProcessInfo {
2540 id: u32,
2541 fold: Option<String>,
2542 }
2543
2544 let current = serde_json::to_string(&sample_info()).unwrap();
2545 let old: V1ProcessInfo = serde_json::from_str(¤t).unwrap();
2546 assert_eq!(old.id, 3);
2547 assert_eq!(old.fold.as_deref(), Some("backend"));
2548 }
2549
2550 #[test]
2551 fn an_rpc_error_without_a_daemon_version_serializes_exactly_as_before() {
2552 // `skip_serializing_if` is what makes this addition free: a daemon
2553 // with nothing to say puts the same bytes on the wire it always did,
2554 // not a `"daemon_version":null` key an older client would have to
2555 // ignore. Pinned as an exact string, because "additive" is a claim
2556 // about bytes.
2557 let plain = RpcError {
2558 code: RpcErrorCode::NotFound,
2559 message: "no sheep".to_string(),
2560 daemon_version: None,
2561 };
2562 assert_eq!(
2563 serde_json::to_string(&plain).unwrap(),
2564 r#"{"code":"not_found","message":"no sheep"}"#
2565 );
2566 }
2567
2568 #[test]
2569 fn a_v1_rpc_error_fixture_deserializes_with_no_daemon_version() {
2570 // Committed byte fixture from before this field existed (IR-35): the
2571 // skew direction that matters, a CURRENT client reading an OLD
2572 // daemon's refusal. It must read as `None` rather than failing to
2573 // decode, or the field breaks the upgrade it exists to smooth.
2574 let fixture =
2575 r#"{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}"#;
2576 let err: RpcError = serde_json::from_str(fixture).unwrap();
2577 assert_eq!(err.code, RpcErrorCode::ProtocolMismatch);
2578 assert_eq!(err.daemon_version, None);
2579 }
2580
2581 #[test]
2582 fn an_old_client_ignores_an_rpc_error_field_it_has_never_seen() {
2583 // Step 1 of the handover work: proof that `RpcError` may grow an
2584 // optional field WITHOUT moving `PROTOCOL_VERSION`. Like
2585 // `ProcessInfo` above, `RpcError` carries no `deny_unknown_fields`,
2586 // so a client built before a field decodes a daemon that sends it
2587 // rather than failing the handshake on it.
2588 #[derive(Deserialize)]
2589 struct OldRpcError {
2590 code: RpcErrorCode,
2591 message: String,
2592 }
2593
2594 let current = serde_json::to_string(&RpcError {
2595 code: RpcErrorCode::ProtocolMismatch,
2596 message: "daemon speaks protocol 1, client sent 2".to_string(),
2597 daemon_version: Some("0.1.16".to_string()),
2598 })
2599 .unwrap();
2600 let old: OldRpcError = serde_json::from_str(¤t).expect("must tolerate");
2601 assert_eq!(old.code, RpcErrorCode::ProtocolMismatch);
2602 assert_eq!(old.message, "daemon speaks protocol 1, client sent 2");
2603 }
2604
2605 #[test]
2606 fn deadline_exceeded_code_serializes_snake_case() {
2607 // Additive variant (evolution rule): the existing codes keep their
2608 // strings, so v1 byte fixtures above still deserialize unchanged.
2609 assert_eq!(
2610 serde_json::to_string(&RpcErrorCode::DeadlineExceeded).unwrap(),
2611 "\"deadline_exceeded\""
2612 );
2613 assert_eq!(
2614 serde_json::from_str::<RpcErrorCode>("\"deadline_exceeded\"").unwrap(),
2615 RpcErrorCode::DeadlineExceeded
2616 );
2617 }
2618
2619 #[test]
2620 fn action_outcome_kinds_serialize_snake_case_and_round_trip() {
2621 // The shared snapshots above exercise exactly one `ActionOutcome`
2622 // variant (`Replied`, the only struct-shaped one, in
2623 // `reply_wire_snapshots`) — nothing else there would catch a rename
2624 // of `no_channel`, `skipped`, or `timed_out`. Pinned here instead,
2625 // the same way `deadline_exceeded_code_serializes_snake_case` pins a
2626 // lone `RpcErrorCode` variant above.
2627 let cases = [
2628 (
2629 ActionOutcome::Replied {
2630 body: "pong".to_string(),
2631 },
2632 r#"{"kind":"replied","body":"pong"}"#,
2633 ),
2634 (ActionOutcome::NoChannel, r#"{"kind":"no_channel"}"#),
2635 (ActionOutcome::Skipped, r#"{"kind":"skipped"}"#),
2636 (ActionOutcome::TimedOut, r#"{"kind":"timed_out"}"#),
2637 ];
2638 for (outcome, wire) in cases {
2639 assert_eq!(
2640 serde_json::to_string(&outcome).unwrap(),
2641 wire,
2642 "{outcome:?}"
2643 );
2644 assert_eq!(
2645 serde_json::from_str::<ActionOutcome>(wire).unwrap(),
2646 outcome
2647 );
2648 }
2649 }
2650
2651 /// fails if `SaveRoll` or `RollSaved` is given a `rename`, or if
2652 /// `Response`'s `content = "data"` is dropped — either changes these two
2653 /// strings while every type-level test in this module keeps passing.
2654 #[test]
2655 fn save_roll_serializes_snake_case_with_its_payload_under_data() {
2656 assert_eq!(
2657 serde_json::to_string(&Request::SaveRoll).unwrap(),
2658 r#"{"kind":"save_roll"}"#
2659 );
2660 let reply = Response::RollSaved {
2661 path: "/tmp/flock.json".to_string(),
2662 apps: 3,
2663 };
2664 let wire = r#"{"kind":"roll_saved","data":{"path":"/tmp/flock.json","apps":3}}"#;
2665 assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
2666 assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
2667 }
2668
2669 /// fails if `Muster` or `Mustered` is given a `rename`, or if `Mustered`
2670 /// is declared fieldless — any of the three changes one of these two
2671 /// strings while every type-level test in this module keeps passing.
2672 ///
2673 /// The listing is empty on purpose. `Mustered` carries the same
2674 /// `Vec<ProcessInfo>` `Flock` does, and `reply_wire_snapshots` already
2675 /// pins that row field by field; what is unpinned until here is this
2676 /// variant's own tag and whether its payload lands under `data` at all.
2677 #[test]
2678 fn muster_serializes_snake_case_with_its_listing_under_data() {
2679 assert_eq!(
2680 serde_json::to_string(&Request::Muster).unwrap(),
2681 r#"{"kind":"muster"}"#
2682 );
2683 let reply = Response::Mustered(Vec::new());
2684 let wire = r#"{"kind":"mustered","data":[]}"#;
2685 assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
2686 assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
2687 }
2688
2689 /// fails if any of the three verbs or either reply is given a `rename`,
2690 /// or if `Response`'s `content = "data"` is dropped. `disable_dog`'s
2691 /// answer is `Deleted`, which no other test in this module pairs with
2692 /// this verb — a handler wired to answer `Deleted` for `EnableDog` would
2693 /// still round-trip, and this is where the pairing is written down.
2694 #[test]
2695 fn the_dog_verbs_serialize_snake_case_with_their_payloads_under_data() {
2696 assert_eq!(
2697 serde_json::to_string(&Request::DogConfig {
2698 name: "bark".to_string()
2699 })
2700 .unwrap(),
2701 r#"{"kind":"dog_config","name":"bark"}"#
2702 );
2703 assert_eq!(
2704 serde_json::to_string(&Request::DisableDog {
2705 name: "bark".to_string()
2706 })
2707 .unwrap(),
2708 r#"{"kind":"disable_dog","name":"bark"}"#
2709 );
2710 let section = Response::DogSection {
2711 toml: "port = 9615\n".to_string().into(),
2712 };
2713 let wire = r#"{"kind":"dog_section","data":{"toml":"port = 9615\n"}}"#;
2714 assert_eq!(serde_json::to_string(§ion).unwrap(), wire);
2715 assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), section);
2716 }
2717
2718 #[test]
2719 fn dog_section_toml_debug_does_not_leak() {
2720 // IR-41: a dog's `[dog.<name>]` section routinely holds webhook
2721 // credentials (a Discord/Slack URL with a bearer token embedded).
2722 // `Response` derives `Debug`, so this is the one thing standing
2723 // between that token and any future `tracing::debug!("{:?}", reply)`.
2724 // Exact string pinned so a lazy `#[derive(Debug)]` refactor on
2725 // `DogSectionToml` fails this test instead of silently reopening
2726 // the leak.
2727 let toml: DogSectionToml =
2728 "webhook_url = \"https://discord.com/api/webhooks/1/super-secret-token\"\n"
2729 .to_string()
2730 .into();
2731 assert_eq!(format!("{toml:?}"), "DogSectionToml(<70 bytes>)");
2732
2733 let response = Response::DogSection { toml };
2734 assert_eq!(
2735 format!("{response:?}"),
2736 "DogSection { toml: DogSectionToml(<70 bytes>) }"
2737 );
2738 }
2739
2740 /// The fixture is built so the two candidate orders CANNOT agree: read
2741 /// by id it is `web/1, api/2, web/0`, read by name it is
2742 /// `api, web, web`. A listing that happened to be alphabetical already,
2743 /// or whose ids happened to ascend with its names, would pass under
2744 /// either rule and prove nothing.
2745 ///
2746 /// The two `web` rows are the tiebreak half, and they are the reason a
2747 /// multi-instance fixture is required: their ids are seeded out of order
2748 /// (1 before 0), so a sort keyed on name alone would leave them as it
2749 /// found them and fail the last assertion while passing the first.
2750 #[test]
2751 fn a_listing_sorts_by_name_then_by_id() {
2752 let mut listing = vec![
2753 ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
2754 ProcessInfo::builder(2, "api", ProcStatus::Online).build(),
2755 ProcessInfo::builder(0, "web", ProcStatus::Online).build(),
2756 ];
2757 sort_flock(&mut listing);
2758
2759 let seen: Vec<(&str, u32)> = listing
2760 .iter()
2761 .map(|info| (info.name.as_str(), info.id))
2762 .collect();
2763 assert_eq!(
2764 seen,
2765 vec![("api", 2), ("web", 0), ("web", 1)],
2766 "name first, then id inside a name"
2767 );
2768 }
2769
2770 #[test]
2771 fn an_instance_slot_survives_a_round_trip_and_defaults_to_absent() {
2772 let with = ProcessInfo::builder(1, "web", ProcStatus::Online)
2773 .instance(Some(2))
2774 .build();
2775 assert_eq!(with.instance, Some(2));
2776
2777 let without = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
2778 assert_eq!(
2779 without.instance, None,
2780 "a row nobody set a slot on says so, rather than claiming slot 0"
2781 );
2782 }
2783
2784 #[test]
2785 fn a_reply_from_a_daemon_without_the_field_deserializes_as_absent() {
2786 // The skew case the Option exists for: an older shepherd's JSON has no
2787 // `instance` key at all.
2788 let json = r#"{"id":1,"name":"web","status":"online","pid":null,
2789 "restarts":0,"uptime_ms":0,"fold":null,"out_file":null,
2790 "err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,
2791 "lambs":null,"last_exit":null,"smit":null}"#;
2792 let info: ProcessInfo = serde_json::from_str(json).expect("older reply still parses");
2793 assert_eq!(info.instance, None);
2794 }
2795
2796 #[test]
2797 fn sort_flock_orders_by_slot_before_id() {
2798 // A reload gave slot 0 a fresh, higher id. Slot order must still win.
2799 let mut listing = vec![
2800 ProcessInfo::builder(9, "web", ProcStatus::Online)
2801 .instance(Some(0))
2802 .build(),
2803 ProcessInfo::builder(2, "web", ProcStatus::Online)
2804 .instance(Some(1))
2805 .build(),
2806 ];
2807 sort_flock(&mut listing);
2808 assert_eq!(
2809 listing.iter().map(|i| i.id).collect::<Vec<_>>(),
2810 vec![9, 2],
2811 "slot 0 leads even though its id is higher"
2812 );
2813 }
2814
2815 #[test]
2816 fn sort_flock_falls_back_to_id_when_no_row_carries_a_slot() {
2817 let mut listing = vec![
2818 ProcessInfo::builder(5, "web", ProcStatus::Online).build(),
2819 ProcessInfo::builder(3, "web", ProcStatus::Online).build(),
2820 ];
2821 sort_flock(&mut listing);
2822 assert_eq!(
2823 listing.iter().map(|i| i.id).collect::<Vec<_>>(),
2824 vec![3, 5],
2825 "an older daemon's listing sorts exactly as it does today"
2826 );
2827 }
2828}