Skip to main content

rustdv_runner/
rustdv_runner.rs

1//! # rustdv-runner
2//!
3//! The regression manager (design-doc D2.4, §4.5): port of cocotb's
4//! `RegressionManager`. Owns the test registry (populated at link time by
5//! `#[rustdv::test]`), runs tests sequentially, applies timeouts, scores
6//! panics/errors vs. expectations, prints the summary table, and provides
7//! the simulator entry point.
8//!
9//! **Bootstrap deviation from D3.2 (STATUS.md):** instead of exporting
10//! cocotb's libpygpi entry symbols, the testbench cdylib is loaded as a
11//! plain **VPI module** (`vvp -M<dir> -m<name>`): `rustdv::vpi_bootstrap!()`
12//! exports `vlog_startup_routines`, whose startup routine registers a
13//! cbStartOfSimulation callback that kicks off the regression.
14
15use std::cell::RefCell;
16use std::future::Future;
17use std::pin::Pin;
18use std::rc::Rc;
19
20use rustdv_gpi as gpi;
21use rustdv_sim::combinators::{first2, Either};
22use rustdv_sim::handle::top_module;
23use rustdv_sim::log;
24use rustdv_sim::time::{sim_time_ns, SimDuration};
25use rustdv_sim::triggers::Timer;
26
27// ===========================================================================
28// Public test-facing types
29// ===========================================================================
30
31/// Test failure value — defined in `rustdv-methodology` since step 4, because
32/// `Component::run` returns it and the UVM crate sits below this one
33/// (D46/D47). Re-exported so `::rustdv::TestError` is unchanged.
34pub use rustdv_methodology::TestError;
35
36/// Handed to each test. Since step 4 this is the one universal context
37/// (D47): the old `TestCtx` and `RunCtx` merged into `RustdvCtx`, which
38/// lives in `rustdv-methodology` beside the `Component` trait that receives it.
39pub use rustdv_methodology::RustdvCtx;
40
41type TestFn =
42    fn(RustdvCtx) -> Pin<Box<dyn Future<Output = Result<(), TestError>>>>;
43
44/// One registered test (design-doc §6.1: the cocotb `Test` option set).
45pub struct TestRegistration {
46    pub name: &'static str,
47    pub module: &'static str,
48    pub file: &'static str,
49    pub line: u32,
50    pub run: TestFn,
51    /// (time, unit), e.g. (100, "us").
52    pub timeout: Option<(u64, &'static str)>,
53    pub skip: bool,
54    pub expect_fail: bool,
55    /// Pass only if the test fails with this cause (D68). Strictly stronger
56    /// than `expect_fail`, which accepts any failure at all.
57    pub expect_error: Option<&'static str>,
58}
59
60// ===========================================================================
61// Link-time registry (design-doc §0.5/§6.1; OQ-4 — ELF section technique,
62// with the sentinel guaranteeing the section exists)
63// ===========================================================================
64
65fn sentinel_shim(_ctx: RustdvCtx) -> Pin<Box<dyn Future<Output = Result<(), TestError>>>> {
66    Box::pin(async { Ok(()) })
67}
68
69#[used]
70// ELF (Linux) names sections freely; Mach-O (macOS) wants segment,section.
71#[cfg_attr(not(target_vendor = "apple"), link_section = "rustdv_tests")]
72#[cfg_attr(target_vendor = "apple", link_section = "__DATA,rustdv_tests")]
73static SENTINEL: &TestRegistration = &TestRegistration {
74    name: "__rustdv_sentinel",
75    module: "rustdv_runner",
76    file: file!(),
77    line: line!(),
78    run: sentinel_shim,
79    timeout: None,
80    skip: true,
81    expect_fail: false,
82    expect_error: None,
83};
84
85// The linker-provided section bounds. ELF defines __start_/__stop_
86// symbols automatically; Mach-O spells them section$start$/section$end$
87// (reached via link_name — the \x01 prefix suppresses mangling).
88#[cfg(not(target_vendor = "apple"))]
89extern "C" {
90    static __start_rustdv_tests: u8;
91    static __stop_rustdv_tests: u8;
92}
93
94#[cfg(target_vendor = "apple")]
95extern "C" {
96    #[link_name = "\x01section$start$__DATA$rustdv_tests"]
97    static __start_rustdv_tests: u8;
98    #[link_name = "\x01section$end$__DATA$rustdv_tests"]
99    static __stop_rustdv_tests: u8;
100}
101
102/// All registered tests, in (file, line) order.
103pub fn collect_tests() -> Vec<&'static TestRegistration> {
104    // Force the sentinel's object file into the link.
105    std::hint::black_box(SENTINEL.name);
106    let mut out: Vec<&'static TestRegistration> = Vec::new();
107    unsafe {
108        let start = std::ptr::addr_of!(__start_rustdv_tests) as usize;
109        let stop = std::ptr::addr_of!(__stop_rustdv_tests) as usize;
110        let entry = std::mem::size_of::<&TestRegistration>();
111        let count = (stop - start) / entry;
112        let base = start as *const &'static TestRegistration;
113        for i in 0..count {
114            let reg = *base.add(i);
115            if reg.name != "__rustdv_sentinel" {
116                out.push(reg);
117            }
118        }
119    }
120    out.sort_by_key(|r| (r.file, r.line));
121    out
122}
123
124// ===========================================================================
125// Regression execution (§4.5)
126// ===========================================================================
127
128#[derive(Clone, Debug, PartialEq, Eq)]
129enum Outcome {
130    Pass,
131    /// `kind` is the machine-readable cause, when the failure had one, so
132    /// `expect_error` can insist a test failed for the *right* reason.
133    Fail { msg: String, kind: Option<&'static str> },
134    Skip,
135}
136
137fn fail(msg: impl Into<String>) -> Outcome {
138    Outcome::Fail { msg: msg.into(), kind: None }
139}
140
141struct TestResult {
142    name: &'static str,
143    outcome: Outcome,
144    sim_ns: f64,
145}
146
147thread_local! {
148    /// "A panic in any task fails the current test" (cocotb:
149    /// TestManager._task_done_callback).
150    static CURRENT_FAILURE: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
151}
152
153fn take_background_failure() -> Option<String> {
154    CURRENT_FAILURE.with(|f| f.borrow_mut().take())
155}
156
157fn seed_from_env() -> u64 {
158    std::env::var("RUSTDV_RANDOM_SEED")
159        .ok()
160        .and_then(|s| s.parse().ok())
161        .unwrap_or_else(|| {
162            std::time::SystemTime::now()
163                .duration_since(std::time::UNIX_EPOCH)
164                .map(|d| d.as_secs())
165                .unwrap_or(1)
166        })
167}
168
169async fn run_one(reg: &'static TestRegistration, seed: u64) -> Outcome {
170    // The simulator's phase outlives the test that put it there (D108). A test
171    // ending inside ReadOnly — `read_only().await` as its last act, which is
172    // exactly how a test that checks a settled value ends — hands the ReadOnly
173    // region straight to whatever the executor runs next, and what it runs next
174    // is this test, in the same drain of the same callback. Its first write
175    // would then be illegal for a reason that has nothing to do with it. Get
176    // out of the region first, which costs one precision step when it happens
177    // at all and nothing when the predecessor ended anywhere else.
178    rustdv_sim::phase::leave_read_only().await;
179
180    // Each test starts clean — pyuvm's run_test does the same, so a test never
181    // inherits the previous test's BFM (with its half-drained queues) or its
182    // logging configuration. D16's rule; the ConfigDb clear is what carries it
183    // for the BFM now that the BFM lives there rather than in a singleton
184    // (D101).
185    rustdv_methodology::ConfigDb::clear();
186    log::reset_config();
187
188    let dut = match top_module() {
189        Ok(d) => d,
190        Err(e) => return fail(format!("no DUT: {e}")),
191    };
192    // D49: the root path is the test's registered name, so `ctx.info(..)`
193    // logs `[random_test]` where UVM logs `uvm_test_top`.
194    let ctx = RustdvCtx::new(reg.name, dut, seed);
195
196    let ex = rustdv_sim::executor::current();
197    let watermark = ex.watermark();
198
199    // UVM's end-of-test consensus: the body finishes, then the test waits
200    // for every outstanding objection. The clone shares the registry, and
201    // folding the wait into the same future keeps it under the timeout.
202    // A test that never objected is not made to wait — that is the Part II
203    // front door (D46), and pyuvm's "you never objected" warning would
204    // otherwise fire on every cocotb-shaped test.
205    let body = {
206        let watcher = ctx.clone();
207        let fut = (reg.run)(ctx);
208        async move {
209            let result = fut.await;
210            if watcher.objections().ever_raised() {
211                watcher.all_objections_dropped().await;
212            }
213            result
214        }
215    };
216
217    let handle = ex.spawn_named(body, Some(reg.name));
218
219    // handle.await → Result<Result<(), TestError>, TaskError>
220    let raw = match reg.timeout {
221        Some((n, unit)) => {
222            let d = SimDuration::from_unit(n, unit);
223            match first2(handle, Timer::new(d)).await {
224                Either::First(r) => Some(r),
225                Either::Second(()) => None, // timeout
226            }
227        }
228        None => Some(handle.await),
229    };
230
231    // Kill surviving tasks spawned during the test (§4.5).
232    ex.cancel_after(watermark);
233
234    let mut outcome = match raw {
235        None => fail(format!(
236            "timeout after {}{}",
237            reg.timeout.unwrap().0,
238            reg.timeout.unwrap().1
239        )),
240        Some(Err(e)) => fail(format!("test task: {e}")),
241        Some(Ok(Err(e))) => Outcome::Fail { msg: e.to_string(), kind: e.kind() },
242        Some(Ok(Ok(()))) => Outcome::Pass,
243    };
244
245    // A panic in a child task fails the test even if the body passed.
246    if let Some(bg) = take_background_failure() {
247        if outcome == Outcome::Pass {
248            outcome = fail(bg);
249        }
250    }
251
252    if let Some(expected) = reg.expect_error {
253        outcome = match outcome {
254            Outcome::Pass => fail(format!("expected error '{expected}' but test passed")),
255            Outcome::Fail { msg, kind } if kind == Some(expected) => {
256                let _ = msg;
257                Outcome::Pass
258            }
259            Outcome::Fail { msg, kind } => fail(format!(
260                "expected error '{expected}', got {}: {msg}",
261                kind.unwrap_or("an unclassified failure")
262            )),
263            s => s,
264        };
265    } else if reg.expect_fail {
266        outcome = match outcome {
267            Outcome::Pass => fail("expected failure but test passed"),
268            Outcome::Fail { .. } => Outcome::Pass,
269            s => s,
270        };
271    }
272    outcome
273}
274
275/// `RUSTDV_TESTCASE` — run only the tests whose names contain one of these
276/// comma-separated substrings (cocotb's `TESTCASE`, widened from exact names
277/// to substrings so a naming prefix selects a group).
278///
279/// Matching is case-insensitive because the two test forms spell their names
280/// differently: `#[rustdv::test]` on a function registers `conc_two_runs`,
281/// and on a struct it registers the type name, `ConcTwoRuns`. One filter
282/// should select a group whichever form its members happen to take.
283///
284/// A filter matching nothing is an **error**, not an empty pass: a typo in a
285/// regression entry would otherwise look like a green run of zero tests.
286fn apply_testcase_filter(
287    tests: Vec<&'static TestRegistration>,
288) -> Result<Vec<&'static TestRegistration>, String> {
289    let Ok(raw) = std::env::var("RUSTDV_TESTCASE") else { return Ok(tests) };
290    let pats: Vec<String> = raw
291        .split(',')
292        .map(|s| s.trim().to_ascii_lowercase())
293        .filter(|s| !s.is_empty())
294        .collect();
295    if pats.is_empty() {
296        return Ok(tests);
297    }
298    let kept: Vec<_> = tests
299        .into_iter()
300        .filter(|t| {
301            let name = t.name.to_ascii_lowercase();
302            pats.iter().any(|p| name.contains(p))
303        })
304        .collect();
305    if kept.is_empty() {
306        return Err(format!("RUSTDV_TESTCASE={raw} matched no test"));
307    }
308    Ok(kept)
309}
310
311async fn regression() {
312    let tests = match apply_testcase_filter(collect_tests()) {
313        Ok(t) => t,
314        Err(e) => {
315            log::error(&e);
316            println!("REGRESSION: FAIL");
317            gpi::finish();
318            return;
319        }
320    };
321    let seed = seed_from_env();
322    log::info(&format!(
323        "rustdv: found {} test(s), RUSTDV_RANDOM_SEED={seed}",
324        tests.len()
325    ));
326
327    let mut results: Vec<TestResult> = Vec::new();
328    let total = tests.len();
329
330    for (i, reg) in tests.iter().enumerate() {
331        if reg.skip {
332            log::info(&format!("skipping {} ({}/{})", reg.name, i + 1, total));
333            results.push(TestResult { name: reg.name, outcome: Outcome::Skip, sim_ns: 0.0 });
334            continue;
335        }
336        log::info(&format!(
337            "running {} ({}/{})  [{}:{}]",
338            reg.name,
339            i + 1,
340            total,
341            reg.file,
342            reg.line
343        ));
344        let t0 = sim_time_ns();
345        let outcome = run_one(reg, seed.wrapping_add(i as u64)).await;
346        let dt = sim_time_ns() - t0;
347        match &outcome {
348            Outcome::Pass => log::info(&format!("{} PASSED", reg.name)),
349            Outcome::Fail { msg, .. } => log::error(&format!("{} FAILED: {msg}", reg.name)),
350            Outcome::Skip => {}
351        }
352        results.push(TestResult { name: reg.name, outcome, sim_ns: dt });
353    }
354
355    print_summary(&results);
356    write_xunit(&results);
357
358    let failed = results.iter().any(|r| matches!(r.outcome, Outcome::Fail { .. }));
359    println!("REGRESSION: {}", if failed { "FAIL" } else { "PASS" });
360    gpi::finish();
361}
362
363fn print_summary(results: &[TestResult]) {
364    // Port of cocotb's summary table shape (regression.py _log_test_summary).
365    println!("{}", "*".repeat(78));
366    println!("** {:<40} {:>8} {:>14}      **", "TEST", "STATUS", "SIM TIME (ns)");
367    println!("{}", "*".repeat(78));
368    for r in results {
369        let status = match &r.outcome {
370            Outcome::Pass => "PASS",
371            Outcome::Fail { .. } => "FAIL",
372            Outcome::Skip => "SKIP",
373        };
374        println!("** {:<40} {:>8} {:>14.2}      **", r.name, status, r.sim_ns);
375    }
376    println!("{}", "*".repeat(78));
377}
378
379/// xUnit XML (cocotb: _xunit_reporter.py) — written only if
380/// RUSTDV_RESULTS_XML names a path.
381fn write_xunit(results: &[TestResult]) {
382    let Ok(path) = std::env::var("RUSTDV_RESULTS_XML") else { return };
383    let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
384    let failures = results.iter().filter(|r| matches!(r.outcome, Outcome::Fail { .. })).count();
385    let skipped = results.iter().filter(|r| matches!(r.outcome, Outcome::Skip)).count();
386    xml.push_str(&format!(
387        "<testsuites>\n<testsuite name=\"rustdv\" tests=\"{}\" failures=\"{}\" skipped=\"{}\">\n",
388        results.len(),
389        failures,
390        skipped
391    ));
392    for r in results {
393        xml.push_str(&format!(
394            "  <testcase name=\"{}\" time=\"{:.2}\"",
395            r.name, r.sim_ns
396        ));
397        match &r.outcome {
398            Outcome::Pass => xml.push_str("/>\n"),
399            Outcome::Skip => xml.push_str("><skipped/></testcase>\n"),
400            Outcome::Fail { msg: m, .. } => xml.push_str(&format!(
401                "><failure message=\"{}\"/></testcase>\n",
402                m.replace('"', "'").replace('<', "(").replace('>', ")")
403            )),
404        }
405    }
406    xml.push_str("</testsuite>\n</testsuites>\n");
407    if let Err(e) = std::fs::write(&path, xml) {
408        log::warning(&format!("could not write {path}: {e}"));
409    }
410}
411
412// ===========================================================================
413// Bootstrap (§3.2, deviated: VPI-module loading — see crate docs)
414// ===========================================================================
415
416/// Called from `vlog_startup_routines` at VPI module load time (before
417/// elaboration). Registers the start-of-simulation hook; everything else
418/// happens from simulator callbacks.
419pub fn vpi_startup() {
420    let cb = gpi::register_start_of_simulation(Box::new(|| {
421        on_start_of_simulation();
422    }));
423    cb.forget();
424}
425
426fn on_start_of_simulation() {
427    let ex = rustdv_sim::init();
428
429    // Route panics — from tasks and from raw GPI callbacks — into
430    // "fail the current test".
431    let flag = CURRENT_FAILURE.with(|f| f.clone());
432    ex.set_failure_sink(Box::new(move |msg| {
433        let mut slot = flag.borrow_mut();
434        if slot.is_none() {
435            *slot = Some(msg.to_string());
436        }
437    }));
438    let flag2 = CURRENT_FAILURE.with(|f| f.clone());
439    gpi::set_panic_sink(Box::new(move |msg| {
440        let mut slot = flag2.borrow_mut();
441        if slot.is_none() {
442            *slot = Some(format!("panic in simulator callback: {msg}"));
443        }
444    }));
445
446    ex.spawn_named(regression(), Some("rustdv_regression"));
447    ex.run_until_idle();
448}