shep_core/protocol/channel.rs
1//! The shepherd channel: the newline-JSON wire carried on fd 3 between the
2//! shepherd and each spawned child.
3//!
4//! [`ChildMessage`] flows child -> shepherd (readiness, metrics, action
5//! replies); [`ShepherdMessage`] flows shepherd -> child (shutdown request,
6//! custom actions). Framing (newline-JSON over `BufReader::lines()`) is wired
7//! by shep-daemon's real runner; this module only pins the message shapes.
8//!
9//! # Why this lives in shep-core
10//!
11//! It did not, until `BusEvent::Channel` (spec §6's `channel.*` topic) began
12//! carrying a [`ChildMessage`] verbatim to every subscriber. A bus event is a
13//! shep-core type, so the message it carries has to be one too — and a second
14//! copy of these shapes in shep-daemon would be two spellings of one wire that
15//! no test could compare across the crate boundary. shep-daemon re-exports
16//! both types from its own `channel` module, so nothing that already names
17//! them had to change.
18//!
19//! Both enums are deliberately NOT `#[non_exhaustive]`, unlike everything else
20//! under `protocol`. There is no handshake on fd 3 and no version to negotiate
21//! (`CHANNEL_VERSION` is a stamp, not a negotiation — see its own doc), so a
22//! new variant here is a change every app that speaks this wire has to be told
23//! about out of band. Leaving them exhaustive means the compiler names every
24//! site that has to decide something, [`crate::protocol::BusEvent::topic`]
25//! included, which is exactly the review a change on this wire deserves.
26//!
27//! This module pins the wire shapes; it is not the app-author-facing contract.
28//! An app that wants to speak this wire — including why it should reply to a
29//! [`ShepherdMessage::Action`] even when it does not recognize the name, how an
30//! echoed `id` gets a reply matched to its exact trigger and what the
31//! name-and-order fallback costs an app that does not echo it, and the `params`
32//! quoting gap — wants `docs/shepherd-channel.md` at the repository root.
33
34use serde::{Deserialize, Serialize};
35
36/// The value the shepherd exports as `SHEP_CHANNEL_VERSION` to every child it
37/// opens a channel for.
38///
39/// One version, and it stays `"1"` through this field addition, because the
40/// addition is additive in both directions: a daemon that stamps and an app
41/// that ignores the stamp interoperate exactly as before. What the variable
42/// buys is not negotiation — the shepherd still cannot ask an app what it
43/// speaks — but the ability for a defensive app to notice that fd 3 is
44/// carrying a protocol it has never seen, instead of failing to parse a line
45/// with nothing anywhere connecting that failure to a protocol change.
46///
47/// `docs/shepherd-channel.md` is the definition of what `"1"` means.
48pub const CHANNEL_VERSION: &str = "1";
49
50/// Child→daemon shepherd-channel message (spec §7 — kebab-case kinds)
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 ChildMessage {
55 /// `{"kind":"ready"}` — readiness signal (`wait_ready` gate)
56 Ready,
57 /// Custom metric sample
58 Metric {
59 /// Metric name
60 name: String,
61 /// Metric value
62 value: f64,
63 },
64 /// Reply to a daemon-initiated action
65 ActionReply {
66 /// The action name this replies to
67 action: String,
68 /// Free-form reply body
69 body: String,
70 /// The `id` of the [`ShepherdMessage::Action`] this answers, echoed
71 /// back verbatim. `None` when the app did not echo it.
72 ///
73 /// Optional, and that is the whole design. An app that echoes gets
74 /// exact correlation: its reply reaches the wait that asked, even
75 /// when an earlier trigger of the same action name timed out and is
76 /// still owed a reply. An app that does not echo — every app written
77 /// before this field existed — sends no `id` key at all, and the
78 /// daemon falls back to matching by name and order exactly as it did
79 /// before. Nothing already speaking this channel breaks, which is
80 /// what makes the field additive on a wire with no handshake.
81 #[serde(skip_serializing_if = "Option::is_none", default)]
82 id: Option<u64>,
83 },
84}
85
86/// Daemon→child message
87// wire format: changing these strings is a breaking change
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(tag = "kind", rename_all = "kebab-case")]
90pub enum ShepherdMessage {
91 /// Graceful-stop request (`shutdown_with_message`)
92 Shutdown,
93 /// Custom action dispatch
94 Action {
95 /// The action name
96 name: String,
97 /// Argument text for the action, passed through to the child
98 /// verbatim; `None` when the action was triggered without any.
99 ///
100 /// Absent from the serialized form when `None`, and absent on the
101 /// wire deserializes back to `None`, so a message carrying no
102 /// arguments is byte-identical to one from before this field
103 /// existed. That is what makes the field additive on a channel that
104 /// has no version to negotiate — see the spec's §9 note on
105 /// `trigger`.
106 ///
107 /// One opaque string, not structured data: the daemon never reads
108 /// it, and an app that wants JSON, a flag list or a bare word parses
109 /// it in the grammar it already has.
110 // `skip_serializing_if` is the load-bearing half: without it a
111 // message with no arguments goes out as `"params":null` instead of
112 // no key at all. `default` is redundant today — serde's derive
113 // already reads a missing `Option` field back as `None`, and no test
114 // can tell whether it is here — and is written anyway because that
115 // is a property of the derive rather than of this field, and a
116 // change of type would withdraw it silently on a channel that has no
117 // version in which to announce one.
118 #[serde(skip_serializing_if = "Option::is_none", default)]
119 params: Option<String>,
120 /// This dispatch's correlation id, unique for the life of the
121 /// daemon. Echo it back on your [`ChildMessage::ActionReply`] as
122 /// `id` and the daemon matches your answer to this exact request
123 /// rather than to its name.
124 ///
125 /// Always present, unlike `params`: an app that ignores the key is
126 /// unaffected, and an app that wants to echo must never have to
127 /// handle its absence. `u64` and monotonically increasing, but
128 /// neither of those is a promise an app should lean on — treat it as
129 /// an opaque token to hand back.
130 id: u64,
131 },
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 // Fixtures pinned FROM SPEC STRINGS (spec §7) — round-tripped both ways so a
139 // silent field/rename drift fails loudly in either direction.
140
141 #[test]
142 fn ready_wire_fixture_round_trips() {
143 let fixture = r#"{"kind":"ready"}"#;
144 assert_eq!(
145 serde_json::from_str::<ChildMessage>(fixture).unwrap(),
146 ChildMessage::Ready
147 );
148 assert_eq!(
149 serde_json::to_string(&ChildMessage::Ready).unwrap(),
150 fixture
151 );
152 }
153
154 #[test]
155 fn metric_wire_fixture_round_trips() {
156 let fixture = r#"{"kind":"metric","name":"rps","value":42.0}"#;
157 let msg = ChildMessage::Metric {
158 name: "rps".to_string(),
159 value: 42.0,
160 };
161 assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
162 assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
163 }
164
165 /// fails if a reply that carries no `id` stops deserializing — the
166 /// spelling every app written before Phase 10 sends, and the one the
167 /// name-and-order fallback exists for.
168 #[test]
169 fn an_action_reply_without_an_id_round_trips() {
170 let fixture = r#"{"kind":"action-reply","action":"gc","body":"ok"}"#;
171 let msg = ChildMessage::ActionReply {
172 action: "gc".to_string(),
173 body: "ok".to_string(),
174 id: None,
175 };
176 assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
177 assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
178 }
179
180 /// fails if an echoed `id` is dropped on the way in, or emitted when
181 /// absent on the way out. Both directions, because the daemon writes
182 /// this type in tests and reads it in production.
183 #[test]
184 fn an_action_reply_with_an_echoed_id_round_trips() {
185 let fixture = r#"{"kind":"action-reply","action":"gc","body":"ok","id":7}"#;
186 let msg = ChildMessage::ActionReply {
187 action: "gc".to_string(),
188 body: "ok".to_string(),
189 id: Some(7),
190 };
191 assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
192 assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
193 }
194
195 #[test]
196 fn shutdown_wire_fixture_round_trips() {
197 let fixture = r#"{"kind":"shutdown"}"#;
198 assert_eq!(
199 serde_json::from_str::<ShepherdMessage>(fixture).unwrap(),
200 ShepherdMessage::Shutdown
201 );
202 assert_eq!(
203 serde_json::to_string(&ShepherdMessage::Shutdown).unwrap(),
204 fixture
205 );
206 }
207
208 /// fails if the daemon stops writing `id` on an action, or starts
209 /// writing `params` when there is none. `id` is unconditional and
210 /// `params` is not — the two halves of the same line.
211 ///
212 /// Both directions per case, not just serialize: `id` being new is what
213 /// changed here, but `params`'s own additive round-trip (the field this
214 /// module already pinned before this task) still has to keep holding.
215 #[test]
216 fn an_action_carries_its_id_with_or_without_params() {
217 let bare = r#"{"kind":"action","name":"gc","id":7}"#;
218 let bare_msg = ShepherdMessage::Action {
219 name: "gc".to_string(),
220 params: None,
221 id: 7,
222 };
223 assert_eq!(serde_json::to_string(&bare_msg).unwrap(), bare);
224 assert_eq!(
225 serde_json::from_str::<ShepherdMessage>(bare).unwrap(),
226 bare_msg
227 );
228
229 let with_params = r#"{"kind":"action","name":"set-log-level","params":"debug","id":8}"#;
230 let with_params_msg = ShepherdMessage::Action {
231 name: "set-log-level".to_string(),
232 params: Some("debug".to_string()),
233 id: 8,
234 };
235 assert_eq!(
236 serde_json::to_string(&with_params_msg).unwrap(),
237 with_params
238 );
239 assert_eq!(
240 serde_json::from_str::<ShepherdMessage>(with_params).unwrap(),
241 with_params_msg
242 );
243 }
244}