Skip to main content

shep_channel/
wire.rs

1//! The shepherd channel: newline-JSON wire between the shepherd and each
2//! spawned child. Unix carries it on fd 3, Windows on a named pipe.
3//! [`ChildMessage`] flows child to shepherd; [`ShepherdMessage`] flows
4//! shepherd to child.
5//!
6//! Both enums are exhaustive on purpose. The channel has no handshake, so
7//! a new variant has to be announced out of band. An exhaustive match
8//! forces every call site to react to it.
9//!
10//! Pins the wire shapes only. See `docs/shepherd-channel.md` for reply and
11//! correlation semantics.
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17/// The value the shepherd exports as `SHEP_CHANNEL_VERSION` to every child
18/// it opens a channel for.
19///
20/// Not a negotiation: a way for an app to notice a wire it has never
21/// seen. `docs/shepherd-channel.md` defines what `"1"` means.
22pub const CHANNEL_VERSION: &str = "1";
23
24/// What kind of number a [`ChildMessage::Metric`] sample is
25///
26/// Wire spellings are `"gauge"` and `"counter"`. Absent on the wire means
27/// gauge. Exhaustive on purpose, like the two message enums: a new kind
28/// is a wire change announced out of band, and an exhaustive match
29/// forces every call site to react to it.
30// wire format: changing these strings is a breaking change
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum MetricType {
34    /// A value that can go up and down
35    Gauge,
36    /// A value that only accumulates
37    Counter,
38}
39
40/// Child -> daemon shepherd-channel message (spec §7, kebab-case kinds)
41// wire format: changing these strings is a breaking change
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43#[serde(tag = "kind", rename_all = "kebab-case")]
44pub enum ChildMessage {
45    /// `{"kind":"ready"}`: readiness signal (`wait_ready` gate)
46    Ready,
47    /// Custom metric sample
48    ///
49    /// Names and labels carry no grammar check here: the metrics dog
50    /// enforces that when it reads them. Do not add a half check to this
51    /// crate.
52    Metric {
53        /// Metric name
54        name: String,
55        /// Metric value
56        value: f64,
57        /// What kind of number this is. Absent on the wire means gauge.
58        ///
59        /// Wire key `type`: a Rust field named `kind` would collide with
60        /// the tag. An unknown string fails to decode, so the shepherd
61        /// drops the frame as malformed rather than guessing a kind.
62        #[serde(rename = "type", skip_serializing_if = "Option::is_none", default)]
63        metric_type: Option<MetricType>,
64        /// Labels for the sample. An absent key and an empty map are the
65        /// same message.
66        #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
67        labels: BTreeMap<String, String>,
68        /// Free-text help for the sample
69        #[serde(skip_serializing_if = "Option::is_none", default)]
70        help: Option<String>,
71    },
72    /// Reply to a daemon-initiated action
73    ActionReply {
74        /// The action name this replies to
75        action: String,
76        /// Free-form reply body
77        body: String,
78        /// The `id` of the [`ShepherdMessage::Action`] this answers, echoed
79        /// back verbatim. `None` when the app did not echo it. Then the
80        /// daemon falls back to matching by name and order.
81        #[serde(skip_serializing_if = "Option::is_none", default)]
82        id: Option<u64>,
83    },
84    /// Names one of this sheep's lambs for `shep describe` and lookout.
85    ///
86    /// Shown only while `pid` is in the sheep's own process tree. An empty
87    /// `label` clears the one `pid` had.
88    LambLabel {
89        /// The lamb's pid, as the app's own spawn call reported it
90        pid: u32,
91        /// What `describe` shows beside the lamb's executable name
92        label: LambLabel,
93    },
94    /// A name and a number for a dog, published as `channel.note`
95    ///
96    /// Not a metric sample: the metrics dog never reads one. No grammar
97    /// check on `name`, no labels and no help.
98    Note {
99        /// Whatever the app and the dog agree on
100        name: String,
101        /// The number that goes with `name`
102        value: f64,
103    },
104}
105
106/// A sheep's own name for one of its lambs
107///
108/// Holds at most [`LambLabel::MAX_CHARS`] characters and no control
109/// character, whether built with [`LambLabel::new`] or read off the wire.
110/// Empty is a real value: it clears a label rather than setting one.
111// wire format: changing this is a breaking change
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(try_from = "String", into = "String")]
114pub struct LambLabel(String);
115
116impl LambLabel {
117    /// The most characters a label may hold, counted as Unicode scalar
118    /// values.
119    // Room for `worker 12 of 16` or a queue name, and still one table column.
120    pub const MAX_CHARS: usize = 64;
121
122    /// A label, checked against the channel's grammar.
123    ///
124    /// # Errors
125    ///
126    /// - [`LambLabelError::TooLong`] when `label` has more than
127    ///   [`Self::MAX_CHARS`] characters.
128    /// - [`LambLabelError::ControlCharacter`] when `label` holds one, a
129    ///   newline included.
130    pub fn new(label: impl Into<String>) -> Result<Self, LambLabelError> {
131        let label = label.into();
132        let chars = label.chars().count();
133        if chars > Self::MAX_CHARS {
134            return Err(LambLabelError::TooLong { chars });
135        }
136        if label.chars().any(char::is_control) {
137            return Err(LambLabelError::ControlCharacter);
138        }
139        Ok(Self(label))
140    }
141
142    /// The label's text.
143    #[must_use]
144    pub fn as_str(&self) -> &str {
145        &self.0
146    }
147
148    /// Whether this clears a label rather than setting one.
149    #[must_use]
150    pub fn is_empty(&self) -> bool {
151        self.0.is_empty()
152    }
153}
154
155impl TryFrom<String> for LambLabel {
156    type Error = LambLabelError;
157
158    fn try_from(label: String) -> Result<Self, Self::Error> {
159        Self::new(label)
160    }
161}
162
163impl From<LambLabel> for String {
164    fn from(label: LambLabel) -> Self {
165        label.0
166    }
167}
168
169/// Why a string is not a [`LambLabel`].
170// Library crate: a new rule in the label grammar is a new variant.
171#[non_exhaustive]
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub enum LambLabelError {
174    /// The label has more than [`LambLabel::MAX_CHARS`] characters.
175    TooLong {
176        /// How many characters it has.
177        chars: usize,
178    },
179    /// The label holds a control character, which could drive the
180    /// terminal it is shown on.
181    ControlCharacter,
182}
183
184impl core::fmt::Display for LambLabelError {
185    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
186        match self {
187            Self::TooLong { chars } => write!(
188                f,
189                "a lamb label holds at most {} characters, and this one has {chars}",
190                LambLabel::MAX_CHARS
191            ),
192            Self::ControlCharacter => f.write_str("a lamb label cannot hold a control character"),
193        }
194    }
195}
196
197impl core::error::Error for LambLabelError {}
198
199/// Daemon -> child message
200// wire format: changing these strings is a breaking change
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202#[serde(tag = "kind", rename_all = "kebab-case")]
203pub enum ShepherdMessage {
204    /// Graceful-stop request (`shutdown_with_message`)
205    Shutdown,
206    /// Custom action dispatch
207    Action {
208        /// The action name
209        name: String,
210        /// Argument text for the action, passed through to the child
211        /// verbatim; `None` when triggered without any. Omitted from the
212        /// wire when `None`, so a message with no arguments round-trips
213        /// byte-identical.
214        ///
215        /// One opaque string the daemon never reads, so an app parses it
216        /// in its own grammar.
217        // `skip_serializing_if` is load-bearing: without it, an empty
218        // message serializes `"params":null` instead of omitting the key.
219        // `default` guards a future type change on a channel with no
220        // version to announce one.
221        #[serde(skip_serializing_if = "Option::is_none", default)]
222        params: Option<String>,
223        /// This dispatch's correlation id, unique for the life of the
224        /// daemon. Echo it back as `id` on your
225        /// [`ChildMessage::ActionReply`]. The daemon then matches your
226        /// answer to this request, not to its name.
227        ///
228        /// Always present, unlike `params`. Treat `u64` and increasing as
229        /// implementation details, not a promise.
230        id: u64,
231    },
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    // Fixtures pinned from spec §7. Round-tripped both ways so a silent
239    // drift fails loudly.
240
241    #[test]
242    fn ready_wire_fixture_round_trips() {
243        let fixture = r#"{"kind":"ready"}"#;
244        assert_eq!(
245            serde_json::from_str::<ChildMessage>(fixture).unwrap(),
246            ChildMessage::Ready
247        );
248        assert_eq!(
249            serde_json::to_string(&ChildMessage::Ready).unwrap(),
250            fixture
251        );
252    }
253
254    #[test]
255    fn metric_wire_fixture_round_trips() {
256        let fixture = r#"{"kind":"metric","name":"rps","value":42.0}"#;
257        let msg = ChildMessage::Metric {
258            name: "rps".to_string(),
259            value: 42.0,
260            metric_type: None,
261            labels: BTreeMap::new(),
262            help: None,
263        };
264        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
265        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
266    }
267
268    /// Field order on the wire is `name, value, type, labels, help`.
269    #[test]
270    fn a_metric_with_type_labels_and_help_round_trips() {
271        let fixture = concat!(
272            r#"{"kind":"metric","name":"queue_depth","value":3.0,"type":"counter","#,
273            r#""labels":{"model":"qwen"},"help":"Requests waiting for the lease."}"#,
274        );
275        let msg = ChildMessage::Metric {
276            name: "queue_depth".to_string(),
277            value: 3.0,
278            metric_type: Some(MetricType::Counter),
279            labels: [("model".to_string(), "qwen".to_string())].into(),
280            help: Some("Requests waiting for the lease.".to_string()),
281        };
282        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
283        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
284    }
285
286    #[test]
287    fn a_gauge_type_round_trips_as_its_own_spelling() {
288        let fixture = r#"{"kind":"metric","name":"rps","value":1.0,"type":"gauge"}"#;
289        let msg = ChildMessage::Metric {
290            name: "rps".to_string(),
291            value: 1.0,
292            metric_type: Some(MetricType::Gauge),
293            labels: BTreeMap::new(),
294            help: None,
295        };
296        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
297        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
298    }
299
300    /// No fallback to gauge: the daemon drops the line as malformed.
301    #[test]
302    fn an_unknown_metric_type_fails_to_decode() {
303        for frame in [
304            r#"{"kind":"metric","name":"rps","value":1.0,"type":"histogram"}"#,
305            r#"{"kind":"metric","name":"rps","value":1.0,"type":"Counter"}"#,
306        ] {
307            assert!(
308                serde_json::from_str::<ChildMessage>(frame).is_err(),
309                "{frame} decoded"
310            );
311        }
312    }
313
314    #[test]
315    fn an_action_reply_without_an_id_round_trips() {
316        let fixture = r#"{"kind":"action-reply","action":"gc","body":"ok"}"#;
317        let msg = ChildMessage::ActionReply {
318            action: "gc".to_string(),
319            body: "ok".to_string(),
320            id: None,
321        };
322        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
323        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
324    }
325
326    #[test]
327    fn an_action_reply_with_an_echoed_id_round_trips() {
328        let fixture = r#"{"kind":"action-reply","action":"gc","body":"ok","id":7}"#;
329        let msg = ChildMessage::ActionReply {
330            action: "gc".to_string(),
331            body: "ok".to_string(),
332            id: Some(7),
333        };
334        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
335        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
336    }
337
338    #[test]
339    fn shutdown_wire_fixture_round_trips() {
340        let fixture = r#"{"kind":"shutdown"}"#;
341        assert_eq!(
342            serde_json::from_str::<ShepherdMessage>(fixture).unwrap(),
343            ShepherdMessage::Shutdown
344        );
345        assert_eq!(
346            serde_json::to_string(&ShepherdMessage::Shutdown).unwrap(),
347            fixture
348        );
349    }
350
351    /// Checks both directions: serialize and deserialize.
352    #[test]
353    fn an_action_carries_its_id_with_or_without_params() {
354        let bare = r#"{"kind":"action","name":"gc","id":7}"#;
355        let bare_msg = ShepherdMessage::Action {
356            name: "gc".to_string(),
357            params: None,
358            id: 7,
359        };
360        assert_eq!(serde_json::to_string(&bare_msg).unwrap(), bare);
361        assert_eq!(
362            serde_json::from_str::<ShepherdMessage>(bare).unwrap(),
363            bare_msg
364        );
365
366        let with_params = r#"{"kind":"action","name":"set-log-level","params":"debug","id":8}"#;
367        let with_params_msg = ShepherdMessage::Action {
368            name: "set-log-level".to_string(),
369            params: Some("debug".to_string()),
370            id: 8,
371        };
372        assert_eq!(
373            serde_json::to_string(&with_params_msg).unwrap(),
374            with_params
375        );
376        assert_eq!(
377            serde_json::from_str::<ShepherdMessage>(with_params).unwrap(),
378            with_params_msg
379        );
380    }
381
382    #[test]
383    fn a_lamb_label_round_trips() {
384        let fixture = r#"{"kind":"lamb-label","pid":4312,"label":"worker 1"}"#;
385        let msg = ChildMessage::LambLabel {
386            pid: 4312,
387            label: LambLabel::new("worker 1").unwrap(),
388        };
389        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
390        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
391    }
392
393    /// The clearing form: an absent key would be a malformed frame instead.
394    #[test]
395    fn an_empty_lamb_label_is_on_the_wire_and_clears() {
396        let fixture = r#"{"kind":"lamb-label","pid":4312,"label":""}"#;
397        let ChildMessage::LambLabel { label, .. } =
398            serde_json::from_str::<ChildMessage>(fixture).unwrap()
399        else {
400            panic!("{fixture} decoded as another kind");
401        };
402        assert!(label.is_empty());
403        assert!(
404            serde_json::from_str::<ChildMessage>(r#"{"kind":"lamb-label","pid":4312}"#).is_err()
405        );
406    }
407
408    /// A name the metrics grammar would refuse goes through untouched.
409    #[test]
410    fn a_note_round_trips_with_any_name() {
411        let fixture = r#"{"kind":"note","name":"lease.web.want.1760500000","value":1.0}"#;
412        let msg = ChildMessage::Note {
413            name: "lease.web.want.1760500000".to_string(),
414            value: 1.0,
415        };
416        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
417        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
418    }
419
420    #[test]
421    fn a_note_without_a_value_fails_to_decode() {
422        assert!(serde_json::from_str::<ChildMessage>(r#"{"kind":"note","name":"x"}"#).is_err());
423    }
424
425    #[test]
426    fn a_label_is_counted_in_characters_up_to_the_limit() {
427        let at_limit = "é".repeat(LambLabel::MAX_CHARS);
428        assert_eq!(LambLabel::new(at_limit.clone()).unwrap().as_str(), at_limit);
429        assert_eq!(
430            LambLabel::new(format!("{at_limit}e")),
431            Err(LambLabelError::TooLong {
432                chars: LambLabel::MAX_CHARS + 1
433            })
434        );
435    }
436
437    #[test]
438    fn a_label_with_a_control_character_is_refused_on_the_wire_too() {
439        for label in ["a\nb", "\u{1b}[2J", "tab\there", "\u{9b}31m"] {
440            assert_eq!(
441                LambLabel::new(label),
442                Err(LambLabelError::ControlCharacter),
443                "{label:?}"
444            );
445            let frame = serde_json::json!({"kind": "lamb-label", "pid": 1, "label": label});
446            assert!(
447                serde_json::from_value::<ChildMessage>(frame).is_err(),
448                "{label:?} decoded"
449            );
450        }
451    }
452
453    #[test]
454    fn a_label_error_says_the_limit_and_the_length() {
455        assert_eq!(
456            LambLabelError::TooLong { chars: 70 }.to_string(),
457            "a lamb label holds at most 64 characters, and this one has 70"
458        );
459    }
460}