Skip to main content

shep_core/protocol/
channel.rs

1//! The shepherd channel: newline-JSON wire on fd 3 between the shepherd and
2//! each spawned child. [`ChildMessage`] flows child -> shepherd;
3//! [`ShepherdMessage`] flows shepherd -> child. Framing is wired by
4//! shep-daemon; lives in shep-core because `BusEvent::Channel` carries a
5//! `ChildMessage` verbatim to every bus subscriber.
6//!
7//! Both enums are exhaustive, unlike everything else under `protocol`: fd 3
8//! has no handshake, so a new variant means telling every app out of band,
9//! and exhaustive matches force every call site to react to it.
10//!
11//! Pins the wire shapes only, not the app-facing contract: see
12//! `docs/shepherd-channel.md` for reply and correlation semantics.
13
14use serde::{Deserialize, Serialize};
15
16/// The value the shepherd exports as `SHEP_CHANNEL_VERSION` to every child it
17/// opens a channel for.
18///
19/// Stays `"1"` through this field addition: a daemon that stamps and an app
20/// that ignores the stamp interoperate exactly as before. Not a
21/// negotiation, just a way for a defensive app to notice that fd 3 carries
22/// a protocol it has never seen.
23///
24/// `docs/shepherd-channel.md` defines what `"1"` means.
25pub const CHANNEL_VERSION: &str = "1";
26
27/// Child -> daemon shepherd-channel message (spec §7, kebab-case kinds)
28// wire format: changing these strings is a breaking change
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(tag = "kind", rename_all = "kebab-case")]
31pub enum ChildMessage {
32    /// `{"kind":"ready"}`: readiness signal (`wait_ready` gate)
33    Ready,
34    /// Custom metric sample
35    Metric {
36        /// Metric name
37        name: String,
38        /// Metric value
39        value: f64,
40    },
41    /// Reply to a daemon-initiated action
42    ActionReply {
43        /// The action name this replies to
44        action: String,
45        /// Free-form reply body
46        body: String,
47        /// The `id` of the [`ShepherdMessage::Action`] this answers, echoed
48        /// back verbatim. `None` when the app did not echo it, in which
49        /// case the daemon falls back to matching by name and order.
50        #[serde(skip_serializing_if = "Option::is_none", default)]
51        id: Option<u64>,
52    },
53}
54
55/// Daemon -> child message
56// wire format: changing these strings is a breaking change
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "kebab-case")]
59pub enum ShepherdMessage {
60    /// Graceful-stop request (`shutdown_with_message`)
61    Shutdown,
62    /// Custom action dispatch
63    Action {
64        /// The action name
65        name: String,
66        /// Argument text for the action, passed through to the child
67        /// verbatim; `None` when triggered without any.
68        ///
69        /// Omitted from the wire when `None`, so a message with no
70        /// arguments round-trips byte-identical.
71        ///
72        /// One opaque string, not structured data: the daemon never reads
73        /// it, so an app parses it in whatever grammar it already has.
74        // `skip_serializing_if` is load-bearing: without it a message with
75        // no arguments serializes `"params":null` instead of omitting the
76        // key. `default` guards a future type change on a channel with no
77        // version to announce one.
78        #[serde(skip_serializing_if = "Option::is_none", default)]
79        params: Option<String>,
80        /// This dispatch's correlation id, unique for the life of the
81        /// daemon. Echo it back on your [`ChildMessage::ActionReply`] as
82        /// `id` and the daemon matches your answer to this exact request
83        /// rather than to its name.
84        ///
85        /// Always present, unlike `params`. Treat it as an opaque token to
86        /// hand back: `u64` and increasing are implementation details, not
87        /// a promise.
88        id: u64,
89    },
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    // Fixtures pinned from spec §7 strings, round-tripped both ways so a
97    // silent field or rename drift fails loudly.
98
99    #[test]
100    fn ready_wire_fixture_round_trips() {
101        let fixture = r#"{"kind":"ready"}"#;
102        assert_eq!(
103            serde_json::from_str::<ChildMessage>(fixture).unwrap(),
104            ChildMessage::Ready
105        );
106        assert_eq!(
107            serde_json::to_string(&ChildMessage::Ready).unwrap(),
108            fixture
109        );
110    }
111
112    #[test]
113    fn metric_wire_fixture_round_trips() {
114        let fixture = r#"{"kind":"metric","name":"rps","value":42.0}"#;
115        let msg = ChildMessage::Metric {
116            name: "rps".to_string(),
117            value: 42.0,
118        };
119        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
120        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
121    }
122
123    /// Apps with no correlation id still send this shape.
124    #[test]
125    fn an_action_reply_without_an_id_round_trips() {
126        let fixture = r#"{"kind":"action-reply","action":"gc","body":"ok"}"#;
127        let msg = ChildMessage::ActionReply {
128            action: "gc".to_string(),
129            body: "ok".to_string(),
130            id: None,
131        };
132        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
133        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
134    }
135
136    #[test]
137    fn an_action_reply_with_an_echoed_id_round_trips() {
138        let fixture = r#"{"kind":"action-reply","action":"gc","body":"ok","id":7}"#;
139        let msg = ChildMessage::ActionReply {
140            action: "gc".to_string(),
141            body: "ok".to_string(),
142            id: Some(7),
143        };
144        assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
145        assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
146    }
147
148    #[test]
149    fn shutdown_wire_fixture_round_trips() {
150        let fixture = r#"{"kind":"shutdown"}"#;
151        assert_eq!(
152            serde_json::from_str::<ShepherdMessage>(fixture).unwrap(),
153            ShepherdMessage::Shutdown
154        );
155        assert_eq!(
156            serde_json::to_string(&ShepherdMessage::Shutdown).unwrap(),
157            fixture
158        );
159    }
160
161    /// `id` is unconditional; `params` is not. Both cases, both directions.
162    #[test]
163    fn an_action_carries_its_id_with_or_without_params() {
164        let bare = r#"{"kind":"action","name":"gc","id":7}"#;
165        let bare_msg = ShepherdMessage::Action {
166            name: "gc".to_string(),
167            params: None,
168            id: 7,
169        };
170        assert_eq!(serde_json::to_string(&bare_msg).unwrap(), bare);
171        assert_eq!(
172            serde_json::from_str::<ShepherdMessage>(bare).unwrap(),
173            bare_msg
174        );
175
176        let with_params = r#"{"kind":"action","name":"set-log-level","params":"debug","id":8}"#;
177        let with_params_msg = ShepherdMessage::Action {
178            name: "set-log-level".to_string(),
179            params: Some("debug".to_string()),
180            id: 8,
181        };
182        assert_eq!(
183            serde_json::to_string(&with_params_msg).unwrap(),
184            with_params
185        );
186        assert_eq!(
187            serde_json::from_str::<ShepherdMessage>(with_params).unwrap(),
188            with_params_msg
189        );
190    }
191}