Skip to main content

shep_core/protocol/
frame.rs

1//! Anything the daemon writes to a connected client
2//!
3//! The server sends two kinds of frames on one socket: [`Reply`] (answers to
4//! requests) and [`BusEvent`] (broadcast events). This type decodes either,
5//! untagged, because their JSON key sets are disjoint (`id`/`result` vs `event`).
6//! Untagged costs **zero wire bytes** and keeps fixtures backward-compatible.
7//!
8//! Deserialization needs `serde/std` for buffered content semantics; this is
9//! available via `serde_json`'s `std` feature (already enabled workspace-wide).
10
11use serde::{Deserialize, Serialize};
12
13use crate::protocol::{BusEvent, Reply};
14
15/// Anything the daemon writes to a connected client
16///
17/// Untagged on purpose: `Reply` and `BusEvent` have disjoint key sets, so
18/// this decodes either without adding a byte to the wire. The daemon
19/// serializes `Reply`/`BusEvent` directly; a `ServerFrame` round-trips to
20/// byte-identical output (pinned by `server_frame_is_byte_identical`).
21// wire format: changing existing variants is a breaking change
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(untagged)]
24// Growth is anticipated: a future frame kind (progress, flow control) is
25// additive here and stays additive on the wire (IR-20).
26#[non_exhaustive]
27pub enum ServerFrame {
28    /// An answer to one request (from [`Envelope`](crate::protocol::Envelope))
29    Reply(Reply),
30    /// One subscribed bus event
31    Event(BusEvent),
32}
33
34#[cfg(test)]
35mod tests {
36    use super::*;
37    use crate::protocol::{
38        BusEvent, ProcessEventKind, ProcessInfo, Reply, Response, RpcError, RpcErrorCode,
39        encode_frame,
40    };
41    use crate::status::ProcStatus;
42
43    fn sample_reply() -> Reply {
44        Reply {
45            id: 7,
46            result: Ok(Response::Pong),
47        }
48    }
49
50    fn sample_event() -> BusEvent {
51        BusEvent::Process {
52            event: ProcessEventKind::Online,
53            info: ProcessInfo {
54                id: 3,
55                name: "web".to_string(),
56                status: ProcStatus::Online,
57                pid: Some(4242),
58                restarts: 0,
59                uptime_ms: 0,
60                fold: None,
61                out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
62                err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
63                cpu_percent: None,
64                memory_bytes: None,
65                dog: None,
66                lambs: None,
67                last_exit: None,
68                smit: None,
69                instance: None,
70                handshook: None,
71            },
72            manually: false,
73            at_ms: 1_700_000_000_000,
74        }
75    }
76
77    #[test]
78    fn server_frame_decodes_both_directions_of_the_stream() {
79        // The two shapes are disjoint: a Reply has no `event` key and an
80        // event has no `id`/`result` pair, so untagged never guesses wrong.
81        let reply = r#"{"id":1,"result":{"Ok":{"kind":"pong"}}}"#;
82        assert!(matches!(
83            serde_json::from_str::<ServerFrame>(reply).unwrap(),
84            ServerFrame::Reply(Reply { id: 1, .. })
85        ));
86        let event = r#"{"event":"log_out","data":{"id":3,"line":"ready"}}"#;
87        assert!(matches!(
88            serde_json::from_str::<ServerFrame>(event).unwrap(),
89            ServerFrame::Event(BusEvent::LogOut { id: 3, .. })
90        ));
91    }
92
93    #[test]
94    fn server_frame_is_byte_identical_to_its_payload() {
95        // The daemon encodes Reply/BusEvent directly; if wrapping ever
96        // started adding bytes, every client would break at once.
97        let reply = sample_reply();
98        assert_eq!(
99            encode_frame(&ServerFrame::Reply(reply.clone())).unwrap(),
100            encode_frame(&reply).unwrap()
101        );
102        let event = sample_event();
103        assert_eq!(
104            encode_frame(&ServerFrame::Event(event.clone())).unwrap(),
105            encode_frame(&event).unwrap()
106        );
107    }
108
109    #[test]
110    fn an_error_reply_still_decodes_as_a_reply_frame() {
111        let err = Reply {
112            id: 2,
113            result: Err(RpcError {
114                code: RpcErrorCode::DeadlineExceeded,
115                message: "request deadline of 5000 ms expired".to_string(),
116                daemon_version: None,
117            }),
118        };
119        let json = serde_json::to_string(&err).unwrap();
120        assert_eq!(
121            serde_json::from_str::<ServerFrame>(&json).unwrap(),
122            ServerFrame::Reply(err)
123        );
124    }
125}