First recursive code-review-harness pass over the authored SDK code (sim/,
config-lint, flowgen, drivers/{relays,freshness}, build/, ci.yml). Four parallel
dimension reviewers; all findings at every severity corrected per workspace rule.
Correctness / reliability:
- freshness.c min_budget_ms: use a `seen` flag, not `best==0`, as the empty
sentinel — a zero-tolerance (max_stale_ms==0) binding was silently widened to a
looser neighbour's budget. Regression test added; still 66/66 MC/DC.
- config-lint parse_reserved_ranges: match `reg` as a whole property token (ident
boundary before, `=` after) so `reg-names` / a `region-*` label no longer
mis-parses into a bogus reserved range.
- config-lint loader check: fail closed — flag any loader with a LOAD_ADDR that is
not a known-safe boot component, instead of only known MCU names, so a future
coprocessor ("Rtos"/"Bl32") can't slip past the 0x40000-brick gate.
- build-kernel.sh: sha256 verification is now mandatory (refuse to build if the pin
is missing) and the mktemp scratch tree is removed on exit (trap), while a
caller-provided WORK is left intact for CI artifact upload.
Test quality:
- freshness: added the age==max_stale boundary case and a clock-wraparound
(now < last_ok) fail-safe-to-UNKNOWN test.
- relays: unsetenv(WARDEN_GPIO_ROOT) at main() so the NULL-env arm is hermetic.
Security / CI:
- ci.yml: top-level `permissions: contents: read` (badges overrides to write);
pin taiki-e/install-action to commit SHA (v2.86.7).
Maintainability / docs:
- drivers/enforce-mcdc.sh: one shared, name-derived gate replaces the two
copy-pasted per-driver scripts; Makefiles call ../../enforce-mcdc.sh.
- docs/architecture.md: §3/§4/§6/§7 rewritten to match reality — NPU/RGA models,
config-lint, and the relays+freshness MC/DC harnesses are done; kernel §6 now
reflects the 5.10->6.18.46 forward-port (ADR-0001), not the superseded plan44/6.6.
- README: status blurb + layout table corrected (kernel/, .github/; stale ci/ and
patches/ descriptions fixed). hpmcu "8 tests" -> 7 in docs.
- freshness.{c,h}: ADR reference points at flare-edge ADR-0004 (warden-sdk's
ADR-0004 is the CI runner — number collision).
- normalize rustfmt drift across sim/ + config-lint.
All green: sim 37 tests, config-lint 8 tests, both drivers 100% MC/DC (relays
40/40, freshness 66/66), clippy clean under -D warnings, gitleaks clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wB8KB3MMQztRDXCMCkPrf
270 lines
9.6 KiB
Rust
270 lines
9.6 KiB
Rust
//! Simulator of the RV1106 HPMCU (Syntacore SCR1) watchdog firmware.
|
|
//!
|
|
//! A faithful software port of `flare-edge/hpmcu/watchdog/main.c`'s poll loop:
|
|
//! it reads the Linux-owned mailbox words (magic + heartbeat counter), writes the
|
|
//! MCU-owned state word, and fires (records a CRU reset) on the same deadlines —
|
|
//! boot-grace if userspace never arms it, heartbeat-timeout if a live heartbeat
|
|
//! stops. Because it runs on a [`MemBus`], the *same* flared arm/beat protocol can
|
|
//! be driven against it in a host unit test, with a virtual clock, in
|
|
//! microseconds — the validation that was missing when a boot-loaded build of this
|
|
//! firmware had to be tested by flashing a panel.
|
|
//!
|
|
//! Deadlines are modelled in whole seconds (the firmware's cycle math exists only
|
|
//! to convert the core clock to these wall-clock seconds); a `tick(now_secs)` runs
|
|
//! exactly one iteration of the firmware's `for(;;)` body at that virtual time.
|
|
|
|
use crate::membus::MemBus;
|
|
|
|
pub const MB_BASE: u64 = 0xff6f_ff00;
|
|
const OFF_MAGIC: u64 = 0x00; // Linux-owned
|
|
const OFF_COUNTER: u64 = 0x04; // Linux-owned
|
|
const OFF_STATE: u64 = 0x08; // MCU-owned
|
|
const OFF_SEEN: u64 = 0x0c; // MCU-owned
|
|
const OFF_CYC: u64 = 0x10; // MCU-owned (coarse runtime)
|
|
|
|
pub const MAGIC_ARMED: u32 = 0x5741_5244; // "WARD"
|
|
pub const MAGIC_DISARM: u32 = 0x4449_5341; // "DISA"
|
|
|
|
pub const STATE_BOOT: u32 = 0xB007_0000;
|
|
pub const STATE_ARMED: u32 = 0xA07D_0000;
|
|
pub const STATE_DISARMED: u32 = 0xD15A_0000;
|
|
pub const STATE_FIRED: u32 = 0xF17E_0000;
|
|
|
|
pub const HEARTBEAT_TIMEOUT_S: u64 = 90;
|
|
pub const BOOT_GRACE_S: u64 = 300;
|
|
|
|
/// The simulated MCU core running the watchdog firmware against `bus`.
|
|
pub struct HpmcuSim<B: MemBus> {
|
|
bus: B,
|
|
base: u64,
|
|
t0: u64,
|
|
last_change: u64,
|
|
last_seen: u32,
|
|
polls: u32,
|
|
armed_ever: bool,
|
|
fired: bool,
|
|
fire_count: u64,
|
|
}
|
|
|
|
impl<B: MemBus> HpmcuSim<B> {
|
|
/// Bring the MCU up at `start_now` (models the firmware's `main()` prologue:
|
|
/// state=BOOT, seen=0, boot-grace clock starts here).
|
|
pub fn new(bus: B, base: u64, start_now: u64) -> Self {
|
|
bus.poke32(base + OFF_STATE, STATE_BOOT);
|
|
bus.poke32(base + OFF_SEEN, 0);
|
|
Self {
|
|
bus,
|
|
base,
|
|
t0: start_now,
|
|
last_change: start_now,
|
|
last_seen: 0,
|
|
polls: 0,
|
|
armed_ever: false,
|
|
fired: false,
|
|
fire_count: 0,
|
|
}
|
|
}
|
|
|
|
pub fn fired(&self) -> bool {
|
|
self.fired
|
|
}
|
|
pub fn fire_count(&self) -> u64 {
|
|
self.fire_count
|
|
}
|
|
/// The sentinel half (high 16 bits) of the MCU state word.
|
|
pub fn state(&self) -> u32 {
|
|
self.bus.peek32(self.base + OFF_STATE) & 0xffff_0000
|
|
}
|
|
|
|
fn fire(&mut self) {
|
|
self.bus.poke32(self.base + OFF_STATE, STATE_FIRED);
|
|
self.fired = true;
|
|
self.fire_count += 1;
|
|
// Real firmware then writes the CRU global reset and spins forever; a
|
|
// harness modelling the resulting reboot re-creates the MCU (new boot
|
|
// grace). Here we just record that it fired.
|
|
}
|
|
|
|
/// One iteration of the firmware poll loop at virtual time `now` (seconds).
|
|
/// Once fired, the real core spins in `fire()`, so further ticks are no-ops.
|
|
pub fn tick(&mut self, now: u64) {
|
|
if self.fired {
|
|
return;
|
|
}
|
|
self.polls = self.polls.wrapping_add(1);
|
|
self.bus
|
|
.poke32(self.base + OFF_CYC, (now & 0xffff_ffff) as u32);
|
|
let magic = self.bus.peek32(self.base + OFF_MAGIC);
|
|
let counter = self.bus.peek32(self.base + OFF_COUNTER);
|
|
let poll_lo = self.polls & 0xffff;
|
|
|
|
if magic == MAGIC_DISARM {
|
|
// Deliberate stand-down: resume the moment Linux re-arms; the grace
|
|
// clock restarts so a disarm-then-silence never fires.
|
|
self.bus
|
|
.poke32(self.base + OFF_STATE, STATE_DISARMED | poll_lo);
|
|
self.t0 = now;
|
|
self.last_change = now;
|
|
} else if magic == MAGIC_ARMED {
|
|
self.armed_ever = true;
|
|
if counter != self.last_seen {
|
|
self.last_seen = counter;
|
|
self.bus.poke32(self.base + OFF_SEEN, counter);
|
|
self.last_change = now;
|
|
}
|
|
self.bus
|
|
.poke32(self.base + OFF_STATE, STATE_ARMED | poll_lo);
|
|
if now.saturating_sub(self.last_change) > HEARTBEAT_TIMEOUT_S {
|
|
self.fire();
|
|
}
|
|
} else {
|
|
// Not (yet) armed. Catches "kernel never brought userspace up" via
|
|
// boot-grace, and a live-then-dead heartbeat via the timeout.
|
|
self.bus.poke32(self.base + OFF_STATE, STATE_BOOT | poll_lo);
|
|
if !self.armed_ever && now.saturating_sub(self.t0) > BOOT_GRACE_S {
|
|
self.fire();
|
|
}
|
|
if self.armed_ever && now.saturating_sub(self.last_change) > HEARTBEAT_TIMEOUT_S {
|
|
self.fire();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::membus::SimBus;
|
|
|
|
fn mcu(bus: &SimBus) -> HpmcuSim<SimBus> {
|
|
HpmcuSim::new(bus.clone(), MB_BASE, 0)
|
|
}
|
|
fn arm_beat(bus: &SimBus, counter: u32) {
|
|
bus.poke32(MB_BASE + OFF_COUNTER, counter);
|
|
bus.poke32(MB_BASE + OFF_MAGIC, MAGIC_ARMED);
|
|
}
|
|
|
|
#[test]
|
|
fn boots_into_boot_state() {
|
|
let bus = SimBus::new();
|
|
let m = mcu(&bus);
|
|
assert_eq!(m.state(), STATE_BOOT);
|
|
assert!(!m.fired());
|
|
}
|
|
|
|
#[test]
|
|
fn boot_grace_fires_only_after_300s_when_never_armed() {
|
|
let bus = SimBus::new();
|
|
let mut m = mcu(&bus);
|
|
// Poll steadily; magic stays 0 (never armed).
|
|
for now in (0..=300).step_by(10) {
|
|
m.tick(now);
|
|
assert!(!m.fired(), "must not fire at or before boot-grace ({now}s)");
|
|
}
|
|
m.tick(301);
|
|
assert!(m.fired(), "must fire just past the 300s boot grace");
|
|
assert_eq!(m.state(), STATE_FIRED);
|
|
}
|
|
|
|
#[test]
|
|
fn armed_and_beating_never_fires() {
|
|
let bus = SimBus::new();
|
|
let mut m = mcu(&bus);
|
|
// flared arms + beats every 5s for a simulated hour.
|
|
for (i, now) in (0..3600).step_by(5).enumerate() {
|
|
arm_beat(&bus, i as u32 + 1);
|
|
m.tick(now);
|
|
assert!(!m.fired(), "a beating heartbeat must never fire (@{now}s)");
|
|
}
|
|
assert_eq!(m.state(), STATE_ARMED);
|
|
}
|
|
|
|
#[test]
|
|
fn stops_beating_fires_after_heartbeat_timeout() {
|
|
let bus = SimBus::new();
|
|
let mut m = mcu(&bus);
|
|
arm_beat(&bus, 1);
|
|
m.tick(0);
|
|
assert_eq!(m.state(), STATE_ARMED);
|
|
// Heartbeat stops (counter frozen). Fires just past 90s.
|
|
for now in (5..=90).step_by(5) {
|
|
m.tick(now);
|
|
assert!(
|
|
!m.fired(),
|
|
"must not fire within the heartbeat window (@{now}s)"
|
|
);
|
|
}
|
|
m.tick(91);
|
|
assert!(m.fired(), "must fire just past the 90s heartbeat timeout");
|
|
}
|
|
|
|
#[test]
|
|
fn disarm_stands_down_indefinitely() {
|
|
let bus = SimBus::new();
|
|
let mut m = mcu(&bus);
|
|
bus.poke32(MB_BASE + OFF_MAGIC, MAGIC_DISARM);
|
|
// Silence for well past both deadlines: a disarmed MCU never fires.
|
|
for now in (0..=1000).step_by(10) {
|
|
m.tick(now);
|
|
assert!(!m.fired(), "a disarmed MCU must never fire (@{now}s)");
|
|
}
|
|
assert_eq!(m.state(), STATE_DISARMED);
|
|
}
|
|
|
|
#[test]
|
|
fn flared_arms_within_boot_grace_no_boot_loop() {
|
|
// The exact safety property the boot-loaded watchdog needs: on a healthy
|
|
// boot, flared comes up well before the 300s boot grace, arms the MCU, and
|
|
// keeps beating — so it transitions BOOT -> ARMED and never fires. (A
|
|
// failure here would be the boot-loop we must never ship.)
|
|
let bus = SimBus::new();
|
|
let mut m = mcu(&bus);
|
|
// 0..40s: kernel booting, flared not up yet (magic 0). MCU polls in BOOT.
|
|
for now in (0..40).step_by(5) {
|
|
m.tick(now);
|
|
assert_eq!(m.state(), STATE_BOOT);
|
|
assert!(!m.fired());
|
|
}
|
|
// flared starts at 40s and arms+beats every 5s thereafter.
|
|
let mut counter = 0u32;
|
|
for now in (40..1000).step_by(5) {
|
|
counter += 1;
|
|
arm_beat(&bus, counter);
|
|
m.tick(now);
|
|
assert!(
|
|
!m.fired(),
|
|
"flared armed before boot-grace: must never fire (@{now}s)"
|
|
);
|
|
}
|
|
assert_eq!(m.state(), STATE_ARMED);
|
|
}
|
|
|
|
#[test]
|
|
fn flared_dies_after_arming_fires_and_would_roll_back() {
|
|
// flared arms, runs a while, then crashes (stops beating). The MCU fires
|
|
// after the heartbeat timeout -> reset -> (with A/B) rollback. This is the
|
|
// recovery the whole supervisor exists for.
|
|
let bus = SimBus::new();
|
|
let mut m = mcu(&bus);
|
|
let mut counter = 0u32;
|
|
// Beat through t=600 (inclusive) — the last heartbeat lands at 600s.
|
|
for now in (0..=600).step_by(5) {
|
|
counter += 1;
|
|
arm_beat(&bus, counter);
|
|
m.tick(now);
|
|
}
|
|
assert!(!m.fired());
|
|
// flared is gone: counter frozen, magic still ARMED. last_change=600, so
|
|
// the 90s heartbeat window closes at 690s; it must not fire before then.
|
|
for now in (605..=690).step_by(5) {
|
|
m.tick(now);
|
|
assert!(!m.fired(), "within the heartbeat window (@{now}s)");
|
|
}
|
|
m.tick(695); // 695 - 600 = 95 > 90
|
|
assert!(
|
|
m.fired(),
|
|
"flared dead > heartbeat timeout: MCU fires the reset"
|
|
);
|
|
}
|
|
}
|