varta_vlp/lib.rs
1#![deny(missing_docs, unsafe_op_in_unsafe_fn, rust_2018_idioms)]
2#![forbid(clippy::dbg_macro, clippy::print_stdout)]
3
4//! Varta Lifeline Protocol — 32-byte fixed-layout health frame.
5//!
6//! This crate is the protocol root for Varta v0.1.0. It defines the on-wire
7//! [`Frame`] representation that agents emit and observers decode, the
8//! [`Status`] enum that classifies an agent's last reported health, and the
9//! [`DecodeError`] returned when validation fails. Every helper operates on
10//! fixed-size byte arrays so the steady-state path on either side of the
11//! socket is heap-clean.
12//!
13//! See `docs/architecture/vlp-frame.md` for the byte map and design notes.
14
15/// Magic prefix on every VLP frame. ASCII `"VA"`, intentionally readable in
16/// hex dumps so a stray byte stream is easy to identify.
17pub const MAGIC: [u8; 2] = [0x56, 0x41];
18
19/// Current Varta Lifeline Protocol version. v0.1.0 ships only `0x01`; any
20/// future on-wire change bumps this byte and adds a [`DecodeError::BadVersion`]
21/// path.
22pub const VERSION: u8 = 0x01;
23
24/// Sentinel nonce value reserved for terminal panic frames.
25///
26/// Regular beats from `varta_client::Varta::beat` cap their nonce at
27/// `NONCE_TERMINAL - 1` so that observers can unambiguously identify a
28/// panic-fired critical frame by its nonce alone.
29pub const NONCE_TERMINAL: u64 = u64::MAX;
30
31/// Health status reported by an agent in a single VLP frame.
32///
33/// The discriminants are explicit because they form part of the on-wire
34/// contract: agents serialise `Status as u8` and observers reconstruct via
35/// [`Status::try_from_u8`].
36#[repr(u8)]
37#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
38pub enum Status {
39 /// The agent is healthy and making progress.
40 Ok = 0,
41 /// The agent is making progress but reporting elevated trouble (e.g.
42 /// retrying, throttled).
43 Degraded = 1,
44 /// The agent is about to die. Emitted by the panic hook in
45 /// `varta-client` immediately before unwinding.
46 Critical = 2,
47 /// The agent appears stuck. Emitted by `varta-watch` when no beat has
48 /// arrived within the configured threshold.
49 Stall = 3,
50}
51
52impl Status {
53 /// Decode a status byte from the wire format. Returns
54 /// [`DecodeError::BadStatus`] carrying the offending byte if the value is
55 /// not a known variant.
56 pub fn try_from_u8(byte: u8) -> Result<Self, DecodeError> {
57 match byte {
58 0 => Ok(Status::Ok),
59 1 => Ok(Status::Degraded),
60 2 => Ok(Status::Critical),
61 3 => Ok(Status::Stall),
62 other => Err(DecodeError::BadStatus(other)),
63 }
64 }
65}
66
67/// On-wire health frame — exactly 32 bytes, 8-byte aligned, little-endian
68/// integer fields. The struct is `repr(C)` so its layout is ABI-stable across
69/// compilations and trivially verifiable by inspection.
70///
71/// Construct frames directly via the public fields, then call
72/// [`Frame::encode`] to write to a socket buffer or [`Frame::decode`] to read
73/// one. There is no `Default`; agents always supply a real `pid`, `nonce` and
74/// timestamp.
75#[repr(C, align(8))]
76#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77pub struct Frame {
78 /// Magic prefix, always equal to [`MAGIC`].
79 pub magic: [u8; 2],
80 /// Protocol version, always equal to [`VERSION`] on emit.
81 pub version: u8,
82 /// Health status reported by the agent. Encoded on the wire as a
83 /// single byte at offset 3 ([`Status`] discriminants are `#[repr(u8)]`).
84 pub status: Status,
85 /// OS process id of the emitting agent.
86 pub pid: u32,
87 /// Monotonic timestamp chosen by the emitter (typically nanoseconds since
88 /// some agent-local epoch). Observers do not interpret it; they only
89 /// compare consecutive timestamps for the same pid.
90 pub timestamp: u64,
91 /// Strictly increasing counter, starting at 1 on the first beat after
92 /// `Varta::connect`. The panic hook pins this to [`NONCE_TERMINAL`] to
93 /// mark a final critical frame. Regular beats cap at `NONCE_TERMINAL - 1`.
94 pub nonce: u64,
95 /// Free-form 8-byte payload — application-defined health context (queue
96 /// depth, error code, etc.). Carried opaquely by the protocol.
97 pub payload: u64,
98}
99
100const _: () = assert!(core::mem::size_of::<Frame>() == 32);
101const _: () = assert!(core::mem::align_of::<Frame>() == 8);
102const _: () = assert!(core::mem::offset_of!(Frame, magic) == 0);
103const _: () = assert!(core::mem::offset_of!(Frame, version) == 2);
104const _: () = assert!(core::mem::offset_of!(Frame, status) == 3);
105const _: () = assert!(core::mem::offset_of!(Frame, pid) == 4);
106const _: () = assert!(core::mem::offset_of!(Frame, timestamp) == 8);
107const _: () = assert!(core::mem::offset_of!(Frame, nonce) == 16);
108const _: () = assert!(core::mem::offset_of!(Frame, payload) == 24);
109
110impl Frame {
111 /// Construct a new frame with the canonical [`MAGIC`] prefix and
112 /// [`VERSION`] byte already populated. All other fields are
113 /// caller-supplied.
114 pub const fn new(status: Status, pid: u32, timestamp: u64, nonce: u64, payload: u64) -> Frame {
115 Frame {
116 magic: MAGIC,
117 version: VERSION,
118 status,
119 pid,
120 timestamp,
121 nonce,
122 payload,
123 }
124 }
125
126 /// Serialise this frame into a 32-byte buffer in canonical
127 /// little-endian layout. The output buffer is overwritten in place; this
128 /// method allocates nothing.
129 pub fn encode(&self, out: &mut [u8; 32]) {
130 out[0..2].copy_from_slice(&self.magic);
131 out[2] = self.version;
132 out[3] = self.status as u8;
133 out[4..8].copy_from_slice(&self.pid.to_le_bytes());
134 out[8..16].copy_from_slice(&self.timestamp.to_le_bytes());
135 out[16..24].copy_from_slice(&self.nonce.to_le_bytes());
136 out[24..32].copy_from_slice(&self.payload.to_le_bytes());
137 }
138
139 /// Decode a 32-byte buffer back into a [`Frame`], validating magic,
140 /// version, and status in that order. Returns [`DecodeError`] on the
141 /// first failed check; the integer fields are not interpreted further.
142 pub fn decode(bytes: &[u8; 32]) -> Result<Frame, DecodeError> {
143 let magic = [bytes[0], bytes[1]];
144 if magic != MAGIC {
145 return Err(DecodeError::BadMagic);
146 }
147 let version = bytes[2];
148 if version != VERSION {
149 return Err(DecodeError::BadVersion);
150 }
151 let status = Status::try_from_u8(bytes[3])?;
152
153 let pid = u32::from_le_bytes(bytes[4..8].try_into().expect("len 4"));
154 let timestamp = u64::from_le_bytes(bytes[8..16].try_into().expect("len 8"));
155 let nonce = u64::from_le_bytes(bytes[16..24].try_into().expect("len 8"));
156 let payload = u64::from_le_bytes(bytes[24..32].try_into().expect("len 8"));
157
158 Ok(Frame {
159 magic,
160 version,
161 status,
162 pid,
163 timestamp,
164 nonce,
165 payload,
166 })
167 }
168}
169
170/// Error returned by [`Frame::decode`] and [`Status::try_from_u8`].
171///
172/// The variants form an exhaustive list of validation failures the protocol
173/// can detect statically; everything else (timestamp drift, nonce regression)
174/// is policy enforced higher in the stack.
175#[derive(Clone, Copy, Debug, Eq, PartialEq)]
176pub enum DecodeError {
177 /// First two bytes did not equal [`MAGIC`].
178 BadMagic,
179 /// Version byte did not equal [`VERSION`].
180 BadVersion,
181 /// Status byte did not match any known [`Status`] variant. The inner
182 /// value is the offending byte, surfaced for observer-side diagnostics.
183 BadStatus(u8),
184}
185
186impl core::fmt::Display for DecodeError {
187 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
188 match self {
189 DecodeError::BadMagic => f.write_str("varta-vlp: bad magic prefix"),
190 DecodeError::BadVersion => f.write_str("varta-vlp: bad version byte"),
191 DecodeError::BadStatus(byte) => {
192 write!(f, "varta-vlp: bad status byte {byte:#04x}")
193 }
194 }
195 }
196}
197
198impl core::error::Error for DecodeError {}