Skip to main content

ghostlink_core/
protocol.rs

1//! Binary Protocol with CRC32 Checksums for Ghost-Link Discovery
2//!
3//! This module implements a fixed-width binary protocol using:
4//! - Fixed-size fields for zero-copy parsing
5//! - CRC32 checksums for frame integrity (hardware-accelerated via crc32fast)
6//! - Sequence numbers and versioning for ordering
7//! - Zero-copy encoding with thread-local buffers
8
9use crc32fast::Hasher;
10
11/// Ghost-Link EtherType (0x88B5)
12pub const GHOSTLINK_ETHERTYPE: u16 = 0x88B5;
13
14/// Protocol version
15pub const PROTOCOL_VERSION: u8 = 1;
16
17/// Maximum payload size for discovery frames
18pub const MAX_PAYLOAD_SIZE: usize = 256;
19
20/// Maximum frame size (header + max payload)
21pub const MAX_FRAME_SIZE: usize = 8 + MAX_PAYLOAD_SIZE;
22
23/// Frame header structure (fixed-width, 8 bytes)
24#[derive(Clone, Copy, Debug)]
25pub struct FrameHeader {
26    /// EtherType identifying the protocol
27    pub ether_type: u16,
28    /// Frame kind (see FrameKind enum)
29    pub kind: u8,
30    /// Protocol version
31    pub version: u8,
32    /// CRC32 checksum of payload
33    pub crc: u32,
34}
35
36impl FrameHeader {
37    const HEADER_SIZE: usize = 8;
38
39    /// Create a new frame header with computed CRC from pre-encoded payload
40    #[inline]
41    pub fn new(ether_type: u16, kind: u8, payload: &[u8]) -> Self {
42        let crc = crc32(payload);
43        Self {
44            ether_type,
45            kind,
46            version: PROTOCOL_VERSION,
47            crc,
48        }
49    }
50
51    /// Encode header directly into a pre-allocated buffer (zero-copy)
52    #[inline]
53    pub fn encode_into(&self, buf: &mut [u8; Self::HEADER_SIZE]) {
54        let et_bytes = self.ether_type.to_le_bytes();
55        buf[0] = et_bytes[0];
56        buf[1] = et_bytes[1];
57        buf[2] = self.kind;
58        buf[3] = self.version;
59        let crc_bytes = self.crc.to_le_bytes();
60        buf[4] = crc_bytes[0];
61        buf[5] = crc_bytes[1];
62        buf[6] = crc_bytes[2];
63        buf[7] = crc_bytes[3];
64    }
65
66    /// Decode header from bytes (zero-copy, no allocation)
67    #[inline]
68    pub fn decode(buf: &[u8]) -> Option<Self> {
69        if buf.len() < Self::HEADER_SIZE {
70            return None;
71        }
72        // Optimize: Use try_into on slices to let the compiler emit unaligned single-instruction loads
73        // instead of compiling down to individual byte loads and array constructions.
74        let ether_type = u16::from_le_bytes(buf[0..2].try_into().ok()?);
75        let kind = buf[2];
76        let version = buf[3];
77        let crc = u32::from_le_bytes(buf[4..8].try_into().ok()?);
78        Some(Self {
79            ether_type,
80            kind,
81            version,
82            crc,
83        })
84    }
85}
86
87/// Compute CRC32 checksum using hardware-accelerated crc32fast
88#[inline]
89pub fn crc32(data: &[u8]) -> u32 {
90    let mut hasher = Hasher::new();
91    hasher.update(data);
92    hasher.finalize()
93}
94
95/// Frame kind enumeration
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum FrameKind {
98    /// Discovery frame - announces node presence
99    Discovery = 1,
100    /// Join frame - requests to join cluster
101    Join = 2,
102    /// Attestation frame - resource verification
103    Attestation = 3,
104    /// Health check frame - periodic liveness probe
105    HealthCheck = 4,
106    /// Resource advertisement frame - capability update
107    ResourceAdvert = 5,
108}
109
110impl FrameKind {
111    const fn as_u8(&self) -> u8 {
112        match self {
113            Self::Discovery => 1,
114            Self::Join => 2,
115            Self::Attestation => 3,
116            Self::HealthCheck => 4,
117            Self::ResourceAdvert => 5,
118        }
119    }
120}
121
122impl TryFrom<u8> for FrameKind {
123    type Error = &'static str;
124
125    fn try_from(value: u8) -> Result<Self, Self::Error> {
126        match value {
127            1 => Ok(Self::Discovery),
128            2 => Ok(Self::Join),
129            3 => Ok(Self::Attestation),
130            4 => Ok(Self::HealthCheck),
131            5 => Ok(Self::ResourceAdvert),
132            _ => Err("unknown frame kind"),
133        }
134    }
135}
136
137/// Node resources structure for discovery frames
138#[derive(Clone, Debug, Default)]
139pub struct NodeResources {
140    /// Unique node identifier
141    pub id: String,
142    /// GPU VRAM in GB (f32 for precision)
143    pub vram_gb: f32,
144    /// System memory in GB
145    pub system_memory_gb: f32,
146    /// CUDA compute capability string (e.g., "8.9")
147    pub compute_capability: String,
148    /// GPU name/model
149    pub gpu_name: Option<String>,
150    /// Port this node's `ggml-rpc-server` is listening on, when it has opted
151    /// in to contributing compute to the cluster (see
152    /// `ghost_link::rpc_cluster`). `None` means this node either isn't
153    /// running one or the peer that sent this frame predates this field —
154    /// both are indistinguishable and both correctly mean "don't route
155    /// distributed inference through this node."
156    pub rpc_port: Option<u16>,
157    /// Short commit-hash fingerprint of this node's local `llama.cpp` build
158    /// (see `ghost_link::native_engine::NativeEngineClient::get_llama_build_id`),
159    /// e.g. `"da296d6"`. `None` means this node either couldn't determine its
160    /// own build id or the peer that sent this frame predates this field —
161    /// both are indistinguishable, and both are treated as "unknown, can't
162    /// verify" rather than "mismatched" by `rpc_cluster::discover_rpc_peers`
163    /// (mismatched-but-unknown must not be treated the same as a confirmed
164    /// mismatch, or every not-yet-updated peer becomes unroutable overnight).
165    /// Used to refuse routing distributed inference through a peer whose
166    /// `llama.cpp` build doesn't match the local one — version-mismatched
167    /// `ggml-rpc` peers have been confirmed on real hardware to silently
168    /// corrupt output for larger models while reporting healthy status the
169    /// whole time (see `docs/BENCHMARKS.md`'s native two-machine entry).
170    pub rpc_build_id: Option<String>,
171}
172
173impl NodeResources {
174    /// Create new node resources
175    pub fn new(
176        id: impl Into<String>,
177        vram_gb: f32,
178        system_memory_gb: f32,
179        compute_capability: impl Into<String>,
180        gpu_name: Option<String>,
181    ) -> Self {
182        Self {
183            id: id.into(),
184            vram_gb,
185            system_memory_gb,
186            compute_capability: compute_capability.into(),
187            gpu_name,
188            rpc_port: None,
189            rpc_build_id: None,
190        }
191    }
192
193    /// Builder-style setter so adding this field didn't require touching
194    /// every existing `NodeResources::new(...)` call site across the
195    /// workspace's tests and CLI commands.
196    pub fn with_rpc_port(mut self, port: u16) -> Self {
197        self.rpc_port = Some(port);
198        self
199    }
200
201    /// Builder-style setter, same rationale as `with_rpc_port`.
202    pub fn with_rpc_build_id(mut self, build_id: impl Into<String>) -> Self {
203        self.rpc_build_id = Some(build_id.into());
204        self
205    }
206
207    /// Serialize node resources into an existing binary buffer.
208    ///
209    /// Returns the length of the written payload on success, or an Error on validation failure.
210    #[inline]
211    pub fn encode_payload_into(
212        &self,
213        buffer: &mut Vec<u8>,
214        max_size: usize,
215    ) -> Result<usize, &'static str> {
216        let id_bytes = self.id.as_bytes();
217        let cc_bytes = self.compute_capability.as_bytes();
218        let gpu_bytes = self.gpu_name.as_ref().map(|name| name.as_bytes());
219        let build_id_bytes = self.rpc_build_id.as_ref().map(|id| id.as_bytes());
220
221        if id_bytes.len() > u8::MAX as usize || cc_bytes.len() > u8::MAX as usize {
222            return Err("field length exceeds u8::MAX");
223        }
224        if let Some(gpu_bytes) = gpu_bytes {
225            if gpu_bytes.len() > u8::MAX as usize {
226                return Err("GPU name length exceeds u8::MAX");
227            }
228        }
229        if let Some(build_id_bytes) = build_id_bytes {
230            if build_id_bytes.len() > u8::MAX as usize {
231                return Err("rpc_build_id length exceeds u8::MAX");
232            }
233        }
234
235        // Trailing 2 bytes: rpc_port as little-endian u16, 0 meaning "none"
236        // (real port 0 is not a valid bind target, so it's an unambiguous
237        // sentinel). Appended *after* the gpu_name section so a decoder built
238        // before this field existed simply never reads these bytes — it
239        // never checked for or rejected trailing data — and this encoder's
240        // output stays parseable by that older decoder. A decoder built
241        // after this field existed, reading an older (shorter) payload from
242        // a peer that predates it, just doesn't find the 2 extra bytes and
243        // defaults to `None` — see `decode_payload`.
244        //
245        // Then a length-prefixed rpc_build_id string (u8 length + bytes,
246        // length 0 meaning "none"/unknown), appended *after* rpc_port for the
247        // same additive-compatibility reasoning: older decoders never read
248        // past what they know about, and this decoder defaults to `None` when
249        // an older/shorter payload doesn't have these trailing bytes at all.
250        let payload_len = 11
251            + id_bytes.len()
252            + cc_bytes.len()
253            + gpu_bytes.map_or(0, |bytes| 1 + bytes.len())
254            + 2
255            + 1
256            + build_id_bytes.map_or(0, |bytes| bytes.len());
257        if payload_len > max_size {
258            return Err("payload length exceeds max_size");
259        }
260
261        buffer.push(id_bytes.len() as u8);
262        buffer.extend_from_slice(id_bytes);
263        buffer.extend_from_slice(&self.vram_gb.to_le_bytes());
264        buffer.extend_from_slice(&self.system_memory_gb.to_le_bytes());
265        buffer.push(cc_bytes.len() as u8);
266        buffer.extend_from_slice(cc_bytes);
267
268        if let Some(gpu_bytes) = gpu_bytes {
269            buffer.push(1);
270            buffer.push(gpu_bytes.len() as u8);
271            buffer.extend_from_slice(gpu_bytes);
272        } else {
273            buffer.push(0);
274        }
275
276        buffer.extend_from_slice(&self.rpc_port.unwrap_or(0).to_le_bytes());
277
278        if let Some(build_id_bytes) = build_id_bytes {
279            buffer.push(build_id_bytes.len() as u8);
280            buffer.extend_from_slice(build_id_bytes);
281        } else {
282            buffer.push(0);
283        }
284
285        Ok(payload_len)
286    }
287
288    /// Serialize node resources to fixed-width binary payload
289    ///
290    /// Format: [id_len(1) + id + vram_f32_le + mem_f32_le + cc_len(1) + cc]
291    #[inline]
292    pub fn encode_payload(&self, max_size: usize) -> Vec<u8> {
293        let id_bytes_len = self.id.len();
294        let cc_bytes_len = self.compute_capability.len();
295        let gpu_bytes_len = self.gpu_name.as_ref().map_or(0, |name| name.len());
296        let build_id_bytes_len = self.rpc_build_id.as_ref().map_or(0, |id| id.len());
297        let est_len = 11
298            + id_bytes_len
299            + cc_bytes_len
300            + if gpu_bytes_len > 0 {
301                2 + gpu_bytes_len
302            } else {
303                0
304            }
305            + 2
306            + 1
307            + build_id_bytes_len;
308
309        let mut payload = Vec::with_capacity(est_len);
310        if self.encode_payload_into(&mut payload, max_size).is_ok() {
311            payload
312        } else {
313            Vec::new()
314        }
315    }
316
317    /// Deserialize node resources from binary payload
318    pub fn decode_payload(payload: &[u8]) -> Result<Self, String> {
319        let len = payload.len();
320        if len < 11 {
321            return Err("payload too short".into());
322        }
323
324        let mut cursor = 0usize;
325
326        let id_len = payload[cursor] as usize;
327        cursor += 1;
328        let id_end = cursor + id_len;
329        if id_end > len {
330            return Err("payload truncated".into());
331        }
332        // Optimize: Avoid allocating and copying to a temporary Vec first.
333        // Direct zero-copy slice validation using std::str::from_utf8, then allocating only on success.
334        let id = std::str::from_utf8(&payload[cursor..id_end])
335            .map_err(|_| "invalid UTF-8 in ID".to_string())?
336            .to_string();
337        cursor = id_end;
338
339        let vram_end = cursor + 8;
340        if vram_end > len {
341            return Err("payload truncated".into());
342        }
343        // Optimize: Use slice try_into for f32 decoding to trigger unaligned single-instruction loads
344        // instead of manual indexing of individual bytes.
345        let vram_gb = f32::from_le_bytes(
346            payload[cursor..cursor + 4]
347                .try_into()
348                .map_err(|_| "vram parsing failed".to_string())?,
349        );
350        let system_memory_gb = f32::from_le_bytes(
351            payload[cursor + 4..cursor + 8]
352                .try_into()
353                .map_err(|_| "system memory parsing failed".to_string())?,
354        );
355        cursor = vram_end;
356
357        if cursor >= len {
358            return Err("payload truncated".into());
359        }
360        let cc_len = payload[cursor] as usize;
361        cursor += 1;
362        let cc_end = cursor + cc_len;
363        if cc_end > len {
364            return Err("payload truncated".into());
365        }
366        // Optimize: Zero-copy validation followed by direct to_string allocation on success.
367        let compute_capability = std::str::from_utf8(&payload[cursor..cc_end])
368            .map_err(|_| "invalid UTF-8 in CC".to_string())?
369            .to_string();
370        cursor = cc_end;
371
372        if cursor >= len {
373            return Err("payload truncated".into());
374        }
375        let has_gpu = payload[cursor] == 1;
376        cursor += 1;
377
378        let gpu_name = if has_gpu {
379            if cursor >= len {
380                return Err("payload truncated".into());
381            }
382            let gpu_len = payload[cursor] as usize;
383            cursor += 1;
384            let gpu_end = cursor + gpu_len;
385            if gpu_end > len {
386                return Err("payload truncated".into());
387            }
388            // Optimize: Zero-copy validation followed by direct to_string allocation on success.
389            let name = std::str::from_utf8(&payload[cursor..gpu_end])
390                .map_err(|_| "invalid UTF-8 in GPU name".to_string())?
391                .to_string();
392            // Was previously left unset here — harmless before this field
393            // existed (cursor was never read again), but the rpc_port read
394            // below now depends on this pointing past the GPU name bytes
395            // rather than into the middle of them.
396            cursor = gpu_end;
397            Some(name)
398        } else {
399            None
400        };
401
402        // Trailing rpc_port field, added after this format shipped — a
403        // payload from an older peer simply won't have these 2 bytes, which
404        // correctly decodes as "no RPC contribution advertised" rather than
405        // an error.
406        // Optimize: Use slice try_into for u16 decoding to trigger unaligned single-instruction loads.
407        let rpc_port = if cursor + 2 <= len {
408            let port = u16::from_le_bytes(
409                payload[cursor..cursor + 2]
410                    .try_into()
411                    .map_err(|_| "rpc_port parsing failed".to_string())?,
412            );
413            cursor += 2;
414            if port == 0 {
415                None
416            } else {
417                Some(port)
418            }
419        } else {
420            None
421        };
422
423        // Trailing length-prefixed rpc_build_id field, added after rpc_port
424        // shipped — same additive-compatibility reasoning: a payload from a
425        // peer that predates this field simply won't have these bytes, which
426        // correctly decodes as "unknown build" rather than an error.
427        let rpc_build_id = if cursor < len {
428            let build_id_len = payload[cursor] as usize;
429            cursor += 1;
430            let build_id_end = cursor + build_id_len;
431            if build_id_end > len {
432                return Err("payload truncated".into());
433            }
434            if build_id_len == 0 {
435                None
436            } else {
437                let id = std::str::from_utf8(&payload[cursor..build_id_end])
438                    .map_err(|_| "invalid UTF-8 in rpc_build_id".to_string())?
439                    .to_string();
440                Some(id)
441            }
442        } else {
443            None
444        };
445
446        Ok(Self {
447            id,
448            vram_gb,
449            system_memory_gb,
450            compute_capability,
451            gpu_name,
452            rpc_port,
453            rpc_build_id,
454        })
455    }
456}
457
458/// Discovery frame with binary encoding and CRC32
459#[derive(Clone, Debug)]
460pub struct DiscoveryFrame {
461    pub kind: FrameKind,
462    pub node: NodeResources,
463}
464
465impl DiscoveryFrame {
466    /// Encode discovery frame to bytes using a stack-allocated fixed-size buffer.
467    /// Avoids all Vec capacity checks and `extend_from_slice` overhead.
468    #[inline]
469    pub fn encode(&self) -> Vec<u8> {
470        let mut buf = [0u8; MAX_FRAME_SIZE];
471        let et = GHOSTLINK_ETHERTYPE.to_le_bytes();
472        buf[0] = et[0];
473        buf[1] = et[1];
474        buf[2] = self.kind.as_u8();
475        buf[3] = PROTOCOL_VERSION;
476
477        let id_bytes = self.node.id.as_bytes();
478        let cc_bytes = self.node.compute_capability.as_bytes();
479
480        if id_bytes.len() > u8::MAX as usize || cc_bytes.len() > u8::MAX as usize {
481            buf[4..8].copy_from_slice(&crc32(&[]).to_le_bytes());
482            return buf[..8].to_vec();
483        }
484        if let Some(ref gpu_name) = self.node.gpu_name {
485            if gpu_name.len() > u8::MAX as usize {
486                buf[4..8].copy_from_slice(&crc32(&[]).to_le_bytes());
487                return buf[..8].to_vec();
488            }
489        }
490        if let Some(ref build_id) = self.node.rpc_build_id {
491            if build_id.len() > u8::MAX as usize {
492                buf[4..8].copy_from_slice(&crc32(&[]).to_le_bytes());
493                return buf[..8].to_vec();
494            }
495        }
496
497        // Guard against the combined fields overflowing the fixed-size stack
498        // buffer: each field is individually bounded by u8::MAX above, but
499        // their sum can still exceed MAX_PAYLOAD_SIZE and panic on the
500        // slice-index writes below if left unchecked.
501        let gpu_len = self.node.gpu_name.as_ref().map(|name| name.len());
502        // +2 for the trailing rpc_port field this hand-rolled duplicate of
503        // `NodeResources::encode_payload_into` previously omitted entirely —
504        // UDP discovery replies silently dropped rpc_port while the mDNS
505        // path (which does call encode_payload_into's TXT-record analog)
506        // carried it correctly, so a UDP-discovered peer could never be
507        // selected for distributed inference. See `decode_payload`, which
508        // already tolerated this field being absent/present either way.
509        //
510        // +1 (length prefix) + build_id bytes for rpc_build_id, appended
511        // after rpc_port for the same additive-compatibility reasoning —
512        // see `NodeResources::encode_payload_into`'s matching comment.
513        let build_id_len = self.node.rpc_build_id.as_ref().map(|id| id.len());
514        let payload_len = 11
515            + id_bytes.len()
516            + cc_bytes.len()
517            + gpu_len.map_or(0, |len| 1 + len)
518            + 2
519            + 1
520            + build_id_len.unwrap_or(0);
521        if payload_len > MAX_PAYLOAD_SIZE {
522            buf[4..8].copy_from_slice(&crc32(&[]).to_le_bytes());
523            return buf[..8].to_vec();
524        }
525
526        let mut pos = 8usize;
527
528        buf[pos] = id_bytes.len() as u8;
529        pos += 1;
530        buf[pos..pos + id_bytes.len()].copy_from_slice(id_bytes);
531        pos += id_bytes.len();
532
533        buf[pos..pos + 4].copy_from_slice(&self.node.vram_gb.to_le_bytes());
534        pos += 4;
535
536        buf[pos..pos + 4].copy_from_slice(&self.node.system_memory_gb.to_le_bytes());
537        pos += 4;
538
539        buf[pos] = cc_bytes.len() as u8;
540        pos += 1;
541        buf[pos..pos + cc_bytes.len()].copy_from_slice(cc_bytes);
542        pos += cc_bytes.len();
543
544        if let Some(ref gpu_name) = self.node.gpu_name {
545            let gpu_bytes = gpu_name.as_bytes();
546            buf[pos] = 1;
547            pos += 1;
548            buf[pos] = gpu_bytes.len() as u8;
549            pos += 1;
550            buf[pos..pos + gpu_bytes.len()].copy_from_slice(gpu_bytes);
551            pos += gpu_bytes.len();
552        } else {
553            buf[pos] = 0;
554            pos += 1;
555        }
556
557        let rpc_port_bytes = self.node.rpc_port.unwrap_or(0).to_le_bytes();
558        buf[pos..pos + 2].copy_from_slice(&rpc_port_bytes);
559        pos += 2;
560
561        if let Some(ref build_id) = self.node.rpc_build_id {
562            let build_id_bytes = build_id.as_bytes();
563            buf[pos] = build_id_bytes.len() as u8;
564            pos += 1;
565            buf[pos..pos + build_id_bytes.len()].copy_from_slice(build_id_bytes);
566            pos += build_id_bytes.len();
567        } else {
568            buf[pos] = 0;
569            pos += 1;
570        }
571
572        let crc = crc32(&buf[8..pos]);
573        buf[4..8].copy_from_slice(&crc.to_le_bytes());
574
575        buf[..pos].to_vec()
576    }
577
578    /// Encode discovery frame into a pre-allocated buffer (zero-copy hot path)
579    /// Returns the number of bytes written.
580    #[inline]
581    pub fn encode_into(&self, buf: &mut Vec<u8>) -> usize {
582        buf.clear();
583        buf.extend_from_slice(&[0u8; 8]);
584
585        if self.node.encode_payload_into(buf, MAX_PAYLOAD_SIZE).is_ok() {
586            let payload = &buf[8..];
587            let crc = crc32(payload);
588
589            let header = FrameHeader {
590                ether_type: GHOSTLINK_ETHERTYPE,
591                kind: self.kind.as_u8(),
592                version: PROTOCOL_VERSION,
593                crc,
594            };
595            header.encode_into(unsafe { &mut *(buf.as_mut_ptr() as *mut [u8; 8]) });
596
597            buf.len()
598        } else {
599            buf.clear();
600            buf.extend_from_slice(&[0u8; 8]);
601            let header = FrameHeader {
602                ether_type: GHOSTLINK_ETHERTYPE,
603                kind: self.kind.as_u8(),
604                version: PROTOCOL_VERSION,
605                crc: crc32(&[]),
606            };
607            header.encode_into(unsafe { &mut *(buf.as_mut_ptr() as *mut [u8; 8]) });
608            buf.len()
609        }
610    }
611
612    /// Decode discovery frame from bytes (header + payload) - optimized path
613    #[inline]
614    pub fn decode(bytes: &[u8]) -> Result<Self, String> {
615        if bytes.len() < FrameHeader::HEADER_SIZE {
616            return Err("frame too short".into());
617        }
618
619        // Fast header decode using zero-copy method
620        let header = FrameHeader::decode(bytes).ok_or("invalid header")?;
621
622        if header.ether_type != GHOSTLINK_ETHERTYPE {
623            return Err(format!("unexpected EtherType 0x{:04x}", header.ether_type));
624        }
625
626        if header.version != PROTOCOL_VERSION {
627            return Err(format!("unsupported protocol version {}", header.version));
628        }
629
630        // Fast CRC check
631        let payload = &bytes[FrameHeader::HEADER_SIZE..];
632        let computed_crc = crc32(payload);
633
634        if computed_crc != header.crc {
635            return Err(format!(
636                "CRC mismatch: expected 0x{:08x}, got 0x{:08x}",
637                header.crc, computed_crc
638            ));
639        }
640
641        // Decode node resources from payload
642        let node = NodeResources::decode_payload(payload)?;
643
644        Ok(Self {
645            kind: FrameKind::try_from(header.kind)?,
646            node,
647        })
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    #[test]
656    fn discovery_frames_round_trip() {
657        let frame = DiscoveryFrame {
658            kind: FrameKind::Join,
659            node: NodeResources::new("node-b", 12.0, 32.0, "8.6", None),
660        };
661
662        let encoded = frame.encode();
663        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
664
665        assert_eq!(decoded.kind, frame.kind);
666        assert_eq!(decoded.node.id, frame.node.id);
667        assert_eq!(decoded.node.vram_gb, frame.node.vram_gb);
668    }
669
670    #[test]
671    fn discovery_frame_encode_round_trips_rpc_port() {
672        // Regression test: `DiscoveryFrame::encode()` is a separate,
673        // hand-duplicated serializer from `NodeResources::encode_payload_into`
674        // (kept for its zero-copy-buffer calling convention) — it silently
675        // omitted the rpc_port field entirely when that field was added,
676        // so a UDP-discovered peer's contribute-compute port never survived
677        // the wire even though the equivalent mDNS path (which does reuse
678        // the shared encoder) carried it correctly. `encode_into` already
679        // covered this path; `encode()` (what `discovery.rs`'s UDP broadcast
680        // path actually calls) did not.
681        let frame = DiscoveryFrame {
682            kind: FrameKind::Join,
683            node: NodeResources::new("node-c", 12.0, 32.0, "8.6", None).with_rpc_port(50052),
684        };
685
686        let encoded = frame.encode();
687        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
688
689        assert_eq!(decoded.node.rpc_port, Some(50052));
690    }
691
692    #[test]
693    fn discovery_frame_encode_without_rpc_port_decodes_to_none() {
694        let frame = DiscoveryFrame {
695            kind: FrameKind::Join,
696            node: NodeResources::new("node-d", 8.0, 16.0, "7.5", None),
697        };
698
699        let encoded = frame.encode();
700        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
701
702        assert_eq!(decoded.node.rpc_port, None);
703    }
704
705    #[test]
706    fn discovery_frame_encode_round_trips_rpc_build_id() {
707        // Same lesson as `discovery_frame_encode_round_trips_rpc_port` above:
708        // `DiscoveryFrame::encode()` is a hand-duplicated serializer from
709        // `NodeResources::encode_payload_into`, so a new trailing field has to
710        // be added to *both* independently or UDP discovery silently drops it
711        // while mDNS (which reuses the shared encoder) carries it correctly.
712        let frame = DiscoveryFrame {
713            kind: FrameKind::Join,
714            node: NodeResources::new("node-i", 12.0, 32.0, "8.6", None)
715                .with_rpc_build_id("da296d6"),
716        };
717
718        let encoded = frame.encode();
719        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
720
721        assert_eq!(decoded.node.rpc_build_id, Some("da296d6".to_string()));
722    }
723
724    #[test]
725    fn discovery_frame_encode_without_rpc_build_id_decodes_to_none() {
726        let frame = DiscoveryFrame {
727            kind: FrameKind::Join,
728            node: NodeResources::new("node-j", 8.0, 16.0, "7.5", None),
729        };
730
731        let encoded = frame.encode();
732        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
733
734        assert_eq!(decoded.node.rpc_build_id, None);
735    }
736
737    #[test]
738    fn discovery_frame_encode_round_trips_rpc_port_and_rpc_build_id_together() {
739        let frame = DiscoveryFrame {
740            kind: FrameKind::Join,
741            node: NodeResources::new("node-k", 12.0, 32.0, "8.6", None)
742                .with_rpc_port(50052)
743                .with_rpc_build_id("e920c523e"),
744        };
745
746        let encoded = frame.encode();
747        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
748
749        assert_eq!(decoded.node.rpc_port, Some(50052));
750        assert_eq!(decoded.node.rpc_build_id, Some("e920c523e".to_string()));
751    }
752
753    #[test]
754    fn crc_verification_fails_on_modified_payload() {
755        let frame = DiscoveryFrame {
756            kind: FrameKind::Discovery,
757            node: NodeResources::new("node-a", 24.0, 64.0, "8.9", Some("RTX4090".to_string())),
758        };
759
760        let encoded = frame.encode();
761        let mut modified = encoded.clone();
762        modified[10] ^= 0xFF; // Modify payload
763
764        let result = DiscoveryFrame::decode(&modified);
765        assert!(result.is_err());
766        assert!(result.unwrap_err().contains("CRC mismatch"));
767    }
768
769    #[test]
770    fn rejects_wrong_ether_type() {
771        let mut fake_frame = vec![0u8; 10];
772        fake_frame[0] = 0xB5u8; // Low byte of GHOSTLINK_ETHERTYPE (0x88B5 LE)
773        fake_frame[1] = 0xFF; // Wrong high byte
774
775        let result = DiscoveryFrame::decode(&fake_frame);
776        assert!(result.is_err());
777    }
778
779    #[test]
780    fn encode_payload_into_accepts_exact_max_size_with_gpu_name() {
781        // Regression test: encode_payload_into previously overcounted the
782        // gpu-name field length by 1 byte, causing it to reject payloads
783        // that actually fit exactly within max_size.
784        let node = NodeResources::new("a", 1.0, 1.0, "b", Some("c".to_string()));
785        let mut buffer = Vec::new();
786        // Actual encoded length: id_len(1)+id(1)+vram(4)+mem(4)+cc_len(1)+cc(1)
787        // +has_gpu(1)+gpu_len(1)+gpu(1)+rpc_port(2)+rpc_build_id_len(1) = 18 bytes.
788        let written = node
789            .encode_payload_into(&mut buffer, 18)
790            .expect("payload of exactly 18 bytes must fit within max_size 18");
791        assert_eq!(written, 18);
792        assert_eq!(buffer.len(), 18);
793    }
794
795    #[test]
796    fn node_resources_round_trip_preserves_rpc_port() {
797        let node = NodeResources::new("node-e", 12.0, 32.0, "8.6", None).with_rpc_port(50052);
798        let encoded = node.encode_payload(64);
799        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
800        assert_eq!(decoded.rpc_port, Some(50052));
801    }
802
803    #[test]
804    fn node_resources_round_trip_without_rpc_port_is_none() {
805        let node = NodeResources::new("node-f", 8.0, 16.0, "7.5", None);
806        let encoded = node.encode_payload(64);
807        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
808        assert_eq!(decoded.rpc_port, None);
809    }
810
811    #[test]
812    fn node_resources_round_trip_with_gpu_name_and_rpc_port_together() {
813        // Regression test: decode_payload previously never advanced `cursor`
814        // past the GPU name bytes (harmless while nothing after it was ever
815        // read), which would have made a trailing field like rpc_port read
816        // from the middle of the GPU name instead of after it.
817        let node = NodeResources::new("node-g", 24.0, 64.0, "8.9", Some("RTX 4090".to_string()))
818            .with_rpc_port(50053);
819        let encoded = node.encode_payload(128);
820        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
821        assert_eq!(decoded.gpu_name, Some("RTX 4090".to_string()));
822        assert_eq!(decoded.rpc_port, Some(50053));
823    }
824
825    #[test]
826    fn decode_payload_from_before_rpc_port_existed_defaults_to_none() {
827        // Simulates a frame from an older peer build: same format, just
828        // without the trailing 2 bytes.
829        let node = NodeResources::new("node-h", 8.0, 16.0, "7.5", None);
830        let mut encoded = node.encode_payload(64);
831        assert!(encoded.len() >= 2);
832        encoded.truncate(encoded.len() - 2);
833        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
834        assert_eq!(decoded.rpc_port, None);
835    }
836
837    #[test]
838    fn node_resources_round_trip_preserves_rpc_build_id() {
839        let node =
840            NodeResources::new("node-i", 12.0, 32.0, "8.6", None).with_rpc_build_id("da296d6");
841        let encoded = node.encode_payload(64);
842        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
843        assert_eq!(decoded.rpc_build_id, Some("da296d6".to_string()));
844    }
845
846    #[test]
847    fn node_resources_round_trip_without_rpc_build_id_is_none() {
848        let node = NodeResources::new("node-j", 8.0, 16.0, "7.5", None);
849        let encoded = node.encode_payload(64);
850        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
851        assert_eq!(decoded.rpc_build_id, None);
852    }
853
854    #[test]
855    fn node_resources_round_trip_with_gpu_name_rpc_port_and_rpc_build_id_together() {
856        // Regression test mirroring
857        // `node_resources_round_trip_with_gpu_name_and_rpc_port_together`:
858        // rpc_build_id is read *after* rpc_port, so both trailing fields must
859        // survive a payload that also has a GPU name in the middle.
860        let node = NodeResources::new("node-k", 24.0, 64.0, "8.9", Some("RTX 4090".to_string()))
861            .with_rpc_port(50053)
862            .with_rpc_build_id("e920c523e");
863        let encoded = node.encode_payload(128);
864        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
865        assert_eq!(decoded.gpu_name, Some("RTX 4090".to_string()));
866        assert_eq!(decoded.rpc_port, Some(50053));
867        assert_eq!(decoded.rpc_build_id, Some("e920c523e".to_string()));
868    }
869
870    #[test]
871    fn decode_payload_from_before_rpc_build_id_existed_defaults_to_none() {
872        // Simulates a frame from a peer build that has rpc_port but predates
873        // rpc_build_id: same format, just without the trailing length-prefix
874        // byte (and any build-id bytes).
875        let node = NodeResources::new("node-l", 8.0, 16.0, "7.5", None).with_rpc_port(50054);
876        let mut encoded = node.encode_payload(64);
877        assert!(!encoded.is_empty());
878        encoded.truncate(encoded.len() - 1);
879        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
880        assert_eq!(decoded.rpc_port, Some(50054));
881        assert_eq!(decoded.rpc_build_id, None);
882    }
883
884    #[test]
885    fn decode_payload_truncated_mid_rpc_build_id_string_is_an_error() {
886        // A length prefix claiming more build-id bytes than are actually
887        // present should error, not panic or silently truncate.
888        let node =
889            NodeResources::new("node-m", 8.0, 16.0, "7.5", None).with_rpc_build_id("da296d6");
890        let mut encoded = node.encode_payload(64);
891        encoded.truncate(encoded.len() - 2); // drop the last 2 bytes of the hash
892        let result = NodeResources::decode_payload(&encoded);
893        assert!(result.is_err());
894    }
895
896    #[test]
897    fn encode_does_not_panic_when_combined_field_lengths_exceed_max_payload() {
898        // Regression test: DiscoveryFrame::encode only validated each field
899        // individually against u8::MAX, so combined field lengths could
900        // exceed MAX_PAYLOAD_SIZE and panic on the fixed-size stack buffer
901        // writes. It should now fall back to a safe header-only frame.
902        let big_id = "x".repeat(200);
903        let big_cc = "y".repeat(200);
904        let frame = DiscoveryFrame {
905            kind: FrameKind::Discovery,
906            node: NodeResources::new(big_id, 1.0, 1.0, big_cc, None),
907        };
908
909        let encoded = frame.encode();
910        assert_eq!(encoded.len(), 8, "should fall back to header-only frame");
911
912        let decoded_err = DiscoveryFrame::decode(&encoded).unwrap_err();
913        assert!(decoded_err.contains("payload too short") || decoded_err.contains("truncated"));
914    }
915
916    #[test]
917    fn handles_gpu_name_field() {
918        let frame = DiscoveryFrame {
919            kind: FrameKind::Discovery,
920            node: NodeResources::new(
921                "gpu-node-1",
922                24.0,
923                64.0,
924                "9.0",
925                Some("NVIDIA GeForce RTX 4090".to_string()),
926            ),
927        };
928
929        let encoded = frame.encode();
930        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
931
932        assert_eq!(
933            decoded.node.gpu_name,
934            Some("NVIDIA GeForce RTX 4090".to_string())
935        );
936    }
937
938    // ====================================================================
939    // PROPERTY-BASED TESTS (proptest)
940    // ====================================================================
941
942    #[cfg(test)]
943    mod proptest_protocol {
944        use super::super::*;
945        use proptest::prelude::*;
946
947        proptest! {
948            /// Property: Any valid discovery frame should round-trip encode/decode
949            #[test]
950            fn prop_discovery_frame_round_trip(
951                node_id in "[a-z0-9]{1,12}",
952                vram in 1.0f32..512.0,
953                ram in 8.0f32..1024.0,
954            ) {
955                let frame = DiscoveryFrame {
956                    kind: FrameKind::Discovery,
957                    node: NodeResources::new(format!("node-{node_id}"), vram, ram, "8.9", None),
958                };
959
960                let encoded = frame.encode();
961                let decoded = DiscoveryFrame::decode(&encoded).expect("round-trip failed");
962
963                assert_eq!(decoded.node.id, frame.node.id);
964                assert!((decoded.node.vram_gb - frame.node.vram_gb).abs() < 0.01);
965                assert!((decoded.node.system_memory_gb - frame.node.system_memory_gb).abs() < 0.01);
966            }
967
968            /// Property: CRC should detect corruption with high probability
969            #[test]
970            fn prop_crc_detects_any_payload_corruption(
971                seed in 0u8..255,
972                corruption_pos in 0usize..256,
973            ) {
974                let frame = DiscoveryFrame {
975                    kind: FrameKind::Join,
976                    node: NodeResources::new("node-test", 24.0, 64.0, "8.9", None),
977                };
978
979                let mut encoded = frame.encode();
980                if corruption_pos < encoded.len() && corruption_pos < 200 {
981                    // Corrupt payload (not header/CRC area)
982                    if corruption_pos > 10 && corruption_pos < encoded.len().saturating_sub(4) {
983                        encoded[corruption_pos] = encoded[corruption_pos].wrapping_add(seed.wrapping_add(1));
984
985                        // Should fail CRC check
986                        let result = DiscoveryFrame::decode(&encoded);
987                        assert!(result.is_err(), "Corruption at byte {corruption_pos} should be detected");
988                    }
989                }
990            }
991
992            /// Property: Frame encoding is deterministic
993            #[test]
994            fn prop_frame_encoding_is_deterministic(
995                node_id in "[a-z0-9]{1,8}",
996                vram in 1.0f32..100.0,
997            ) {
998                let frame1 = DiscoveryFrame {
999                    kind: FrameKind::Discovery,
1000                    node: NodeResources::new(format!("node-{node_id}"), vram, 64.0, "8.9", None),
1001                };
1002
1003                let frame2 = DiscoveryFrame {
1004                    kind: FrameKind::Discovery,
1005                    node: NodeResources::new(format!("node-{node_id}"), vram, 64.0, "8.9", None),
1006                };
1007
1008                let enc1 = frame1.encode();
1009                let enc2 = frame2.encode();
1010
1011                // Same input should produce same output
1012                assert_eq!(enc1, enc2, "Encoding should be deterministic");
1013            }
1014        }
1015    }
1016}