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/// The daemon's server-side filter globs against `topic()`.
67// wire format: changing existing variants is a breaking change
68//
69// `large_enum_variant` allowed, not fixed, for the same reason
70// `Response`'s own `#[allow]` gives (see that type's doc): `Process` holds
71// a whole `ProcessInfo` inline, and task 12's own `pending`/`overridden`
72// fields are what pushed the spread past the lint's threshold this time.
73// Boxing would be a source break for every `BusEvent::Process { info, .. }`
74// in and out of this workspace, for nothing: an event is built once per
75// transition and serialized onto the bus immediately, so the size it
76// occupies on one stack frame in between is not a cost anybody pays.
77#[allow(clippy::large_enum_variant)]
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79// Adjacent tagging chosen because internally-tagged form cannot compile:
80// the `Process` variant has its own `event: ProcessEventKind` field, which
81// collides with an internal tag named `event` (serde_derive rejects this).
82// Adjacently tagging (with `content = "data"`) avoids the collision while
83// matching `Response`'s serde convention. Wire shape pinned by snapshot.
84#[serde(tag = "event", content = "data", rename_all = "snake_case")]
85#[non_exhaustive]
86pub enum BusEvent {
87 /// Lifecycle event for one sheep
88 Process {
89 /// What happened
90 event: ProcessEventKind,
91 /// Sheep snapshot at event time
92 info: ProcessInfo,
93 /// True when a user action caused it
94 manually: bool,
95 /// Unix millis
96 at_ms: u64,
97 },
98 /// One stdout line from a sheep
99 LogOut {
100 /// Sheep id
101 id: u32,
102 /// The line (no trailing newline)
103 line: String,
104 },
105 /// One stderr line from a sheep
106 LogErr {
107 /// Sheep id
108 id: u32,
109 /// The line
110 line: String,
111 },
112 /// One message a sheep wrote on its shepherd channel (fd 3).
113 ///
114 /// Child->shepherd only. The shepherd's own writes — the
115 /// `{"kind":"shutdown"}` of `shutdown_with_message`, and an `action` a
116 /// `Trigger` dispatched — are deliberately not here. Every one of them is
117 /// something an operator or the daemon just did and already has a
118 /// reporter: a shutdown message is followed by `process.stop`, and an
119 /// action is answered to the caller that sent it by
120 /// `Response::Triggered`. Putting them here as well would make this the
121 /// only event on the bus reporting a REQUEST rather than an outcome, and
122 /// would loop a dog that both subscribes and triggers back onto its own
123 /// dispatches. Adding the outbound half later stays additive — another
124 /// variant, more `channel.` topics, no version bump — so this is a
125 /// narrowing, not a door closed.
126 ///
127 /// `message` is the app's own text, whole and unredacted, unlike the
128 /// `[dog.<name>]` config that travels as [`DogSectionToml`]. Nothing on
129 /// this wire is a credential: `Ready` is empty, `Metric` is a name and a
130 /// float, and an `ActionReply` body is text the app chose to publish to
131 /// whoever triggered it. That is what makes a derived `Debug` safe here.
132 ///
133 /// [`DogSectionToml`]: crate::protocol::DogSectionToml
134 Channel {
135 /// The sheep that wrote it.
136 id: u32,
137 /// The message, exactly as it came off fd 3.
138 message: ChildMessage,
139 },
140 /// The bounded queue dropped this many events for this subscriber
141 Dropped {
142 /// Dropped-event count since last notice
143 count: u64,
144 },
145 /// Daemon is shutting down
146 DaemonShutdown,
147}
148
149impl BusEvent {
150 /// The dotted subscription topic for this event (spec §6 grammar)
151 #[must_use]
152 pub fn topic(&self) -> &'static str {
153 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 },
166 Self::LogOut { .. } => "log.out",
167 Self::LogErr { .. } => "log.err",
168 // Total over `ChildMessage`, with no wildcard, and that is the
169 // point of leaving that enum exhaustive (see its module doc): a
170 // fourth kind on fd 3 fails to compile here until someone decides
171 // what its topic is, rather than defaulting into a topic no
172 // subscriber ever asked for.
173 Self::Channel { message, .. } => match message {
174 ChildMessage::Ready => "channel.ready",
175 ChildMessage::Metric { .. } => "channel.metric",
176 ChildMessage::ActionReply { .. } => "channel.action_reply",
177 },
178 Self::Dropped { .. } => "daemon.dropped",
179 Self::DaemonShutdown => "daemon.shutdown",
180 }
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::protocol::request::{ExitInfo, ProcessInfo};
188 use crate::status::ProcStatus;
189
190 #[test]
191 fn bus_event_wire_snapshots() {
192 let mut events = vec![
193 BusEvent::Process {
194 event: ProcessEventKind::Exit,
195 info: ProcessInfo {
196 id: 3,
197 name: "web".to_string(),
198 status: ProcStatus::WaitingRestart,
199 pid: None,
200 restarts: 2,
201 uptime_ms: 500,
202 fold: None,
203 out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
204 err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
205 // A bus event is built from the actor's own snapshot,
206 // which never carries a resource reading.
207 cpu_percent: None,
208 memory_bytes: None,
209 dog: None,
210 lambs: None,
211 // `restarts: 2` already says this is not this sheep's
212 // first exit; `handle_exited` sets `last_exit` before it
213 // decides what to do with the exit, so the `Exit` event
214 // this row pins carries the very outcome it announces.
215 last_exit: Some(ExitInfo {
216 code: Some(1),
217 signal: None,
218 }),
219 // A non-ASCII marker on purpose: this snapshot is what
220 // pins the encoding a subscriber reads, and a smit is
221 // the one field on this row a third party writes.
222 smit: Some("\u{25b2} main@a1b2c3".to_string()),
223 instance: None,
224 handshook: None,
225 dog_stale: None,
226 pending: None,
227 overridden: None,
228 },
229 manually: false,
230 at_ms: 1_700_000_000_000,
231 },
232 BusEvent::LogOut {
233 id: 3,
234 line: "listening on :8080".to_string(),
235 },
236 BusEvent::Dropped { count: 17 },
237 // The row above pins `instance`'s absent shape; every lifecycle
238 // row below reuses it via `sample`. This is the only place the
239 // present shape (a live slot on a scaled app) is on the wire.
240 BusEvent::Process {
241 event: ProcessEventKind::Online,
242 info: ProcessInfo::builder(4, "web", ProcStatus::Online)
243 .pid(Some(5150))
244 .instance(Some(2))
245 .build(),
246 manually: false,
247 at_ms: 1_700_000_000_000,
248 },
249 ];
250
251 // The lifecycle kinds exercised here (the reload trio has its own
252 // fixture), over one
253 // identical `info`, so the snapshot rows differ by their `event` tag
254 // and by nothing else. These are the ordinary events a real
255 // integration — a dashboard, a bark rule — depends on first, and a
256 // Rust-identifier rename on any of them would change the wire string
257 // mechanically, compile clean, and break that integration silently.
258 let sample = ProcessInfo::builder(3, "web", ProcStatus::WaitingRestart)
259 .restarts(2)
260 .uptime_ms(500)
261 .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
262 .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
263 // Reused below for `Stop` and `Delete` too — the two operator-
264 // caused endings, and proof that a `shep stop`/`shep delete`
265 // still carries the exit that produced them rather than losing
266 // it because an operator asked for it.
267 .last_exit(Some(ExitInfo {
268 code: Some(1),
269 signal: None,
270 }))
271 .build();
272
273 let lifecycle = [
274 ProcessEventKind::Start,
275 ProcessEventKind::Online,
276 ProcessEventKind::Restart,
277 ProcessEventKind::Stop,
278 ProcessEventKind::Delete,
279 ProcessEventKind::Errored,
280 ]
281 .map(|event| BusEvent::Process {
282 event,
283 info: sample.clone(),
284 manually: false,
285 at_ms: 1_700_000_000_000,
286 });
287
288 events.extend(lifecycle);
289
290 // All three shepherd-channel topics, over one sheep id, because the
291 // adjacent-tagged shape puts the message's own `kind` INSIDE `data`
292 // next to `id` — a nesting that is easy to get wrong by hand and
293 // invisible in a round-trip test, which only proves this crate agrees
294 // with itself.
295 events.extend([
296 BusEvent::Channel {
297 id: 3,
298 message: ChildMessage::Ready,
299 },
300 BusEvent::Channel {
301 id: 3,
302 message: ChildMessage::Metric {
303 name: "rps".to_string(),
304 value: 42.0,
305 },
306 },
307 BusEvent::Channel {
308 id: 3,
309 message: ChildMessage::ActionReply {
310 action: "gc".to_string(),
311 body: "freed 12MB".to_string(),
312 id: Some(7),
313 },
314 },
315 ]);
316
317 insta::assert_json_snapshot!("bus_event_wire_v2", events);
318 }
319
320 #[test]
321 fn topics_follow_the_dotted_grammar() {
322 // spec §6: process.* / log.out / log.err / daemon.*
323 let e = BusEvent::LogOut {
324 id: 1,
325 line: String::new(),
326 };
327 assert_eq!(e.topic(), "log.out");
328 assert_eq!(BusEvent::DaemonShutdown.topic(), "daemon.shutdown");
329 }
330
331 /// The three kinds a reload reports itself with, pinned as topic strings
332 /// and as wire strings.
333 ///
334 /// Fails if [`BusEvent::topic`] maps any of them to the wrong dotted
335 /// string — a typo there is invisible to a `process.*` subscriber, which
336 /// matches anything under `process.`, and silently unreachable to one
337 /// that named the topic it wanted. Fails too if a variant's serde
338 /// spelling drifts from its snake_case default (a stray
339 /// `#[serde(rename)]`, or a variant renamed without its topic): a reload's
340 /// reply is an acceptance, so these frames are the whole of what a client
341 /// ever learns about how the reload went, and a client matching on the
342 /// wire string would stop recognising them.
343 #[test]
344 fn a_reload_reports_itself_under_three_topics() {
345 for (kind, topic, wire) in [
346 (ProcessEventKind::Reload, "process.reload", "\"reload\""),
347 (
348 ProcessEventKind::Reloaded,
349 "process.reloaded",
350 "\"reloaded\"",
351 ),
352 (
353 ProcessEventKind::ReloadAbandoned,
354 "process.reload_abandoned",
355 "\"reload_abandoned\"",
356 ),
357 ] {
358 let event = BusEvent::Process {
359 event: kind,
360 info: ProcessInfo {
361 id: 3,
362 name: "web".to_string(),
363 status: ProcStatus::Stopping,
364 pid: Some(4242),
365 restarts: 0,
366 uptime_ms: 0,
367 fold: None,
368 out_file: None,
369 err_file: None,
370 cpu_percent: None,
371 memory_bytes: None,
372 dog: None,
373 lambs: None,
374 last_exit: None,
375 smit: None,
376 instance: None,
377 handshook: None,
378 dog_stale: None,
379 pending: None,
380 overridden: None,
381 },
382 manually: true,
383 at_ms: 0,
384 };
385 assert_eq!(event.topic(), topic, "{kind:?}");
386 assert_eq!(serde_json::to_string(&kind).unwrap(), wire, "{kind:?}");
387 }
388 }
389
390 #[test]
391 fn v1_bus_event_fixture_still_deserializes() {
392 // Adjacent-tagged shape pinned as a byte fixture (IR-35).
393 let fixture = r#"{"event":"log_out","data":{"id":3,"line":"ready"}}"#;
394 let ev: BusEvent = serde_json::from_str(fixture).unwrap();
395 assert!(matches!(ev, BusEvent::LogOut { id: 3, .. }));
396 }
397
398 /// fails if a shepherd-channel message maps to the wrong dotted topic. A
399 /// subscriber that asked for `channel.metric` and silently receives nothing
400 /// has no other way to find out, and `channel.*` matches whatever typo is
401 /// there — so the exact strings are the contract, not the prefix.
402 #[test]
403 fn every_shepherd_channel_message_has_its_own_topic() {
404 for (message, topic) in [
405 (ChildMessage::Ready, "channel.ready"),
406 (
407 ChildMessage::Metric {
408 name: "rps".to_string(),
409 value: 42.0,
410 },
411 "channel.metric",
412 ),
413 (
414 ChildMessage::ActionReply {
415 action: "gc".to_string(),
416 body: "ok".to_string(),
417 id: Some(7),
418 },
419 "channel.action_reply",
420 ),
421 ] {
422 let event = BusEvent::Channel {
423 id: 3,
424 message: message.clone(),
425 };
426 assert_eq!(event.topic(), topic, "{message:?}");
427 }
428 }
429
430 /// fails if `channel.*` stops reaching every one of the three. The glob a
431 /// dashboard writes is the prefix, so a topic that drifted out from under it
432 /// (`channel_ready`, say) would be unreachable by the only pattern anyone
433 /// actually subscribes with.
434 #[test]
435 fn the_channel_glob_reaches_all_three_topics() {
436 for message in [
437 ChildMessage::Ready,
438 ChildMessage::Metric {
439 name: "rps".to_string(),
440 value: 1.0,
441 },
442 ChildMessage::ActionReply {
443 action: "gc".to_string(),
444 body: String::new(),
445 id: None,
446 },
447 ] {
448 let topic = BusEvent::Channel { id: 1, message }.topic();
449 assert!(
450 topic.starts_with("channel."),
451 "`{topic}` is not under the channel.* glob"
452 );
453 }
454 }
455
456 /// fails if the event stops carrying the message body. The whole argument for
457 /// putting the real message on the bus rather than a summary is that nothing
458 /// on this wire is a credential — a reply body that arrived truncated or
459 /// replaced would make the topic useless for the case it exists for, a
460 /// dashboard watching what apps actually say.
461 #[test]
462 fn a_channel_event_carries_the_message_verbatim() {
463 let event = BusEvent::Channel {
464 id: 3,
465 message: ChildMessage::ActionReply {
466 action: "gc".to_string(),
467 body: "freed 12MB".to_string(),
468 id: Some(7),
469 },
470 };
471 let json = serde_json::to_string(&event).unwrap();
472 assert!(json.contains("freed 12MB"), "{json}");
473 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
474 }
475}