shep_core/protocol/events.rs
1//! Bus events broadcast to subscribed clients
2
3use serde::{Deserialize, Serialize};
4
5use crate::protocol::channel::ChildMessage;
6use crate::protocol::request::ProcessInfo;
7
8/// What happened to a sheep
9// wire format: changing existing variants is a breaking change
10//
11// A NEW variant is additive for Rust and for the protocol version, but it is
12// not free for a subscriber that predates it, and this enum is the one place
13// in the protocol where that is true. There is no `#[serde(other)]` fallback,
14// and every variant's topic is `process.<something>`, which the `process.*`
15// glob an existing subscriber already uses matches — so an older client is
16// sent a frame it cannot decode. It drops that frame; it is not sent
17// anything it asked for and lost. The same does not arise for `Request` or
18// `Response`, where an old client never sends the verb whose answer it could
19// not read. Weigh that cost against the alternative before adding one:
20// reusing an existing kind and leaving subscribers to infer the event is the
21// other option, and it was the losing one for reload only because a reload's
22// reply is an acceptance, which leaves the bus as the only place its outcome
23// is ever reported.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26#[non_exhaustive]
27pub enum ProcessEventKind {
28 /// Spawn initiated
29 Start,
30 /// Became ready/online
31 Online,
32 /// Process exited
33 Exit,
34 /// Restart initiated
35 Restart,
36 /// A reload is replacing this instance: its replacement has been spawned
37 /// into the same instance slot, and this one will be asked to go once
38 /// that replacement is serving
39 Reload,
40 /// This instance has replaced the one it was spawned to drain, and that
41 /// one is gone — the swap is over
42 Reloaded,
43 /// A reload gave up, so the instances it had not reached are left alone
44 ///
45 /// The instance named is the one the abandonment left holding the slot,
46 /// and whether that one is serving depends on which abandonment it was.
47 /// Where the reload gave up on replacing an instance, that instance is
48 /// named and is still the app's live one. Where it gave up because the
49 /// replacement went down instead, the replacement is named. As with every
50 /// event here, `info` is that instance as it stood when the event was
51 /// raised, so read `info.status` rather than assuming a live one.
52 ReloadAbandoned,
53 /// Stopped by request
54 Stop,
55 /// Deregistered
56 Delete,
57 /// Restart budget exhausted
58 Errored,
59}
60
61/// One event on the daemon bus
62///
63/// Uses adjacently tagged serde format with `event` discriminator and `data` wrapper.
64/// Subscription TOPICS are the dotted strings from [`BusEvent::topic`]
65/// (`process.exit`, `log.out`, `daemon.*` — spec §6 grammar).
66/// Phase 2's server-side filter globs against `topic()`.
67// wire format: changing existing variants is a breaking change
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69// Adjacent tagging chosen because internally-tagged form cannot compile:
70// the `Process` variant has its own `event: ProcessEventKind` field, which
71// collides with an internal tag named `event` (serde_derive rejects this).
72// Adjacently tagging (with `content = "data"`) avoids the collision while
73// matching `Response`'s serde convention. Wire shape pinned by snapshot.
74#[serde(tag = "event", content = "data", rename_all = "snake_case")]
75#[non_exhaustive]
76pub enum BusEvent {
77 /// Lifecycle event for one sheep
78 Process {
79 /// What happened
80 event: ProcessEventKind,
81 /// Sheep snapshot at event time
82 info: ProcessInfo,
83 /// True when a user action caused it
84 manually: bool,
85 /// Unix millis
86 at_ms: u64,
87 },
88 /// One stdout line from a sheep
89 LogOut {
90 /// Sheep id
91 id: u32,
92 /// The line (no trailing newline)
93 line: String,
94 },
95 /// One stderr line from a sheep
96 LogErr {
97 /// Sheep id
98 id: u32,
99 /// The line
100 line: String,
101 },
102 /// One message a sheep wrote on its shepherd channel (fd 3).
103 ///
104 /// Child->shepherd only. The shepherd's own writes — the
105 /// `{"kind":"shutdown"}` of `shutdown_with_message`, and an `action` a
106 /// `Trigger` dispatched — are deliberately not here. Every one of them is
107 /// something an operator or the daemon just did and already has a
108 /// reporter: a shutdown message is followed by `process.stop`, and an
109 /// action is answered to the caller that sent it by
110 /// `Response::Triggered`. Putting them here as well would make this the
111 /// only event on the bus reporting a REQUEST rather than an outcome, and
112 /// would loop a dog that both subscribes and triggers back onto its own
113 /// dispatches. Adding the outbound half later stays additive — another
114 /// variant, more `channel.` topics, no version bump — so this is a
115 /// narrowing, not a door closed.
116 ///
117 /// `message` is the app's own text, whole and unredacted, unlike the
118 /// `[dog.<name>]` config that travels as [`DogSectionToml`]. Nothing on
119 /// this wire is a credential: `Ready` is empty, `Metric` is a name and a
120 /// float, and an `ActionReply` body is text the app chose to publish to
121 /// whoever triggered it. That is what makes a derived `Debug` safe here.
122 ///
123 /// [`DogSectionToml`]: crate::protocol::DogSectionToml
124 Channel {
125 /// The sheep that wrote it.
126 id: u32,
127 /// The message, exactly as it came off fd 3.
128 message: ChildMessage,
129 },
130 /// The bounded queue dropped this many events for this subscriber
131 Dropped {
132 /// Dropped-event count since last notice
133 count: u64,
134 },
135 /// Daemon is shutting down
136 DaemonShutdown,
137}
138
139impl BusEvent {
140 /// The dotted subscription topic for this event (spec §6 grammar)
141 #[must_use]
142 pub fn topic(&self) -> &'static str {
143 match self {
144 Self::Process { event, .. } => match event {
145 ProcessEventKind::Start => "process.start",
146 ProcessEventKind::Online => "process.online",
147 ProcessEventKind::Exit => "process.exit",
148 ProcessEventKind::Restart => "process.restart",
149 ProcessEventKind::Reload => "process.reload",
150 ProcessEventKind::Reloaded => "process.reloaded",
151 ProcessEventKind::ReloadAbandoned => "process.reload_abandoned",
152 ProcessEventKind::Stop => "process.stop",
153 ProcessEventKind::Delete => "process.delete",
154 ProcessEventKind::Errored => "process.errored",
155 },
156 Self::LogOut { .. } => "log.out",
157 Self::LogErr { .. } => "log.err",
158 // Total over `ChildMessage`, with no wildcard, and that is the
159 // point of leaving that enum exhaustive (see its module doc): a
160 // fourth kind on fd 3 fails to compile here until someone decides
161 // what its topic is, rather than defaulting into a topic no
162 // subscriber ever asked for.
163 Self::Channel { message, .. } => match message {
164 ChildMessage::Ready => "channel.ready",
165 ChildMessage::Metric { .. } => "channel.metric",
166 ChildMessage::ActionReply { .. } => "channel.action_reply",
167 },
168 Self::Dropped { .. } => "daemon.dropped",
169 Self::DaemonShutdown => "daemon.shutdown",
170 }
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::protocol::request::{ExitInfo, ProcessInfo};
178 use crate::status::ProcStatus;
179
180 #[test]
181 fn bus_event_wire_snapshots() {
182 let mut events = vec![
183 BusEvent::Process {
184 event: ProcessEventKind::Exit,
185 info: ProcessInfo {
186 id: 3,
187 name: "web".to_string(),
188 status: ProcStatus::WaitingRestart,
189 pid: None,
190 restarts: 2,
191 uptime_ms: 500,
192 fold: None,
193 out_file: Some("/home/rin/.shep/logs/web-0-out.log".to_string()),
194 err_file: Some("/home/rin/.shep/logs/web-0-err.log".to_string()),
195 // A bus event is built from the actor's own snapshot,
196 // which never carries a resource reading.
197 cpu_percent: None,
198 memory_bytes: None,
199 dog: None,
200 lambs: None,
201 // `restarts: 2` already says this is not this sheep's
202 // first exit; `handle_exited` sets `last_exit` before it
203 // decides what to do with the exit, so the `Exit` event
204 // this row pins carries the very outcome it announces.
205 last_exit: Some(ExitInfo {
206 code: Some(1),
207 signal: None,
208 }),
209 },
210 manually: false,
211 at_ms: 1_700_000_000_000,
212 },
213 BusEvent::LogOut {
214 id: 3,
215 line: "listening on :8080".to_string(),
216 },
217 BusEvent::Dropped { count: 17 },
218 ];
219
220 // Every lifecycle kind a `process.*` subscriber can receive, over one
221 // identical `info`, so the snapshot rows differ by their `event` tag
222 // and by nothing else. Only `Exit` and the three reload kinds were
223 // pinned before Phase 10; the six here are the ordinary events a real
224 // integration — a dashboard, a bark rule — depends on first, and a
225 // Rust-identifier rename on any of them would change the wire string
226 // mechanically, compile clean, and break that integration silently.
227 let sample = ProcessInfo::builder(3, "web", ProcStatus::WaitingRestart)
228 .restarts(2)
229 .uptime_ms(500)
230 .out_file(Some("/home/rin/.shep/logs/web-0-out.log".to_string()))
231 .err_file(Some("/home/rin/.shep/logs/web-0-err.log".to_string()))
232 // Reused below for `Stop` and `Delete` too — the two operator-
233 // caused endings, and proof that a `shep stop`/`shep delete`
234 // still carries the exit that produced them rather than losing
235 // it because an operator asked for it.
236 .last_exit(Some(ExitInfo {
237 code: Some(1),
238 signal: None,
239 }))
240 .build();
241
242 let lifecycle = [
243 ProcessEventKind::Start,
244 ProcessEventKind::Online,
245 ProcessEventKind::Restart,
246 ProcessEventKind::Stop,
247 ProcessEventKind::Delete,
248 ProcessEventKind::Errored,
249 ]
250 .map(|event| BusEvent::Process {
251 event,
252 info: sample.clone(),
253 manually: false,
254 at_ms: 1_700_000_000_000,
255 });
256
257 events.extend(lifecycle);
258
259 // All three shepherd-channel topics, over one sheep id, because the
260 // adjacent-tagged shape puts the message's own `kind` INSIDE `data`
261 // next to `id` — a nesting that is easy to get wrong by hand and
262 // invisible in a round-trip test, which only proves this crate agrees
263 // with itself.
264 events.extend([
265 BusEvent::Channel {
266 id: 3,
267 message: ChildMessage::Ready,
268 },
269 BusEvent::Channel {
270 id: 3,
271 message: ChildMessage::Metric {
272 name: "rps".to_string(),
273 value: 42.0,
274 },
275 },
276 BusEvent::Channel {
277 id: 3,
278 message: ChildMessage::ActionReply {
279 action: "gc".to_string(),
280 body: "freed 12MB".to_string(),
281 id: Some(7),
282 },
283 },
284 ]);
285
286 insta::assert_json_snapshot!("bus_event_wire_v1", events);
287 }
288
289 #[test]
290 fn topics_follow_the_dotted_grammar() {
291 // spec §6: process.* / log.out / log.err / daemon.*
292 let e = BusEvent::LogOut {
293 id: 1,
294 line: String::new(),
295 };
296 assert_eq!(e.topic(), "log.out");
297 assert_eq!(BusEvent::DaemonShutdown.topic(), "daemon.shutdown");
298 }
299
300 /// The three kinds a reload reports itself with, pinned as topic strings
301 /// and as wire strings.
302 ///
303 /// Fails if [`BusEvent::topic`] maps any of them to the wrong dotted
304 /// string — a typo there is invisible to a `process.*` subscriber, which
305 /// matches anything under `process.`, and silently unreachable to one
306 /// that named the topic it wanted. Fails too if a variant's serde
307 /// spelling drifts from its snake_case default (a stray
308 /// `#[serde(rename)]`, or a variant renamed without its topic): a reload's
309 /// reply is an acceptance, so these frames are the whole of what a client
310 /// ever learns about how the reload went, and a client matching on the
311 /// wire string would stop recognising them.
312 #[test]
313 fn a_reload_reports_itself_under_three_topics() {
314 for (kind, topic, wire) in [
315 (ProcessEventKind::Reload, "process.reload", "\"reload\""),
316 (
317 ProcessEventKind::Reloaded,
318 "process.reloaded",
319 "\"reloaded\"",
320 ),
321 (
322 ProcessEventKind::ReloadAbandoned,
323 "process.reload_abandoned",
324 "\"reload_abandoned\"",
325 ),
326 ] {
327 let event = BusEvent::Process {
328 event: kind,
329 info: ProcessInfo {
330 id: 3,
331 name: "web".to_string(),
332 status: ProcStatus::Stopping,
333 pid: Some(4242),
334 restarts: 0,
335 uptime_ms: 0,
336 fold: None,
337 out_file: None,
338 err_file: None,
339 cpu_percent: None,
340 memory_bytes: None,
341 dog: None,
342 lambs: None,
343 last_exit: None,
344 },
345 manually: true,
346 at_ms: 0,
347 };
348 assert_eq!(event.topic(), topic, "{kind:?}");
349 assert_eq!(serde_json::to_string(&kind).unwrap(), wire, "{kind:?}");
350 }
351 }
352
353 #[test]
354 fn v1_bus_event_fixture_still_deserializes() {
355 // Adjacent-tagged shape pinned as a byte fixture (IR-35).
356 let fixture = r#"{"event":"log_out","data":{"id":3,"line":"ready"}}"#;
357 let ev: BusEvent = serde_json::from_str(fixture).unwrap();
358 assert!(matches!(ev, BusEvent::LogOut { id: 3, .. }));
359 }
360
361 /// fails if a shepherd-channel message maps to the wrong dotted topic. A
362 /// subscriber that asked for `channel.metric` and silently receives nothing
363 /// has no other way to find out, and `channel.*` matches whatever typo is
364 /// there — so the exact strings are the contract, not the prefix.
365 #[test]
366 fn every_shepherd_channel_message_has_its_own_topic() {
367 for (message, topic) in [
368 (ChildMessage::Ready, "channel.ready"),
369 (
370 ChildMessage::Metric {
371 name: "rps".to_string(),
372 value: 42.0,
373 },
374 "channel.metric",
375 ),
376 (
377 ChildMessage::ActionReply {
378 action: "gc".to_string(),
379 body: "ok".to_string(),
380 id: Some(7),
381 },
382 "channel.action_reply",
383 ),
384 ] {
385 let event = BusEvent::Channel {
386 id: 3,
387 message: message.clone(),
388 };
389 assert_eq!(event.topic(), topic, "{message:?}");
390 }
391 }
392
393 /// fails if `channel.*` stops reaching every one of the three. The glob a
394 /// dashboard writes is the prefix, so a topic that drifted out from under it
395 /// (`channel_ready`, say) would be unreachable by the only pattern anyone
396 /// actually subscribes with.
397 #[test]
398 fn the_channel_glob_reaches_all_three_topics() {
399 for message in [
400 ChildMessage::Ready,
401 ChildMessage::Metric {
402 name: "rps".to_string(),
403 value: 1.0,
404 },
405 ChildMessage::ActionReply {
406 action: "gc".to_string(),
407 body: String::new(),
408 id: None,
409 },
410 ] {
411 let topic = BusEvent::Channel { id: 1, message }.topic();
412 assert!(
413 topic.starts_with("channel."),
414 "`{topic}` is not under the channel.* glob"
415 );
416 }
417 }
418
419 /// fails if the event stops carrying the message body. The whole argument for
420 /// putting the real message on the bus rather than a summary is that nothing
421 /// on this wire is a credential — a reply body that arrived truncated or
422 /// replaced would make the topic useless for the case it exists for, a
423 /// dashboard watching what apps actually say.
424 #[test]
425 fn a_channel_event_carries_the_message_verbatim() {
426 let event = BusEvent::Channel {
427 id: 3,
428 message: ChildMessage::ActionReply {
429 action: "gc".to_string(),
430 body: "freed 12MB".to_string(),
431 id: Some(7),
432 },
433 };
434 let json = serde_json::to_string(&event).unwrap();
435 assert!(json.contains("freed 12MB"), "{json}");
436 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
437 }
438}