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}