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}