Skip to main content

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 — apps
166    /// with no correlation id still send this shape, and the
167    /// name-and-order fallback exists for exactly that.
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}