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 serde::{Deserialize, Serialize};
14
15/// The value the shepherd exports as `SHEP_CHANNEL_VERSION` to every child
16/// it opens a channel for.
17///
18/// Not a negotiation: a way for an app to notice a wire it has never
19/// seen. `docs/shepherd-channel.md` defines what `"1"` means.
20pub const CHANNEL_VERSION: &str = "1";
21
22/// Child -> daemon shepherd-channel message (spec §7, kebab-case kinds)
23// wire format: changing these strings is a breaking change
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25#[serde(tag = "kind", rename_all = "kebab-case")]
26pub enum ChildMessage {
27    /// `{"kind":"ready"}`: readiness signal (`wait_ready` gate)
28    Ready,
29    /// Custom metric sample
30    Metric {
31        /// Metric name
32        name: String,
33        /// Metric value
34        value: f64,
35    },
36    /// Reply to a daemon-initiated action
37    ActionReply {
38        /// The action name this replies to
39        action: String,
40        /// Free-form reply body
41        body: String,
42        /// The `id` of the [`ShepherdMessage::Action`] this answers, echoed
43        /// back verbatim. `None` when the app did not echo it. Then the
44        /// daemon falls back to matching by name and order.
45        #[serde(skip_serializing_if = "Option::is_none", default)]
46        id: Option<u64>,
47    },
48    /// Names one of this sheep's lambs for `shep describe` and lookout.
49    ///
50    /// Shown only while `pid` is in the sheep's own process tree. An empty
51    /// `label` clears the one `pid` had.
52    LambLabel {
53        /// The lamb's pid, as the app's own spawn call reported it
54        pid: u32,
55        /// What `describe` shows beside the lamb's executable name
56        label: LambLabel,
57    },
58}
59
60/// A sheep's own name for one of its lambs
61///
62/// Holds at most [`LambLabel::MAX_CHARS`] characters and no control
63/// character, whether built with [`LambLabel::new`] or read off the wire.
64/// Empty is a real value: it clears a label rather than setting one.
65// wire format: changing this is a breaking change
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(try_from = "String", into = "String")]
68pub struct LambLabel(String);
69
70impl LambLabel {
71    /// The most characters a label may hold, counted as Unicode scalar
72    /// values.
73    // Room for `worker 12 of 16` or a queue name, and still one table column.
74    pub const MAX_CHARS: usize = 64;
75
76    /// A label, checked against the channel's grammar.
77    ///
78    /// # Errors
79    ///
80    /// - [`LambLabelError::TooLong`] when `label` has more than
81    ///   [`Self::MAX_CHARS`] characters.
82    /// - [`LambLabelError::ControlCharacter`] when `label` holds one, a
83    ///   newline included.
84    pub fn new(label: impl Into<String>) -> Result<Self, LambLabelError> {
85        let label = label.into();
86        let chars = label.chars().count();
87        if chars > Self::MAX_CHARS {
88            return Err(LambLabelError::TooLong { chars });
89        }
90        if label.chars().any(char::is_control) {
91            return Err(LambLabelError::ControlCharacter);
92        }
93        Ok(Self(label))
94    }
95
96    /// The label's text.
97    #[must_use]
98    pub fn as_str(&self) -> &str {
99        &self.0
100    }
101
102    /// Whether this clears a label rather than setting one.
103    #[must_use]
104    pub fn is_empty(&self) -> bool {
105        self.0.is_empty()
106    }
107}
108
109impl TryFrom<String> for LambLabel {
110    type Error = LambLabelError;
111
112    fn try_from(label: String) -> Result<Self, Self::Error> {
113        Self::new(label)
114    }
115}
116
117impl From<LambLabel> for String {
118    fn from(label: LambLabel) -> Self {
119        label.0
120    }
121}
122
123/// Why a string is not a [`LambLabel`].
124// Library crate: a new rule in the label grammar is a new variant.
125#[non_exhaustive]
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum LambLabelError {
128    /// The label has more than [`LambLabel::MAX_CHARS`] characters.
129    TooLong {
130        /// How many characters it has.
131        chars: usize,
132    },
133    /// The label holds a control character, which could drive the
134    /// terminal it is shown on.
135    ControlCharacter,
136}
137
138impl core::fmt::Display for LambLabelError {
139    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
140        match self {
141            Self::TooLong { chars } => write!(
142                f,
143                "a lamb label holds at most {} characters, and this one has {chars}",
144                LambLabel::MAX_CHARS
145            ),
146            Self::ControlCharacter => f.write_str("a lamb label cannot hold a control character"),
147        }
148    }
149}
150
151impl core::error::Error for LambLabelError {}
152
153/// Daemon -> child message
154// wire format: changing these strings is a breaking change
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156#[serde(tag = "kind", rename_all = "kebab-case")]
157pub enum ShepherdMessage {
158    /// Graceful-stop request (`shutdown_with_message`)
159    Shutdown,
160    /// Custom action dispatch
161    Action {
162        /// The action name
163        name: String,
164        /// Argument text for the action, passed through to the child
165        /// verbatim; `None` when triggered without any. Omitted from the
166        /// wire when `None`, so a message with no arguments round-trips
167        /// byte-identical.
168        ///
169        /// One opaque string the daemon never reads, so an app parses it
170        /// in its own grammar.
171        // `skip_serializing_if` is load-bearing: without it, an empty
172        // message serializes `"params":null` instead of omitting the key.
173        // `default` guards a future type change on a channel with no
174        // version to announce one.
175        #[serde(skip_serializing_if = "Option::is_none", default)]
176        params: Option<String>,
177        /// This dispatch's correlation id, unique for the life of the
178        /// daemon. Echo it back as `id` on your
179        /// [`ChildMessage::ActionReply`]. The daemon then matches your
180        /// answer to this request, not to its name.
181        ///
182        /// Always present, unlike `params`. Treat `u64` and increasing as
183        /// implementation details, not a promise.
184        id: u64,
185    },
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    // Fixtures pinned from spec §7. Round-tripped both ways so a silent
193    // drift fails loudly.
194
195    #[test]
196    fn ready_wire_fixture_round_trips() {
197        let fixture = r#"{"kind":"ready"}"#;
198        assert_eq!(
199            serde_json::from_str::<ChildMessage>(fixture).unwrap(),
200            ChildMessage::Ready
201        );
202        assert_eq!(
203            serde_json::to_string(&ChildMessage::Ready).unwrap(),
204            fixture
205        );
206    }
207
208    #[test]
209    fn metric_wire_fixture_round_trips() {
210        let fixture = r#"{"kind":"metric","name":"rps","value":42.0}"#;
211        let msg = ChildMessage::Metric {
212            name: "rps".to_string(),
213            value: 42.0,
214        };
215        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
216        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
217    }
218
219    #[test]
220    fn an_action_reply_without_an_id_round_trips() {
221        let fixture = r#"{"kind":"action-reply","action":"gc","body":"ok"}"#;
222        let msg = ChildMessage::ActionReply {
223            action: "gc".to_string(),
224            body: "ok".to_string(),
225            id: None,
226        };
227        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
228        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
229    }
230
231    #[test]
232    fn an_action_reply_with_an_echoed_id_round_trips() {
233        let fixture = r#"{"kind":"action-reply","action":"gc","body":"ok","id":7}"#;
234        let msg = ChildMessage::ActionReply {
235            action: "gc".to_string(),
236            body: "ok".to_string(),
237            id: Some(7),
238        };
239        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
240        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
241    }
242
243    #[test]
244    fn shutdown_wire_fixture_round_trips() {
245        let fixture = r#"{"kind":"shutdown"}"#;
246        assert_eq!(
247            serde_json::from_str::<ShepherdMessage>(fixture).unwrap(),
248            ShepherdMessage::Shutdown
249        );
250        assert_eq!(
251            serde_json::to_string(&ShepherdMessage::Shutdown).unwrap(),
252            fixture
253        );
254    }
255
256    /// Checks both directions: serialize and deserialize.
257    #[test]
258    fn an_action_carries_its_id_with_or_without_params() {
259        let bare = r#"{"kind":"action","name":"gc","id":7}"#;
260        let bare_msg = ShepherdMessage::Action {
261            name: "gc".to_string(),
262            params: None,
263            id: 7,
264        };
265        assert_eq!(serde_json::to_string(&bare_msg).unwrap(), bare);
266        assert_eq!(
267            serde_json::from_str::<ShepherdMessage>(bare).unwrap(),
268            bare_msg
269        );
270
271        let with_params = r#"{"kind":"action","name":"set-log-level","params":"debug","id":8}"#;
272        let with_params_msg = ShepherdMessage::Action {
273            name: "set-log-level".to_string(),
274            params: Some("debug".to_string()),
275            id: 8,
276        };
277        assert_eq!(
278            serde_json::to_string(&with_params_msg).unwrap(),
279            with_params
280        );
281        assert_eq!(
282            serde_json::from_str::<ShepherdMessage>(with_params).unwrap(),
283            with_params_msg
284        );
285    }
286
287    #[test]
288    fn a_lamb_label_round_trips() {
289        let fixture = r#"{"kind":"lamb-label","pid":4312,"label":"worker 1"}"#;
290        let msg = ChildMessage::LambLabel {
291            pid: 4312,
292            label: LambLabel::new("worker 1").unwrap(),
293        };
294        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
295        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
296    }
297
298    /// The clearing form: an absent key would be a malformed frame instead.
299    #[test]
300    fn an_empty_lamb_label_is_on_the_wire_and_clears() {
301        let fixture = r#"{"kind":"lamb-label","pid":4312,"label":""}"#;
302        let ChildMessage::LambLabel { label, .. } =
303            serde_json::from_str::<ChildMessage>(fixture).unwrap()
304        else {
305            panic!("{fixture} decoded as another kind");
306        };
307        assert!(label.is_empty());
308        assert!(
309            serde_json::from_str::<ChildMessage>(r#"{"kind":"lamb-label","pid":4312}"#).is_err()
310        );
311    }
312
313    #[test]
314    fn a_label_is_counted_in_characters_up_to_the_limit() {
315        let at_limit = "é".repeat(LambLabel::MAX_CHARS);
316        assert_eq!(LambLabel::new(at_limit.clone()).unwrap().as_str(), at_limit);
317        assert_eq!(
318            LambLabel::new(format!("{at_limit}e")),
319            Err(LambLabelError::TooLong {
320                chars: LambLabel::MAX_CHARS + 1
321            })
322        );
323    }
324
325    #[test]
326    fn a_label_with_a_control_character_is_refused_on_the_wire_too() {
327        for label in ["a\nb", "\u{1b}[2J", "tab\there", "\u{9b}31m"] {
328            assert_eq!(
329                LambLabel::new(label),
330                Err(LambLabelError::ControlCharacter),
331                "{label:?}"
332            );
333            let frame = serde_json::json!({"kind": "lamb-label", "pid": 1, "label": label});
334            assert!(
335                serde_json::from_value::<ChildMessage>(frame).is_err(),
336                "{label:?} decoded"
337            );
338        }
339    }
340
341    #[test]
342    fn a_label_error_says_the_limit_and_the_length() {
343        assert_eq!(
344            LambLabelError::TooLong { chars: 70 }.to_string(),
345            "a lamb label holds at most 64 characters, and this one has 70"
346        );
347    }
348}