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}