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