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