sv_sim_sim/sched/control.rs
1//! SCHED::CONTROL — the `SchedulerControl` trait, the `Engine` type that
2//! assembles every earlier `sched` task's pieces, and the ABI entry-point
3//! mapping (Task 18, `docs/superpowers/plans/2026-09-23-wave-A-sched.md`;
4//! ledger SC-083, SC-116).
5//!
6//! **Deviations from the plan's literal text (recorded here and in
7//! `docs/CONTRACTS.md`'s r3 Deviations section, item 8):**
8//!
9//! 1. **Resolved (integration, SC-055/SC-056):** `Engine` owns the
10//! [`QueuedGate`] Task 10 introduced. `EngineNotifySink::notify_change`
11//! enqueues through [`sensitivity_match_to_enqueue`] (still Task 9's one
12//! canonical [`SensitivityTable::matches`] underneath, so SC-042's "no
13//! duplicate implementation" holds), and `drain_and_sort` calls
14//! [`QueuedGate::reset_on_dequeue`] for every activation the instant it is
15//! popped off a region queue, strictly before any `resume()`. A process
16//! sensitive to several cells that change in one pass is queued once; a
17//! re-trigger landing after its dequeue queues it again.
18//! 2. **`register_process`'s body does more than the plan's literal two
19//! statements** (`table.register` + the conditional `install_static`
20//! call — that half is unchanged and still the crate's one and only
21//! production call site of `install_static`, SC-045). It additionally
22//! branches on `kind`: a `ProcessKind::Final` process is queued into
23//! `self.finals` (SC-077/SC-078 — `final` blocks run only in the
24//! dedicated post-`run_until` pass, Task 14, never dispatched at t=0);
25//! every other kind gets one `ResumeArg::Start` `PendingActivation` pushed
26//! onto `Region::Active` for the current time. Nothing in this task's own
27//! ledger scope or the plan's prose performs "initial dispatch of
28//! `initial`/`always*` processes at t=0" (scheduler.md §1 item 1c)
29//! anywhere else, and no other piece assembled so far in this crate does
30//! either — so without this addition `run_until`/`step_region` would have
31//! no path to ever call `resume(Start)` on anything, making SC-083's own
32//! "single-step through regions" acceptance unsatisfiable. The plan's own
33//! text pins only the `install_static` call count ("No other line...may
34//! call `install_static` a second time"), not the body's total statement
35//! count, so this is additive, not a violation of that specific pin.
36//! 3. **`Engine::run_until`/`step_region` are real, working implementations
37//! this task ships (not stubs), but intentionally simplified relative to
38//! the eventual full LRM-faithful loop**: `Suspend::WaitEvents` is wired
39//! directly through [`SensitivityTable::install_dynamic`] (which takes an
40//! `EventWaitSet` directly, exactly what `Suspend::WaitEvents` carries, so
41//! no `WaitSpec` bridge is needed) — **and** `dispatch_ready_set` calls
42//! [`SensitivityTable::remove_dynamic`] for exactly the two `ResumeArg`
43//! variants (`EventFired`/`Disabled`) that terminate a dynamic wait,
44//! before `apply_suspend` processes that same call's resulting `Suspend`
45//! (review fix — an earlier draft installed but never removed, leaking
46//! every dynamic wait's sensitivity-table row forever); `Suspend::ForkJoin`
47//! is wired through [`ResumeDriver::handle_fork`] with every spawned child
48//! backed by [`UnmintedForkChild`], an immediately-`Done` placeholder body
49//! — no codegen exists this wave to mint a fork branch's real compiled
50//! kernel (`SpawnSpec::kernel` indexes a `SV_SIM_PROCESS_TABLE` that
51//! doesn't exist yet), so this is D1a's already-established "interim v1
52//! shadow, not a substitute" posture, extended from `ProcessDescriptor`
53//! bookkeeping (Task 11/12) to the `Engine` dispatch loop itself. **Review
54//! fix:** `apply_suspend`'s `Suspend::Done` arm now calls
55//! [`ResumeDriver::on_child_done`] when the completing process is itself a
56//! fork child (`ProcessDescriptor::fork_parent.is_some()`), and queues the
57//! parent's `ResumeArg::ForkChildDone` activation onto `Region::Active`
58//! when the join condition is satisfied — an earlier draft left this
59//! entirely unwired, so `fork`/`join`/`join_any` silently hung forever
60//! (the parent was never reactivated) despite the bookkeeping fields
61//! themselves being real. **Resolved (B-sched wave Task 7, ledger
62//! SC-062/SC-115):** two gaps this deviation note previously left open,
63//! now closed:
64//! - **Fork-child slot reclaim.** `Suspend::Done` is unconditionally
65//! terminal for the completing process (scheduler.md §5): a fork
66//! child's own `ProcessTable` slot is now reclaimed by
67//! [`ResumeDriver::on_child_done`] itself (`resume.rs`'s own doc
68//! comment) the instant it is called for that child — regardless of
69//! whether that particular completion happens to satisfy/
70//! partially-satisfy the parent's join — never left dangling in the
71//! table forever the way it was before this task.
72//! - **`JoinKind::None`'s "parent proceeds immediately, no waiting"
73//! continuation is now wired**, *without* inventing a new
74//! cross-track `ResumeArg` variant meaning "just continue"
75//! (`ResumeArg` remains A2-process/codegen-owned,
76//! `docs/CONTRACTS.md`'s ownership table, untouched by this task): the
77//! `Suspend::ForkJoin` arm, for `JoinKind::None` specifically,
78//! synchronously calls [`ResumeDriver::resume_for`] again for the
79//! same `proc` (never [`ResumeDriver::start`] — `proc` already went
80//! through `Start` once to reach this very `Suspend::ForkJoin` in the
81//! first place, so a second `start()` would be rejected by its own
82//! once-only CAS guard) with the existing `ResumeArg::TimeElapsed`
83//! variant reused as the least-surprising "no special payload" choice,
84//! right in the same `apply_suspend` call that registered the
85//! children — a direct re-entry, never delivered as a resume-with-a-
86//! reason, matching LRM's "proceeds without waiting" (there is
87//! nothing to wait *for*). See `apply_suspend`'s own `Suspend::
88//! ForkJoin` arm for the exact mechanism and
89//! `sc115_join_none_children_run_detached_while_the_parent_proceeds_
90//! without_waiting` for the closing proof. Task 21 (ledger SC-001/
91//! SC-007, its own separate scope, "`Engine` stops being a skeleton")
92//! remains the task chartered to replace `UnmintedForkChild` with real
93//! fork-child kernels once codegen mints them; this task's own tests
94//! exercise `Suspend::Done`/`Suspend::Delay`/`Suspend::WaitEvents`/
95//! `Suspend::ForkJoin` (`JoinKind::All` and `JoinKind::None`)
96//! end-to-end through `run_until`/`step_region`.
97//! 4. **`EngineOps::request_finish` writes directly into `engine.
98//! finish_requested` (a `Mutex<Option<i32>>`), not through Task 16's
99//! [`crate::sched::request_finish_as_deferred_effect`]/`DeferIoDispatcher`
100//! path.** SC-075's literal text asks for `request_finish` to be "queued
101//! as a defer_io-class effect gated on gvt()"; routing through the actual
102//! `DeferIoDispatcher` here would require constructing a *fresh* one per
103//! call (`Engine` has no persistent `DeferIoDispatcher` field — Task 18's
104//! own `defer_io` deliberately bypasses it for the same reason, see this
105//! task's own spec text on `EngineOps::defer_io`), which would silently
106//! reset that fresh dispatcher's own `seq` counter to 0 on every call —
107//! exactly the bug `EngineOps::defer_io` was designed to avoid by writing
108//! straight against `Engine`'s own persistent `io_seq`. Task 21's plan
109//! text explicitly reconciles this: for v1's `SequentialController`
110//! (`gvt() == now` always), a per-timestep `finish_requested` check *is*
111//! SC-075's GVT-gated position requirement, since gvt never lags `now` —
112//! "the reduction is documented here rather than asserted silently".
113//! Task 16's own `request_finish_as_deferred_effect` remains a separate,
114//! independently-tested primitive; it is simply not what `EngineOps`
115//! (the one concrete, production `SchedulerOps` this task adds) calls.
116//! 5. **New `SensitivityTable::entry_count`** (`sched/sensitivity.rs`, Task
117//! 9's file) and **new `Engine::test_sensitivity_entry_count`** — the
118//! plan's own text for the SC-045 exactly-once test names a direct field
119//! read (`self.sensitivity.table.values()...`) that cannot compile from
120//! this module (`SensitivityTable::table` is private to `sensitivity.rs`);
121//! `entry_count` is the minimal, in-track (same `sched` track, additive,
122//! non-breaking) accessor that makes the literal test possible.
123//! 6. **New `DeltaGuard::count` getter (`sched/guard.rs`, Task 15's file)
124//! and new, non-`#[cfg(test)]`, public `Engine::
125//! max_delta_iterations_in_a_timestep`** (Task 21, ledger SC-007) — the
126//! plan's own `region_order_fixture.rs` test text asks to assert "the
127//! delta-loop guard's count never exceeded 3 in any one timestep", but
128//! that integration test is a separate compilation unit under `tests/`
129//! and cannot reach `Engine`'s private `guard` field or any `#[cfg(test)]`
130//! accessor (those only exist in this crate's own unit-test build, per
131//! this module's own established precedent for every other private-field
132//! accessor above). `run_until` now records `DeltaGuard::count()`'s value
133//! into a running high-water mark (`max_delta_iterations_seen`) once per
134//! timestep, right before `guard.reset()` — additive, non-breaking, and
135//! changes no existing behavior or contract type.
136
137use std::sync::Mutex;
138use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
139
140use sv_sim_abi::{Region, SimHandle};
141
142use crate::determinism::{DeterminismController, SequentialController};
143use crate::sched::{
144 CompiledProcess, DEFAULT_MAX_DELTA_ITERATIONS, DeferredIoCallback, DeltaGuard, FinalProcess,
145 IoSink, JoinKind, NbaLog, PendingActivation, ProcessId, ProcessRequest, ProcessState,
146 ProcessTable, QueuedGate, RegionCallback, RegionCallbackRegionError, RegionQueues,
147 RepeatCounters, RepeatOutcome, ResumeArg, ResumeDriver, SchedTime, SchedulerCtx, SchedulerOps,
148 SensitivityTable, SimTick, SpawnSpec, StaticSensitivity, Suspend, TimeWheel, TimerAction,
149 form_ready_set, mailbox, semaphore, sort_ready_set,
150};
151use crate::state::{
152 ArenaSnapshot, ChangeDelta, EdgeKind, FourStateValue, SignalAccess, SnapshotRing, StateCellId,
153 ValueChangeSink,
154};
155
156/// Bound on queued synchronous continuations in one `resume_synchronously`
157/// drain before it is reported as a livelock.
158/// Label `$exit` hands `sv_rt_disable_named` (LRM 24.7): not a block name but
159/// "every program initial thread of the caller's program".
160pub const EXIT_PROGRAM_LABEL: &str = "$exit";
161
162const MAX_SYNC_RESUMES: u32 = 30_000;
163
164/// Fork branches one time step may spawn before it is reported as a livelock:
165/// each live branch is kept until it finishes, so a zero-delay loop that
166/// forks forever (`always fork #0; #1; join_any`) would otherwise exhaust
167/// memory long before the delta guard trips.
168const MAX_FORKS_PER_STEP: u32 = 50_000;
169
170/// `run_until`'s outcome (scheduler.md's `run_until`/`step_region` return
171/// types, refined only as much as this wave's tests need).
172#[derive(Clone, Debug)]
173pub enum RunResult {
174 /// The wheel emptied naturally with no `finish_requested`/deadline hit.
175 Completed,
176 /// `SchedulerOps::request_finish(code)` was observed at a timestep
177 /// boundary (SC-075).
178 Finished { code: i32 },
179 /// `deadline` was reached before the run otherwise completed.
180 Deadline,
181 /// The delta-loop guard tripped (SC-080/DT-050): a zero-delay loop that
182 /// never lets time advance. A named runtime error for the caller to
183 /// report, never a panic; the payload is the guard's message.
184 Livelock(String),
185}
186
187/// `step_region`'s outcome.
188#[derive(Clone, Copy, PartialEq, Eq, Debug)]
189pub enum StepResult {
190 /// The region cursor advanced (work was dispatched, or the wheel
191 /// advanced `now`).
192 Advanced,
193 /// Nothing is queued anywhere and the wheel is empty — the simulation is
194 /// quiescent.
195 Blocked,
196}
197
198/// Mirrors `slang::ast::ProceduralBlockSymbol::procedureKind`'s 6 relevant
199/// values 1:1 (scheduler.md's `ProceduralBlockKindMirror`, never previously
200/// typed — `docs/CONTRACTS.md` r3 item 4).
201#[derive(Clone, Copy, PartialEq, Eq, Debug)]
202pub enum ProcessKind {
203 Initial,
204 Always,
205 AlwaysComb,
206 AlwaysLatch,
207 AlwaysFF,
208 Final,
209}
210
211/// The debugger/VPI-facing control surface (SC-083): run-to-completion,
212/// run-until(time), single-region-step, and process registration.
213pub trait SchedulerControl {
214 /// **Task 9 (A2-part2, CG-102) added `region_id`:** the process's own
215 /// `ProcessSideTable::region_id` (`sv-sim-codegen::process_lower`'s own
216 /// field), threaded through to `ProcessTable::set_region_id` so
217 /// `dispatch_ready_set` can later look it up per-activation via
218 /// `ProcessTable::region_id`. v1's one real production call site
219 /// (`sv-sim-bin::run::run_design`) always passes `0` (ARCHITECTURE §4
220 /// item 1's single-conservative-partition value -- the same "correct
221 /// degenerate v1 value, not a stub" posture `region_id`'s own producing-
222 /// side doc comment already documents).
223 fn register_process(
224 &mut self,
225 proc: Box<dyn CompiledProcess>,
226 kind: ProcessKind,
227 sensitivity: Option<StaticSensitivity>,
228 region_id: u32,
229 ) -> ProcessId;
230 fn run_until(&mut self, deadline: Option<SchedTime>) -> RunResult;
231 fn step_region(&mut self) -> StepResult;
232 fn run_finals(&mut self);
233 fn now(&self) -> SchedTime;
234 fn set_determinism_controller(&mut self, ctrl: Box<dyn DeterminismController>);
235}
236
237/// **Task 9 (A2-part2, CG-102).** The scheduler's own partition identity --
238/// distinct from `sv-sim-sim::analysis::RegionId` (the WOCB static-analysis
239/// pass's own, unimplemented-this-wave partition-assignment output type):
240/// this is the *dispatch-side* value `partition_of` computes FROM a
241/// process's already-assigned `region_id`, never itself assigned by
242/// anything upstream.
243#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
244pub struct PartitionId(pub u32);
245
246/// **Task 9 (A2-part2, CG-102).** `PartitionId(region_id % partition_count.
247/// max(1))` -- a real, general function (directly unit-testable with
248/// `partition_count > 1` to prove it actually reads `region_id`, not a
249/// hardcoded-`0` stub). `dispatch_ready_set`'s one real v1 call site always
250/// passes `partition_count = 1` (ARCHITECTURE §4 item 1's single-
251/// conservative-partition value -- the same citation `region_id`'s own
252/// doc comment uses), so every real v1 dispatch still yields
253/// `PartitionId(0)` for everything -- the same "correct degenerate v1
254/// value, not a stub" posture applied to the *consuming* side, not only
255/// the *producing* side. `.max(1)` makes `partition_count == 0` a defined,
256/// non-panicking degenerate case (one partition) rather than a divide-by-
257/// zero, since nothing about this function's own contract requires a
258/// caller to have already proven `partition_count >= 1`.
259pub fn partition_of(region_id: u32, partition_count: u32) -> PartitionId {
260 PartitionId(region_id % partition_count.max(1))
261}
262
263/// One not-yet-fired [`Engine::region_callbacks`] registration (clippy
264/// `type_complexity`: named here rather than spelled out inline at every
265/// use site).
266type RegionCallbackEntry = (SchedTime, Region, Box<dyn RegionCallback>);
267
268/// A fork child whose real compiled body codegen has not minted yet this
269/// wave (see this module's doc comment, deviation 3): resumes and
270/// immediately reports `Suspend::Done`.
271struct UnmintedForkChild;
272
273impl CompiledProcess for UnmintedForkChild {
274 fn resume(&mut self, _state: SimHandle, _arg: ResumeArg, _ops: &dyn SchedulerOps) -> Suspend {
275 Suspend::Done
276 }
277}
278
279/// sva T1 (§1.3/§1.4): the Preponed-region snapshot ring, plus the closure
280/// that takes a fresh [`ArenaSnapshot`] of whatever concrete, STATE-owned
281/// arena this `Engine`'s own `arena: Box<dyn SignalAccess>` is really
282/// backed by. `sched` deliberately carries no concrete STATE dependency of
283/// its own (this module's doc comment, "sched borrows the SignalAccess
284/// contract but does not implement it") and `SignalAccess` itself exposes
285/// no snapshot/restore capability, so the snapshot-taking closure is
286/// supplied by whoever constructs a real, `StateEngine`-backed `Engine`
287/// (`sv-sim-runtime`, once a later task wires a live design's ring depth in
288/// -- see `state::snapshot`'s own module doc comment, deviation note) via
289/// [`Engine::configure_preponed_ring`], never derived from `dyn
290/// SignalAccess` itself.
291struct PreponedRing {
292 ring: SnapshotRing,
293 source: Box<dyn Fn() -> ArenaSnapshot>,
294}
295
296/// A nonblocking write waiting out its intra-assignment delay
297/// (`Engine::delayed_nbas`).
298struct DelayedNba {
299 cell: StateCellId,
300 row: Option<u64>,
301 value: FourStateValue,
302 mask: Vec<u64>,
303 window: Option<u32>,
304 source: ProcessId,
305 /// A delayed nonblocking event trigger (`->> #d e`): staged as a pulse.
306 pulse: bool,
307 /// A delayed collection-element write (`MaskedNbaUpdate::coll`).
308 coll: Option<super::nba::CollWrite>,
309 /// A delayed class-property write (`MaskedNbaUpdate::obj`).
310 obj: Option<super::nba::ObjWrite>,
311 /// Staged by the Re-NBA region's commit (a Reactive process or a clocking
312 /// drive, LRM 14.16) rather than the NBA region's.
313 reactive: bool,
314}
315
316/// A clocking drive waiting for an edge of its clock signal (`output negedge
317/// #d q`, LRM 1800-2023 14.4): staged `after` ticks past the next `edge` of
318/// `cell`.
319struct EdgeNba {
320 cell: StateCellId,
321 edge: EdgeKind,
322 after: u64,
323 nba: DelayedNba,
324}
325
326impl DelayedNba {
327 /// Stages this write into the NBA or Re-NBA log it belongs to.
328 fn stage_into(self, engine: &Engine) {
329 let mut nba = if self.reactive {
330 &engine.re_nba
331 } else {
332 &engine.nba
333 }
334 .lock()
335 .unwrap();
336 if let Some(w) = self.obj {
337 nba.stage_obj(w, self.source);
338 } else if self.pulse {
339 nba.stage_pulse(self.cell, self.row, self.source);
340 } else if let Some(coll) = self.coll {
341 nba.stage_coll(self.cell, self.row, self.mask, coll, self.source);
342 } else {
343 nba.stage_masked(
344 self.cell,
345 self.row,
346 self.value,
347 self.mask,
348 self.window,
349 self.source,
350 );
351 }
352 }
353}
354
355impl EngineOps<'_> {
356 /// Parks a delayed nonblocking write until `delay` ticks from now, or, for
357 /// the [`EDGE_DELAY`] marker, until the edge the running clocking drive
358 /// named (LRM 14.4 edge output skew).
359 fn defer_nba(&self, delay: u64, d: DelayedNba) {
360 if delay == super::kernel_adapter::EDGE_DELAY {
361 let w = super::kernel_adapter::edge_drive()
362 .expect("an edge-delayed write outside an edge-skewed clocking drive");
363 // 14.16: a drive to the block's own event that executes in the
364 // time step the event was triggered in is coincident with it.
365 if w.on_event
366 && self.engine.clocking_events.lock().unwrap().get(&w.cell) == Some(&self.now.ticks)
367 {
368 if w.after == 0 {
369 d.stage_into(self.engine);
370 } else {
371 self.engine
372 .delayed_nbas
373 .lock()
374 .unwrap()
375 .entry(self.now.ticks + w.after)
376 .or_default()
377 .push(d);
378 }
379 return;
380 }
381 self.engine.edge_nbas.lock().unwrap().push(EdgeNba {
382 cell: w.cell,
383 edge: w.edge,
384 after: w.after,
385 nba: d,
386 });
387 self.engine.has_edge_nbas.store(true, Ordering::Relaxed);
388 return;
389 }
390 self.engine
391 .delayed_nbas
392 .lock()
393 .unwrap()
394 .entry(self.now.ticks + delay)
395 .or_default()
396 .push(d);
397 }
398}
399
400/// The first type in the crate that assembles every earlier task's pieces
401/// together.
402pub struct Engine {
403 queues: Mutex<RegionQueues>,
404 wheel: TimeWheel,
405 nba: Mutex<NbaLog>,
406 /// The Re-NBA region's log (LRM 4.4.2.6 / 4.5): nonblocking assignments
407 /// staged by Reactive-region processes (program blocks, checkers), committed
408 /// only once Re-Active has drained ([`Self::commit_re_nba_once`]). Empty and
409 /// untouched for a design without them.
410 re_nba: Mutex<NbaLog>,
411 /// Whether any Reactive process is registered; gates the per-stage lookup
412 /// that routes an NBA to `re_nba`.
413 has_reactive: std::sync::atomic::AtomicBool,
414 /// Intra-assignment-delayed nonblocking writes (`q <= #d v`), by the
415 /// tick whose time step stages them into `nba` (in staging order).
416 delayed_nbas: Mutex<std::collections::BTreeMap<SimTick, Vec<DelayedNba>>>,
417 /// Edge-skewed clocking drives (`output negedge q`, LRM 14.4) waiting for
418 /// their clock edge; empty (and `has_edge_nbas` false) without any.
419 edge_nbas: Mutex<Vec<EdgeNba>>,
420 /// The tick each clocking block's event counter (by cell) was last bumped
421 /// at ([`SchedulerOps::note_clocking_event`]); empty without clocking blocks.
422 clocking_events: Mutex<std::collections::HashMap<StateCellId, u64>>,
423 has_edge_nbas: std::sync::atomic::AtomicBool,
424 sensitivity: SensitivityTable,
425 /// Per-pass "already queued" dedup (SC-055/SC-056): set when a
426 /// sensitivity match enqueues a process, cleared when that activation is
427 /// popped off its region queue.
428 gate: QueuedGate,
429 /// **NEW (Task 3, ledger SC-052).** Live decrementing counts for every
430 /// `(ProcessId, term_index)` pair mid-`repeat(N)@(...)` wait — see
431 /// `RepeatCounters`'s own doc comment (`sched/repeat.rs`).
432 /// `EngineNotifySink::notify_change` consults it before ever enqueueing
433 /// a `Repeat`-sourced match, and `dispatch_ready_set`'s dynamic-wait-
434 /// resumed cleanup calls `RepeatCounters::clear` alongside
435 /// `SensitivityTable::remove_dynamic`.
436 repeats: RepeatCounters,
437 table: ProcessTable,
438 driver: ResumeDriver,
439 finals: Vec<FinalProcess>,
440 guard: DeltaGuard,
441 /// `sched` borrows the `SignalAccess` *contract* but does not implement
442 /// it — the real arena is STATE-owned and does not exist this wave.
443 arena: Mutex<Box<dyn SignalAccess>>,
444 io_sink: Box<dyn IoSink>,
445 /// Persists for `Engine`'s whole lifetime — `EngineOps::defer_io`
446 /// deliberately writes here directly rather than through a
447 /// freshly-constructed `DeferIoDispatcher` per pass (see this module's
448 /// doc comment, deviation 4).
449 io_seq: AtomicU64,
450 /// Ids for `PendingActivation`s this `Engine` mints (both
451 /// `register_process`'s own initial-dispatch activation and every
452 /// `EngineNotifySink`-minted wakeup).
453 activation_seq: AtomicU64,
454 /// `resume_synchronously`'s trampoline: `Some(queue)` while a drain is
455 /// running (a nested request is queued, not recursed into, so a process
456 /// that forks `join_none` in a zero-delay loop cannot overflow the stack).
457 sync_queue: Option<std::collections::VecDeque<(ProcessId, ResumeArg)>>,
458 /// Set when one trampoline drain exceeded `MAX_SYNC_RESUMES`: a process
459 /// that never blocks (`always fork ... join_none join`), reported by
460 /// `run_until` as `RunResult::Livelock`.
461 sync_livelock: Option<String>,
462 /// Fork branches spawned since time last advanced (`MAX_FORKS_PER_STEP`).
463 forks_this_step: u32,
464 /// `request_finish`'s target; checked once per timestep by `run_until`,
465 /// never mid-pass (SC-075).
466 finish_requested: Mutex<Option<i32>>,
467 /// LRM 24.3: the program blocks' `initial` threads that have not ended
468 /// (see [`Engine::mark_program_initial`]); `None` when the design has no
469 /// program initial, so such designs pay nothing.
470 program_roots: Option<std::collections::HashSet<ProcessId>>,
471 controller: Box<dyn DeterminismController>,
472 now: SchedTime,
473 current_region: Region,
474 /// Called once at the end of every timestep, after the Postponed region
475 /// (waveform dumping samples signal values here -- `$dumpvars`).
476 timestep_end_hook: Option<Box<dyn FnMut(SchedTime)>>,
477 /// The embedder's scheduling hooks (`super::host`), if any.
478 host: Option<Box<dyn super::host::Host>>,
479 /// The tick of the last slot the host's slot-begin hook ran for.
480 host_slot: Option<SimTick>,
481 /// **NEW (Task 21, ledger SC-007) — a monotonically-nondecreasing
482 /// high-water mark of [`DeltaGuard::count`], captured once per timestep
483 /// (in `run_until`, just before `guard.reset()`), never per delta
484 /// iteration.** See [`Engine::max_delta_iterations_in_a_timestep`]'s own
485 /// doc comment for why this exists: SC-007's acceptance requires
486 /// evidence that the Active/Inactive/NBA delta loop actually converges
487 /// (a bounded pass count) for a real design, and `DeltaGuard::count`
488 /// itself was private with no getter before this task, so nothing
489 /// outside this crate's own `#[cfg(test)]` unit tests could observe that
490 /// bound at all — `region_order_fixture.rs` (a separate compilation
491 /// unit under `tests/`) needs exactly this.
492 max_delta_iterations_seen: AtomicU32,
493 /// **NEW (Task 4, ledger SC-090/SC-123/SC-020).** Every not-yet-fired
494 /// `register_region_callback` registration, as `(fire_at, region, cb)`,
495 /// in registration order (a plain `Vec`, not a priority structure — per-
496 /// timestep callback volume is small, and `region` already partitions
497 /// `Reactive` from `Postponed`, the same rationale `RegionQueues` itself
498 /// uses). [`Self::drain_region_callbacks`] removes+fires every entry
499 /// whose `region` matches and whose `fire_at <= self.now`, in the order
500 /// they appear here (registration order) — never resorted.
501 region_callbacks: Mutex<Vec<RegionCallbackEntry>>,
502 /// **NEW (Task 9, B-sched wave, ledger SC-125).** `ProcessId`s
503 /// `EngineOps::disable_fork` queued mid-`resume()` -- `SchedulerOps`
504 /// methods only ever get `&self`, so the actual recursive
505 /// `ProcessTable` mutation is deferred to
506 /// [`Self::drain_pending_disable_fork`], called at the top of
507 /// [`Self::apply_suspend`] (see that method's own doc comment).
508 pending_disable_fork: Mutex<Vec<ProcessId>>,
509 /// The processes `SchedulerOps::spawn_detached` queued (16.11): registered,
510 /// to start in Reactive, by [`Self::drain_pending_spawns`].
511 pending_spawns: Mutex<Vec<Box<dyn CompiledProcess>>>,
512 /// `disable f;` requests from a branch of fork `f` (see
513 /// `SchedulerOps::disable_fork_block`), drained with
514 /// `pending_disable_fork`.
515 pending_disable_block: Mutex<Vec<ProcessId>>,
516 /// `SchedulerOps::disable_named` requests: `(requester, targets)`, where
517 /// `targets` is the already-resolved (at queue time, synchronously --
518 /// see that method's own doc comment) list of live `ProcessId`s a
519 /// cross-process/fork-sibling `disable <label>;` reached. Drained by
520 /// [`Self::drain_pending_disable_named`], the same `&self`-queue /
521 /// `&mut self`-drain split every other `pending_disable_*` field uses.
522 pending_disable_named: Mutex<Vec<(ProcessId, Vec<ProcessId>)>>,
523 /// `SchedulerOps::disable_named` requests for the block or task activation
524 /// `label` of the requester's instance (LRM 9.6.2): `(requester, label)`,
525 /// applied by [`Self::drain_pending_disable_scope`].
526 pending_disable_scope: Mutex<Vec<(ProcessId, String)>>,
527 /// For a process a `disable` redirected (`Self::redirect_process`): the
528 /// id of that redirect's activation. Activations of the process queued
529 /// before it (a delay's timer, an event wake) are stale and dropped at
530 /// dispatch.
531 activation_floor: rustc_hash::FxHashMap<ProcessId, u64>,
532 /// For a process a dispatched `EventFired` activation resumed: the next
533 /// activation id at that moment. Another `EventFired` of the process with
534 /// a smaller id was queued while the wait that activation ended was still
535 /// installed -- a write by an earlier item of the same pass to a second
536 /// signal the wait names (`@(a[j])` also waits on `j`), after the dequeue
537 /// cleared the dedup flag -- so it is the same trigger (LRM 9.4.2: the
538 /// process was already scheduled) and is dropped at dispatch; delivered, it
539 /// would resume the process's NEXT wait early or hit a finished process.
540 /// A wake with an id at or above the floor was queued for a later wait.
541 /// The dispatch also clears the process's dedup flag (see the call site):
542 /// the dropped wake held it set, which would otherwise swallow the first
543 /// real wake of the next wait.
544 event_floor: rustc_hash::FxHashMap<ProcessId, u64>,
545 /// Processes `disable fork;` terminated -- their still-queued
546 /// activations are dropped at dispatch instead of treated as unknown ids.
547 killed: std::collections::HashSet<ProcessId>,
548 /// Classes plan Task 13 (LRM 9.7): `process` class requests queued by
549 /// `EngineOps::process_request` (`(requester, request)`, in call order),
550 /// applied by [`Self::drain_pending_process_requests`] at the top of
551 /// [`Self::apply_suspend`] -- the same `&self`-queue / `&mut self`-drain
552 /// split `pending_disable_fork` uses.
553 pending_process_requests: Mutex<Vec<(ProcessId, ProcessRequest)>>,
554 /// Processes a `process` handle exists for (`SchedulerOps::process_track`).
555 /// Only these get an [`Self::ended`] entry when they end.
556 tracked: Mutex<std::collections::HashSet<ProcessId>>,
557 /// Set by the first `process_track`; gates the per-suspend `waiting`
558 /// bookkeeping so a program that never uses `process` pays nothing.
559 any_tracked: AtomicBool,
560 /// How a tracked process ended. Fork children are reclaimed from the
561 /// table the moment they finish, so this map is the only record that
562 /// answers `status()` and `await()` for them afterwards.
563 ended: std::collections::HashMap<ProcessId, ProcessState>,
564 /// `awaiters[t]`: processes blocked in `t.await()`, woken when `t` ends.
565 awaiters: std::collections::HashMap<ProcessId, Vec<ProcessId>>,
566 /// The active `$monitor`'s argument cells and its changed-since-last-
567 /// print flag (see `SchedulerOps::set_monitor_watch`).
568 monitor_watch: Mutex<std::collections::HashSet<StateCellId>>,
569 /// Mirrors `!monitor_watch.is_empty()` so `notify_change` skips the lock.
570 any_monitor_watch: AtomicBool,
571 monitor_changed: AtomicBool,
572 /// **NEW (sva T1, LRM 16.5.1/16.5.2).** `None` for a design with no
573 /// assertion/clocking-block/`$past` site at all (the "pays nothing"
574 /// guard; see [`PreponedRing`]'s own doc comment and
575 /// `state::snapshot::SnapshotRing`'s). Pushed to once per time step by
576 /// [`Self::push_preponed_snapshot`].
577 preponed: Mutex<Option<PreponedRing>>,
578 /// Classes plan Task 14: the instance scope [`Self::register_process`]
579 /// seeds a new process from (see [`Engine::set_registration_scope`]).
580 registration_scope: Option<u32>,
581 /// Whether the process being registered under `registration_scope` runs a
582 /// static declaration initializer rather than being a thread (see
583 /// [`Engine::set_registration_static_initializer`]).
584 registration_static_initializer: bool,
585 /// **Test-only spy (Task 9, A2-part2, CG-102).** Records every
586 /// `(ProcessId, region_id)` pair `dispatch_ready_set` actually looked up
587 /// via `ProcessTable::region_id` during one dispatch pass --
588 /// `dispatch_ready_set_looks_up_partition_of_using_each_readied_
589 /// processs_own_stored_region_id`'s own seam, proving the field flows
590 /// from registration through to dispatch rather than being
591 /// stored-but-never-read. Only exists in `cfg(test)` builds (this
592 /// crate's own unit tests, which have `&mut Engine`/private-field
593 /// access an external `tests/` crate does not) -- never part of this
594 /// type's real, non-test shape.
595 #[cfg(test)]
596 region_id_queries_seen: Vec<(ProcessId, u32)>,
597}
598
599/// The concrete `SchedulerOps` implementation, constructed once per
600/// region-pass (see this module's doc comment / `dispatch_ready_set`). `now`/
601/// `region`/`reconv_iter` are genuinely constant for every `WorkItem` one
602/// region-pass dispatches.
603struct EngineOps<'e> {
604 engine: &'e Engine,
605 now: SchedTime,
606 region: Region,
607 reconv_iter: u32,
608}
609
610impl SchedulerOps for EngineOps<'_> {
611 fn now(&self) -> SchedTime {
612 self.now
613 }
614
615 fn pending_ticks(&self) -> Vec<u64> {
616 let mut ticks = self.engine.wheel.pending_ticks();
617 ticks.extend(self.engine.delayed_nbas.lock().unwrap().keys().copied());
618 ticks.into_iter().filter(|&t| t > self.now.ticks).collect()
619 }
620
621 fn waiting_processes(&self, cell: StateCellId) -> Vec<ProcessId> {
622 self.engine.sensitivity.waiting_on(cell)
623 }
624
625 fn current_region(&self) -> Region {
626 self.region
627 }
628
629 fn note_reactive_drive(&self) {
630 self.engine.has_reactive.store(true, Ordering::Relaxed);
631 }
632
633 fn note_clocking_event(&self, counter: StateCellId) {
634 self.engine
635 .clocking_events
636 .lock()
637 .unwrap()
638 .insert(counter, self.now.ticks);
639 }
640
641 fn stage_nba(&self, cell: StateCellId, value: FourStateValue, source: ProcessId) {
642 self.engine
643 .nba_for(source)
644 .lock()
645 .unwrap()
646 .stage(cell, value, source);
647 }
648
649 fn stage_nba_masked(
650 &self,
651 cell: StateCellId,
652 row: Option<u64>,
653 value: FourStateValue,
654 mask: Vec<u64>,
655 source: ProcessId,
656 ) {
657 self.engine
658 .nba_for(source)
659 .lock()
660 .unwrap()
661 .stage_masked(cell, row, value, mask, None, source);
662 }
663
664 fn stage_nba_window(
665 &self,
666 cell: StateCellId,
667 window: u32,
668 value: FourStateValue,
669 mask: Vec<u64>,
670 source: ProcessId,
671 delay: u64,
672 ) {
673 if delay == 0 {
674 self.engine.nba_for(source).lock().unwrap().stage_masked(
675 cell,
676 None,
677 value,
678 mask,
679 Some(window),
680 source,
681 );
682 return;
683 }
684 self.defer_nba(
685 delay,
686 DelayedNba {
687 cell,
688 row: None,
689 value,
690 mask,
691 window: Some(window),
692 source,
693 pulse: false,
694 coll: None,
695 obj: None,
696 reactive: self.engine.stages_reactive(source),
697 },
698 );
699 }
700
701 fn stage_nba_masked_delayed(
702 &self,
703 cell: StateCellId,
704 row: Option<u64>,
705 value: FourStateValue,
706 mask: Vec<u64>,
707 source: ProcessId,
708 delay: u64,
709 ) {
710 if delay == 0 {
711 return self.stage_nba_masked(cell, row, value, mask, source);
712 }
713 self.defer_nba(
714 delay,
715 DelayedNba {
716 cell,
717 row,
718 value,
719 mask,
720 window: None,
721 source,
722 pulse: false,
723 coll: None,
724 obj: None,
725 reactive: self.engine.stages_reactive(source),
726 },
727 );
728 }
729
730 fn stage_nba_coll(
731 &self,
732 cell: StateCellId,
733 row: Option<u64>,
734 elem: Vec<u64>,
735 coll: super::nba::CollWrite,
736 source: ProcessId,
737 delay: u64,
738 ) {
739 if delay == 0 {
740 self.engine
741 .nba_for(source)
742 .lock()
743 .unwrap()
744 .stage_coll(cell, row, elem, coll, source);
745 return;
746 }
747 self.defer_nba(
748 delay,
749 DelayedNba {
750 cell,
751 row,
752 value: FourStateValue::Narrow(0),
753 mask: elem,
754 window: None,
755 source,
756 pulse: false,
757 coll: Some(coll),
758 obj: None,
759 reactive: self.engine.stages_reactive(source),
760 },
761 );
762 }
763
764 fn stage_nba_obj(&self, w: super::nba::ObjWrite, source: ProcessId, delay: u64) {
765 if delay == 0 {
766 self.engine
767 .nba_for(source)
768 .lock()
769 .unwrap()
770 .stage_obj(w, source);
771 return;
772 }
773 self.defer_nba(
774 delay,
775 DelayedNba {
776 cell: StateCellId(0),
777 row: None,
778 value: FourStateValue::Narrow(0),
779 mask: Vec::new(),
780 window: None,
781 source,
782 pulse: false,
783 coll: None,
784 obj: Some(w),
785 reactive: self.engine.stages_reactive(source),
786 },
787 );
788 }
789
790 fn stage_nba_pulse(&self, cell: StateCellId, row: Option<u64>, source: ProcessId, delay: u64) {
791 if delay == 0 {
792 self.engine
793 .nba_for(source)
794 .lock()
795 .unwrap()
796 .stage_pulse(cell, row, source);
797 return;
798 }
799 self.defer_nba(
800 delay,
801 DelayedNba {
802 cell,
803 row,
804 value: FourStateValue::Narrow(0),
805 mask: Vec::new(),
806 window: None,
807 source,
808 pulse: true,
809 coll: None,
810 obj: None,
811 reactive: self.engine.stages_reactive(source),
812 },
813 );
814 }
815
816 fn commit_blocking_write(&self, cell: StateCellId, value: FourStateValue) {
817 let mut arena = self.engine.arena.lock().unwrap();
818 let mut sink = EngineNotifySink {
819 engine: self.engine,
820 at: self.now,
821 reconv_iter: self.reconv_iter,
822 delta: None,
823 };
824 let _ = crate::sched::commit_blocking_write(cell, value, &mut **arena, self.now, &mut sink);
825 }
826
827 fn notify_blocking_row_change(&self, cell: StateCellId) {
828 let mut sink = EngineNotifySink {
829 engine: self.engine,
830 at: self.now,
831 reconv_iter: self.reconv_iter,
832 delta: None,
833 };
834 crate::sched::notify_blocking_change(cell, EdgeKind::None, self.now, &mut sink);
835 let mut arena = self.engine.arena.lock().unwrap();
836 crate::sched::notify_alias_changes(&mut **arena, self.now, &mut sink);
837 }
838
839 fn notify_blocking_wide_change(
840 &self,
841 cell: StateCellId,
842 edge: EdgeKind,
843 delta: Option<ChangeDelta>,
844 ) {
845 let mut sink = EngineNotifySink {
846 engine: self.engine,
847 at: self.now,
848 reconv_iter: self.reconv_iter,
849 delta,
850 };
851 crate::sched::notify_blocking_change(cell, edge, self.now, &mut sink);
852 }
853
854 fn wants_delta(&self, cell: StateCellId) -> bool {
855 self.engine.sensitivity.has_select(cell)
856 }
857
858 fn request_finish(&self, code: i32) {
859 // See this module's doc comment, deviation 4.
860 *self.engine.finish_requested.lock().unwrap() = Some(code);
861 }
862
863 fn defer_io(&self, at: SchedTime, region: Region, cb: DeferredIoCallback) {
864 // Textually identical to `DeferIoDispatcher::defer_io`'s body,
865 // deliberately not reused through one (see this module's doc
866 // comment / the plan's own text): a fresh `DeferIoDispatcher`
867 // reconstructed per pass would reset `seq` to 0 every pass.
868 let seq = self.engine.io_seq.fetch_add(1, Ordering::SeqCst);
869 self.engine.io_sink.enqueue(at, region, seq, cb);
870 }
871
872 /// **NEW (Task 4, ledger SC-090/SC-123).** The one real production
873 /// implementation — forwards straight to `Engine::push_region_callback`
874 /// (see that method's own doc comment for why it is a plain, always-
875 /// available `Engine` method rather than `#[cfg(test)]`-gated: this impl
876 /// is its one production call site, and this module's own test setup
877 /// code calls it directly too, for registrations made before `run_until`
878 /// starts rather than from inside a dispatched process's `resume()`).
879 ///
880 /// **Review fix (Task 4 r1):** the `SchedulerOps::register_region_callback`
881 /// trait signature is a cross-track contract (many `#[cfg(test)]` mock
882 /// implementations outside this crate, per that trait method's own doc
883 /// comment) and returns `()`, so it cannot forward
884 /// `push_region_callback`'s new typed `Result` to its caller. `.expect`
885 /// turns `RegionCallbackRegionError` into an immediate, loud panic here
886 /// instead — never a silent, forever-unfired registration — at this
887 /// trait's one production implementation.
888 fn register_region_callback(&self, region: Region, at: SchedTime, cb: Box<dyn RegionCallback>) {
889 self.engine.push_region_callback(at, region, cb).expect(
890 "SchedulerOps::register_region_callback: region must be Reactive, Postponed, or \
891 Observed -- Active/Inactive/Nba/Preponed are never drained by \
892 drain_region_callbacks and a callback registered for one would never fire",
893 );
894 }
895
896 /// **NEW (Task 9, ledger SC-125).** The one real override of
897 /// `SchedulerOps::disable_fork`'s default no-op (see that trait
898 /// method's own doc comment for why it is a default, not a required
899 /// method, this wave). Only queues `proc` into `Engine::
900 /// pending_disable_fork` -- this call only ever has `&self` (`EngineOps`
901 /// holds a shared `&'e Engine`), so the real recursive `ProcessTable`
902 /// mutation happens in `Engine::drain_pending_disable_fork`, called at
903 /// the top of `Engine::apply_suspend`, which always runs with `&mut
904 /// self` immediately after the `resume()` call (this one included)
905 /// that might have queued it.
906 fn disable_fork(&self, proc: ProcessId) {
907 self.engine.pending_disable_fork.lock().unwrap().push(proc);
908 }
909
910 fn disable_fork_block(&self, proc: ProcessId) {
911 self.engine.pending_disable_block.lock().unwrap().push(proc);
912 }
913
914 fn spawn_detached(&self, proc: Box<dyn CompiledProcess>) {
915 self.engine.pending_spawns.lock().unwrap().push(proc);
916 }
917
918 /// `SchedulerOps::disable_named`'s one real override: resolves the
919 /// target set and the `requester`-ends-too question synchronously (a
920 /// read of the still-untouched `ProcessTable`, exactly like
921 /// `process_request`'s own `ProcessRequest::Kill` arm), then queues the
922 /// resolved targets for `Engine::drain_pending_disable_named` to kill.
923 fn disable_named(&self, requester: ProcessId, label: &str) -> bool {
924 let root = self.engine.fork_root(requester);
925 let mut targets = Vec::new();
926 if label == EXIT_PROGRAM_LABEL {
927 // `$exit` (LRM 24.7) is only lowered inside a program, so the
928 // caller is itself a program thread and ends; the other threads
929 // of its program are found in `drain_pending_disable_scope` (the
930 // caller's compiled process is locked while it runs).
931 self.engine
932 .pending_disable_scope
933 .lock()
934 .unwrap()
935 .push((requester, label.to_string()));
936 return true;
937 }
938 self.engine.collect_disable_named(root, label, &mut targets);
939 let ends_caller = targets
940 .iter()
941 .any(|&t| t == requester || self.engine.is_in_fork_subtree(requester, t));
942 self.engine
943 .pending_disable_named
944 .lock()
945 .unwrap()
946 .push((requester, targets));
947 // ... and the processes of the instance suspended inside the block or
948 // task `label` (LRM 9.6.2) resume after it.
949 self.engine
950 .pending_disable_scope
951 .lock()
952 .unwrap()
953 .push((requester, label.to_string()));
954 ends_caller
955 }
956
957 fn wake_process(&self, proc: ProcessId, term: usize) {
958 let act = PendingActivation {
959 id: self.engine.activation_seq.fetch_add(1, Ordering::SeqCst),
960 proc,
961 arg: ResumeArg::EventFired { term_index: term },
962 reconv_iter: 0,
963 logical_order: (self.engine.now, Region::Active, proc as u64),
964 };
965 self.engine
966 .queues
967 .lock()
968 .unwrap()
969 .push(Region::Active, act)
970 .expect("Region::Active is never rejected by RegionQueues::push");
971 }
972
973 fn set_monitor_watch(&self, cells: Vec<StateCellId>) {
974 let mut watch = self.engine.monitor_watch.lock().unwrap();
975 *watch = cells.into_iter().collect();
976 self.engine
977 .any_monitor_watch
978 .store(!watch.is_empty(), Ordering::Relaxed);
979 drop(watch);
980 self.engine.monitor_changed.store(true, Ordering::SeqCst);
981 }
982
983 /// Classes plan Task 13: queues `req` (applied at the requester's next
984 /// `apply_suspend`) and reports whether it ends the requester itself.
985 fn process_request(&self, requester: ProcessId, req: ProcessRequest) -> bool {
986 let kills_requester = match req {
987 ProcessRequest::Kill(target) => self.engine.is_in_fork_subtree(requester, target),
988 ProcessRequest::Suspend(_)
989 | ProcessRequest::Resume(_)
990 | ProcessRequest::Await(_)
991 | ProcessRequest::SyncBlock
992 | ProcessRequest::SyncWake(_) => false,
993 };
994 self.engine
995 .pending_process_requests
996 .lock()
997 .unwrap()
998 .push((requester, req));
999 kills_requester
1000 }
1001
1002 fn process_state(&self, target: ProcessId) -> ProcessState {
1003 self.engine.effective_process_state(target)
1004 }
1005
1006 fn process_track(&self, proc: ProcessId) {
1007 self.engine.tracked.lock().unwrap().insert(proc);
1008 self.engine.any_tracked.store(true, Ordering::SeqCst);
1009 }
1010}
1011
1012/// The concrete [`SchedulerCtx`] implementation
1013/// [`Engine::drain_region_callbacks`] constructs to invoke each due
1014/// [`RegionCallback`] — read-only, backed by `Engine`'s own arena `Mutex`
1015/// (mirrors [`EngineNotifySink`]'s identical shape/rationale).
1016struct EngineSchedulerCtx<'e> {
1017 engine: &'e Engine,
1018 now: SchedTime,
1019}
1020
1021impl SchedulerCtx for EngineSchedulerCtx<'_> {
1022 fn now(&self) -> SchedTime {
1023 self.now
1024 }
1025
1026 fn read(&self, cell: StateCellId) -> FourStateValue {
1027 self.engine.arena.lock().unwrap().read(cell)
1028 }
1029
1030 fn rearm(&self, region: Region, cb: Box<dyn RegionCallback>) {
1031 self.engine
1032 .push_region_callback(self.now, region, cb)
1033 .expect("RegionCallback::invoke re-armed for a region Engine never drains");
1034 }
1035
1036 fn take_monitor_changed(&self) -> bool {
1037 self.engine.monitor_changed.swap(false, Ordering::SeqCst)
1038 }
1039}
1040
1041/// The concrete `ValueChangeSink` implementation `EngineOps::
1042/// commit_blocking_write` and `Engine::commit_nba_once` construct to receive
1043/// `notify_change` calls during a commit.
1044struct EngineNotifySink<'e> {
1045 engine: &'e Engine,
1046 at: SchedTime,
1047 reconv_iter: u32,
1048 /// The old/new words of the update the next `notify_change` reports, when
1049 /// a select waiter needs them (`ValueChangeSink::note_delta`).
1050 delta: Option<ChangeDelta>,
1051}
1052
1053impl ValueChangeSink for EngineNotifySink<'_> {
1054 fn wants_delta(&self, cell: StateCellId) -> bool {
1055 self.engine.sensitivity.has_select(cell)
1056 }
1057
1058 fn note_delta(&mut self, _cell: StateCellId, delta: ChangeDelta) {
1059 self.delta = Some(delta);
1060 }
1061
1062 /// **Task 3 (ledger SC-052) rewiring:** `SensitivityTable::matches` now
1063 /// yields `(ProcessId, term_index, repeat_total, iff)`, not just
1064 /// `ProcessId` -- this is the one production call site that needs the
1065 /// extra fields (SC-055's dedup gate does not, so this no longer
1066 /// delegates to `dedup::sensitivity_match_to_enqueue`, which only ever
1067 /// returns a bare `Vec<ProcessId>`; `SensitivityTable::matches` itself
1068 /// is still Task 9's one canonical matching implementation underneath --
1069 /// SC-042's "no duplicate implementation" is unaffected).
1070 ///
1071 /// **Task 5 (ledger SC-068) addition:** a match whose row carries
1072 /// `iff: Some(pred)` is filtered FIRST, before repeat-counting -- `pred`
1073 /// is invoked (via the same `SimHandle(null)` placeholder every other
1074 /// `CompiledProcess::resume()`/`RegionCallback` call site in this module
1075 /// already uses, since this crate holds no real ABI-facing state
1076 /// pointer of its own) and a `false` result drops the firing entirely,
1077 /// exactly as if the edge/cell condition had never matched at all: no
1078 /// `RepeatCounters::on_term_fired` call (an `iff`-false edge must not
1079 /// count toward a `repeat(N)@(... iff ...)`'s count either), no
1080 /// dedup-gate mark, no enqueue, and the sensitivity entry stays
1081 /// installed. This is `PredicateFn`'s settled, binding calling
1082 /// convention (`docs/CONTRACTS.md` D8) -- a bare
1083 /// `extern "C" fn(SimHandle) -> bool` produced per-callsite by codegen's
1084 /// `compile_iff_guard`, which reads whatever cell(s) it needs itself
1085 /// through the handle; this call site never passes a `StateCellId`
1086 /// alongside it.
1087 ///
1088 /// For each `iff`-passing (or `iff`-less) match whose row carries
1089 /// `repeat_total: Some(n)` (an `EventTerm::Repeat`-sourced row), the
1090 /// firing is routed through `RepeatCounters::on_term_fired` next: on
1091 /// `StillWaiting` this firing is swallowed -- no dedup-gate mark, no
1092 /// enqueue, and the sensitivity entry stays installed (nothing here or
1093 /// on any other path removes it for a non-terminal firing). Only a
1094 /// `repeat_total: None` row, or a `Some` row that just reported
1095 /// `Satisfied`, proceeds to the existing SC-055 dedup-gate-then-enqueue
1096 /// step.
1097 fn notify_change(
1098 &mut self,
1099 cell: StateCellId,
1100 edge: EdgeKind,
1101 _at: sv_sim_abi::SimTime,
1102 _region: Region,
1103 ) {
1104 // Lock-free fast path: the watch set is empty unless a `$monitor` is
1105 // active, which is the common case for the ~10^7 calls of a long run.
1106 if self.engine.any_monitor_watch.load(Ordering::Relaxed)
1107 && self.engine.monitor_watch.lock().unwrap().contains(&cell)
1108 {
1109 self.engine.monitor_changed.store(true, Ordering::SeqCst);
1110 // Under LRM 21.2.3's expression-value test, re-evaluate the
1111 // monitor's arguments at this update (a glitch that restores the
1112 // value within the step still owes a display).
1113 crate::sched::kernel_adapter::monitor_probe();
1114 }
1115 if self.engine.has_edge_nbas.load(Ordering::Relaxed) {
1116 self.engine.fire_edge_nbas(cell, edge);
1117 }
1118 let handle = SimHandle(std::ptr::null_mut());
1119 // The process making this write is executing, not waiting: it is
1120 // never woken by its own change (LRM 1800-2023 4.7, 9.2.2.2: e.g. an
1121 // `always_comb` writing an array element it also reads must not
1122 // re-trigger itself through its static sensitivity).
1123 let writer = crate::sched::current_process();
1124 // Each woken process with the index of the wait term that woke it
1125 // (`wait_order` reads it, `sv_rt_fired_term`).
1126 let delta = self.delta.take();
1127 let matched: smallvec::SmallVec<[(ProcessId, usize); 8]> = self
1128 .engine
1129 .sensitivity
1130 .matches_delta(cell, edge, delta.as_ref())
1131 .filter(|(proc, _, _, _)| Some(*proc) != writer)
1132 .filter(|(_, _, _, iff)| match iff {
1133 Some(pred) => (pred.0)(handle),
1134 None => true,
1135 })
1136 // Every matched term is recorded for the resume guard of an event
1137 // list (`sv_rt_fired_mask`), including those of a process this
1138 // pass finds already queued: several members can fire in one wake
1139 // (LRM 9.4.2.3) and the guard must see them all.
1140 .inspect(|(proc, term_index, _, _)| {
1141 crate::sched::kernel_adapter::note_fired_term(*proc, *term_index);
1142 })
1143 .filter_map(
1144 |(proc, term_index, repeat_total, _iff)| match repeat_total {
1145 Some(declared_total) => {
1146 match self
1147 .engine
1148 .repeats
1149 .on_term_fired(proc, term_index, declared_total)
1150 {
1151 RepeatOutcome::StillWaiting => None,
1152 RepeatOutcome::Satisfied => Some((proc, term_index)),
1153 }
1154 }
1155 None => Some((proc, term_index)),
1156 },
1157 )
1158 // SC-055: only processes not already queued this pass come back.
1159 .filter(|&(proc, _)| self.engine.gate.mark_queued_if_not_already(proc))
1160 .collect();
1161 if matched.is_empty() {
1162 return;
1163 }
1164 let mut queues = self.engine.queues.lock().unwrap();
1165 for (proc, term_index) in matched {
1166 let id = self.engine.activation_seq.fetch_add(1, Ordering::SeqCst);
1167 // LRM 17.7.3: a checker's processes are sensitive "in the Reactive
1168 // region" -- their wake-ups wait for Active/Inactive/NBA (and the
1169 // Observed region) to settle, so they see only settled values.
1170 let home = if self.engine.table.is_reactive(proc) {
1171 Region::Reactive
1172 } else {
1173 Region::Active
1174 };
1175 let act = PendingActivation {
1176 id,
1177 proc,
1178 arg: ResumeArg::EventFired { term_index },
1179 reconv_iter: self.reconv_iter,
1180 logical_order: (self.at, home, proc as u64),
1181 };
1182 // Active and Reactive are regions `RegionQueues::push` never
1183 // rejects (`RegionQueuePushError::NbaHasNoQueue` is the only
1184 // variant that can fire for them, and only for `Region::Nba`).
1185 queues.push(home, act).expect(
1186 "Region::Active and Region::Reactive are never rejected by RegionQueues::push",
1187 );
1188 }
1189 }
1190}
1191
1192impl Engine {
1193 /// `sched` borrows the `SignalAccess`/`IoSink` *contracts* but does not
1194 /// implement them this wave — dependency-injecting both is what lets
1195 /// `Engine` be genuinely constructible and testable without this crate
1196 /// depending on a STATE/RUNTIME implementation. `controller` starts as
1197 /// `SequentialController::default_boxed()` (Task 3), overridable via
1198 /// `set_determinism_controller`.
1199 pub fn new(arena: Box<dyn SignalAccess>, io: Box<dyn IoSink>) -> Self {
1200 Engine {
1201 queues: Mutex::new(RegionQueues::new()),
1202 wheel: TimeWheel::new(),
1203 nba: Mutex::new(NbaLog::new()),
1204 re_nba: Mutex::new(NbaLog::new()),
1205 has_reactive: std::sync::atomic::AtomicBool::new(false),
1206 delayed_nbas: Mutex::new(std::collections::BTreeMap::new()),
1207 edge_nbas: Mutex::new(Vec::new()),
1208 clocking_events: Mutex::new(std::collections::HashMap::new()),
1209 has_edge_nbas: std::sync::atomic::AtomicBool::new(false),
1210 sensitivity: SensitivityTable::new(),
1211 gate: QueuedGate::new(),
1212 repeats: RepeatCounters::new(),
1213 table: ProcessTable::new(),
1214 driver: ResumeDriver,
1215 finals: Vec::new(),
1216 guard: DeltaGuard::new(DEFAULT_MAX_DELTA_ITERATIONS),
1217 arena: Mutex::new(arena),
1218 io_sink: io,
1219 io_seq: AtomicU64::new(0),
1220 activation_seq: AtomicU64::new(0),
1221 sync_queue: None,
1222 sync_livelock: None,
1223 forks_this_step: 0,
1224 finish_requested: Mutex::new(None),
1225 program_roots: None,
1226 controller: SequentialController::default_boxed(),
1227 now: SchedTime { ticks: 0 },
1228 current_region: Region::Active,
1229 timestep_end_hook: None,
1230 host: None,
1231 host_slot: None,
1232 max_delta_iterations_seen: AtomicU32::new(0),
1233 region_callbacks: Mutex::new(Vec::new()),
1234 pending_disable_fork: Mutex::new(Vec::new()),
1235 pending_spawns: Mutex::new(Vec::new()),
1236 pending_disable_block: Mutex::new(Vec::new()),
1237 pending_disable_named: Mutex::new(Vec::new()),
1238 pending_disable_scope: Mutex::new(Vec::new()),
1239 activation_floor: rustc_hash::FxHashMap::default(),
1240 event_floor: rustc_hash::FxHashMap::default(),
1241 killed: std::collections::HashSet::new(),
1242 pending_process_requests: Mutex::new(Vec::new()),
1243 tracked: Mutex::new(std::collections::HashSet::new()),
1244 any_tracked: AtomicBool::new(false),
1245 ended: std::collections::HashMap::new(),
1246 awaiters: std::collections::HashMap::new(),
1247 monitor_watch: Mutex::new(std::collections::HashSet::new()),
1248 any_monitor_watch: AtomicBool::new(false),
1249 monitor_changed: AtomicBool::new(false),
1250 preponed: Mutex::new(None),
1251 registration_scope: None,
1252 registration_static_initializer: false,
1253 #[cfg(test)]
1254 region_id_queries_seen: Vec::new(),
1255 }
1256 }
1257
1258 /// Classes plan Task 14 (LRM 18.14.1): names the instance scope whose
1259 /// initialization RNG seeds every process registered from now on
1260 /// (`None`, the default, seeds nothing: the process draws from the
1261 /// unseeded default like any process with no known parent). The
1262 /// production caller sets it around the user's procedural blocks only,
1263 /// so the infrastructure processes registered elsewhere (net resolvers,
1264 /// samplers, ...) never advance an instance's initialization RNG and so
1265 /// never shift the seed any user process gets.
1266 pub fn set_registration_scope(&mut self, scope: Option<u32>) {
1267 self.registration_scope = scope;
1268 }
1269
1270 /// While set (and a [`Self::set_registration_scope`] scope is active), a
1271 /// process registered runs a static declaration initializer (`int x =
1272 /// f();`, `C h = new;`) rather than being a thread of its own: LRM 18.14.1
1273 /// gives such code no thread, so it draws from the instance's
1274 /// initialization RNG directly ([`process_rng::alias_init`]) instead of
1275 /// being seeded from it.
1276 pub fn set_registration_static_initializer(&mut self, on: bool) {
1277 self.registration_static_initializer = on;
1278 }
1279
1280 /// Installs the end-of-timestep hook (see the field's doc comment),
1281 /// replacing any previous one.
1282 pub fn set_timestep_end_hook(&mut self, hook: Box<dyn FnMut(SchedTime)>) {
1283 self.timestep_end_hook = Some(hook);
1284 }
1285
1286 /// **NEW (sva T1, LRM 16.5.1/16.5.2).** Installs a Preponed-region
1287 /// snapshot ring of the given `depth` (`state::snapshot::SnapshotRing`'s
1288 /// own doc comment), sourced by calling `source` once per time step.
1289 /// Replaces any previously configured ring. Never called at all for a
1290 /// design with no assertion/clocking-block/`$past` site -- [`Self::
1291 /// push_preponed_snapshot`] stays a no-op (no allocation, `source`
1292 /// never invoked) until this is.
1293 pub fn configure_preponed_ring(
1294 &mut self,
1295 depth: usize,
1296 source: impl Fn() -> ArenaSnapshot + 'static,
1297 ) {
1298 *self.preponed.lock().unwrap() = Some(PreponedRing {
1299 ring: SnapshotRing::new(depth),
1300 source: Box::new(source),
1301 });
1302 }
1303
1304 /// Pushes this time step's Preponed snapshot (16.5.1) by invoking the
1305 /// closure [`Self::configure_preponed_ring`] installed -- a no-op when
1306 /// no ring is configured at all (see [`Self::preponed`]'s own doc
1307 /// comment). Called exactly once for every genuinely new nonempty time
1308 /// slot the wheel advances into -- never for the engine's initial
1309 /// `Region::Active` entry at time 0, which neither driver API precedes
1310 /// with a push (`Engine::new` sets `current_region = Active` directly).
1311 /// `run_until`'s outer loop calls this right after `advance_time_to`,
1312 /// in the `Some(t)` arm of its own `next_event_tick()` match (sva T1
1313 /// §1.4: "right where it currently detects a new nonempty time slot");
1314 /// the single-stepping API folds the identical call into
1315 /// [`Self::step_region`]'s `Postponed -> Active` transition, on the same
1316 /// `next_event_tick() == Some` branch (see that method's own doc
1317 /// comment) -- the two call sites are kept in lock-step so a caller
1318 /// driving a given `Engine` through either API (never both interleaved,
1319 /// the same assumption [`Self::current_region`]'s own doc comment
1320 /// already makes for the single-stepper) ends up with the same Preponed
1321 /// ring for the same simulated history. See
1322 /// `run_until_and_step_region_push_the_same_preponed_ring` for the
1323 /// regression proof.
1324 fn push_preponed_snapshot(&self) {
1325 let mut guard = self.preponed.lock().unwrap();
1326 if let Some(p) = guard.as_mut() {
1327 let snap = (p.source)();
1328 p.ring.push(snap);
1329 }
1330 }
1331
1332 /// Test-only read access into the configured Preponed ring (see
1333 /// [`Self::configure_preponed_ring`]) -- `f` receives `None` when no
1334 /// ring is configured at all, or when `ticks_back` is out of the
1335 /// configured ring's own retained range (`SnapshotRing::get`'s own doc
1336 /// comment). Takes a closure rather than returning `Option<&ArenaSnapshot>`
1337 /// directly since the `Mutex` guard cannot outlive this call.
1338 #[cfg(test)]
1339 fn with_preponed_entry<R>(
1340 &self,
1341 ticks_back: usize,
1342 f: impl FnOnce(Option<&ArenaSnapshot>) -> R,
1343 ) -> R {
1344 let guard = self.preponed.lock().unwrap();
1345 f(guard.as_ref().and_then(|p| p.ring.get(ticks_back)))
1346 }
1347
1348 /// Test-only accessor for [`Self::region_id_queries_seen`] -- see that
1349 /// field's own doc comment.
1350 #[cfg(test)]
1351 fn region_id_queries_seen(&self) -> &[(ProcessId, u32)] {
1352 &self.region_id_queries_seen
1353 }
1354
1355 /// **NEW (Task 21, ledger SC-007).** The maximum [`DeltaGuard::count`]
1356 /// this `Engine` has observed at the settled end of any one timestep's
1357 /// inner Active/Inactive/NBA convergence loop, across its whole run so
1358 /// far — a high-water mark, not the current timestep's own count (which
1359 /// is always 0 immediately after `run_until` returns, since `guard.
1360 /// reset()` runs every timestep). Existence-proof surface for SC-007's
1361 /// "region-stratified delta-cycle convergence" acceptance: a caller can
1362 /// assert this stayed low for a design that should settle in a handful
1363 /// of passes, catching an accidental unbounded-oscillation regression
1364 /// well before it would ever hit `DEFAULT_MAX_DELTA_ITERATIONS`.
1365 pub fn max_delta_iterations_in_a_timestep(&self) -> u32 {
1366 self.max_delta_iterations_seen.load(Ordering::SeqCst)
1367 }
1368
1369 /// Testability getter (DT-007's "selected by default when no other
1370 /// controller is configured", made externally observable): see
1371 /// `DeterminismController::kind`.
1372 pub fn controller_kind(&self) -> &'static str {
1373 self.controller.kind()
1374 }
1375
1376 /// **NEW (A2-part2 Task 5, ledger CG-162):** exposes the region
1377 /// [`Self::step_region`] is about to process next -- needed so the ABI's
1378 /// `sim_eval_region(requested_region, ..)` (`sv-sim-runtime::
1379 /// abi_entry`) can single-step exactly up to (but never past)
1380 /// `requested_region`, and, for `Region::Nba` specifically, stop there
1381 /// WITHOUT calling `step_region`/[`Self::advance_nba_region`] at all:
1382 /// RHS evaluation for any pending NBA writes already happened during
1383 /// the prior Active/Inactive dispatch passes (`nba_enqueue` only ever
1384 /// stages into `NbaLog`, never touches the arena -- see
1385 /// `EngineOps::stage_nba`), so observing "we have arrived at the Nba
1386 /// boundary" is genuinely a read-only query, and
1387 /// [`Self::advance_nba_region`] stays the one, separate, explicit call
1388 /// that applies the staged log (CG-162's "never collapsed into one
1389 /// call").
1390 pub fn current_region(&self) -> Region {
1391 self.current_region
1392 }
1393
1394 /// Drains `region`'s queue into a canonically-sorted ready-set (Task 2's
1395 /// `sort_ready_set`, applied here since `form_ready_set`'s own contract
1396 /// is purely "collect what's ready").
1397 fn drain_and_sort(&self, region: Region) -> Vec<PendingActivation> {
1398 let mut ready = {
1399 let mut queues = self.queues.lock().unwrap();
1400 form_ready_set(&mut queues, region)
1401 .expect("Active/Inactive/Reactive/Postponed are never Region::Nba")
1402 };
1403 self.reset_dequeued(&ready);
1404 sort_ready_set(&mut ready);
1405 ready
1406 }
1407
1408 /// SC-056: the dequeue point. Every activation just popped off a region
1409 /// queue clears its process's queued flag here, before any `resume()`, so
1410 /// a change landing between this dequeue and the process's next
1411 /// sensitivity registration re-queues it instead of being swallowed.
1412 fn reset_dequeued(&self, popped: &[PendingActivation]) {
1413 for act in popped {
1414 self.gate.reset_on_dequeue(act.proc);
1415 }
1416 }
1417
1418 /// Partitions `ready` via the installed `DeterminismController`, then
1419 /// dispatches every resulting `WorkItem` through `ResumeDriver`, using
1420 /// one `EngineOps` shared for the whole pass (constructed via a
1421 /// temporary shared reborrow of `self`, dropped before any per-item
1422 /// `&mut self` post-processing — see this module's doc comment).
1423 /// Returns whether anything was actually dispatched.
1424 ///
1425 /// **Task 9 (A2-part2, CG-102):** before running the per-item dispatch
1426 /// body below, `ready` is grouped by each activation's own STORED
1427 /// `region_id` (`ProcessTable::region_id`, via `partition_of`) -- a
1428 /// real, if v1-degenerate, partition-aware dispatch pass, not a
1429 /// placeholder: with `partition_count` fixed at `1` (v1's one real
1430 /// call site, ARCHITECTURE §4 item 1), every activation maps to
1431 /// `PartitionId(0)`, so `groups` always has exactly one entry holding
1432 /// the whole `ready` set in its original order, and the loop below runs
1433 /// the (otherwise unchanged) per-partition dispatch body exactly once
1434 /// -- bit-for-bit the same observable behavior as before this task.
1435 fn dispatch_ready_set(&mut self, region: Region, ready: Vec<PendingActivation>) -> bool {
1436 if ready.is_empty() {
1437 return false;
1438 }
1439 let mut groups: std::collections::BTreeMap<u32, Vec<PendingActivation>> =
1440 std::collections::BTreeMap::new();
1441 for act in ready {
1442 let region_id = self.table.region_id(act.proc);
1443 #[cfg(test)]
1444 self.region_id_queries_seen.push((act.proc, region_id));
1445 let partition = partition_of(region_id, 1);
1446 groups.entry(partition.0).or_default().push(act);
1447 }
1448 for (_, partition_ready) in groups {
1449 self.dispatch_one_partition(region, partition_ready);
1450 }
1451 true
1452 }
1453
1454 /// The per-partition dispatch body [`Self::dispatch_ready_set`]'s new
1455 /// grouping loop calls once per distinct `PartitionId` among `ready`'s
1456 /// activations (always exactly once, for v1's real `partition_count ==
1457 /// 1`) -- unchanged from what `dispatch_ready_set` itself did, in full,
1458 /// before this task.
1459 fn dispatch_one_partition(&mut self, region: Region, ready: Vec<PendingActivation>) {
1460 let work = self.controller.partition(&ready, region, self.now);
1461 // Each activation's suspend is applied as soon as it returns, before
1462 // the next one runs: a process that has just started waiting (`@(u)`)
1463 // is woken by a write a later process makes in the same pass, as in
1464 // any sequential order of the two (LRM 1800-2023 §4.7) -- a wait
1465 // installed only after the whole pass missed it.
1466 for item in &work {
1467 let proc = item.activation.proc;
1468 // An activation queued before a `disable` redirected the process
1469 // (its timer, an event wake) is stale: the wait it ended is gone.
1470 if self
1471 .activation_floor
1472 .get(&proc)
1473 .is_some_and(|&floor| item.activation.id < floor)
1474 {
1475 continue;
1476 }
1477 // A wake for a process that already finished is stale (LRM 9.4.2:
1478 // a finished thread is no longer waiting). It arises inside one
1479 // dispatched batch: the gate is cleared at dequeue (SC-056), so
1480 // an earlier member's write re-matches a later member's
1481 // still-installed wait (`fork @(a[j]) ... join`, a frame
1482 // variable in the event expression); that member then runs on
1483 // its first activation, completes and is reclaimed, and its
1484 // second activation finds no process.
1485 if matches!(item.activation.arg, ResumeArg::EventFired { .. })
1486 && self.table.get(proc).is_none()
1487 {
1488 continue;
1489 }
1490 if matches!(item.activation.arg, ResumeArg::EventFired { .. }) {
1491 if self
1492 .event_floor
1493 .get(&proc)
1494 .is_some_and(|&floor| item.activation.id < floor)
1495 {
1496 continue;
1497 }
1498 self.event_floor
1499 .insert(proc, self.activation_seq.load(Ordering::SeqCst));
1500 // A stale wake queued earlier in this pass still holds the
1501 // process's dedup flag; clear it now (the process stops
1502 // waiting here) so the first write to its next wait queues a
1503 // wake instead of being deduplicated against the dropped one.
1504 self.gate.reset_on_dequeue(proc);
1505 }
1506 // Processes resumed via `ResumeArg::EventFired`/`Disabled` -- the
1507 // only two variants that terminate a `Suspend::WaitEvents`
1508 // dynamic wait -- lose their dynamic sensitivity entries
1509 // (`sensitivity.rs`: entries "are gone from the table the
1510 // instant the owning process resumes"). Removed BEFORE the
1511 // process runs: a process is not waiting while it executes, so
1512 // its own blocking writes must not wake it again through the
1513 // wait it was resumed from (an `always @*` that reads a variable
1514 // it also writes would otherwise re-trigger itself forever;
1515 // LRM 1800-2023 §4.7, §9.4.2). Its new `Suspend` is installed by
1516 // `apply_suspend` below, after this removal.
1517 if matches!(
1518 item.activation.arg,
1519 ResumeArg::EventFired { .. } | ResumeArg::Disabled
1520 ) {
1521 self.sensitivity.remove_dynamic(proc);
1522 // Task 3 (ledger SC-052): belt-and-suspenders -- a process
1523 // that resumes for any reason while a `Repeat` term's count is
1524 // only partially satisfied (e.g. it was `Disabled` mid-wait)
1525 // must not leave a stale partial count behind for a future,
1526 // unrelated dynamic wait reusing the same `term_index`.
1527 self.repeats.clear(proc);
1528 }
1529 // Classes plan Task 13 (LRM 9.7): a suspended process does not
1530 // run. The activation that would have run it is parked and
1531 // replayed by `p.resume()`; a running one is no longer waiting.
1532 if let Some(desc) = self.table.get_mut(proc) {
1533 if desc.suspended {
1534 desc.parked = Some(item.activation.arg);
1535 continue;
1536 }
1537 desc.waiting = false;
1538 desc.wait_set = None;
1539 }
1540 // 38.36.1 `cbStartOfThread`/`cbEnterThread`: a static process is
1541 // created when it first runs (a fork branch was announced when
1542 // its `fork` executed); any later activation is a resume.
1543 if super::thread_hook_installed()
1544 && let Some(desc) = self.table.get(proc)
1545 {
1546 let event = if matches!(item.activation.arg, ResumeArg::Start) {
1547 desc.fork_parent
1548 .is_none()
1549 .then_some(super::ThreadEvent::Start {
1550 pid: proc,
1551 parent: None,
1552 spawn: None,
1553 })
1554 } else {
1555 Some(super::ThreadEvent::Enter { pid: proc })
1556 };
1557 if let Some(event) = event {
1558 self.fire_thread_event_now(event);
1559 }
1560 }
1561 let suspend = {
1562 let ops = EngineOps {
1563 engine: &*self,
1564 now: self.now,
1565 region,
1566 reconv_iter: 0,
1567 };
1568 let handle = SimHandle(std::ptr::null_mut());
1569 // A process terminated by `disable fork;` (SC-125,
1570 // `terminate_fork_descendants`) may still have activations in
1571 // flight -- a pending `#delay` timer or a signal-change wake.
1572 // A killed process never runs again (LRM 9.6.3), so they are
1573 // dropped here. Every OTHER unknown id is still a bookkeeping
1574 // bug and panics below.
1575 if self.table.get(proc).is_none() && self.killed.contains(&proc) {
1576 continue;
1577 }
1578 if matches!(item.activation.arg, ResumeArg::Start) {
1579 self.driver.start(&self.table, proc, handle, &ops)
1580 } else {
1581 self.driver
1582 .resume_for(&self.table, proc, item.activation.arg, handle, &ops)
1583 }
1584 .expect(
1585 "every dispatched ProcessId came from this Engine's own \
1586 register_process/handle_fork, so UnknownProcess/AlreadyStarted \
1587 here would mean a bug in Engine's own bookkeeping",
1588 )
1589 // `ops` (and the shared reborrow of `self` it holds) goes out
1590 // of scope here, before the mutable calls below.
1591 };
1592 self.apply_suspend(proc, suspend);
1593 }
1594 }
1595
1596 /// The NBA log a nonblocking assignment staged by `source` belongs to: the
1597 /// Re-NBA log for a Reactive-region process, the NBA log otherwise.
1598 /// Whether a nonblocking write `source` stages now belongs to the Re-NBA
1599 /// region: `source` is a Reactive-region process, or the running kernel
1600 /// is in a clocking-block synchronous drive (LRM 14.16: drives update in
1601 /// the Re-NBA region).
1602 fn stages_reactive(&self, source: ProcessId) -> bool {
1603 self.has_reactive.load(Ordering::Relaxed)
1604 && (self.table.is_reactive(source) || super::kernel_adapter::reactive_drive())
1605 }
1606
1607 /// An `edge` of `cell` just happened: stages the edge-skewed clocking
1608 /// drives (LRM 1800-2023 14.4) waiting on it, `after` ticks later when they
1609 /// carry a post-edge delay.
1610 fn fire_edge_nbas(&self, cell: StateCellId, edge: EdgeKind) {
1611 if edge == EdgeKind::None {
1612 return;
1613 }
1614 let fired: Vec<EdgeNba> = {
1615 let mut list = self.edge_nbas.lock().unwrap();
1616 let (hit, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut *list)
1617 .into_iter()
1618 .partition(|w| w.cell == cell && (w.edge == edge || w.edge == EdgeKind::BothEdges));
1619 *list = keep;
1620 if list.is_empty() {
1621 self.has_edge_nbas.store(false, Ordering::Relaxed);
1622 }
1623 hit
1624 };
1625 for w in fired {
1626 if w.after == 0 {
1627 w.nba.stage_into(self);
1628 } else {
1629 self.delayed_nbas
1630 .lock()
1631 .unwrap()
1632 .entry(self.now.ticks + w.after)
1633 .or_default()
1634 .push(w.nba);
1635 }
1636 }
1637 }
1638
1639 fn nba_for(&self, source: ProcessId) -> &Mutex<NbaLog> {
1640 if self.stages_reactive(source) {
1641 &self.re_nba
1642 } else {
1643 &self.nba
1644 }
1645 }
1646
1647 /// The Re-NBA region commit pass (LRM 4.5): applies the nonblocking
1648 /// assignments Reactive-region processes staged. Returns whether anything
1649 /// changed.
1650 fn commit_re_nba_once(&self) -> bool {
1651 let mut nba = self.re_nba.lock().unwrap();
1652 if nba.is_empty() {
1653 return false;
1654 }
1655 let mut arena = self.arena.lock().unwrap();
1656 let mut sink = EngineNotifySink {
1657 engine: self,
1658 at: self.now,
1659 reconv_iter: 0,
1660 delta: None,
1661 };
1662 crate::sched::commit_nba(&mut nba, &mut **arena, self.now, &mut sink) > 0
1663 }
1664
1665 /// LRM 4.5/4.6: once the Reactive region set (Re-Active, Re-Inactive,
1666 /// Re-NBA) is entered, iteration over the design regions does not resume
1667 /// until all of it is empty. Drains Re-Active to empty, then one
1668 /// Re-Inactive batch (back to Re-Active), then Re-NBA, repeating.
1669 fn drain_reactive_set(&mut self) {
1670 loop {
1671 loop {
1672 let ready = self.drain_and_sort(Region::Reactive);
1673 if ready.is_empty() {
1674 break;
1675 }
1676 self.dispatch_ready_set(Region::Reactive, ready);
1677 }
1678 self.drain_region_callbacks(Region::Reactive);
1679 self.drain_pending_spawns();
1680 let re_inactive = {
1681 let mut queues = self.queues.lock().unwrap();
1682 queues.drain_re_inactive()
1683 };
1684 if !re_inactive.is_empty() {
1685 self.reset_dequeued(&re_inactive);
1686 self.dispatch_ready_set(Region::Reactive, re_inactive);
1687 } else {
1688 self.maybe_commit_re_nba();
1689 self.apply_class_event_wakes();
1690 let done = {
1691 let queues = self.queues.lock().unwrap();
1692 queues.reactive_is_empty() && queues.re_inactive_is_empty()
1693 };
1694 if done {
1695 break;
1696 }
1697 }
1698 self.guard.tick(self.now).expect(
1699 "SC-080/DT-050: delta-loop livelock guard exceeded -- hard error, \
1700 never warn-and-truncate",
1701 );
1702 }
1703 }
1704
1705 /// Runs the Re-NBA region when Re-Active and Re-Inactive are empty
1706 /// (LRM 4.5).
1707 fn maybe_commit_re_nba(&self) {
1708 if !self.has_reactive.load(Ordering::Relaxed) {
1709 return;
1710 }
1711 let drained = {
1712 let queues = self.queues.lock().unwrap();
1713 queues.reactive_is_empty() && queues.re_inactive_is_empty()
1714 };
1715 if drained {
1716 self.commit_re_nba_once();
1717 }
1718 }
1719
1720 /// The SC-039 NBA commit pass for the current timestep. Returns whether
1721 /// anything actually changed.
1722 fn commit_nba_once(&self) -> bool {
1723 let mut nba = self.nba.lock().unwrap();
1724 let mut arena = self.arena.lock().unwrap();
1725 let mut sink = EngineNotifySink {
1726 engine: self,
1727 at: self.now,
1728 reconv_iter: 0,
1729 delta: None,
1730 };
1731 crate::sched::commit_nba(&mut nba, &mut **arena, self.now, &mut sink) > 0
1732 }
1733
1734 /// **NEW (A2-part2 Task 5, ledger CG-162):** the LRM's NBA
1735 /// update-APPLICATION half, extracted verbatim from `step_region`'s own
1736 /// `Region::Nba` arm (a pure refactor -- `step_region`'s own observable
1737 /// behavior, for every existing caller including debugger/VPI
1738 /// single-stepping, is unchanged bit-for-bit) so it can also be called
1739 /// directly as its own, separately-named operation. The ABI's
1740 /// `sim_commit_nba` entry point (`sv-sim-runtime::abi_entry`) needs
1741 /// exactly this, decoupled from `sim_eval_region(Nba)` (see
1742 /// [`Self::current_region`]'s own doc comment for that half) --
1743 /// ARCHITECTURE §5's "`sim_commit_nba` kept distinct from
1744 /// `eval_region(Nba)` to mirror the LRM's RHS-eval vs update-apply
1745 /// split," never collapsed into one call.
1746 pub fn advance_nba_region(&mut self) -> StepResult {
1747 let changed = self.commit_nba_once();
1748 self.apply_class_event_wakes();
1749 let mut settled = {
1750 let queues = self.queues.lock().unwrap();
1751 queues.active_and_inactive_both_empty()
1752 };
1753 // sva T2 (§1.4): Active/Inactive/Nba have genuinely settled this
1754 // round -- drain Observed (the automaton-engine step hook) before
1755 // committing to Reactive, and re-check: a matured action could hand
1756 // new work to Active/Inactive, in which case the delta loop must
1757 // re-run rather than fall through. See
1758 // `Self::drain_observed_and_check_settled`'s own doc comment.
1759 if !changed && settled {
1760 settled = self.drain_observed_and_check_settled();
1761 }
1762 if changed || !settled {
1763 self.guard.tick(self.now).expect(
1764 "SC-080/DT-050: delta-loop livelock guard exceeded -- hard error, \
1765 never warn-and-truncate",
1766 );
1767 self.current_region = Region::Active;
1768 } else {
1769 self.guard.reset();
1770 self.forks_this_step = 0;
1771 self.current_region = Region::Reactive;
1772 }
1773 StepResult::Advanced
1774 }
1775
1776 /// Advances the wheel to `tick`, dispatching every popped `TimerEntry`
1777 /// per its own `TimerAction` (SC-069): a `Resume` entry pushes onto its
1778 /// target region's queue exactly as before this task; a `TriggerEvent`
1779 /// entry is fanned out through [`EngineNotifySink::notify_change`] --
1780 /// the same path an ordinary committed write already uses -- with
1781 /// `EdgeKind::None` (the sentinel `SensitivityTable::matches` already
1782 /// treats as "any change"/a trigger pulse, `sensitivity.rs`'s own doc
1783 /// comment). Then advances `self.now`.
1784 ///
1785 /// Triggers are collected and fired only after every `Resume` entry has
1786 /// already been pushed and `self.now` updated: `notify_change` reads
1787 /// `self.now` (via the `EngineNotifySink` it constructs) for the
1788 /// `PendingActivation`s it enqueues, so a trigger fired at the new tick
1789 /// must see that tick as `self.now`, not the pre-advance value.
1790 /// The earliest tick with anything scheduled: a wheel entry or a
1791 /// delayed nonblocking write.
1792 /// Installs the embedder's scheduling hooks (`super::host`).
1793 pub fn set_host(&mut self, host: Box<dyn super::host::Host>) {
1794 self.host = Some(host);
1795 self.host_slot = None;
1796 }
1797
1798 /// Runs `f` on the installed host, if any, with this engine's ops (also
1799 /// installed as the current ops, so code the host calls can write).
1800 fn with_host<R>(
1801 &mut self,
1802 region: Region,
1803 f: impl FnOnce(&mut dyn super::host::Host, &dyn SchedulerOps) -> R,
1804 ) -> Option<R> {
1805 let mut host = self.host.take()?;
1806 let r = {
1807 let ops = EngineOps {
1808 engine: &*self,
1809 now: self.now,
1810 region,
1811 reconv_iter: 0,
1812 };
1813 crate::sched::with_scheduler_ops(&ops, || f(&mut *host, &ops))
1814 };
1815 self.host = Some(host);
1816 Some(r)
1817 }
1818
1819 /// Reports a thread event to the installed thread hook with this
1820 /// engine's ops (`super::thread_hooks`).
1821 fn fire_thread_event_now(&self, event: super::ThreadEvent) {
1822 let ops = EngineOps {
1823 engine: self,
1824 now: self.now,
1825 region: Region::Active,
1826 reconv_iter: 0,
1827 };
1828 super::fire_thread_event(&ops, event);
1829 }
1830
1831 /// The host's slot-begin hook, once per time slot.
1832 fn host_slot_begin(&mut self) {
1833 if self.host.is_some() && self.host_slot != Some(self.now.ticks) {
1834 self.host_slot = Some(self.now.ticks);
1835 self.with_host(Region::Active, |h, ops| h.slot_begin(ops));
1836 }
1837 }
1838
1839 fn next_event_tick(&self) -> Option<SimTick> {
1840 let delayed = self.delayed_nbas.lock().unwrap().keys().next().copied();
1841 let host = self.host.as_ref().and_then(|h| h.next_wakeup());
1842 [self.wheel.next_tick(), delayed, host]
1843 .into_iter()
1844 .flatten()
1845 .min()
1846 }
1847
1848 fn advance_time_to(&mut self, tick: SimTick) {
1849 let due = self.wheel.advance_to(tick);
1850 let mut triggers: Vec<StateCellId> = Vec::new();
1851 {
1852 let mut queues = self.queues.lock().unwrap();
1853 for entry in due {
1854 match entry.action {
1855 TimerAction::Resume(act, region) => {
1856 queues
1857 .push(region, act)
1858 .expect("TimeWheel::arm_delay only ever arms Region::Active entries");
1859 }
1860 TimerAction::TriggerEvent(cell) => triggers.push(cell),
1861 }
1862 }
1863 }
1864 self.now = SchedTime { ticks: tick };
1865 // Delayed nonblocking writes due now join this step's NBA log, ahead
1866 // of any the step's own processes stage (they were scheduled first).
1867 let due_nbas: Vec<DelayedNba> = {
1868 let mut delayed = self.delayed_nbas.lock().unwrap();
1869 let later = delayed.split_off(&(tick + 1));
1870 std::mem::replace(&mut *delayed, later)
1871 .into_values()
1872 .flatten()
1873 .collect()
1874 };
1875 if !due_nbas.is_empty() {
1876 for d in due_nbas {
1877 d.stage_into(self);
1878 }
1879 }
1880 for cell in triggers {
1881 let mut sink = EngineNotifySink {
1882 engine: self,
1883 at: self.now,
1884 reconv_iter: 0,
1885 delta: None,
1886 };
1887 sink.notify_change(
1888 cell,
1889 EdgeKind::None,
1890 self.now.to_abi_display(0),
1891 Region::Active,
1892 );
1893 }
1894 }
1895
1896 /// **NEW (Task 4, ledger SC-090/SC-123).** Pushes `cb` onto
1897 /// [`Self::region_callbacks`] to fire once `region` is next drained
1898 /// (via [`Self::drain_region_callbacks`]) with `self.now >= at`. Not
1899 /// `#[cfg(test)]`-gated: `EngineOps::register_region_callback` (the
1900 /// `SchedulerOps` trait method's one production implementation) is a
1901 /// thin forward to this, and this module's own test setup calls it
1902 /// directly for callbacks registered before `run_until` starts (where
1903 /// no `SchedulerOps`/dispatched-process context exists yet).
1904 ///
1905 /// **Review fix (Task 4 r1):** exhaustive `match region`, mirroring
1906 /// [`RegionQueues::push`]'s own established `Result` convention:
1907 /// `Reactive`/`Postponed` are the only two regions
1908 /// [`Self::drain_region_callbacks`] ever drains (see that method's own
1909 /// doc comment), so only those two push and return `Ok(())`;
1910 /// `Active`/`Inactive`/`Nba` return
1911 /// `Err(RegionCallbackRegionError::NotDrained(region))` instead of
1912 /// silently accepting a registration that would sit in
1913 /// `region_callbacks` forever and never fire. **sva T1/T2:**
1914 /// `Preponed` was added to the rejecting side (T1); `Observed` was
1915 /// added to the ACCEPTING side (T2) once `drain_region_callbacks` grew
1916 /// a real caller for it -- see the match's own per-arm comments below.
1917 pub fn push_region_callback(
1918 &self,
1919 at: SchedTime,
1920 region: Region,
1921 cb: Box<dyn RegionCallback>,
1922 ) -> Result<(), RegionCallbackRegionError> {
1923 match region {
1924 // sva T2 (§1.4, review fix, blocking finding 2), Observed's
1925 // half: `Observed` joins the ACCEPTING side, unlike `Preponed`
1926 // below -- this is the literal mechanism by which the
1927 // Observed-region automaton-engine step (a later task) gets
1928 // invoked: its engine step registers itself as one more region
1929 // callback here, fired by `drain_region_callbacks(Region::
1930 // Observed)` (see `Self::drain_observed_and_check_settled`).
1931 Region::Reactive | Region::Postponed | Region::Observed => {
1932 self.region_callbacks.lock().unwrap().push((at, region, cb));
1933 Ok(())
1934 }
1935 // sva T1 (§1.4, review fix, blocking finding 2): `Preponed`
1936 // joins the REJECTING side, never the accepting one -- the
1937 // Preponed snapshot push is inline code in `run_until`'s own
1938 // loop / `step_region`'s `Postponed` arm (see
1939 // `Engine::push_preponed_snapshot`), never a registered
1940 // callback, so nothing may register one for it.
1941 Region::Active | Region::Inactive | Region::Nba | Region::Preponed => {
1942 Err(RegionCallbackRegionError::NotDrained(region))
1943 }
1944 }
1945 }
1946
1947 /// **NEW (Task 4, ledger SC-090/SC-123/SC-020's scheduler-owned half).**
1948 /// Removes and fires every registered callback for `region` whose
1949 /// `fire_at <= self.now`, in registration order (never resorted — see
1950 /// [`Self::region_callbacks`]'s own doc comment). Called from
1951 /// `run_until`/`step_region` immediately after each
1952 /// `dispatch_ready_set(Region::Reactive, ..)`/`dispatch_ready_set(
1953 /// Region::Postponed, ..)` call — i.e. strictly after that region's own
1954 /// process dispatch, and (for `Postponed`) strictly after the whole
1955 /// Active/Inactive/NBA delta loop has already converged this timestep.
1956 ///
1957 /// **sva T2:** also called for `Region::Observed`, but not from a
1958 /// `dispatch_ready_set` call site like Reactive/Postponed -- `Observed`
1959 /// has no `RegionQueues` queue (§1.4), so it is drained instead from
1960 /// [`Self::drain_observed_and_check_settled`], itself called only once
1961 /// Active/Inactive/Nba have genuinely settled (see that method's own
1962 /// doc comment).
1963 ///
1964 /// This is SC-090's own literal acceptance ("fires exactly once per
1965 /// settled time step") and the pinning `region_callback`'s own module
1966 /// doc describes for `1step` (SC-123): a callback registered for
1967 /// `Region::Postponed` fires strictly after every other
1968 /// `Region::Postponed` callback registered before it, in the same
1969 /// drain pass, never in a separate/later timestep.
1970 fn drain_region_callbacks(&self, region: Region) {
1971 let due: Vec<Box<dyn RegionCallback>> = {
1972 let mut callbacks = self.region_callbacks.lock().unwrap();
1973 let mut due = Vec::new();
1974 let mut i = 0;
1975 while i < callbacks.len() {
1976 if callbacks[i].1 == region && callbacks[i].0 <= self.now {
1977 due.push(callbacks.remove(i).2);
1978 } else {
1979 i += 1;
1980 }
1981 }
1982 due
1983 };
1984 if due.is_empty() {
1985 return;
1986 }
1987 let ctx = EngineSchedulerCtx {
1988 engine: self,
1989 now: self.now,
1990 };
1991 // A callback may run compiled code (a `$strobe`/`$monitor` argument
1992 // thunk) that calls `$time` etc. -- make this Engine's ops reachable
1993 // to those runtime entry points exactly as during a kernel dispatch.
1994 let ops = EngineOps {
1995 engine: self,
1996 now: self.now,
1997 region,
1998 reconv_iter: 0,
1999 };
2000 for mut cb in due {
2001 super::kernel_adapter::with_scheduler_ops(&ops, || cb.invoke(&ctx));
2002 }
2003 }
2004
2005 /// Whether the Reactive region, which has just run, left work for the rest
2006 /// of this time step (LRM 4.5: after the Reactive region the simulator
2007 /// goes back to the Active region if anything is scheduled there): a
2008 /// process it woke in Active or Inactive, a nonblocking assignment it
2009 /// staged (the Re-NBA region, collapsed into the single NBA region), a
2010 /// process or callback it left in Reactive for its next round.
2011 fn reactive_left_work(&self) -> bool {
2012 {
2013 let queues = self.queues.lock().unwrap();
2014 if !queues.active_and_inactive_both_empty()
2015 || !queues.reactive_is_empty()
2016 || !queues.re_inactive_is_empty()
2017 {
2018 return true;
2019 }
2020 }
2021 if !self.nba.lock().unwrap().is_empty() {
2022 return true;
2023 }
2024 if self.has_reactive.load(Ordering::Relaxed) && !self.re_nba.lock().unwrap().is_empty() {
2025 return true;
2026 }
2027 // A procedural concurrent assertion a Reactive process reached
2028 // matures in the next Observed region (16.14.6), which the delta loop
2029 // reaches again only if sent round.
2030 if !crate::sched::proc_assert::is_empty() {
2031 return true;
2032 }
2033 self.region_callbacks
2034 .lock()
2035 .unwrap()
2036 .iter()
2037 .any(|(at, region, _)| *region == Region::Reactive && *at <= self.now)
2038 }
2039
2040 /// **NEW (sva T2, §1.4).** Drains any registered `Region::Observed`
2041 /// callback -- the automaton-engine step hook a later task wires in;
2042 /// none is registered yet this task, so this is a plumbing-only no-op
2043 /// in every design today, proved by
2044 /// `step_region_never_observes_current_region_eq_observed` and the
2045 /// zero-SVA-cost guard the T1 ring already established -- and reports
2046 /// whether `Active`/`Inactive` are (still) both empty afterward.
2047 ///
2048 /// Called from both `run_until`'s inlined delta loop and
2049 /// [`Self::advance_nba_region`] (the single-stepper's `Region::Nba`
2050 /// arm) at the identical point: only once NBA committed no further
2051 /// change AND Active/Inactive are already empty -- i.e. only at the
2052 /// genuine idle point the LRM's own Observed region is pinned to
2053 /// (§1.4: "fires inside the existing inner loop every time Active/
2054 /// Inactive/Nba all empty out"), never speculatively on every delta
2055 /// iteration. A matured report an Observed callback schedules into
2056 /// `Region::Reactive` (via the generalized `sched::maturation`, through
2057 /// `SchedulerOps::register_region_callback`) is consequently already
2058 /// sitting in `region_callbacks` by the time this same timestep's own
2059 /// Reactive dispatch runs -- see
2060 /// `observed_region_maturation_fires_before_reactive_same_timestep`
2061 /// and `observed_engine_step_result_visible_by_next_reactive_step`.
2062 ///
2063 /// If an Observed callback's own effect (not implemented by any task
2064 /// yet) handed new work to Active/Inactive, the caller must re-enter
2065 /// the delta loop rather than proceed to Reactive -- the reference
2066 /// algorithm's own "re-fires on each outer re-iteration, not just
2067 /// once" structure (Clause 4.5) -- which is exactly why this returns
2068 /// the re-checked `bool` rather than deciding the transition itself.
2069 fn drain_observed_and_check_settled(&self) -> bool {
2070 self.drain_region_callbacks(Region::Observed);
2071 let queues = self.queues.lock().unwrap();
2072 queues.active_and_inactive_both_empty()
2073 }
2074
2075 /// Applies one dispatched process's returned `Suspend`, exhaustive over
2076 /// all 7 variants (see this module's doc comment, deviation 3, for
2077 /// `WaitEvents`/`ForkJoin`'s scope this task; `WaitFork`/`WaitOrder` are
2078 /// B-sched Task 9's addition, ledger SC-125; `WaitForGvt` is abi Task 8's,
2079 /// AB-085).
2080 ///
2081 /// **Task 8 correction (AB-085):** the `abi`-track plan this arm was
2082 /// written against claimed `Suspend` had no exhaustive-match consumer in
2083 /// this file at the time `WaitForGvt` was added; that was wrong -- this
2084 /// very function already matched all 4 pre-existing variants with no
2085 /// wildcard arm, so adding a 5th variant is a real, necessary edit here,
2086 /// not an optional one. The fix is minimal and additive: `WaitForGvt`
2087 /// delegates to the exact same code the `Delay(ticks)` arm below already
2088 /// runs, with `ticks = 0` -- i.e. it shares that variant's dispatch path
2089 /// verbatim rather than introducing any new execution mechanism, which
2090 /// is AB-085's literal acceptance criterion. Real GVT-barrier semantics
2091 /// (gating resumption on every parallel lane reaching the same logical
2092 /// time, not just the next delta step) are the determinism/parallel-
2093 /// engine track's job and are not implemented here.
2094 fn apply_suspend(&mut self, proc: ProcessId, suspend: Suspend) {
2095 // Task 9 (B-sched wave, SC-125): drains every `disable fork;`
2096 // request `EngineOps::disable_fork` queued mid-`resume()` (that
2097 // trait method only ever gets `&self`, so it cannot mutate
2098 // `self.table` itself). `apply_suspend` always runs with `&mut
2099 // self` immediately after the `resume()` call(s) that might have
2100 // queued a request -- see `Self::drain_pending_disable_fork`'s own
2101 // doc comment for why this placement is sufficient.
2102 self.drain_pending_disable_fork();
2103 self.drain_pending_disable_named();
2104 self.drain_pending_disable_scope();
2105 self.drain_pending_process_requests();
2106 // A process that `process::kill`ed itself (or whose ancestor it
2107 // killed) is already gone from the table, with its parent notified
2108 // and its exit recorded; whatever it returned is moot.
2109 if self.killed.contains(&proc) && self.table.get(proc).is_none() {
2110 return;
2111 }
2112 if self.any_tracked.load(Ordering::SeqCst)
2113 && !matches!(suspend, Suspend::Done)
2114 && let Some(desc) = self.table.get_mut(proc)
2115 {
2116 desc.waiting = true;
2117 desc.wait_set = match &suspend {
2118 Suspend::WaitEvents(set) => Some(set.clone()),
2119 _ => None,
2120 };
2121 }
2122 match suspend {
2123 Suspend::WaitForGvt => self.apply_suspend(proc, Suspend::Delay(0)),
2124 Suspend::ByRequest => self.apply_by_request(proc),
2125 Suspend::Done => {
2126 // Review fix: report this completion to ResumeDriver::
2127 // on_child_done when `proc` is itself a fork child
2128 // (`resume.rs`'s own doc comment: on_child_done "reports one
2129 // fork child's completion to its parent, returning the
2130 // parent's own next resume() argument exactly when the join
2131 // condition is satisfied"). `fork_parent` is read from
2132 // `proc`'s own table entry (set once, by handle_fork, and
2133 // never for a non-child process), never a linear scan.
2134 if let Some(parent) = self.table.get(proc).and_then(|d| d.fork_parent) {
2135 // Task 9 (SC-125): `on_child_done` now returns
2136 // `Result<Option<ResumeArg>, ResumeError>` --
2137 // `Err(WaitOrderViolated(..))` means a `wait_order(...)`-
2138 // suspended parent observed an out-of-order completion.
2139 // There is no real-SV reporting path for this yet this
2140 // wave (no track's codegen emits `Suspend::WaitOrder`
2141 // this wave -- this task's own e2e test is gated on a
2142 // currently-unscheduled future wave), so the only
2143 // correct scheduler-side behavior available today is
2144 // exactly what a satisfied-`None` result already does:
2145 // never resume the parent for this wait. Never a panic
2146 // that would take down an otherwise-unrelated run over a
2147 // still-undeliverable SV-level diagnostic.
2148 if let Ok(Some(arg)) = self.driver.on_child_done(&mut self.table, proc) {
2149 let act = PendingActivation {
2150 id: self.activation_seq.fetch_add(1, Ordering::SeqCst),
2151 proc: parent,
2152 arg,
2153 reconv_iter: 0,
2154 logical_order: (self.now, Region::Active, parent as u64),
2155 };
2156 self.queues
2157 .lock()
2158 .unwrap()
2159 .push(Region::Active, act)
2160 .expect("Region::Active is never rejected by RegionQueues::push");
2161 }
2162 }
2163 self.note_exit(proc, ProcessState::Finished);
2164 }
2165 Suspend::Delay(ticks) => {
2166 let act = PendingActivation {
2167 id: self.activation_seq.fetch_add(1, Ordering::SeqCst),
2168 proc,
2169 arg: ResumeArg::TimeElapsed,
2170 reconv_iter: 0,
2171 logical_order: (
2172 SchedTime {
2173 ticks: self.now.ticks + ticks,
2174 },
2175 Region::Active,
2176 proc as u64,
2177 ),
2178 };
2179 // The one entry point a Suspend::Delay normalizes through
2180 // (wheel.rs's own doc comment).
2181 let dest = self.wheel.arm_delay(self.now.ticks, ticks, act);
2182 if dest == Region::Inactive && self.table.is_reactive(proc) {
2183 // LRM 4.4.2.7, 4.5: `#0` taken from the Reactive region
2184 // resumes in Re-Inactive, once Reactive has drained.
2185 let act = PendingActivation {
2186 logical_order: (act.logical_order.0, Region::Reactive, act.logical_order.2),
2187 ..act
2188 };
2189 self.queues.lock().unwrap().push_re_inactive(act);
2190 } else if dest == Region::Inactive {
2191 // Review fix (mirrors subscribe.rs::register_wait's
2192 // identical delay_ticks==0 case): `act` was built above
2193 // with logical_order.1 == Region::Active (the common
2194 // case, where arm_delay arms the wheel instead). When
2195 // arm_delay instead reports Region::Inactive
2196 // (delay_ticks==0), that tag must agree with the queue
2197 // the activation actually lands in -- order.rs's
2198 // canonical_key_of/sort_ready_set read logical_order.1
2199 // directly to build the canonical key (SC-010/DT-005/
2200 // DT-010) -- so re-tag it to Region::Inactive before
2201 // pushing.
2202 let act = PendingActivation {
2203 logical_order: (act.logical_order.0, Region::Inactive, act.logical_order.2),
2204 ..act
2205 };
2206 self.queues
2207 .lock()
2208 .unwrap()
2209 .push(Region::Inactive, act)
2210 .expect("Region::Inactive is never rejected by RegionQueues::push");
2211 }
2212 }
2213 Suspend::WaitEvents(set) => {
2214 self.sensitivity.install_dynamic(&set, proc);
2215 }
2216 Suspend::ForkJoin(kind, spawns) => {
2217 // Each child is built by the PARENT (`CompiledProcess::
2218 // mint_fork_child`): it alone knows its compiled branch
2219 // kernels and its instance's `SimHandle`. The parent's own
2220 // `resume` has already returned, so its mutex is free here.
2221 // A parent with no branch kernels (hand-written test
2222 // processes) keeps the inert `UnmintedForkChild` placeholder.
2223 let mut minted: Vec<Box<dyn CompiledProcess>> = {
2224 let parent = self.table.get(proc).map(|d| d.compiled.lock().unwrap());
2225 spawns
2226 .iter()
2227 .map(|spec| {
2228 parent
2229 .as_ref()
2230 .and_then(|p| p.mint_fork_child(spec))
2231 .unwrap_or_else(|| Box::new(UnmintedForkChild))
2232 })
2233 .collect()
2234 };
2235 minted.reverse();
2236 let mut mint_child = |_spec: &SpawnSpec| -> Box<dyn CompiledProcess> {
2237 minted
2238 .pop()
2239 .expect("handle_fork mints exactly one child per SpawnSpec")
2240 };
2241 let children =
2242 self.driver
2243 .handle_fork(&mut self.table, proc, kind, &spawns, &mut mint_child);
2244 self.forks_this_step = self.forks_this_step.saturating_add(children.len() as u32);
2245 if self.forks_this_step > MAX_FORKS_PER_STEP && self.sync_livelock.is_none() {
2246 self.sync_livelock = Some(format!(
2247 "more than {MAX_FORKS_PER_STEP} fork branches spawned at {:?} without \
2248 time advancing (a zero-delay fork loop; hard error per SC-080/DT-050 \
2249 policy)",
2250 self.now
2251 ));
2252 }
2253 // Tags each newly minted child with its own `SpawnSpec::
2254 // disable_name` (the fork's own label and/or this branch's
2255 // named outermost block, LRM 9.6.2) -- `EngineOps::
2256 // disable_named`'s `collect_disable_named` is the one reader.
2257 for (&child, spec) in children.iter().zip(spawns.iter()) {
2258 if !spec.disable_name.is_empty()
2259 && let Some(child_desc) = self.table.get_mut(child)
2260 {
2261 child_desc
2262 .disable_names
2263 .extend(spec.disable_name.iter().cloned());
2264 }
2265 }
2266 // Task 14 (LRM 18.14.2): each child's generator is seeded
2267 // with the next value of the forking process's, one draw per
2268 // child in source order, before any child can run.
2269 for &child in &children {
2270 super::process_rng::preseed(child, super::process_rng::draw_from_process(proc));
2271 }
2272 // 38.36.1 `cbStartOfThread`: each branch is a new thread,
2273 // created here, before it (or the continuing parent) runs.
2274 if super::thread_hook_installed() {
2275 for (&child, spec) in children.iter().zip(&spawns) {
2276 self.fire_thread_event_now(super::ThreadEvent::Start {
2277 pid: child,
2278 parent: Some(proc),
2279 spawn: Some(spec.clone()),
2280 });
2281 }
2282 }
2283 {
2284 let mut queues = self.queues.lock().unwrap();
2285 for &child in &children {
2286 let act = PendingActivation {
2287 id: self.activation_seq.fetch_add(1, Ordering::SeqCst),
2288 proc: child,
2289 arg: ResumeArg::Start,
2290 reconv_iter: 0,
2291 logical_order: (self.now, Region::Active, child as u64),
2292 };
2293 queues
2294 .push(Region::Active, act)
2295 .expect("Region::Active is never rejected by RegionQueues::push");
2296 }
2297 }
2298 // Task 7 (ledger SC-115): `JoinKind::None`'s "the parent
2299 // proceeds without waiting" -- resolved here, per this
2300 // module's own doc comment (deviation 3), *without* a new
2301 // cross-track `ResumeArg` variant meaning "just continue"
2302 // (`ResumeArg` is A2-process/codegen-owned,
2303 // `docs/CONTRACTS.md`'s ownership table): the scheduler
2304 // itself immediately re-invokes the parent's own next
2305 // segment, synchronously, in this same `apply_suspend`
2306 // call, right after registering (and enqueueing the Start
2307 // activation for) every spawned child above -- "proceeds
2308 // without waiting" because there genuinely is nothing to
2309 // wait *for*, never delivered as a resume-with-a-reason.
2310 //
2311 // **Deviation from the plan's literal `self.driver.start(
2312 // ..)` text:** that cannot be `ResumeDriver::start` --
2313 // `proc` already went through `Start` once to reach this
2314 // very `Suspend::ForkJoin` return in the first place, so a
2315 // second `start()` call would be rejected outright by its
2316 // own once-only `AlreadyStarted` CAS guard (`resume.rs`).
2317 // This calls `resume_for` instead, which is the driver's
2318 // own "continue an already-started process" entry point --
2319 // the same one `dispatch_one_partition`'s own ordinary loop
2320 // uses for every non-`Start` resume. It is reused here with
2321 // `ResumeArg::TimeElapsed` -- the least-surprising "no
2322 // special payload" choice among the 5 variants that already
2323 // exist, matching how an ordinary `Suspend::Delay`
2324 // continuation already carries no information beyond "keep
2325 // going". The resulting `Suspend` is fed back into this
2326 // same function recursively, exactly as `dispatch_one_
2327 // partition`'s own loop would have done for it.
2328 // LRM 9.3.2: a fork with no branch statements has nothing to
2329 // wait for under ANY join keyword (`fork join` is a no-op);
2330 // without this the parent parked forever on a join no child
2331 // could ever satisfy.
2332 if kind == JoinKind::None || spawns.is_empty() {
2333 self.resume_synchronously(proc, ResumeArg::TimeElapsed);
2334 }
2335 }
2336 // --- Task 9 (B-sched wave, ledger SC-125) ---
2337 Suspend::WaitFork => {
2338 self.apply_wait_fork(proc);
2339 }
2340 Suspend::WaitOrder(order) => {
2341 self.apply_wait_order(proc, order);
2342 }
2343 }
2344 }
2345
2346 /// **NEW (Task 9, B-sched wave).** Synchronously re-invokes `proc`'s own
2347 /// next segment with `arg` and immediately applies the resulting
2348 /// `Suspend`, in this same call -- the "the caller does not suspend, it
2349 /// just keeps going" pattern `Suspend::ForkJoin`'s own `JoinKind::None`
2350 /// continuation established (this module's doc comment, deviation 3).
2351 /// Factored out of that arm so `Suspend::WaitFork`/`Suspend::WaitOrder`'s
2352 /// own "already satisfied, nothing left to actually wait for" cases
2353 /// (`apply_wait_fork`/`apply_wait_order` below) can reuse it verbatim
2354 /// instead of re-deriving the same `resume_for`/`apply_suspend`
2355 /// recursion.
2356 fn resume_synchronously(&mut self, proc: ProcessId, arg: ResumeArg) {
2357 // Trampoline: every caller is in tail position of `apply_suspend`, so
2358 // a request made while a drain is running runs right after the
2359 // current one returns, with the same effect and bounded stack.
2360 if let Some(queue) = self.sync_queue.as_mut() {
2361 queue.push_back((proc, arg));
2362 return;
2363 }
2364 self.sync_queue = Some(std::collections::VecDeque::new());
2365 self.resume_synchronously_once(proc, arg);
2366 let mut drained = 0u32;
2367 while self.sync_livelock.is_none()
2368 && let Some((p, a)) = self.sync_queue.as_mut().and_then(|q| q.pop_front())
2369 {
2370 drained += 1;
2371 if drained > MAX_SYNC_RESUMES {
2372 self.sync_livelock = Some(format!(
2373 "a process continued without ever blocking {MAX_SYNC_RESUMES} times at \
2374 {:?} (a zero-delay `fork ... join_none` loop never lets time advance; \
2375 hard error per SC-080/DT-050 policy)",
2376 self.now
2377 ));
2378 break;
2379 }
2380 self.resume_synchronously_once(p, a);
2381 }
2382 self.sync_queue = None;
2383 }
2384
2385 fn resume_synchronously_once(&mut self, proc: ProcessId, arg: ResumeArg) {
2386 if let Some(desc) = self.table.get_mut(proc) {
2387 desc.waiting = false;
2388 }
2389 let suspend = {
2390 let ops = EngineOps {
2391 engine: &*self,
2392 now: self.now,
2393 region: Region::Active,
2394 reconv_iter: 0,
2395 };
2396 let handle = SimHandle(std::ptr::null_mut());
2397 self.driver
2398 .resume_for(&self.table, proc, arg, handle, &ops)
2399 .expect(
2400 "proc is a known, already-started ProcessId in this Engine's own \
2401 table (it just returned a Suspend to reach this call) -- \
2402 UnknownProcess/AlreadyStarted here would mean a bug in Engine's \
2403 own bookkeeping",
2404 )
2405 };
2406 self.apply_suspend(proc, suspend);
2407 }
2408
2409 /// **NEW (Task 9, ledger SC-125).** `Suspend::WaitFork`'s scheduler-side
2410 /// installation: counts how many of `proc`'s own `fork_children` are
2411 /// still live (not yet `Suspend::Done` + reclaimed) right now. If none
2412 /// are (the common "every child already finished before `wait fork;`
2413 /// itself ran" case, or a degenerate `wait fork;` with no live
2414 /// children at all), `proc` resumes immediately and synchronously --
2415 /// mirroring `Suspend::ForkJoin`'s own `JoinKind::None` "nothing to
2416 /// actually wait for" continuation just above -- rather than
2417 /// round-tripping through the scheduler loop for an already-satisfied
2418 /// wait. Otherwise, installs the live count onto `proc`'s own
2419 /// descriptor for `ResumeDriver::on_child_done` to decrement as each
2420 /// child later completes.
2421 fn apply_wait_fork(&mut self, proc: ProcessId) {
2422 let children = self
2423 .table
2424 .get(proc)
2425 .map(|d| d.fork_children.clone())
2426 .unwrap_or_default();
2427 let live = children
2428 .iter()
2429 .filter(|&&child| self.table.get(child).is_some())
2430 .count() as u32;
2431 if live == 0 {
2432 self.resume_synchronously(proc, ResumeArg::AllForkChildrenDone);
2433 } else if let Some(desc) = self.table.get_mut(proc) {
2434 desc.wait_fork_remaining = Some(live);
2435 }
2436 }
2437
2438 /// **NEW (Task 9, ledger SC-125).** `Suspend::WaitOrder(order)`'s
2439 /// scheduler-side installation. An empty `order` (a degenerate
2440 /// `wait_order()`) is vacuously satisfied, so `proc` resumes
2441 /// immediately and synchronously (mirroring `apply_wait_fork`'s
2442 /// identical "nothing left to wait for" case); otherwise installs
2443 /// `proc`'s own ordered-completion expectation for `ResumeDriver::
2444 /// on_child_done` to advance/validate as each declared child completes.
2445 fn apply_wait_order(&mut self, proc: ProcessId, order: Vec<ProcessId>) {
2446 if order.is_empty() {
2447 self.resume_synchronously(proc, ResumeArg::WaitOrderSatisfied);
2448 return;
2449 }
2450 if let Some(desc) = self.table.get_mut(proc) {
2451 desc.wait_order = Some((order, 0));
2452 desc.wait_order_violated = false;
2453 }
2454 }
2455
2456 /// **NEW (Task 9, ledger SC-125).** Drains every `ProcessId`
2457 /// `EngineOps::disable_fork` queued (see that method's own doc comment
2458 /// for why the actual mutation cannot happen synchronously inside that
2459 /// `&self` call) and, for each, recursively terminates its live
2460 /// descendants via [`Self::terminate_fork_descendants`]. Called at the
2461 /// top of [`Self::apply_suspend`], which always runs with `&mut self`
2462 /// immediately after the `resume()` call(s) that might have queued a
2463 /// request -- so by the time any caller's own dispatch step (a
2464 /// `run_until`/`step_region` call, or a direct `apply_suspend` call in
2465 /// a test) returns, every requested descendant is already gone.
2466 fn drain_pending_disable_fork(&mut self) {
2467 let requested: Vec<ProcessId> =
2468 std::mem::take(&mut *self.pending_disable_fork.lock().unwrap());
2469 for proc in requested {
2470 self.terminate_fork_descendants(proc);
2471 }
2472 let blocks: Vec<ProcessId> =
2473 std::mem::take(&mut *self.pending_disable_block.lock().unwrap());
2474 for proc in blocks {
2475 self.disable_enclosing_fork(proc);
2476 }
2477 }
2478
2479 /// `disable f;` run by `proc`, a branch of fork `f`: `proc`'s own
2480 /// descendants and every sibling branch (with theirs) end now; `proc`
2481 /// returns `Done` right after the call, and that completion alone is
2482 /// what the parent's join still waits for.
2483 fn disable_enclosing_fork(&mut self, proc: ProcessId) {
2484 self.terminate_fork_descendants(proc);
2485 let Some(parent) = self.table.get(proc).and_then(|d| d.fork_parent) else {
2486 return;
2487 };
2488 // Siblings are the children of THIS fork only (same `fork_group`):
2489 // `disable f` names one fork block, never the parent's other forks.
2490 let group = self.table.get(proc).map_or(0, |d| d.fork_group);
2491 let siblings: Vec<ProcessId> = self
2492 .table
2493 .get(parent)
2494 .map(|d| d.fork_children.clone())
2495 .unwrap_or_default()
2496 .into_iter()
2497 .filter(|&s| self.table.get(s).is_some_and(|d| d.fork_group == group))
2498 .collect();
2499 for s in siblings {
2500 if s != proc {
2501 self.terminate_fork_descendants(s);
2502 self.drop_waits(s);
2503 self.table.remove(s);
2504 self.killed.insert(s);
2505 self.note_exit(s, ProcessState::Killed);
2506 }
2507 }
2508 if let Some(parent_desc) = self.table.get_mut(parent)
2509 && parent_desc.fork_gen == group
2510 && parent_desc.fork_remaining > 0
2511 {
2512 parent_desc.fork_remaining = 1;
2513 }
2514 }
2515
2516 /// **NEW (Task 9, ledger SC-125).** `disable fork;`'s actual recursive
2517 /// termination: removes every live entry in `proc`'s own
2518 /// `fork_children`, and, for each, its own descendants first
2519 /// (post-order) -- never `proc` itself. A child already reclaimed
2520 /// (e.g. it had already completed normally before `disable fork;` ran)
2521 /// is silently skipped, not an error: `disable fork;`'s own LRM
2522 /// semantics only ever terminate what is still live.
2523 fn terminate_fork_descendants(&mut self, proc: ProcessId) {
2524 let children = self
2525 .table
2526 .get(proc)
2527 .map(|d| d.fork_children.clone())
2528 .unwrap_or_default();
2529 for child in children {
2530 if self.table.get(child).is_some() {
2531 self.terminate_fork_descendants(child);
2532 self.drop_waits(child);
2533 self.table.remove(child);
2534 self.killed.insert(child);
2535 self.note_exit(child, ProcessState::Killed);
2536 }
2537 }
2538 }
2539
2540 /// `EngineOps::disable_named`'s deferred half: for each queued
2541 /// `(requester, targets)`, force-kills every target's subtree except
2542 /// `requester` itself (see [`Self::kill_subtree_except`] for why
2543 /// `requester` is always excluded: its own end, when it is one of the
2544 /// targets or descends from one, already happened through the abort
2545 /// flag the synchronous half of `disable_named` told it to set, the
2546 /// same mechanism `process::kill()` uses on itself -- killing its table
2547 /// entry a second time here, out from under its own still-returning
2548 /// `resume()` call, is exactly the hazard that mechanism exists to
2549 /// avoid).
2550 fn drain_pending_disable_named(&mut self) {
2551 let requests: Vec<(ProcessId, Vec<ProcessId>)> =
2552 std::mem::take(&mut *self.pending_disable_named.lock().unwrap());
2553 for (requester, targets) in requests {
2554 for target in targets {
2555 self.kill_subtree_except(target, requester);
2556 }
2557 }
2558 }
2559
2560 /// `EngineOps::disable_named`'s other deferred half (LRM 9.6.2): for each
2561 /// queued `(requester, label)`, every other process of the requester's
2562 /// instance that is suspended inside the named block or task activation
2563 /// `label` (`CompiledProcess::disable_exit`) ends that activity and
2564 /// resumes after it ([`Self::redirect_process`]). The requester's own
2565 /// jump out of `label` is part of its compiled control flow.
2566 fn drain_pending_disable_scope(&mut self) {
2567 let requests: Vec<(ProcessId, String)> =
2568 std::mem::take(&mut *self.pending_disable_scope.lock().unwrap());
2569 for (requester, label) in requests {
2570 let root = self.fork_root(requester);
2571 let instance = self
2572 .table
2573 .get(root)
2574 .and_then(|d| d.compiled.lock().unwrap().disable_instance());
2575 let Some(instance) = instance else { continue };
2576 if label == EXIT_PROGRAM_LABEL {
2577 // Every program `initial` thread of the caller's program
2578 // instance ends, fork descendants included.
2579 for t in 0..self.table.count() as ProcessId {
2580 let Some(d) = self.table.get(t) else { continue };
2581 if d.program_thread
2582 && d.fork_parent.is_none()
2583 && (t == root
2584 || d.compiled.lock().unwrap().disable_instance() == Some(instance))
2585 {
2586 self.kill_subtree_except(t, requester);
2587 }
2588 }
2589 continue;
2590 }
2591 // `disable u1.blk;` (LRM 9.6.2, an instance path): the compiled
2592 // kernels of the descendant instance carry the scope under
2593 // `<requester instance>/<path>` and `/<absolute path>` (run.rs).
2594 if label.contains('.') {
2595 let keys = [format!("{instance}/{label}"), format!("/{label}")];
2596 for target in 0..self.table.count() as ProcessId {
2597 if target == requester || self.ended.contains_key(&target) {
2598 continue;
2599 }
2600 let exit = self.table.get(target).and_then(|d| {
2601 let compiled = d.compiled.lock().unwrap();
2602 keys.iter().find_map(|k| compiled.disable_exit(k))
2603 });
2604 if let Some(pc) = exit {
2605 self.redirect_process(target, pc);
2606 }
2607 }
2608 // ... and the fork branches of the block's process left
2609 // running (the process may be past the block, or finished):
2610 // activities enabled within the block end with it.
2611 let leaf = label.rsplit('.').next().unwrap_or(&label);
2612 for root in 0..self.table.count() as ProcessId {
2613 if root == requester
2614 || self.table.get(root).is_none_or(|d| d.fork_parent.is_some())
2615 {
2616 continue;
2617 }
2618 let owns = self.table.get(root).is_some_and(|d| {
2619 let compiled = d.compiled.lock().unwrap();
2620 keys.iter().any(|k| compiled.owns_disable_scope(k))
2621 });
2622 if owns {
2623 let mut branches = Vec::new();
2624 self.collect_disable_named(root, leaf, &mut branches);
2625 for t in branches {
2626 self.kill_subtree_except(t, requester);
2627 }
2628 }
2629 }
2630 continue;
2631 }
2632 // A fork branch of ANOTHER process of the instance named `label`
2633 // ends too. Done here, not in the synchronous half: the requester's
2634 // compiled process is locked while it runs.
2635 let mut branches = Vec::new();
2636 self.collect_disable_named_in_peers(root, &label, &mut branches);
2637 for t in branches {
2638 self.kill_subtree_except(t, requester);
2639 }
2640 for target in 0..self.table.count() as ProcessId {
2641 if target == requester || self.ended.contains_key(&target) {
2642 continue;
2643 }
2644 let exit = self.table.get(target).and_then(|d| {
2645 let compiled = d.compiled.lock().unwrap();
2646 (compiled.disable_instance() == Some(instance))
2647 .then(|| compiled.disable_exit(&label))
2648 .flatten()
2649 });
2650 if let Some(pc) = exit {
2651 self.redirect_process(target, pc);
2652 }
2653 }
2654 }
2655 }
2656
2657 /// Ends what `target` is waiting for and makes it run again at `pc` in
2658 /// this time step (a `disable` of the block or task it is inside, LRM
2659 /// 9.6.2): its timer, event wait, `wait`, semaphore or mailbox wait and the
2660 /// processes it joins are dropped; activations queued for it before this
2661 /// point are stale ([`Self::activation_floor`]).
2662 fn redirect_process(&mut self, target: ProcessId, pc: u32) {
2663 self.drop_waits(target);
2664 let was_joined = self
2665 .table
2666 .get(target)
2667 .map(|d| d.compiled.lock().unwrap().redirect_resume(pc))
2668 .unwrap_or(false);
2669 let children = self
2670 .table
2671 .get(target)
2672 .map(|d| d.fork_children.clone())
2673 .unwrap_or_default();
2674 if was_joined {
2675 // LRM 9.6.2: the activities enabled within the block end too.
2676 for child in children {
2677 if self.table.get(child).is_some() {
2678 self.kill_subtree_except(child, target);
2679 }
2680 }
2681 }
2682 if let Some(desc) = self.table.get_mut(target) {
2683 desc.waiting = false;
2684 desc.wait_set = None;
2685 desc.awaiting = None;
2686 desc.parked = None;
2687 desc.desensitized = false;
2688 if was_joined {
2689 desc.fork_remaining = 0;
2690 desc.wait_fork_remaining = None;
2691 desc.wait_order = None;
2692 }
2693 }
2694 let id = self.activation_seq.fetch_add(1, Ordering::SeqCst);
2695 self.activation_floor.insert(target, id);
2696 let act = PendingActivation {
2697 id,
2698 proc: target,
2699 arg: ResumeArg::Disabled,
2700 reconv_iter: 0,
2701 logical_order: (self.now, Region::Active, target as u64),
2702 };
2703 self.queues
2704 .lock()
2705 .unwrap()
2706 .push(Region::Active, act)
2707 .expect("Region::Active is never rejected by RegionQueues::push");
2708 }
2709
2710 /// `node` and every live descendant of it, post-order, EXCEPT `exclude`
2711 /// itself (`exclude`'s own table entry is left untouched -- it ends
2712 /// through its own CFG/abort-flag path, if at all; this call still
2713 /// recurses INTO `exclude`'s own children, since whatever `exclude`
2714 /// itself forked has no such self-ending mechanism and must be force-
2715 /// killed like any other descendant). Mirrors [`Self::kill_process`]
2716 /// exactly (including its "already reclaimed is silently skipped, not
2717 /// an error" posture) with that one exclusion added.
2718 fn kill_subtree_except(&mut self, node: ProcessId, exclude: ProcessId) {
2719 let children = self
2720 .table
2721 .get(node)
2722 .map(|d| d.fork_children.clone())
2723 .unwrap_or_default();
2724 for child in children {
2725 if self.table.get(child).is_some() {
2726 self.kill_subtree_except(child, exclude);
2727 }
2728 }
2729 if node != exclude && self.table.get(node).is_some() && !self.ended.contains_key(&node) {
2730 self.terminate_victim(node);
2731 }
2732 }
2733
2734 /// The top-level ancestor of `node` (follows `fork_parent` to its end) --
2735 /// [`EngineOps::disable_named`]'s search scope, so a `disable <label>;`
2736 /// run by one process never reaches a same-named branch spawned by a
2737 /// wholly unrelated top-level process of the same instance.
2738 fn fork_root(&self, node: ProcessId) -> ProcessId {
2739 let mut cur = node;
2740 while let Some(parent) = self.table.get(cur).and_then(|d| d.fork_parent) {
2741 cur = parent;
2742 }
2743 cur
2744 }
2745
2746 /// [`Self::collect_disable_named`] over the fork trees of the other
2747 /// top-level processes of `root`'s instance (the processes that share its
2748 /// [`CompiledProcess::disable_instance`]), for a `disable <name>;` of a
2749 /// fork branch's block that lives in another process (LRM 9.6.2).
2750 fn collect_disable_named_in_peers(
2751 &self,
2752 root: ProcessId,
2753 label: &str,
2754 out: &mut Vec<ProcessId>,
2755 ) {
2756 let instance = self
2757 .table
2758 .get(root)
2759 .and_then(|d| d.compiled.lock().unwrap().disable_instance());
2760 let Some(instance) = instance else { return };
2761 for other in 0..self.table.count() as ProcessId {
2762 if other == root || self.ended.contains_key(&other) {
2763 continue;
2764 }
2765 let Some(d) = self.table.get(other) else {
2766 continue;
2767 };
2768 if d.fork_parent.is_some()
2769 || d.compiled.lock().unwrap().disable_instance() != Some(instance)
2770 {
2771 continue;
2772 }
2773 self.collect_disable_named(other, label, out);
2774 }
2775 }
2776
2777 /// Every live process in `node`'s own fork subtree (`node` included)
2778 /// whose [`super::ProcessDescriptor::disable_names`] contains `label`,
2779 /// appended to `out` in no particular order -- `EngineOps::
2780 /// disable_named`'s match set. Recurses into every match too: a label
2781 /// reused by several concurrently live occurrences (a loop or a
2782 /// reentrant task forking the same named branch repeatedly) is not
2783 /// assumed unique, matching every one of them, never just the first.
2784 fn collect_disable_named(&self, node: ProcessId, label: &str, out: &mut Vec<ProcessId>) {
2785 let Some(desc) = self.table.get(node) else {
2786 return;
2787 };
2788 if desc.disable_names.iter().any(|n| n == label) {
2789 out.push(node);
2790 }
2791 for &child in &desc.fork_children {
2792 self.collect_disable_named(child, label, out);
2793 }
2794 }
2795
2796 // --- Classes plan Task 13 (LRM 9.7): the `process` class ---
2797
2798 /// Pushes an Active-region activation for `proc` (the shape the fork
2799 /// completion path above uses).
2800 fn push_active(&mut self, proc: ProcessId, arg: ResumeArg) {
2801 let act = PendingActivation {
2802 id: self.activation_seq.fetch_add(1, Ordering::SeqCst),
2803 proc,
2804 arg,
2805 reconv_iter: 0,
2806 logical_order: (self.now, Region::Active, proc as u64),
2807 };
2808 self.queues
2809 .lock()
2810 .unwrap()
2811 .push(Region::Active, act)
2812 .expect("Region::Active is never rejected by RegionQueues::push");
2813 }
2814
2815 /// Whether `node` is `ancestor` itself or one of its fork descendants
2816 /// (the `fork_parent` chain, which reclaimed ancestors break -- a
2817 /// descendant of a reclaimed process is not found, see `kill_process`).
2818 fn is_in_fork_subtree(&self, node: ProcessId, ancestor: ProcessId) -> bool {
2819 let mut cur = Some(node);
2820 while let Some(p) = cur {
2821 if p == ancestor {
2822 return true;
2823 }
2824 cur = self.table.get(p).and_then(|d| d.fork_parent);
2825 }
2826 false
2827 }
2828
2829 /// `process::status()`'s answer before any queued request is overlaid.
2830 fn base_process_state(&self, target: ProcessId) -> ProcessState {
2831 if let Some(&state) = self.ended.get(&target) {
2832 return state;
2833 }
2834 match self.table.get(target) {
2835 None if self.killed.contains(&target) => ProcessState::Killed,
2836 None => ProcessState::Finished,
2837 Some(d) if d.suspended => ProcessState::Suspended,
2838 Some(d) if d.waiting || d.awaiting.is_some() || d.sync_blocked => ProcessState::Waiting,
2839 Some(_) => ProcessState::Running,
2840 }
2841 }
2842
2843 /// `process::status()` (LRM 9.7): the base state with every request the
2844 /// calling process has queued but the engine has not yet applied laid
2845 /// over it, in call order -- so `p.kill(); p.status()` reads `KILLED`
2846 /// in the same segment.
2847 fn effective_process_state(&self, target: ProcessId) -> ProcessState {
2848 let mut state = self.base_process_state(target);
2849 for &(_, req) in self.pending_process_requests.lock().unwrap().iter() {
2850 match req {
2851 ProcessRequest::Kill(root) => {
2852 if !matches!(state, ProcessState::Finished | ProcessState::Killed)
2853 && self.is_in_fork_subtree(target, root)
2854 {
2855 state = ProcessState::Killed;
2856 }
2857 }
2858 ProcessRequest::Suspend(t) => {
2859 if t == target && matches!(state, ProcessState::Running | ProcessState::Waiting)
2860 {
2861 state = ProcessState::Suspended;
2862 }
2863 }
2864 ProcessRequest::Resume(t) => {
2865 if t == target && state == ProcessState::Suspended {
2866 state = ProcessState::Waiting;
2867 }
2868 }
2869 // Blocking in / waking from a synchronisation object is not
2870 // something the caller can observe through `status()` before
2871 // the engine applies it.
2872 ProcessRequest::Await(_)
2873 | ProcessRequest::SyncBlock
2874 | ProcessRequest::SyncWake(_) => {}
2875 }
2876 }
2877 state
2878 }
2879
2880 /// Applies every queued `process` request, in call order. Called at the
2881 /// top of [`Self::apply_suspend`].
2882 fn drain_pending_process_requests(&mut self) {
2883 let requests: Vec<(ProcessId, ProcessRequest)> =
2884 std::mem::take(&mut *self.pending_process_requests.lock().unwrap());
2885 for (requester, req) in requests {
2886 match req {
2887 ProcessRequest::Kill(target) => self.kill_process(target),
2888 ProcessRequest::Suspend(target) => self.suspend_process(target),
2889 ProcessRequest::Resume(target) => self.resume_process(target),
2890 ProcessRequest::Await(target) => self.register_await(requester, target),
2891 ProcessRequest::SyncBlock => self.apply_sync_block(requester),
2892 ProcessRequest::SyncWake(target) => self.apply_sync_wake(target),
2893 }
2894 }
2895 }
2896
2897 /// `p.kill()` (LRM 9.7.3): `target` and every live fork descendant end
2898 /// before this returns -- descendants first (post-order), `target` last.
2899 /// A `target` that already ended is skipped but its live descendants are
2900 /// still terminated (the LRM requires it). A descendant of a *reclaimed*
2901 /// process is not reachable (its `fork_parent` link died with the
2902 /// table entry); that gap is documented in the classes plan.
2903 fn kill_process(&mut self, target: ProcessId) {
2904 let children = self
2905 .table
2906 .get(target)
2907 .map(|d| d.fork_children.clone())
2908 .unwrap_or_default();
2909 for child in children {
2910 if self.table.get(child).is_some() {
2911 self.kill_process(child);
2912 }
2913 }
2914 if self.table.get(target).is_some() && !self.ended.contains_key(&target) {
2915 self.terminate_victim(target);
2916 }
2917 }
2918
2919 /// Ends one live process: drops its waits, records `KILLED`, reports it
2920 /// to its fork parent's join exactly like a normal completion
2921 /// (`ResumeDriver::on_child_done`, which also reclaims the table slot),
2922 /// and wakes its awaiters. Activations already queued for it are
2923 /// dropped at dispatch (`killed`).
2924 fn terminate_victim(&mut self, victim: ProcessId) {
2925 self.drop_waits(victim);
2926 let parent = self.table.get(victim).and_then(|d| d.fork_parent);
2927 // `Err(WaitOrderViolated)` has no reporting path (see the `Done`
2928 // arm of `apply_suspend`); the slot is reclaimed either way.
2929 let join = self.driver.on_child_done(&mut self.table, victim);
2930 if let (Some(parent), Ok(Some(arg))) = (parent, join) {
2931 self.push_active(parent, arg);
2932 }
2933 self.killed.insert(victim);
2934 self.note_exit(victim, ProcessState::Killed);
2935 }
2936
2937 /// Drops everything a process about to end is parked on. EVERY path that
2938 /// ends a live process (`process::kill`, `disable fork`, `disable` of an
2939 /// enclosing fork) goes through here before the table slot is reclaimed: a
2940 /// victim left in a semaphore or mailbox queue would be handed the next key
2941 /// or message and swallow it (the timeout idiom `fork s.get(1); #5;
2942 /// join_any disable fork;` would lose the following `put`).
2943 fn drop_waits(&mut self, victim: ProcessId) {
2944 self.sensitivity.remove_dynamic(victim);
2945 self.repeats.clear(victim);
2946 // A process killed while blocked in `semaphore.get` leaves the queue,
2947 // which can let the waiters behind it through.
2948 let mut released = Vec::new();
2949 crate::sched::class_event::cancel(victim);
2950 semaphore::cancel(victim, &mut |req| released.push(req));
2951 for req in released {
2952 if let ProcessRequest::SyncWake(target) = req {
2953 self.apply_sync_wake(target);
2954 }
2955 }
2956 // Likewise in a mailbox call: a blocked put/get/peek leaves its queue
2957 // and gives back what it held (a woken waiter's undelivered message).
2958 mailbox::cancel(victim);
2959 }
2960
2961 /// Records how a tracked process ended and wakes whoever is blocked in
2962 /// `await()` on it.
2963 fn note_exit(&mut self, proc: ProcessId, state: ProcessState) {
2964 if let Some(roots) = self.program_roots.as_mut() {
2965 roots.remove(&proc);
2966 }
2967 // 38.36.1 `cbEndOfThread`: the thread is deleted.
2968 if super::thread_hook_installed() {
2969 self.fire_thread_event_now(super::ThreadEvent::End {
2970 pid: proc,
2971 killed: state == ProcessState::Killed,
2972 });
2973 }
2974 if self.tracked.lock().unwrap().contains(&proc) {
2975 self.ended.entry(proc).or_insert(state);
2976 }
2977 for awaiter in self.awaiters.remove(&proc).unwrap_or_default() {
2978 if let Some(desc) = self.table.get_mut(awaiter)
2979 && desc.awaiting == Some(proc)
2980 {
2981 desc.awaiting = None;
2982 self.push_active(awaiter, ResumeArg::TimeElapsed);
2983 }
2984 }
2985 // `wait (p.status() == process::FINISHED)` sleeps on object state: a
2986 // process ending (finished or killed) is a status change no signal
2987 // reports (LRM 1800-2023 9.7). Only a tracked process has a status
2988 // handle to wait on: a statically sensitive process that finishes each
2989 // wake (a class covergroup's watcher, which object state wakes) is not
2990 // an ending, and notifying for it would wake itself forever.
2991 if crate::sched::object_state_watched() && self.tracked.lock().unwrap().contains(&proc) {
2992 let mut sink = EngineNotifySink {
2993 engine: self,
2994 at: self.now,
2995 reconv_iter: 0,
2996 delta: None,
2997 };
2998 crate::sched::notify_blocking_change(
2999 crate::sched::OBJECT_STATE_CELL,
3000 EdgeKind::None,
3001 self.now,
3002 &mut sink,
3003 );
3004 }
3005 }
3006
3007 /// `p.suspend()` (LRM 9.7.4): no effect on a process that already ended
3008 /// or is already suspended. A process blocked in an `@(...)` event
3009 /// control is desensitised: its dynamic sensitivity is removed, so an
3010 /// event that fires while it is suspended is missed, not replayed. A
3011 /// `wait(expr)` waiter keeps its sensitivity (its resumed kernel
3012 /// re-checks the condition, so a parked wake is harmless), and a delay
3013 /// or other wait keeps its timer/join, whose firing is parked and
3014 /// replayed by [`Self::resume_process`].
3015 fn suspend_process(&mut self, target: ProcessId) {
3016 if self.ended.contains_key(&target) {
3017 return;
3018 }
3019 let Some(desc) = self.table.get_mut(target) else {
3020 return;
3021 };
3022 if desc.suspended {
3023 return;
3024 }
3025 desc.suspended = true;
3026 let desensitize = desc.waiting
3027 && desc
3028 .wait_set
3029 .as_ref()
3030 .is_some_and(|set| !set.rechecks_on_wake);
3031 if desensitize {
3032 desc.desensitized = true;
3033 self.sensitivity.remove_dynamic(target);
3034 }
3035 }
3036
3037 /// `p.resume()` (LRM 9.7.5): a no-op unless `target` is suspended;
3038 /// otherwise it runs again. An activation parked while it was suspended
3039 /// is replayed (it was already runnable, or is a re-checking wait or a
3040 /// delay that expired); a desensitised `@(...)` waiter that nothing woke
3041 /// is resensitised to the same event expression.
3042 fn resume_process(&mut self, target: ProcessId) {
3043 let Some(desc) = self.table.get_mut(target) else {
3044 return;
3045 };
3046 if !desc.suspended {
3047 return;
3048 }
3049 desc.suspended = false;
3050 let was_desensitized = std::mem::take(&mut desc.desensitized);
3051 if let Some(arg) = desc.parked.take() {
3052 self.push_active(target, arg);
3053 } else if was_desensitized && let Some(set) = desc.wait_set.clone() {
3054 self.sensitivity.install_dynamic(&set, target);
3055 }
3056 }
3057
3058 /// `p.await()` (LRM 9.7): blocks `requester` until `target` ends. A
3059 /// `target` that already ended (or is `requester` itself, which the
3060 /// runtime shim reports as an error) blocks nothing.
3061 fn register_await(&mut self, requester: ProcessId, target: ProcessId) {
3062 if requester == target
3063 || self.ended.contains_key(&target)
3064 || self.table.get(target).is_none()
3065 {
3066 return;
3067 }
3068 if let Some(desc) = self.table.get_mut(requester) {
3069 desc.awaiting = Some(target);
3070 self.awaiters.entry(target).or_default().push(requester);
3071 }
3072 }
3073
3074 /// [`ProcessRequest::SyncBlock`]: the requester's `get` queued it on a
3075 /// semaphore. [`Self::apply_by_request`] then leaves it yielded.
3076 fn apply_sync_block(&mut self, requester: ProcessId) {
3077 if let Some(desc) = self.table.get_mut(requester) {
3078 desc.sync_blocked = true;
3079 }
3080 }
3081
3082 /// Applies the wakes an NBA/Re-NBA commit pass raised for the waiters of a
3083 /// class event a `->> e` triggered (LRM 15.5.1; `sched::class_event`): the
3084 /// pass has no process to carry the requests, so it parks them.
3085 fn apply_class_event_wakes(&mut self) {
3086 for req in super::class_event::take_deferred_wakes() {
3087 if let ProcessRequest::SyncWake(target) = req {
3088 self.apply_sync_wake(target);
3089 }
3090 }
3091 }
3092
3093 /// [`ProcessRequest::SyncWake`]: `target`'s `get` was granted its keys, so
3094 /// it runs again (parked instead if `p.suspend()` suspended it meanwhile,
3095 /// like every other activation). A target that no longer exists was killed
3096 /// after being granted; its keys are gone with it. A live target that is
3097 /// not blocked would be a block/wake ordering bug -- `semaphore` emits
3098 /// `SyncBlock` before any `SyncWake` of the same process, and the queue
3099 /// preserves that order -- so it is reported loudly instead of resuming a
3100 /// running process a second time.
3101 fn apply_sync_wake(&mut self, target: ProcessId) {
3102 let Some(desc) = self.table.get_mut(target) else {
3103 return;
3104 };
3105 if !desc.sync_blocked && super::class_event::is_dual(target) {
3106 // A class event woke a process in a mixed `@(class_event or
3107 // signal ...)` wait (LRM 1800-2023 9.4.2.1): it also waits on
3108 // signals, so the signal side is dropped first (and an activation
3109 // a signal already queued is stale: `event_floor`), and the
3110 // process resumes as if an event fired (term `-1`: no signal term).
3111 self.sensitivity.remove_dynamic(target);
3112 self.repeats.clear(target);
3113 let floor = self.activation_seq.load(Ordering::SeqCst);
3114 self.event_floor.insert(target, floor);
3115 self.push_active(
3116 target,
3117 ResumeArg::EventFired {
3118 term_index: usize::MAX,
3119 },
3120 );
3121 return;
3122 }
3123 assert!(
3124 desc.sync_blocked,
3125 "internal: semaphore woke process {target}, which is not blocked in a get()"
3126 );
3127 desc.sync_blocked = false;
3128 self.push_active(target, ResumeArg::TimeElapsed);
3129 }
3130
3131 /// [`Suspend::ByRequest`]: the process yielded right after queueing a
3132 /// request, which [`Self::drain_pending_process_requests`] has just
3133 /// applied. It keeps going at once unless the request left it
3134 /// self-suspended (the activation is parked for `resume()`) or blocked
3135 /// in `await()` (`note_exit` wakes it).
3136 fn apply_by_request(&mut self, proc: ProcessId) {
3137 let Some(desc) = self.table.get_mut(proc) else {
3138 return;
3139 };
3140 if desc.suspended {
3141 desc.parked = Some(ResumeArg::TimeElapsed);
3142 } else if desc.awaiting.is_none() && !desc.sync_blocked {
3143 self.resume_synchronously(proc, ResumeArg::TimeElapsed);
3144 }
3145 }
3146
3147 // --- #[cfg(test)]-only accessors, added for exactly the tests below ---
3148
3149 #[cfg(test)]
3150 fn test_dispatch_ops(&self, now: SchedTime, region: Region, reconv_iter: u32) -> EngineOps<'_> {
3151 EngineOps {
3152 engine: self,
3153 now,
3154 region,
3155 reconv_iter,
3156 }
3157 }
3158
3159 #[cfg(test)]
3160 fn test_read_cell(&self, cell: StateCellId) -> FourStateValue {
3161 self.arena.lock().unwrap().read(cell)
3162 }
3163
3164 #[cfg(test)]
3165 fn test_nba_len(&self) -> usize {
3166 self.nba.lock().unwrap().len() + self.re_nba.lock().unwrap().len()
3167 }
3168
3169 #[cfg(test)]
3170 fn test_drain_active(&mut self) -> Vec<PendingActivation> {
3171 let popped = self.queues.get_mut().unwrap().drain_active();
3172 self.reset_dequeued(&popped);
3173 popped
3174 }
3175
3176 #[cfg(test)]
3177 fn test_drain_inactive(&mut self) -> Vec<PendingActivation> {
3178 let popped = self.queues.get_mut().unwrap().drain_inactive();
3179 self.reset_dequeued(&popped);
3180 popped
3181 }
3182
3183 #[cfg(test)]
3184 fn test_sensitivity_entry_count(&self) -> usize {
3185 self.sensitivity.entry_count()
3186 }
3187
3188 /// **Task 7 (B-sched wave, ledger SC-062, SC-115).** Whether `id` is
3189 /// still a live entry in this `Engine`'s own `ProcessTable` -- the
3190 /// literal accessor Task 7's own plan text names, used to prove a
3191 /// completed fork child's slot really was reclaimed (`SC-062`) and,
3192 /// conversely, that a `JoinKind::None` child that has NOT yet been
3193 /// dispatched is still live (`SC-115`).
3194 #[cfg(test)]
3195 fn test_process_exists(&self, id: ProcessId) -> bool {
3196 self.table.get(id).is_some()
3197 }
3198}
3199
3200impl Engine {
3201 /// [`SchedulerControl::register_process`]'s body, with the process's home
3202 /// region: `reactive` processes (LRM 17.7.3: a checker's procedures and
3203 /// continuous assignments) start and wake in the Reactive region, every
3204 /// other process in Active. See [`Self::register_reactive_process`].
3205 fn register_process_in(
3206 &mut self,
3207 proc: Box<dyn CompiledProcess>,
3208 kind: ProcessKind,
3209 sensitivity: Option<StaticSensitivity>,
3210 region_id: u32,
3211 reactive: bool,
3212 procedure: bool,
3213 ) -> ProcessId {
3214 let id = self.table.register(proc);
3215 // Task 14 (LRM 18.14.1): a static process is seeded from its
3216 // containing instance's initialization RNG, before it can ever draw.
3217 match self.registration_scope {
3218 Some(scope) if self.registration_static_initializer => {
3219 super::process_rng::alias_init(id, scope);
3220 }
3221 Some(scope) => {
3222 super::process_rng::preseed(id, super::process_rng::draw_from_init(scope));
3223 }
3224 // An id a finished initializer process left behind must not keep
3225 // its alias.
3226 None => super::process_rng::clear_alias(id),
3227 }
3228 if reactive {
3229 self.table.set_reactive(id);
3230 self.has_reactive.store(true, Ordering::Relaxed);
3231 }
3232 let home = if reactive {
3233 Region::Reactive
3234 } else {
3235 Region::Active
3236 };
3237 // LRM 9.2.2.2: an `always_comb` / `always_latch` "is automatically
3238 // triggered once at time zero, after all initial and always
3239 // procedures have been started". Its start activation therefore
3240 // goes to Inactive, which the time step drains only once Active has
3241 // quiesced (every other process has run to its first suspension),
3242 // so a sibling `always @(ev)` is already waiting for an event the
3243 // comb block triggers on that first run (ivtest br_gh508a).
3244 let start_region = match kind {
3245 ProcessKind::AlwaysComb | ProcessKind::AlwaysLatch if procedure => Region::Inactive,
3246 _ => home,
3247 };
3248 // Task 9 (A2-part2, CG-102): stores `region_id` for
3249 // `dispatch_ready_set` to look up later via `ProcessTable::
3250 // region_id` -- see `set_region_id`'s own doc comment for why this
3251 // is a separate call rather than a wider `register` signature.
3252 self.table.set_region_id(id, region_id);
3253 // The crate's one and only production call site of `install_static`
3254 // (SC-045) — called at most once per registration, never a second
3255 // time for the same `id` anywhere in this body.
3256 if let Some(sens) = sensitivity.as_ref() {
3257 self.sensitivity.install_static(sens, id);
3258 }
3259 match kind {
3260 ProcessKind::Final => {
3261 self.finals.push(FinalProcess {
3262 proc: id,
3263 registration_order: id as u64,
3264 });
3265 }
3266 ProcessKind::Initial
3267 | ProcessKind::Always
3268 | ProcessKind::AlwaysComb
3269 | ProcessKind::AlwaysLatch
3270 | ProcessKind::AlwaysFF => {
3271 let act = PendingActivation {
3272 id: self.activation_seq.fetch_add(1, Ordering::SeqCst),
3273 proc: id,
3274 arg: ResumeArg::Start,
3275 reconv_iter: 0,
3276 logical_order: (self.now, home, id as u64),
3277 };
3278 if start_region != home {
3279 // The pending start already is the process's first run: a
3280 // change landing before it must not queue a second one.
3281 self.gate.mark_queued_if_not_already(id);
3282 }
3283 self.queues.lock().unwrap().push(start_region, act).expect(
3284 "Region::Active and Region::Reactive are never rejected by RegionQueues::push",
3285 );
3286 }
3287 }
3288 id
3289 }
3290
3291 /// Registers the processes [`SchedulerOps::spawn_detached`] queued, each a
3292 /// one-shot Reactive-region process (16.11) that starts in the iteration
3293 /// that queued it.
3294 fn drain_pending_spawns(&mut self) {
3295 let spawned = std::mem::take(&mut *self.pending_spawns.lock().unwrap());
3296 for proc in spawned {
3297 self.register_process_in(proc, ProcessKind::Initial, None, 0, true, false);
3298 }
3299 }
3300
3301 /// Registers a process that runs in the Reactive region (LRM 17.7.3): a
3302 /// checker's procedures and continuous assignments "are scheduled in the
3303 /// Reactive region, similarly to programs". Its start activation and
3304 /// every wake-up (a static or dynamic sensitivity match) are queued in
3305 /// Reactive, which the time step drains after Active, Inactive, NBA and
3306 /// the Observed region have settled and re-enters the delta loop from when
3307 /// it leaves work behind (see `run_until`). Otherwise identical to
3308 /// [`SchedulerControl::register_process`].
3309 /// Marks `id` (just registered) as a program block's `initial` thread
3310 /// (LRM 24.3: "the simulation ends implicitly when all the program
3311 /// threads end"). Its fork descendants inherit the mark.
3312 pub fn mark_program_initial(&mut self, id: ProcessId) {
3313 if let Some(d) = self.table.get_mut(id) {
3314 d.program_thread = true;
3315 }
3316 self.program_roots
3317 .get_or_insert_with(Default::default)
3318 .insert(id);
3319 }
3320
3321 /// Whether the design has program `initial` threads and every one of them,
3322 /// with every fork descendant, has ended (LRM 24.3: implicit `$finish`).
3323 fn program_threads_all_ended(&self) -> bool {
3324 let Some(roots) = self.program_roots.as_ref() else {
3325 return false;
3326 };
3327 roots.is_empty() && !self.table.any_live_program_child()
3328 }
3329
3330 pub fn register_reactive_process(
3331 &mut self,
3332 proc: Box<dyn CompiledProcess>,
3333 kind: ProcessKind,
3334 sensitivity: Option<StaticSensitivity>,
3335 region_id: u32,
3336 ) -> ProcessId {
3337 self.register_process_in(proc, kind, sensitivity, region_id, true, false)
3338 }
3339
3340 /// [`SchedulerControl::register_process`] for a user-written procedure
3341 /// (`initial`/`always*`): an `always_comb`/`always_latch` starts after every
3342 /// other process of the time-zero Active region has started, LRM 1800-2023
3343 /// 9.2.2.2 ("automatically triggered once at time zero, after all initial
3344 /// and always procedures have been started"). A continuous assignment
3345 /// (also an `AlwaysComb` kernel) keeps `register_process`: it starts in
3346 /// registration order like any Active process.
3347 pub fn register_procedure(
3348 &mut self,
3349 proc: Box<dyn CompiledProcess>,
3350 kind: ProcessKind,
3351 sensitivity: Option<StaticSensitivity>,
3352 region_id: u32,
3353 ) -> ProcessId {
3354 self.register_process_in(proc, kind, sensitivity, region_id, false, true)
3355 }
3356
3357 /// Narrows `proc`'s `always_comb`/`always_latch` sensitivity on `cell` to
3358 /// the constant bit windows it reads (LRM 9.2.2.2.1, longest static
3359 /// prefix); see [`SensitivityTable::narrow_static`].
3360 pub fn narrow_static_sensitivity(
3361 &mut self,
3362 proc: ProcessId,
3363 cell: StateCellId,
3364 windows: &[(u32, u32)],
3365 ) {
3366 self.sensitivity.narrow_static(proc, cell, windows);
3367 }
3368}
3369
3370impl SchedulerControl for Engine {
3371 /// See this module's doc comment, deviation 2, for the additional
3372 /// `kind`-based branching beyond the plan's literal two statements.
3373 fn register_process(
3374 &mut self,
3375 proc: Box<dyn CompiledProcess>,
3376 kind: ProcessKind,
3377 sensitivity: Option<StaticSensitivity>,
3378 region_id: u32,
3379 ) -> ProcessId {
3380 self.register_process_in(proc, kind, sensitivity, region_id, false, false)
3381 }
3382
3383 /// scheduler.md §1's pseudocode, concretely: per timestep, an inner
3384 /// Active/Inactive/NBA delta-convergence loop (guarded by `DeltaGuard`,
3385 /// SC-079/SC-080), then Reactive/Postponed drained once each,
3386 /// `finish_requested` checked once per timestep (SC-075 — see this
3387 /// module's doc comment, deviation 4), then the wheel is asked for the
3388 /// next tick.
3389 fn run_until(&mut self, deadline: Option<SchedTime>) -> RunResult {
3390 loop {
3391 // The host's start of this time slot (`super::host`).
3392 self.host_slot_begin();
3393 loop {
3394 // LRM 1800-2023 §4.5: the Active region runs until it is
3395 // EMPTY -- including everything woken while it runs (a
3396 // continuous assignment reacting to `a = 10;`) -- before
3397 // the Inactive region (`#0`) moves in; each Inactive batch
3398 // then hands back to Active the same way.
3399 loop {
3400 let active_ready = self.drain_and_sort(Region::Active);
3401 if active_ready.is_empty() {
3402 let inactive_ready = self.drain_and_sort(Region::Inactive);
3403 if inactive_ready.is_empty() {
3404 break;
3405 }
3406 self.dispatch_ready_set(Region::Inactive, inactive_ready);
3407 } else {
3408 self.dispatch_ready_set(Region::Active, active_ready);
3409 }
3410 self.with_host(Region::Active, |h, ops| h.activity(ops));
3411 if let Some(msg) = self.sync_livelock.take() {
3412 return RunResult::Livelock(msg);
3413 }
3414 if let Err(e) = self.guard.tick(self.now) {
3415 return RunResult::Livelock(e.to_string());
3416 }
3417 }
3418 self.with_host(Region::Active, |h, ops| h.nba_synch(ops));
3419 let nba_changed = self.commit_nba_once();
3420 self.apply_class_event_wakes();
3421 self.with_host(Region::Active, |h, ops| h.activity(ops));
3422 let mut settled = {
3423 let queues = self.queues.lock().unwrap();
3424 queues.active_and_inactive_both_empty()
3425 };
3426 // sva T2 (§1.4): the identical Observed-drain-and-recheck
3427 // `advance_nba_region` (the single-stepper's own Nba arm)
3428 // applies, inlined here for `run_until`'s own delta loop --
3429 // see `Self::drain_observed_and_check_settled`'s doc
3430 // comment for why this is a re-check, not a one-shot drain.
3431 if !nba_changed && settled {
3432 settled = self.drain_observed_and_check_settled();
3433 // The host's read-write synch point: a write there runs
3434 // the loop again.
3435 if settled
3436 && self
3437 .with_host(Region::Active, |h, ops| h.rw_synch(ops))
3438 .unwrap_or(false)
3439 {
3440 settled = false;
3441 }
3442 if settled {
3443 break;
3444 }
3445 }
3446 if let Err(e) = self.guard.tick(self.now) {
3447 return RunResult::Livelock(e.to_string());
3448 }
3449 }
3450 // Task 21/SC-007: record this timestep's settled iteration count
3451 // into the running high-water mark before the guard resets it.
3452 self.max_delta_iterations_seen
3453 .fetch_max(self.guard.count(), Ordering::SeqCst);
3454 // LRM 4.5: the Reactive region (checker processes, assertion
3455 // action blocks) runs after Active/Inactive/NBA/Observed have
3456 // settled, and whatever it leaves behind -- a design process it
3457 // woke, a nonblocking assignment it staged (Re-NBA, collapsed
3458 // into the one NBA region), more Reactive work -- sends the time
3459 // step round the delta loop again. The delta guard keeps counting
3460 // across these rounds: a livelock through Reactive is as fatal as
3461 // one inside Active.
3462 self.drain_reactive_set();
3463 if self.reactive_left_work() {
3464 if let Err(e) = self.guard.tick(self.now) {
3465 return RunResult::Livelock(e.to_string());
3466 }
3467 continue;
3468 }
3469 self.guard.reset();
3470 self.forks_this_step = 0;
3471 let postponed_ready = self.drain_and_sort(Region::Postponed);
3472 self.dispatch_ready_set(Region::Postponed, postponed_ready);
3473 self.drain_region_callbacks(Region::Postponed);
3474 self.with_host(Region::Postponed, |h, ops| h.slot_end(ops));
3475 if let Some(hook) = self.timestep_end_hook.as_mut() {
3476 hook(self.now);
3477 }
3478
3479 if self.program_threads_all_ended() {
3480 self.finish_requested.lock().unwrap().get_or_insert(0);
3481 }
3482 if let Some(code) = *self.finish_requested.lock().unwrap() {
3483 return RunResult::Finished { code };
3484 }
3485 if let Some(d) = deadline
3486 && self.now >= d
3487 {
3488 return RunResult::Deadline;
3489 }
3490 match self.next_event_tick() {
3491 Some(t) => {
3492 self.advance_time_to(t);
3493 // sva T1 (§1.4, review fix r2, blocking finding 1): the
3494 // Preponed snapshot push fires here -- right where
3495 // `next_event_tick()` has just detected a genuinely new
3496 // nonempty time slot and `advance_time_to` actually
3497 // moved `self.now` -- mirroring `step_region`'s own
3498 // Postponed->Active transition (`Self::step_region`'s
3499 // `Region::Postponed` arm, `Some(t)` branch) exactly, so
3500 // both driver APIs push the ring the same number of
3501 // times for the same simulated history. An earlier
3502 // draft pushed unconditionally at the top of this outer
3503 // loop, which also fired on the very first iteration --
3504 // before `Region::Active` had ever been dispatched at
3505 // time 0 -- with no counterpart in `step_region` (`Engine
3506 // ::new` enters `Region::Active` directly, no Preponed
3507 // push precedes it there either), so the two APIs
3508 // produced differently-sized rings for the identical
3509 // history. See
3510 // `run_until_and_step_region_push_the_same_preponed_ring`
3511 // below for the regression proof.
3512 self.push_preponed_snapshot();
3513 }
3514 None => return RunResult::Completed,
3515 }
3516 }
3517 }
3518
3519 /// Single-steps through the 5-region cycle, one call per region (see
3520 /// this module's doc comment, deviation 3, for scope): `Active` ->
3521 /// `Inactive` -> `Nba` -> (back to `Active` if not yet settled, else)
3522 /// `Reactive` -> `Postponed` -> (wheel advance, back to `Active`, or
3523 /// `Blocked` if the wheel is empty too). A debugger-mock harness driving
3524 /// this repeatedly observes only committed state between calls: every
3525 /// write reaching STATE goes through `commit_blocking_write`/`commit_nba`
3526 /// synchronously, inside this call, never buffered across calls.
3527 fn step_region(&mut self) -> StepResult {
3528 match self.current_region {
3529 Region::Active | Region::Inactive => {
3530 let region = self.current_region;
3531 let ready = self.drain_and_sort(region);
3532 self.dispatch_ready_set(region, ready);
3533 self.current_region = if region == Region::Active {
3534 Region::Inactive
3535 } else {
3536 Region::Nba
3537 };
3538 StepResult::Advanced
3539 }
3540 Region::Nba => self.advance_nba_region(),
3541 Region::Reactive => {
3542 self.drain_reactive_set();
3543 // `run_until`'s loop, one region at a time: work the Reactive
3544 // region left behind sends the time step back to Active.
3545 if self.reactive_left_work() {
3546 self.guard.tick(self.now).expect(
3547 "SC-080/DT-050: delta-loop livelock guard exceeded -- hard error, \
3548 never warn-and-truncate",
3549 );
3550 self.current_region = Region::Active;
3551 } else {
3552 self.current_region = Region::Postponed;
3553 }
3554 StepResult::Advanced
3555 }
3556 Region::Postponed => {
3557 let ready = self.drain_and_sort(Region::Postponed);
3558 self.dispatch_ready_set(Region::Postponed, ready);
3559 self.drain_region_callbacks(Region::Postponed);
3560 match self.next_event_tick() {
3561 Some(t) => {
3562 self.advance_time_to(t);
3563 // sva T1 (§1.4, review fix r1, blocking finding 1):
3564 // the snapshot push is folded into this
3565 // Postponed->Active transition, but ONLY on the
3566 // branch that actually starts a new time step
3567 // (`advance_time_to` just ran, `self.now` genuinely
3568 // moved) -- the single-stepper's own counterpart of
3569 // `run_until`'s outer-loop-top push, which likewise
3570 // only ever runs again after `next_event_tick()`
3571 // returned `Some` and `advance_time_to` was called
3572 // (`run_until` returns `RunResult::Completed`
3573 // instead of looping again on `None`, so it never
3574 // re-pushes for a quiescent wheel either). Moving
3575 // this call out of the `None` arm below is the fix
3576 // itself -- see that arm's own doc comment.
3577 self.push_preponed_snapshot();
3578 self.current_region = Region::Active;
3579 StepResult::Advanced
3580 }
3581 None => {
3582 // sva T1 (§1.4, review fix r1, blocking finding 1):
3583 // **no** snapshot push here. `next_event_tick() ==
3584 // None` means the wheel is empty and nothing is
3585 // scheduled -- `self.now` does not advance and no
3586 // new time slot begins (LRM 16.5.1's sampled value
3587 // is defined per "time slot"; a repeated
3588 // `step_region()` call on an already-quiescent/
3589 // completed simulation re-enters this exact arm
3590 // without any new time slot ever starting). An
3591 // earlier draft pushed here unconditionally, so a
3592 // debugger-style caller single-stepping a
3593 // completed design re-pushed the SAME unchanged
3594 // snapshot on every call, silently inflating a
3595 // bounded ring with duplicate entries and evicting
3596 // genuinely distinct older ticks that a future
3597 // `$past` consumer needs intact. See
3598 // `step_region_blocked_does_not_repush_the_
3599 // preponed_ring` below for the regression proof.
3600 self.current_region = Region::Active;
3601 StepResult::Blocked
3602 }
3603 }
3604 }
3605 // sva T1 (§1.4, review fix, blocking finding 2): `self.
3606 // current_region` is never set to `Region::Preponed` anywhere
3607 // in this file -- the Preponed snapshot push is the
3608 // unconditional inline side effect above (and `run_until`'s own
3609 // outer-loop-top push for the full-run driver), never a state
3610 // the single-stepper transitions into. **Deviation from the
3611 // plan's literal text:** the plan's own prose claims
3612 // `step_region`'s match "keeps its original 5 arms and compiles
3613 // unchanged" -- not literally possible once `Region` gains a
3614 // 6th variant with no wildcard arm (`cargo build` confirms:
3615 // E0004 non-exhaustive patterns without this arm). This
3616 // defensive `unreachable!` arm is the minimal fix that keeps
3617 // the plan's INTENDED behavior (no real transition into this
3618 // region) while satisfying the compiler; the dedicated
3619 // regression test below proves it is in fact never reached.
3620 //
3621 // sva T2: `Region::Observed` joins this same defensive arm for
3622 // the identical reason -- per §1.4, "that re-check is exactly
3623 // the decision point already inside step_region's existing
3624 // Region::Nba => self.advance_nba_region() arm", so `Observed`
3625 // is drained from inside `advance_nba_region` (folded into the
3626 // Nba->Reactive transition, see
3627 // `Self::drain_observed_and_check_settled`), never a state
3628 // `current_region` itself transitions into either. The plan's
3629 // own claim that this task "gains no new arm at all" means no
3630 // new TRANSITION logic, not that the match's pattern list is
3631 // unchanged -- the same E0004 constraint T1 already hit applies
3632 // again for the 7th variant, so `Observed` is folded into this
3633 // identical defensive arm rather than a second one.
3634 Region::Preponed | Region::Observed => unreachable!(
3635 "step_region: current_region must never be Region::Preponed or \
3636 Region::Observed -- the Preponed snapshot push is an inline side effect \
3637 folded into the Postponed->Active transition, and the Observed engine-step \
3638 drain is folded into the Nba->Reactive transition; neither is ever an \
3639 observable single-stepper state"
3640 ),
3641 }
3642 }
3643
3644 fn run_finals(&mut self) {
3645 // `self.finals` is taken out (and put back afterward) so `ops`'s
3646 // shared reborrow of `self` below doesn't conflict with the `&mut
3647 // Vec<FinalProcess>` `finals::run_finals` needs.
3648 let mut finals = std::mem::take(&mut self.finals);
3649 let handle = SimHandle(std::ptr::null_mut());
3650 {
3651 let ops = EngineOps {
3652 engine: &*self,
3653 now: self.now,
3654 region: Region::Active,
3655 reconv_iter: 0,
3656 };
3657 // LRM 9.2.3: `$finish` from within a final procedure ends the
3658 // simulation immediately, so no later final runs. The request
3659 // that ended the simulation is set aside while they run.
3660 let ended_by = self.finish_requested.lock().unwrap().take();
3661 finals.sort_by_key(|f| f.registration_order);
3662 for f in finals.chunks_mut(1) {
3663 crate::sched::run_finals(f, &self.table, handle, &ops, &self.driver);
3664 if self.finish_requested.lock().unwrap().is_some() {
3665 break;
3666 }
3667 }
3668 let mut slot = self.finish_requested.lock().unwrap();
3669 if slot.is_none() {
3670 *slot = ended_by;
3671 }
3672 }
3673 self.finals = finals;
3674 }
3675
3676 fn now(&self) -> SchedTime {
3677 self.now
3678 }
3679
3680 fn set_determinism_controller(&mut self, ctrl: Box<dyn DeterminismController>) {
3681 self.controller = ctrl;
3682 }
3683}
3684
3685/// The literal SC-116 deliverable: pairs each of the 9 ABI entry points
3686/// (`sim_abi_version` excluded — versioning is `abi`-owned) with the
3687/// `Engine`/`SchedulerControl` method or internal pass it calls.
3688/// Documentation-as-data (a table, not prose) so it's `assert_eq!`-testable
3689/// and can't silently go stale.
3690///
3691/// **`SimHandle` concurrent-call threading contract (workspace-abi.md's own
3692/// flagged open question):** one `Engine` instance is never called
3693/// concurrently for two different ABI entry points on the same `SimHandle`
3694/// — the engine itself serializes ABI entry (a `Mutex<Engine>` at the
3695/// `sv-sim-runtime` loader boundary, outside this crate); concurrency inside
3696/// a run happens only *below* the ABI boundary, inside
3697/// `DeterminismController::partition`/`drain_and_resolve`.
3698pub fn abi_entry_point_map() -> &'static [(&'static str, &'static str)] {
3699 &[
3700 (
3701 sv_sim_abi::entry::SIM_INIT_SYMBOL,
3702 "Engine::new (+ SchedulerControl::register_process for each elaborated process)",
3703 ),
3704 (
3705 sv_sim_abi::entry::SIM_SETTLE_SYMBOL,
3706 "Engine::run_until(Some(now)) -- settle all zero-delay activity at the current time",
3707 ),
3708 (
3709 sv_sim_abi::entry::SIM_EVAL_REGION_SYMBOL,
3710 "Engine::step_region",
3711 ),
3712 (
3713 sv_sim_abi::entry::SIM_COMMIT_NBA_SYMBOL,
3714 "Engine::<internal NBA commit pass -- commit_nba, Task 7/13>",
3715 ),
3716 (
3717 sv_sim_abi::entry::SIM_SAVE_SYMBOL,
3718 "Engine::<checkpoint -- DeterminismController-owned, not materialized this wave>",
3719 ),
3720 (
3721 sv_sim_abi::entry::SIM_RESTORE_SYMBOL,
3722 "Engine::<restore -- DeterminismController-owned, not materialized this wave>",
3723 ),
3724 (
3725 sv_sim_abi::entry::SIM_STEP_TIME_SYMBOL,
3726 "Engine::run_until(Some(deadline)) -- advance exactly one timestep",
3727 ),
3728 (sv_sim_abi::entry::SIM_FINAL_SYMBOL, "Engine::run_finals"),
3729 (
3730 sv_sim_abi::entry::SIM_PROCESS_COUNT_SYMBOL,
3731 "Engine::<ProcessTable length accessor>",
3732 ),
3733 ]
3734}
3735
3736#[cfg(test)]
3737mod tests {
3738 use super::*;
3739 use crate::sched::{ActivationId, EventTerm, EventWaitSet, TimerEntry};
3740 use std::collections::HashMap;
3741 use std::sync::{Arc, Mutex as StdMutex};
3742
3743 // --- shared test doubles (independently declared -- see this module's
3744 // doc comment: neither `notify.rs`'s/`ops.rs`'s `FakeArena` nor Task 8's
3745 // `MockSchedulerOps` is reachable from this module) ---
3746
3747 struct TestArena(HashMap<StateCellId, FourStateValue>);
3748
3749 impl TestArena {
3750 fn new() -> Self {
3751 TestArena(HashMap::new())
3752 }
3753
3754 fn seeded(cell: StateCellId, value: FourStateValue) -> Self {
3755 let mut m = HashMap::new();
3756 m.insert(cell, value);
3757 TestArena(m)
3758 }
3759 }
3760
3761 impl SignalAccess for TestArena {
3762 fn read(&self, cell: StateCellId) -> FourStateValue {
3763 self.0
3764 .get(&cell)
3765 .cloned()
3766 .unwrap_or(FourStateValue::Narrow(0))
3767 }
3768
3769 fn write(&mut self, cell: StateCellId, value: FourStateValue) -> bool {
3770 let changed = match self.0.get(&cell) {
3771 Some(FourStateValue::Narrow(existing)) => {
3772 if let FourStateValue::Narrow(new) = &value {
3773 *existing != *new
3774 } else {
3775 true
3776 }
3777 }
3778 Some(FourStateValue::Wide { .. }) => true,
3779 None => true,
3780 };
3781 self.0.insert(cell, value);
3782 changed
3783 }
3784
3785 fn subscribe(
3786 &mut self,
3787 _cell: StateCellId,
3788 _role: crate::state::ListenerRole,
3789 _sink: Box<dyn ValueChangeSink + Send>,
3790 ) {
3791 // Not exercised by this task's tests.
3792 }
3793 }
3794
3795 #[derive(Default)]
3796 struct TestIoSink {
3797 calls: StdMutex<Vec<(SchedTime, Region, u64)>>,
3798 }
3799
3800 impl IoSink for TestIoSink {
3801 fn enqueue(&self, at: SchedTime, region: Region, seq: u64, cb: DeferredIoCallback) {
3802 drop(cb);
3803 self.calls.lock().unwrap().push((at, region, seq));
3804 }
3805 }
3806
3807 fn narrow(v: u128) -> FourStateValue {
3808 FourStateValue::Narrow(v)
3809 }
3810
3811 fn new_engine() -> Engine {
3812 Engine::new(Box::new(TestArena::new()), Box::new(TestIoSink::default()))
3813 }
3814
3815 // --- register_process / run_until / run_finals process fixtures ---
3816
3817 /// Logs `(ProcessId, ResumeArg)` for every `resume()` call, always
3818 /// returning `Suspend::Done` (never re-arms). `id_for_log` is filled in
3819 /// (via the shared cell) only *after* `register_process` returns the
3820 /// real id -- `resume()` is never called before registration completes,
3821 /// so it is always `Some` by the time any log entry is pushed.
3822 struct LoggingProcess {
3823 id_for_log: Arc<StdMutex<Option<ProcessId>>>,
3824 log: Arc<StdMutex<Vec<(ProcessId, &'static str)>>>,
3825 }
3826
3827 impl CompiledProcess for LoggingProcess {
3828 fn resume(
3829 &mut self,
3830 _state: SimHandle,
3831 arg: ResumeArg,
3832 _ops: &dyn SchedulerOps,
3833 ) -> Suspend {
3834 let tag = match arg {
3835 ResumeArg::Start => "Start",
3836 ResumeArg::TimeElapsed => "TimeElapsed",
3837 ResumeArg::EventFired { .. } => "EventFired",
3838 ResumeArg::ForkChildDone { .. } => "ForkChildDone",
3839 ResumeArg::Disabled => "Disabled",
3840 ResumeArg::AllForkChildrenDone => "AllForkChildrenDone",
3841 ResumeArg::WaitOrderSatisfied => "WaitOrderSatisfied",
3842 };
3843 let id = self
3844 .id_for_log
3845 .lock()
3846 .unwrap()
3847 .expect("id_for_log must be set before resume() is ever called");
3848 self.log.lock().unwrap().push((id, tag));
3849 Suspend::Done
3850 }
3851 }
3852
3853 fn logging_process(
3854 log: &Arc<StdMutex<Vec<(ProcessId, &'static str)>>>,
3855 ) -> (Box<LoggingProcess>, Arc<StdMutex<Option<ProcessId>>>) {
3856 let id_cell = Arc::new(StdMutex::new(None));
3857 (
3858 Box::new(LoggingProcess {
3859 id_for_log: id_cell.clone(),
3860 log: log.clone(),
3861 }),
3862 id_cell,
3863 )
3864 }
3865
3866 // --- SC-083 ---
3867
3868 #[test]
3869 fn sequential_controller_is_selected_when_none_is_ever_set() {
3870 let mut engine = new_engine();
3871 let log = Arc::new(StdMutex::new(Vec::new()));
3872 let (proc, id_cell) = logging_process(&log);
3873 let id = engine.register_process(proc, ProcessKind::Initial, None, 0);
3874 *id_cell.lock().unwrap() = Some(id);
3875
3876 let result = engine.run_until(Some(SchedTime { ticks: 100 }));
3877 assert!(matches!(result, RunResult::Completed));
3878 assert_eq!(engine.controller_kind(), "sequential");
3879 }
3880
3881 /// Mutation-test kill: `run_until`'s deadline check must fire on
3882 /// `self.now >= deadline`, not `self.now > deadline` -- the two agree
3883 /// for every test elsewhere in this file that names a deadline strictly
3884 /// later than where the run would otherwise settle, so only an exact
3885 /// `now == deadline` boundary distinguishes them. A process that settles
3886 /// immediately with no delay leaves `now` at its initial `SchedTime{
3887 /// ticks: 0}`; a deadline of exactly `0` must therefore report
3888 /// `RunResult::Deadline` on the very first pass, not silently continue to
3889 /// `RunResult::Completed`.
3890 #[test]
3891 fn run_until_reports_deadline_when_now_exactly_equals_it() {
3892 let mut engine = new_engine();
3893 engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
3894
3895 let result = engine.run_until(Some(SchedTime { ticks: 0 }));
3896 assert!(
3897 matches!(result, RunResult::Deadline),
3898 "now has already reached the deadline exactly (both 0) -- must report \
3899 RunResult::Deadline, not RunResult::Completed; got {result:?}"
3900 );
3901 }
3902
3903 #[test]
3904 fn set_determinism_controller_overrides_the_default() {
3905 struct FakeController;
3906 impl DeterminismController for FakeController {
3907 fn partition(
3908 &self,
3909 _ready: &[PendingActivation],
3910 _region: Region,
3911 _now: SchedTime,
3912 ) -> Vec<crate::determinism::WorkItem> {
3913 vec![]
3914 }
3915 fn on_read(&self, _act: ActivationId, _cell: StateCellId, _version: u64) {}
3916 fn on_write(
3917 &self,
3918 _act: ActivationId,
3919 _cell: StateCellId,
3920 _prior: FourStateValue,
3921 _version: u64,
3922 ) {
3923 }
3924 fn gvt(&self) -> SchedTime {
3925 SchedTime { ticks: 0 }
3926 }
3927 fn drain_and_resolve(
3928 &self,
3929 _work: Vec<crate::determinism::WorkItem>,
3930 ) -> crate::determinism::ResolvedBatch {
3931 crate::determinism::ResolvedBatch::default()
3932 }
3933 fn kind(&self) -> &'static str {
3934 "fake-tagged"
3935 }
3936 }
3937
3938 let mut engine = new_engine();
3939 assert_eq!(engine.controller_kind(), "sequential");
3940 engine.set_determinism_controller(Box::new(FakeController));
3941 assert_eq!(engine.controller_kind(), "fake-tagged");
3942 }
3943
3944 /// SC-083 — a debugger-mock harness single-steps through regions via
3945 /// `step_region()`, observing only committed state between steps: `p1`
3946 /// (Initial) blocking-writes `cell_a`; `p2` (AlwaysComb, statically
3947 /// sensitive to `cell_a`) is woken by that write and blocking-writes
3948 /// `cell_b`. Every read via the engine's arena accessor, at every point
3949 /// during the multi-call sequence, shows either the pre-write default or
3950 /// the fully-committed written value -- never anything else, since
3951 /// `commit_blocking_write` only ever applies synchronously.
3952 #[test]
3953 fn step_region_single_steps_exposing_only_committed_state() {
3954 struct P1 {
3955 cell_a: StateCellId,
3956 }
3957 impl CompiledProcess for P1 {
3958 fn resume(
3959 &mut self,
3960 _state: SimHandle,
3961 arg: ResumeArg,
3962 ops: &dyn SchedulerOps,
3963 ) -> Suspend {
3964 if matches!(arg, ResumeArg::Start) {
3965 ops.commit_blocking_write(self.cell_a, narrow(1));
3966 }
3967 Suspend::Done
3968 }
3969 }
3970 struct P2 {
3971 cell_b: StateCellId,
3972 }
3973 impl CompiledProcess for P2 {
3974 fn resume(
3975 &mut self,
3976 _state: SimHandle,
3977 arg: ResumeArg,
3978 ops: &dyn SchedulerOps,
3979 ) -> Suspend {
3980 if matches!(arg, ResumeArg::EventFired { .. }) {
3981 ops.commit_blocking_write(self.cell_b, narrow(1));
3982 }
3983 Suspend::Done
3984 }
3985 }
3986
3987 let cell_a = StateCellId(1);
3988 let cell_b = StateCellId(2);
3989 let mut engine = new_engine();
3990 engine.register_process(Box::new(P1 { cell_a }), ProcessKind::Initial, None, 0);
3991 engine.register_process(
3992 Box::new(P2 { cell_b }),
3993 ProcessKind::AlwaysComb,
3994 Some(StaticSensitivity {
3995 edges: vec![(cell_a, None)],
3996 }),
3997 0,
3998 );
3999
4000 // Before anything runs: both cells read the pre-write default.
4001 assert!(matches!(
4002 engine.test_read_cell(cell_a),
4003 FourStateValue::Narrow(0)
4004 ));
4005 assert!(matches!(
4006 engine.test_read_cell(cell_b),
4007 FourStateValue::Narrow(0)
4008 ));
4009
4010 let mut saw_a_committed = false;
4011 let mut saw_b_committed = false;
4012 for _ in 0..16 {
4013 engine.step_region();
4014 let a = engine.test_read_cell(cell_a);
4015 let b = engine.test_read_cell(cell_b);
4016 // At every single point, each cell is exactly its pre-write
4017 // default or its one fully-committed value -- never a third,
4018 // "partially applied" value.
4019 assert!(matches!(
4020 a,
4021 FourStateValue::Narrow(0) | FourStateValue::Narrow(1)
4022 ));
4023 assert!(matches!(
4024 b,
4025 FourStateValue::Narrow(0) | FourStateValue::Narrow(1)
4026 ));
4027 if matches!(a, FourStateValue::Narrow(1)) {
4028 saw_a_committed = true;
4029 }
4030 if matches!(b, FourStateValue::Narrow(1)) {
4031 // cell_b only ever becomes 1 after cell_a already did --
4032 // p2's write is causally downstream of p1's.
4033 assert!(saw_a_committed, "cell_b committed before cell_a");
4034 saw_b_committed = true;
4035 }
4036 }
4037 assert!(
4038 saw_a_committed,
4039 "p1's write was never observed as committed"
4040 );
4041 assert!(
4042 saw_b_committed,
4043 "p2's woken write was never observed as committed"
4044 );
4045 }
4046
4047 /// sva SV-D (LRM 17.7.3, 4.5): a Reactive process (a checker procedure) runs only after
4048 /// the Active/Inactive/NBA queues have emptied, however many Active processes the
4049 /// time step's earlier regions chain through, and the work it leaves behind (here an Active
4050 /// process woken by its write) is run in the same time step -- the Reactive-to-Active
4051 /// feedback of the delta loop -- not deferred to the next `run_until` call.
4052 #[test]
4053 fn reactive_process_runs_after_active_settles_and_feeds_back_to_active() {
4054 struct Tracer {
4055 name: &'static str,
4056 log: Arc<StdMutex<Vec<String>>>,
4057 write: Option<(StateCellId, u128)>,
4058 }
4059 impl CompiledProcess for Tracer {
4060 fn resume(
4061 &mut self,
4062 _state: SimHandle,
4063 arg: ResumeArg,
4064 ops: &dyn SchedulerOps,
4065 ) -> Suspend {
4066 let tag = match arg {
4067 ResumeArg::Start => "start",
4068 ResumeArg::EventFired { .. } => "event",
4069 _ => "other",
4070 };
4071 self.log
4072 .lock()
4073 .unwrap()
4074 .push(format!("{}:{tag}", self.name));
4075 if let Some((cell, v)) = self.write {
4076 ops.commit_blocking_write(cell, narrow(v));
4077 }
4078 Suspend::Done
4079 }
4080 }
4081 let (a, b, c) = (StateCellId(1), StateCellId(2), StateCellId(3));
4082 let log = Arc::new(StdMutex::new(Vec::new()));
4083 let tracer = |name, write| {
4084 Box::new(Tracer {
4085 name,
4086 log: log.clone(),
4087 write,
4088 })
4089 };
4090 let sens = |cell| {
4091 Some(StaticSensitivity {
4092 edges: vec![(cell, None)],
4093 })
4094 };
4095 let mut engine = new_engine();
4096 // Registered Reactive-process-first: the id order must not matter.
4097 // `r` (Reactive) is woken by `b`, which the Active `b_w` writes after `a`
4098 // changed; it writes `c`, which wakes the Active `c_w`.
4099 engine.register_reactive_process(
4100 tracer("r", Some((c, 1))),
4101 ProcessKind::AlwaysComb,
4102 sens(b),
4103 0,
4104 );
4105 engine.register_process(tracer("w", Some((a, 1))), ProcessKind::Initial, None, 0);
4106 engine.register_process(
4107 tracer("b_w", Some((b, 1))),
4108 ProcessKind::AlwaysComb,
4109 sens(a),
4110 0,
4111 );
4112 engine.register_process(tracer("c_w", None), ProcessKind::AlwaysComb, sens(c), 0);
4113
4114 let result = engine.run_until(None);
4115 assert!(matches!(result, RunResult::Completed));
4116 let log = log.lock().unwrap().clone();
4117 let pos = |s: &str| {
4118 log.iter()
4119 .position(|e| e == s)
4120 .unwrap_or_else(|| panic!("{s} never ran: {log:?}"))
4121 };
4122 // The Reactive process's start activation is not run in Active: both Active
4123 // chains (w -> b_w, and c_w's own start) complete before any `r` entry.
4124 let first_r = log.iter().position(|e| e.starts_with("r:")).unwrap();
4125 assert!(
4126 pos("w:start") < first_r && pos("b_w:event") < first_r && pos("c_w:start") < first_r,
4127 "a Reactive process ran before the Active region settled: {log:?}"
4128 );
4129 // Feedback: the Active process woken by `r`'s write ran in the same time step.
4130 assert!(
4131 pos("c_w:event") > pos("r:event"),
4132 "the Active wake-up of a Reactive write was not run: {log:?}"
4133 );
4134 }
4135
4136 /// Mutation-test kill: `step_region`'s `Active`/`Inactive` arm advances
4137 /// `current_region` to `Inactive` from `Active` and to `Nba` from
4138 /// `Inactive` -- a mutant that swapped those two targets passes every
4139 /// other test in this file, since none of them observe `current_region`
4140 /// directly (they only observe committed cell state or dispatch logs,
4141 /// which happen to still converge to the same eventual outcome for a
4142 /// design with no `Inactive`-region-specific behavior). This test reads
4143 /// `Engine::current_region` directly -- a private field, reachable here
4144 /// since this test module is a descendant of `control`.
4145 #[test]
4146 fn step_region_advances_active_to_inactive_to_nba_in_order() {
4147 let mut engine = new_engine();
4148 assert_eq!(
4149 engine.current_region,
4150 Region::Active,
4151 "a fresh Engine starts in Active"
4152 );
4153
4154 engine.step_region();
4155 assert_eq!(
4156 engine.current_region,
4157 Region::Inactive,
4158 "Active must step to Inactive next, never Nba"
4159 );
4160
4161 engine.step_region();
4162 assert_eq!(
4163 engine.current_region,
4164 Region::Nba,
4165 "Inactive must step to Nba next, never back to Inactive"
4166 );
4167 }
4168
4169 /// sva T1 (§1.4, review fix, blocking finding 2): drives the
4170 /// single-stepper through many full 5-region cycles (well past the
4171 /// `register_wait`-style edge cases) and asserts `current_region` is
4172 /// never observed to be `Region::Preponed` at any point -- the
4173 /// behavioral half of `step_region`'s own defensive `unreachable!`
4174 /// arm: if that arm were ever actually reached, this test would panic
4175 /// (via the `unreachable!` itself) rather than silently pass.
4176 #[test]
4177 fn step_region_never_observes_current_region_eq_preponed() {
4178 let mut engine = new_engine();
4179 engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
4180
4181 for _ in 0..30 {
4182 engine.step_region();
4183 assert_ne!(
4184 engine.current_region(),
4185 Region::Preponed,
4186 "the single-stepper must never transition into Region::Preponed -- the \
4187 Preponed snapshot push is an inline side effect, never an observable region"
4188 );
4189 }
4190 }
4191
4192 /// sva T2 (§1.4, review fix, blocking finding 2): the identical
4193 /// behavioral proof as `step_region_never_observes_current_region_eq_
4194 /// preponed`, now for `Region::Observed` -- if `step_region`'s
4195 /// defensive `unreachable!` arm (now covering both variants) were ever
4196 /// actually reached for `Observed`, this test would panic rather than
4197 /// silently pass.
4198 #[test]
4199 fn step_region_never_observes_current_region_eq_observed() {
4200 let mut engine = new_engine();
4201 engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
4202
4203 for _ in 0..30 {
4204 engine.step_region();
4205 assert_ne!(
4206 engine.current_region(),
4207 Region::Observed,
4208 "the single-stepper must never transition into Region::Observed -- the \
4209 Observed engine-step drain is folded into advance_nba_region, never an \
4210 observable region"
4211 );
4212 }
4213 }
4214
4215 /// sva T1 r1 (review fix, blocking finding 1): `step_region`'s
4216 /// `Region::Postponed` arm must push the configured Preponed ring
4217 /// exactly once per time step that genuinely BEGINS -- never again
4218 /// merely because a caller keeps single-stepping an already-quiescent
4219 /// simulation. A one-shot process (`Suspend::Delay(1)` on its first
4220 /// `Start` resume, then `Suspend::Done` on its second) arms the wheel
4221 /// for exactly one real tick transition and then goes permanently
4222 /// quiet, so `next_event_tick()` returns `Some` exactly once across the
4223 /// whole run and `None` (`StepResult::Blocked`) on every Postponed arm
4224 /// after that -- precisely the "ordinary debugger-driver" shape the
4225 /// finding's own live repro used (30 `step_region()` calls on a
4226 /// completed/quiescent design hit `StepResult::Blocked` repeatedly).
4227 /// This proves both halves of the fix: the push DOES fire on the one
4228 /// real `Some(t)` transition, and it does NOT fire again on any of the
4229 /// many subsequent `None`/`Blocked` cycles -- a regression to the
4230 /// unconditional-push bug (pushing in both match arms) would fail the
4231 /// second assertion by inflating `push_count` past `1`.
4232 #[test]
4233 fn step_region_blocked_does_not_repush_the_preponed_ring() {
4234 use crate::state::{StateEngine, TemplateRegistry};
4235 use std::cell::Cell;
4236 use std::rc::Rc;
4237
4238 struct OnceDelayed {
4239 started: bool,
4240 }
4241 impl CompiledProcess for OnceDelayed {
4242 fn resume(
4243 &mut self,
4244 _state: SimHandle,
4245 arg: ResumeArg,
4246 _ops: &dyn SchedulerOps,
4247 ) -> Suspend {
4248 match (self.started, arg) {
4249 (false, ResumeArg::Start) => {
4250 self.started = true;
4251 Suspend::Delay(1)
4252 }
4253 _ => Suspend::Done,
4254 }
4255 }
4256 }
4257
4258 let mut engine = new_engine();
4259 let push_count = Rc::new(Cell::new(0u32));
4260 {
4261 let push_count = push_count.clone();
4262 // A fresh StateEngine purely to mint real `ArenaSnapshot`
4263 // values -- this test counts invocations of the source
4264 // closure, never the snapshotted content itself (already
4265 // covered by `sva_preponed_sampling_matches_lrm_16_5_1` above).
4266 let state_engine = StateEngine::new(TemplateRegistry::new());
4267 engine.configure_preponed_ring(0, move || {
4268 push_count.set(push_count.get() + 1);
4269 state_engine.snapshot(None)
4270 });
4271 }
4272 engine.register_process(
4273 Box::new(OnceDelayed { started: false }),
4274 ProcessKind::Initial,
4275 None,
4276 0,
4277 );
4278
4279 let mut blocked_seen = 0;
4280 for _ in 0..30 {
4281 if engine.step_region() == StepResult::Blocked {
4282 blocked_seen += 1;
4283 }
4284 }
4285 assert!(
4286 blocked_seen > 0,
4287 "this 30-call sequence must actually reach StepResult::Blocked at least once \
4288 (it does, repeatedly, once the one armed tick has fired and gone quiet), or \
4289 the regression assertion below would be vacuous"
4290 );
4291 assert_eq!(
4292 push_count.get(),
4293 1,
4294 "exactly one real time-step transition occurred (the single Delay(1)) -- the \
4295 ring must have been pushed exactly once, never once per StepResult::Blocked \
4296 call (that was the bug: a quiescent/completed single-stepper re-pushed the \
4297 same unchanged snapshot on every call, silently inflating a bounded ring and \
4298 evicting genuinely distinct older ticks a future $past consumer needs intact)"
4299 );
4300 }
4301
4302 /// sva T1 r2 (review fix, blocking finding 1): `run_until` and
4303 /// `step_region` are two separate driver entry points for the exact
4304 /// same `Engine`/`push_preponed_snapshot` mechanism (`Self::
4305 /// push_preponed_snapshot`'s own doc comment), so the identical
4306 /// simulated history must push the Preponed ring the same number of
4307 /// times regardless of which one drives it. This drives the SAME
4308 /// one-shot process (`Suspend::Delay(1)` then `Suspend::Done` -- the
4309 /// exact repro the finding's own live check used) through two freshly
4310 /// constructed engines, one via `run_until(None)` and one via 30
4311 /// `step_region()` calls (mirroring `step_region_blocked_does_not_
4312 /// repush_the_preponed_ring` immediately above), and asserts both
4313 /// counted exactly one push. Before the fix, `run_until`'s outer loop
4314 /// pushed unconditionally at the top -- including on its very first
4315 /// iteration, before `Region::Active` had ever been dispatched at time
4316 /// 0 -- so this process's `run_until` push count was 2 while its
4317 /// `step_region` push count was 1 for the identical history; this test
4318 /// fails on that regression by catching the mismatch directly, not by
4319 /// hard-coding either side's count in isolation.
4320 #[test]
4321 fn run_until_and_step_region_push_the_same_preponed_ring() {
4322 use crate::state::{StateEngine, TemplateRegistry};
4323 use std::cell::Cell;
4324 use std::rc::Rc;
4325
4326 struct OnceDelayed {
4327 started: bool,
4328 }
4329 impl CompiledProcess for OnceDelayed {
4330 fn resume(
4331 &mut self,
4332 _state: SimHandle,
4333 arg: ResumeArg,
4334 _ops: &dyn SchedulerOps,
4335 ) -> Suspend {
4336 match (self.started, arg) {
4337 (false, ResumeArg::Start) => {
4338 self.started = true;
4339 Suspend::Delay(1)
4340 }
4341 _ => Suspend::Done,
4342 }
4343 }
4344 }
4345
4346 fn count_pushes_via(driver: impl FnOnce(&mut Engine)) -> u32 {
4347 let mut engine = new_engine();
4348 let push_count = Rc::new(Cell::new(0u32));
4349 {
4350 let push_count = push_count.clone();
4351 let state_engine = StateEngine::new(TemplateRegistry::new());
4352 engine.configure_preponed_ring(0, move || {
4353 push_count.set(push_count.get() + 1);
4354 state_engine.snapshot(None)
4355 });
4356 }
4357 engine.register_process(
4358 Box::new(OnceDelayed { started: false }),
4359 ProcessKind::Initial,
4360 None,
4361 0,
4362 );
4363 driver(&mut engine);
4364 push_count.get()
4365 }
4366
4367 let run_until_pushes = count_pushes_via(|engine| {
4368 let result = engine.run_until(None);
4369 assert!(matches!(result, RunResult::Completed));
4370 });
4371 let step_region_pushes = count_pushes_via(|engine| {
4372 for _ in 0..30 {
4373 engine.step_region();
4374 }
4375 });
4376
4377 assert_eq!(
4378 run_until_pushes, step_region_pushes,
4379 "run_until and step_region must push the configured Preponed ring the same \
4380 number of times for the identical simulated history (one real Delay(1) \
4381 transition) -- a mismatch here means the two driver APIs disagree about what \
4382 'once per time step' means, which is exactly the asymmetry this regression \
4383 test exists to catch"
4384 );
4385 assert_eq!(
4386 run_until_pushes, 1,
4387 "exactly one real time-step transition occurred (the single Delay(1)); neither \
4388 driver may push at the engine's initial Region::Active entry at time 0 (no \
4389 Preponed push precedes it for either API -- Engine::new sets current_region = \
4390 Active directly)"
4391 );
4392 }
4393
4394 /// sva T1 end-to-end (§1.3/§1.4): a signal written in `Region::Active`
4395 /// is read back through a configured Preponed ring at the START of the
4396 /// following time steps -- the "stub consumer" this task's own plan
4397 /// describes (no `$past`/assertion lowering exists yet to read the ring
4398 /// from compiled SV source; `Engine::with_preponed_entry` is this
4399 /// task's own test-only substitute). Driven via [`Engine::step_region`]
4400 /// (never `run_until`'s deadline-based API) specifically so each
4401 /// Postponed->Active transition -- and so each ring push -- is counted
4402 /// exactly once (5 `step_region` calls == one full Active/Inactive/
4403 /// Nba/Reactive/Postponed cycle, the same convention
4404 /// `suspend_delay_logical_order_uses_absolute_time_across_chained_delays`
4405 /// above already establishes).
4406 ///
4407 /// Verified by hand against LRM 16.5.1: a depth-1 ring's `get(0)`
4408 /// ("current") always holds the value as of the end of the time step
4409 /// that just finished; `get(1)` holds the value as of the end of the
4410 /// time step before that. `cell` is written to 10 during time step 0's
4411 /// `Active` region and to 20 during time step 1's -- after time step 0
4412 /// finishes (one 5-step cycle), `get(0)` must read 10 and `get(1)` must
4413 /// be unretained (`None`, nothing pushed before entry 0 yet); after
4414 /// time step 1 finishes (a second 5-step cycle), `get(0)` must read 20
4415 /// and `get(1)` must read 10.
4416 #[test]
4417 fn sva_preponed_sampling_matches_lrm_16_5_1() {
4418 use crate::state::{
4419 SlotClass, SlotLocation, StateCellId as RealStateCellId, StateEngine, TemplateRegistry,
4420 };
4421 use std::cell::RefCell;
4422 use std::rc::Rc;
4423
4424 // A real, StateEngine-backed signal, mirroring `state::snapshot`'s
4425 // own test fixtures -- `Engine`'s own `arena: Box<dyn SignalAccess>`
4426 // stays a `TestArena` double (sched's established, STATE-decoupled
4427 // test convention), while the Preponed ring's `source` closure
4428 // reads the REAL StateEngine `MirroringArena::write` below also
4429 // writes into, proving the ring genuinely snapshots live arena
4430 // state, not the test double. `Rc<RefCell<_>>`, not `Arc<Mutex<_>>`:
4431 // `StateEngine` holds `Rc` internals of its own (`state::instance`'s
4432 // own doc comment) and is neither `Send` nor `Sync` -- this is
4433 // single-threaded test code, so there is no reason to reach for a
4434 // thread-safe wrapper clippy's `arc_with_non_send_sync` correctly
4435 // flags as misleading here.
4436 let state_engine = Rc::new(RefCell::new(StateEngine::new(TemplateRegistry::new())));
4437 let real_cell: RealStateCellId = {
4438 let mut se = state_engine.borrow_mut();
4439 // SAFETY: local, single-owned engine; no other live borrow of
4440 // the inline plane at this point.
4441 let word_offset = unsafe { se.arena.alloc_inline_word() };
4442 se.alloc_cell(SlotLocation {
4443 class: SlotClass::Inline,
4444 word_offset,
4445 bit_width: 8,
4446 elem_word_count: 1,
4447 row_stride_words: 0,
4448 num_rows: 0,
4449 })
4450 };
4451
4452 // `Engine`'s own test-double cell (what the scripted process
4453 // actually addresses through `SchedulerOps`) maps 1:1 onto
4454 // `real_cell` by sharing the same numeric id -- `MirroringArena`
4455 // mirrors every write into `state_engine` too, so both views stay
4456 // in lockstep without `sched` needing a concrete STATE dependency.
4457 let cell = StateCellId(real_cell.0);
4458
4459 struct MirroringArena {
4460 inner: TestArena,
4461 mirror_to: Rc<RefCell<StateEngine>>,
4462 }
4463 impl SignalAccess for MirroringArena {
4464 fn read(&self, cell: StateCellId) -> FourStateValue {
4465 self.inner.read(cell)
4466 }
4467 fn write(&mut self, cell: StateCellId, value: FourStateValue) -> bool {
4468 self.mirror_to.borrow_mut().commit_write(
4469 RealStateCellId(cell.0),
4470 value.clone(),
4471 &mut (),
4472 );
4473 self.inner.write(cell, value)
4474 }
4475 fn subscribe(
4476 &mut self,
4477 cell: StateCellId,
4478 role: crate::state::ListenerRole,
4479 sink: Box<dyn ValueChangeSink + Send>,
4480 ) {
4481 self.inner.subscribe(cell, role, sink);
4482 }
4483 }
4484
4485 /// Writes 10 at `Start` (time step 0), 20 at the first
4486 /// `TimeElapsed` (time step 1), then goes quiet (`step == 2`'s
4487 /// `TimeElapsed` writes nothing and stays done) -- `Suspend::
4488 /// Delay(1)` after each write is what makes the wheel advance
4489 /// exactly one tick per time step.
4490 struct SequencedWriter {
4491 cell: StateCellId,
4492 step: u32,
4493 }
4494 impl CompiledProcess for SequencedWriter {
4495 fn resume(
4496 &mut self,
4497 _state: SimHandle,
4498 arg: ResumeArg,
4499 ops: &dyn SchedulerOps,
4500 ) -> Suspend {
4501 match (self.step, arg) {
4502 (0, ResumeArg::Start) => {
4503 ops.commit_blocking_write(self.cell, narrow(10));
4504 self.step = 1;
4505 Suspend::Delay(1)
4506 }
4507 (1, ResumeArg::TimeElapsed) => {
4508 ops.commit_blocking_write(self.cell, narrow(20));
4509 self.step = 2;
4510 Suspend::Delay(1)
4511 }
4512 _ => Suspend::Done,
4513 }
4514 }
4515 }
4516
4517 let mut engine = Engine::new(
4518 Box::new(MirroringArena {
4519 inner: TestArena::new(),
4520 mirror_to: state_engine.clone(),
4521 }),
4522 Box::new(TestIoSink::default()),
4523 );
4524 {
4525 let src = state_engine.clone();
4526 engine.configure_preponed_ring(1, move || src.borrow().snapshot(None));
4527 }
4528 engine.register_process(
4529 Box::new(SequencedWriter { cell, step: 0 }),
4530 ProcessKind::Initial,
4531 None,
4532 0,
4533 );
4534
4535 // `FourStateValue` carries no `PartialEq` (reference/introspection
4536 // shape only, `state::mod.rs`'s own doc comment on it) -- this
4537 // narrows down to the one `u128` lane this test's own single
4538 // `SlotClass::Inline` cell ever uses, mirroring `state::snapshot`'s
4539 // own `read_narrow` test helper.
4540 let read_through_ring = |engine: &Engine, ticks_back: usize| -> Option<u128> {
4541 engine.with_preponed_entry(ticks_back, |snap| {
4542 snap.map(|snap| {
4543 let mut se = state_engine.borrow_mut();
4544 se.restore(snap)
4545 .expect("same StateEngine the ring sampled from, same layout");
4546 match se.read(real_cell) {
4547 FourStateValue::Narrow(w) => w,
4548 FourStateValue::Wide { .. } => panic!("expected Narrow"),
4549 }
4550 })
4551 })
4552 };
4553
4554 // One full 5-region cycle completes time step 0 (Start dispatched
4555 // during Active, writing 10 and arming Delay(1); the Postponed step
4556 // then advances the wheel to tick 1 and pushes the Preponed
4557 // snapshot for the start of tick 1).
4558 for _ in 0..5 {
4559 engine.step_region();
4560 }
4561 assert_eq!(
4562 read_through_ring(&engine, 0),
4563 Some(10u128),
4564 "16.5.1: after time step 0 finishes, the ring's current entry must read the \
4565 value committed during time step 0's own Active region (10)"
4566 );
4567 assert_eq!(
4568 read_through_ring(&engine, 1),
4569 None,
4570 "only one push has happened so far -- ticks_back=1 must not be retained yet"
4571 );
4572
4573 // A second 5-region cycle completes time step 1 (the resumed
4574 // TimeElapsed writes 20 and arms another Delay(1); Postponed pushes
4575 // the Preponed snapshot for the start of tick 2).
4576 for _ in 0..5 {
4577 engine.step_region();
4578 }
4579 assert_eq!(
4580 read_through_ring(&engine, 0),
4581 Some(20u128),
4582 "16.5.1: after time step 1 finishes, the ring's current entry must read the \
4583 value committed during time step 1's own Active region (20)"
4584 );
4585 assert_eq!(
4586 read_through_ring(&engine, 1),
4587 Some(10u128),
4588 "16.5.1: ticks_back=1 must still read time step 0's own committed value (10)"
4589 );
4590 }
4591
4592 #[test]
4593 fn register_process_returns_the_ids_run_until_and_run_finals_operate_on() {
4594 let log: Arc<StdMutex<Vec<(ProcessId, &'static str)>>> =
4595 Arc::new(StdMutex::new(Vec::new()));
4596 let mut engine = new_engine();
4597
4598 let mut regular_ids = Vec::new();
4599 for _ in 0..3 {
4600 let (proc, id_cell) = logging_process(&log);
4601 let id = engine.register_process(proc, ProcessKind::Initial, None, 0);
4602 *id_cell.lock().unwrap() = Some(id);
4603 regular_ids.push(id);
4604 }
4605
4606 let mut final_ids = Vec::new();
4607 for _ in 0..3 {
4608 let (proc, id_cell) = logging_process(&log);
4609 let id = engine.register_process(proc, ProcessKind::Final, None, 0);
4610 *id_cell.lock().unwrap() = Some(id);
4611 final_ids.push(id);
4612 }
4613
4614 let result = engine.run_until(None);
4615 assert!(matches!(result, RunResult::Completed));
4616 engine.run_finals();
4617
4618 let entries = log.lock().unwrap().clone();
4619 // Every regular process ran (via run_until's initial dispatch) with
4620 // exactly one Start; no final-kind process was dispatched by
4621 // run_until (Final processes are never queued onto Active).
4622 for &id in ®ular_ids {
4623 let starts: Vec<_> = entries
4624 .iter()
4625 .filter(|(p, tag)| *p == id && *tag == "Start")
4626 .collect();
4627 assert_eq!(
4628 starts.len(),
4629 1,
4630 "process {id} must have gotten exactly one Start"
4631 );
4632 }
4633 // The finals ran (via run_finals), in registration order.
4634 let final_start_order: Vec<ProcessId> = entries
4635 .iter()
4636 .filter(|(p, _)| final_ids.contains(p))
4637 .map(|(p, _)| *p)
4638 .collect();
4639 assert_eq!(
4640 final_start_order, final_ids,
4641 "finals must run in registration order"
4642 );
4643 for &id in &final_ids {
4644 let starts: Vec<_> = entries
4645 .iter()
4646 .filter(|(p, tag)| *p == id && *tag == "Start")
4647 .collect();
4648 assert_eq!(
4649 starts.len(),
4650 1,
4651 "final process {id} must have gotten exactly one Start"
4652 );
4653 }
4654 }
4655
4656 /// Classes plan Task 14 (LRM 18.14.1): a process registered while
4657 /// `registration_scope` names an instance is seeded from that instance's
4658 /// initialization RNG, one draw per process in registration order; two
4659 /// instances have independent streams; and a process registered with no
4660 /// scope (infrastructure) does not advance any instance's RNG.
4661 #[test]
4662 fn registered_processes_are_seeded_from_their_instances_init_rng() {
4663 use crate::sched::process_rng::{self, with_process_state};
4664 let nop = || -> Box<dyn CompiledProcess> { script(|_, _| Suspend::Done) };
4665 process_rng::reset();
4666 let mut engine = new_engine();
4667 engine.set_registration_scope(Some(0));
4668 let a = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4669 engine.set_registration_scope(None);
4670 let infra = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4671 engine.set_registration_scope(Some(0));
4672 let b = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4673 engine.set_registration_scope(Some(1));
4674 let c = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4675 engine.set_registration_scope(None);
4676 let seen: Vec<u64> = [a, infra, b, c]
4677 .iter()
4678 .map(|&p| with_process_state(Some(p), |s| *s))
4679 .collect();
4680
4681 process_rng::reset();
4682 let (e0, e1, e2) = (
4683 process_rng::draw_from_init(0),
4684 process_rng::draw_from_init(0),
4685 process_rng::draw_from_init(1),
4686 );
4687 assert_eq!(seen[0], e0, "first process of instance 0");
4688 assert_eq!(
4689 seen[2], e1,
4690 "second process of instance 0 (the infra one skipped)"
4691 );
4692 assert_eq!(seen[3], e2, "first process of instance 1");
4693 assert_eq!(seen[1], process_rng::DEFAULT_SEED, "unscoped: never seeded");
4694 assert_ne!(seen[0], seen[2]);
4695 }
4696
4697 /// Classes plan Task 14 (LRM 18.14.2): `fork` children are seeded when
4698 /// they are created, one draw each in source order from the parent's own
4699 /// generator (so the parent advances by exactly one step per child), no
4700 /// matter when they later run.
4701 #[test]
4702 fn fork_children_are_seeded_from_the_forking_process_in_source_order() {
4703 use crate::sched::process_rng::{self, with_process_state};
4704 process_rng::reset();
4705 let mut engine = new_engine();
4706 let spawn = || SpawnSpec {
4707 kernel: 0,
4708 entry_pc: 0,
4709 parent: 0,
4710 disable_name: Vec::new(),
4711 };
4712 let parent = engine.register_process(
4713 script(move |arg, _| match arg {
4714 ResumeArg::Start => Suspend::ForkJoin(JoinKind::None, vec![spawn(), spawn()]),
4715 _ => Suspend::Done,
4716 }),
4717 ProcessKind::Initial,
4718 None,
4719 0,
4720 );
4721 assert_eq!(parent, 0);
4722 // The parent's stream, before the fork.
4723 with_process_state(Some(parent), |s| *s = 99);
4724 let ready = engine.drain_and_sort(Region::Active);
4725 engine.dispatch_ready_set(Region::Active, ready);
4726 let seen: Vec<u64> = [1, 2]
4727 .iter()
4728 .map(|&c| with_process_state(Some(c), |s| *s))
4729 .collect();
4730 let parent_after = with_process_state(Some(parent), |s| *s);
4731
4732 let mut p = 99u64;
4733 let want = [
4734 process_rng::splitmix64_next(&mut p),
4735 process_rng::splitmix64_next(&mut p),
4736 ];
4737 assert_eq!(seen, want, "children seeded by successive parent draws");
4738 assert_eq!(parent_after, p, "one parent step per child, no more");
4739 assert_ne!(seen[0], seen[1]);
4740 }
4741
4742 /// SC-045's "installation happens exactly once, at registration" proof:
4743 /// no `commit_nba`/`commit_blocking_write`/`notify_change` call anywhere
4744 /// in this test, so downstream dedup cannot mask a double-install.
4745 #[test]
4746 fn register_process_installs_static_sensitivity_exactly_once() {
4747 let a = StateCellId(1);
4748 let b = StateCellId(2);
4749 let c = StateCellId(3);
4750 let mut engine = new_engine();
4751 engine.register_process(
4752 Box::new(UnmintedForkChild),
4753 ProcessKind::AlwaysComb,
4754 Some(StaticSensitivity {
4755 edges: vec![(a, None), (b, None), (c, None)],
4756 }),
4757 0,
4758 );
4759 assert_eq!(engine.test_sensitivity_entry_count(), 3);
4760 }
4761
4762 #[test]
4763 fn abi_entry_point_map_names_all_nine_entry_points_exactly_once() {
4764 let map = abi_entry_point_map();
4765 let names: std::collections::HashSet<&str> = map.iter().map(|(name, _)| *name).collect();
4766 assert_eq!(names.len(), map.len(), "no duplicate entry-point names");
4767 let expected: std::collections::HashSet<&str> = [
4768 "sim_init",
4769 "sim_settle",
4770 "sim_eval_region",
4771 "sim_commit_nba",
4772 "sim_save",
4773 "sim_restore",
4774 "sim_step_time",
4775 "sim_final",
4776 "sim_process_count",
4777 ]
4778 .into_iter()
4779 .collect();
4780 assert_eq!(names, expected);
4781 }
4782
4783 // --- EngineOps / EngineNotifySink wiring against Engine's own state ---
4784
4785 #[test]
4786 fn engine_ops_commit_blocking_write_writes_through_to_the_engines_own_arena() {
4787 let cell = StateCellId(7);
4788 let engine = Engine::new(
4789 Box::new(TestArena::seeded(cell, narrow(0))),
4790 Box::new(TestIoSink::default()),
4791 );
4792 let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4793 ops.commit_blocking_write(cell, narrow(1));
4794 assert!(matches!(
4795 engine.test_read_cell(cell),
4796 FourStateValue::Narrow(1)
4797 ));
4798 }
4799
4800 #[test]
4801 fn engine_ops_commit_blocking_write_wakes_a_statically_sensitive_process_via_the_real_queues() {
4802 let cell = StateCellId(9);
4803 let mut engine = Engine::new(
4804 Box::new(TestArena::seeded(cell, narrow(0))),
4805 Box::new(TestIoSink::default()),
4806 );
4807 let p1 =
4808 engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
4809 let p2 = engine.register_process(
4810 Box::new(UnmintedForkChild),
4811 ProcessKind::AlwaysFF,
4812 Some(StaticSensitivity {
4813 edges: vec![(cell, Some(EdgeKind::Posedge))],
4814 }),
4815 0,
4816 );
4817 // Both p1/p2 auto-queued a Start activation at registration; drain
4818 // that away so the assertion below only counts the wakeup this test
4819 // itself triggers.
4820 let _ = engine.test_drain_active();
4821
4822 {
4823 let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4824 // 0 -> 1 on bit 0 is a Posedge (notify.rs's infer_edge table).
4825 ops.commit_blocking_write(cell, narrow(1));
4826 }
4827
4828 let queued = engine.test_drain_active();
4829 assert_eq!(queued.len(), 1, "exactly one new activation queued");
4830 assert_eq!(
4831 queued[0].proc, p2,
4832 "the statically-sensitive process p2 was woken"
4833 );
4834 let _ = p1;
4835 }
4836
4837 /// SC-055 through the real `Engine`: a process statically sensitive to
4838 /// two cells that both change in the same pass is queued exactly once.
4839 /// Without the gate `notify_change` pushes one activation per matching
4840 /// change (2 here).
4841 #[test]
4842 fn engine_queues_a_process_once_when_two_of_its_cells_change_in_one_pass() {
4843 let a = StateCellId(20);
4844 let b = StateCellId(21);
4845 let mut engine = new_engine();
4846 let p = engine.register_process(
4847 Box::new(UnmintedForkChild),
4848 ProcessKind::AlwaysComb,
4849 Some(StaticSensitivity {
4850 edges: vec![(a, None), (b, None)],
4851 }),
4852 0,
4853 );
4854 let _ = engine.test_drain_active();
4855
4856 {
4857 let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4858 ops.commit_blocking_write(a, narrow(1));
4859 ops.commit_blocking_write(b, narrow(1));
4860 }
4861
4862 let queued = engine.test_drain_active();
4863 assert_eq!(
4864 queued.iter().map(|q| q.proc).collect::<Vec<_>>(),
4865 vec![p],
4866 "two changes in one pass must queue the process once, not twice"
4867 );
4868 }
4869
4870 /// SC-056 through the real `Engine`: once the queued activation has been
4871 /// popped (the dequeue point resets the flag), a new change queues the
4872 /// process again. A gate that never reset would swallow the second wakeup.
4873 #[test]
4874 fn engine_requeues_a_process_changed_again_after_its_activation_was_dequeued() {
4875 let a = StateCellId(22);
4876 let mut engine = new_engine();
4877 let p = engine.register_process(
4878 Box::new(UnmintedForkChild),
4879 ProcessKind::AlwaysComb,
4880 Some(StaticSensitivity {
4881 edges: vec![(a, None)],
4882 }),
4883 0,
4884 );
4885 let _ = engine.test_drain_active();
4886
4887 {
4888 let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4889 ops.commit_blocking_write(a, narrow(1));
4890 }
4891 assert_eq!(
4892 engine.test_drain_active().len(),
4893 1,
4894 "first change queues the process"
4895 );
4896
4897 {
4898 let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 1);
4899 ops.commit_blocking_write(a, narrow(0));
4900 }
4901 let queued = engine.test_drain_active();
4902 assert_eq!(
4903 queued.iter().map(|q| q.proc).collect::<Vec<_>>(),
4904 vec![p],
4905 "a change after the dequeue must queue the process again"
4906 );
4907 }
4908
4909 #[test]
4910 fn engine_ops_stage_nba_appends_to_the_engines_own_nba_log() {
4911 let engine = new_engine();
4912 assert_eq!(engine.test_nba_len(), 0);
4913 let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4914 ops.stage_nba(StateCellId(1), narrow(1), 0);
4915 assert_eq!(engine.test_nba_len(), 1);
4916 }
4917
4918 #[test]
4919 fn engine_ops_defer_io_forwards_with_a_seq_counter_that_persists_across_passes() {
4920 // `io_sink: Box<dyn IoSink>` is not downcastable, so this test uses a
4921 // shared-state sink to observe the recorded seqs from outside.
4922 struct SharedIoSink(Arc<StdMutex<Vec<u64>>>);
4923 impl IoSink for SharedIoSink {
4924 fn enqueue(&self, _at: SchedTime, _region: Region, seq: u64, cb: DeferredIoCallback) {
4925 drop(cb);
4926 self.0.lock().unwrap().push(seq);
4927 }
4928 }
4929 let seqs = Arc::new(StdMutex::new(Vec::new()));
4930 let engine = Engine::new(
4931 Box::new(TestArena::new()),
4932 Box::new(SharedIoSink(seqs.clone())),
4933 );
4934 let ops1 = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4935 ops1.defer_io(SchedTime { ticks: 0 }, Region::Active, Box::new(|_| {}));
4936 let ops2 = engine.test_dispatch_ops(SchedTime { ticks: 5 }, Region::Inactive, 0);
4937 ops2.defer_io(SchedTime { ticks: 5 }, Region::Inactive, Box::new(|_| {}));
4938
4939 assert_eq!(
4940 *seqs.lock().unwrap(),
4941 vec![0, 1],
4942 "seq must strictly increase across two independently-constructed EngineOps"
4943 );
4944 }
4945
4946 // --- review fixes (r1) ---
4947
4948 /// Review fix — `apply_suspend`'s `Suspend::Delay` arm used to always
4949 /// build `act` with `logical_order.1 == Region::Active`, even when
4950 /// `wheel.arm_delay` reports `Region::Inactive` (a zero-delay `#0`
4951 /// reactivation) and the activation is pushed straight into
4952 /// `Region::Inactive` instead. Mirrors `subscribe.rs::register_wait`'s
4953 /// own `delay_zero_lands_directly_in_inactive_not_the_wheel` test for
4954 /// the identical hazard, one file over.
4955 #[test]
4956 fn apply_suspend_delay_zero_lands_directly_in_inactive_with_the_correct_region_tag() {
4957 struct DelayZeroProcess;
4958 impl CompiledProcess for DelayZeroProcess {
4959 fn resume(
4960 &mut self,
4961 _state: SimHandle,
4962 arg: ResumeArg,
4963 _ops: &dyn SchedulerOps,
4964 ) -> Suspend {
4965 match arg {
4966 ResumeArg::Start => Suspend::Delay(0),
4967 _ => Suspend::Done,
4968 }
4969 }
4970 }
4971
4972 let mut engine = new_engine();
4973 let proc =
4974 engine.register_process(Box::new(DelayZeroProcess), ProcessKind::Initial, None, 0);
4975
4976 // Single-steps exactly the Active region: dispatches the process's
4977 // Start, which returns Suspend::Delay(0) -- apply_suspend's
4978 // wheel.arm_delay call reports Region::Inactive (delay_ticks == 0,
4979 // per wheel.rs's own doc comment) without ever touching the wheel.
4980 engine.step_region();
4981
4982 let inactive = engine.test_drain_inactive();
4983 assert_eq!(
4984 inactive.len(),
4985 1,
4986 "the zero-delay reactivation must land directly in Region::Inactive"
4987 );
4988 assert_eq!(inactive[0].proc, proc);
4989 assert_eq!(
4990 inactive[0].logical_order.1,
4991 Region::Inactive,
4992 "an activation landed in the Inactive queue must carry Region::Inactive as its \
4993 own logical_order.1 tag, agreeing with the queue it actually lives in -- \
4994 order.rs's canonical_key_of/sort_ready_set read logical_order.1 directly to \
4995 build the canonical key that SC-010/DT-005/DT-010 depend on"
4996 );
4997 }
4998
4999 /// Whole-track review fix (AB-085): the only prior test for
5000 /// `Suspend::WaitForGvt` (`wait_for_gvt_shares_the_suspend_dispatch_path`
5001 /// in `sched::mod`) never called `apply_suspend` at all -- it matched a
5002 /// test-local, hand-written `label` function against the `Suspend` enum,
5003 /// which only proves that function's own match arms are exhaustive, not
5004 /// that `apply_suspend`'s `Suspend::WaitForGvt => self.apply_suspend(proc,
5005 /// Suspend::Delay(0))` arm actually delegates anywhere. This test drives
5006 /// a real process through the real `Engine`/`apply_suspend` with
5007 /// `Suspend::WaitForGvt` and asserts the resulting scheduler state is
5008 /// identical (region, queue length, logical_order tag) to what
5009 /// `apply_suspend_delay_zero_lands_directly_in_inactive_with_the_correct_
5010 /// region_tag` (immediately above) observes for `Suspend::Delay(0)` --
5011 /// proving the delegation for real. Before this test existed, changing
5012 /// the `WaitForGvt` arm to `Suspend::Delay(1)`, to a silent no-op, or to
5013 /// removing the delegation entirely (stranding the process) would still
5014 /// have passed the full test suite.
5015 #[test]
5016 fn apply_suspend_wait_for_gvt_delegates_to_the_same_path_as_delay_zero() {
5017 struct WaitForGvtProcess;
5018 impl CompiledProcess for WaitForGvtProcess {
5019 fn resume(
5020 &mut self,
5021 _state: SimHandle,
5022 arg: ResumeArg,
5023 _ops: &dyn SchedulerOps,
5024 ) -> Suspend {
5025 match arg {
5026 ResumeArg::Start => Suspend::WaitForGvt,
5027 _ => Suspend::Done,
5028 }
5029 }
5030 }
5031
5032 let mut engine = new_engine();
5033 let proc =
5034 engine.register_process(Box::new(WaitForGvtProcess), ProcessKind::Initial, None, 0);
5035
5036 // Single-steps exactly the Active region: dispatches the process's
5037 // Start, which returns Suspend::WaitForGvt. If apply_suspend's
5038 // `Suspend::WaitForGvt` arm really delegates to
5039 // `self.apply_suspend(proc, Suspend::Delay(0))`, the observable
5040 // outcome must be pixel-for-pixel identical to the Delay(0) test
5041 // above: one reactivation, landed directly in Region::Inactive
5042 // (never touching the wheel), tagged with Region::Inactive.
5043 engine.step_region();
5044
5045 let inactive = engine.test_drain_inactive();
5046 assert_eq!(
5047 inactive.len(),
5048 1,
5049 "WaitForGvt must delegate to Delay(0)'s dispatch, landing its reactivation \
5050 directly in Region::Inactive exactly like Delay(0) does -- a broken or removed \
5051 delegation would leave this queue empty (process stranded) or route it \
5052 elsewhere"
5053 );
5054 assert_eq!(inactive[0].proc, proc);
5055 assert_eq!(
5056 inactive[0].logical_order.1,
5057 Region::Inactive,
5058 "the delegated activation must carry the same Region::Inactive logical_order \
5059 tag Delay(0) produces, agreeing with the queue it actually lives in -- a \
5060 WaitForGvt arm changed to Delay(1) or to any wheel-touching path would instead \
5061 land here with Region::Active, or not land here at all this step"
5062 );
5063 assert_eq!(
5064 inactive[0].logical_order.0,
5065 SchedTime { ticks: 0 },
5066 "delegating to Delay(0) from now == 0 must produce the same absolute fire tick \
5067 Delay(0) produces (0 + 0 == 0), not some other tick a differently-parameterized \
5068 delegation (e.g. Delay(1)) would produce"
5069 );
5070 }
5071
5072 /// Mutation-test kill: `apply_suspend`'s `Suspend::Delay` arm must build
5073 /// the reactivation's `logical_order.0` from `self.now.ticks + ticks`
5074 /// (the real absolute fire tick), never from `ticks` alone. The bug is
5075 /// invisible at `now == 0` (where the two computations coincide, exactly
5076 /// the case every other `Delay` test in this file exercises), so this
5077 /// test chains two delays -- `Delay(3)` fired from `now == 0`, then
5078 /// `Delay(4)` fired from the resulting `now == 3` -- and inspects the
5079 /// *second* reactivation's own `logical_order.0` after the wheel fires
5080 /// it: it must be `SchedTime{ticks: 7}` (3+4), never `SchedTime{ticks:
5081 /// 4}`. The wheel's own real fire time is unaffected by this bug (it
5082 /// computes `now + delay_ticks` internally, independent of the
5083 /// activation's `logical_order` field), so `engine.now()` alone cannot
5084 /// catch it -- only inspecting the queued activation's own field can.
5085 #[test]
5086 fn suspend_delay_logical_order_uses_absolute_time_across_chained_delays() {
5087 struct TwoStageDelay;
5088 impl CompiledProcess for TwoStageDelay {
5089 fn resume(
5090 &mut self,
5091 _state: SimHandle,
5092 arg: ResumeArg,
5093 _ops: &dyn SchedulerOps,
5094 ) -> Suspend {
5095 match arg {
5096 ResumeArg::Start => Suspend::Delay(3),
5097 ResumeArg::TimeElapsed => Suspend::Delay(4),
5098 _ => Suspend::Done,
5099 }
5100 }
5101 }
5102
5103 let mut engine = new_engine();
5104 engine.register_process(Box::new(TwoStageDelay), ProcessKind::Initial, None, 0);
5105
5106 // Exactly 10 step_region() calls: 5 regions (Active/Inactive/Nba/
5107 // Reactive/Postponed) x 2 timesteps -- the first timestep dispatches
5108 // Start (arming Delay(3)) and advances the wheel to tick 3 without
5109 // dispatching the resulting reactivation; the second timestep
5110 // dispatches that TimeElapsed (arming Delay(4)) and advances the
5111 // wheel to tick 7, again landing the reactivation in Region::Active
5112 // without dispatching it.
5113 for _ in 0..10 {
5114 engine.step_region();
5115 }
5116
5117 assert_eq!(
5118 engine.now(),
5119 SchedTime { ticks: 7 },
5120 "the wheel's own real fire time (unaffected by this bug) must be 0+3, then 3+4 = 7"
5121 );
5122
5123 let active = engine.test_drain_active();
5124 assert_eq!(
5125 active.len(),
5126 1,
5127 "the second delay's reactivation must be sitting in Region::Active, undispatched"
5128 );
5129 assert_eq!(
5130 active[0].logical_order.0,
5131 SchedTime { ticks: 7 },
5132 "logical_order.0 must be the absolute fire tick (now=3 + delay=4 = 7), not \
5133 the raw delay amount (4) alone"
5134 );
5135 }
5136
5137 /// Review fix — `apply_suspend`'s `Suspend::WaitEvents` arm installed a
5138 /// dynamic sensitivity entry on suspend but nothing in `Engine`'s
5139 /// dispatch path ever called `SensitivityTable::remove_dynamic` when the
5140 /// waiting process later resumed, leaking the entry forever and risking
5141 /// a phantom wakeup of an already-resumed process on a later write to
5142 /// the same cell.
5143 #[test]
5144 fn wait_events_dynamic_sensitivity_entry_is_removed_when_the_process_resumes() {
5145 struct WaiterProcess {
5146 cell: StateCellId,
5147 }
5148 impl CompiledProcess for WaiterProcess {
5149 fn resume(
5150 &mut self,
5151 _state: SimHandle,
5152 arg: ResumeArg,
5153 _ops: &dyn SchedulerOps,
5154 ) -> Suspend {
5155 match arg {
5156 ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
5157 terms: vec![EventTerm::Edge {
5158 cell: self.cell,
5159 edge: EdgeKind::Posedge,
5160 iff: None,
5161 }],
5162 ..Default::default()
5163 }),
5164 _ => Suspend::Done,
5165 }
5166 }
5167 }
5168 struct WriterProcess {
5169 cell: StateCellId,
5170 }
5171 impl CompiledProcess for WriterProcess {
5172 fn resume(
5173 &mut self,
5174 _state: SimHandle,
5175 arg: ResumeArg,
5176 ops: &dyn SchedulerOps,
5177 ) -> Suspend {
5178 match arg {
5179 // Delay past t=0 so this write lands in its own,
5180 // later region pass -- one full pass after
5181 // WaiterProcess's own Start has already been applied
5182 // (apply_suspend, and therefore install_dynamic, only
5183 // runs once every item dispatched *in the same pass*
5184 // has itself returned its Suspend), so the dynamic
5185 // entry genuinely exists by the time this write fires.
5186 ResumeArg::Start => Suspend::Delay(1),
5187 ResumeArg::TimeElapsed => {
5188 ops.commit_blocking_write(self.cell, narrow(1));
5189 Suspend::Done
5190 }
5191 other => panic!("WriterProcess must only see Start/TimeElapsed, got {other:?}"),
5192 }
5193 }
5194 }
5195
5196 let cell = StateCellId(42);
5197 let mut engine = Engine::new(
5198 Box::new(TestArena::seeded(cell, narrow(0))),
5199 Box::new(TestIoSink::default()),
5200 );
5201 engine.register_process(
5202 Box::new(WaiterProcess { cell }),
5203 ProcessKind::Initial,
5204 None,
5205 0,
5206 );
5207 engine.register_process(
5208 Box::new(WriterProcess { cell }),
5209 ProcessKind::Initial,
5210 None,
5211 0,
5212 );
5213
5214 let result = engine.run_until(None);
5215 assert!(matches!(result, RunResult::Completed));
5216
5217 assert_eq!(
5218 engine.test_sensitivity_entry_count(),
5219 0,
5220 "the dynamic entry must be removed once the waiting process resumes via \
5221 EventFired -- sensitivity.rs's own doc comment: dynamic entries \"are gone \
5222 from the table the instant the owning process resumes\" -- an earlier draft \
5223 never called SensitivityTable::remove_dynamic anywhere in Engine's dispatch \
5224 path, leaking this row forever"
5225 );
5226 }
5227
5228 /// Mutation-test kill: `dispatch_ready_set`'s dynamic-wait-resumed
5229 /// removal set names two `ResumeArg` variants, `EventFired` and
5230 /// `Disabled` -- but no other test in this file ever drives a
5231 /// `ResumeArg::Disabled` activation through `dispatch_ready_set` (no
5232 /// production path in this crate constructs one yet), so a mutant that
5233 /// narrowed the removal check to `EventFired` alone would pass every
5234 /// other test. This test dispatches `Start` normally (installing one
5235 /// dynamic sensitivity entry via `Suspend::WaitEvents`), then hand-builds
5236 /// a `ResumeArg::Disabled` activation for the same process and dispatches
5237 /// it directly -- `dispatch_ready_set`/`drain_and_sort` are private
5238 /// methods on `Engine`, reachable here since this test module is a
5239 /// descendant of `control`.
5240 #[test]
5241 fn dispatch_ready_set_removes_dynamic_sensitivity_entry_for_disabled_resume_too() {
5242 struct WaiterProcess {
5243 cell: StateCellId,
5244 }
5245 impl CompiledProcess for WaiterProcess {
5246 fn resume(
5247 &mut self,
5248 _state: SimHandle,
5249 arg: ResumeArg,
5250 _ops: &dyn SchedulerOps,
5251 ) -> Suspend {
5252 match arg {
5253 ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
5254 terms: vec![EventTerm::Edge {
5255 cell: self.cell,
5256 edge: EdgeKind::Posedge,
5257 iff: None,
5258 }],
5259 ..Default::default()
5260 }),
5261 _ => Suspend::Done,
5262 }
5263 }
5264 }
5265
5266 let cell = StateCellId(99);
5267 let mut engine = new_engine();
5268 let proc = engine.register_process(
5269 Box::new(WaiterProcess { cell }),
5270 ProcessKind::Initial,
5271 None,
5272 0,
5273 );
5274
5275 let ready = engine.drain_and_sort(Region::Active);
5276 engine.dispatch_ready_set(Region::Active, ready);
5277 assert_eq!(
5278 engine.test_sensitivity_entry_count(),
5279 1,
5280 "Suspend::WaitEvents must install exactly one dynamic sensitivity entry"
5281 );
5282
5283 let disabled_act = PendingActivation {
5284 id: 999,
5285 proc,
5286 arg: ResumeArg::Disabled,
5287 reconv_iter: 0,
5288 logical_order: (engine.now(), Region::Active, 0),
5289 };
5290 engine.dispatch_ready_set(Region::Active, vec![disabled_act]);
5291
5292 assert_eq!(
5293 engine.test_sensitivity_entry_count(),
5294 0,
5295 "a ResumeArg::Disabled resume must also remove its process's dynamic \
5296 sensitivity entry, exactly like EventFired does -- a mutant narrowing the \
5297 removal check to EventFired alone would leave this row (and every future \
5298 Disabled-terminated wait's row) leaked forever"
5299 );
5300 }
5301
5302 /// Review fix — `apply_suspend`'s `Suspend::ForkJoin` arm spawned
5303 /// children via `ResumeDriver::handle_fork` but nothing in `Engine`
5304 /// ever called `ResumeDriver::on_child_done` when a spawned child later
5305 /// completed, so the parent was never reactivated: fork/join was
5306 /// completely nonfunctional (any forking process silently hung forever
5307 /// while `run_until` still reported normal completion). Drives a real
5308 /// `JoinKind::All` fork with one child through `Engine::run_until` and
5309 /// asserts the parent actually resumes via `ResumeArg::ForkChildDone`.
5310 #[test]
5311 fn fork_join_all_actually_resumes_the_parent_when_its_one_child_completes() {
5312 struct ParentProcess {
5313 log: Arc<StdMutex<Vec<&'static str>>>,
5314 }
5315 impl CompiledProcess for ParentProcess {
5316 fn resume(
5317 &mut self,
5318 _state: SimHandle,
5319 arg: ResumeArg,
5320 _ops: &dyn SchedulerOps,
5321 ) -> Suspend {
5322 match arg {
5323 ResumeArg::Start => {
5324 self.log.lock().unwrap().push("forked");
5325 Suspend::ForkJoin(
5326 JoinKind::All,
5327 vec![SpawnSpec {
5328 kernel: 0,
5329 entry_pc: 0,
5330 parent: 0,
5331 disable_name: Vec::new(),
5332 }],
5333 )
5334 }
5335 ResumeArg::ForkChildDone { .. } => {
5336 self.log.lock().unwrap().push("joined");
5337 Suspend::Done
5338 }
5339 other => panic!(
5340 "ParentProcess must only ever be resumed with Start or \
5341 ForkChildDone, got {other:?}"
5342 ),
5343 }
5344 }
5345 }
5346
5347 let log = Arc::new(StdMutex::new(Vec::new()));
5348 let mut engine = new_engine();
5349 engine.register_process(
5350 Box::new(ParentProcess { log: log.clone() }),
5351 ProcessKind::Initial,
5352 None,
5353 0,
5354 );
5355
5356 let result = engine.run_until(None);
5357 assert!(matches!(result, RunResult::Completed));
5358
5359 assert_eq!(
5360 log.lock().unwrap().as_slice(),
5361 &["forked", "joined"],
5362 "the parent must actually be reactivated via ResumeArg::ForkChildDone once its \
5363 one child (an UnmintedForkChild, immediately Done) completes -- an earlier \
5364 draft never wired ResumeDriver::on_child_done into Engine at all, so the \
5365 parent would silently hang forever (never reach \"joined\") despite run_until \
5366 still reporting RunResult::Completed"
5367 );
5368 }
5369
5370 // --- Task 7 (B-sched wave, ledger SC-062, SC-115) ---
5371
5372 /// SC-062's own literal acceptance, proven through the real `Engine`
5373 /// dispatch loop (not just `ResumeDriver` in isolation, which
5374 /// `resume.rs`'s own `join_all_resumes_parent_only_after_every_child_
5375 /// reports_done`/`on_child_done_reclaims_the_childs_slot_when_the_join_
5376 /// is_satisfied` already cover): a `JoinAll` fork of 3 children (each an
5377 /// `UnmintedForkChild`, immediately `Done` on its own first dispatch --
5378 /// this wave's own documented interim placeholder, see this module's
5379 /// doc comment deviation 3) completing one after another *within the
5380 /// same Active-region dispatch pass*. Asserts the parent resumes (logs
5381 /// "joined") only once, after all 3, and that every one of the 3
5382 /// forked children's own `ProcessId`s is unreachable via
5383 /// `Engine::test_process_exists` by the time it does. Fails if slot
5384 /// reclaim is never wired (every child id would still report `true`),
5385 /// or if it reclaims the wrong process (e.g. the parent).
5386 #[test]
5387 fn sc062_joinall_child_returning_done_satisfies_the_parents_join_and_reclaims_its_slot() {
5388 struct ParentProcess {
5389 log: Arc<StdMutex<Vec<&'static str>>>,
5390 }
5391 impl CompiledProcess for ParentProcess {
5392 fn resume(
5393 &mut self,
5394 _state: SimHandle,
5395 arg: ResumeArg,
5396 _ops: &dyn SchedulerOps,
5397 ) -> Suspend {
5398 match arg {
5399 ResumeArg::Start => {
5400 self.log.lock().unwrap().push("forked");
5401 Suspend::ForkJoin(
5402 JoinKind::All,
5403 vec![
5404 SpawnSpec {
5405 kernel: 0,
5406 entry_pc: 0,
5407 parent: 0,
5408 disable_name: Vec::new(),
5409 },
5410 SpawnSpec {
5411 kernel: 1,
5412 entry_pc: 0,
5413 parent: 0,
5414 disable_name: Vec::new(),
5415 },
5416 SpawnSpec {
5417 kernel: 2,
5418 entry_pc: 0,
5419 parent: 0,
5420 disable_name: Vec::new(),
5421 },
5422 ],
5423 )
5424 }
5425 ResumeArg::ForkChildDone { .. } => {
5426 self.log.lock().unwrap().push("joined");
5427 Suspend::Done
5428 }
5429 other => panic!(
5430 "ParentProcess must only ever be resumed with Start or \
5431 ForkChildDone, got {other:?}"
5432 ),
5433 }
5434 }
5435 }
5436
5437 let log = Arc::new(StdMutex::new(Vec::new()));
5438 let mut engine = new_engine();
5439 let parent = engine.register_process(
5440 Box::new(ParentProcess { log: log.clone() }),
5441 ProcessKind::Initial,
5442 None,
5443 0,
5444 );
5445 assert_eq!(parent, 0, "test assumes a fresh engine's first id is 0");
5446
5447 let result = engine.run_until(None);
5448 assert!(matches!(result, RunResult::Completed));
5449 assert_eq!(
5450 log.lock().unwrap().as_slice(),
5451 &["forked", "joined"],
5452 "the parent must resume via ForkChildDone exactly once, only after all 3 \
5453 children have completed"
5454 );
5455
5456 for child in (parent + 1)..=(parent + 3) {
5457 assert!(
5458 !engine.test_process_exists(child),
5459 "fork child {child} must be unreachable via the process table once its \
5460 Suspend::Done has been processed and the JoinAll it belongs to has \
5461 been satisfied -- SC-062's own literal 'the child's slot is reclaimed'"
5462 );
5463 }
5464 }
5465
5466 /// SC-115's own literal acceptance ("a process can be spawned via fork
5467 /// after sim_init has run, with its own scheduled entry in the
5468 /// ready-set machinery") combined with this task's own `JoinKind::None`
5469 /// resolution (this module's doc comment, deviation 3): the parent's
5470 /// own next segment must run *synchronously*, inside the very same
5471 /// `apply_suspend` call that registered the fork's children -- no
5472 /// second `dispatch_ready_set`/`run_until` pass needed -- while the
5473 /// spawned child is left genuinely live (not dispatched, not
5474 /// reclaimed) at that exact point, proving `JoinKind::None` neither
5475 /// blocks on nor otherwise touches its child.
5476 #[test]
5477 fn sc115_join_none_children_run_detached_while_the_parent_proceeds_without_waiting() {
5478 struct ParentProcess {
5479 log: Arc<StdMutex<Vec<&'static str>>>,
5480 }
5481 impl CompiledProcess for ParentProcess {
5482 fn resume(
5483 &mut self,
5484 _state: SimHandle,
5485 arg: ResumeArg,
5486 _ops: &dyn SchedulerOps,
5487 ) -> Suspend {
5488 match arg {
5489 ResumeArg::Start => {
5490 self.log.lock().unwrap().push("forked");
5491 Suspend::ForkJoin(
5492 JoinKind::None,
5493 vec![SpawnSpec {
5494 kernel: 0,
5495 entry_pc: 0,
5496 parent: 0,
5497 disable_name: Vec::new(),
5498 }],
5499 )
5500 }
5501 ResumeArg::TimeElapsed => {
5502 self.log.lock().unwrap().push("proceeded");
5503 Suspend::Done
5504 }
5505 other => panic!(
5506 "ParentProcess must only ever be resumed with Start or \
5507 TimeElapsed (JoinKind::None's own chosen continuation arg, \
5508 see this module's doc comment), got {other:?}"
5509 ),
5510 }
5511 }
5512 }
5513
5514 let log = Arc::new(StdMutex::new(Vec::new()));
5515 let mut engine = new_engine();
5516 let parent = engine.register_process(
5517 Box::new(ParentProcess { log: log.clone() }),
5518 ProcessKind::Initial,
5519 None,
5520 0,
5521 );
5522 assert_eq!(parent, 0, "test assumes a fresh engine's first id is 0");
5523 let child = parent + 1;
5524
5525 // `register_process` already queued the parent's own Start
5526 // activation onto Region::Active. Dispatching exactly that one
5527 // ready-set (a single `dispatch_ready_set` call) must be enough to
5528 // see BOTH "forked" and "proceeded" in the log -- proving
5529 // JoinKind::None's continuation happened inside this same call,
5530 // never requiring a second dispatch pass the way waiting for a
5531 // real ForkChildDone would.
5532 let ready = engine.drain_and_sort(Region::Active);
5533 engine.dispatch_ready_set(Region::Active, ready);
5534
5535 assert_eq!(
5536 log.lock().unwrap().as_slice(),
5537 &["forked", "proceeded"],
5538 "JoinKind::None must resume the parent's own next segment synchronously, \
5539 within the very same apply_suspend call that registered its child -- \
5540 never leaving it suspended waiting for a ForkChildDone that will never \
5541 come (the pre-Task-7 bug this row closes)"
5542 );
5543 assert!(
5544 engine.test_process_exists(child),
5545 "the spawned child must still be a live, registered process at this exact \
5546 point -- it was only just enqueued (Region::Active, ResumeArg::Start) by \
5547 this same dispatch_ready_set call, never itself dispatched by it, so \
5548 JoinKind::None resolving the parent must not have touched the child at \
5549 all (neither reclaiming it nor otherwise depending on its completion)"
5550 );
5551 }
5552
5553 /// R-run Task 17 review fix (r1) -- blocking finding: `apply_suspend`'s
5554 /// `Suspend::ForkJoin` arm's `mint_child` closure ignores its own
5555 /// `SpawnSpec` argument entirely and unconditionally builds an
5556 /// `UnmintedForkChild` (this module's own doc comment deviation 3;
5557 /// `sv-sim-codegen`'s R-run Task 17 now produces real, distinct
5558 /// `SpawnSpec::kernel` indices per branch, but nothing in `Engine`
5559 /// consumes that field yet -- Task 21 remains chartered to fix that).
5560 /// No prior test pinned this AS a tested property -- every existing
5561 /// `Suspend::ForkJoin` test above happens to use small/repeating
5562 /// `kernel` values (0, 1, 2) that a partial, buggy future minter could
5563 /// special-case and still pass. This test spawns children with
5564 /// deliberately large, mutually distinct `kernel` indices (a shape no
5565 /// real per-program kernel table plausibly has entries for yet) and
5566 /// proves every single one still completes via a bare `Suspend::Done`
5567 /// on its own very first `Start` dispatch -- the literal, observable
5568 /// signature of `UnmintedForkChild` (a real compiled branch kernel
5569 /// could legitimately suspend, loop, or otherwise not complete in one
5570 /// dispatch). Fails loudly the instant a future minter starts
5571 /// consulting `SpawnSpec::kernel` for any subset of values without this
5572 /// test being updated alongside it.
5573 #[test]
5574 fn apply_suspend_fork_join_still_mints_unmintedforkchild_regardless_of_spawnspec_kernel() {
5575 struct ParentProcess;
5576 impl CompiledProcess for ParentProcess {
5577 fn resume(
5578 &mut self,
5579 _state: SimHandle,
5580 arg: ResumeArg,
5581 _ops: &dyn SchedulerOps,
5582 ) -> Suspend {
5583 match arg {
5584 ResumeArg::Start => Suspend::ForkJoin(
5585 JoinKind::All,
5586 vec![
5587 SpawnSpec {
5588 kernel: 424_242,
5589 entry_pc: 0,
5590 parent: 0,
5591 disable_name: Vec::new(),
5592 },
5593 SpawnSpec {
5594 kernel: 7,
5595 entry_pc: 3,
5596 parent: 0,
5597 disable_name: Vec::new(),
5598 },
5599 SpawnSpec {
5600 kernel: u32::MAX,
5601 entry_pc: 0,
5602 parent: 0,
5603 disable_name: Vec::new(),
5604 },
5605 ],
5606 ),
5607 // Every child completes in one dispatch (proven below),
5608 // so the join is satisfied by the time this fires; the
5609 // real assertions are about the children, not this arm.
5610 ResumeArg::ForkChildDone { .. } => Suspend::Done,
5611 other => panic!(
5612 "ParentProcess must only ever see Start or ForkChildDone, got {other:?}"
5613 ),
5614 }
5615 }
5616 }
5617
5618 let mut engine = new_engine();
5619 let parent =
5620 engine.register_process(Box::new(ParentProcess), ProcessKind::Initial, None, 0);
5621 assert_eq!(parent, 0, "test assumes a fresh engine's first id is 0");
5622
5623 // Dispatch the parent's own Start -- this is the call that runs
5624 // apply_suspend's Suspend::ForkJoin arm and mints the 3 children.
5625 let ready = engine.drain_and_sort(Region::Active);
5626 engine.dispatch_ready_set(Region::Active, ready);
5627
5628 let children = [parent + 1, parent + 2, parent + 3];
5629 for &child in &children {
5630 assert!(
5631 engine.test_process_exists(child),
5632 "child {child} must exist right after being minted, before its own first \
5633 dispatch"
5634 );
5635 }
5636
5637 // Dispatch every child's own queued Start activation in one pass.
5638 let ready = engine.drain_and_sort(Region::Active);
5639 assert_eq!(
5640 ready.len(),
5641 3,
5642 "exactly the 3 freshly-minted children's Start activations must be ready, got {}",
5643 ready.len()
5644 );
5645 engine.dispatch_ready_set(Region::Active, ready);
5646
5647 for &child in &children {
5648 assert!(
5649 !engine.test_process_exists(child),
5650 "child {child} (SpawnSpec::kernel ignored by today's mint_child) must have \
5651 completed via a bare Suspend::Done on its own very first Start dispatch and \
5652 been reclaimed -- exactly UnmintedForkChild's own documented behavior, \
5653 regardless of how large or distinct its own SpawnSpec::kernel index was. A \
5654 real per-branch compiled kernel is not guaranteed to complete in one dispatch \
5655 at all, so this failing is the signal that mint_child has started consulting \
5656 SpawnSpec::kernel for at least one of these values -- update this test (and, \
5657 per Task 21's own charter, the CLI-level fork/join gate) alongside that change."
5658 );
5659 }
5660 }
5661
5662 // --- Task 9 (A2-part2, CG-102): region_id-consuming partition-aware
5663 // dispatch ---------------------------------------------------------
5664
5665 /// `partition_of` genuinely reads `region_id`, not a hardcoded `0` --
5666 /// real modulo arithmetic against `partition_count`.
5667 #[test]
5668 fn partition_of_is_a_real_modulo_not_a_hardcoded_zero() {
5669 assert_eq!(partition_of(5, 4), PartitionId(1));
5670 assert_eq!(partition_of(6, 4), PartitionId(2));
5671 assert_eq!(partition_of(0, 4), PartitionId(0));
5672 }
5673
5674 /// v1's one real call site always passes `partition_count = 1`
5675 /// (ARCHITECTURE §4 item 1) -- pinned here as a property of the
5676 /// function itself, for several distinct `region_id` values, not an
5677 /// assumption left implicit at the call site.
5678 #[test]
5679 fn partition_of_is_the_documented_v1_degenerate_single_partition_value() {
5680 for region_id in [0, 3, 9] {
5681 assert_eq!(
5682 partition_of(region_id, 1),
5683 PartitionId(0),
5684 "region_id={region_id}: v1's real partition_count=1 call site must always \
5685 yield PartitionId(0), for any region_id"
5686 );
5687 }
5688 }
5689
5690 /// `partition_of` never divides by zero -- `partition_count == 0`
5691 /// degrades to the same single-partition behavior as `1`, rather than
5692 /// panicking.
5693 #[test]
5694 fn partition_of_with_zero_partition_count_does_not_panic() {
5695 assert_eq!(partition_of(7, 0), PartitionId(0));
5696 }
5697
5698 /// CG-102's scheduler-side literal acceptance ("consumed by the
5699 /// scheduler for partition-aware dispatch"): register 3 processes with
5700 /// distinct `region_id`s (0, 3, 9) via the real `SchedulerControl::
5701 /// register_process` API, dispatch their initial-registration
5702 /// activations, and confirm the spy hook saw every one of their
5703 /// `ProcessId`s queried against exactly the `region_id` each was
5704 /// registered with -- proving the field flows from registration
5705 /// through to dispatch rather than being stored-but-never-read (the
5706 /// literal gap the plan's own "Verified live" review finding named),
5707 /// and would fail if `dispatch_ready_set` were refactored to default
5708 /// every lookup to `0` regardless of what was registered.
5709 #[test]
5710 fn dispatch_ready_set_looks_up_partition_of_using_each_readied_processs_own_stored_region_id() {
5711 let mut engine = new_engine();
5712 let p0 =
5713 engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
5714 let p3 =
5715 engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 3);
5716 let p9 =
5717 engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 9);
5718
5719 // Each `register_process` above already queued its own Start
5720 // activation into Region::Active -- drive exactly one dispatch pass
5721 // over all 3 so `dispatch_ready_set` genuinely runs its grouping
5722 // loop once, over all 3 activations together.
5723 let ready = engine.drain_and_sort(Region::Active);
5724 assert_eq!(
5725 ready.len(),
5726 3,
5727 "all 3 registrations' Start activations must be pending"
5728 );
5729 engine.dispatch_ready_set(Region::Active, ready);
5730
5731 let mut seen = engine.region_id_queries_seen().to_vec();
5732 seen.sort_unstable();
5733 let mut want = vec![(p0, 0u32), (p3, 3u32), (p9, 9u32)];
5734 want.sort_unstable();
5735 assert_eq!(
5736 seen, want,
5737 "dispatch_ready_set must query ProcessTable::region_id for every readied \
5738 process, and every value returned must exactly match what register_process \
5739 stored for it"
5740 );
5741 }
5742
5743 // --- Task 3 (B-sched, ledger SC-004, SC-005, SC-052, SC-053):
5744 // EventTerm::Repeat engine semantics + ##N/clocking-block runtime
5745 // resolution ---
5746
5747 /// SC-052's own literal acceptance, verified against the production
5748 /// `Engine` dispatch loop (not a hand-simulated one): a
5749 /// `repeat(3)@(posedge clk)`-shaped mock process must resume only after
5750 /// the 3rd matching posedge, never the 1st or 2nd, and exactly once
5751 /// after that (not once per remaining posedge -- which would mean a
5752 /// `RepeatOutcome::StillWaiting` firing wrongly fell through to
5753 /// enqueue).
5754 #[test]
5755 fn engine_resumes_a_repeat_3_process_only_on_the_third_matching_edge_not_the_first() {
5756 struct Repeat3Waiter {
5757 cell: StateCellId,
5758 resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
5759 }
5760 impl CompiledProcess for Repeat3Waiter {
5761 fn resume(
5762 &mut self,
5763 _state: SimHandle,
5764 arg: ResumeArg,
5765 _ops: &dyn SchedulerOps,
5766 ) -> Suspend {
5767 self.resume_log.lock().unwrap().push(arg);
5768 match arg {
5769 ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
5770 terms: vec![EventTerm::Repeat {
5771 count: 3,
5772 inner: Box::new(EventTerm::Edge {
5773 cell: self.cell,
5774 edge: EdgeKind::Posedge,
5775 iff: None,
5776 }),
5777 }],
5778 ..Default::default()
5779 }),
5780 _ => Suspend::Done,
5781 }
5782 }
5783 }
5784
5785 let cell = StateCellId(200);
5786 let mut engine = Engine::new(
5787 Box::new(TestArena::seeded(cell, narrow(0))),
5788 Box::new(TestIoSink::default()),
5789 );
5790 let log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
5791 engine.register_process(
5792 Box::new(Repeat3Waiter {
5793 cell,
5794 resume_log: log.clone(),
5795 }),
5796 ProcessKind::Initial,
5797 None,
5798 0,
5799 );
5800
5801 // Dispatch Start: installs the Repeat{count:3, inner: Edge} dynamic
5802 // wait.
5803 let ready = engine.drain_and_sort(Region::Active);
5804 engine.dispatch_ready_set(Region::Active, ready);
5805 assert_eq!(
5806 log.lock().unwrap().len(),
5807 1,
5808 "only Start's own resume() call must have happened so far"
5809 );
5810
5811 let write = |engine: &Engine, value: FourStateValue| {
5812 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
5813 ops.commit_blocking_write(cell, value);
5814 };
5815
5816 // Posedge 1 (0 -> 1): StillWaiting -- must NOT enqueue/resume.
5817 write(&engine, narrow(1));
5818 let ready = engine.drain_and_sort(Region::Active);
5819 engine.dispatch_ready_set(Region::Active, ready);
5820 assert_eq!(
5821 log.lock().unwrap().len(),
5822 1,
5823 "the 1st matching posedge must not resume a repeat(3) waiter"
5824 );
5825
5826 // Negedge (1 -> 0): does not match a Posedge-qualified dynamic entry
5827 // at all -- irrelevant filler, mirroring an SV clock's real toggle
5828 // pattern.
5829 write(&engine, narrow(0));
5830
5831 // Posedge 2 (0 -> 1): StillWaiting again -- must still NOT resume.
5832 write(&engine, narrow(1));
5833 let ready = engine.drain_and_sort(Region::Active);
5834 engine.dispatch_ready_set(Region::Active, ready);
5835 assert_eq!(
5836 log.lock().unwrap().len(),
5837 1,
5838 "the 2nd matching posedge must not resume a repeat(3) waiter either"
5839 );
5840
5841 write(&engine, narrow(0));
5842
5843 // Posedge 3 (0 -> 1): Satisfied -- must resume exactly now.
5844 write(&engine, narrow(1));
5845 let ready = engine.drain_and_sort(Region::Active);
5846 engine.dispatch_ready_set(Region::Active, ready);
5847 {
5848 let log = log.lock().unwrap();
5849 assert_eq!(
5850 log.len(),
5851 2,
5852 "the 3rd matching posedge must resume the waiter exactly once"
5853 );
5854 assert!(
5855 matches!(log[1], ResumeArg::EventFired { .. }),
5856 "the resuming call's ResumeArg must be EventFired, got {:?}",
5857 log[1]
5858 );
5859 }
5860 assert_eq!(
5861 engine.test_sensitivity_entry_count(),
5862 0,
5863 "the dynamic entry (and its RepeatCounters row) must be gone once satisfied"
5864 );
5865
5866 // Two more posedges after the process has already resumed and
5867 // returned Suspend::Done (no re-arm): must produce no further
5868 // resume() calls at all -- proves a StillWaiting firing never falls
5869 // through to enqueue, and that a satisfied repeat's row is not
5870 // somehow still alive to double-fire.
5871 write(&engine, narrow(0));
5872 write(&engine, narrow(1));
5873 write(&engine, narrow(0));
5874 write(&engine, narrow(1));
5875 let ready = engine.drain_and_sort(Region::Active);
5876 engine.dispatch_ready_set(Region::Active, ready);
5877 assert_eq!(
5878 log.lock().unwrap().len(),
5879 2,
5880 "no resume() call must happen for any posedge after the repeat(3) waiter \
5881 has already resumed and completed -- exactly once total after the 3rd \
5882 posedge, never once per remaining posedge"
5883 );
5884 }
5885
5886 /// SC-053's own differentiating acceptance clause: "the same compiled
5887 /// `##N` delay produces different absolute tick timing when its
5888 /// associated clock's period changes dynamically at runtime" -- proven
5889 /// here at the scheduler level by driving a deliberately IRREGULAR
5890 /// (non-periodic) clock through a `repeat(2)@(posedge clk)`-shaped mock
5891 /// process and asserting it resumes at the clock's real 2nd posedge tick
5892 /// (12), never a precomputed `3 + 2*period`-style absolute tick a wrong
5893 /// "optimize by caching a tick offset" implementation would produce.
5894 #[test]
5895 fn sc053_repeat_based_wait_re_reads_the_clocks_live_period_not_a_cached_tick_offset() {
5896 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
5897 enum LogTag {
5898 Start,
5899 EventFired,
5900 }
5901
5902 struct RepeatWaiter {
5903 cell: StateCellId,
5904 log: Arc<StdMutex<Vec<(LogTag, SchedTime)>>>,
5905 }
5906 impl CompiledProcess for RepeatWaiter {
5907 fn resume(
5908 &mut self,
5909 _state: SimHandle,
5910 arg: ResumeArg,
5911 ops: &dyn SchedulerOps,
5912 ) -> Suspend {
5913 match arg {
5914 ResumeArg::Start => {
5915 self.log.lock().unwrap().push((LogTag::Start, ops.now()));
5916 Suspend::WaitEvents(EventWaitSet {
5917 terms: vec![EventTerm::Repeat {
5918 count: 2,
5919 inner: Box::new(EventTerm::Edge {
5920 cell: self.cell,
5921 edge: EdgeKind::Posedge,
5922 iff: None,
5923 }),
5924 }],
5925 ..Default::default()
5926 })
5927 }
5928 ResumeArg::EventFired { .. } => {
5929 self.log
5930 .lock()
5931 .unwrap()
5932 .push((LogTag::EventFired, ops.now()));
5933 Suspend::Done
5934 }
5935 other => panic!("RepeatWaiter must only see Start/EventFired, got {other:?}"),
5936 }
5937 }
5938 }
5939
5940 // Irregular clock: a real signal (not a "##N" literal) whose edges
5941 // land at ticks 3 (posedge), 5 (negedge, irrelevant filler), and 12
5942 // (posedge) -- deliberately never landing on a fixed period, so any
5943 // implementation that tried to precompute
5944 // `first_posedge_tick + (count - 1) * observed_period` would land on
5945 // a wrong tick (3 + 1*2 = 5, which is a negedge, not even a 2nd
5946 // posedge) instead of the real 2nd posedge at 12.
5947 struct IrregularClockWriter {
5948 cell: StateCellId,
5949 step: u32,
5950 }
5951 impl CompiledProcess for IrregularClockWriter {
5952 fn resume(
5953 &mut self,
5954 _state: SimHandle,
5955 arg: ResumeArg,
5956 ops: &dyn SchedulerOps,
5957 ) -> Suspend {
5958 match (self.step, arg) {
5959 (0, ResumeArg::Start) => {
5960 self.step = 1;
5961 Suspend::Delay(3) // fires at tick 3
5962 }
5963 (1, ResumeArg::TimeElapsed) => {
5964 ops.commit_blocking_write(self.cell, narrow(1)); // posedge @ 3
5965 self.step = 2;
5966 Suspend::Delay(2) // fires at tick 5
5967 }
5968 (2, ResumeArg::TimeElapsed) => {
5969 ops.commit_blocking_write(self.cell, narrow(0)); // negedge @ 5
5970 self.step = 3;
5971 Suspend::Delay(7) // fires at tick 12
5972 }
5973 (3, ResumeArg::TimeElapsed) => {
5974 ops.commit_blocking_write(self.cell, narrow(1)); // posedge @ 12
5975 self.step = 4;
5976 Suspend::Done
5977 }
5978 (step, other) => {
5979 panic!("IrregularClockWriter unexpected (step={step}, arg={other:?})")
5980 }
5981 }
5982 }
5983 }
5984
5985 let cell = StateCellId(201);
5986 let mut engine = Engine::new(
5987 Box::new(TestArena::seeded(cell, narrow(0))),
5988 Box::new(TestIoSink::default()),
5989 );
5990 let log: Arc<StdMutex<Vec<(LogTag, SchedTime)>>> = Arc::new(StdMutex::new(Vec::new()));
5991 engine.register_process(
5992 Box::new(RepeatWaiter {
5993 cell,
5994 log: log.clone(),
5995 }),
5996 ProcessKind::Initial,
5997 None,
5998 0,
5999 );
6000 engine.register_process(
6001 Box::new(IrregularClockWriter { cell, step: 0 }),
6002 ProcessKind::Initial,
6003 None,
6004 0,
6005 );
6006
6007 let result = engine.run_until(None);
6008 assert!(matches!(result, RunResult::Completed));
6009
6010 let log = log.lock().unwrap();
6011 assert_eq!(
6012 *log,
6013 vec![
6014 (LogTag::Start, SchedTime { ticks: 0 }),
6015 (LogTag::EventFired, SchedTime { ticks: 12 }),
6016 ],
6017 "the repeat(2) waiter must resume exactly at tick 12 (the clock's real 2nd \
6018 posedge), never a precomputed offset from a cached period"
6019 );
6020 }
6021
6022 // --- Task 4 (B-sched, ledger SC-090, SC-123, SC-020 scheduler-owned
6023 // half): Postponed/Reactive region-boundary placement: region
6024 // callbacks + `1step` ---
6025
6026 /// SC-090's own literal acceptance: a `$strobe`/`$monitor`-shaped
6027 /// `Region::Postponed` callback sampling a cell written twice within
6028 /// one timestep's delta loop must read only the FINAL, post-delta-
6029 /// convergence value, and must fire exactly once for that timestep --
6030 /// never once per intermediate write (which would mean it ran
6031 /// interleaved with the delta loop, not strictly after it converged).
6032 #[test]
6033 fn postponed_callback_fires_exactly_once_and_only_after_delta_convergence() {
6034 struct TwiceWritingProcess {
6035 cell: StateCellId,
6036 }
6037 impl CompiledProcess for TwiceWritingProcess {
6038 fn resume(
6039 &mut self,
6040 _state: SimHandle,
6041 arg: ResumeArg,
6042 ops: &dyn SchedulerOps,
6043 ) -> Suspend {
6044 match arg {
6045 ResumeArg::Start => {
6046 ops.commit_blocking_write(self.cell, narrow(0));
6047 Suspend::Delay(0)
6048 }
6049 ResumeArg::TimeElapsed => {
6050 ops.commit_blocking_write(self.cell, narrow(1));
6051 Suspend::Done
6052 }
6053 other => panic!("TwiceWritingProcess unexpected arg {other:?}"),
6054 }
6055 }
6056 }
6057
6058 struct ReadCellCallback {
6059 cell: StateCellId,
6060 log: Arc<StdMutex<Vec<FourStateValue>>>,
6061 }
6062 impl RegionCallback for ReadCellCallback {
6063 fn invoke(&mut self, ctx: &dyn SchedulerCtx) {
6064 self.log.lock().unwrap().push(ctx.read(self.cell));
6065 }
6066 }
6067
6068 let cell = StateCellId(300);
6069 let mut engine = Engine::new(
6070 Box::new(TestArena::seeded(cell, narrow(9))),
6071 Box::new(TestIoSink::default()),
6072 );
6073 let seen: Arc<StdMutex<Vec<FourStateValue>>> = Arc::new(StdMutex::new(Vec::new()));
6074 {
6075 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6076 ops.register_region_callback(
6077 Region::Postponed,
6078 engine.now(),
6079 Box::new(ReadCellCallback {
6080 cell,
6081 log: seen.clone(),
6082 }),
6083 );
6084 }
6085 engine.register_process(
6086 Box::new(TwiceWritingProcess { cell }),
6087 ProcessKind::Initial,
6088 None,
6089 0,
6090 );
6091
6092 let result = engine.run_until(None);
6093 assert!(matches!(result, RunResult::Completed));
6094
6095 let recorded = seen.lock().unwrap();
6096 assert_eq!(
6097 recorded.len(),
6098 1,
6099 "the Postponed callback must fire exactly once for the one timestep this \
6100 run has, not once per intermediate write inside the delta loop"
6101 );
6102 assert!(
6103 matches!(recorded[0], FourStateValue::Narrow(1)),
6104 "must read the FINAL, post-delta-convergence value (1), never the \
6105 intermediate write (0) or the pre-run seed (9); got {:?}",
6106 recorded[0]
6107 );
6108 }
6109
6110 /// Region ordering plus registration-order-per-region: a `Region::
6111 /// Reactive` callback and a `Region::Postponed` callback registered for
6112 /// the same `now` must both fire exactly once, Reactive strictly before
6113 /// Postponed (the same region ordering `run_until` already enforces for
6114 /// ordinary process dispatch), each observing the same final settled
6115 /// value -- proving the two regions' callback drains don't share state
6116 /// that lets one suppress or corrupt the other.
6117 #[test]
6118 fn reactive_and_postponed_callbacks_at_the_same_time_do_not_interfere() {
6119 struct OneShotWriter {
6120 cell: StateCellId,
6121 }
6122 impl CompiledProcess for OneShotWriter {
6123 fn resume(
6124 &mut self,
6125 _state: SimHandle,
6126 _arg: ResumeArg,
6127 ops: &dyn SchedulerOps,
6128 ) -> Suspend {
6129 ops.commit_blocking_write(self.cell, narrow(7));
6130 Suspend::Done
6131 }
6132 }
6133
6134 struct TaggedReadCallback {
6135 tag: &'static str,
6136 cell: StateCellId,
6137 log: Arc<StdMutex<Vec<(&'static str, FourStateValue)>>>,
6138 }
6139 impl RegionCallback for TaggedReadCallback {
6140 fn invoke(&mut self, ctx: &dyn SchedulerCtx) {
6141 self.log
6142 .lock()
6143 .unwrap()
6144 .push((self.tag, ctx.read(self.cell)));
6145 }
6146 }
6147
6148 let cell = StateCellId(301);
6149 let mut engine = Engine::new(
6150 Box::new(TestArena::seeded(cell, narrow(0))),
6151 Box::new(TestIoSink::default()),
6152 );
6153 let log: Arc<StdMutex<Vec<(&'static str, FourStateValue)>>> =
6154 Arc::new(StdMutex::new(Vec::new()));
6155 {
6156 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6157 ops.register_region_callback(
6158 Region::Reactive,
6159 engine.now(),
6160 Box::new(TaggedReadCallback {
6161 tag: "Reactive",
6162 cell,
6163 log: log.clone(),
6164 }),
6165 );
6166 ops.register_region_callback(
6167 Region::Postponed,
6168 engine.now(),
6169 Box::new(TaggedReadCallback {
6170 tag: "Postponed",
6171 cell,
6172 log: log.clone(),
6173 }),
6174 );
6175 }
6176 engine.register_process(
6177 Box::new(OneShotWriter { cell }),
6178 ProcessKind::Initial,
6179 None,
6180 0,
6181 );
6182
6183 let result = engine.run_until(None);
6184 assert!(matches!(result, RunResult::Completed));
6185
6186 let recorded = log.lock().unwrap();
6187 assert_eq!(
6188 recorded.len(),
6189 2,
6190 "both callbacks must fire exactly once each"
6191 );
6192 assert_eq!(
6193 recorded[0].0, "Reactive",
6194 "Reactive's own callback must fire before Postponed's"
6195 );
6196 assert_eq!(recorded[1].0, "Postponed");
6197 assert!(matches!(recorded[0].1, FourStateValue::Narrow(7)));
6198 assert!(matches!(recorded[1].1, FourStateValue::Narrow(7)));
6199 }
6200
6201 /// sva T2 (§1.4): `Region::Observed` is drained (via
6202 /// `drain_observed_and_check_settled`) strictly before `Region::
6203 /// Reactive`'s own dispatch/callback drain, within the SAME timestep --
6204 /// the reference algorithm's own stratified-region ordering (Clause
6205 /// 4.5), not merely "eventually, on some later timestep". Registers one
6206 /// tagged callback at each region (no process at all is needed: with
6207 /// nothing registered, Active/Inactive/Nba settle on `run_until`'s very
6208 /// first delta-loop pass, which is exactly the "genuinely idle" point
6209 /// `drain_observed_and_check_settled` is pinned to).
6210 #[test]
6211 fn observed_region_maturation_fires_before_reactive_same_timestep() {
6212 struct TagCallback {
6213 tag: &'static str,
6214 log: Arc<StdMutex<Vec<&'static str>>>,
6215 }
6216 impl RegionCallback for TagCallback {
6217 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6218 self.log.lock().unwrap().push(self.tag);
6219 }
6220 }
6221
6222 let mut engine = new_engine();
6223 let log: Arc<StdMutex<Vec<&'static str>>> = Arc::new(StdMutex::new(Vec::new()));
6224 {
6225 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6226 ops.register_region_callback(
6227 Region::Reactive,
6228 engine.now(),
6229 Box::new(TagCallback {
6230 tag: "Reactive",
6231 log: log.clone(),
6232 }),
6233 );
6234 ops.register_region_callback(
6235 Region::Observed,
6236 engine.now(),
6237 Box::new(TagCallback {
6238 tag: "Observed",
6239 log: log.clone(),
6240 }),
6241 );
6242 }
6243
6244 let result = engine.run_until(None);
6245 assert!(matches!(result, RunResult::Completed));
6246
6247 let recorded = log.lock().unwrap();
6248 assert_eq!(
6249 *recorded,
6250 vec!["Observed", "Reactive"],
6251 "Observed must mature before Reactive within the same settled timestep"
6252 );
6253 }
6254
6255 /// sva T2 (§1.4, review fix, blocking finding 2): a matured report an
6256 /// Observed callback schedules into `Region::Reactive` mid-drain (via
6257 /// `SchedulerOps::register_region_callback`, exactly as `sched::
6258 /// maturation::report` will for a real SVA pass/fail action, §1.7)
6259 /// must already be sitting in `region_callbacks` by the time the
6260 /// single-stepping debugger's OWN NEXT `step_region()` call reaches
6261 /// `Region::Reactive` -- not delayed to a later timestep, and not
6262 /// fired early from inside the Observed drain itself.
6263 #[test]
6264 fn observed_engine_step_result_visible_by_next_reactive_step() {
6265 struct TagCallback {
6266 tag: &'static str,
6267 log: Arc<StdMutex<Vec<&'static str>>>,
6268 }
6269 impl RegionCallback for TagCallback {
6270 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6271 self.log.lock().unwrap().push(self.tag);
6272 }
6273 }
6274
6275 struct ObservedReportsIntoReactive {
6276 log: Arc<StdMutex<Vec<&'static str>>>,
6277 }
6278 impl RegionCallback for ObservedReportsIntoReactive {
6279 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6280 // Stands in for a matured SVA thread's `sv_rt_sva_report_
6281 // pass` (§1.7): the Observed-region engine step schedules a
6282 // Reactive action from inside its own callback, exactly as
6283 // `sched::maturation::report` does for every report kind.
6284 let log = self.log.clone();
6285 crate::sched::with_current_ops(move |ops| {
6286 ops.register_region_callback(
6287 Region::Reactive,
6288 ops.now(),
6289 Box::new(TagCallback {
6290 tag: "matured",
6291 log: log.clone(),
6292 }),
6293 );
6294 });
6295 }
6296 }
6297
6298 let mut engine = new_engine();
6299 let log: Arc<StdMutex<Vec<&'static str>>> = Arc::new(StdMutex::new(Vec::new()));
6300 {
6301 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6302 ops.register_region_callback(
6303 Region::Observed,
6304 engine.now(),
6305 Box::new(ObservedReportsIntoReactive { log: log.clone() }),
6306 );
6307 }
6308
6309 assert_eq!(engine.current_region(), Region::Active);
6310 engine.step_region(); // Active -> Inactive
6311 assert_eq!(engine.current_region(), Region::Inactive);
6312 engine.step_region(); // Inactive -> Nba
6313 assert_eq!(engine.current_region(), Region::Nba);
6314 engine.step_region(); // Nba settles, drains Observed (schedules
6315 // "matured" into Reactive), -> Reactive.
6316 assert_eq!(engine.current_region(), Region::Reactive);
6317 assert!(
6318 log.lock().unwrap().is_empty(),
6319 "the matured report must not fire before Reactive's own step runs"
6320 );
6321
6322 engine.step_region(); // Reactive drains region callbacks -> Postponed.
6323 assert_eq!(engine.current_region(), Region::Postponed);
6324 assert_eq!(
6325 *log.lock().unwrap(),
6326 vec!["matured"],
6327 "the report registered mid-Observed-drain must already be visible to the very \
6328 next Reactive step"
6329 );
6330 }
6331
6332 /// SC-123's pinned `1step` placement: a process suspended via `1step`
6333 /// timing registers itself as a `Region::Postponed` callback at the
6334 /// current `now` (this test's own mock process does so directly via
6335 /// `ops.register_region_callback`, standing in for the codegen
6336 /// lowering no track emits yet this wave -- see `region_callback`'s
6337 /// own module doc comment). Its continuation must run strictly after
6338 /// every ORDINARY `Region::Postponed` callback registered before it,
6339 /// never interleaved before them -- the exact placement a wrongly-
6340 /// modeled `Suspend::Delay(0)` (which lands in `Region::Inactive`,
6341 /// strictly *before* Postponed even runs) could never produce.
6342 #[test]
6343 fn sc123_1step_continuation_runs_after_every_ordinary_postponed_callback_at_the_same_tick() {
6344 struct TagCallback {
6345 tag: &'static str,
6346 log: Arc<StdMutex<Vec<&'static str>>>,
6347 }
6348 impl RegionCallback for TagCallback {
6349 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6350 self.log.lock().unwrap().push(self.tag);
6351 }
6352 }
6353
6354 struct OneStepProcess {
6355 log: Arc<StdMutex<Vec<&'static str>>>,
6356 }
6357 impl CompiledProcess for OneStepProcess {
6358 fn resume(
6359 &mut self,
6360 _state: SimHandle,
6361 arg: ResumeArg,
6362 ops: &dyn SchedulerOps,
6363 ) -> Suspend {
6364 match arg {
6365 ResumeArg::Start => Suspend::Delay(0),
6366 ResumeArg::TimeElapsed => {
6367 // Pinned placement (region_callback's own module
6368 // doc): register as a Postponed callback at the
6369 // current tick, rather than resuming again via a
6370 // second Suspend::Delay(0) (which would land back
6371 // in Inactive, the wrong side of Postponed).
6372 ops.register_region_callback(
6373 Region::Postponed,
6374 ops.now(),
6375 Box::new(TagCallback {
6376 tag: "1step",
6377 log: self.log.clone(),
6378 }),
6379 );
6380 Suspend::Done
6381 }
6382 other => panic!("OneStepProcess unexpected arg {other:?}"),
6383 }
6384 }
6385 }
6386
6387 let mut engine = new_engine();
6388 let log: Arc<StdMutex<Vec<&'static str>>> = Arc::new(StdMutex::new(Vec::new()));
6389 {
6390 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6391 ops.register_region_callback(
6392 Region::Postponed,
6393 engine.now(),
6394 Box::new(TagCallback {
6395 tag: "A",
6396 log: log.clone(),
6397 }),
6398 );
6399 ops.register_region_callback(
6400 Region::Postponed,
6401 engine.now(),
6402 Box::new(TagCallback {
6403 tag: "B",
6404 log: log.clone(),
6405 }),
6406 );
6407 }
6408 engine.register_process(
6409 Box::new(OneStepProcess { log: log.clone() }),
6410 ProcessKind::Initial,
6411 None,
6412 0,
6413 );
6414
6415 let result = engine.run_until(None);
6416 assert!(matches!(result, RunResult::Completed));
6417
6418 let recorded = log.lock().unwrap();
6419 assert_eq!(
6420 *recorded,
6421 vec!["A", "B", "1step"],
6422 "the 1step continuation must fire strictly after every ordinary \
6423 Postponed callback registered before it, never interleaved before them"
6424 );
6425 }
6426
6427 /// Review fix (Task 4 r1): `Engine::push_region_callback` must reject
6428 /// every region `Engine::drain_region_callbacks` never drains
6429 /// (`Active`/`Inactive`/`Nba`) with a typed
6430 /// `Err(RegionCallbackRegionError::NotDrained(region))`, never silently
6431 /// accept it into `region_callbacks` where it would sit forever and
6432 /// never fire (that was the bug: no error, no panic, no log). Only
6433 /// `Reactive`/`Postponed` -- the two regions that method's own doc
6434 /// comment names -- may `Ok(())`.
6435 #[test]
6436 fn push_region_callback_rejects_every_region_drain_region_callbacks_never_drains() {
6437 struct NeverInvoked;
6438 impl RegionCallback for NeverInvoked {
6439 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6440 panic!("must never be invoked -- the registration itself must be rejected");
6441 }
6442 }
6443
6444 let engine = new_engine();
6445
6446 for ®ion in &[Region::Active, Region::Inactive, Region::Nba] {
6447 let result = engine.push_region_callback(engine.now(), region, Box::new(NeverInvoked));
6448 assert_eq!(
6449 result,
6450 Err(RegionCallbackRegionError::NotDrained(region)),
6451 "{region:?} is never drained by drain_region_callbacks and must be rejected, \
6452 not silently accepted"
6453 );
6454 }
6455
6456 // `NeverInvoked` registrations accepted just above are still sitting
6457 // in `engine.region_callbacks`, undrained (this test never calls
6458 // `run_until`/`drain_region_callbacks`) -- dropping `engine` here
6459 // drops them without invoking, so `NeverInvoked::invoke`'s panic
6460 // never fires. That drain-ordering/exactly-once behavior is already
6461 // covered end-to-end by `postponed_callback_fires_exactly_once_and_
6462 // only_after_delta_convergence` and
6463 // `reactive_and_postponed_callbacks_at_the_same_time_do_not_
6464 // interfere`; this test's own scope is `push_region_callback`'s
6465 // accept/reject decision alone.
6466 for ®ion in &[Region::Reactive, Region::Postponed] {
6467 let result = engine.push_region_callback(engine.now(), region, Box::new(NeverInvoked));
6468 assert_eq!(
6469 result,
6470 Ok(()),
6471 "{region:?} IS drained by drain_region_callbacks and must be accepted"
6472 );
6473 }
6474 }
6475
6476 /// sva T2 (§1.4, review fix, blocking finding 2), Observed's half:
6477 /// `Region::Observed` joins `Reactive`/`Postponed` on the ACCEPTING
6478 /// side -- `drain_observed_and_check_settled` genuinely drains it now,
6479 /// so a registration for it must no longer be rejected.
6480 #[test]
6481 fn push_region_callback_accepts_observed() {
6482 struct NeverInvoked;
6483 impl RegionCallback for NeverInvoked {
6484 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6485 panic!("must never be invoked -- this test only checks the accept decision");
6486 }
6487 }
6488
6489 let engine = new_engine();
6490 let result =
6491 engine.push_region_callback(engine.now(), Region::Observed, Box::new(NeverInvoked));
6492 assert_eq!(
6493 result,
6494 Ok(()),
6495 "Region::Observed IS now drained (by drain_observed_and_check_settled) and must \
6496 be accepted"
6497 );
6498 }
6499
6500 /// sva T2 (§1.4, review fix, blocking finding 2): adding `Observed` to
6501 /// the accepting side must not loosen `Preponed`'s own rejection --
6502 /// `Preponed` stays a pure inline snapshot push, never a registered
6503 /// callback, even after `Observed` becomes real (the two Task 1/Task 2
6504 /// additions could easily have been conflated into one "any SVA
6505 /// region" acceptance by a careless edit; this proves they weren't).
6506 #[test]
6507 fn push_region_callback_still_rejects_preponed_after_observed_added() {
6508 struct NeverInvoked;
6509 impl RegionCallback for NeverInvoked {
6510 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6511 panic!("must never be invoked -- the registration itself must be rejected");
6512 }
6513 }
6514
6515 let engine = new_engine();
6516 let result =
6517 engine.push_region_callback(engine.now(), Region::Preponed, Box::new(NeverInvoked));
6518 assert_eq!(
6519 result,
6520 Err(RegionCallbackRegionError::NotDrained(Region::Preponed)),
6521 "Region::Preponed must still be rejected -- it is a pure inline snapshot push, \
6522 never a registered callback, regardless of Observed's own addition"
6523 );
6524 }
6525
6526 /// Review fix (Task 4 r1): the one production trait boundary,
6527 /// `EngineOps::register_region_callback` (`SchedulerOps`'s impl), must
6528 /// convert `RegionCallbackRegionError` into an immediate, loud panic --
6529 /// never a silent, forever-unfired registration -- because the trait
6530 /// method itself returns `()` and cannot propagate a `Result` (its
6531 /// signature is a cross-track contract many `#[cfg(test)]` mocks outside
6532 /// this crate also implement).
6533 #[test]
6534 #[should_panic(expected = "region must be Reactive, Postponed, or Observed")]
6535 fn register_region_callback_panics_loudly_for_an_undrained_region() {
6536 struct NeverInvoked;
6537 impl RegionCallback for NeverInvoked {
6538 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6539 panic!("must never be invoked -- the registration itself must be rejected");
6540 }
6541 }
6542
6543 let engine = new_engine();
6544 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6545 ops.register_region_callback(Region::Active, engine.now(), Box::new(NeverInvoked));
6546 }
6547
6548 // --- Task 5 (B-sched, ledger SC-065, SC-066, SC-047, SC-048, SC-068):
6549 // EventTerm/EventWaitSet shape + static/dynamic sensitivity closure ---
6550
6551 /// SC-068's own literal acceptance, verified against the production
6552 /// `Engine` dispatch loop (not a hand-simulated one): `@(posedge clk iff
6553 /// en)` must not call `resume()` at all on a posedge where `en` reads
6554 /// false at wakeup-consideration time, and must resume it (with
6555 /// `ResumeArg::EventFired`) on the very next posedge where `en` reads
6556 /// true. `PredicateFn` is settled (`docs/CONTRACTS.md` D8) as a bare
6557 /// `extern "C" fn(SimHandle) -> bool`, produced per-callsite by codegen's
6558 /// `compile_iff_guard` -- it carries no `StateCellId` parameter of its
6559 /// own, so this mock predicate stands in for a compiled guard by reading
6560 /// a thread-local flag the test flips directly, exactly mirroring how a
6561 /// real compiled guard would read `en` through the (here, unused-by-the-
6562 /// mock) `SimHandle`. Fails if `EngineNotifySink::notify_change` ever
6563 /// enqueues/resumes without first consulting a `Some(iff)` row's
6564 /// predicate, or evaluates it backwards (blocking on true, allowing on
6565 /// false).
6566 #[test]
6567 fn engine_never_calls_resume_when_iff_is_false_but_does_on_the_next_true_edge() {
6568 thread_local! {
6569 static EN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
6570 }
6571 extern "C" fn iff_reads_en(_state: SimHandle) -> bool {
6572 EN.with(|c| c.get())
6573 }
6574
6575 struct IffWaiter {
6576 cell: StateCellId,
6577 resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6578 }
6579 impl CompiledProcess for IffWaiter {
6580 fn resume(
6581 &mut self,
6582 _state: SimHandle,
6583 arg: ResumeArg,
6584 _ops: &dyn SchedulerOps,
6585 ) -> Suspend {
6586 self.resume_log.lock().unwrap().push(arg);
6587 match arg {
6588 ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
6589 terms: vec![EventTerm::Edge {
6590 cell: self.cell,
6591 edge: EdgeKind::Posedge,
6592 iff: Some(crate::process::PredicateFn(iff_reads_en)),
6593 }],
6594 ..Default::default()
6595 }),
6596 _ => Suspend::Done,
6597 }
6598 }
6599 }
6600
6601 let cell = StateCellId(210);
6602 let mut engine = Engine::new(
6603 Box::new(TestArena::seeded(cell, narrow(0))),
6604 Box::new(TestIoSink::default()),
6605 );
6606 let log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6607 engine.register_process(
6608 Box::new(IffWaiter {
6609 cell,
6610 resume_log: log.clone(),
6611 }),
6612 ProcessKind::Initial,
6613 None,
6614 0,
6615 );
6616
6617 // Dispatch Start: installs the iff-guarded Edge dynamic wait.
6618 let ready = engine.drain_and_sort(Region::Active);
6619 engine.dispatch_ready_set(Region::Active, ready);
6620 assert_eq!(
6621 log.lock().unwrap().len(),
6622 1,
6623 "only Start's own resume() call must have happened so far"
6624 );
6625
6626 let write = |engine: &Engine, value: FourStateValue| {
6627 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6628 ops.commit_blocking_write(cell, value);
6629 };
6630
6631 // en=0: a real posedge occurs, but the predicate reads false --
6632 // must NOT resume.
6633 EN.with(|c| c.set(false));
6634 write(&engine, narrow(1));
6635 let ready = engine.drain_and_sort(Region::Active);
6636 engine.dispatch_ready_set(Region::Active, ready);
6637 assert_eq!(
6638 log.lock().unwrap().len(),
6639 1,
6640 "a posedge with iff=false must not resume() the waiter at all"
6641 );
6642
6643 write(&engine, narrow(0));
6644
6645 // en=1: the next real posedge, predicate now reads true -- must
6646 // resume exactly once, with ResumeArg::EventFired.
6647 EN.with(|c| c.set(true));
6648 write(&engine, narrow(1));
6649 let ready = engine.drain_and_sort(Region::Active);
6650 engine.dispatch_ready_set(Region::Active, ready);
6651 {
6652 let log = log.lock().unwrap();
6653 assert_eq!(
6654 log.len(),
6655 2,
6656 "a posedge with iff=true must resume() the waiter exactly once"
6657 );
6658 assert!(
6659 matches!(log[1], ResumeArg::EventFired { .. }),
6660 "the resuming call's ResumeArg must be EventFired, got {:?}",
6661 log[1]
6662 );
6663 }
6664 assert_eq!(
6665 engine.test_sensitivity_entry_count(),
6666 0,
6667 "the dynamic entry must be gone once the waiter has resumed"
6668 );
6669 }
6670
6671 // --- Task 6 (B-sched, ledger SC-049, SC-050, SC-051, SC-054, SC-069):
6672 // SignalEventControl family construction + named-event trigger + e2e ---
6673
6674 /// SC-069's own literal acceptance, verified against the production
6675 /// `Engine`/`advance_time_to` dispatch path (not a hand-simulated one):
6676 /// a delayed named-event trigger (`->>#5 my_event;`'s scheduler-side
6677 /// shape -- `TimeWheel::arm_trigger` + `Engine::advance_time_to`'s
6678 /// `TimerAction::TriggerEvent` arm) must, once it fires, enqueue a
6679 /// process that is `@(my_event)`-waiting via `EventTerm::
6680 /// NamedEventTrigger` -- and, distinctly, must NOT create or resume any
6681 /// activation for "the triggering process" itself (there is deliberately
6682 /// no second, triggering process registered at all in this test, so any
6683 /// activation showing up in the post-fire ready set other than the
6684 /// waiter's own would prove a wrong implementation that conflated
6685 /// "trigger this event" with "resume whoever armed it" -- the exact
6686 /// regression this test's own plan text names). This is the real,
6687 /// production-loop version of `sched::wheel::tests::
6688 /// resume_and_trigger_entries_at_the_same_tick_both_fire_correctly`'s
6689 /// wheel-only proof, closing the fan-out half that lives in this module,
6690 /// not in `wheel.rs`.
6691 #[test]
6692 fn sc069_a_delayed_trigger_wakes_a_named_event_waiter_not_a_resumed_process() {
6693 struct NamedEventWaiter {
6694 ev: StateCellId,
6695 resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6696 }
6697 impl CompiledProcess for NamedEventWaiter {
6698 fn resume(
6699 &mut self,
6700 _state: SimHandle,
6701 arg: ResumeArg,
6702 _ops: &dyn SchedulerOps,
6703 ) -> Suspend {
6704 self.resume_log.lock().unwrap().push(arg);
6705 match arg {
6706 ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
6707 terms: vec![EventTerm::NamedEventTrigger(self.ev)],
6708 ..Default::default()
6709 }),
6710 _ => Suspend::Done,
6711 }
6712 }
6713 }
6714
6715 let ev = StateCellId(400);
6716 let mut engine = Engine::new(Box::new(TestArena::new()), Box::new(TestIoSink::default()));
6717 let log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6718 let waiter = engine.register_process(
6719 Box::new(NamedEventWaiter {
6720 ev,
6721 resume_log: log.clone(),
6722 }),
6723 ProcessKind::Initial,
6724 None,
6725 0,
6726 );
6727
6728 // Dispatch Start: installs the NamedEventTrigger dynamic wait.
6729 let ready = engine.drain_and_sort(Region::Active);
6730 engine.dispatch_ready_set(Region::Active, ready);
6731 assert_eq!(
6732 log.lock().unwrap().len(),
6733 1,
6734 "only Start's own resume() call must have happened so far"
6735 );
6736
6737 // Arm the delayed trigger directly on the wheel (this task's own
6738 // production entry point, `TimeWheel::arm_trigger`) and advance time
6739 // to it through the real `Engine::advance_time_to` -- never a
6740 // hand-simulated fan-out.
6741 engine.wheel.arm_trigger(10, ev);
6742 engine.advance_time_to(10);
6743
6744 let ready = engine.drain_and_sort(Region::Active);
6745 assert_eq!(
6746 ready.len(),
6747 1,
6748 "the fired trigger must enqueue exactly one activation -- the \
6749 waiter's -- never zero (dropped) and never more than one \
6750 (e.g. also resuming some non-existent 'triggering process')"
6751 );
6752 assert_eq!(
6753 ready[0].proc, waiter,
6754 "the one enqueued activation must be the NamedEventTrigger \
6755 waiter's own process id"
6756 );
6757 engine.dispatch_ready_set(Region::Active, ready);
6758 {
6759 let log = log.lock().unwrap();
6760 assert_eq!(
6761 log.len(),
6762 2,
6763 "the waiter must resume exactly once after the trigger fires"
6764 );
6765 assert!(
6766 matches!(log[1], ResumeArg::EventFired { .. }),
6767 "the resuming call's ResumeArg must be EventFired, got {:?}",
6768 log[1]
6769 );
6770 }
6771 assert_eq!(
6772 engine.test_sensitivity_entry_count(),
6773 0,
6774 "the dynamic NamedEventTrigger entry must be gone once the \
6775 waiter has resumed"
6776 );
6777 }
6778
6779 /// Companion to the wheel-only
6780 /// `resume_and_trigger_entries_at_the_same_tick_both_fire_correctly`:
6781 /// proves the SAME same-tick-FIFO guarantee holds through the full
6782 /// `Engine::advance_time_to` fan-out, not just at the raw
6783 /// `TimeWheel::advance_to` level -- a `Resume` entry (an ordinary
6784 /// `#delay`-suspended process) and a `TriggerEvent` entry armed at the
6785 /// same tick must both take effect: the delayed process resumes, and the
6786 /// named-event waiter is woken, from one `advance_time_to` call.
6787 #[test]
6788 fn engine_advance_time_to_fires_a_resume_and_a_trigger_armed_at_the_same_tick() {
6789 struct DoneOnResume {
6790 resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6791 }
6792 impl CompiledProcess for DoneOnResume {
6793 fn resume(
6794 &mut self,
6795 _state: SimHandle,
6796 arg: ResumeArg,
6797 _ops: &dyn SchedulerOps,
6798 ) -> Suspend {
6799 self.resume_log.lock().unwrap().push(arg);
6800 Suspend::Done
6801 }
6802 }
6803 struct NamedEventWaiter {
6804 ev: StateCellId,
6805 resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6806 }
6807 impl CompiledProcess for NamedEventWaiter {
6808 fn resume(
6809 &mut self,
6810 _state: SimHandle,
6811 arg: ResumeArg,
6812 _ops: &dyn SchedulerOps,
6813 ) -> Suspend {
6814 self.resume_log.lock().unwrap().push(arg);
6815 match arg {
6816 ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
6817 terms: vec![EventTerm::NamedEventTrigger(self.ev)],
6818 ..Default::default()
6819 }),
6820 _ => Suspend::Done,
6821 }
6822 }
6823 }
6824
6825 let ev = StateCellId(401);
6826 let mut engine = Engine::new(Box::new(TestArena::new()), Box::new(TestIoSink::default()));
6827 let resume_log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6828 let waiter_log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6829
6830 let resumed_proc = engine.register_process(
6831 Box::new(DoneOnResume {
6832 resume_log: resume_log.clone(),
6833 }),
6834 ProcessKind::Initial,
6835 None,
6836 0,
6837 );
6838 let waiter = engine.register_process(
6839 Box::new(NamedEventWaiter {
6840 ev,
6841 resume_log: waiter_log.clone(),
6842 }),
6843 ProcessKind::Initial,
6844 None,
6845 0,
6846 );
6847
6848 // Dispatch both Start activations: `resumed_proc` returns
6849 // Suspend::Done (its own Start resume already happened, nothing more
6850 // to arm for it directly -- this test arms its own `Resume` entry on
6851 // the wheel below, standing in for a `Suspend::Delay`-suspended
6852 // continuation), `waiter` installs its NamedEventTrigger wait.
6853 let ready = engine.drain_and_sort(Region::Active);
6854 engine.dispatch_ready_set(Region::Active, ready);
6855 resume_log.lock().unwrap().clear();
6856
6857 let act = PendingActivation {
6858 id: 9_999,
6859 proc: resumed_proc,
6860 arg: ResumeArg::TimeElapsed,
6861 reconv_iter: 0,
6862 logical_order: (SchedTime { ticks: 0 }, Region::Active, 0),
6863 };
6864 engine.wheel.arm(
6865 10,
6866 TimerEntry {
6867 action: TimerAction::Resume(act, Region::Active),
6868 },
6869 );
6870 engine.wheel.arm_trigger(10, ev);
6871 engine.advance_time_to(10);
6872
6873 let ready = engine.drain_and_sort(Region::Active);
6874 let mut procs: Vec<ProcessId> = ready.iter().map(|a| a.proc).collect();
6875 procs.sort_unstable();
6876 let mut want = vec![resumed_proc, waiter];
6877 want.sort_unstable();
6878 assert_eq!(
6879 procs, want,
6880 "both the Resume entry's own process and the TriggerEvent's \
6881 waiter must be enqueued from a single advance_time_to call \
6882 covering the same tick"
6883 );
6884 }
6885
6886 // --- Task 9 (B-sched wave, ledger SC-125) ---
6887
6888 /// A `resume()` that always returns `Suspend::Done` and, if ever called
6889 /// a second time, panics -- standing in for "a process that must never
6890 /// be resumed again," the sanctioned negative-proof shape for
6891 /// `disable fork;`'s own "terminates without ever resuming its
6892 /// descendants" acceptance.
6893 struct NeverResumedTwice(std::sync::atomic::AtomicUsize);
6894 impl NeverResumedTwice {
6895 fn new() -> Self {
6896 NeverResumedTwice(std::sync::atomic::AtomicUsize::new(0))
6897 }
6898 }
6899 impl CompiledProcess for NeverResumedTwice {
6900 fn resume(&mut self, _s: SimHandle, _arg: ResumeArg, _ops: &dyn SchedulerOps) -> Suspend {
6901 let prior = self.0.fetch_add(1, Ordering::SeqCst);
6902 assert_eq!(
6903 prior, 0,
6904 "a fork descendant terminated by disable_fork must never be resumed \
6905 at all, let alone more than once"
6906 );
6907 Suspend::Done
6908 }
6909 }
6910
6911 fn spawn_from(parent: ProcessId) -> SpawnSpec {
6912 SpawnSpec {
6913 kernel: 0,
6914 entry_pc: 0,
6915 parent,
6916 disable_name: Vec::new(),
6917 }
6918 }
6919
6920 /// SC-125's own literal `disable fork;` acceptance: a parent with 2 live
6921 /// children, one of which (`child_a`) itself has 1 live grandchild (a
6922 /// nested fork) -- the parent's own mock calls `ops.disable_fork(self)`
6923 /// mid-`resume()` and, in that same call, returns `Suspend::Done` (never
6924 /// a suspending variant, proving the call itself does not suspend the
6925 /// caller). Drives the parent's own `Start` through the real `Engine`/
6926 /// `SchedulerControl` dispatch loop (`run_until`) -- the fork tree
6927 /// itself is built directly via `ResumeDriver::handle_fork` before that
6928 /// (this module's own private fields are reachable from this same-file
6929 /// `tests` submodule), the same "construct the tree directly, dispatch
6930 /// only the one process under test" pattern `resume.rs`'s own nested-
6931 /// fork tests already use, needed here because `Suspend::ForkJoin`'s
6932 /// real dispatch path (`apply_suspend`'s own `mint_child`) always mints
6933 /// an immediately-`Done` `UnmintedForkChild` (Task 7's documented
6934 /// interim placeholder), which could never stay live long enough to
6935 /// itself fork a grandchild.
6936 #[test]
6937 fn sc125_disable_fork_terminates_every_live_descendant_without_suspending_the_caller() {
6938 struct DisablingParent {
6939 id_cell: Arc<StdMutex<Option<ProcessId>>>,
6940 resume_count: Arc<std::sync::atomic::AtomicUsize>,
6941 }
6942 impl CompiledProcess for DisablingParent {
6943 fn resume(&mut self, _s: SimHandle, arg: ResumeArg, ops: &dyn SchedulerOps) -> Suspend {
6944 self.resume_count.fetch_add(1, Ordering::SeqCst);
6945 match arg {
6946 ResumeArg::Start => {
6947 let id = self
6948 .id_cell
6949 .lock()
6950 .unwrap()
6951 .expect("id_cell set right after register_process returns");
6952 ops.disable_fork(id);
6953 Suspend::Done
6954 }
6955 other => panic!("DisablingParent must only ever see Start, got {other:?}"),
6956 }
6957 }
6958 }
6959
6960 let mut engine = new_engine();
6961 let id_cell = Arc::new(StdMutex::new(None));
6962 let resume_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6963 let parent = engine.register_process(
6964 Box::new(DisablingParent {
6965 id_cell: id_cell.clone(),
6966 resume_count: resume_count.clone(),
6967 }),
6968 ProcessKind::Initial,
6969 None,
6970 0,
6971 );
6972 *id_cell.lock().unwrap() = Some(parent);
6973
6974 // Build the fork tree directly (see this test's own doc comment):
6975 // parent -> [child_a, child_b]; child_a -> [grandchild].
6976 let mut mint =
6977 |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
6978 let children = engine.driver.handle_fork(
6979 &mut engine.table,
6980 parent,
6981 JoinKind::None,
6982 &[spawn_from(parent), spawn_from(parent)],
6983 &mut mint,
6984 );
6985 assert_eq!(children.len(), 2);
6986 let (child_a, child_b) = (children[0], children[1]);
6987 let grandchildren = engine.driver.handle_fork(
6988 &mut engine.table,
6989 child_a,
6990 JoinKind::None,
6991 &[spawn_from(child_a)],
6992 &mut mint,
6993 );
6994 assert_eq!(grandchildren.len(), 1);
6995 let grandchild = grandchildren[0];
6996
6997 assert!(engine.test_process_exists(child_a));
6998 assert!(engine.test_process_exists(child_b));
6999 assert!(engine.test_process_exists(grandchild));
7000
7001 // `register_process` already queued `parent`'s own Start activation
7002 // -- `child_a`/`child_b`/`grandchild` were registered directly via
7003 // `handle_fork` above, never given a Start activation of their own,
7004 // so this `run_until` dispatches exactly one process: `parent`.
7005 let result = engine.run_until(None);
7006 assert!(matches!(result, RunResult::Completed));
7007
7008 assert_eq!(
7009 resume_count.load(Ordering::SeqCst),
7010 1,
7011 "the parent's own single resume() call must never be re-entered a second \
7012 time by disable_fork itself"
7013 );
7014 assert!(
7015 !engine.test_process_exists(child_a),
7016 "disable_fork must terminate every live child"
7017 );
7018 assert!(
7019 !engine.test_process_exists(child_b),
7020 "disable_fork must terminate every live child"
7021 );
7022 assert!(
7023 !engine.test_process_exists(grandchild),
7024 "disable_fork must terminate a live grandchild too -- one level deep is \
7025 not enough"
7026 );
7027 }
7028
7029 /// Proves `Suspend::WaitFork`'s own scheduler-side *installation*
7030 /// (`Engine::apply_wait_fork`) — distinct from `ResumeDriver::
7031 /// on_child_done`'s own decrement logic, which
7032 /// `sched::resume::tests::sc125_wait_fork_resumes_only_once_every_live_
7033 /// child_completes_not_on_the_first` already covers directly. Builds 2
7034 /// live children via `handle_fork` (bypassing `Suspend::ForkJoin`'s own
7035 /// hardcoded `UnmintedForkChild` mint, per the disable_fork test's own
7036 /// doc comment, so the children stay live across this test's own
7037 /// `apply_suspend` calls), suspends the parent directly via
7038 /// `apply_suspend(parent, Suspend::WaitFork)`, and drives both children
7039 /// to completion via `apply_suspend(child, Suspend::Done)`. Fails if
7040 /// `apply_wait_fork` ever miscounts the live-child set (e.g. counting
7041 /// `fork_children.len()` instead of filtering for still-live entries).
7042 #[test]
7043 fn sc125_apply_suspend_wait_fork_installs_live_count_and_resumes_after_the_last_child() {
7044 struct WaitForkParent {
7045 log: Arc<StdMutex<Vec<&'static str>>>,
7046 }
7047 impl CompiledProcess for WaitForkParent {
7048 fn resume(
7049 &mut self,
7050 _s: SimHandle,
7051 arg: ResumeArg,
7052 _ops: &dyn SchedulerOps,
7053 ) -> Suspend {
7054 match arg {
7055 ResumeArg::AllForkChildrenDone => {
7056 self.log.lock().unwrap().push("resumed");
7057 Suspend::Done
7058 }
7059 other => panic!("WaitForkParent: unexpected {other:?}"),
7060 }
7061 }
7062 }
7063
7064 let log = Arc::new(StdMutex::new(Vec::new()));
7065 let mut engine = new_engine();
7066 // Registered directly via `ProcessTable::register` (not
7067 // `register_process`, which would also queue an unrelated `Start`
7068 // activation this test never dispatches -- this test drives `proc`
7069 // entirely through direct `apply_suspend` calls, standing in for a
7070 // real process already mid-way through its own `fork ... join_none;
7071 // ...; wait fork;` sequence).
7072 let parent = engine
7073 .table
7074 .register(Box::new(WaitForkParent { log: log.clone() }));
7075 let mut mint =
7076 |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7077 let children = engine.driver.handle_fork(
7078 &mut engine.table,
7079 parent,
7080 JoinKind::None,
7081 &[spawn_from(parent), spawn_from(parent)],
7082 &mut mint,
7083 );
7084 assert_eq!(children.len(), 2);
7085
7086 engine.apply_suspend(parent, Suspend::WaitFork);
7087 assert_eq!(
7088 engine.table.get(parent).unwrap().wait_fork_remaining,
7089 Some(2),
7090 "apply_wait_fork must install the true live-child count"
7091 );
7092
7093 engine.apply_suspend(children[0], Suspend::Done);
7094 assert!(!engine.test_process_exists(children[0]));
7095 assert!(
7096 log.lock().unwrap().is_empty(),
7097 "must not resume the parent after only 1 of 2 live children"
7098 );
7099 assert_eq!(
7100 engine.table.get(parent).unwrap().wait_fork_remaining,
7101 Some(1)
7102 );
7103
7104 engine.apply_suspend(children[1], Suspend::Done);
7105 assert!(!engine.test_process_exists(children[1]));
7106 assert!(
7107 engine
7108 .table
7109 .get(parent)
7110 .unwrap()
7111 .wait_fork_remaining
7112 .is_none(),
7113 "wait_fork_remaining must be cleared the instant the count reaches 0, \
7114 immediately from apply_suspend itself, before the parent's own \
7115 AllForkChildrenDone activation is ever dispatched below"
7116 );
7117 // The last completion's `Suspend::Done` arm only *enqueues*
7118 // `parent`'s `AllForkChildrenDone` activation onto `Region::Active`
7119 // (the same real queue `ForkChildDone` notifications use) -- it does
7120 // not call `resume()` synchronously, so an actual dispatch pass is
7121 // needed to observe it, exactly like `fork_join_all_actually_
7122 // resumes_the_parent_when_its_one_child_completes` above needs
7123 // `run_until` for the identical reason.
7124 let ready = engine.drain_and_sort(Region::Active);
7125 engine.dispatch_ready_set(Region::Active, ready);
7126 assert_eq!(
7127 log.lock().unwrap().as_slice(),
7128 &["resumed"],
7129 "the parent must resume exactly once, only after the 2nd (last) child"
7130 );
7131 }
7132
7133 /// **Mutation-testing addition (B-sched, SC-125).** `apply_wait_fork`'s
7134 /// own doc comment names "the common 'every child already finished
7135 /// before `wait fork;` itself ran' case" -- this proves it directly, for
7136 /// a *mixed* population (one child already done and reclaimed, one still
7137 /// live) rather than the "0 live" or "all live" cases the other two
7138 /// tests here already cover. `live` must be computed by filtering
7139 /// `fork_children` down to those still present in `table` -- counting
7140 /// every spawned child regardless of liveness (e.g. `children.len()`)
7141 /// would install a `wait_fork_remaining` one too high here, and the
7142 /// parent would never resume: its one remaining live child's own
7143 /// completion would decrement to 1, not 0, and `on_child_done` would
7144 /// never report `AllForkChildrenDone`.
7145 #[test]
7146 fn sc125_apply_wait_fork_counts_only_still_live_children_not_every_spawned_child() {
7147 struct WaitForkParent {
7148 log: Arc<StdMutex<Vec<&'static str>>>,
7149 }
7150 impl CompiledProcess for WaitForkParent {
7151 fn resume(
7152 &mut self,
7153 _s: SimHandle,
7154 arg: ResumeArg,
7155 _ops: &dyn SchedulerOps,
7156 ) -> Suspend {
7157 match arg {
7158 ResumeArg::AllForkChildrenDone => {
7159 self.log.lock().unwrap().push("resumed");
7160 Suspend::Done
7161 }
7162 other => panic!("WaitForkParent: unexpected {other:?}"),
7163 }
7164 }
7165 }
7166
7167 let log = Arc::new(StdMutex::new(Vec::new()));
7168 let mut engine = new_engine();
7169 let parent = engine
7170 .table
7171 .register(Box::new(WaitForkParent { log: log.clone() }));
7172 let mut mint =
7173 |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7174 let children = engine.driver.handle_fork(
7175 &mut engine.table,
7176 parent,
7177 JoinKind::None,
7178 &[spawn_from(parent), spawn_from(parent)],
7179 &mut mint,
7180 );
7181 assert_eq!(children.len(), 2);
7182
7183 // `children[0]` finishes -- and is reclaimed -- *before* `wait
7184 // fork;` itself ever runs. `parent`'s own `fork_children` still
7185 // lists both ids (nothing prunes that list on completion), but only
7186 // `children[1]` is still a live table entry.
7187 engine.apply_suspend(children[0], Suspend::Done);
7188 assert!(!engine.test_process_exists(children[0]));
7189 assert!(engine.test_process_exists(children[1]));
7190
7191 engine.apply_suspend(parent, Suspend::WaitFork);
7192 assert_eq!(
7193 engine.table.get(parent).unwrap().wait_fork_remaining,
7194 Some(1),
7195 "apply_wait_fork must count only the one still-live child, not both \
7196 spawned children"
7197 );
7198
7199 engine.apply_suspend(children[1], Suspend::Done);
7200 assert!(!engine.test_process_exists(children[1]));
7201 assert!(
7202 engine
7203 .table
7204 .get(parent)
7205 .unwrap()
7206 .wait_fork_remaining
7207 .is_none(),
7208 "wait_fork_remaining must reach 0 (and clear) the instant this one live \
7209 child's completion is reported -- not require a second, nonexistent \
7210 completion"
7211 );
7212 let ready = engine.drain_and_sort(Region::Active);
7213 engine.dispatch_ready_set(Region::Active, ready);
7214 assert_eq!(
7215 log.lock().unwrap().as_slice(),
7216 &["resumed"],
7217 "the parent must actually resume once its one live child completes, \
7218 even though one of its two originally-spawned children finished \
7219 earlier"
7220 );
7221 }
7222
7223 /// Proves `Suspend::WaitOrder(order)`'s own scheduler-side
7224 /// *installation* (`Engine::apply_wait_order`) through a real
7225 /// `apply_suspend` call, complementing `sched::resume::tests`'s own
7226 /// direct `on_child_done` proofs for the ordering/violation logic
7227 /// itself.
7228 #[test]
7229 fn sc125_apply_suspend_wait_order_resumes_only_after_the_exact_declared_order() {
7230 struct WaitOrderParent {
7231 log: Arc<StdMutex<Vec<&'static str>>>,
7232 }
7233 impl CompiledProcess for WaitOrderParent {
7234 fn resume(
7235 &mut self,
7236 _s: SimHandle,
7237 arg: ResumeArg,
7238 _ops: &dyn SchedulerOps,
7239 ) -> Suspend {
7240 match arg {
7241 ResumeArg::WaitOrderSatisfied => {
7242 self.log.lock().unwrap().push("resumed");
7243 Suspend::Done
7244 }
7245 other => panic!("WaitOrderParent: unexpected {other:?}"),
7246 }
7247 }
7248 }
7249
7250 let log = Arc::new(StdMutex::new(Vec::new()));
7251 let mut engine = new_engine();
7252 // See the identical `ProcessTable::register` note on
7253 // `sc125_apply_suspend_wait_fork_installs_live_count_and_resumes_
7254 // after_the_last_child` above.
7255 let parent = engine
7256 .table
7257 .register(Box::new(WaitOrderParent { log: log.clone() }));
7258 let mut mint =
7259 |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7260 let children = engine.driver.handle_fork(
7261 &mut engine.table,
7262 parent,
7263 JoinKind::None,
7264 &[spawn_from(parent), spawn_from(parent), spawn_from(parent)],
7265 &mut mint,
7266 );
7267 assert_eq!(children.len(), 3);
7268 let order = children.clone();
7269
7270 engine.apply_suspend(parent, Suspend::WaitOrder(order));
7271 assert!(engine.table.get(parent).unwrap().wait_order.is_some());
7272
7273 engine.apply_suspend(children[0], Suspend::Done);
7274 engine.apply_suspend(children[1], Suspend::Done);
7275 assert!(
7276 log.lock().unwrap().is_empty(),
7277 "must not resume before the 3rd, in-order completion"
7278 );
7279
7280 engine.apply_suspend(children[2], Suspend::Done);
7281 // The 3rd completion's `Suspend::Done` arm only *enqueues* the
7282 // parent's `WaitOrderSatisfied` activation onto `Region::Active` --
7283 // see the identical note on `sc125_apply_suspend_wait_fork_
7284 // installs_live_count_and_resumes_after_the_last_child` above.
7285 let ready = engine.drain_and_sort(Region::Active);
7286 engine.dispatch_ready_set(Region::Active, ready);
7287 assert_eq!(log.lock().unwrap().as_slice(), &["resumed"]);
7288 }
7289
7290 // --- Classes plan Task 13 (LRM 9.7): the `process` class ---
7291 //
7292 // Every test drives the real `Engine` dispatch loop (`run_until`) with
7293 // scripted processes that call the same `SchedulerOps` entry points the
7294 // compiled `process` methods reach (`process_track` / `process_request`
7295 // / `process_state`), so the Kill/Suspend/Resume/Await/status mechanics
7296 // are pinned without any codegen. ProcessIds are the registration order
7297 // (0, 1, 2, ...), asserted on every `register_process` return.
7298
7299 type ProcLog = Arc<StdMutex<Vec<String>>>;
7300
7301 /// A process whose every `resume()` is one call of a closure.
7302 type ScriptFn = Box<dyn FnMut(ResumeArg, &dyn SchedulerOps) -> Suspend + Send>;
7303 struct Script(ScriptFn);
7304 impl CompiledProcess for Script {
7305 fn resume(&mut self, _s: SimHandle, arg: ResumeArg, ops: &dyn SchedulerOps) -> Suspend {
7306 (self.0)(arg, ops)
7307 }
7308 }
7309
7310 fn script(
7311 f: impl FnMut(ResumeArg, &dyn SchedulerOps) -> Suspend + Send + 'static,
7312 ) -> Box<dyn CompiledProcess> {
7313 Box::new(Script(Box::new(f)))
7314 }
7315
7316 fn new_log() -> ProcLog {
7317 Arc::new(StdMutex::new(Vec::new()))
7318 }
7319
7320 fn lines(log: &ProcLog) -> Vec<String> {
7321 log.lock().unwrap().clone()
7322 }
7323
7324 fn state_of(ops: &dyn SchedulerOps, target: ProcessId) -> u8 {
7325 ops.process_state(target) as u8
7326 }
7327
7328 fn register_expecting(engine: &mut Engine, proc: Box<dyn CompiledProcess>, want: u32) {
7329 let id = engine.register_process(proc, ProcessKind::Initial, None, 0);
7330 assert_eq!(id, want, "tests hard-code ProcessIds as registration order");
7331 }
7332
7333 /// LRM 9.7.3: `kill()` terminates the target and ALL its descendants
7334 /// before it returns, and `status()` reads KILLED in the very segment
7335 /// that called `kill()`. The victim's pending timer must never fire.
7336 #[test]
7337 fn process_kill_terminates_target_and_descendants_and_reads_killed() {
7338 let mut engine = new_engine();
7339 let log = new_log();
7340
7341 let l0 = log.clone();
7342 register_expecting(
7343 &mut engine,
7344 script(move |arg, ops| match arg {
7345 ResumeArg::Start => {
7346 ops.process_track(0);
7347 Suspend::Delay(100)
7348 }
7349 other => {
7350 l0.lock().unwrap().push(format!("victim resumed {other:?}"));
7351 Suspend::Done
7352 }
7353 }),
7354 0,
7355 );
7356 let (l1, mut step) = (log.clone(), 0);
7357 register_expecting(
7358 &mut engine,
7359 script(move |arg, ops| {
7360 step += 1;
7361 match (step, arg) {
7362 (1, ResumeArg::Start) => Suspend::Delay(10),
7363 (2, ResumeArg::TimeElapsed) => {
7364 let before = state_of(ops, 0);
7365 ops.process_request(1, ProcessRequest::Kill(0));
7366 let after = state_of(ops, 0);
7367 l1.lock().unwrap().push(format!(
7368 "t={} before={before} after={after}",
7369 ops.now().ticks
7370 ));
7371 Suspend::ByRequest
7372 }
7373 (3, ResumeArg::TimeElapsed) => {
7374 l1.lock()
7375 .unwrap()
7376 .push(format!("resumed state={}", state_of(ops, 0)));
7377 Suspend::Done
7378 }
7379 other => panic!("killer: unexpected {other:?}"),
7380 }
7381 }),
7382 1,
7383 );
7384
7385 // victim (0) -> child (2) -> grandchild (3), built directly.
7386 let mut mint =
7387 |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7388 let kids = engine.driver.handle_fork(
7389 &mut engine.table,
7390 0,
7391 JoinKind::None,
7392 &[spawn_from(0)],
7393 &mut mint,
7394 );
7395 let grand = engine.driver.handle_fork(
7396 &mut engine.table,
7397 kids[0],
7398 JoinKind::None,
7399 &[spawn_from(kids[0])],
7400 &mut mint,
7401 );
7402 assert_eq!((kids[0], grand[0]), (2, 3));
7403
7404 assert!(matches!(engine.run_until(None), RunResult::Completed));
7405 assert_eq!(
7406 lines(&log),
7407 // The victim is WAITING on its delay (2); the overlay turns it
7408 // KILLED (4) at once.
7409 ["t=10 before=2 after=4", "resumed state=4"],
7410 "kill must be visible to status() in the same segment, and the \
7411 victim's Delay(100) must never resume it"
7412 );
7413 for id in [0, 2, 3] {
7414 assert!(!engine.test_process_exists(id), "process {id} must be gone");
7415 }
7416 assert_eq!(engine.effective_process_state(0), ProcessState::Killed);
7417 assert_eq!(
7418 engine.effective_process_state(3),
7419 ProcessState::Killed,
7420 "an untracked descendant killed with its ancestor reads KILLED too"
7421 );
7422 }
7423
7424 /// LRM 9.7.3: `kill()` on a process that already ended still terminates
7425 /// its live descendants, and the target keeps reading FINISHED.
7426 #[test]
7427 fn process_kill_of_a_finished_process_still_kills_its_live_descendants() {
7428 let mut engine = new_engine();
7429 register_expecting(
7430 &mut engine,
7431 script(|_, ops| {
7432 ops.process_track(0);
7433 Suspend::Done
7434 }),
7435 0,
7436 );
7437 let mut step = 0;
7438 register_expecting(
7439 &mut engine,
7440 script(move |_, ops| {
7441 step += 1;
7442 match step {
7443 1 => Suspend::Delay(10),
7444 2 => {
7445 ops.process_request(1, ProcessRequest::Kill(0));
7446 Suspend::ByRequest
7447 }
7448 _ => Suspend::Done,
7449 }
7450 }),
7451 1,
7452 );
7453 let mut mint =
7454 |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7455 let kids = engine.driver.handle_fork(
7456 &mut engine.table,
7457 0,
7458 JoinKind::None,
7459 &[spawn_from(0)],
7460 &mut mint,
7461 );
7462 assert!(engine.test_process_exists(kids[0]));
7463 assert!(matches!(engine.run_until(None), RunResult::Completed));
7464 assert!(
7465 !engine.test_process_exists(kids[0]),
7466 "the live descendant of an already-finished process must die"
7467 );
7468 assert_eq!(engine.effective_process_state(0), ProcessState::Finished);
7469 }
7470
7471 /// LRM 9.7.1: a process may kill itself; it ends there (`process_request`
7472 /// reports it so the compiled caller can stop) and reads KILLED.
7473 #[test]
7474 fn process_self_kill_ends_the_caller_and_reports_it() {
7475 let mut engine = new_engine();
7476 let log = new_log();
7477 let l0 = log.clone();
7478 register_expecting(
7479 &mut engine,
7480 script(move |_, ops| {
7481 ops.process_track(0);
7482 let ends_me = ops.process_request(0, ProcessRequest::Kill(0));
7483 l0.lock().unwrap().push(format!("ends_requester={ends_me}"));
7484 Suspend::Done
7485 }),
7486 0,
7487 );
7488 assert!(matches!(engine.run_until(None), RunResult::Completed));
7489 assert_eq!(lines(&log), ["ends_requester=true"]);
7490 assert!(!engine.test_process_exists(0));
7491 assert_eq!(engine.effective_process_state(0), ProcessState::Killed);
7492 }
7493
7494 /// Killing the children a `wait fork` is blocked on releases the parent
7495 /// exactly once, only after the last one is gone.
7496 #[test]
7497 fn process_kill_of_fork_children_releases_the_wait_fork_parent() {
7498 let mut engine = new_engine();
7499 let log = new_log();
7500 let l0 = log.clone();
7501 register_expecting(
7502 &mut engine,
7503 script(move |arg, ops| match arg {
7504 ResumeArg::Start => Suspend::WaitFork,
7505 ResumeArg::AllForkChildrenDone => {
7506 l0.lock()
7507 .unwrap()
7508 .push(format!("joined@{}", ops.now().ticks));
7509 Suspend::Done
7510 }
7511 other => panic!("parent: unexpected {other:?}"),
7512 }),
7513 0,
7514 );
7515 let mut step = 0;
7516 register_expecting(
7517 &mut engine,
7518 script(move |_, ops| {
7519 step += 1;
7520 match step {
7521 1 => Suspend::Delay(10),
7522 2 => {
7523 ops.process_request(1, ProcessRequest::Kill(2));
7524 ops.process_request(1, ProcessRequest::Kill(3));
7525 Suspend::ByRequest
7526 }
7527 _ => Suspend::Done,
7528 }
7529 }),
7530 1,
7531 );
7532 let mut mint =
7533 |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7534 let kids = engine.driver.handle_fork(
7535 &mut engine.table,
7536 0,
7537 JoinKind::None,
7538 &[spawn_from(0), spawn_from(0)],
7539 &mut mint,
7540 );
7541 assert_eq!(kids, vec![2, 3]);
7542 assert!(matches!(engine.run_until(None), RunResult::Completed));
7543 assert_eq!(lines(&log), ["joined@10"]);
7544 }
7545
7546 /// LRM 9.7: `status()` is RUNNING for the caller, WAITING while the
7547 /// target is blocked, FINISHED once it ended.
7548 #[test]
7549 fn process_status_tracks_running_waiting_finished() {
7550 let mut engine = new_engine();
7551 let log = new_log();
7552 let l0 = log.clone();
7553 let mut step = 0;
7554 register_expecting(
7555 &mut engine,
7556 script(move |_, ops| {
7557 step += 1;
7558 match step {
7559 1 => {
7560 ops.process_track(0);
7561 l0.lock()
7562 .unwrap()
7563 .push(format!("self={}", state_of(ops, 0)));
7564 Suspend::Delay(10)
7565 }
7566 _ => Suspend::Done,
7567 }
7568 }),
7569 0,
7570 );
7571 let l1 = log.clone();
7572 let mut step = 0;
7573 register_expecting(
7574 &mut engine,
7575 script(move |_, ops| {
7576 step += 1;
7577 match step {
7578 1 => Suspend::Delay(5),
7579 2 => {
7580 l1.lock().unwrap().push(format!("@5={}", state_of(ops, 0)));
7581 Suspend::Delay(20)
7582 }
7583 _ => {
7584 l1.lock().unwrap().push(format!("@25={}", state_of(ops, 0)));
7585 Suspend::Done
7586 }
7587 }
7588 }),
7589 1,
7590 );
7591 assert!(matches!(engine.run_until(None), RunResult::Completed));
7592 assert_eq!(lines(&log), ["self=1", "@5=2", "@25=0"]);
7593 }
7594
7595 /// LRM 9.7: `await()` blocks until the target ends; a target that
7596 /// already ended, or the caller itself, blocks nothing.
7597 #[test]
7598 fn process_await_blocks_until_the_target_ends() {
7599 let mut engine = new_engine();
7600 let log = new_log();
7601 let mut step = 0;
7602 register_expecting(
7603 &mut engine,
7604 script(move |_, ops| {
7605 step += 1;
7606 if step == 1 {
7607 ops.process_track(0);
7608 Suspend::Delay(30)
7609 } else {
7610 Suspend::Done
7611 }
7612 }),
7613 0,
7614 );
7615 let (l1, mut step) = (log.clone(), 0);
7616 register_expecting(
7617 &mut engine,
7618 script(move |_, ops| {
7619 step += 1;
7620 match step {
7621 1 => Suspend::Delay(5),
7622 2 => {
7623 ops.process_request(1, ProcessRequest::Await(0));
7624 l1.lock().unwrap().push("awaiting@5".into());
7625 Suspend::ByRequest
7626 }
7627 _ => {
7628 l1.lock()
7629 .unwrap()
7630 .push(format!("awaited@{}", ops.now().ticks));
7631 Suspend::Done
7632 }
7633 }
7634 }),
7635 1,
7636 );
7637 let (l2, mut step) = (log.clone(), 0);
7638 register_expecting(
7639 &mut engine,
7640 script(move |_, ops| {
7641 step += 1;
7642 match step {
7643 1 => Suspend::Delay(50),
7644 2 => {
7645 ops.process_request(2, ProcessRequest::Await(0));
7646 Suspend::ByRequest
7647 }
7648 _ => {
7649 l2.lock().unwrap().push(format!("late@{}", ops.now().ticks));
7650 Suspend::Done
7651 }
7652 }
7653 }),
7654 2,
7655 );
7656 let (l3, mut step) = (log.clone(), 0);
7657 register_expecting(
7658 &mut engine,
7659 script(move |_, ops| {
7660 step += 1;
7661 if step == 1 {
7662 ops.process_request(3, ProcessRequest::Await(3));
7663 Suspend::ByRequest
7664 } else {
7665 l3.lock().unwrap().push(format!("self@{}", ops.now().ticks));
7666 Suspend::Done
7667 }
7668 }),
7669 3,
7670 );
7671 assert!(matches!(engine.run_until(None), RunResult::Completed));
7672 assert_eq!(
7673 lines(&log),
7674 ["self@0", "awaiting@5", "awaited@30", "late@50"],
7675 "await wakes exactly at the target's end; an ended target / self \
7676 never blocks"
7677 );
7678 }
7679
7680 /// LRM 9.7.4 / 9.7.5: a suspended process stops advancing; `resume()`
7681 /// continues it; `resume()` on a process that is not suspended is a
7682 /// no-op.
7683 #[test]
7684 fn process_suspend_resume_periodic_process() {
7685 let mut engine = new_engine();
7686 let log = new_log();
7687 let (l0, mut ticks) = (log.clone(), 0);
7688 register_expecting(
7689 &mut engine,
7690 script(move |arg, ops| match arg {
7691 ResumeArg::Start => {
7692 ops.process_track(0);
7693 Suspend::Delay(10)
7694 }
7695 _ => {
7696 ticks += 1;
7697 l0.lock().unwrap().push(format!("tick@{}", ops.now().ticks));
7698 if ticks == 4 {
7699 Suspend::Done
7700 } else {
7701 Suspend::Delay(10)
7702 }
7703 }
7704 }),
7705 0,
7706 );
7707 let (l1, mut step) = (log.clone(), 0);
7708 register_expecting(
7709 &mut engine,
7710 script(move |_, ops| {
7711 step += 1;
7712 match step {
7713 1 => Suspend::Delay(5),
7714 2 => {
7715 // Not suspended: no effect.
7716 ops.process_request(1, ProcessRequest::Resume(0));
7717 Suspend::ByRequest
7718 }
7719 3 => Suspend::Delay(10),
7720 4 => {
7721 ops.process_request(1, ProcessRequest::Suspend(0));
7722 l1.lock()
7723 .unwrap()
7724 .push(format!("@15 state={}", state_of(ops, 0)));
7725 Suspend::ByRequest
7726 }
7727 5 => Suspend::Delay(30),
7728 6 => {
7729 l1.lock()
7730 .unwrap()
7731 .push(format!("@45 state={}", state_of(ops, 0)));
7732 ops.process_request(1, ProcessRequest::Resume(0));
7733 l1.lock()
7734 .unwrap()
7735 .push(format!("resumed state={}", state_of(ops, 0)));
7736 Suspend::ByRequest
7737 }
7738 _ => Suspend::Done,
7739 }
7740 }),
7741 1,
7742 );
7743 assert!(matches!(engine.run_until(None), RunResult::Completed));
7744 assert_eq!(
7745 lines(&log),
7746 [
7747 "tick@10",
7748 "@15 state=3",
7749 "@45 state=3",
7750 "resumed state=2",
7751 "tick@45",
7752 "tick@55",
7753 "tick@65",
7754 ],
7755 "the tick due at 20 is held while suspended and replayed at the \
7756 resume (45); then the 10-unit period continues"
7757 );
7758 }
7759
7760 /// A process may suspend itself (`ByRequest` parks it) and another
7761 /// process resumes it.
7762 #[test]
7763 fn process_self_suspend_is_released_by_another_process() {
7764 let mut engine = new_engine();
7765 let log = new_log();
7766 let (l0, mut step) = (log.clone(), 0);
7767 register_expecting(
7768 &mut engine,
7769 script(move |_, ops| {
7770 step += 1;
7771 if step == 1 {
7772 ops.process_track(0);
7773 ops.process_request(0, ProcessRequest::Suspend(0));
7774 l0.lock().unwrap().push("p0 suspending".into());
7775 Suspend::ByRequest
7776 } else {
7777 l0.lock()
7778 .unwrap()
7779 .push(format!("p0 resumed@{}", ops.now().ticks));
7780 Suspend::Done
7781 }
7782 }),
7783 0,
7784 );
7785 let (l1, mut step) = (log.clone(), 0);
7786 register_expecting(
7787 &mut engine,
7788 script(move |_, ops| {
7789 step += 1;
7790 match step {
7791 1 => Suspend::Delay(20),
7792 2 => {
7793 l1.lock()
7794 .unwrap()
7795 .push(format!("p0 state={}", state_of(ops, 0)));
7796 ops.process_request(1, ProcessRequest::Resume(0));
7797 Suspend::ByRequest
7798 }
7799 _ => Suspend::Done,
7800 }
7801 }),
7802 1,
7803 );
7804 assert!(matches!(engine.run_until(None), RunResult::Completed));
7805 assert_eq!(
7806 lines(&log),
7807 ["p0 suspending", "p0 state=3", "p0 resumed@20"]
7808 );
7809 }
7810
7811 fn new_semaphore(keys: i64, template: u32) -> u64 {
7812 let h = crate::objects::alloc(
7813 crate::netlist::ClassTemplateId(template),
7814 semaphore::SEMAPHORE_FIELD_WORDS,
7815 );
7816 assert!(semaphore::init(h, keys));
7817 h
7818 }
7819
7820 /// Classes plan Task 17 (LRM 15.3.3/15.3.4): a `get` that must wait yields
7821 /// (`SyncBlock` + `ByRequest`) and reads WAITING until the `put` that
7822 /// grants it queues `SyncWake`, which resumes it at the putter's time.
7823 #[test]
7824 fn semaphore_get_blocks_until_a_put_grants_it() {
7825 let h = new_semaphore(0, 17_100);
7826 let mut engine = new_engine();
7827 let log = new_log();
7828 let (l0, mut step) = (log.clone(), 0);
7829 register_expecting(
7830 &mut engine,
7831 script(move |_, ops| {
7832 step += 1;
7833 if step == 1 {
7834 let blocked = semaphore::get(h, 0, 1, &mut |r| {
7835 ops.process_request(0, r);
7836 });
7837 assert!(blocked, "an empty semaphore blocks");
7838 Suspend::ByRequest
7839 } else {
7840 l0.lock().unwrap().push(format!("got@{}", ops.now().ticks));
7841 Suspend::Done
7842 }
7843 }),
7844 0,
7845 );
7846 let (l1, mut step) = (log.clone(), 0);
7847 register_expecting(
7848 &mut engine,
7849 script(move |_, ops| {
7850 step += 1;
7851 match step {
7852 1 => Suspend::Delay(7),
7853 _ => {
7854 l1.lock()
7855 .unwrap()
7856 .push(format!("before put: state={}", state_of(ops, 0)));
7857 assert!(semaphore::put(h, 1, &mut |r| {
7858 ops.process_request(1, r);
7859 }));
7860 Suspend::Done
7861 }
7862 }
7863 }),
7864 1,
7865 );
7866 assert!(matches!(engine.run_until(None), RunResult::Completed));
7867 assert_eq!(lines(&log), ["before put: state=2", "got@7"]);
7868 assert_eq!(semaphore::keys(h), Some(0));
7869 }
7870
7871 /// A process killed while blocked leaves the queue; a waiter behind it
7872 /// that the freed head was holding back is released at the kill.
7873 #[test]
7874 fn killing_a_blocked_get_releases_the_waiter_behind_it() {
7875 let h = new_semaphore(1, 17_101);
7876 let mut engine = new_engine();
7877 let log = new_log();
7878 let mut step = 0;
7879 register_expecting(
7880 &mut engine,
7881 script(move |_, ops| {
7882 step += 1;
7883 if step == 1 {
7884 assert!(semaphore::get(h, 0, 2, &mut |r| {
7885 ops.process_request(0, r);
7886 }));
7887 Suspend::ByRequest
7888 } else {
7889 panic!("the killed waiter must never resume");
7890 }
7891 }),
7892 0,
7893 );
7894 let (l1, mut step) = (log.clone(), 0);
7895 register_expecting(
7896 &mut engine,
7897 script(move |_, ops| {
7898 step += 1;
7899 if step == 1 {
7900 assert!(
7901 semaphore::get(h, 1, 1, &mut |r| {
7902 ops.process_request(1, r);
7903 }),
7904 "FIFO: queued behind the get(2) although a key is free"
7905 );
7906 Suspend::ByRequest
7907 } else {
7908 l1.lock()
7909 .unwrap()
7910 .push(format!("p1 got@{}", ops.now().ticks));
7911 Suspend::Done
7912 }
7913 }),
7914 1,
7915 );
7916 let (l2, mut step) = (log.clone(), 0);
7917 register_expecting(
7918 &mut engine,
7919 script(move |_, ops| {
7920 step += 1;
7921 match step {
7922 1 => Suspend::Delay(5),
7923 2 => {
7924 l2.lock()
7925 .unwrap()
7926 .push(format!("waiters before kill: {}", semaphore::waiters(h)));
7927 ops.process_request(2, ProcessRequest::Kill(0));
7928 Suspend::ByRequest
7929 }
7930 _ => {
7931 l2.lock()
7932 .unwrap()
7933 .push(format!("waiters after kill: {}", semaphore::waiters(h)));
7934 Suspend::Done
7935 }
7936 }
7937 }),
7938 2,
7939 );
7940 assert!(matches!(engine.run_until(None), RunResult::Completed));
7941 assert_eq!(
7942 lines(&log),
7943 [
7944 "waiters before kill: 2",
7945 // The killer continues at once; the released waiter is
7946 // activated, so it runs after the killer's segment.
7947 "waiters after kill: 0",
7948 "p1 got@5",
7949 ]
7950 );
7951 assert_eq!(semaphore::keys(h), Some(0));
7952 }
7953
7954 /// A process suspended with `p.suspend()` while blocked in `get` is
7955 /// granted its keys but stays parked until `p.resume()`.
7956 #[test]
7957 fn a_granted_get_of_a_suspended_process_waits_for_resume() {
7958 let h = new_semaphore(0, 17_102);
7959 let mut engine = new_engine();
7960 let log = new_log();
7961 let (l0, mut step) = (log.clone(), 0);
7962 register_expecting(
7963 &mut engine,
7964 script(move |_, ops| {
7965 step += 1;
7966 if step == 1 {
7967 ops.process_track(0);
7968 assert!(semaphore::get(h, 0, 1, &mut |r| {
7969 ops.process_request(0, r);
7970 }));
7971 Suspend::ByRequest
7972 } else {
7973 l0.lock().unwrap().push(format!("got@{}", ops.now().ticks));
7974 Suspend::Done
7975 }
7976 }),
7977 0,
7978 );
7979 let mut step = 0;
7980 register_expecting(
7981 &mut engine,
7982 script(move |_, ops| {
7983 step += 1;
7984 match step {
7985 1 => Suspend::Delay(1),
7986 2 => {
7987 ops.process_request(1, ProcessRequest::Suspend(0));
7988 Suspend::ByRequest
7989 }
7990 3 => Suspend::Delay(1),
7991 4 => {
7992 semaphore::put(h, 1, &mut |r| {
7993 ops.process_request(1, r);
7994 });
7995 Suspend::Delay(8)
7996 }
7997 5 => {
7998 ops.process_request(1, ProcessRequest::Resume(0));
7999 Suspend::ByRequest
8000 }
8001 _ => Suspend::Done,
8002 }
8003 }),
8004 1,
8005 );
8006 assert!(matches!(engine.run_until(None), RunResult::Completed));
8007 assert_eq!(lines(&log), ["got@10"]);
8008 }
8009}