Skip to main content

shep_core/protocol/
events.rs

1//! Bus events broadcast to subscribed clients
2
3use std::borrow::Cow;
4
5use serde::{Deserialize, Serialize};
6
7use crate::protocol::ChildMessage;
8use crate::protocol::request::ProcessInfo;
9
10/// What happened to a sheep
11// wire format: changing existing variants is a breaking change
12//
13// A new variant is not free here: there is no `#[serde(other)]` fallback,
14// so an old subscriber is sent a frame under `process.*` it cannot decode.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17#[non_exhaustive]
18pub enum ProcessEventKind {
19    /// Spawn initiated
20    Start,
21    /// Became ready/online
22    Online,
23    /// Process exited
24    Exit,
25    /// Restart initiated
26    Restart,
27    /// A reload is replacing this instance: its replacement has been spawned
28    /// into the same instance slot, and this one will be asked to go once
29    /// that replacement is serving
30    Reload,
31    /// This instance has replaced the one it was spawned to drain; that one
32    /// is gone.
33    Reloaded,
34    /// A reload gave up, so the instances it had not reached are left alone
35    ///
36    /// The instance named is whichever one the abandonment left holding the
37    /// slot: still the app's live instance if the reload gave up before
38    /// replacing it, or the replacement if that one went down instead.
39    /// `info` reflects that instance's state at event time; read
40    /// `info.status` rather than assume it is live.
41    ReloadAbandoned,
42    /// Stopped by request
43    Stop,
44    /// Deregistered
45    Delete,
46    /// Restart budget exhausted
47    Errored,
48}
49
50/// One event on the daemon bus
51///
52/// Adjacently tagged: `event` discriminator, `data` wrapper. Subscription
53/// topics are the dotted strings from [`BusEvent::topic`] (`process.exit`,
54/// `log.out`, `daemon.*`); the daemon's filter globs against them.
55// wire format: changing existing variants is a breaking change
56//
57// `large_enum_variant` allowed: boxing `Process` would break every match on
58// it, for no benefit since an event is serialized immediately.
59#[allow(clippy::large_enum_variant)]
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61// Adjacently tagged, not internally: `Process`'s own `event` field would
62// collide with an internal tag named `event`, and serde_derive refuses
63// that.
64#[serde(tag = "event", content = "data", rename_all = "snake_case")]
65#[non_exhaustive]
66pub enum BusEvent {
67    /// Lifecycle event for one sheep
68    Process {
69        /// What happened
70        event: ProcessEventKind,
71        /// Sheep snapshot at event time
72        info: ProcessInfo,
73        /// True when a user action caused it
74        manually: bool,
75        /// Unix millis
76        at_ms: u64,
77    },
78    /// One stdout line from a sheep
79    LogOut {
80        /// Sheep id
81        id: u32,
82        /// The line (no trailing newline)
83        line: String,
84    },
85    /// One stderr line from a sheep
86    LogErr {
87        /// Sheep id
88        id: u32,
89        /// The line
90        line: String,
91    },
92    /// One message a sheep wrote on its shepherd channel (fd 3).
93    ///
94    /// Child -> shepherd only: the shepherd's own writes (a shutdown
95    /// message, a dispatched action) are reported elsewhere, by
96    /// `process.stop` and `Response::Triggered`.
97    ///
98    /// `message` is the app's own text, whole and unredacted. The daemon
99    /// adds nothing of its own; app-provided text must be safe for every
100    /// subscriber, since it is broadcast verbatim.
101    Channel {
102        /// The sheep that wrote it.
103        id: u32,
104        /// The message, exactly as it came off fd 3.
105        message: ChildMessage,
106    },
107    /// The bounded queue dropped this many events for this subscriber
108    Dropped {
109        /// Dropped-event count since last notice
110        count: u64,
111    },
112    /// Daemon is shutting down
113    DaemonShutdown,
114    /// A dog's section in `dogs.toml` changed. Published under
115    /// `config.dog.<name>`, so a dog subscribes to its own name and hears
116    /// nobody else's.
117    ///
118    /// Carries only the dog's name, nothing else: the bus is a broadcast,
119    /// and a `[bark]` section can hold a webhook URL as a bearer credential
120    /// (why [`DogSectionToml`] redacts its own `Debug`). A dog that wants
121    /// the values re-asks with
122    /// [`Request::DogConfig`](crate::protocol::Request::DogConfig), which
123    /// answers only that dog's own section.
124    ///
125    /// [`DogSectionToml`]: crate::protocol::DogSectionToml
126    DogConfigChanged {
127        /// The dog whose section changed.
128        dog: String,
129    },
130}
131
132impl BusEvent {
133    /// The dotted subscription topic for this event (spec §6 grammar)
134    ///
135    /// A [`Cow`] rather than a `&'static str`, because one topic is not
136    /// fixed: [`Self::DogConfigChanged`] names its dog in the topic
137    /// itself, which is what lets a dog subscribe to its own config and
138    /// hear nobody else's. Every other variant is still a borrowed
139    /// literal and allocates nothing.
140    #[must_use]
141    pub fn topic(&self) -> Cow<'static, str> {
142        let fixed = match self {
143            Self::Process { event, .. } => match event {
144                ProcessEventKind::Start => "process.start",
145                ProcessEventKind::Online => "process.online",
146                ProcessEventKind::Exit => "process.exit",
147                ProcessEventKind::Restart => "process.restart",
148                ProcessEventKind::Reload => "process.reload",
149                ProcessEventKind::Reloaded => "process.reloaded",
150                ProcessEventKind::ReloadAbandoned => "process.reload_abandoned",
151                ProcessEventKind::Stop => "process.stop",
152                ProcessEventKind::Delete => "process.delete",
153                ProcessEventKind::Errored => "process.errored",
154            },
155            Self::LogOut { .. } => "log.out",
156            Self::LogErr { .. } => "log.err",
157            // Total match over `ChildMessage`: a fourth kind on fd 3 fails
158            // to compile here until its topic is decided.
159            Self::Channel { message, .. } => match message {
160                ChildMessage::Ready => "channel.ready",
161                ChildMessage::Metric { .. } => "channel.metric",
162                ChildMessage::ActionReply { .. } => "channel.action_reply",
163            },
164            Self::Dropped { .. } => "daemon.dropped",
165            Self::DaemonShutdown => "daemon.shutdown",
166            // The one topic built rather than named. `config.dog.` is the
167            // prefix a subscriber globs on; the dog's own name is the last
168            // segment, so `config.dog.bark` reaches one dog and `config.*`
169            // reaches all of them.
170            Self::DogConfigChanged { dog } => return Cow::Owned(format!("config.dog.{dog}")),
171        };
172        Cow::Borrowed(fixed)
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::protocol::request::{ExitInfo, ProcessInfo};
180    use crate::status::ProcStatus;
181
182    #[test]
183    fn bus_event_wire_snapshots() {
184        let mut events = vec![
185            BusEvent::Process {
186                event: ProcessEventKind::Exit,
187                info: ProcessInfo {
188                    id: 3,
189                    name: "web".to_string(),
190                    status: ProcStatus::WaitingRestart,
191                    pid: None,
192                    restarts: 2,
193                    uptime_ms: 500,
194                    fold: None,
195                    out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
196                    err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
197                    // A bus event is built from the actor's own snapshot,
198                    // which never carries a resource reading.
199                    cpu_percent: None,
200                    memory_bytes: None,
201                    dog: None,
202                    lambs: None,
203                    // `handle_exited` sets `last_exit` before deciding what
204                    // to do with the exit, so this `Exit` row carries the
205                    // outcome it announces.
206                    last_exit: Some(ExitInfo {
207                        code: Some(1),
208                        signal: None,
209                    }),
210                    // A non-ASCII marker on purpose: this snapshot is what
211                    // pins the encoding a subscriber reads, and a smit is
212                    // the one field on this row a third party writes.
213                    smit: Some("\u{25b2} main@a1b2c3".to_string()),
214                    instance: None,
215                    handshook: None,
216                    dog_stale: None,
217                    pending: None,
218                    overridden: None,
219                    max_memory: None,
220                },
221                manually: false,
222                at_ms: 1_700_000_000_000,
223            },
224            BusEvent::LogOut {
225                id: 3,
226                line: "listening on :8080".to_string(),
227            },
228            BusEvent::Dropped { count: 17 },
229            // The row above pins `instance`'s absent shape; every lifecycle
230            // row below reuses it via `sample`. This is the only place the
231            // present shape (a live slot on a scaled app) is on the wire.
232            BusEvent::Process {
233                event: ProcessEventKind::Online,
234                info: ProcessInfo::builder(4, "web", ProcStatus::Online)
235                    .pid(Some(5150))
236                    .instance(Some(2))
237                    .build(),
238                manually: false,
239                at_ms: 1_700_000_000_000,
240            },
241        ];
242
243        // One identical `info` reused below, so these rows differ only by
244        // their `event` tag: a variant rename changes the wire string
245        // silently otherwise.
246        let sample = ProcessInfo::builder(3, "web", ProcStatus::WaitingRestart)
247            .restarts(2)
248            .uptime_ms(500)
249            .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
250            .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
251            // Reused below for `Stop` and `Delete` too: both still carry the
252            // exit that produced them.
253            .last_exit(Some(ExitInfo {
254                code: Some(1),
255                signal: None,
256            }))
257            .build();
258
259        let lifecycle = [
260            ProcessEventKind::Start,
261            ProcessEventKind::Online,
262            ProcessEventKind::Restart,
263            ProcessEventKind::Stop,
264            ProcessEventKind::Delete,
265            ProcessEventKind::Errored,
266        ]
267        .map(|event| BusEvent::Process {
268            event,
269            info: sample.clone(),
270            manually: false,
271            at_ms: 1_700_000_000_000,
272        });
273
274        events.extend(lifecycle);
275
276        // The adjacent-tagged shape nests the message's own `kind` inside
277        // `data`, next to `id`: easy to get wrong by hand.
278        events.extend([
279            BusEvent::Channel {
280                id: 3,
281                message: ChildMessage::Ready,
282            },
283            BusEvent::Channel {
284                id: 3,
285                message: ChildMessage::Metric {
286                    name: "rps".to_string(),
287                    value: 42.0,
288                },
289            },
290            BusEvent::Channel {
291                id: 3,
292                message: ChildMessage::ActionReply {
293                    action: "gc".to_string(),
294                    body: "freed 12MB".to_string(),
295                    id: Some(7),
296                },
297            },
298        ]);
299
300        // Last, so every row above keeps its index. The one topic that is
301        // not a fixed string, and the one frame a dog subscribes to on its
302        // own name: an operator's `dogs.toml` edit reaches a running dog
303        // through this shape or through nothing.
304        events.push(BusEvent::DogConfigChanged {
305            dog: "bark".to_string(),
306        });
307
308        insta::assert_json_snapshot!("bus_event_wire_v4", events);
309    }
310
311    #[test]
312    fn topics_follow_the_dotted_grammar() {
313        // spec §6: process.* / log.out / log.err / daemon.*
314        let e = BusEvent::LogOut {
315            id: 1,
316            line: String::new(),
317        };
318        assert_eq!(e.topic(), "log.out");
319        assert_eq!(BusEvent::DaemonShutdown.topic(), "daemon.shutdown");
320    }
321
322    /// The three kinds a reload reports itself with, pinned as topic
323    /// strings and wire strings: a reload's reply is an acceptance, so
324    /// these frames are the only place a client learns how it went.
325    #[test]
326    fn a_reload_reports_itself_under_three_topics() {
327        for (kind, topic, wire) in [
328            (ProcessEventKind::Reload, "process.reload", "\"reload\""),
329            (
330                ProcessEventKind::Reloaded,
331                "process.reloaded",
332                "\"reloaded\"",
333            ),
334            (
335                ProcessEventKind::ReloadAbandoned,
336                "process.reload_abandoned",
337                "\"reload_abandoned\"",
338            ),
339        ] {
340            let event = BusEvent::Process {
341                event: kind,
342                info: ProcessInfo {
343                    id: 3,
344                    name: "web".to_string(),
345                    status: ProcStatus::Stopping,
346                    pid: Some(4242),
347                    restarts: 0,
348                    uptime_ms: 0,
349                    fold: None,
350                    out_file: None,
351                    err_file: None,
352                    cpu_percent: None,
353                    memory_bytes: None,
354                    dog: None,
355                    lambs: None,
356                    last_exit: None,
357                    smit: None,
358                    instance: None,
359                    handshook: None,
360                    dog_stale: None,
361                    pending: None,
362                    overridden: None,
363                    max_memory: None,
364                },
365                manually: true,
366                at_ms: 0,
367            };
368            assert_eq!(event.topic(), topic, "{kind:?}");
369            assert_eq!(serde_json::to_string(&kind).unwrap(), wire, "{kind:?}");
370        }
371    }
372
373    #[test]
374    fn v1_bus_event_fixture_still_deserializes() {
375        // Adjacent-tagged shape pinned as a byte fixture.
376        let fixture = r#"{"event":"log_out","data":{"id":3,"line":"ready"}}"#;
377        let ev: BusEvent = serde_json::from_str(fixture).unwrap();
378        assert!(matches!(ev, BusEvent::LogOut { id: 3, .. }));
379    }
380
381    /// The exact topic strings are the contract, not just the `channel.*`
382    /// prefix.
383    #[test]
384    fn every_shepherd_channel_message_has_its_own_topic() {
385        for (message, topic) in [
386            (ChildMessage::Ready, "channel.ready"),
387            (
388                ChildMessage::Metric {
389                    name: "rps".to_string(),
390                    value: 42.0,
391                },
392                "channel.metric",
393            ),
394            (
395                ChildMessage::ActionReply {
396                    action: "gc".to_string(),
397                    body: "ok".to_string(),
398                    id: Some(7),
399                },
400                "channel.action_reply",
401            ),
402        ] {
403            let event = BusEvent::Channel {
404                id: 3,
405                message: message.clone(),
406            };
407            assert_eq!(event.topic(), topic, "{message:?}");
408        }
409    }
410
411    /// `channel.*` is the only pattern anyone subscribes with; a topic that
412    /// drifts out from under it becomes unreachable.
413    #[test]
414    fn the_channel_glob_reaches_all_three_topics() {
415        for message in [
416            ChildMessage::Ready,
417            ChildMessage::Metric {
418                name: "rps".to_string(),
419                value: 1.0,
420            },
421            ChildMessage::ActionReply {
422                action: "gc".to_string(),
423                body: String::new(),
424                id: None,
425            },
426        ] {
427            let topic = BusEvent::Channel { id: 1, message }.topic();
428            assert!(
429                topic.starts_with("channel."),
430                "`{topic}` is not under the channel.* glob"
431            );
432        }
433    }
434
435    /// The message carries verbatim: nothing on this wire is a credential.
436    #[test]
437    fn a_channel_event_carries_the_message_verbatim() {
438        let event = BusEvent::Channel {
439            id: 3,
440            message: ChildMessage::ActionReply {
441                action: "gc".to_string(),
442                body: "freed 12MB".to_string(),
443                id: Some(7),
444            },
445        };
446        let json = serde_json::to_string(&event).unwrap();
447        assert!(json.contains("freed 12MB"), "{json}");
448        assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
449    }
450
451    /// The topic is the whole of what a dog subscribes with; a name that
452    /// misses it leaves the dog listening to nothing.
453    #[test]
454    fn a_dog_config_event_names_the_dog_in_its_topic() {
455        for dog in ["bark", "metrics", "otel-shipper"] {
456            let event = BusEvent::DogConfigChanged {
457                dog: dog.to_string(),
458            };
459            assert_eq!(event.topic(), format!("config.dog.{dog}"));
460        }
461    }
462
463    /// A value here would put another dog's webhook credential in front of
464    /// every subscriber on `config.*`.
465    #[test]
466    fn a_dog_config_event_carries_the_name_and_nothing_else() {
467        let event = BusEvent::DogConfigChanged {
468            dog: "bark".to_string(),
469        };
470        let json = serde_json::to_string(&event).unwrap();
471        assert_eq!(
472            json,
473            r#"{"event":"dog_config_changed","data":{"dog":"bark"}}"#
474        );
475        assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
476    }
477}