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