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                    cpu_ms: None,
257                    dog: None,
258                    lambs: None,
259                    // `handle_exited` sets `last_exit` before deciding what
260                    // to do with the exit, so this `Exit` row carries the
261                    // outcome it announces.
262                    last_exit: Some(ExitInfo {
263                        code: Some(1),
264                        signal: None,
265                    }),
266                    // A non-ASCII marker on purpose: this snapshot is what
267                    // pins the encoding a subscriber reads, and a smit is
268                    // the one field on this row a third party writes.
269                    smit: Some("\u{25b2} main@a1b2c3".to_string()),
270                    instance: None,
271                    handshook: None,
272                    dog_stale: None,
273                    pending: None,
274                    overridden: None,
275                    max_memory: None,
276                    level_rules: Vec::new(),
277                    reload_deadline_ms: None,
278                },
279                manually: false,
280                at_ms: 1_700_000_000_000,
281            },
282            BusEvent::LogOut {
283                id: 3,
284                line: "listening on :8080".to_string(),
285            },
286            BusEvent::Dropped { count: 17 },
287            // The row above pins `instance`'s absent shape; every lifecycle
288            // row below reuses it via `sample`. This is the only place the
289            // present shape (a live slot on a scaled app) is on the wire.
290            BusEvent::Process {
291                event: ProcessEventKind::Online,
292                info: ProcessInfo::builder(4, "web", ProcStatus::Online)
293                    .pid(Some(5150))
294                    .instance(Some(2))
295                    .build(),
296                manually: false,
297                at_ms: 1_700_000_000_000,
298            },
299        ];
300
301        // One identical `info` reused below, so these rows differ only by
302        // their `event` tag: a variant rename changes the wire string
303        // silently otherwise.
304        let sample = ProcessInfo::builder(3, "web", ProcStatus::WaitingRestart)
305            .restarts(2)
306            .uptime_ms(500)
307            .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
308            .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
309            // Reused below for `Stop` and `Delete` too: both still carry the
310            // exit that produced them.
311            .last_exit(Some(ExitInfo {
312                code: Some(1),
313                signal: None,
314            }))
315            .build();
316
317        let lifecycle = [
318            ProcessEventKind::Start,
319            ProcessEventKind::Online,
320            ProcessEventKind::Restart,
321            ProcessEventKind::Stop,
322            ProcessEventKind::Delete,
323            ProcessEventKind::Errored,
324        ]
325        .map(|event| BusEvent::Process {
326            event,
327            info: sample.clone(),
328            manually: false,
329            at_ms: 1_700_000_000_000,
330        });
331
332        events.extend(lifecycle);
333
334        // The adjacent-tagged shape nests the message's own `kind` inside
335        // `data`, next to `id`: easy to get wrong by hand.
336        events.extend([
337            BusEvent::Channel {
338                id: 3,
339                message: ChildMessage::Ready,
340            },
341            BusEvent::Channel {
342                id: 3,
343                message: ChildMessage::Metric {
344                    name: "rps".to_string(),
345                    value: 42.0,
346                },
347            },
348            BusEvent::Channel {
349                id: 3,
350                message: ChildMessage::ActionReply {
351                    action: "gc".to_string(),
352                    body: "freed 12MB".to_string(),
353                    id: Some(7),
354                },
355            },
356        ]);
357
358        // Last, so every row above keeps its index. The one topic that is
359        // not a fixed string, and the one frame a dog subscribes to on its
360        // own name: an operator's `dogs.toml` edit reaches a running dog
361        // through this shape or through nothing.
362        events.push(BusEvent::DogConfigChanged {
363            dog: "bark".to_string(),
364        });
365
366        insta::assert_json_snapshot!("bus_event_wire_v9", events);
367    }
368
369    #[test]
370    fn topics_follow_the_dotted_grammar() {
371        // spec §6: process.* / log.out / log.err / daemon.*
372        let e = BusEvent::LogOut {
373            id: 1,
374            line: String::new(),
375        };
376        assert_eq!(e.topic(), "log.out");
377        assert_eq!(BusEvent::DaemonShutdown.topic(), "daemon.shutdown");
378    }
379
380    /// The three kinds a reload reports itself with, pinned as topic
381    /// strings and wire strings: a reload's reply is an acceptance, so
382    /// these frames are the only place a client learns how it went.
383    #[test]
384    fn a_reload_reports_itself_under_three_topics() {
385        for (kind, topic, wire) in [
386            (ProcessEventKind::Reload, "process.reload", "\"reload\""),
387            (
388                ProcessEventKind::Reloaded,
389                "process.reloaded",
390                "\"reloaded\"",
391            ),
392            (
393                ProcessEventKind::ReloadAbandoned,
394                "process.reload_abandoned",
395                "\"reload_abandoned\"",
396            ),
397        ] {
398            let event = BusEvent::Process {
399                event: kind,
400                // Scaffolding: `topic()` reads only `event`, and nothing
401                // here is asserted. The builder says so; the exhaustive
402                // literal in `bus_event_wire_snapshots` says the opposite
403                // about its own row, deliberately.
404                info: ProcessInfo::builder(3, "web", ProcStatus::Stopping)
405                    .pid(Some(4242))
406                    .build(),
407                manually: true,
408                at_ms: 0,
409            };
410            assert_eq!(event.topic(), topic, "{kind:?}");
411            assert_eq!(serde_json::to_string(&kind).unwrap(), wire, "{kind:?}");
412        }
413    }
414
415    #[test]
416    fn v1_bus_event_fixture_still_deserializes() {
417        // Adjacent-tagged shape pinned as a byte fixture.
418        let fixture = r#"{"event":"log_out","data":{"id":3,"line":"ready"}}"#;
419        let ev: BusEvent = serde_json::from_str(fixture).unwrap();
420        assert!(matches!(ev, BusEvent::LogOut { id: 3, .. }));
421    }
422
423    /// The exact topic strings are the contract, not just the `channel.*`
424    /// prefix.
425    #[test]
426    fn every_shepherd_channel_message_has_its_own_topic() {
427        for (message, topic) in [
428            (ChildMessage::Ready, "channel.ready"),
429            (
430                ChildMessage::Metric {
431                    name: "rps".to_string(),
432                    value: 42.0,
433                },
434                "channel.metric",
435            ),
436            (
437                ChildMessage::ActionReply {
438                    action: "gc".to_string(),
439                    body: "ok".to_string(),
440                    id: Some(7),
441                },
442                "channel.action_reply",
443            ),
444        ] {
445            let event = BusEvent::Channel {
446                id: 3,
447                message: message.clone(),
448            };
449            assert_eq!(event.topic(), topic, "{message:?}");
450        }
451    }
452
453    /// `channel.*` is the only pattern anyone subscribes with; a topic that
454    /// drifts out from under it becomes unreachable.
455    #[test]
456    fn the_channel_glob_reaches_all_three_topics() {
457        for message in [
458            ChildMessage::Ready,
459            ChildMessage::Metric {
460                name: "rps".to_string(),
461                value: 1.0,
462            },
463            ChildMessage::ActionReply {
464                action: "gc".to_string(),
465                body: String::new(),
466                id: None,
467            },
468        ] {
469            let topic = BusEvent::Channel { id: 1, message }.topic();
470            assert!(
471                topic.starts_with("channel."),
472                "`{topic}` is not under the channel.* glob"
473            );
474        }
475    }
476
477    /// The message carries verbatim: nothing on this wire is a credential.
478    #[test]
479    fn a_channel_event_carries_the_message_verbatim() {
480        let event = BusEvent::Channel {
481            id: 3,
482            message: ChildMessage::ActionReply {
483                action: "gc".to_string(),
484                body: "freed 12MB".to_string(),
485                id: Some(7),
486            },
487        };
488        let json = serde_json::to_string(&event).unwrap();
489        assert!(json.contains("freed 12MB"), "{json}");
490        assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
491    }
492
493    /// The topic is the whole of what a dog subscribes with; a name that
494    /// misses it leaves the dog listening to nothing.
495    #[test]
496    fn a_dog_config_event_names_the_dog_in_its_topic() {
497        for dog in ["bark", "metrics", "otel-shipper"] {
498            let event = BusEvent::DogConfigChanged {
499                dog: dog.to_string(),
500            };
501            assert_eq!(event.topic(), format!("config.dog.{dog}"));
502        }
503    }
504
505    /// A value here would put another dog's webhook credential in front of
506    /// every subscriber on `config.*`.
507    #[test]
508    fn a_dog_config_event_carries_the_name_and_nothing_else() {
509        let event = BusEvent::DogConfigChanged {
510            dog: "bark".to_string(),
511        };
512        let json = serde_json::to_string(&event).unwrap();
513        assert_eq!(
514            json,
515            r#"{"event":"dog_config_changed","data":{"dog":"bark"}}"#
516        );
517        assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
518    }
519}