Skip to main content

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 {}