Skip to main content

martensite_plugin/
ring_buffer.rs

1//! Zero-allocation shared-memory ring buffer for plugin paint commands.
2//!
3//! The host and the WebAssembly guest share a single linear memory region.
4//! The guest writes raw `PluginPaintCmd` records followed by their payload
5//! directly into the buffer, and the host consumes them by parsing in place,
6//! avoiding any per-frame allocation or serialization overhead.
7
8use std::fmt;
9
10use tracing::error;
11
12/// Default size of the shared linear memory ring buffer (256 KiB).
13pub const DEFAULT_CAPACITY: usize = 256 * 1024;
14
15/// Number of leading bytes reserved for the producer/consumer cursors in a
16/// shared ring region.
17///
18/// When the ring buffer lives inside the guest's linear memory, the first
19/// [`SHARED_HEADER_SIZE`] bytes hold the `head` (consumer cursor) and `tail`
20/// (producer cursor) as little-endian `u32` values so that both sides can
21/// observe the buffer state without a host call. The remaining bytes are the
22/// circular payload area.
23///
24/// # Examples
25///
26/// ```
27/// use martensite_plugin::ring_buffer::SHARED_HEADER_SIZE;
28///
29/// assert_eq!(SHARED_HEADER_SIZE, 8);
30/// ```
31pub const SHARED_HEADER_SIZE: usize = 8;
32
33/// Fixed header size of a [`PluginPaintCmd`] in bytes.
34const CMD_SIZE: usize = std::mem::size_of::<PluginPaintCmd>();
35
36/// A raw paint command produced by a WebAssembly plugin.
37///
38/// The record is laid out exactly as the guest writes it into shared memory so
39/// that the host can read it directly from the ring buffer without copying.
40///
41/// # Examples
42///
43/// ```
44/// use martensite_plugin::PluginPaintCmd;
45///
46/// let cmd = PluginPaintCmd {
47///     cmd_type: 1,
48///     flags: 0,
49///     data_len: 12,
50///     payload_offset: 100,
51/// };
52///
53/// assert_eq!(cmd.data_len, 12);
54/// ```
55#[repr(C)]
56#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
57pub struct PluginPaintCmd {
58    /// Discriminant of the paint operation (e.g. 0=DrawLine, 1=FillRect).
59    pub cmd_type: u16,
60    /// Command flags for future extensions.
61    pub flags: u16,
62    /// Length of the variable-length payload in bytes.
63    pub data_len: u32,
64    /// Byte offset of the payload relative to the start of the ring buffer.
65    pub payload_offset: u32,
66}
67
68impl PluginPaintCmd {
69    /// Returns the number of bytes occupied by the command header.
70    #[inline]
71    pub const fn header_size() -> usize {
72        CMD_SIZE
73    }
74
75    /// Writes the command into the supplied byte slice in little-endian order.
76    ///
77    /// Returns `None` if `buf` is too short.
78    fn write_to(&self, buf: &mut [u8]) -> Option<()> {
79        if buf.len() < CMD_SIZE {
80            return None;
81        }
82        let (cmd_type, rest) = buf.split_at_mut(2);
83        cmd_type.copy_from_slice(&self.cmd_type.to_le_bytes());
84        let (flags, rest) = rest.split_at_mut(2);
85        flags.copy_from_slice(&self.flags.to_le_bytes());
86        let (data_len, rest) = rest.split_at_mut(4);
87        data_len.copy_from_slice(&self.data_len.to_le_bytes());
88        let (payload_offset, _) = rest.split_at_mut(4);
89        payload_offset.copy_from_slice(&self.payload_offset.to_le_bytes());
90        Some(())
91    }
92
93    /// Reads a command from the supplied byte slice in little-endian order.
94    ///
95    /// Returns `None` if the slice is too short.
96    fn read_from(buf: &[u8]) -> Option<Self> {
97        if buf.len() < CMD_SIZE {
98            return None;
99        }
100        let cmd_type = u16::from_le_bytes([buf[0], buf[1]]);
101        let flags = u16::from_le_bytes([buf[2], buf[3]]);
102        let data_len = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
103        let payload_offset = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
104        Some(Self {
105            cmd_type,
106            flags,
107            data_len,
108            payload_offset,
109        })
110    }
111}
112
113/// Errors that can occur while writing into a [`PluginRingBuffer`].
114#[derive(Clone, Debug, PartialEq, Eq)]
115pub enum RingBufferError {
116    /// The ring buffer does not have enough contiguous free space for the
117    /// command and its payload.
118    BufferFull,
119    /// The command's `data_len` field does not match the supplied payload.
120    PayloadLengthMismatch,
121    /// The command's `payload_offset` or `data_len` is outside the buffer.
122    InvalidPayloadOffset,
123}
124
125impl fmt::Display for RingBufferError {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        match self {
128            RingBufferError::BufferFull => write!(f, "ring buffer is full"),
129            RingBufferError::PayloadLengthMismatch => {
130                write!(f, "command data_len does not match payload length")
131            }
132            RingBufferError::InvalidPayloadOffset => {
133                write!(f, "command payload offset or length is out of bounds")
134            }
135        }
136    }
137}
138
139impl std::error::Error for RingBufferError {}
140
141/// A circular command buffer backed by a shared linear memory slice.
142///
143/// The buffer is intentionally not thread-safe; it is intended for single-
144/// producer/single-consumer use between the plugin guest and the host render
145/// thread. All hot-path reads return borrowed slices and perform no allocation.
146///
147/// # Examples
148///
149/// ```
150/// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
151///
152/// let mut backing = vec![0u8; DEFAULT_CAPACITY];
153/// let mut rb = PluginRingBuffer::new(&mut backing);
154///
155/// let cmd = PluginPaintCmd {
156///     cmd_type: 1,
157///     flags: 0,
158///     data_len: 4,
159///     payload_offset: 0,
160/// };
161/// rb.produce(&cmd, &[1, 2, 3, 4]).unwrap();
162///
163/// let (read_cmd, payload) = rb.consume().unwrap();
164/// assert_eq!(read_cmd.cmd_type, 1);
165/// assert_eq!(payload, &[1, 2, 3, 4]);
166/// ```
167pub struct PluginRingBuffer<'a> {
168    data: &'a mut [u8],
169    /// Optional leading cursor block when the buffer lives in shared memory.
170    ///
171    /// When present, `head` and `tail` are mirrored into this 8-byte
172    /// little-endian header after every mutation so the other side of the
173    /// shared region can observe the cursors without a host call.
174    header: Option<&'a mut [u8]>,
175    head: u32,
176    tail: u32,
177}
178
179impl<'a> PluginRingBuffer<'a> {
180    /// Creates a ring buffer over the supplied shared memory slice.
181    ///
182    /// The slice must be large enough for at least one command header plus a
183    /// small payload. The buffer starts empty and the cursors are held only
184    /// in this struct; use [`PluginRingBuffer::new_shared`] when the slice is
185    /// a region shared with another party (e.g. guest linear memory).
186    pub fn new(data: &'a mut [u8]) -> Self {
187        Self {
188            data,
189            header: None,
190            head: 0,
191            tail: 0,
192        }
193    }
194
195    /// Creates a ring buffer over a shared memory region with a persisted
196    /// cursor header.
197    ///
198    /// The first [`SHARED_HEADER_SIZE`] bytes of `data` are interpreted as the
199    /// `head`/`tail` cursor block (little-endian `u32` each); the rest is the
200    /// circular payload area. Cursors are read on construction and written
201    /// back after every [`produce`](Self::produce)/[`consume`](Self::consume)
202    /// so a peer sharing the same region sees consistent state. Corrupt
203    /// out-of-range cursors reset the buffer to empty.
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use martensite_plugin::ring_buffer::SHARED_HEADER_SIZE;
209    /// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer};
210    ///
211    /// // 8-byte cursor header + 64 bytes of payload area.
212    /// let mut region = vec![0u8; SHARED_HEADER_SIZE + 64];
213    /// let mut rb = PluginRingBuffer::new_shared(&mut region);
214    /// let cmd = PluginPaintCmd {
215    ///     cmd_type: 1,
216    ///     flags: 0,
217    ///     data_len: 4,
218    ///     payload_offset: 0,
219    /// };
220    /// rb.produce(&cmd, &[1, 2, 3, 4]).unwrap();
221    /// // The peer can observe `tail` in the header.
222    /// assert_eq!(u32::from_le_bytes(region[4..8].try_into().unwrap()), 16);
223    /// ```
224    pub fn new_shared(data: &'a mut [u8]) -> Self {
225        if data.len() < SHARED_HEADER_SIZE {
226            return Self::new(data);
227        }
228        let (header, payload) = data.split_at_mut(SHARED_HEADER_SIZE);
229        let head = u32::from_le_bytes([header[0], header[1], header[2], header[3]]);
230        let tail = u32::from_le_bytes([header[4], header[5], header[6], header[7]]);
231        let capacity = payload.len() as u32;
232        // Reject corrupt cursors; an empty buffer is the safe fallback.
233        let (head, tail) = if head <= capacity && tail <= capacity {
234            (head, tail)
235        } else {
236            (0, 0)
237        };
238        let mut rb = Self {
239            data: payload,
240            header: Some(header),
241            head,
242            tail,
243        };
244        rb.sync_header();
245        rb
246    }
247
248    /// Mirrors the in-struct cursors into the shared header, if present.
249    fn sync_header(&mut self) {
250        if let Some(header) = self.header.as_deref_mut() {
251            header[0..4].copy_from_slice(&self.head.to_le_bytes());
252            header[4..8].copy_from_slice(&self.tail.to_le_bytes());
253        }
254    }
255
256    /// Returns the total capacity of the buffer in bytes.
257    pub fn capacity(&self) -> usize {
258        self.data.len()
259    }
260
261    fn capacity_u32(&self) -> u32 {
262        self.data.len() as u32
263    }
264
265    /// Returns the number of bytes currently stored in the buffer.
266    pub fn len(&self) -> usize {
267        let cap = self.capacity_u32();
268        if self.head == self.tail {
269            0
270        } else if self.tail > self.head {
271            (self.tail - self.head) as usize
272        } else {
273            (cap - self.head + self.tail) as usize
274        }
275    }
276
277    /// Returns `true` if the buffer contains no commands.
278    pub fn is_empty(&self) -> bool {
279        self.head == self.tail
280    }
281
282    /// Returns the total byte size of a record containing `payload_len` bytes.
283    #[inline]
284    const fn record_size(payload_len: usize) -> usize {
285        CMD_SIZE.saturating_add(payload_len)
286    }
287
288    /// Writes a command and its payload into the ring buffer.
289    ///
290    /// The `payload_offset` field of `cmd` is ignored; it is overwritten with
291    /// the actual offset of the payload in the buffer. `data_len` must match
292    /// `payload.len()`. Records are never split across the buffer boundary.
293    ///
294    /// # Errors
295    ///
296    /// Returns [`RingBufferError::PayloadLengthMismatch`] if `cmd.data_len` does
297    /// not equal `payload.len()`, or [`RingBufferError::BufferFull`] if the
298    /// command does not fit.
299    ///
300    /// # Examples
301    ///
302    /// ```
303    /// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
304    ///
305    /// let mut backing = vec![0u8; DEFAULT_CAPACITY];
306    /// let mut rb = PluginRingBuffer::new(&mut backing);
307    ///
308    /// let cmd = PluginPaintCmd {
309    ///     cmd_type: 2,
310    ///     flags: 0,
311    ///     data_len: 6,
312    ///     payload_offset: 0,
313    /// };
314    /// rb.produce(&cmd, &[9; 6]).unwrap();
315    /// ```
316    pub fn produce(&mut self, cmd: &PluginPaintCmd, payload: &[u8]) -> Result<(), RingBufferError> {
317        if cmd.data_len as usize != payload.len() {
318            return Err(RingBufferError::PayloadLengthMismatch);
319        }
320        let payload_len = payload.len();
321        let total = Self::record_size(payload_len);
322        if total == 0 || self.capacity() == 0 {
323            return Err(RingBufferError::BufferFull);
324        }
325        // Keep one byte of slack so that head == tail always means "empty".
326        if self.len().saturating_add(total) >= self.capacity() {
327            return Err(RingBufferError::BufferFull);
328        }
329
330        let cap_u32 = self.capacity_u32();
331        let tail = self.tail as usize;
332
333        // Decide where to write the new record. Records are never split across
334        // the end of the buffer; if there is not enough contiguous space at the
335        // tail, wrap to the start of the buffer and discard the trailing slack.
336        // The consumer `head` is left unchanged so any unconsumed records at
337        // the end of the buffer are consumed before the newly-wrapped record.
338        // If the buffer is empty before the wrap, the consumer can safely start
339        // from zero.
340        let (write_pos, wrapped) = if self.tail < self.head {
341            if tail.saturating_add(total) > self.head as usize {
342                return Err(RingBufferError::BufferFull);
343            }
344            (tail, false)
345        } else if tail.saturating_add(total) <= self.capacity() {
346            (tail, false)
347        } else if total < self.head as usize {
348            (0, true)
349        } else {
350            return Err(RingBufferError::BufferFull);
351        };
352
353        if wrapped && self.tail == self.head {
354            self.head = 0;
355        }
356
357        let payload_offset = write_pos + CMD_SIZE;
358        let mut stored_cmd = *cmd;
359        stored_cmd.payload_offset = payload_offset as u32;
360        stored_cmd
361            .write_to(&mut self.data[write_pos..])
362            .ok_or(RingBufferError::BufferFull)?;
363        self.data[payload_offset..payload_offset.saturating_add(payload_len)]
364            .copy_from_slice(payload);
365
366        self.tail = (write_pos + total) as u32;
367        if self.tail >= cap_u32 {
368            self.tail = 0;
369        }
370        self.sync_header();
371        Ok(())
372    }
373
374    /// Reads and removes the next command from the ring buffer.
375    ///
376    /// Returns `None` when the buffer is empty or the next record is malformed.
377    /// The returned payload is a borrowed view into the underlying shared
378    /// memory, so no allocation occurs on the readback hot path.
379    ///
380    /// # Examples
381    ///
382    /// ```
383    /// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
384    ///
385    /// let mut backing = vec![0u8; DEFAULT_CAPACITY];
386    /// let mut rb = PluginRingBuffer::new(&mut backing);
387    ///
388    /// let cmd = PluginPaintCmd {
389    ///     cmd_type: 0,
390    ///     flags: 0,
391    ///     data_len: 0,
392    ///     payload_offset: 0,
393    /// };
394    /// rb.produce(&cmd, &[]).unwrap();
395    ///
396    /// let (read_cmd, payload) = rb.consume().unwrap();
397    /// assert_eq!(payload.len(), 0);
398    /// assert_eq!(read_cmd.cmd_type, 0);
399    /// ```
400    pub fn consume(&mut self) -> Option<(PluginPaintCmd, &[u8])> {
401        if self.is_empty() {
402            return None;
403        }
404
405        let cap_u32 = self.capacity_u32();
406        if self.head >= cap_u32 {
407            self.head = 0;
408            if self.is_empty() {
409                self.sync_header();
410                return None;
411            }
412        }
413
414        let pos = self.head as usize;
415        if pos.saturating_add(CMD_SIZE) > self.data.len() {
416            // Head points into the slack created by a previous wrap-around.
417            self.head = 0;
418            return self.consume();
419        }
420
421        let cmd = match PluginPaintCmd::read_from(&self.data[pos..]) {
422            Some(cmd) => cmd,
423            None => {
424                // Fatal corruption: the header could not be parsed. Reset the
425                // buffer so the consumer does not wedge on the same bad record
426                // forever.
427                error!(
428                    head = self.head,
429                    tail = self.tail,
430                    pos,
431                    "ring buffer: malformed command header; resetting cursors"
432                );
433                self.head = 0;
434                self.tail = 0;
435                self.sync_header();
436                return None;
437            }
438        };
439        let payload_len = cmd.data_len as usize;
440        let expected_payload_start = pos.saturating_add(CMD_SIZE);
441        let expected_payload_end = expected_payload_start.saturating_add(payload_len);
442
443        // The guest is untrusted; reject any record whose payload is not
444        // contiguously after the header within the buffer. A malformed record
445        // is treated as fatal corruption: resetting the cursors prevents the
446        // consumer from spinning forever on the same record.
447        if cmd.payload_offset as usize != expected_payload_start
448            || expected_payload_end > self.data.len()
449            || expected_payload_end < expected_payload_start
450        {
451            error!(
452                head = self.head,
453                tail = self.tail,
454                pos,
455                payload_offset = cmd.payload_offset,
456                data_len = cmd.data_len,
457                expected_payload_start,
458                expected_payload_end,
459                buf_len = self.data.len(),
460                "ring buffer: malformed record (payload not contiguous); resetting cursors"
461            );
462            self.head = 0;
463            self.tail = 0;
464            self.sync_header();
465            return None;
466        }
467
468        self.head = (expected_payload_end) as u32;
469        if self.head >= cap_u32 {
470            self.head = 0;
471        }
472        // Write the cursors back before borrowing `data` for the payload.
473        if let Some(header) = self.header.as_deref_mut() {
474            header[0..4].copy_from_slice(&self.head.to_le_bytes());
475            header[4..8].copy_from_slice(&self.tail.to_le_bytes());
476        }
477        let payload = &self.data[expected_payload_start..expected_payload_end];
478        Some((cmd, payload))
479    }
480
481    /// Drains all currently available commands from the buffer, invoking the
482    /// provided closure for each command and its borrowed payload.
483    ///
484    /// This is the preferred host readback API because it keeps all reads
485    /// zero-allocation and bounded.
486    ///
487    /// # Examples
488    ///
489    /// ```
490    /// use martensite_plugin::{PluginPaintCmd, PluginRingBuffer, DEFAULT_CAPACITY};
491    ///
492    /// let mut backing = vec![0u8; DEFAULT_CAPACITY];
493    /// let mut rb = PluginRingBuffer::new(&mut backing);
494    ///
495    /// let cmd = PluginPaintCmd {
496    ///     cmd_type: 1,
497    ///     flags: 0,
498    ///     data_len: 2,
499    ///     payload_offset: 0,
500    /// };
501    /// rb.produce(&cmd, &[10, 20]).unwrap();
502    /// rb.produce(&cmd, &[30, 40]).unwrap();
503    ///
504    /// let mut count = 0;
505    /// rb.drain(|_cmd, payload| {
506    ///     count += 1;
507    ///     assert_eq!(payload.len(), 2);
508    /// });
509    /// assert_eq!(count, 2);
510    /// ```
511    pub fn drain<F>(&mut self, mut f: F)
512    where
513        F: FnMut(&PluginPaintCmd, &[u8]),
514    {
515        while let Some((cmd, payload)) = self.consume() {
516            f(&cmd, payload);
517        }
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn empty_buffer_returns_none() {
527        let mut data = vec![0u8; DEFAULT_CAPACITY];
528        let mut rb = PluginRingBuffer::new(&mut data);
529        assert!(rb.is_empty());
530        assert_eq!(rb.len(), 0);
531        assert!(rb.consume().is_none());
532    }
533
534    #[test]
535    fn produce_and_consume_single_command() {
536        let mut data = vec![0u8; DEFAULT_CAPACITY];
537        let mut rb = PluginRingBuffer::new(&mut data);
538        let cmd = PluginPaintCmd {
539            cmd_type: 1,
540            flags: 0,
541            data_len: 4,
542            payload_offset: 0,
543        };
544        rb.produce(&cmd, &[1, 2, 3, 4]).unwrap();
545        assert_eq!(rb.len(), CMD_SIZE + 4);
546
547        let (read_cmd, payload) = rb.consume().unwrap();
548        assert_eq!(read_cmd.cmd_type, 1);
549        assert_eq!(read_cmd.data_len, 4);
550        assert_eq!(payload, &[1, 2, 3, 4]);
551        assert!(rb.is_empty());
552    }
553
554    #[test]
555    fn payload_length_mismatch_is_rejected() {
556        let mut data = vec![0u8; DEFAULT_CAPACITY];
557        let mut rb = PluginRingBuffer::new(&mut data);
558        let cmd = PluginPaintCmd {
559            cmd_type: 1,
560            flags: 0,
561            data_len: 10,
562            payload_offset: 0,
563        };
564        assert_eq!(
565            rb.produce(&cmd, &[1, 2, 3, 4]),
566            Err(RingBufferError::PayloadLengthMismatch)
567        );
568    }
569
570    #[test]
571    fn wrap_around_reuses_start_of_buffer() {
572        let mut data = vec![0u8; 64];
573        let mut rb = PluginRingBuffer::new(&mut data);
574
575        // Fill most of the buffer.
576        let cmd = PluginPaintCmd {
577            cmd_type: 2,
578            flags: 0,
579            data_len: 40,
580            payload_offset: 0,
581        };
582        rb.produce(&cmd, &[7; 40]).unwrap();
583        rb.consume().unwrap();
584
585        // A new command that does not fit at the old tail should wrap to the
586        // start of the buffer.
587        let cmd2 = PluginPaintCmd {
588            cmd_type: 3,
589            flags: 0,
590            data_len: 16,
591            payload_offset: 0,
592        };
593        rb.produce(&cmd2, &[8; 16]).unwrap();
594
595        let (read_cmd, payload) = rb.consume().unwrap();
596        assert_eq!(read_cmd.cmd_type, 3);
597        assert_eq!(payload, &[8; 16]);
598        assert!(rb.is_empty());
599    }
600
601    #[test]
602    fn buffer_full_is_reported() {
603        let mut data = vec![0u8; 64];
604        let mut rb = PluginRingBuffer::new(&mut data);
605
606        let cmd = PluginPaintCmd {
607            cmd_type: 1,
608            flags: 0,
609            data_len: 40,
610            payload_offset: 0,
611        };
612        rb.produce(&cmd, &[1; 40]).unwrap();
613        assert_eq!(rb.produce(&cmd, &[1; 40]), Err(RingBufferError::BufferFull));
614    }
615
616    #[test]
617    fn drain_visits_all_commands() {
618        let mut data = vec![0u8; DEFAULT_CAPACITY];
619        let mut rb = PluginRingBuffer::new(&mut data);
620
621        let cmd = PluginPaintCmd {
622            cmd_type: 1,
623            flags: 0,
624            data_len: 2,
625            payload_offset: 0,
626        };
627        for i in 0u8..5 {
628            rb.produce(&cmd, &[i, i + 1]).unwrap();
629        }
630
631        let mut count = 0;
632        rb.drain(|_cmd, payload| {
633            assert_eq!(payload.len(), 2);
634            count += 1;
635        });
636        assert_eq!(count, 5);
637        assert!(rb.is_empty());
638    }
639
640    #[test]
641    fn cmd_serialization_roundtrips() {
642        let original = PluginPaintCmd {
643            cmd_type: 0xABCD,
644            flags: 0x1234,
645            data_len: 0xDEAD_BEEF,
646            payload_offset: 0xCAFE_BABE,
647        };
648        let mut buf = [0u8; CMD_SIZE];
649        original.write_to(&mut buf).unwrap();
650        let parsed = PluginPaintCmd::read_from(&buf).unwrap();
651        assert_eq!(original, parsed);
652    }
653
654    #[test]
655    fn zero_payload_command_roundtrips() {
656        let mut data = vec![0u8; 64];
657        let mut rb = PluginRingBuffer::new(&mut data);
658
659        let cmd = PluginPaintCmd {
660            cmd_type: 0,
661            flags: 0,
662            data_len: 0,
663            payload_offset: 0,
664        };
665        rb.produce(&cmd, &[]).unwrap();
666        let (read_cmd, payload) = rb.consume().unwrap();
667        assert_eq!(read_cmd.cmd_type, cmd.cmd_type);
668        assert_eq!(read_cmd.flags, cmd.flags);
669        assert_eq!(read_cmd.data_len, cmd.data_len);
670        assert!(payload.is_empty());
671    }
672
673    #[test]
674    fn malformed_record_resets_cursors_and_does_not_wedge() {
675        let mut data = vec![0u8; 64];
676
677        // Manually craft a record whose payload_offset does not match the
678        // expected position right after the header. Write it directly into the
679        // backing store before handing the slice to the ring buffer.
680        let bad = PluginPaintCmd {
681            cmd_type: 1,
682            flags: 0,
683            data_len: 4,
684            payload_offset: 0, // wrong: should be CMD_SIZE
685        };
686        bad.write_to(&mut data[0..]).unwrap();
687        // Fill the payload area with non-zero bytes so the record looks
688        // populated.
689        data[CMD_SIZE..CMD_SIZE + 4].fill(9);
690
691        let mut rb = PluginRingBuffer::new(&mut data);
692        rb.head = 0;
693        rb.tail = (CMD_SIZE + 4) as u32;
694
695        // First call detects corruption and returns None.
696        assert!(rb.consume().is_none());
697        // Cursors were reset, so the buffer is now empty and subsequent calls
698        // do not wedge on the same bad record.
699        assert!(rb.is_empty());
700        assert!(rb.consume().is_none());
701
702        // The buffer is usable again after the reset.
703        let cmd = PluginPaintCmd {
704            cmd_type: 2,
705            flags: 0,
706            data_len: 2,
707            payload_offset: 0,
708        };
709        rb.produce(&cmd, &[3, 4]).unwrap();
710        let (read_cmd, payload) = rb.consume().unwrap();
711        assert_eq!(read_cmd.cmd_type, 2);
712        assert_eq!(payload, &[3, 4]);
713    }
714
715    #[test]
716    fn malformed_record_resets_shared_header() {
717        use crate::ring_buffer::SHARED_HEADER_SIZE;
718        let mut region = vec![0u8; SHARED_HEADER_SIZE + 64];
719
720        // Write a valid record, then corrupt its payload_offset so the next
721        // consume treats it as malformed. The corruption is applied directly to
722        // the backing region before the ring buffer borrows it.
723        {
724            let mut rb = PluginRingBuffer::new_shared(&mut region);
725            let cmd = PluginPaintCmd {
726                cmd_type: 1,
727                flags: 0,
728                data_len: 4,
729                payload_offset: 0,
730            };
731            rb.produce(&cmd, &[1, 2, 3, 4]).unwrap();
732        }
733        // Corrupt the payload_offset field (bytes 8..12 of the payload area,
734        // i.e. region offset SHARED_HEADER_SIZE + 8).
735        let off = SHARED_HEADER_SIZE + 8;
736        region[off..off + 4].copy_from_slice(&99u32.to_le_bytes());
737
738        {
739            let mut rb = PluginRingBuffer::new_shared(&mut region);
740            assert!(rb.consume().is_none());
741        }
742        // The shared header must reflect the reset cursors (head == tail == 0).
743        let head = u32::from_le_bytes(region[0..4].try_into().unwrap());
744        let tail = u32::from_le_bytes(region[4..8].try_into().unwrap());
745        assert_eq!(head, 0);
746        assert_eq!(tail, 0);
747    }
748}