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