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}
49
50/// Daemon -> child message
51// wire format: changing these strings is a breaking change
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53#[serde(tag = "kind", rename_all = "kebab-case")]
54pub enum ShepherdMessage {
55    /// Graceful-stop request (`shutdown_with_message`)
56    Shutdown,
57    /// Custom action dispatch
58    Action {
59        /// The action name
60        name: String,
61        /// Argument text for the action, passed through to the child
62        /// verbatim; `None` when triggered without any. Omitted from the
63        /// wire when `None`, so a message with no arguments round-trips
64        /// byte-identical.
65        ///
66        /// One opaque string the daemon never reads, so an app parses it
67        /// in its own grammar.
68        // `skip_serializing_if` is load-bearing: without it, an empty
69        // message serializes `"params":null` instead of omitting the key.
70        // `default` guards a future type change on a channel with no
71        // version to announce one.
72        #[serde(skip_serializing_if = "Option::is_none", default)]
73        params: Option<String>,
74        /// This dispatch's correlation id, unique for the life of the
75        /// daemon. Echo it back as `id` on your
76        /// [`ChildMessage::ActionReply`]. The daemon then matches your
77        /// answer to this request, not to its name.
78        ///
79        /// Always present, unlike `params`. Treat `u64` and increasing as
80        /// implementation details, not a promise.
81        id: u64,
82    },
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    // Fixtures pinned from spec §7. Round-tripped both ways so a silent
90    // drift fails loudly.
91
92    #[test]
93    fn ready_wire_fixture_round_trips() {
94        let fixture = r#"{"kind":"ready"}"#;
95        assert_eq!(
96            serde_json::from_str::<ChildMessage>(fixture).unwrap(),
97            ChildMessage::Ready
98        );
99        assert_eq!(
100            serde_json::to_string(&ChildMessage::Ready).unwrap(),
101            fixture
102        );
103    }
104
105    #[test]
106    fn metric_wire_fixture_round_trips() {
107        let fixture = r#"{"kind":"metric","name":"rps","value":42.0}"#;
108        let msg = ChildMessage::Metric {
109            name: "rps".to_string(),
110            value: 42.0,
111        };
112        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
113        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
114    }
115
116    #[test]
117    fn an_action_reply_without_an_id_round_trips() {
118        let fixture = r#"{"kind":"action-reply","action":"gc","body":"ok"}"#;
119        let msg = ChildMessage::ActionReply {
120            action: "gc".to_string(),
121            body: "ok".to_string(),
122            id: None,
123        };
124        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
125        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
126    }
127
128    #[test]
129    fn an_action_reply_with_an_echoed_id_round_trips() {
130        let fixture = r#"{"kind":"action-reply","action":"gc","body":"ok","id":7}"#;
131        let msg = ChildMessage::ActionReply {
132            action: "gc".to_string(),
133            body: "ok".to_string(),
134            id: Some(7),
135        };
136        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
137        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
138    }
139
140    #[test]
141    fn shutdown_wire_fixture_round_trips() {
142        let fixture = r#"{"kind":"shutdown"}"#;
143        assert_eq!(
144            serde_json::from_str::<ShepherdMessage>(fixture).unwrap(),
145            ShepherdMessage::Shutdown
146        );
147        assert_eq!(
148            serde_json::to_string(&ShepherdMessage::Shutdown).unwrap(),
149            fixture
150        );
151    }
152
153    /// Checks both directions: serialize and deserialize.
154    #[test]
155    fn an_action_carries_its_id_with_or_without_params() {
156        let bare = r#"{"kind":"action","name":"gc","id":7}"#;
157        let bare_msg = ShepherdMessage::Action {
158            name: "gc".to_string(),
159            params: None,
160            id: 7,
161        };
162        assert_eq!(serde_json::to_string(&bare_msg).unwrap(), bare);
163        assert_eq!(
164            serde_json::from_str::<ShepherdMessage>(bare).unwrap(),
165            bare_msg
166        );
167
168        let with_params = r#"{"kind":"action","name":"set-log-level","params":"debug","id":8}"#;
169        let with_params_msg = ShepherdMessage::Action {
170            name: "set-log-level".to_string(),
171            params: Some("debug".to_string()),
172            id: 8,
173        };
174        assert_eq!(
175            serde_json::to_string(&with_params_msg).unwrap(),
176            with_params
177        );
178        assert_eq!(
179            serde_json::from_str::<ShepherdMessage>(with_params).unwrap(),
180            with_params_msg
181        );
182    }
183}