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    /// A dog's `[app.dogs.<name>]` table changed on one sheep. Published
142    /// under `config.sheep.<dog>`, so a dog subscribes to its own name and
143    /// hears nobody else's, the way [`Self::DogConfigChanged`] does for
144    /// `dogs.toml`.
145    ///
146    /// Fires once per dog and sheep for a change that adds, removes or
147    /// edits that sheep's table for `dog`: a name gaining its first table,
148    /// the last instance of such a name being deleted, or a load,
149    /// `Request::SetSheepDogSettings` or `--reset` changing one. Never for
150    /// a handover, since that changes no answer.
151    ///
152    /// Carries only the sheep's name and the dog's: a table can hold a
153    /// credential, the same reason [`Self::DogConfigChanged`] carries only
154    /// the dog's own name. A dog that wants the values re-asks with
155    /// [`Request::DogSheepSettings`](crate::protocol::Request::DogSheepSettings),
156    /// which answers with that dog's tables.
157    DogSheepSettingsChanged {
158        /// The dog whose table changed.
159        dog: String,
160        /// The sheep whose table changed.
161        sheep: String,
162    },
163}
164
165impl BusEvent {
166    /// The dotted subscription topic for this event (spec §6 grammar)
167    ///
168    /// A [`Cow`] rather than a `&'static str`, because two topics are not
169    /// fixed: [`Self::DogConfigChanged`] and [`Self::DogSheepSettingsChanged`]
170    /// name their dog in the topic itself, which is what lets a dog
171    /// subscribe to its own config and hear nobody else's. Every other
172    /// variant is still a borrowed literal and allocates nothing.
173    #[must_use]
174    pub fn topic(&self) -> Cow<'static, str> {
175        let fixed = match self {
176            Self::Process { event, .. } => match event {
177                ProcessEventKind::Start => "process.start",
178                ProcessEventKind::Online => "process.online",
179                ProcessEventKind::Exit => "process.exit",
180                ProcessEventKind::Restart => "process.restart",
181                ProcessEventKind::Reload => "process.reload",
182                ProcessEventKind::Reloaded => "process.reloaded",
183                ProcessEventKind::ReloadAbandoned => "process.reload_abandoned",
184                ProcessEventKind::Stop => "process.stop",
185                ProcessEventKind::Delete => "process.delete",
186                ProcessEventKind::Errored => "process.errored",
187                // Decode-only, never emitted by this build. Given its own
188                // topic rather than acted on: a subscriber sees it pass
189                // through like any other kind it does not specifically
190                // filter for, instead of the daemon treating it as a real
191                // lifecycle transition.
192                ProcessEventKind::Unrecognized => "process.unrecognized",
193            },
194            Self::LogOut { .. } => "log.out",
195            Self::LogErr { .. } => "log.err",
196            // Total match over `ChildMessage`: a new kind on fd 3 fails
197            // to compile here until its topic is decided.
198            Self::Channel { message, .. } => match message {
199                ChildMessage::Ready => "channel.ready",
200                ChildMessage::Metric { .. } => "channel.metric",
201                ChildMessage::ActionReply { .. } => "channel.action_reply",
202                ChildMessage::LambLabel { .. } => "channel.lamb_label",
203            },
204            Self::Dropped { .. } => "daemon.dropped",
205            Self::DaemonShutdown => "daemon.shutdown",
206            // Built rather than named. `config.dog.` is the prefix a
207            // subscriber globs on; the dog's own name is the last segment,
208            // so `config.dog.bark` reaches one dog and `config.*` reaches
209            // all of them.
210            Self::DogConfigChanged { dog } => return Cow::Owned(format!("config.dog.{dog}")),
211            // Named by the dog, like `DogConfigChanged` above: the sheep is
212            // the payload, not the topic, so a dog subscribes once for
213            // every sheep carrying its table.
214            Self::DogSheepSettingsChanged { dog, .. } => {
215                return Cow::Owned(format!("config.sheep.{dog}"));
216            }
217        };
218        Cow::Borrowed(fixed)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::protocol::LambLabel;
226    use crate::protocol::request::{ExitInfo, ProcessInfo};
227    use crate::status::ProcStatus;
228
229    /// The comment above this enum said a new variant is not free because
230    /// there is no fallback. This is the fallback.
231    #[test]
232    fn an_unknown_process_event_decodes_as_unrecognized() {
233        assert_eq!(
234            serde_json::from_str::<ProcessEventKind>(r#""invented_next_year""#).unwrap(),
235            ProcessEventKind::Unrecognized
236        );
237    }
238
239    #[test]
240    fn every_known_process_event_still_round_trips() {
241        for kind in [
242            ProcessEventKind::Start,
243            ProcessEventKind::Online,
244            ProcessEventKind::Exit,
245            ProcessEventKind::Restart,
246            ProcessEventKind::Reload,
247            ProcessEventKind::Reloaded,
248            ProcessEventKind::ReloadAbandoned,
249            ProcessEventKind::Stop,
250            ProcessEventKind::Delete,
251            ProcessEventKind::Errored,
252        ] {
253            let json = serde_json::to_string(&kind).unwrap();
254            assert_eq!(
255                serde_json::from_str::<ProcessEventKind>(&json).unwrap(),
256                kind
257            );
258        }
259    }
260
261    #[test]
262    fn a_non_string_process_event_is_still_an_error() {
263        assert!(serde_json::from_str::<ProcessEventKind>("42").is_err());
264    }
265
266    #[test]
267    fn bus_event_wire_snapshots() {
268        let mut events = vec![
269            BusEvent::Process {
270                event: ProcessEventKind::Exit,
271                info: ProcessInfo {
272                    id: 3,
273                    name: "web".to_string(),
274                    status: ProcStatus::WaitingRestart,
275                    pid: None,
276                    restarts: 2,
277                    uptime_ms: 500,
278                    fold: None,
279                    depends_on: Vec::new(),
280                    out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
281                    err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
282                    // A bus event is built from the actor's own snapshot,
283                    // which never carries a resource reading.
284                    cpu_percent: None,
285                    memory_bytes: None,
286                    cpu_ms: None,
287                    dog: None,
288                    lambs: None,
289                    // `handle_exited` sets `last_exit` before deciding what
290                    // to do with the exit, so this `Exit` row carries the
291                    // outcome it announces.
292                    last_exit: Some(ExitInfo {
293                        code: Some(1),
294                        signal: None,
295                    }),
296                    // A non-ASCII marker on purpose: this snapshot is what
297                    // pins the encoding a subscriber reads, and a smit is
298                    // the one field on this row a third party writes.
299                    smit: Some("\u{25b2} main@a1b2c3".to_string()),
300                    instance: None,
301                    handshook: None,
302                    dog_stale: None,
303                    pending: None,
304                    overridden: None,
305                    max_memory: None,
306                    level_rules: Vec::new(),
307                    reload_deadline_ms: None,
308                },
309                manually: false,
310                at_ms: 1_700_000_000_000,
311            },
312            BusEvent::LogOut {
313                id: 3,
314                line: "listening on :8080".to_string(),
315            },
316            BusEvent::Dropped { count: 17 },
317            // The row above pins `instance`'s absent shape; every lifecycle
318            // row below reuses it via `sample`. This is the only place the
319            // present shape (a live slot on a scaled app) is on the wire.
320            BusEvent::Process {
321                event: ProcessEventKind::Online,
322                info: ProcessInfo::builder(4, "web", ProcStatus::Online)
323                    .pid(Some(5150))
324                    .instance(Some(2))
325                    .build(),
326                manually: false,
327                at_ms: 1_700_000_000_000,
328            },
329        ];
330
331        // One identical `info` reused below, so these rows differ only by
332        // their `event` tag: a variant rename changes the wire string
333        // silently otherwise.
334        let sample = ProcessInfo::builder(3, "web", ProcStatus::WaitingRestart)
335            .restarts(2)
336            .uptime_ms(500)
337            .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
338            .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
339            // Reused below for `Stop` and `Delete` too: both still carry the
340            // exit that produced them.
341            .last_exit(Some(ExitInfo {
342                code: Some(1),
343                signal: None,
344            }))
345            .build();
346
347        let lifecycle = [
348            ProcessEventKind::Start,
349            ProcessEventKind::Online,
350            ProcessEventKind::Restart,
351            ProcessEventKind::Stop,
352            ProcessEventKind::Delete,
353            ProcessEventKind::Errored,
354        ]
355        .map(|event| BusEvent::Process {
356            event,
357            info: sample.clone(),
358            manually: false,
359            at_ms: 1_700_000_000_000,
360        });
361
362        events.extend(lifecycle);
363
364        // The adjacent-tagged shape nests the message's own `kind` inside
365        // `data`, next to `id`: easy to get wrong by hand.
366        events.extend([
367            BusEvent::Channel {
368                id: 3,
369                message: ChildMessage::Ready,
370            },
371            BusEvent::Channel {
372                id: 3,
373                message: ChildMessage::Metric {
374                    name: "rps".to_string(),
375                    value: 42.0,
376                },
377            },
378            BusEvent::Channel {
379                id: 3,
380                message: ChildMessage::ActionReply {
381                    action: "gc".to_string(),
382                    body: "freed 12MB".to_string(),
383                    id: Some(7),
384                },
385            },
386        ]);
387
388        // Last, so every row above keeps its index. The one topic that is
389        // not a fixed string, and the one frame a dog subscribes to on its
390        // own name: an operator's `dogs.toml` edit reaches a running dog
391        // through this shape or through nothing.
392        events.push(BusEvent::DogConfigChanged {
393            dog: "bark".to_string(),
394        });
395        // The second event whose topic is built rather than named, and the
396        // one that carries a sheep's name beside the dog's.
397        events.push(BusEvent::DogSheepSettingsChanged {
398            dog: "jobs".to_string(),
399            sheep: "web".to_string(),
400        });
401
402        insta::assert_json_snapshot!("bus_event_wire_v11", events);
403    }
404
405    #[test]
406    fn topics_follow_the_dotted_grammar() {
407        // spec §6: process.* / log.out / log.err / daemon.*
408        let e = BusEvent::LogOut {
409            id: 1,
410            line: String::new(),
411        };
412        assert_eq!(e.topic(), "log.out");
413        assert_eq!(BusEvent::DaemonShutdown.topic(), "daemon.shutdown");
414    }
415
416    /// The three kinds a reload reports itself with, pinned as topic
417    /// strings and wire strings: a reload's reply is an acceptance, so
418    /// these frames are the only place a client learns how it went.
419    #[test]
420    fn a_reload_reports_itself_under_three_topics() {
421        for (kind, topic, wire) in [
422            (ProcessEventKind::Reload, "process.reload", "\"reload\""),
423            (
424                ProcessEventKind::Reloaded,
425                "process.reloaded",
426                "\"reloaded\"",
427            ),
428            (
429                ProcessEventKind::ReloadAbandoned,
430                "process.reload_abandoned",
431                "\"reload_abandoned\"",
432            ),
433        ] {
434            let event = BusEvent::Process {
435                event: kind,
436                // Scaffolding: `topic()` reads only `event`, and nothing
437                // here is asserted. The builder says so; the exhaustive
438                // literal in `bus_event_wire_snapshots` says the opposite
439                // about its own row, deliberately.
440                info: ProcessInfo::builder(3, "web", ProcStatus::Stopping)
441                    .pid(Some(4242))
442                    .build(),
443                manually: true,
444                at_ms: 0,
445            };
446            assert_eq!(event.topic(), topic, "{kind:?}");
447            assert_eq!(serde_json::to_string(&kind).unwrap(), wire, "{kind:?}");
448        }
449    }
450
451    #[test]
452    fn v1_bus_event_fixture_still_deserializes() {
453        // Adjacent-tagged shape pinned as a byte fixture.
454        let fixture = r#"{"event":"log_out","data":{"id":3,"line":"ready"}}"#;
455        let ev: BusEvent = serde_json::from_str(fixture).unwrap();
456        assert!(matches!(ev, BusEvent::LogOut { id: 3, .. }));
457    }
458
459    /// The exact topic strings are the contract, not just the `channel.*`
460    /// prefix.
461    #[test]
462    fn every_shepherd_channel_message_has_its_own_topic() {
463        for (message, topic) in [
464            (ChildMessage::Ready, "channel.ready"),
465            (
466                ChildMessage::Metric {
467                    name: "rps".to_string(),
468                    value: 42.0,
469                },
470                "channel.metric",
471            ),
472            (
473                ChildMessage::ActionReply {
474                    action: "gc".to_string(),
475                    body: "ok".to_string(),
476                    id: Some(7),
477                },
478                "channel.action_reply",
479            ),
480            (
481                ChildMessage::LambLabel {
482                    pid: 4312,
483                    label: LambLabel::new("worker 1").unwrap(),
484                },
485                "channel.lamb_label",
486            ),
487        ] {
488            let event = BusEvent::Channel {
489                id: 3,
490                message: message.clone(),
491            };
492            assert_eq!(event.topic(), topic, "{message:?}");
493        }
494    }
495
496    /// `channel.*` is the only pattern anyone subscribes with; a topic that
497    /// drifts out from under it becomes unreachable.
498    #[test]
499    fn the_channel_glob_reaches_every_channel_topic() {
500        for message in [
501            ChildMessage::Ready,
502            ChildMessage::Metric {
503                name: "rps".to_string(),
504                value: 1.0,
505            },
506            ChildMessage::ActionReply {
507                action: "gc".to_string(),
508                body: String::new(),
509                id: None,
510            },
511            ChildMessage::LambLabel {
512                pid: 1,
513                label: LambLabel::new("").unwrap(),
514            },
515        ] {
516            let topic = BusEvent::Channel { id: 1, message }.topic();
517            assert!(
518                topic.starts_with("channel."),
519                "`{topic}` is not under the channel.* glob"
520            );
521        }
522    }
523
524    /// The message carries verbatim: nothing on this wire is a credential.
525    #[test]
526    fn a_channel_event_carries_the_message_verbatim() {
527        let event = BusEvent::Channel {
528            id: 3,
529            message: ChildMessage::ActionReply {
530                action: "gc".to_string(),
531                body: "freed 12MB".to_string(),
532                id: Some(7),
533            },
534        };
535        let json = serde_json::to_string(&event).unwrap();
536        assert!(json.contains("freed 12MB"), "{json}");
537        assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
538    }
539
540    /// The topic is the whole of what a dog subscribes with; a name that
541    /// misses it leaves the dog listening to nothing.
542    #[test]
543    fn a_dog_config_event_names_the_dog_in_its_topic() {
544        for dog in ["bark", "metrics", "otel-shipper"] {
545            let event = BusEvent::DogConfigChanged {
546                dog: dog.to_string(),
547            };
548            assert_eq!(event.topic(), format!("config.dog.{dog}"));
549        }
550    }
551
552    /// A value here would put another dog's webhook credential in front of
553    /// every subscriber on `config.*`.
554    #[test]
555    fn a_dog_config_event_carries_the_name_and_nothing_else() {
556        let event = BusEvent::DogConfigChanged {
557            dog: "bark".to_string(),
558        };
559        let json = serde_json::to_string(&event).unwrap();
560        assert_eq!(
561            json,
562            r#"{"event":"dog_config_changed","data":{"dog":"bark"}}"#
563        );
564        assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
565    }
566
567    /// The topic names the dog, not the sheep, so one dog's subscription
568    /// reaches every sheep carrying its table.
569    #[test]
570    fn a_dog_sheep_settings_event_names_the_dog_in_its_topic() {
571        for dog in ["jobs", "metrics"] {
572            let event = BusEvent::DogSheepSettingsChanged {
573                dog: dog.to_string(),
574                sheep: "web".to_string(),
575            };
576            assert_eq!(event.topic(), format!("config.sheep.{dog}"));
577        }
578    }
579
580    /// `config.*` still reaches this event, beside `config.dog.*`: a
581    /// dashboard watching every dog's config must not lose this topic to a
582    /// glob that only matched the older shape.
583    #[test]
584    fn a_dog_sheep_settings_topic_is_still_under_the_config_glob() {
585        let event = BusEvent::DogSheepSettingsChanged {
586            dog: "jobs".to_string(),
587            sheep: "web".to_string(),
588        };
589        assert!(event.topic().starts_with("config."));
590    }
591
592    /// The sheep rides in the payload, not the topic: a dog subscribes once
593    /// on its own name and hears about every sheep carrying its table.
594    #[test]
595    fn a_dog_sheep_settings_event_carries_the_sheep_and_the_dog() {
596        let event = BusEvent::DogSheepSettingsChanged {
597            dog: "jobs".to_string(),
598            sheep: "web".to_string(),
599        };
600        let json = serde_json::to_string(&event).unwrap();
601        assert_eq!(
602            json,
603            r#"{"event":"dog_sheep_settings_changed","data":{"dog":"jobs","sheep":"web"}}"#
604        );
605        assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
606    }
607}