rigless 0.0.2

Virtual hardware-in-the-loop runtime: run unmodified firmware on an emulated MCU with every peripheral, chip model, and unit of virtual time in Rust; needs the rigless-sidecar package from rigless-labs/qemu
Documentation
// Copyright (c) 2026 Rigless Labs.
// SPDX-License-Identifier: Apache-2.0
//! The turn word: which side may write its slot, and how the other side waits.

use core::time::Duration;

use crate::sidecar::sync::{AtomicU32, Ordering};

/// How long a waiter spins on the turn word before it starts sleeping between polls.
pub const SPIN_WINDOW: Duration = Duration::from_micros(50);

/// How long a waiter sleeps between two polls of the turn word once the spin window is over.
pub const SLEEP_QUANTUM: Duration = Duration::from_micros(20);

/// Which side may write its slot and hand over.
#[repr(u32)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Turn {
    /// The sidecar writes its slot; the core waits.
    Sidecar = 0,
    /// The core writes its slot; the sidecar waits.
    Core = 1,
}

impl Turn {
    const fn other(self) -> Self {
        match self {
            Self::Sidecar => Self::Core,
            Self::Core => Self::Sidecar,
        }
    }
}

/// Why a wait for the turn ended without it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum WaitError {
    /// The peer exited before handing over the turn.
    #[error("the peer exited before handing over the turn")]
    PeerGone,
    /// The turn word holds a value that names neither side.
    #[error("the turn word holds {0}, which is neither side")]
    CorruptTurn(u32),
    /// The wait was cancelled from outside, by whoever holds the cancel flag.
    #[error("the wait was cancelled")]
    Cancelled,
}

/// The turn word on its own cache line; every write to the word goes through it.
#[repr(C, align(64))]
#[derive(Debug)]
pub struct TurnWord(AtomicU32);

impl TurnWord {
    pub(crate) fn new(holder: Turn) -> Self {
        Self(AtomicU32::new(holder as u32))
    }

    /// Publishes this side's slot and interrupt levels and gives the turn to `to`.
    pub fn hand_over(&self, to: Turn) {
        self.0.store(to as u32, Ordering::Release);
    }

    /// Blocks until the turn is `me`. After the spin window it calls `check` once per sleep quantum and ends the
    /// wait with the error `check` returns: the peer is gone, or the run was cancelled.
    #[cfg(not(loom))]
    #[expect(
        clippy::disallowed_types,
        clippy::disallowed_methods,
        reason = "pacing the wait for the peer; virtual time is untouched"
    )]
    pub fn wait_for(
        &self,
        me: Turn,
        mut check: impl FnMut() -> Result<(), WaitError>,
    ) -> Result<(), WaitError> {
        let mut spin_start = None;
        loop {
            if self.is_held_by(me)? {
                return Ok(());
            }
            let spin_start = spin_start.get_or_insert_with(std::time::Instant::now);
            if spin_start.elapsed() < SPIN_WINDOW {
                core::hint::spin_loop();
                continue;
            }
            check()?;
            std::thread::sleep(SLEEP_QUANTUM);
        }
    }

    /// Blocks until the turn is `me`, calling `check` on every yield.
    #[cfg(loom)]
    pub fn wait_for(
        &self,
        me: Turn,
        mut check: impl FnMut() -> Result<(), WaitError>,
    ) -> Result<(), WaitError> {
        while !self.is_held_by(me)? {
            check()?;
            loom::thread::yield_now();
        }
        Ok(())
    }

    fn is_held_by(&self, me: Turn) -> Result<bool, WaitError> {
        let holder = self.0.load(Ordering::Acquire);
        if holder == me as u32 {
            Ok(true)
        } else if holder == me.other() as u32 {
            Ok(false)
        } else {
            Err(WaitError::CorruptTurn(holder))
        }
    }
}

#[cfg(not(loom))]
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn wait_returns_at_once_when_the_turn_is_already_mine() {
        let word = TurnWord::new(Turn::Core);
        assert_eq!(
            word.wait_for(Turn::Core, || Err(WaitError::PeerGone)),
            Ok(())
        );
    }

    #[test]
    fn wait_ends_with_the_error_the_check_returns() {
        let word = TurnWord::new(Turn::Sidecar);
        for ending in [WaitError::PeerGone, WaitError::Cancelled] {
            assert_eq!(word.wait_for(Turn::Core, || Err(ending)), Err(ending));
        }
    }

    #[test]
    fn wait_rejects_a_turn_word_that_names_neither_side() {
        let word = TurnWord::new(Turn::Sidecar);
        word.0.store(2, Ordering::Relaxed);
        assert_eq!(
            word.wait_for(Turn::Core, || Ok(())),
            Err(WaitError::CorruptTurn(2))
        );
    }

    #[test]
    #[expect(
        clippy::disallowed_methods,
        reason = "the peer is paced past the spin window"
    )]
    fn wait_survives_a_hand_over_after_the_spin_window() {
        const PEER_DELAY: Duration = Duration::from_millis(5);
        let word = TurnWord::new(Turn::Sidecar);
        std::thread::scope(|scope| {
            scope.spawn(|| {
                std::thread::sleep(PEER_DELAY);
                word.hand_over(Turn::Core);
            });
            assert_eq!(word.wait_for(Turn::Core, || Ok(())), Ok(()));
        });
    }
}

#[cfg(loom)]
#[cfg(test)]
mod loom_tests {
    use crate::soc::Width;
    use loom::sync::Arc;

    use super::*;
    use crate::sidecar::layout::{ControlBlock, InterruptLine};
    use crate::sidecar::message::{CoreMessage, SidecarMessage};

    #[test]
    fn turn_hand_over_publishes_the_slot_and_the_levels() {
        const READ: SidecarMessage = SidecarMessage::Read {
            address: 0x4002_0014,
            width: Width::W32,
            instruction_count: 12,
            program_counter: 0x0800_0100,
            virtual_time_ns: 48,
        };
        const REPLY: CoreMessage = CoreMessage::Reply {
            value: 0x55,
            deadline_ns: 1_000_000,
        };
        let line = InterruptLine::new(70).unwrap();
        loom::model(move || {
            let block = Arc::new(ControlBlock::new());
            let sidecar_block = Arc::clone(&block);
            let sidecar = loom::thread::spawn(move || {
                READ.encode(&sidecar_block.sidecar_slot);
                sidecar_block.turn.hand_over(Turn::Core);
                sidecar_block
                    .turn
                    .wait_for(Turn::Sidecar, || Ok(()))
                    .unwrap();
                assert_eq!(CoreMessage::decode(&sidecar_block.core_slot), Ok(REPLY));
                assert!(sidecar_block.interrupt_levels.is_raised(line));
            });
            block.turn.wait_for(Turn::Core, || Ok(())).unwrap();
            assert_eq!(SidecarMessage::decode(&block.sidecar_slot), Ok(READ));
            REPLY.encode(&block.core_slot);
            block.interrupt_levels.raise(line);
            block.turn.hand_over(Turn::Sidecar);
            sidecar.join().unwrap();
        });
    }
}