vj_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 `VJ_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 vj_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` (`vj-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 /// (`vj::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 `vj-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/// (`vj-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: vj_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, ..)` (`vj-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 (`vj-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/// `vj-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 vj_abi::entry::SIM_INIT_SYMBOL,
3702 "Engine::new (+ SchedulerControl::register_process for each elaborated process)",
3703 ),
3704 (
3705 vj_abi::entry::SIM_SETTLE_SYMBOL,
3706 "Engine::run_until(Some(now)) -- settle all zero-delay activity at the current time",
3707 ),
3708 (vj_abi::entry::SIM_EVAL_REGION_SYMBOL, "Engine::step_region"),
3709 (
3710 vj_abi::entry::SIM_COMMIT_NBA_SYMBOL,
3711 "Engine::<internal NBA commit pass -- commit_nba, Task 7/13>",
3712 ),
3713 (
3714 vj_abi::entry::SIM_SAVE_SYMBOL,
3715 "Engine::<checkpoint -- DeterminismController-owned, not materialized this wave>",
3716 ),
3717 (
3718 vj_abi::entry::SIM_RESTORE_SYMBOL,
3719 "Engine::<restore -- DeterminismController-owned, not materialized this wave>",
3720 ),
3721 (
3722 vj_abi::entry::SIM_STEP_TIME_SYMBOL,
3723 "Engine::run_until(Some(deadline)) -- advance exactly one timestep",
3724 ),
3725 (vj_abi::entry::SIM_FINAL_SYMBOL, "Engine::run_finals"),
3726 (
3727 vj_abi::entry::SIM_PROCESS_COUNT_SYMBOL,
3728 "Engine::<ProcessTable length accessor>",
3729 ),
3730 ]
3731}
3732
3733#[cfg(test)]
3734mod tests {
3735 use super::*;
3736 use crate::sched::{ActivationId, EventTerm, EventWaitSet, TimerEntry};
3737 use std::collections::HashMap;
3738 use std::sync::{Arc, Mutex as StdMutex};
3739
3740 // --- shared test doubles (independently declared -- see this module's
3741 // doc comment: neither `notify.rs`'s/`ops.rs`'s `FakeArena` nor Task 8's
3742 // `MockSchedulerOps` is reachable from this module) ---
3743
3744 struct TestArena(HashMap<StateCellId, FourStateValue>);
3745
3746 impl TestArena {
3747 fn new() -> Self {
3748 TestArena(HashMap::new())
3749 }
3750
3751 fn seeded(cell: StateCellId, value: FourStateValue) -> Self {
3752 let mut m = HashMap::new();
3753 m.insert(cell, value);
3754 TestArena(m)
3755 }
3756 }
3757
3758 impl SignalAccess for TestArena {
3759 fn read(&self, cell: StateCellId) -> FourStateValue {
3760 self.0
3761 .get(&cell)
3762 .cloned()
3763 .unwrap_or(FourStateValue::Narrow(0))
3764 }
3765
3766 fn write(&mut self, cell: StateCellId, value: FourStateValue) -> bool {
3767 let changed = match self.0.get(&cell) {
3768 Some(FourStateValue::Narrow(existing)) => {
3769 if let FourStateValue::Narrow(new) = &value {
3770 *existing != *new
3771 } else {
3772 true
3773 }
3774 }
3775 Some(FourStateValue::Wide { .. }) => true,
3776 None => true,
3777 };
3778 self.0.insert(cell, value);
3779 changed
3780 }
3781
3782 fn subscribe(
3783 &mut self,
3784 _cell: StateCellId,
3785 _role: crate::state::ListenerRole,
3786 _sink: Box<dyn ValueChangeSink + Send>,
3787 ) {
3788 // Not exercised by this task's tests.
3789 }
3790 }
3791
3792 #[derive(Default)]
3793 struct TestIoSink {
3794 calls: StdMutex<Vec<(SchedTime, Region, u64)>>,
3795 }
3796
3797 impl IoSink for TestIoSink {
3798 fn enqueue(&self, at: SchedTime, region: Region, seq: u64, cb: DeferredIoCallback) {
3799 drop(cb);
3800 self.calls.lock().unwrap().push((at, region, seq));
3801 }
3802 }
3803
3804 fn narrow(v: u128) -> FourStateValue {
3805 FourStateValue::Narrow(v)
3806 }
3807
3808 fn new_engine() -> Engine {
3809 Engine::new(Box::new(TestArena::new()), Box::new(TestIoSink::default()))
3810 }
3811
3812 // --- register_process / run_until / run_finals process fixtures ---
3813
3814 /// Logs `(ProcessId, ResumeArg)` for every `resume()` call, always
3815 /// returning `Suspend::Done` (never re-arms). `id_for_log` is filled in
3816 /// (via the shared cell) only *after* `register_process` returns the
3817 /// real id -- `resume()` is never called before registration completes,
3818 /// so it is always `Some` by the time any log entry is pushed.
3819 struct LoggingProcess {
3820 id_for_log: Arc<StdMutex<Option<ProcessId>>>,
3821 log: Arc<StdMutex<Vec<(ProcessId, &'static str)>>>,
3822 }
3823
3824 impl CompiledProcess for LoggingProcess {
3825 fn resume(
3826 &mut self,
3827 _state: SimHandle,
3828 arg: ResumeArg,
3829 _ops: &dyn SchedulerOps,
3830 ) -> Suspend {
3831 let tag = match arg {
3832 ResumeArg::Start => "Start",
3833 ResumeArg::TimeElapsed => "TimeElapsed",
3834 ResumeArg::EventFired { .. } => "EventFired",
3835 ResumeArg::ForkChildDone { .. } => "ForkChildDone",
3836 ResumeArg::Disabled => "Disabled",
3837 ResumeArg::AllForkChildrenDone => "AllForkChildrenDone",
3838 ResumeArg::WaitOrderSatisfied => "WaitOrderSatisfied",
3839 };
3840 let id = self
3841 .id_for_log
3842 .lock()
3843 .unwrap()
3844 .expect("id_for_log must be set before resume() is ever called");
3845 self.log.lock().unwrap().push((id, tag));
3846 Suspend::Done
3847 }
3848 }
3849
3850 fn logging_process(
3851 log: &Arc<StdMutex<Vec<(ProcessId, &'static str)>>>,
3852 ) -> (Box<LoggingProcess>, Arc<StdMutex<Option<ProcessId>>>) {
3853 let id_cell = Arc::new(StdMutex::new(None));
3854 (
3855 Box::new(LoggingProcess {
3856 id_for_log: id_cell.clone(),
3857 log: log.clone(),
3858 }),
3859 id_cell,
3860 )
3861 }
3862
3863 // --- SC-083 ---
3864
3865 #[test]
3866 fn sequential_controller_is_selected_when_none_is_ever_set() {
3867 let mut engine = new_engine();
3868 let log = Arc::new(StdMutex::new(Vec::new()));
3869 let (proc, id_cell) = logging_process(&log);
3870 let id = engine.register_process(proc, ProcessKind::Initial, None, 0);
3871 *id_cell.lock().unwrap() = Some(id);
3872
3873 let result = engine.run_until(Some(SchedTime { ticks: 100 }));
3874 assert!(matches!(result, RunResult::Completed));
3875 assert_eq!(engine.controller_kind(), "sequential");
3876 }
3877
3878 /// Mutation-test kill: `run_until`'s deadline check must fire on
3879 /// `self.now >= deadline`, not `self.now > deadline` -- the two agree
3880 /// for every test elsewhere in this file that names a deadline strictly
3881 /// later than where the run would otherwise settle, so only an exact
3882 /// `now == deadline` boundary distinguishes them. A process that settles
3883 /// immediately with no delay leaves `now` at its initial `SchedTime{
3884 /// ticks: 0}`; a deadline of exactly `0` must therefore report
3885 /// `RunResult::Deadline` on the very first pass, not silently continue to
3886 /// `RunResult::Completed`.
3887 #[test]
3888 fn run_until_reports_deadline_when_now_exactly_equals_it() {
3889 let mut engine = new_engine();
3890 engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
3891
3892 let result = engine.run_until(Some(SchedTime { ticks: 0 }));
3893 assert!(
3894 matches!(result, RunResult::Deadline),
3895 "now has already reached the deadline exactly (both 0) -- must report \
3896 RunResult::Deadline, not RunResult::Completed; got {result:?}"
3897 );
3898 }
3899
3900 #[test]
3901 fn set_determinism_controller_overrides_the_default() {
3902 struct FakeController;
3903 impl DeterminismController for FakeController {
3904 fn partition(
3905 &self,
3906 _ready: &[PendingActivation],
3907 _region: Region,
3908 _now: SchedTime,
3909 ) -> Vec<crate::determinism::WorkItem> {
3910 vec![]
3911 }
3912 fn on_read(&self, _act: ActivationId, _cell: StateCellId, _version: u64) {}
3913 fn on_write(
3914 &self,
3915 _act: ActivationId,
3916 _cell: StateCellId,
3917 _prior: FourStateValue,
3918 _version: u64,
3919 ) {
3920 }
3921 fn gvt(&self) -> SchedTime {
3922 SchedTime { ticks: 0 }
3923 }
3924 fn drain_and_resolve(
3925 &self,
3926 _work: Vec<crate::determinism::WorkItem>,
3927 ) -> crate::determinism::ResolvedBatch {
3928 crate::determinism::ResolvedBatch::default()
3929 }
3930 fn kind(&self) -> &'static str {
3931 "fake-tagged"
3932 }
3933 }
3934
3935 let mut engine = new_engine();
3936 assert_eq!(engine.controller_kind(), "sequential");
3937 engine.set_determinism_controller(Box::new(FakeController));
3938 assert_eq!(engine.controller_kind(), "fake-tagged");
3939 }
3940
3941 /// SC-083 — a debugger-mock harness single-steps through regions via
3942 /// `step_region()`, observing only committed state between steps: `p1`
3943 /// (Initial) blocking-writes `cell_a`; `p2` (AlwaysComb, statically
3944 /// sensitive to `cell_a`) is woken by that write and blocking-writes
3945 /// `cell_b`. Every read via the engine's arena accessor, at every point
3946 /// during the multi-call sequence, shows either the pre-write default or
3947 /// the fully-committed written value -- never anything else, since
3948 /// `commit_blocking_write` only ever applies synchronously.
3949 #[test]
3950 fn step_region_single_steps_exposing_only_committed_state() {
3951 struct P1 {
3952 cell_a: StateCellId,
3953 }
3954 impl CompiledProcess for P1 {
3955 fn resume(
3956 &mut self,
3957 _state: SimHandle,
3958 arg: ResumeArg,
3959 ops: &dyn SchedulerOps,
3960 ) -> Suspend {
3961 if matches!(arg, ResumeArg::Start) {
3962 ops.commit_blocking_write(self.cell_a, narrow(1));
3963 }
3964 Suspend::Done
3965 }
3966 }
3967 struct P2 {
3968 cell_b: StateCellId,
3969 }
3970 impl CompiledProcess for P2 {
3971 fn resume(
3972 &mut self,
3973 _state: SimHandle,
3974 arg: ResumeArg,
3975 ops: &dyn SchedulerOps,
3976 ) -> Suspend {
3977 if matches!(arg, ResumeArg::EventFired { .. }) {
3978 ops.commit_blocking_write(self.cell_b, narrow(1));
3979 }
3980 Suspend::Done
3981 }
3982 }
3983
3984 let cell_a = StateCellId(1);
3985 let cell_b = StateCellId(2);
3986 let mut engine = new_engine();
3987 engine.register_process(Box::new(P1 { cell_a }), ProcessKind::Initial, None, 0);
3988 engine.register_process(
3989 Box::new(P2 { cell_b }),
3990 ProcessKind::AlwaysComb,
3991 Some(StaticSensitivity {
3992 edges: vec![(cell_a, None)],
3993 }),
3994 0,
3995 );
3996
3997 // Before anything runs: both cells read the pre-write default.
3998 assert!(matches!(
3999 engine.test_read_cell(cell_a),
4000 FourStateValue::Narrow(0)
4001 ));
4002 assert!(matches!(
4003 engine.test_read_cell(cell_b),
4004 FourStateValue::Narrow(0)
4005 ));
4006
4007 let mut saw_a_committed = false;
4008 let mut saw_b_committed = false;
4009 for _ in 0..16 {
4010 engine.step_region();
4011 let a = engine.test_read_cell(cell_a);
4012 let b = engine.test_read_cell(cell_b);
4013 // At every single point, each cell is exactly its pre-write
4014 // default or its one fully-committed value -- never a third,
4015 // "partially applied" value.
4016 assert!(matches!(
4017 a,
4018 FourStateValue::Narrow(0) | FourStateValue::Narrow(1)
4019 ));
4020 assert!(matches!(
4021 b,
4022 FourStateValue::Narrow(0) | FourStateValue::Narrow(1)
4023 ));
4024 if matches!(a, FourStateValue::Narrow(1)) {
4025 saw_a_committed = true;
4026 }
4027 if matches!(b, FourStateValue::Narrow(1)) {
4028 // cell_b only ever becomes 1 after cell_a already did --
4029 // p2's write is causally downstream of p1's.
4030 assert!(saw_a_committed, "cell_b committed before cell_a");
4031 saw_b_committed = true;
4032 }
4033 }
4034 assert!(
4035 saw_a_committed,
4036 "p1's write was never observed as committed"
4037 );
4038 assert!(
4039 saw_b_committed,
4040 "p2's woken write was never observed as committed"
4041 );
4042 }
4043
4044 /// sva SV-D (LRM 17.7.3, 4.5): a Reactive process (a checker procedure) runs only after
4045 /// the Active/Inactive/NBA queues have emptied, however many Active processes the
4046 /// time step's earlier regions chain through, and the work it leaves behind (here an Active
4047 /// process woken by its write) is run in the same time step -- the Reactive-to-Active
4048 /// feedback of the delta loop -- not deferred to the next `run_until` call.
4049 #[test]
4050 fn reactive_process_runs_after_active_settles_and_feeds_back_to_active() {
4051 struct Tracer {
4052 name: &'static str,
4053 log: Arc<StdMutex<Vec<String>>>,
4054 write: Option<(StateCellId, u128)>,
4055 }
4056 impl CompiledProcess for Tracer {
4057 fn resume(
4058 &mut self,
4059 _state: SimHandle,
4060 arg: ResumeArg,
4061 ops: &dyn SchedulerOps,
4062 ) -> Suspend {
4063 let tag = match arg {
4064 ResumeArg::Start => "start",
4065 ResumeArg::EventFired { .. } => "event",
4066 _ => "other",
4067 };
4068 self.log
4069 .lock()
4070 .unwrap()
4071 .push(format!("{}:{tag}", self.name));
4072 if let Some((cell, v)) = self.write {
4073 ops.commit_blocking_write(cell, narrow(v));
4074 }
4075 Suspend::Done
4076 }
4077 }
4078 let (a, b, c) = (StateCellId(1), StateCellId(2), StateCellId(3));
4079 let log = Arc::new(StdMutex::new(Vec::new()));
4080 let tracer = |name, write| {
4081 Box::new(Tracer {
4082 name,
4083 log: log.clone(),
4084 write,
4085 })
4086 };
4087 let sens = |cell| {
4088 Some(StaticSensitivity {
4089 edges: vec![(cell, None)],
4090 })
4091 };
4092 let mut engine = new_engine();
4093 // Registered Reactive-process-first: the id order must not matter.
4094 // `r` (Reactive) is woken by `b`, which the Active `b_w` writes after `a`
4095 // changed; it writes `c`, which wakes the Active `c_w`.
4096 engine.register_reactive_process(
4097 tracer("r", Some((c, 1))),
4098 ProcessKind::AlwaysComb,
4099 sens(b),
4100 0,
4101 );
4102 engine.register_process(tracer("w", Some((a, 1))), ProcessKind::Initial, None, 0);
4103 engine.register_process(
4104 tracer("b_w", Some((b, 1))),
4105 ProcessKind::AlwaysComb,
4106 sens(a),
4107 0,
4108 );
4109 engine.register_process(tracer("c_w", None), ProcessKind::AlwaysComb, sens(c), 0);
4110
4111 let result = engine.run_until(None);
4112 assert!(matches!(result, RunResult::Completed));
4113 let log = log.lock().unwrap().clone();
4114 let pos = |s: &str| {
4115 log.iter()
4116 .position(|e| e == s)
4117 .unwrap_or_else(|| panic!("{s} never ran: {log:?}"))
4118 };
4119 // The Reactive process's start activation is not run in Active: both Active
4120 // chains (w -> b_w, and c_w's own start) complete before any `r` entry.
4121 let first_r = log.iter().position(|e| e.starts_with("r:")).unwrap();
4122 assert!(
4123 pos("w:start") < first_r && pos("b_w:event") < first_r && pos("c_w:start") < first_r,
4124 "a Reactive process ran before the Active region settled: {log:?}"
4125 );
4126 // Feedback: the Active process woken by `r`'s write ran in the same time step.
4127 assert!(
4128 pos("c_w:event") > pos("r:event"),
4129 "the Active wake-up of a Reactive write was not run: {log:?}"
4130 );
4131 }
4132
4133 /// Mutation-test kill: `step_region`'s `Active`/`Inactive` arm advances
4134 /// `current_region` to `Inactive` from `Active` and to `Nba` from
4135 /// `Inactive` -- a mutant that swapped those two targets passes every
4136 /// other test in this file, since none of them observe `current_region`
4137 /// directly (they only observe committed cell state or dispatch logs,
4138 /// which happen to still converge to the same eventual outcome for a
4139 /// design with no `Inactive`-region-specific behavior). This test reads
4140 /// `Engine::current_region` directly -- a private field, reachable here
4141 /// since this test module is a descendant of `control`.
4142 #[test]
4143 fn step_region_advances_active_to_inactive_to_nba_in_order() {
4144 let mut engine = new_engine();
4145 assert_eq!(
4146 engine.current_region,
4147 Region::Active,
4148 "a fresh Engine starts in Active"
4149 );
4150
4151 engine.step_region();
4152 assert_eq!(
4153 engine.current_region,
4154 Region::Inactive,
4155 "Active must step to Inactive next, never Nba"
4156 );
4157
4158 engine.step_region();
4159 assert_eq!(
4160 engine.current_region,
4161 Region::Nba,
4162 "Inactive must step to Nba next, never back to Inactive"
4163 );
4164 }
4165
4166 /// sva T1 (§1.4, review fix, blocking finding 2): drives the
4167 /// single-stepper through many full 5-region cycles (well past the
4168 /// `register_wait`-style edge cases) and asserts `current_region` is
4169 /// never observed to be `Region::Preponed` at any point -- the
4170 /// behavioral half of `step_region`'s own defensive `unreachable!`
4171 /// arm: if that arm were ever actually reached, this test would panic
4172 /// (via the `unreachable!` itself) rather than silently pass.
4173 #[test]
4174 fn step_region_never_observes_current_region_eq_preponed() {
4175 let mut engine = new_engine();
4176 engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
4177
4178 for _ in 0..30 {
4179 engine.step_region();
4180 assert_ne!(
4181 engine.current_region(),
4182 Region::Preponed,
4183 "the single-stepper must never transition into Region::Preponed -- the \
4184 Preponed snapshot push is an inline side effect, never an observable region"
4185 );
4186 }
4187 }
4188
4189 /// sva T2 (§1.4, review fix, blocking finding 2): the identical
4190 /// behavioral proof as `step_region_never_observes_current_region_eq_
4191 /// preponed`, now for `Region::Observed` -- if `step_region`'s
4192 /// defensive `unreachable!` arm (now covering both variants) were ever
4193 /// actually reached for `Observed`, this test would panic rather than
4194 /// silently pass.
4195 #[test]
4196 fn step_region_never_observes_current_region_eq_observed() {
4197 let mut engine = new_engine();
4198 engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
4199
4200 for _ in 0..30 {
4201 engine.step_region();
4202 assert_ne!(
4203 engine.current_region(),
4204 Region::Observed,
4205 "the single-stepper must never transition into Region::Observed -- the \
4206 Observed engine-step drain is folded into advance_nba_region, never an \
4207 observable region"
4208 );
4209 }
4210 }
4211
4212 /// sva T1 r1 (review fix, blocking finding 1): `step_region`'s
4213 /// `Region::Postponed` arm must push the configured Preponed ring
4214 /// exactly once per time step that genuinely BEGINS -- never again
4215 /// merely because a caller keeps single-stepping an already-quiescent
4216 /// simulation. A one-shot process (`Suspend::Delay(1)` on its first
4217 /// `Start` resume, then `Suspend::Done` on its second) arms the wheel
4218 /// for exactly one real tick transition and then goes permanently
4219 /// quiet, so `next_event_tick()` returns `Some` exactly once across the
4220 /// whole run and `None` (`StepResult::Blocked`) on every Postponed arm
4221 /// after that -- precisely the "ordinary debugger-driver" shape the
4222 /// finding's own live repro used (30 `step_region()` calls on a
4223 /// completed/quiescent design hit `StepResult::Blocked` repeatedly).
4224 /// This proves both halves of the fix: the push DOES fire on the one
4225 /// real `Some(t)` transition, and it does NOT fire again on any of the
4226 /// many subsequent `None`/`Blocked` cycles -- a regression to the
4227 /// unconditional-push bug (pushing in both match arms) would fail the
4228 /// second assertion by inflating `push_count` past `1`.
4229 #[test]
4230 fn step_region_blocked_does_not_repush_the_preponed_ring() {
4231 use crate::state::{StateEngine, TemplateRegistry};
4232 use std::cell::Cell;
4233 use std::rc::Rc;
4234
4235 struct OnceDelayed {
4236 started: bool,
4237 }
4238 impl CompiledProcess for OnceDelayed {
4239 fn resume(
4240 &mut self,
4241 _state: SimHandle,
4242 arg: ResumeArg,
4243 _ops: &dyn SchedulerOps,
4244 ) -> Suspend {
4245 match (self.started, arg) {
4246 (false, ResumeArg::Start) => {
4247 self.started = true;
4248 Suspend::Delay(1)
4249 }
4250 _ => Suspend::Done,
4251 }
4252 }
4253 }
4254
4255 let mut engine = new_engine();
4256 let push_count = Rc::new(Cell::new(0u32));
4257 {
4258 let push_count = push_count.clone();
4259 // A fresh StateEngine purely to mint real `ArenaSnapshot`
4260 // values -- this test counts invocations of the source
4261 // closure, never the snapshotted content itself (already
4262 // covered by `sva_preponed_sampling_matches_lrm_16_5_1` above).
4263 let state_engine = StateEngine::new(TemplateRegistry::new());
4264 engine.configure_preponed_ring(0, move || {
4265 push_count.set(push_count.get() + 1);
4266 state_engine.snapshot(None)
4267 });
4268 }
4269 engine.register_process(
4270 Box::new(OnceDelayed { started: false }),
4271 ProcessKind::Initial,
4272 None,
4273 0,
4274 );
4275
4276 let mut blocked_seen = 0;
4277 for _ in 0..30 {
4278 if engine.step_region() == StepResult::Blocked {
4279 blocked_seen += 1;
4280 }
4281 }
4282 assert!(
4283 blocked_seen > 0,
4284 "this 30-call sequence must actually reach StepResult::Blocked at least once \
4285 (it does, repeatedly, once the one armed tick has fired and gone quiet), or \
4286 the regression assertion below would be vacuous"
4287 );
4288 assert_eq!(
4289 push_count.get(),
4290 1,
4291 "exactly one real time-step transition occurred (the single Delay(1)) -- the \
4292 ring must have been pushed exactly once, never once per StepResult::Blocked \
4293 call (that was the bug: a quiescent/completed single-stepper re-pushed the \
4294 same unchanged snapshot on every call, silently inflating a bounded ring and \
4295 evicting genuinely distinct older ticks a future $past consumer needs intact)"
4296 );
4297 }
4298
4299 /// sva T1 r2 (review fix, blocking finding 1): `run_until` and
4300 /// `step_region` are two separate driver entry points for the exact
4301 /// same `Engine`/`push_preponed_snapshot` mechanism (`Self::
4302 /// push_preponed_snapshot`'s own doc comment), so the identical
4303 /// simulated history must push the Preponed ring the same number of
4304 /// times regardless of which one drives it. This drives the SAME
4305 /// one-shot process (`Suspend::Delay(1)` then `Suspend::Done` -- the
4306 /// exact repro the finding's own live check used) through two freshly
4307 /// constructed engines, one via `run_until(None)` and one via 30
4308 /// `step_region()` calls (mirroring `step_region_blocked_does_not_
4309 /// repush_the_preponed_ring` immediately above), and asserts both
4310 /// counted exactly one push. Before the fix, `run_until`'s outer loop
4311 /// pushed unconditionally at the top -- including on its very first
4312 /// iteration, before `Region::Active` had ever been dispatched at time
4313 /// 0 -- so this process's `run_until` push count was 2 while its
4314 /// `step_region` push count was 1 for the identical history; this test
4315 /// fails on that regression by catching the mismatch directly, not by
4316 /// hard-coding either side's count in isolation.
4317 #[test]
4318 fn run_until_and_step_region_push_the_same_preponed_ring() {
4319 use crate::state::{StateEngine, TemplateRegistry};
4320 use std::cell::Cell;
4321 use std::rc::Rc;
4322
4323 struct OnceDelayed {
4324 started: bool,
4325 }
4326 impl CompiledProcess for OnceDelayed {
4327 fn resume(
4328 &mut self,
4329 _state: SimHandle,
4330 arg: ResumeArg,
4331 _ops: &dyn SchedulerOps,
4332 ) -> Suspend {
4333 match (self.started, arg) {
4334 (false, ResumeArg::Start) => {
4335 self.started = true;
4336 Suspend::Delay(1)
4337 }
4338 _ => Suspend::Done,
4339 }
4340 }
4341 }
4342
4343 fn count_pushes_via(driver: impl FnOnce(&mut Engine)) -> u32 {
4344 let mut engine = new_engine();
4345 let push_count = Rc::new(Cell::new(0u32));
4346 {
4347 let push_count = push_count.clone();
4348 let state_engine = StateEngine::new(TemplateRegistry::new());
4349 engine.configure_preponed_ring(0, move || {
4350 push_count.set(push_count.get() + 1);
4351 state_engine.snapshot(None)
4352 });
4353 }
4354 engine.register_process(
4355 Box::new(OnceDelayed { started: false }),
4356 ProcessKind::Initial,
4357 None,
4358 0,
4359 );
4360 driver(&mut engine);
4361 push_count.get()
4362 }
4363
4364 let run_until_pushes = count_pushes_via(|engine| {
4365 let result = engine.run_until(None);
4366 assert!(matches!(result, RunResult::Completed));
4367 });
4368 let step_region_pushes = count_pushes_via(|engine| {
4369 for _ in 0..30 {
4370 engine.step_region();
4371 }
4372 });
4373
4374 assert_eq!(
4375 run_until_pushes, step_region_pushes,
4376 "run_until and step_region must push the configured Preponed ring the same \
4377 number of times for the identical simulated history (one real Delay(1) \
4378 transition) -- a mismatch here means the two driver APIs disagree about what \
4379 'once per time step' means, which is exactly the asymmetry this regression \
4380 test exists to catch"
4381 );
4382 assert_eq!(
4383 run_until_pushes, 1,
4384 "exactly one real time-step transition occurred (the single Delay(1)); neither \
4385 driver may push at the engine's initial Region::Active entry at time 0 (no \
4386 Preponed push precedes it for either API -- Engine::new sets current_region = \
4387 Active directly)"
4388 );
4389 }
4390
4391 /// sva T1 end-to-end (§1.3/§1.4): a signal written in `Region::Active`
4392 /// is read back through a configured Preponed ring at the START of the
4393 /// following time steps -- the "stub consumer" this task's own plan
4394 /// describes (no `$past`/assertion lowering exists yet to read the ring
4395 /// from compiled SV source; `Engine::with_preponed_entry` is this
4396 /// task's own test-only substitute). Driven via [`Engine::step_region`]
4397 /// (never `run_until`'s deadline-based API) specifically so each
4398 /// Postponed->Active transition -- and so each ring push -- is counted
4399 /// exactly once (5 `step_region` calls == one full Active/Inactive/
4400 /// Nba/Reactive/Postponed cycle, the same convention
4401 /// `suspend_delay_logical_order_uses_absolute_time_across_chained_delays`
4402 /// above already establishes).
4403 ///
4404 /// Verified by hand against LRM 16.5.1: a depth-1 ring's `get(0)`
4405 /// ("current") always holds the value as of the end of the time step
4406 /// that just finished; `get(1)` holds the value as of the end of the
4407 /// time step before that. `cell` is written to 10 during time step 0's
4408 /// `Active` region and to 20 during time step 1's -- after time step 0
4409 /// finishes (one 5-step cycle), `get(0)` must read 10 and `get(1)` must
4410 /// be unretained (`None`, nothing pushed before entry 0 yet); after
4411 /// time step 1 finishes (a second 5-step cycle), `get(0)` must read 20
4412 /// and `get(1)` must read 10.
4413 #[test]
4414 fn sva_preponed_sampling_matches_lrm_16_5_1() {
4415 use crate::state::{
4416 SlotClass, SlotLocation, StateCellId as RealStateCellId, StateEngine, TemplateRegistry,
4417 };
4418 use std::cell::RefCell;
4419 use std::rc::Rc;
4420
4421 // A real, StateEngine-backed signal, mirroring `state::snapshot`'s
4422 // own test fixtures -- `Engine`'s own `arena: Box<dyn SignalAccess>`
4423 // stays a `TestArena` double (sched's established, STATE-decoupled
4424 // test convention), while the Preponed ring's `source` closure
4425 // reads the REAL StateEngine `MirroringArena::write` below also
4426 // writes into, proving the ring genuinely snapshots live arena
4427 // state, not the test double. `Rc<RefCell<_>>`, not `Arc<Mutex<_>>`:
4428 // `StateEngine` holds `Rc` internals of its own (`state::instance`'s
4429 // own doc comment) and is neither `Send` nor `Sync` -- this is
4430 // single-threaded test code, so there is no reason to reach for a
4431 // thread-safe wrapper clippy's `arc_with_non_send_sync` correctly
4432 // flags as misleading here.
4433 let state_engine = Rc::new(RefCell::new(StateEngine::new(TemplateRegistry::new())));
4434 let real_cell: RealStateCellId = {
4435 let mut se = state_engine.borrow_mut();
4436 // SAFETY: local, single-owned engine; no other live borrow of
4437 // the inline plane at this point.
4438 let word_offset = unsafe { se.arena.alloc_inline_word() };
4439 se.alloc_cell(SlotLocation {
4440 class: SlotClass::Inline,
4441 word_offset,
4442 bit_width: 8,
4443 elem_word_count: 1,
4444 row_stride_words: 0,
4445 num_rows: 0,
4446 })
4447 };
4448
4449 // `Engine`'s own test-double cell (what the scripted process
4450 // actually addresses through `SchedulerOps`) maps 1:1 onto
4451 // `real_cell` by sharing the same numeric id -- `MirroringArena`
4452 // mirrors every write into `state_engine` too, so both views stay
4453 // in lockstep without `sched` needing a concrete STATE dependency.
4454 let cell = StateCellId(real_cell.0);
4455
4456 struct MirroringArena {
4457 inner: TestArena,
4458 mirror_to: Rc<RefCell<StateEngine>>,
4459 }
4460 impl SignalAccess for MirroringArena {
4461 fn read(&self, cell: StateCellId) -> FourStateValue {
4462 self.inner.read(cell)
4463 }
4464 fn write(&mut self, cell: StateCellId, value: FourStateValue) -> bool {
4465 self.mirror_to.borrow_mut().commit_write(
4466 RealStateCellId(cell.0),
4467 value.clone(),
4468 &mut (),
4469 );
4470 self.inner.write(cell, value)
4471 }
4472 fn subscribe(
4473 &mut self,
4474 cell: StateCellId,
4475 role: crate::state::ListenerRole,
4476 sink: Box<dyn ValueChangeSink + Send>,
4477 ) {
4478 self.inner.subscribe(cell, role, sink);
4479 }
4480 }
4481
4482 /// Writes 10 at `Start` (time step 0), 20 at the first
4483 /// `TimeElapsed` (time step 1), then goes quiet (`step == 2`'s
4484 /// `TimeElapsed` writes nothing and stays done) -- `Suspend::
4485 /// Delay(1)` after each write is what makes the wheel advance
4486 /// exactly one tick per time step.
4487 struct SequencedWriter {
4488 cell: StateCellId,
4489 step: u32,
4490 }
4491 impl CompiledProcess for SequencedWriter {
4492 fn resume(
4493 &mut self,
4494 _state: SimHandle,
4495 arg: ResumeArg,
4496 ops: &dyn SchedulerOps,
4497 ) -> Suspend {
4498 match (self.step, arg) {
4499 (0, ResumeArg::Start) => {
4500 ops.commit_blocking_write(self.cell, narrow(10));
4501 self.step = 1;
4502 Suspend::Delay(1)
4503 }
4504 (1, ResumeArg::TimeElapsed) => {
4505 ops.commit_blocking_write(self.cell, narrow(20));
4506 self.step = 2;
4507 Suspend::Delay(1)
4508 }
4509 _ => Suspend::Done,
4510 }
4511 }
4512 }
4513
4514 let mut engine = Engine::new(
4515 Box::new(MirroringArena {
4516 inner: TestArena::new(),
4517 mirror_to: state_engine.clone(),
4518 }),
4519 Box::new(TestIoSink::default()),
4520 );
4521 {
4522 let src = state_engine.clone();
4523 engine.configure_preponed_ring(1, move || src.borrow().snapshot(None));
4524 }
4525 engine.register_process(
4526 Box::new(SequencedWriter { cell, step: 0 }),
4527 ProcessKind::Initial,
4528 None,
4529 0,
4530 );
4531
4532 // `FourStateValue` carries no `PartialEq` (reference/introspection
4533 // shape only, `state::mod.rs`'s own doc comment on it) -- this
4534 // narrows down to the one `u128` lane this test's own single
4535 // `SlotClass::Inline` cell ever uses, mirroring `state::snapshot`'s
4536 // own `read_narrow` test helper.
4537 let read_through_ring = |engine: &Engine, ticks_back: usize| -> Option<u128> {
4538 engine.with_preponed_entry(ticks_back, |snap| {
4539 snap.map(|snap| {
4540 let mut se = state_engine.borrow_mut();
4541 se.restore(snap)
4542 .expect("same StateEngine the ring sampled from, same layout");
4543 match se.read(real_cell) {
4544 FourStateValue::Narrow(w) => w,
4545 FourStateValue::Wide { .. } => panic!("expected Narrow"),
4546 }
4547 })
4548 })
4549 };
4550
4551 // One full 5-region cycle completes time step 0 (Start dispatched
4552 // during Active, writing 10 and arming Delay(1); the Postponed step
4553 // then advances the wheel to tick 1 and pushes the Preponed
4554 // snapshot for the start of tick 1).
4555 for _ in 0..5 {
4556 engine.step_region();
4557 }
4558 assert_eq!(
4559 read_through_ring(&engine, 0),
4560 Some(10u128),
4561 "16.5.1: after time step 0 finishes, the ring's current entry must read the \
4562 value committed during time step 0's own Active region (10)"
4563 );
4564 assert_eq!(
4565 read_through_ring(&engine, 1),
4566 None,
4567 "only one push has happened so far -- ticks_back=1 must not be retained yet"
4568 );
4569
4570 // A second 5-region cycle completes time step 1 (the resumed
4571 // TimeElapsed writes 20 and arms another Delay(1); Postponed pushes
4572 // the Preponed snapshot for the start of tick 2).
4573 for _ in 0..5 {
4574 engine.step_region();
4575 }
4576 assert_eq!(
4577 read_through_ring(&engine, 0),
4578 Some(20u128),
4579 "16.5.1: after time step 1 finishes, the ring's current entry must read the \
4580 value committed during time step 1's own Active region (20)"
4581 );
4582 assert_eq!(
4583 read_through_ring(&engine, 1),
4584 Some(10u128),
4585 "16.5.1: ticks_back=1 must still read time step 0's own committed value (10)"
4586 );
4587 }
4588
4589 #[test]
4590 fn register_process_returns_the_ids_run_until_and_run_finals_operate_on() {
4591 let log: Arc<StdMutex<Vec<(ProcessId, &'static str)>>> =
4592 Arc::new(StdMutex::new(Vec::new()));
4593 let mut engine = new_engine();
4594
4595 let mut regular_ids = Vec::new();
4596 for _ in 0..3 {
4597 let (proc, id_cell) = logging_process(&log);
4598 let id = engine.register_process(proc, ProcessKind::Initial, None, 0);
4599 *id_cell.lock().unwrap() = Some(id);
4600 regular_ids.push(id);
4601 }
4602
4603 let mut final_ids = Vec::new();
4604 for _ in 0..3 {
4605 let (proc, id_cell) = logging_process(&log);
4606 let id = engine.register_process(proc, ProcessKind::Final, None, 0);
4607 *id_cell.lock().unwrap() = Some(id);
4608 final_ids.push(id);
4609 }
4610
4611 let result = engine.run_until(None);
4612 assert!(matches!(result, RunResult::Completed));
4613 engine.run_finals();
4614
4615 let entries = log.lock().unwrap().clone();
4616 // Every regular process ran (via run_until's initial dispatch) with
4617 // exactly one Start; no final-kind process was dispatched by
4618 // run_until (Final processes are never queued onto Active).
4619 for &id in ®ular_ids {
4620 let starts: Vec<_> = entries
4621 .iter()
4622 .filter(|(p, tag)| *p == id && *tag == "Start")
4623 .collect();
4624 assert_eq!(
4625 starts.len(),
4626 1,
4627 "process {id} must have gotten exactly one Start"
4628 );
4629 }
4630 // The finals ran (via run_finals), in registration order.
4631 let final_start_order: Vec<ProcessId> = entries
4632 .iter()
4633 .filter(|(p, _)| final_ids.contains(p))
4634 .map(|(p, _)| *p)
4635 .collect();
4636 assert_eq!(
4637 final_start_order, final_ids,
4638 "finals must run in registration order"
4639 );
4640 for &id in &final_ids {
4641 let starts: Vec<_> = entries
4642 .iter()
4643 .filter(|(p, tag)| *p == id && *tag == "Start")
4644 .collect();
4645 assert_eq!(
4646 starts.len(),
4647 1,
4648 "final process {id} must have gotten exactly one Start"
4649 );
4650 }
4651 }
4652
4653 /// Classes plan Task 14 (LRM 18.14.1): a process registered while
4654 /// `registration_scope` names an instance is seeded from that instance's
4655 /// initialization RNG, one draw per process in registration order; two
4656 /// instances have independent streams; and a process registered with no
4657 /// scope (infrastructure) does not advance any instance's RNG.
4658 #[test]
4659 fn registered_processes_are_seeded_from_their_instances_init_rng() {
4660 use crate::sched::process_rng::{self, with_process_state};
4661 let nop = || -> Box<dyn CompiledProcess> { script(|_, _| Suspend::Done) };
4662 process_rng::reset();
4663 let mut engine = new_engine();
4664 engine.set_registration_scope(Some(0));
4665 let a = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4666 engine.set_registration_scope(None);
4667 let infra = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4668 engine.set_registration_scope(Some(0));
4669 let b = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4670 engine.set_registration_scope(Some(1));
4671 let c = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4672 engine.set_registration_scope(None);
4673 let seen: Vec<u64> = [a, infra, b, c]
4674 .iter()
4675 .map(|&p| with_process_state(Some(p), |s| *s))
4676 .collect();
4677
4678 process_rng::reset();
4679 let (e0, e1, e2) = (
4680 process_rng::draw_from_init(0),
4681 process_rng::draw_from_init(0),
4682 process_rng::draw_from_init(1),
4683 );
4684 assert_eq!(seen[0], e0, "first process of instance 0");
4685 assert_eq!(
4686 seen[2], e1,
4687 "second process of instance 0 (the infra one skipped)"
4688 );
4689 assert_eq!(seen[3], e2, "first process of instance 1");
4690 assert_eq!(seen[1], process_rng::DEFAULT_SEED, "unscoped: never seeded");
4691 assert_ne!(seen[0], seen[2]);
4692 }
4693
4694 /// Classes plan Task 14 (LRM 18.14.2): `fork` children are seeded when
4695 /// they are created, one draw each in source order from the parent's own
4696 /// generator (so the parent advances by exactly one step per child), no
4697 /// matter when they later run.
4698 #[test]
4699 fn fork_children_are_seeded_from_the_forking_process_in_source_order() {
4700 use crate::sched::process_rng::{self, with_process_state};
4701 process_rng::reset();
4702 let mut engine = new_engine();
4703 let spawn = || SpawnSpec {
4704 kernel: 0,
4705 entry_pc: 0,
4706 parent: 0,
4707 disable_name: Vec::new(),
4708 };
4709 let parent = engine.register_process(
4710 script(move |arg, _| match arg {
4711 ResumeArg::Start => Suspend::ForkJoin(JoinKind::None, vec![spawn(), spawn()]),
4712 _ => Suspend::Done,
4713 }),
4714 ProcessKind::Initial,
4715 None,
4716 0,
4717 );
4718 assert_eq!(parent, 0);
4719 // The parent's stream, before the fork.
4720 with_process_state(Some(parent), |s| *s = 99);
4721 let ready = engine.drain_and_sort(Region::Active);
4722 engine.dispatch_ready_set(Region::Active, ready);
4723 let seen: Vec<u64> = [1, 2]
4724 .iter()
4725 .map(|&c| with_process_state(Some(c), |s| *s))
4726 .collect();
4727 let parent_after = with_process_state(Some(parent), |s| *s);
4728
4729 let mut p = 99u64;
4730 let want = [
4731 process_rng::splitmix64_next(&mut p),
4732 process_rng::splitmix64_next(&mut p),
4733 ];
4734 assert_eq!(seen, want, "children seeded by successive parent draws");
4735 assert_eq!(parent_after, p, "one parent step per child, no more");
4736 assert_ne!(seen[0], seen[1]);
4737 }
4738
4739 /// SC-045's "installation happens exactly once, at registration" proof:
4740 /// no `commit_nba`/`commit_blocking_write`/`notify_change` call anywhere
4741 /// in this test, so downstream dedup cannot mask a double-install.
4742 #[test]
4743 fn register_process_installs_static_sensitivity_exactly_once() {
4744 let a = StateCellId(1);
4745 let b = StateCellId(2);
4746 let c = StateCellId(3);
4747 let mut engine = new_engine();
4748 engine.register_process(
4749 Box::new(UnmintedForkChild),
4750 ProcessKind::AlwaysComb,
4751 Some(StaticSensitivity {
4752 edges: vec![(a, None), (b, None), (c, None)],
4753 }),
4754 0,
4755 );
4756 assert_eq!(engine.test_sensitivity_entry_count(), 3);
4757 }
4758
4759 #[test]
4760 fn abi_entry_point_map_names_all_nine_entry_points_exactly_once() {
4761 let map = abi_entry_point_map();
4762 let names: std::collections::HashSet<&str> = map.iter().map(|(name, _)| *name).collect();
4763 assert_eq!(names.len(), map.len(), "no duplicate entry-point names");
4764 let expected: std::collections::HashSet<&str> = [
4765 "sim_init",
4766 "sim_settle",
4767 "sim_eval_region",
4768 "sim_commit_nba",
4769 "sim_save",
4770 "sim_restore",
4771 "sim_step_time",
4772 "sim_final",
4773 "sim_process_count",
4774 ]
4775 .into_iter()
4776 .collect();
4777 assert_eq!(names, expected);
4778 }
4779
4780 // --- EngineOps / EngineNotifySink wiring against Engine's own state ---
4781
4782 #[test]
4783 fn engine_ops_commit_blocking_write_writes_through_to_the_engines_own_arena() {
4784 let cell = StateCellId(7);
4785 let engine = Engine::new(
4786 Box::new(TestArena::seeded(cell, narrow(0))),
4787 Box::new(TestIoSink::default()),
4788 );
4789 let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4790 ops.commit_blocking_write(cell, narrow(1));
4791 assert!(matches!(
4792 engine.test_read_cell(cell),
4793 FourStateValue::Narrow(1)
4794 ));
4795 }
4796
4797 #[test]
4798 fn engine_ops_commit_blocking_write_wakes_a_statically_sensitive_process_via_the_real_queues() {
4799 let cell = StateCellId(9);
4800 let mut engine = Engine::new(
4801 Box::new(TestArena::seeded(cell, narrow(0))),
4802 Box::new(TestIoSink::default()),
4803 );
4804 let p1 =
4805 engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
4806 let p2 = engine.register_process(
4807 Box::new(UnmintedForkChild),
4808 ProcessKind::AlwaysFF,
4809 Some(StaticSensitivity {
4810 edges: vec![(cell, Some(EdgeKind::Posedge))],
4811 }),
4812 0,
4813 );
4814 // Both p1/p2 auto-queued a Start activation at registration; drain
4815 // that away so the assertion below only counts the wakeup this test
4816 // itself triggers.
4817 let _ = engine.test_drain_active();
4818
4819 {
4820 let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4821 // 0 -> 1 on bit 0 is a Posedge (notify.rs's infer_edge table).
4822 ops.commit_blocking_write(cell, narrow(1));
4823 }
4824
4825 let queued = engine.test_drain_active();
4826 assert_eq!(queued.len(), 1, "exactly one new activation queued");
4827 assert_eq!(
4828 queued[0].proc, p2,
4829 "the statically-sensitive process p2 was woken"
4830 );
4831 let _ = p1;
4832 }
4833
4834 /// SC-055 through the real `Engine`: a process statically sensitive to
4835 /// two cells that both change in the same pass is queued exactly once.
4836 /// Without the gate `notify_change` pushes one activation per matching
4837 /// change (2 here).
4838 #[test]
4839 fn engine_queues_a_process_once_when_two_of_its_cells_change_in_one_pass() {
4840 let a = StateCellId(20);
4841 let b = StateCellId(21);
4842 let mut engine = new_engine();
4843 let p = engine.register_process(
4844 Box::new(UnmintedForkChild),
4845 ProcessKind::AlwaysComb,
4846 Some(StaticSensitivity {
4847 edges: vec![(a, None), (b, None)],
4848 }),
4849 0,
4850 );
4851 let _ = engine.test_drain_active();
4852
4853 {
4854 let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4855 ops.commit_blocking_write(a, narrow(1));
4856 ops.commit_blocking_write(b, narrow(1));
4857 }
4858
4859 let queued = engine.test_drain_active();
4860 assert_eq!(
4861 queued.iter().map(|q| q.proc).collect::<Vec<_>>(),
4862 vec![p],
4863 "two changes in one pass must queue the process once, not twice"
4864 );
4865 }
4866
4867 /// SC-056 through the real `Engine`: once the queued activation has been
4868 /// popped (the dequeue point resets the flag), a new change queues the
4869 /// process again. A gate that never reset would swallow the second wakeup.
4870 #[test]
4871 fn engine_requeues_a_process_changed_again_after_its_activation_was_dequeued() {
4872 let a = StateCellId(22);
4873 let mut engine = new_engine();
4874 let p = engine.register_process(
4875 Box::new(UnmintedForkChild),
4876 ProcessKind::AlwaysComb,
4877 Some(StaticSensitivity {
4878 edges: vec![(a, None)],
4879 }),
4880 0,
4881 );
4882 let _ = engine.test_drain_active();
4883
4884 {
4885 let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4886 ops.commit_blocking_write(a, narrow(1));
4887 }
4888 assert_eq!(
4889 engine.test_drain_active().len(),
4890 1,
4891 "first change queues the process"
4892 );
4893
4894 {
4895 let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 1);
4896 ops.commit_blocking_write(a, narrow(0));
4897 }
4898 let queued = engine.test_drain_active();
4899 assert_eq!(
4900 queued.iter().map(|q| q.proc).collect::<Vec<_>>(),
4901 vec![p],
4902 "a change after the dequeue must queue the process again"
4903 );
4904 }
4905
4906 #[test]
4907 fn engine_ops_stage_nba_appends_to_the_engines_own_nba_log() {
4908 let engine = new_engine();
4909 assert_eq!(engine.test_nba_len(), 0);
4910 let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4911 ops.stage_nba(StateCellId(1), narrow(1), 0);
4912 assert_eq!(engine.test_nba_len(), 1);
4913 }
4914
4915 #[test]
4916 fn engine_ops_defer_io_forwards_with_a_seq_counter_that_persists_across_passes() {
4917 // `io_sink: Box<dyn IoSink>` is not downcastable, so this test uses a
4918 // shared-state sink to observe the recorded seqs from outside.
4919 struct SharedIoSink(Arc<StdMutex<Vec<u64>>>);
4920 impl IoSink for SharedIoSink {
4921 fn enqueue(&self, _at: SchedTime, _region: Region, seq: u64, cb: DeferredIoCallback) {
4922 drop(cb);
4923 self.0.lock().unwrap().push(seq);
4924 }
4925 }
4926 let seqs = Arc::new(StdMutex::new(Vec::new()));
4927 let engine = Engine::new(
4928 Box::new(TestArena::new()),
4929 Box::new(SharedIoSink(seqs.clone())),
4930 );
4931 let ops1 = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4932 ops1.defer_io(SchedTime { ticks: 0 }, Region::Active, Box::new(|_| {}));
4933 let ops2 = engine.test_dispatch_ops(SchedTime { ticks: 5 }, Region::Inactive, 0);
4934 ops2.defer_io(SchedTime { ticks: 5 }, Region::Inactive, Box::new(|_| {}));
4935
4936 assert_eq!(
4937 *seqs.lock().unwrap(),
4938 vec![0, 1],
4939 "seq must strictly increase across two independently-constructed EngineOps"
4940 );
4941 }
4942
4943 // --- review fixes (r1) ---
4944
4945 /// Review fix — `apply_suspend`'s `Suspend::Delay` arm used to always
4946 /// build `act` with `logical_order.1 == Region::Active`, even when
4947 /// `wheel.arm_delay` reports `Region::Inactive` (a zero-delay `#0`
4948 /// reactivation) and the activation is pushed straight into
4949 /// `Region::Inactive` instead. Mirrors `subscribe.rs::register_wait`'s
4950 /// own `delay_zero_lands_directly_in_inactive_not_the_wheel` test for
4951 /// the identical hazard, one file over.
4952 #[test]
4953 fn apply_suspend_delay_zero_lands_directly_in_inactive_with_the_correct_region_tag() {
4954 struct DelayZeroProcess;
4955 impl CompiledProcess for DelayZeroProcess {
4956 fn resume(
4957 &mut self,
4958 _state: SimHandle,
4959 arg: ResumeArg,
4960 _ops: &dyn SchedulerOps,
4961 ) -> Suspend {
4962 match arg {
4963 ResumeArg::Start => Suspend::Delay(0),
4964 _ => Suspend::Done,
4965 }
4966 }
4967 }
4968
4969 let mut engine = new_engine();
4970 let proc =
4971 engine.register_process(Box::new(DelayZeroProcess), ProcessKind::Initial, None, 0);
4972
4973 // Single-steps exactly the Active region: dispatches the process's
4974 // Start, which returns Suspend::Delay(0) -- apply_suspend's
4975 // wheel.arm_delay call reports Region::Inactive (delay_ticks == 0,
4976 // per wheel.rs's own doc comment) without ever touching the wheel.
4977 engine.step_region();
4978
4979 let inactive = engine.test_drain_inactive();
4980 assert_eq!(
4981 inactive.len(),
4982 1,
4983 "the zero-delay reactivation must land directly in Region::Inactive"
4984 );
4985 assert_eq!(inactive[0].proc, proc);
4986 assert_eq!(
4987 inactive[0].logical_order.1,
4988 Region::Inactive,
4989 "an activation landed in the Inactive queue must carry Region::Inactive as its \
4990 own logical_order.1 tag, agreeing with the queue it actually lives in -- \
4991 order.rs's canonical_key_of/sort_ready_set read logical_order.1 directly to \
4992 build the canonical key that SC-010/DT-005/DT-010 depend on"
4993 );
4994 }
4995
4996 /// Whole-track review fix (AB-085): the only prior test for
4997 /// `Suspend::WaitForGvt` (`wait_for_gvt_shares_the_suspend_dispatch_path`
4998 /// in `sched::mod`) never called `apply_suspend` at all -- it matched a
4999 /// test-local, hand-written `label` function against the `Suspend` enum,
5000 /// which only proves that function's own match arms are exhaustive, not
5001 /// that `apply_suspend`'s `Suspend::WaitForGvt => self.apply_suspend(proc,
5002 /// Suspend::Delay(0))` arm actually delegates anywhere. This test drives
5003 /// a real process through the real `Engine`/`apply_suspend` with
5004 /// `Suspend::WaitForGvt` and asserts the resulting scheduler state is
5005 /// identical (region, queue length, logical_order tag) to what
5006 /// `apply_suspend_delay_zero_lands_directly_in_inactive_with_the_correct_
5007 /// region_tag` (immediately above) observes for `Suspend::Delay(0)` --
5008 /// proving the delegation for real. Before this test existed, changing
5009 /// the `WaitForGvt` arm to `Suspend::Delay(1)`, to a silent no-op, or to
5010 /// removing the delegation entirely (stranding the process) would still
5011 /// have passed the full test suite.
5012 #[test]
5013 fn apply_suspend_wait_for_gvt_delegates_to_the_same_path_as_delay_zero() {
5014 struct WaitForGvtProcess;
5015 impl CompiledProcess for WaitForGvtProcess {
5016 fn resume(
5017 &mut self,
5018 _state: SimHandle,
5019 arg: ResumeArg,
5020 _ops: &dyn SchedulerOps,
5021 ) -> Suspend {
5022 match arg {
5023 ResumeArg::Start => Suspend::WaitForGvt,
5024 _ => Suspend::Done,
5025 }
5026 }
5027 }
5028
5029 let mut engine = new_engine();
5030 let proc =
5031 engine.register_process(Box::new(WaitForGvtProcess), ProcessKind::Initial, None, 0);
5032
5033 // Single-steps exactly the Active region: dispatches the process's
5034 // Start, which returns Suspend::WaitForGvt. If apply_suspend's
5035 // `Suspend::WaitForGvt` arm really delegates to
5036 // `self.apply_suspend(proc, Suspend::Delay(0))`, the observable
5037 // outcome must be pixel-for-pixel identical to the Delay(0) test
5038 // above: one reactivation, landed directly in Region::Inactive
5039 // (never touching the wheel), tagged with Region::Inactive.
5040 engine.step_region();
5041
5042 let inactive = engine.test_drain_inactive();
5043 assert_eq!(
5044 inactive.len(),
5045 1,
5046 "WaitForGvt must delegate to Delay(0)'s dispatch, landing its reactivation \
5047 directly in Region::Inactive exactly like Delay(0) does -- a broken or removed \
5048 delegation would leave this queue empty (process stranded) or route it \
5049 elsewhere"
5050 );
5051 assert_eq!(inactive[0].proc, proc);
5052 assert_eq!(
5053 inactive[0].logical_order.1,
5054 Region::Inactive,
5055 "the delegated activation must carry the same Region::Inactive logical_order \
5056 tag Delay(0) produces, agreeing with the queue it actually lives in -- a \
5057 WaitForGvt arm changed to Delay(1) or to any wheel-touching path would instead \
5058 land here with Region::Active, or not land here at all this step"
5059 );
5060 assert_eq!(
5061 inactive[0].logical_order.0,
5062 SchedTime { ticks: 0 },
5063 "delegating to Delay(0) from now == 0 must produce the same absolute fire tick \
5064 Delay(0) produces (0 + 0 == 0), not some other tick a differently-parameterized \
5065 delegation (e.g. Delay(1)) would produce"
5066 );
5067 }
5068
5069 /// Mutation-test kill: `apply_suspend`'s `Suspend::Delay` arm must build
5070 /// the reactivation's `logical_order.0` from `self.now.ticks + ticks`
5071 /// (the real absolute fire tick), never from `ticks` alone. The bug is
5072 /// invisible at `now == 0` (where the two computations coincide, exactly
5073 /// the case every other `Delay` test in this file exercises), so this
5074 /// test chains two delays -- `Delay(3)` fired from `now == 0`, then
5075 /// `Delay(4)` fired from the resulting `now == 3` -- and inspects the
5076 /// *second* reactivation's own `logical_order.0` after the wheel fires
5077 /// it: it must be `SchedTime{ticks: 7}` (3+4), never `SchedTime{ticks:
5078 /// 4}`. The wheel's own real fire time is unaffected by this bug (it
5079 /// computes `now + delay_ticks` internally, independent of the
5080 /// activation's `logical_order` field), so `engine.now()` alone cannot
5081 /// catch it -- only inspecting the queued activation's own field can.
5082 #[test]
5083 fn suspend_delay_logical_order_uses_absolute_time_across_chained_delays() {
5084 struct TwoStageDelay;
5085 impl CompiledProcess for TwoStageDelay {
5086 fn resume(
5087 &mut self,
5088 _state: SimHandle,
5089 arg: ResumeArg,
5090 _ops: &dyn SchedulerOps,
5091 ) -> Suspend {
5092 match arg {
5093 ResumeArg::Start => Suspend::Delay(3),
5094 ResumeArg::TimeElapsed => Suspend::Delay(4),
5095 _ => Suspend::Done,
5096 }
5097 }
5098 }
5099
5100 let mut engine = new_engine();
5101 engine.register_process(Box::new(TwoStageDelay), ProcessKind::Initial, None, 0);
5102
5103 // Exactly 10 step_region() calls: 5 regions (Active/Inactive/Nba/
5104 // Reactive/Postponed) x 2 timesteps -- the first timestep dispatches
5105 // Start (arming Delay(3)) and advances the wheel to tick 3 without
5106 // dispatching the resulting reactivation; the second timestep
5107 // dispatches that TimeElapsed (arming Delay(4)) and advances the
5108 // wheel to tick 7, again landing the reactivation in Region::Active
5109 // without dispatching it.
5110 for _ in 0..10 {
5111 engine.step_region();
5112 }
5113
5114 assert_eq!(
5115 engine.now(),
5116 SchedTime { ticks: 7 },
5117 "the wheel's own real fire time (unaffected by this bug) must be 0+3, then 3+4 = 7"
5118 );
5119
5120 let active = engine.test_drain_active();
5121 assert_eq!(
5122 active.len(),
5123 1,
5124 "the second delay's reactivation must be sitting in Region::Active, undispatched"
5125 );
5126 assert_eq!(
5127 active[0].logical_order.0,
5128 SchedTime { ticks: 7 },
5129 "logical_order.0 must be the absolute fire tick (now=3 + delay=4 = 7), not \
5130 the raw delay amount (4) alone"
5131 );
5132 }
5133
5134 /// Review fix — `apply_suspend`'s `Suspend::WaitEvents` arm installed a
5135 /// dynamic sensitivity entry on suspend but nothing in `Engine`'s
5136 /// dispatch path ever called `SensitivityTable::remove_dynamic` when the
5137 /// waiting process later resumed, leaking the entry forever and risking
5138 /// a phantom wakeup of an already-resumed process on a later write to
5139 /// the same cell.
5140 #[test]
5141 fn wait_events_dynamic_sensitivity_entry_is_removed_when_the_process_resumes() {
5142 struct WaiterProcess {
5143 cell: StateCellId,
5144 }
5145 impl CompiledProcess for WaiterProcess {
5146 fn resume(
5147 &mut self,
5148 _state: SimHandle,
5149 arg: ResumeArg,
5150 _ops: &dyn SchedulerOps,
5151 ) -> Suspend {
5152 match arg {
5153 ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
5154 terms: vec![EventTerm::Edge {
5155 cell: self.cell,
5156 edge: EdgeKind::Posedge,
5157 iff: None,
5158 }],
5159 ..Default::default()
5160 }),
5161 _ => Suspend::Done,
5162 }
5163 }
5164 }
5165 struct WriterProcess {
5166 cell: StateCellId,
5167 }
5168 impl CompiledProcess for WriterProcess {
5169 fn resume(
5170 &mut self,
5171 _state: SimHandle,
5172 arg: ResumeArg,
5173 ops: &dyn SchedulerOps,
5174 ) -> Suspend {
5175 match arg {
5176 // Delay past t=0 so this write lands in its own,
5177 // later region pass -- one full pass after
5178 // WaiterProcess's own Start has already been applied
5179 // (apply_suspend, and therefore install_dynamic, only
5180 // runs once every item dispatched *in the same pass*
5181 // has itself returned its Suspend), so the dynamic
5182 // entry genuinely exists by the time this write fires.
5183 ResumeArg::Start => Suspend::Delay(1),
5184 ResumeArg::TimeElapsed => {
5185 ops.commit_blocking_write(self.cell, narrow(1));
5186 Suspend::Done
5187 }
5188 other => panic!("WriterProcess must only see Start/TimeElapsed, got {other:?}"),
5189 }
5190 }
5191 }
5192
5193 let cell = StateCellId(42);
5194 let mut engine = Engine::new(
5195 Box::new(TestArena::seeded(cell, narrow(0))),
5196 Box::new(TestIoSink::default()),
5197 );
5198 engine.register_process(
5199 Box::new(WaiterProcess { cell }),
5200 ProcessKind::Initial,
5201 None,
5202 0,
5203 );
5204 engine.register_process(
5205 Box::new(WriterProcess { cell }),
5206 ProcessKind::Initial,
5207 None,
5208 0,
5209 );
5210
5211 let result = engine.run_until(None);
5212 assert!(matches!(result, RunResult::Completed));
5213
5214 assert_eq!(
5215 engine.test_sensitivity_entry_count(),
5216 0,
5217 "the dynamic entry must be removed once the waiting process resumes via \
5218 EventFired -- sensitivity.rs's own doc comment: dynamic entries \"are gone \
5219 from the table the instant the owning process resumes\" -- an earlier draft \
5220 never called SensitivityTable::remove_dynamic anywhere in Engine's dispatch \
5221 path, leaking this row forever"
5222 );
5223 }
5224
5225 /// Mutation-test kill: `dispatch_ready_set`'s dynamic-wait-resumed
5226 /// removal set names two `ResumeArg` variants, `EventFired` and
5227 /// `Disabled` -- but no other test in this file ever drives a
5228 /// `ResumeArg::Disabled` activation through `dispatch_ready_set` (no
5229 /// production path in this crate constructs one yet), so a mutant that
5230 /// narrowed the removal check to `EventFired` alone would pass every
5231 /// other test. This test dispatches `Start` normally (installing one
5232 /// dynamic sensitivity entry via `Suspend::WaitEvents`), then hand-builds
5233 /// a `ResumeArg::Disabled` activation for the same process and dispatches
5234 /// it directly -- `dispatch_ready_set`/`drain_and_sort` are private
5235 /// methods on `Engine`, reachable here since this test module is a
5236 /// descendant of `control`.
5237 #[test]
5238 fn dispatch_ready_set_removes_dynamic_sensitivity_entry_for_disabled_resume_too() {
5239 struct WaiterProcess {
5240 cell: StateCellId,
5241 }
5242 impl CompiledProcess for WaiterProcess {
5243 fn resume(
5244 &mut self,
5245 _state: SimHandle,
5246 arg: ResumeArg,
5247 _ops: &dyn SchedulerOps,
5248 ) -> Suspend {
5249 match arg {
5250 ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
5251 terms: vec![EventTerm::Edge {
5252 cell: self.cell,
5253 edge: EdgeKind::Posedge,
5254 iff: None,
5255 }],
5256 ..Default::default()
5257 }),
5258 _ => Suspend::Done,
5259 }
5260 }
5261 }
5262
5263 let cell = StateCellId(99);
5264 let mut engine = new_engine();
5265 let proc = engine.register_process(
5266 Box::new(WaiterProcess { cell }),
5267 ProcessKind::Initial,
5268 None,
5269 0,
5270 );
5271
5272 let ready = engine.drain_and_sort(Region::Active);
5273 engine.dispatch_ready_set(Region::Active, ready);
5274 assert_eq!(
5275 engine.test_sensitivity_entry_count(),
5276 1,
5277 "Suspend::WaitEvents must install exactly one dynamic sensitivity entry"
5278 );
5279
5280 let disabled_act = PendingActivation {
5281 id: 999,
5282 proc,
5283 arg: ResumeArg::Disabled,
5284 reconv_iter: 0,
5285 logical_order: (engine.now(), Region::Active, 0),
5286 };
5287 engine.dispatch_ready_set(Region::Active, vec![disabled_act]);
5288
5289 assert_eq!(
5290 engine.test_sensitivity_entry_count(),
5291 0,
5292 "a ResumeArg::Disabled resume must also remove its process's dynamic \
5293 sensitivity entry, exactly like EventFired does -- a mutant narrowing the \
5294 removal check to EventFired alone would leave this row (and every future \
5295 Disabled-terminated wait's row) leaked forever"
5296 );
5297 }
5298
5299 /// Review fix — `apply_suspend`'s `Suspend::ForkJoin` arm spawned
5300 /// children via `ResumeDriver::handle_fork` but nothing in `Engine`
5301 /// ever called `ResumeDriver::on_child_done` when a spawned child later
5302 /// completed, so the parent was never reactivated: fork/join was
5303 /// completely nonfunctional (any forking process silently hung forever
5304 /// while `run_until` still reported normal completion). Drives a real
5305 /// `JoinKind::All` fork with one child through `Engine::run_until` and
5306 /// asserts the parent actually resumes via `ResumeArg::ForkChildDone`.
5307 #[test]
5308 fn fork_join_all_actually_resumes_the_parent_when_its_one_child_completes() {
5309 struct ParentProcess {
5310 log: Arc<StdMutex<Vec<&'static str>>>,
5311 }
5312 impl CompiledProcess for ParentProcess {
5313 fn resume(
5314 &mut self,
5315 _state: SimHandle,
5316 arg: ResumeArg,
5317 _ops: &dyn SchedulerOps,
5318 ) -> Suspend {
5319 match arg {
5320 ResumeArg::Start => {
5321 self.log.lock().unwrap().push("forked");
5322 Suspend::ForkJoin(
5323 JoinKind::All,
5324 vec![SpawnSpec {
5325 kernel: 0,
5326 entry_pc: 0,
5327 parent: 0,
5328 disable_name: Vec::new(),
5329 }],
5330 )
5331 }
5332 ResumeArg::ForkChildDone { .. } => {
5333 self.log.lock().unwrap().push("joined");
5334 Suspend::Done
5335 }
5336 other => panic!(
5337 "ParentProcess must only ever be resumed with Start or \
5338 ForkChildDone, got {other:?}"
5339 ),
5340 }
5341 }
5342 }
5343
5344 let log = Arc::new(StdMutex::new(Vec::new()));
5345 let mut engine = new_engine();
5346 engine.register_process(
5347 Box::new(ParentProcess { log: log.clone() }),
5348 ProcessKind::Initial,
5349 None,
5350 0,
5351 );
5352
5353 let result = engine.run_until(None);
5354 assert!(matches!(result, RunResult::Completed));
5355
5356 assert_eq!(
5357 log.lock().unwrap().as_slice(),
5358 &["forked", "joined"],
5359 "the parent must actually be reactivated via ResumeArg::ForkChildDone once its \
5360 one child (an UnmintedForkChild, immediately Done) completes -- an earlier \
5361 draft never wired ResumeDriver::on_child_done into Engine at all, so the \
5362 parent would silently hang forever (never reach \"joined\") despite run_until \
5363 still reporting RunResult::Completed"
5364 );
5365 }
5366
5367 // --- Task 7 (B-sched wave, ledger SC-062, SC-115) ---
5368
5369 /// SC-062's own literal acceptance, proven through the real `Engine`
5370 /// dispatch loop (not just `ResumeDriver` in isolation, which
5371 /// `resume.rs`'s own `join_all_resumes_parent_only_after_every_child_
5372 /// reports_done`/`on_child_done_reclaims_the_childs_slot_when_the_join_
5373 /// is_satisfied` already cover): a `JoinAll` fork of 3 children (each an
5374 /// `UnmintedForkChild`, immediately `Done` on its own first dispatch --
5375 /// this wave's own documented interim placeholder, see this module's
5376 /// doc comment deviation 3) completing one after another *within the
5377 /// same Active-region dispatch pass*. Asserts the parent resumes (logs
5378 /// "joined") only once, after all 3, and that every one of the 3
5379 /// forked children's own `ProcessId`s is unreachable via
5380 /// `Engine::test_process_exists` by the time it does. Fails if slot
5381 /// reclaim is never wired (every child id would still report `true`),
5382 /// or if it reclaims the wrong process (e.g. the parent).
5383 #[test]
5384 fn sc062_joinall_child_returning_done_satisfies_the_parents_join_and_reclaims_its_slot() {
5385 struct ParentProcess {
5386 log: Arc<StdMutex<Vec<&'static str>>>,
5387 }
5388 impl CompiledProcess for ParentProcess {
5389 fn resume(
5390 &mut self,
5391 _state: SimHandle,
5392 arg: ResumeArg,
5393 _ops: &dyn SchedulerOps,
5394 ) -> Suspend {
5395 match arg {
5396 ResumeArg::Start => {
5397 self.log.lock().unwrap().push("forked");
5398 Suspend::ForkJoin(
5399 JoinKind::All,
5400 vec![
5401 SpawnSpec {
5402 kernel: 0,
5403 entry_pc: 0,
5404 parent: 0,
5405 disable_name: Vec::new(),
5406 },
5407 SpawnSpec {
5408 kernel: 1,
5409 entry_pc: 0,
5410 parent: 0,
5411 disable_name: Vec::new(),
5412 },
5413 SpawnSpec {
5414 kernel: 2,
5415 entry_pc: 0,
5416 parent: 0,
5417 disable_name: Vec::new(),
5418 },
5419 ],
5420 )
5421 }
5422 ResumeArg::ForkChildDone { .. } => {
5423 self.log.lock().unwrap().push("joined");
5424 Suspend::Done
5425 }
5426 other => panic!(
5427 "ParentProcess must only ever be resumed with Start or \
5428 ForkChildDone, got {other:?}"
5429 ),
5430 }
5431 }
5432 }
5433
5434 let log = Arc::new(StdMutex::new(Vec::new()));
5435 let mut engine = new_engine();
5436 let parent = engine.register_process(
5437 Box::new(ParentProcess { log: log.clone() }),
5438 ProcessKind::Initial,
5439 None,
5440 0,
5441 );
5442 assert_eq!(parent, 0, "test assumes a fresh engine's first id is 0");
5443
5444 let result = engine.run_until(None);
5445 assert!(matches!(result, RunResult::Completed));
5446 assert_eq!(
5447 log.lock().unwrap().as_slice(),
5448 &["forked", "joined"],
5449 "the parent must resume via ForkChildDone exactly once, only after all 3 \
5450 children have completed"
5451 );
5452
5453 for child in (parent + 1)..=(parent + 3) {
5454 assert!(
5455 !engine.test_process_exists(child),
5456 "fork child {child} must be unreachable via the process table once its \
5457 Suspend::Done has been processed and the JoinAll it belongs to has \
5458 been satisfied -- SC-062's own literal 'the child's slot is reclaimed'"
5459 );
5460 }
5461 }
5462
5463 /// SC-115's own literal acceptance ("a process can be spawned via fork
5464 /// after sim_init has run, with its own scheduled entry in the
5465 /// ready-set machinery") combined with this task's own `JoinKind::None`
5466 /// resolution (this module's doc comment, deviation 3): the parent's
5467 /// own next segment must run *synchronously*, inside the very same
5468 /// `apply_suspend` call that registered the fork's children -- no
5469 /// second `dispatch_ready_set`/`run_until` pass needed -- while the
5470 /// spawned child is left genuinely live (not dispatched, not
5471 /// reclaimed) at that exact point, proving `JoinKind::None` neither
5472 /// blocks on nor otherwise touches its child.
5473 #[test]
5474 fn sc115_join_none_children_run_detached_while_the_parent_proceeds_without_waiting() {
5475 struct ParentProcess {
5476 log: Arc<StdMutex<Vec<&'static str>>>,
5477 }
5478 impl CompiledProcess for ParentProcess {
5479 fn resume(
5480 &mut self,
5481 _state: SimHandle,
5482 arg: ResumeArg,
5483 _ops: &dyn SchedulerOps,
5484 ) -> Suspend {
5485 match arg {
5486 ResumeArg::Start => {
5487 self.log.lock().unwrap().push("forked");
5488 Suspend::ForkJoin(
5489 JoinKind::None,
5490 vec![SpawnSpec {
5491 kernel: 0,
5492 entry_pc: 0,
5493 parent: 0,
5494 disable_name: Vec::new(),
5495 }],
5496 )
5497 }
5498 ResumeArg::TimeElapsed => {
5499 self.log.lock().unwrap().push("proceeded");
5500 Suspend::Done
5501 }
5502 other => panic!(
5503 "ParentProcess must only ever be resumed with Start or \
5504 TimeElapsed (JoinKind::None's own chosen continuation arg, \
5505 see this module's doc comment), got {other:?}"
5506 ),
5507 }
5508 }
5509 }
5510
5511 let log = Arc::new(StdMutex::new(Vec::new()));
5512 let mut engine = new_engine();
5513 let parent = engine.register_process(
5514 Box::new(ParentProcess { log: log.clone() }),
5515 ProcessKind::Initial,
5516 None,
5517 0,
5518 );
5519 assert_eq!(parent, 0, "test assumes a fresh engine's first id is 0");
5520 let child = parent + 1;
5521
5522 // `register_process` already queued the parent's own Start
5523 // activation onto Region::Active. Dispatching exactly that one
5524 // ready-set (a single `dispatch_ready_set` call) must be enough to
5525 // see BOTH "forked" and "proceeded" in the log -- proving
5526 // JoinKind::None's continuation happened inside this same call,
5527 // never requiring a second dispatch pass the way waiting for a
5528 // real ForkChildDone would.
5529 let ready = engine.drain_and_sort(Region::Active);
5530 engine.dispatch_ready_set(Region::Active, ready);
5531
5532 assert_eq!(
5533 log.lock().unwrap().as_slice(),
5534 &["forked", "proceeded"],
5535 "JoinKind::None must resume the parent's own next segment synchronously, \
5536 within the very same apply_suspend call that registered its child -- \
5537 never leaving it suspended waiting for a ForkChildDone that will never \
5538 come (the pre-Task-7 bug this row closes)"
5539 );
5540 assert!(
5541 engine.test_process_exists(child),
5542 "the spawned child must still be a live, registered process at this exact \
5543 point -- it was only just enqueued (Region::Active, ResumeArg::Start) by \
5544 this same dispatch_ready_set call, never itself dispatched by it, so \
5545 JoinKind::None resolving the parent must not have touched the child at \
5546 all (neither reclaiming it nor otherwise depending on its completion)"
5547 );
5548 }
5549
5550 /// R-run Task 17 review fix (r1) -- blocking finding: `apply_suspend`'s
5551 /// `Suspend::ForkJoin` arm's `mint_child` closure ignores its own
5552 /// `SpawnSpec` argument entirely and unconditionally builds an
5553 /// `UnmintedForkChild` (this module's own doc comment deviation 3;
5554 /// `vj-codegen`'s R-run Task 17 now produces real, distinct
5555 /// `SpawnSpec::kernel` indices per branch, but nothing in `Engine`
5556 /// consumes that field yet -- Task 21 remains chartered to fix that).
5557 /// No prior test pinned this AS a tested property -- every existing
5558 /// `Suspend::ForkJoin` test above happens to use small/repeating
5559 /// `kernel` values (0, 1, 2) that a partial, buggy future minter could
5560 /// special-case and still pass. This test spawns children with
5561 /// deliberately large, mutually distinct `kernel` indices (a shape no
5562 /// real per-program kernel table plausibly has entries for yet) and
5563 /// proves every single one still completes via a bare `Suspend::Done`
5564 /// on its own very first `Start` dispatch -- the literal, observable
5565 /// signature of `UnmintedForkChild` (a real compiled branch kernel
5566 /// could legitimately suspend, loop, or otherwise not complete in one
5567 /// dispatch). Fails loudly the instant a future minter starts
5568 /// consulting `SpawnSpec::kernel` for any subset of values without this
5569 /// test being updated alongside it.
5570 #[test]
5571 fn apply_suspend_fork_join_still_mints_unmintedforkchild_regardless_of_spawnspec_kernel() {
5572 struct ParentProcess;
5573 impl CompiledProcess for ParentProcess {
5574 fn resume(
5575 &mut self,
5576 _state: SimHandle,
5577 arg: ResumeArg,
5578 _ops: &dyn SchedulerOps,
5579 ) -> Suspend {
5580 match arg {
5581 ResumeArg::Start => Suspend::ForkJoin(
5582 JoinKind::All,
5583 vec![
5584 SpawnSpec {
5585 kernel: 424_242,
5586 entry_pc: 0,
5587 parent: 0,
5588 disable_name: Vec::new(),
5589 },
5590 SpawnSpec {
5591 kernel: 7,
5592 entry_pc: 3,
5593 parent: 0,
5594 disable_name: Vec::new(),
5595 },
5596 SpawnSpec {
5597 kernel: u32::MAX,
5598 entry_pc: 0,
5599 parent: 0,
5600 disable_name: Vec::new(),
5601 },
5602 ],
5603 ),
5604 // Every child completes in one dispatch (proven below),
5605 // so the join is satisfied by the time this fires; the
5606 // real assertions are about the children, not this arm.
5607 ResumeArg::ForkChildDone { .. } => Suspend::Done,
5608 other => panic!(
5609 "ParentProcess must only ever see Start or ForkChildDone, got {other:?}"
5610 ),
5611 }
5612 }
5613 }
5614
5615 let mut engine = new_engine();
5616 let parent =
5617 engine.register_process(Box::new(ParentProcess), ProcessKind::Initial, None, 0);
5618 assert_eq!(parent, 0, "test assumes a fresh engine's first id is 0");
5619
5620 // Dispatch the parent's own Start -- this is the call that runs
5621 // apply_suspend's Suspend::ForkJoin arm and mints the 3 children.
5622 let ready = engine.drain_and_sort(Region::Active);
5623 engine.dispatch_ready_set(Region::Active, ready);
5624
5625 let children = [parent + 1, parent + 2, parent + 3];
5626 for &child in &children {
5627 assert!(
5628 engine.test_process_exists(child),
5629 "child {child} must exist right after being minted, before its own first \
5630 dispatch"
5631 );
5632 }
5633
5634 // Dispatch every child's own queued Start activation in one pass.
5635 let ready = engine.drain_and_sort(Region::Active);
5636 assert_eq!(
5637 ready.len(),
5638 3,
5639 "exactly the 3 freshly-minted children's Start activations must be ready, got {}",
5640 ready.len()
5641 );
5642 engine.dispatch_ready_set(Region::Active, ready);
5643
5644 for &child in &children {
5645 assert!(
5646 !engine.test_process_exists(child),
5647 "child {child} (SpawnSpec::kernel ignored by today's mint_child) must have \
5648 completed via a bare Suspend::Done on its own very first Start dispatch and \
5649 been reclaimed -- exactly UnmintedForkChild's own documented behavior, \
5650 regardless of how large or distinct its own SpawnSpec::kernel index was. A \
5651 real per-branch compiled kernel is not guaranteed to complete in one dispatch \
5652 at all, so this failing is the signal that mint_child has started consulting \
5653 SpawnSpec::kernel for at least one of these values -- update this test (and, \
5654 per Task 21's own charter, the CLI-level fork/join gate) alongside that change."
5655 );
5656 }
5657 }
5658
5659 // --- Task 9 (A2-part2, CG-102): region_id-consuming partition-aware
5660 // dispatch ---------------------------------------------------------
5661
5662 /// `partition_of` genuinely reads `region_id`, not a hardcoded `0` --
5663 /// real modulo arithmetic against `partition_count`.
5664 #[test]
5665 fn partition_of_is_a_real_modulo_not_a_hardcoded_zero() {
5666 assert_eq!(partition_of(5, 4), PartitionId(1));
5667 assert_eq!(partition_of(6, 4), PartitionId(2));
5668 assert_eq!(partition_of(0, 4), PartitionId(0));
5669 }
5670
5671 /// v1's one real call site always passes `partition_count = 1`
5672 /// (ARCHITECTURE §4 item 1) -- pinned here as a property of the
5673 /// function itself, for several distinct `region_id` values, not an
5674 /// assumption left implicit at the call site.
5675 #[test]
5676 fn partition_of_is_the_documented_v1_degenerate_single_partition_value() {
5677 for region_id in [0, 3, 9] {
5678 assert_eq!(
5679 partition_of(region_id, 1),
5680 PartitionId(0),
5681 "region_id={region_id}: v1's real partition_count=1 call site must always \
5682 yield PartitionId(0), for any region_id"
5683 );
5684 }
5685 }
5686
5687 /// `partition_of` never divides by zero -- `partition_count == 0`
5688 /// degrades to the same single-partition behavior as `1`, rather than
5689 /// panicking.
5690 #[test]
5691 fn partition_of_with_zero_partition_count_does_not_panic() {
5692 assert_eq!(partition_of(7, 0), PartitionId(0));
5693 }
5694
5695 /// CG-102's scheduler-side literal acceptance ("consumed by the
5696 /// scheduler for partition-aware dispatch"): register 3 processes with
5697 /// distinct `region_id`s (0, 3, 9) via the real `SchedulerControl::
5698 /// register_process` API, dispatch their initial-registration
5699 /// activations, and confirm the spy hook saw every one of their
5700 /// `ProcessId`s queried against exactly the `region_id` each was
5701 /// registered with -- proving the field flows from registration
5702 /// through to dispatch rather than being stored-but-never-read (the
5703 /// literal gap the plan's own "Verified live" review finding named),
5704 /// and would fail if `dispatch_ready_set` were refactored to default
5705 /// every lookup to `0` regardless of what was registered.
5706 #[test]
5707 fn dispatch_ready_set_looks_up_partition_of_using_each_readied_processs_own_stored_region_id() {
5708 let mut engine = new_engine();
5709 let p0 =
5710 engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
5711 let p3 =
5712 engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 3);
5713 let p9 =
5714 engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 9);
5715
5716 // Each `register_process` above already queued its own Start
5717 // activation into Region::Active -- drive exactly one dispatch pass
5718 // over all 3 so `dispatch_ready_set` genuinely runs its grouping
5719 // loop once, over all 3 activations together.
5720 let ready = engine.drain_and_sort(Region::Active);
5721 assert_eq!(
5722 ready.len(),
5723 3,
5724 "all 3 registrations' Start activations must be pending"
5725 );
5726 engine.dispatch_ready_set(Region::Active, ready);
5727
5728 let mut seen = engine.region_id_queries_seen().to_vec();
5729 seen.sort_unstable();
5730 let mut want = vec![(p0, 0u32), (p3, 3u32), (p9, 9u32)];
5731 want.sort_unstable();
5732 assert_eq!(
5733 seen, want,
5734 "dispatch_ready_set must query ProcessTable::region_id for every readied \
5735 process, and every value returned must exactly match what register_process \
5736 stored for it"
5737 );
5738 }
5739
5740 // --- Task 3 (B-sched, ledger SC-004, SC-005, SC-052, SC-053):
5741 // EventTerm::Repeat engine semantics + ##N/clocking-block runtime
5742 // resolution ---
5743
5744 /// SC-052's own literal acceptance, verified against the production
5745 /// `Engine` dispatch loop (not a hand-simulated one): a
5746 /// `repeat(3)@(posedge clk)`-shaped mock process must resume only after
5747 /// the 3rd matching posedge, never the 1st or 2nd, and exactly once
5748 /// after that (not once per remaining posedge -- which would mean a
5749 /// `RepeatOutcome::StillWaiting` firing wrongly fell through to
5750 /// enqueue).
5751 #[test]
5752 fn engine_resumes_a_repeat_3_process_only_on_the_third_matching_edge_not_the_first() {
5753 struct Repeat3Waiter {
5754 cell: StateCellId,
5755 resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
5756 }
5757 impl CompiledProcess for Repeat3Waiter {
5758 fn resume(
5759 &mut self,
5760 _state: SimHandle,
5761 arg: ResumeArg,
5762 _ops: &dyn SchedulerOps,
5763 ) -> Suspend {
5764 self.resume_log.lock().unwrap().push(arg);
5765 match arg {
5766 ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
5767 terms: vec![EventTerm::Repeat {
5768 count: 3,
5769 inner: Box::new(EventTerm::Edge {
5770 cell: self.cell,
5771 edge: EdgeKind::Posedge,
5772 iff: None,
5773 }),
5774 }],
5775 ..Default::default()
5776 }),
5777 _ => Suspend::Done,
5778 }
5779 }
5780 }
5781
5782 let cell = StateCellId(200);
5783 let mut engine = Engine::new(
5784 Box::new(TestArena::seeded(cell, narrow(0))),
5785 Box::new(TestIoSink::default()),
5786 );
5787 let log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
5788 engine.register_process(
5789 Box::new(Repeat3Waiter {
5790 cell,
5791 resume_log: log.clone(),
5792 }),
5793 ProcessKind::Initial,
5794 None,
5795 0,
5796 );
5797
5798 // Dispatch Start: installs the Repeat{count:3, inner: Edge} dynamic
5799 // wait.
5800 let ready = engine.drain_and_sort(Region::Active);
5801 engine.dispatch_ready_set(Region::Active, ready);
5802 assert_eq!(
5803 log.lock().unwrap().len(),
5804 1,
5805 "only Start's own resume() call must have happened so far"
5806 );
5807
5808 let write = |engine: &Engine, value: FourStateValue| {
5809 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
5810 ops.commit_blocking_write(cell, value);
5811 };
5812
5813 // Posedge 1 (0 -> 1): StillWaiting -- must NOT enqueue/resume.
5814 write(&engine, narrow(1));
5815 let ready = engine.drain_and_sort(Region::Active);
5816 engine.dispatch_ready_set(Region::Active, ready);
5817 assert_eq!(
5818 log.lock().unwrap().len(),
5819 1,
5820 "the 1st matching posedge must not resume a repeat(3) waiter"
5821 );
5822
5823 // Negedge (1 -> 0): does not match a Posedge-qualified dynamic entry
5824 // at all -- irrelevant filler, mirroring an SV clock's real toggle
5825 // pattern.
5826 write(&engine, narrow(0));
5827
5828 // Posedge 2 (0 -> 1): StillWaiting again -- must still NOT resume.
5829 write(&engine, narrow(1));
5830 let ready = engine.drain_and_sort(Region::Active);
5831 engine.dispatch_ready_set(Region::Active, ready);
5832 assert_eq!(
5833 log.lock().unwrap().len(),
5834 1,
5835 "the 2nd matching posedge must not resume a repeat(3) waiter either"
5836 );
5837
5838 write(&engine, narrow(0));
5839
5840 // Posedge 3 (0 -> 1): Satisfied -- must resume exactly now.
5841 write(&engine, narrow(1));
5842 let ready = engine.drain_and_sort(Region::Active);
5843 engine.dispatch_ready_set(Region::Active, ready);
5844 {
5845 let log = log.lock().unwrap();
5846 assert_eq!(
5847 log.len(),
5848 2,
5849 "the 3rd matching posedge must resume the waiter exactly once"
5850 );
5851 assert!(
5852 matches!(log[1], ResumeArg::EventFired { .. }),
5853 "the resuming call's ResumeArg must be EventFired, got {:?}",
5854 log[1]
5855 );
5856 }
5857 assert_eq!(
5858 engine.test_sensitivity_entry_count(),
5859 0,
5860 "the dynamic entry (and its RepeatCounters row) must be gone once satisfied"
5861 );
5862
5863 // Two more posedges after the process has already resumed and
5864 // returned Suspend::Done (no re-arm): must produce no further
5865 // resume() calls at all -- proves a StillWaiting firing never falls
5866 // through to enqueue, and that a satisfied repeat's row is not
5867 // somehow still alive to double-fire.
5868 write(&engine, narrow(0));
5869 write(&engine, narrow(1));
5870 write(&engine, narrow(0));
5871 write(&engine, narrow(1));
5872 let ready = engine.drain_and_sort(Region::Active);
5873 engine.dispatch_ready_set(Region::Active, ready);
5874 assert_eq!(
5875 log.lock().unwrap().len(),
5876 2,
5877 "no resume() call must happen for any posedge after the repeat(3) waiter \
5878 has already resumed and completed -- exactly once total after the 3rd \
5879 posedge, never once per remaining posedge"
5880 );
5881 }
5882
5883 /// SC-053's own differentiating acceptance clause: "the same compiled
5884 /// `##N` delay produces different absolute tick timing when its
5885 /// associated clock's period changes dynamically at runtime" -- proven
5886 /// here at the scheduler level by driving a deliberately IRREGULAR
5887 /// (non-periodic) clock through a `repeat(2)@(posedge clk)`-shaped mock
5888 /// process and asserting it resumes at the clock's real 2nd posedge tick
5889 /// (12), never a precomputed `3 + 2*period`-style absolute tick a wrong
5890 /// "optimize by caching a tick offset" implementation would produce.
5891 #[test]
5892 fn sc053_repeat_based_wait_re_reads_the_clocks_live_period_not_a_cached_tick_offset() {
5893 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
5894 enum LogTag {
5895 Start,
5896 EventFired,
5897 }
5898
5899 struct RepeatWaiter {
5900 cell: StateCellId,
5901 log: Arc<StdMutex<Vec<(LogTag, SchedTime)>>>,
5902 }
5903 impl CompiledProcess for RepeatWaiter {
5904 fn resume(
5905 &mut self,
5906 _state: SimHandle,
5907 arg: ResumeArg,
5908 ops: &dyn SchedulerOps,
5909 ) -> Suspend {
5910 match arg {
5911 ResumeArg::Start => {
5912 self.log.lock().unwrap().push((LogTag::Start, ops.now()));
5913 Suspend::WaitEvents(EventWaitSet {
5914 terms: vec![EventTerm::Repeat {
5915 count: 2,
5916 inner: Box::new(EventTerm::Edge {
5917 cell: self.cell,
5918 edge: EdgeKind::Posedge,
5919 iff: None,
5920 }),
5921 }],
5922 ..Default::default()
5923 })
5924 }
5925 ResumeArg::EventFired { .. } => {
5926 self.log
5927 .lock()
5928 .unwrap()
5929 .push((LogTag::EventFired, ops.now()));
5930 Suspend::Done
5931 }
5932 other => panic!("RepeatWaiter must only see Start/EventFired, got {other:?}"),
5933 }
5934 }
5935 }
5936
5937 // Irregular clock: a real signal (not a "##N" literal) whose edges
5938 // land at ticks 3 (posedge), 5 (negedge, irrelevant filler), and 12
5939 // (posedge) -- deliberately never landing on a fixed period, so any
5940 // implementation that tried to precompute
5941 // `first_posedge_tick + (count - 1) * observed_period` would land on
5942 // a wrong tick (3 + 1*2 = 5, which is a negedge, not even a 2nd
5943 // posedge) instead of the real 2nd posedge at 12.
5944 struct IrregularClockWriter {
5945 cell: StateCellId,
5946 step: u32,
5947 }
5948 impl CompiledProcess for IrregularClockWriter {
5949 fn resume(
5950 &mut self,
5951 _state: SimHandle,
5952 arg: ResumeArg,
5953 ops: &dyn SchedulerOps,
5954 ) -> Suspend {
5955 match (self.step, arg) {
5956 (0, ResumeArg::Start) => {
5957 self.step = 1;
5958 Suspend::Delay(3) // fires at tick 3
5959 }
5960 (1, ResumeArg::TimeElapsed) => {
5961 ops.commit_blocking_write(self.cell, narrow(1)); // posedge @ 3
5962 self.step = 2;
5963 Suspend::Delay(2) // fires at tick 5
5964 }
5965 (2, ResumeArg::TimeElapsed) => {
5966 ops.commit_blocking_write(self.cell, narrow(0)); // negedge @ 5
5967 self.step = 3;
5968 Suspend::Delay(7) // fires at tick 12
5969 }
5970 (3, ResumeArg::TimeElapsed) => {
5971 ops.commit_blocking_write(self.cell, narrow(1)); // posedge @ 12
5972 self.step = 4;
5973 Suspend::Done
5974 }
5975 (step, other) => {
5976 panic!("IrregularClockWriter unexpected (step={step}, arg={other:?})")
5977 }
5978 }
5979 }
5980 }
5981
5982 let cell = StateCellId(201);
5983 let mut engine = Engine::new(
5984 Box::new(TestArena::seeded(cell, narrow(0))),
5985 Box::new(TestIoSink::default()),
5986 );
5987 let log: Arc<StdMutex<Vec<(LogTag, SchedTime)>>> = Arc::new(StdMutex::new(Vec::new()));
5988 engine.register_process(
5989 Box::new(RepeatWaiter {
5990 cell,
5991 log: log.clone(),
5992 }),
5993 ProcessKind::Initial,
5994 None,
5995 0,
5996 );
5997 engine.register_process(
5998 Box::new(IrregularClockWriter { cell, step: 0 }),
5999 ProcessKind::Initial,
6000 None,
6001 0,
6002 );
6003
6004 let result = engine.run_until(None);
6005 assert!(matches!(result, RunResult::Completed));
6006
6007 let log = log.lock().unwrap();
6008 assert_eq!(
6009 *log,
6010 vec![
6011 (LogTag::Start, SchedTime { ticks: 0 }),
6012 (LogTag::EventFired, SchedTime { ticks: 12 }),
6013 ],
6014 "the repeat(2) waiter must resume exactly at tick 12 (the clock's real 2nd \
6015 posedge), never a precomputed offset from a cached period"
6016 );
6017 }
6018
6019 // --- Task 4 (B-sched, ledger SC-090, SC-123, SC-020 scheduler-owned
6020 // half): Postponed/Reactive region-boundary placement: region
6021 // callbacks + `1step` ---
6022
6023 /// SC-090's own literal acceptance: a `$strobe`/`$monitor`-shaped
6024 /// `Region::Postponed` callback sampling a cell written twice within
6025 /// one timestep's delta loop must read only the FINAL, post-delta-
6026 /// convergence value, and must fire exactly once for that timestep --
6027 /// never once per intermediate write (which would mean it ran
6028 /// interleaved with the delta loop, not strictly after it converged).
6029 #[test]
6030 fn postponed_callback_fires_exactly_once_and_only_after_delta_convergence() {
6031 struct TwiceWritingProcess {
6032 cell: StateCellId,
6033 }
6034 impl CompiledProcess for TwiceWritingProcess {
6035 fn resume(
6036 &mut self,
6037 _state: SimHandle,
6038 arg: ResumeArg,
6039 ops: &dyn SchedulerOps,
6040 ) -> Suspend {
6041 match arg {
6042 ResumeArg::Start => {
6043 ops.commit_blocking_write(self.cell, narrow(0));
6044 Suspend::Delay(0)
6045 }
6046 ResumeArg::TimeElapsed => {
6047 ops.commit_blocking_write(self.cell, narrow(1));
6048 Suspend::Done
6049 }
6050 other => panic!("TwiceWritingProcess unexpected arg {other:?}"),
6051 }
6052 }
6053 }
6054
6055 struct ReadCellCallback {
6056 cell: StateCellId,
6057 log: Arc<StdMutex<Vec<FourStateValue>>>,
6058 }
6059 impl RegionCallback for ReadCellCallback {
6060 fn invoke(&mut self, ctx: &dyn SchedulerCtx) {
6061 self.log.lock().unwrap().push(ctx.read(self.cell));
6062 }
6063 }
6064
6065 let cell = StateCellId(300);
6066 let mut engine = Engine::new(
6067 Box::new(TestArena::seeded(cell, narrow(9))),
6068 Box::new(TestIoSink::default()),
6069 );
6070 let seen: Arc<StdMutex<Vec<FourStateValue>>> = Arc::new(StdMutex::new(Vec::new()));
6071 {
6072 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6073 ops.register_region_callback(
6074 Region::Postponed,
6075 engine.now(),
6076 Box::new(ReadCellCallback {
6077 cell,
6078 log: seen.clone(),
6079 }),
6080 );
6081 }
6082 engine.register_process(
6083 Box::new(TwiceWritingProcess { cell }),
6084 ProcessKind::Initial,
6085 None,
6086 0,
6087 );
6088
6089 let result = engine.run_until(None);
6090 assert!(matches!(result, RunResult::Completed));
6091
6092 let recorded = seen.lock().unwrap();
6093 assert_eq!(
6094 recorded.len(),
6095 1,
6096 "the Postponed callback must fire exactly once for the one timestep this \
6097 run has, not once per intermediate write inside the delta loop"
6098 );
6099 assert!(
6100 matches!(recorded[0], FourStateValue::Narrow(1)),
6101 "must read the FINAL, post-delta-convergence value (1), never the \
6102 intermediate write (0) or the pre-run seed (9); got {:?}",
6103 recorded[0]
6104 );
6105 }
6106
6107 /// Region ordering plus registration-order-per-region: a `Region::
6108 /// Reactive` callback and a `Region::Postponed` callback registered for
6109 /// the same `now` must both fire exactly once, Reactive strictly before
6110 /// Postponed (the same region ordering `run_until` already enforces for
6111 /// ordinary process dispatch), each observing the same final settled
6112 /// value -- proving the two regions' callback drains don't share state
6113 /// that lets one suppress or corrupt the other.
6114 #[test]
6115 fn reactive_and_postponed_callbacks_at_the_same_time_do_not_interfere() {
6116 struct OneShotWriter {
6117 cell: StateCellId,
6118 }
6119 impl CompiledProcess for OneShotWriter {
6120 fn resume(
6121 &mut self,
6122 _state: SimHandle,
6123 _arg: ResumeArg,
6124 ops: &dyn SchedulerOps,
6125 ) -> Suspend {
6126 ops.commit_blocking_write(self.cell, narrow(7));
6127 Suspend::Done
6128 }
6129 }
6130
6131 struct TaggedReadCallback {
6132 tag: &'static str,
6133 cell: StateCellId,
6134 log: Arc<StdMutex<Vec<(&'static str, FourStateValue)>>>,
6135 }
6136 impl RegionCallback for TaggedReadCallback {
6137 fn invoke(&mut self, ctx: &dyn SchedulerCtx) {
6138 self.log
6139 .lock()
6140 .unwrap()
6141 .push((self.tag, ctx.read(self.cell)));
6142 }
6143 }
6144
6145 let cell = StateCellId(301);
6146 let mut engine = Engine::new(
6147 Box::new(TestArena::seeded(cell, narrow(0))),
6148 Box::new(TestIoSink::default()),
6149 );
6150 let log: Arc<StdMutex<Vec<(&'static str, FourStateValue)>>> =
6151 Arc::new(StdMutex::new(Vec::new()));
6152 {
6153 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6154 ops.register_region_callback(
6155 Region::Reactive,
6156 engine.now(),
6157 Box::new(TaggedReadCallback {
6158 tag: "Reactive",
6159 cell,
6160 log: log.clone(),
6161 }),
6162 );
6163 ops.register_region_callback(
6164 Region::Postponed,
6165 engine.now(),
6166 Box::new(TaggedReadCallback {
6167 tag: "Postponed",
6168 cell,
6169 log: log.clone(),
6170 }),
6171 );
6172 }
6173 engine.register_process(
6174 Box::new(OneShotWriter { cell }),
6175 ProcessKind::Initial,
6176 None,
6177 0,
6178 );
6179
6180 let result = engine.run_until(None);
6181 assert!(matches!(result, RunResult::Completed));
6182
6183 let recorded = log.lock().unwrap();
6184 assert_eq!(
6185 recorded.len(),
6186 2,
6187 "both callbacks must fire exactly once each"
6188 );
6189 assert_eq!(
6190 recorded[0].0, "Reactive",
6191 "Reactive's own callback must fire before Postponed's"
6192 );
6193 assert_eq!(recorded[1].0, "Postponed");
6194 assert!(matches!(recorded[0].1, FourStateValue::Narrow(7)));
6195 assert!(matches!(recorded[1].1, FourStateValue::Narrow(7)));
6196 }
6197
6198 /// sva T2 (§1.4): `Region::Observed` is drained (via
6199 /// `drain_observed_and_check_settled`) strictly before `Region::
6200 /// Reactive`'s own dispatch/callback drain, within the SAME timestep --
6201 /// the reference algorithm's own stratified-region ordering (Clause
6202 /// 4.5), not merely "eventually, on some later timestep". Registers one
6203 /// tagged callback at each region (no process at all is needed: with
6204 /// nothing registered, Active/Inactive/Nba settle on `run_until`'s very
6205 /// first delta-loop pass, which is exactly the "genuinely idle" point
6206 /// `drain_observed_and_check_settled` is pinned to).
6207 #[test]
6208 fn observed_region_maturation_fires_before_reactive_same_timestep() {
6209 struct TagCallback {
6210 tag: &'static str,
6211 log: Arc<StdMutex<Vec<&'static str>>>,
6212 }
6213 impl RegionCallback for TagCallback {
6214 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6215 self.log.lock().unwrap().push(self.tag);
6216 }
6217 }
6218
6219 let mut engine = new_engine();
6220 let log: Arc<StdMutex<Vec<&'static str>>> = Arc::new(StdMutex::new(Vec::new()));
6221 {
6222 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6223 ops.register_region_callback(
6224 Region::Reactive,
6225 engine.now(),
6226 Box::new(TagCallback {
6227 tag: "Reactive",
6228 log: log.clone(),
6229 }),
6230 );
6231 ops.register_region_callback(
6232 Region::Observed,
6233 engine.now(),
6234 Box::new(TagCallback {
6235 tag: "Observed",
6236 log: log.clone(),
6237 }),
6238 );
6239 }
6240
6241 let result = engine.run_until(None);
6242 assert!(matches!(result, RunResult::Completed));
6243
6244 let recorded = log.lock().unwrap();
6245 assert_eq!(
6246 *recorded,
6247 vec!["Observed", "Reactive"],
6248 "Observed must mature before Reactive within the same settled timestep"
6249 );
6250 }
6251
6252 /// sva T2 (§1.4, review fix, blocking finding 2): a matured report an
6253 /// Observed callback schedules into `Region::Reactive` mid-drain (via
6254 /// `SchedulerOps::register_region_callback`, exactly as `sched::
6255 /// maturation::report` will for a real SVA pass/fail action, §1.7)
6256 /// must already be sitting in `region_callbacks` by the time the
6257 /// single-stepping debugger's OWN NEXT `step_region()` call reaches
6258 /// `Region::Reactive` -- not delayed to a later timestep, and not
6259 /// fired early from inside the Observed drain itself.
6260 #[test]
6261 fn observed_engine_step_result_visible_by_next_reactive_step() {
6262 struct TagCallback {
6263 tag: &'static str,
6264 log: Arc<StdMutex<Vec<&'static str>>>,
6265 }
6266 impl RegionCallback for TagCallback {
6267 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6268 self.log.lock().unwrap().push(self.tag);
6269 }
6270 }
6271
6272 struct ObservedReportsIntoReactive {
6273 log: Arc<StdMutex<Vec<&'static str>>>,
6274 }
6275 impl RegionCallback for ObservedReportsIntoReactive {
6276 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6277 // Stands in for a matured SVA thread's `sv_rt_sva_report_
6278 // pass` (§1.7): the Observed-region engine step schedules a
6279 // Reactive action from inside its own callback, exactly as
6280 // `sched::maturation::report` does for every report kind.
6281 let log = self.log.clone();
6282 crate::sched::with_current_ops(move |ops| {
6283 ops.register_region_callback(
6284 Region::Reactive,
6285 ops.now(),
6286 Box::new(TagCallback {
6287 tag: "matured",
6288 log: log.clone(),
6289 }),
6290 );
6291 });
6292 }
6293 }
6294
6295 let mut engine = new_engine();
6296 let log: Arc<StdMutex<Vec<&'static str>>> = Arc::new(StdMutex::new(Vec::new()));
6297 {
6298 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6299 ops.register_region_callback(
6300 Region::Observed,
6301 engine.now(),
6302 Box::new(ObservedReportsIntoReactive { log: log.clone() }),
6303 );
6304 }
6305
6306 assert_eq!(engine.current_region(), Region::Active);
6307 engine.step_region(); // Active -> Inactive
6308 assert_eq!(engine.current_region(), Region::Inactive);
6309 engine.step_region(); // Inactive -> Nba
6310 assert_eq!(engine.current_region(), Region::Nba);
6311 engine.step_region(); // Nba settles, drains Observed (schedules
6312 // "matured" into Reactive), -> Reactive.
6313 assert_eq!(engine.current_region(), Region::Reactive);
6314 assert!(
6315 log.lock().unwrap().is_empty(),
6316 "the matured report must not fire before Reactive's own step runs"
6317 );
6318
6319 engine.step_region(); // Reactive drains region callbacks -> Postponed.
6320 assert_eq!(engine.current_region(), Region::Postponed);
6321 assert_eq!(
6322 *log.lock().unwrap(),
6323 vec!["matured"],
6324 "the report registered mid-Observed-drain must already be visible to the very \
6325 next Reactive step"
6326 );
6327 }
6328
6329 /// SC-123's pinned `1step` placement: a process suspended via `1step`
6330 /// timing registers itself as a `Region::Postponed` callback at the
6331 /// current `now` (this test's own mock process does so directly via
6332 /// `ops.register_region_callback`, standing in for the codegen
6333 /// lowering no track emits yet this wave -- see `region_callback`'s
6334 /// own module doc comment). Its continuation must run strictly after
6335 /// every ORDINARY `Region::Postponed` callback registered before it,
6336 /// never interleaved before them -- the exact placement a wrongly-
6337 /// modeled `Suspend::Delay(0)` (which lands in `Region::Inactive`,
6338 /// strictly *before* Postponed even runs) could never produce.
6339 #[test]
6340 fn sc123_1step_continuation_runs_after_every_ordinary_postponed_callback_at_the_same_tick() {
6341 struct TagCallback {
6342 tag: &'static str,
6343 log: Arc<StdMutex<Vec<&'static str>>>,
6344 }
6345 impl RegionCallback for TagCallback {
6346 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6347 self.log.lock().unwrap().push(self.tag);
6348 }
6349 }
6350
6351 struct OneStepProcess {
6352 log: Arc<StdMutex<Vec<&'static str>>>,
6353 }
6354 impl CompiledProcess for OneStepProcess {
6355 fn resume(
6356 &mut self,
6357 _state: SimHandle,
6358 arg: ResumeArg,
6359 ops: &dyn SchedulerOps,
6360 ) -> Suspend {
6361 match arg {
6362 ResumeArg::Start => Suspend::Delay(0),
6363 ResumeArg::TimeElapsed => {
6364 // Pinned placement (region_callback's own module
6365 // doc): register as a Postponed callback at the
6366 // current tick, rather than resuming again via a
6367 // second Suspend::Delay(0) (which would land back
6368 // in Inactive, the wrong side of Postponed).
6369 ops.register_region_callback(
6370 Region::Postponed,
6371 ops.now(),
6372 Box::new(TagCallback {
6373 tag: "1step",
6374 log: self.log.clone(),
6375 }),
6376 );
6377 Suspend::Done
6378 }
6379 other => panic!("OneStepProcess unexpected arg {other:?}"),
6380 }
6381 }
6382 }
6383
6384 let mut engine = new_engine();
6385 let log: Arc<StdMutex<Vec<&'static str>>> = Arc::new(StdMutex::new(Vec::new()));
6386 {
6387 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6388 ops.register_region_callback(
6389 Region::Postponed,
6390 engine.now(),
6391 Box::new(TagCallback {
6392 tag: "A",
6393 log: log.clone(),
6394 }),
6395 );
6396 ops.register_region_callback(
6397 Region::Postponed,
6398 engine.now(),
6399 Box::new(TagCallback {
6400 tag: "B",
6401 log: log.clone(),
6402 }),
6403 );
6404 }
6405 engine.register_process(
6406 Box::new(OneStepProcess { log: log.clone() }),
6407 ProcessKind::Initial,
6408 None,
6409 0,
6410 );
6411
6412 let result = engine.run_until(None);
6413 assert!(matches!(result, RunResult::Completed));
6414
6415 let recorded = log.lock().unwrap();
6416 assert_eq!(
6417 *recorded,
6418 vec!["A", "B", "1step"],
6419 "the 1step continuation must fire strictly after every ordinary \
6420 Postponed callback registered before it, never interleaved before them"
6421 );
6422 }
6423
6424 /// Review fix (Task 4 r1): `Engine::push_region_callback` must reject
6425 /// every region `Engine::drain_region_callbacks` never drains
6426 /// (`Active`/`Inactive`/`Nba`) with a typed
6427 /// `Err(RegionCallbackRegionError::NotDrained(region))`, never silently
6428 /// accept it into `region_callbacks` where it would sit forever and
6429 /// never fire (that was the bug: no error, no panic, no log). Only
6430 /// `Reactive`/`Postponed` -- the two regions that method's own doc
6431 /// comment names -- may `Ok(())`.
6432 #[test]
6433 fn push_region_callback_rejects_every_region_drain_region_callbacks_never_drains() {
6434 struct NeverInvoked;
6435 impl RegionCallback for NeverInvoked {
6436 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6437 panic!("must never be invoked -- the registration itself must be rejected");
6438 }
6439 }
6440
6441 let engine = new_engine();
6442
6443 for ®ion in &[Region::Active, Region::Inactive, Region::Nba] {
6444 let result = engine.push_region_callback(engine.now(), region, Box::new(NeverInvoked));
6445 assert_eq!(
6446 result,
6447 Err(RegionCallbackRegionError::NotDrained(region)),
6448 "{region:?} is never drained by drain_region_callbacks and must be rejected, \
6449 not silently accepted"
6450 );
6451 }
6452
6453 // `NeverInvoked` registrations accepted just above are still sitting
6454 // in `engine.region_callbacks`, undrained (this test never calls
6455 // `run_until`/`drain_region_callbacks`) -- dropping `engine` here
6456 // drops them without invoking, so `NeverInvoked::invoke`'s panic
6457 // never fires. That drain-ordering/exactly-once behavior is already
6458 // covered end-to-end by `postponed_callback_fires_exactly_once_and_
6459 // only_after_delta_convergence` and
6460 // `reactive_and_postponed_callbacks_at_the_same_time_do_not_
6461 // interfere`; this test's own scope is `push_region_callback`'s
6462 // accept/reject decision alone.
6463 for ®ion in &[Region::Reactive, Region::Postponed] {
6464 let result = engine.push_region_callback(engine.now(), region, Box::new(NeverInvoked));
6465 assert_eq!(
6466 result,
6467 Ok(()),
6468 "{region:?} IS drained by drain_region_callbacks and must be accepted"
6469 );
6470 }
6471 }
6472
6473 /// sva T2 (§1.4, review fix, blocking finding 2), Observed's half:
6474 /// `Region::Observed` joins `Reactive`/`Postponed` on the ACCEPTING
6475 /// side -- `drain_observed_and_check_settled` genuinely drains it now,
6476 /// so a registration for it must no longer be rejected.
6477 #[test]
6478 fn push_region_callback_accepts_observed() {
6479 struct NeverInvoked;
6480 impl RegionCallback for NeverInvoked {
6481 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6482 panic!("must never be invoked -- this test only checks the accept decision");
6483 }
6484 }
6485
6486 let engine = new_engine();
6487 let result =
6488 engine.push_region_callback(engine.now(), Region::Observed, Box::new(NeverInvoked));
6489 assert_eq!(
6490 result,
6491 Ok(()),
6492 "Region::Observed IS now drained (by drain_observed_and_check_settled) and must \
6493 be accepted"
6494 );
6495 }
6496
6497 /// sva T2 (§1.4, review fix, blocking finding 2): adding `Observed` to
6498 /// the accepting side must not loosen `Preponed`'s own rejection --
6499 /// `Preponed` stays a pure inline snapshot push, never a registered
6500 /// callback, even after `Observed` becomes real (the two Task 1/Task 2
6501 /// additions could easily have been conflated into one "any SVA
6502 /// region" acceptance by a careless edit; this proves they weren't).
6503 #[test]
6504 fn push_region_callback_still_rejects_preponed_after_observed_added() {
6505 struct NeverInvoked;
6506 impl RegionCallback for NeverInvoked {
6507 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6508 panic!("must never be invoked -- the registration itself must be rejected");
6509 }
6510 }
6511
6512 let engine = new_engine();
6513 let result =
6514 engine.push_region_callback(engine.now(), Region::Preponed, Box::new(NeverInvoked));
6515 assert_eq!(
6516 result,
6517 Err(RegionCallbackRegionError::NotDrained(Region::Preponed)),
6518 "Region::Preponed must still be rejected -- it is a pure inline snapshot push, \
6519 never a registered callback, regardless of Observed's own addition"
6520 );
6521 }
6522
6523 /// Review fix (Task 4 r1): the one production trait boundary,
6524 /// `EngineOps::register_region_callback` (`SchedulerOps`'s impl), must
6525 /// convert `RegionCallbackRegionError` into an immediate, loud panic --
6526 /// never a silent, forever-unfired registration -- because the trait
6527 /// method itself returns `()` and cannot propagate a `Result` (its
6528 /// signature is a cross-track contract many `#[cfg(test)]` mocks outside
6529 /// this crate also implement).
6530 #[test]
6531 #[should_panic(expected = "region must be Reactive, Postponed, or Observed")]
6532 fn register_region_callback_panics_loudly_for_an_undrained_region() {
6533 struct NeverInvoked;
6534 impl RegionCallback for NeverInvoked {
6535 fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6536 panic!("must never be invoked -- the registration itself must be rejected");
6537 }
6538 }
6539
6540 let engine = new_engine();
6541 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6542 ops.register_region_callback(Region::Active, engine.now(), Box::new(NeverInvoked));
6543 }
6544
6545 // --- Task 5 (B-sched, ledger SC-065, SC-066, SC-047, SC-048, SC-068):
6546 // EventTerm/EventWaitSet shape + static/dynamic sensitivity closure ---
6547
6548 /// SC-068's own literal acceptance, verified against the production
6549 /// `Engine` dispatch loop (not a hand-simulated one): `@(posedge clk iff
6550 /// en)` must not call `resume()` at all on a posedge where `en` reads
6551 /// false at wakeup-consideration time, and must resume it (with
6552 /// `ResumeArg::EventFired`) on the very next posedge where `en` reads
6553 /// true. `PredicateFn` is settled (`docs/CONTRACTS.md` D8) as a bare
6554 /// `extern "C" fn(SimHandle) -> bool`, produced per-callsite by codegen's
6555 /// `compile_iff_guard` -- it carries no `StateCellId` parameter of its
6556 /// own, so this mock predicate stands in for a compiled guard by reading
6557 /// a thread-local flag the test flips directly, exactly mirroring how a
6558 /// real compiled guard would read `en` through the (here, unused-by-the-
6559 /// mock) `SimHandle`. Fails if `EngineNotifySink::notify_change` ever
6560 /// enqueues/resumes without first consulting a `Some(iff)` row's
6561 /// predicate, or evaluates it backwards (blocking on true, allowing on
6562 /// false).
6563 #[test]
6564 fn engine_never_calls_resume_when_iff_is_false_but_does_on_the_next_true_edge() {
6565 thread_local! {
6566 static EN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
6567 }
6568 extern "C" fn iff_reads_en(_state: SimHandle) -> bool {
6569 EN.with(|c| c.get())
6570 }
6571
6572 struct IffWaiter {
6573 cell: StateCellId,
6574 resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6575 }
6576 impl CompiledProcess for IffWaiter {
6577 fn resume(
6578 &mut self,
6579 _state: SimHandle,
6580 arg: ResumeArg,
6581 _ops: &dyn SchedulerOps,
6582 ) -> Suspend {
6583 self.resume_log.lock().unwrap().push(arg);
6584 match arg {
6585 ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
6586 terms: vec![EventTerm::Edge {
6587 cell: self.cell,
6588 edge: EdgeKind::Posedge,
6589 iff: Some(crate::process::PredicateFn(iff_reads_en)),
6590 }],
6591 ..Default::default()
6592 }),
6593 _ => Suspend::Done,
6594 }
6595 }
6596 }
6597
6598 let cell = StateCellId(210);
6599 let mut engine = Engine::new(
6600 Box::new(TestArena::seeded(cell, narrow(0))),
6601 Box::new(TestIoSink::default()),
6602 );
6603 let log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6604 engine.register_process(
6605 Box::new(IffWaiter {
6606 cell,
6607 resume_log: log.clone(),
6608 }),
6609 ProcessKind::Initial,
6610 None,
6611 0,
6612 );
6613
6614 // Dispatch Start: installs the iff-guarded Edge dynamic wait.
6615 let ready = engine.drain_and_sort(Region::Active);
6616 engine.dispatch_ready_set(Region::Active, ready);
6617 assert_eq!(
6618 log.lock().unwrap().len(),
6619 1,
6620 "only Start's own resume() call must have happened so far"
6621 );
6622
6623 let write = |engine: &Engine, value: FourStateValue| {
6624 let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6625 ops.commit_blocking_write(cell, value);
6626 };
6627
6628 // en=0: a real posedge occurs, but the predicate reads false --
6629 // must NOT resume.
6630 EN.with(|c| c.set(false));
6631 write(&engine, narrow(1));
6632 let ready = engine.drain_and_sort(Region::Active);
6633 engine.dispatch_ready_set(Region::Active, ready);
6634 assert_eq!(
6635 log.lock().unwrap().len(),
6636 1,
6637 "a posedge with iff=false must not resume() the waiter at all"
6638 );
6639
6640 write(&engine, narrow(0));
6641
6642 // en=1: the next real posedge, predicate now reads true -- must
6643 // resume exactly once, with ResumeArg::EventFired.
6644 EN.with(|c| c.set(true));
6645 write(&engine, narrow(1));
6646 let ready = engine.drain_and_sort(Region::Active);
6647 engine.dispatch_ready_set(Region::Active, ready);
6648 {
6649 let log = log.lock().unwrap();
6650 assert_eq!(
6651 log.len(),
6652 2,
6653 "a posedge with iff=true must resume() the waiter exactly once"
6654 );
6655 assert!(
6656 matches!(log[1], ResumeArg::EventFired { .. }),
6657 "the resuming call's ResumeArg must be EventFired, got {:?}",
6658 log[1]
6659 );
6660 }
6661 assert_eq!(
6662 engine.test_sensitivity_entry_count(),
6663 0,
6664 "the dynamic entry must be gone once the waiter has resumed"
6665 );
6666 }
6667
6668 // --- Task 6 (B-sched, ledger SC-049, SC-050, SC-051, SC-054, SC-069):
6669 // SignalEventControl family construction + named-event trigger + e2e ---
6670
6671 /// SC-069's own literal acceptance, verified against the production
6672 /// `Engine`/`advance_time_to` dispatch path (not a hand-simulated one):
6673 /// a delayed named-event trigger (`->>#5 my_event;`'s scheduler-side
6674 /// shape -- `TimeWheel::arm_trigger` + `Engine::advance_time_to`'s
6675 /// `TimerAction::TriggerEvent` arm) must, once it fires, enqueue a
6676 /// process that is `@(my_event)`-waiting via `EventTerm::
6677 /// NamedEventTrigger` -- and, distinctly, must NOT create or resume any
6678 /// activation for "the triggering process" itself (there is deliberately
6679 /// no second, triggering process registered at all in this test, so any
6680 /// activation showing up in the post-fire ready set other than the
6681 /// waiter's own would prove a wrong implementation that conflated
6682 /// "trigger this event" with "resume whoever armed it" -- the exact
6683 /// regression this test's own plan text names). This is the real,
6684 /// production-loop version of `sched::wheel::tests::
6685 /// resume_and_trigger_entries_at_the_same_tick_both_fire_correctly`'s
6686 /// wheel-only proof, closing the fan-out half that lives in this module,
6687 /// not in `wheel.rs`.
6688 #[test]
6689 fn sc069_a_delayed_trigger_wakes_a_named_event_waiter_not_a_resumed_process() {
6690 struct NamedEventWaiter {
6691 ev: StateCellId,
6692 resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6693 }
6694 impl CompiledProcess for NamedEventWaiter {
6695 fn resume(
6696 &mut self,
6697 _state: SimHandle,
6698 arg: ResumeArg,
6699 _ops: &dyn SchedulerOps,
6700 ) -> Suspend {
6701 self.resume_log.lock().unwrap().push(arg);
6702 match arg {
6703 ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
6704 terms: vec![EventTerm::NamedEventTrigger(self.ev)],
6705 ..Default::default()
6706 }),
6707 _ => Suspend::Done,
6708 }
6709 }
6710 }
6711
6712 let ev = StateCellId(400);
6713 let mut engine = Engine::new(Box::new(TestArena::new()), Box::new(TestIoSink::default()));
6714 let log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6715 let waiter = engine.register_process(
6716 Box::new(NamedEventWaiter {
6717 ev,
6718 resume_log: log.clone(),
6719 }),
6720 ProcessKind::Initial,
6721 None,
6722 0,
6723 );
6724
6725 // Dispatch Start: installs the NamedEventTrigger dynamic wait.
6726 let ready = engine.drain_and_sort(Region::Active);
6727 engine.dispatch_ready_set(Region::Active, ready);
6728 assert_eq!(
6729 log.lock().unwrap().len(),
6730 1,
6731 "only Start's own resume() call must have happened so far"
6732 );
6733
6734 // Arm the delayed trigger directly on the wheel (this task's own
6735 // production entry point, `TimeWheel::arm_trigger`) and advance time
6736 // to it through the real `Engine::advance_time_to` -- never a
6737 // hand-simulated fan-out.
6738 engine.wheel.arm_trigger(10, ev);
6739 engine.advance_time_to(10);
6740
6741 let ready = engine.drain_and_sort(Region::Active);
6742 assert_eq!(
6743 ready.len(),
6744 1,
6745 "the fired trigger must enqueue exactly one activation -- the \
6746 waiter's -- never zero (dropped) and never more than one \
6747 (e.g. also resuming some non-existent 'triggering process')"
6748 );
6749 assert_eq!(
6750 ready[0].proc, waiter,
6751 "the one enqueued activation must be the NamedEventTrigger \
6752 waiter's own process id"
6753 );
6754 engine.dispatch_ready_set(Region::Active, ready);
6755 {
6756 let log = log.lock().unwrap();
6757 assert_eq!(
6758 log.len(),
6759 2,
6760 "the waiter must resume exactly once after the trigger fires"
6761 );
6762 assert!(
6763 matches!(log[1], ResumeArg::EventFired { .. }),
6764 "the resuming call's ResumeArg must be EventFired, got {:?}",
6765 log[1]
6766 );
6767 }
6768 assert_eq!(
6769 engine.test_sensitivity_entry_count(),
6770 0,
6771 "the dynamic NamedEventTrigger entry must be gone once the \
6772 waiter has resumed"
6773 );
6774 }
6775
6776 /// Companion to the wheel-only
6777 /// `resume_and_trigger_entries_at_the_same_tick_both_fire_correctly`:
6778 /// proves the SAME same-tick-FIFO guarantee holds through the full
6779 /// `Engine::advance_time_to` fan-out, not just at the raw
6780 /// `TimeWheel::advance_to` level -- a `Resume` entry (an ordinary
6781 /// `#delay`-suspended process) and a `TriggerEvent` entry armed at the
6782 /// same tick must both take effect: the delayed process resumes, and the
6783 /// named-event waiter is woken, from one `advance_time_to` call.
6784 #[test]
6785 fn engine_advance_time_to_fires_a_resume_and_a_trigger_armed_at_the_same_tick() {
6786 struct DoneOnResume {
6787 resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6788 }
6789 impl CompiledProcess for DoneOnResume {
6790 fn resume(
6791 &mut self,
6792 _state: SimHandle,
6793 arg: ResumeArg,
6794 _ops: &dyn SchedulerOps,
6795 ) -> Suspend {
6796 self.resume_log.lock().unwrap().push(arg);
6797 Suspend::Done
6798 }
6799 }
6800 struct NamedEventWaiter {
6801 ev: StateCellId,
6802 resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6803 }
6804 impl CompiledProcess for NamedEventWaiter {
6805 fn resume(
6806 &mut self,
6807 _state: SimHandle,
6808 arg: ResumeArg,
6809 _ops: &dyn SchedulerOps,
6810 ) -> Suspend {
6811 self.resume_log.lock().unwrap().push(arg);
6812 match arg {
6813 ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
6814 terms: vec![EventTerm::NamedEventTrigger(self.ev)],
6815 ..Default::default()
6816 }),
6817 _ => Suspend::Done,
6818 }
6819 }
6820 }
6821
6822 let ev = StateCellId(401);
6823 let mut engine = Engine::new(Box::new(TestArena::new()), Box::new(TestIoSink::default()));
6824 let resume_log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6825 let waiter_log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6826
6827 let resumed_proc = engine.register_process(
6828 Box::new(DoneOnResume {
6829 resume_log: resume_log.clone(),
6830 }),
6831 ProcessKind::Initial,
6832 None,
6833 0,
6834 );
6835 let waiter = engine.register_process(
6836 Box::new(NamedEventWaiter {
6837 ev,
6838 resume_log: waiter_log.clone(),
6839 }),
6840 ProcessKind::Initial,
6841 None,
6842 0,
6843 );
6844
6845 // Dispatch both Start activations: `resumed_proc` returns
6846 // Suspend::Done (its own Start resume already happened, nothing more
6847 // to arm for it directly -- this test arms its own `Resume` entry on
6848 // the wheel below, standing in for a `Suspend::Delay`-suspended
6849 // continuation), `waiter` installs its NamedEventTrigger wait.
6850 let ready = engine.drain_and_sort(Region::Active);
6851 engine.dispatch_ready_set(Region::Active, ready);
6852 resume_log.lock().unwrap().clear();
6853
6854 let act = PendingActivation {
6855 id: 9_999,
6856 proc: resumed_proc,
6857 arg: ResumeArg::TimeElapsed,
6858 reconv_iter: 0,
6859 logical_order: (SchedTime { ticks: 0 }, Region::Active, 0),
6860 };
6861 engine.wheel.arm(
6862 10,
6863 TimerEntry {
6864 action: TimerAction::Resume(act, Region::Active),
6865 },
6866 );
6867 engine.wheel.arm_trigger(10, ev);
6868 engine.advance_time_to(10);
6869
6870 let ready = engine.drain_and_sort(Region::Active);
6871 let mut procs: Vec<ProcessId> = ready.iter().map(|a| a.proc).collect();
6872 procs.sort_unstable();
6873 let mut want = vec![resumed_proc, waiter];
6874 want.sort_unstable();
6875 assert_eq!(
6876 procs, want,
6877 "both the Resume entry's own process and the TriggerEvent's \
6878 waiter must be enqueued from a single advance_time_to call \
6879 covering the same tick"
6880 );
6881 }
6882
6883 // --- Task 9 (B-sched wave, ledger SC-125) ---
6884
6885 /// A `resume()` that always returns `Suspend::Done` and, if ever called
6886 /// a second time, panics -- standing in for "a process that must never
6887 /// be resumed again," the sanctioned negative-proof shape for
6888 /// `disable fork;`'s own "terminates without ever resuming its
6889 /// descendants" acceptance.
6890 struct NeverResumedTwice(std::sync::atomic::AtomicUsize);
6891 impl NeverResumedTwice {
6892 fn new() -> Self {
6893 NeverResumedTwice(std::sync::atomic::AtomicUsize::new(0))
6894 }
6895 }
6896 impl CompiledProcess for NeverResumedTwice {
6897 fn resume(&mut self, _s: SimHandle, _arg: ResumeArg, _ops: &dyn SchedulerOps) -> Suspend {
6898 let prior = self.0.fetch_add(1, Ordering::SeqCst);
6899 assert_eq!(
6900 prior, 0,
6901 "a fork descendant terminated by disable_fork must never be resumed \
6902 at all, let alone more than once"
6903 );
6904 Suspend::Done
6905 }
6906 }
6907
6908 fn spawn_from(parent: ProcessId) -> SpawnSpec {
6909 SpawnSpec {
6910 kernel: 0,
6911 entry_pc: 0,
6912 parent,
6913 disable_name: Vec::new(),
6914 }
6915 }
6916
6917 /// SC-125's own literal `disable fork;` acceptance: a parent with 2 live
6918 /// children, one of which (`child_a`) itself has 1 live grandchild (a
6919 /// nested fork) -- the parent's own mock calls `ops.disable_fork(self)`
6920 /// mid-`resume()` and, in that same call, returns `Suspend::Done` (never
6921 /// a suspending variant, proving the call itself does not suspend the
6922 /// caller). Drives the parent's own `Start` through the real `Engine`/
6923 /// `SchedulerControl` dispatch loop (`run_until`) -- the fork tree
6924 /// itself is built directly via `ResumeDriver::handle_fork` before that
6925 /// (this module's own private fields are reachable from this same-file
6926 /// `tests` submodule), the same "construct the tree directly, dispatch
6927 /// only the one process under test" pattern `resume.rs`'s own nested-
6928 /// fork tests already use, needed here because `Suspend::ForkJoin`'s
6929 /// real dispatch path (`apply_suspend`'s own `mint_child`) always mints
6930 /// an immediately-`Done` `UnmintedForkChild` (Task 7's documented
6931 /// interim placeholder), which could never stay live long enough to
6932 /// itself fork a grandchild.
6933 #[test]
6934 fn sc125_disable_fork_terminates_every_live_descendant_without_suspending_the_caller() {
6935 struct DisablingParent {
6936 id_cell: Arc<StdMutex<Option<ProcessId>>>,
6937 resume_count: Arc<std::sync::atomic::AtomicUsize>,
6938 }
6939 impl CompiledProcess for DisablingParent {
6940 fn resume(&mut self, _s: SimHandle, arg: ResumeArg, ops: &dyn SchedulerOps) -> Suspend {
6941 self.resume_count.fetch_add(1, Ordering::SeqCst);
6942 match arg {
6943 ResumeArg::Start => {
6944 let id = self
6945 .id_cell
6946 .lock()
6947 .unwrap()
6948 .expect("id_cell set right after register_process returns");
6949 ops.disable_fork(id);
6950 Suspend::Done
6951 }
6952 other => panic!("DisablingParent must only ever see Start, got {other:?}"),
6953 }
6954 }
6955 }
6956
6957 let mut engine = new_engine();
6958 let id_cell = Arc::new(StdMutex::new(None));
6959 let resume_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6960 let parent = engine.register_process(
6961 Box::new(DisablingParent {
6962 id_cell: id_cell.clone(),
6963 resume_count: resume_count.clone(),
6964 }),
6965 ProcessKind::Initial,
6966 None,
6967 0,
6968 );
6969 *id_cell.lock().unwrap() = Some(parent);
6970
6971 // Build the fork tree directly (see this test's own doc comment):
6972 // parent -> [child_a, child_b]; child_a -> [grandchild].
6973 let mut mint =
6974 |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
6975 let children = engine.driver.handle_fork(
6976 &mut engine.table,
6977 parent,
6978 JoinKind::None,
6979 &[spawn_from(parent), spawn_from(parent)],
6980 &mut mint,
6981 );
6982 assert_eq!(children.len(), 2);
6983 let (child_a, child_b) = (children[0], children[1]);
6984 let grandchildren = engine.driver.handle_fork(
6985 &mut engine.table,
6986 child_a,
6987 JoinKind::None,
6988 &[spawn_from(child_a)],
6989 &mut mint,
6990 );
6991 assert_eq!(grandchildren.len(), 1);
6992 let grandchild = grandchildren[0];
6993
6994 assert!(engine.test_process_exists(child_a));
6995 assert!(engine.test_process_exists(child_b));
6996 assert!(engine.test_process_exists(grandchild));
6997
6998 // `register_process` already queued `parent`'s own Start activation
6999 // -- `child_a`/`child_b`/`grandchild` were registered directly via
7000 // `handle_fork` above, never given a Start activation of their own,
7001 // so this `run_until` dispatches exactly one process: `parent`.
7002 let result = engine.run_until(None);
7003 assert!(matches!(result, RunResult::Completed));
7004
7005 assert_eq!(
7006 resume_count.load(Ordering::SeqCst),
7007 1,
7008 "the parent's own single resume() call must never be re-entered a second \
7009 time by disable_fork itself"
7010 );
7011 assert!(
7012 !engine.test_process_exists(child_a),
7013 "disable_fork must terminate every live child"
7014 );
7015 assert!(
7016 !engine.test_process_exists(child_b),
7017 "disable_fork must terminate every live child"
7018 );
7019 assert!(
7020 !engine.test_process_exists(grandchild),
7021 "disable_fork must terminate a live grandchild too -- one level deep is \
7022 not enough"
7023 );
7024 }
7025
7026 /// Proves `Suspend::WaitFork`'s own scheduler-side *installation*
7027 /// (`Engine::apply_wait_fork`) — distinct from `ResumeDriver::
7028 /// on_child_done`'s own decrement logic, which
7029 /// `sched::resume::tests::sc125_wait_fork_resumes_only_once_every_live_
7030 /// child_completes_not_on_the_first` already covers directly. Builds 2
7031 /// live children via `handle_fork` (bypassing `Suspend::ForkJoin`'s own
7032 /// hardcoded `UnmintedForkChild` mint, per the disable_fork test's own
7033 /// doc comment, so the children stay live across this test's own
7034 /// `apply_suspend` calls), suspends the parent directly via
7035 /// `apply_suspend(parent, Suspend::WaitFork)`, and drives both children
7036 /// to completion via `apply_suspend(child, Suspend::Done)`. Fails if
7037 /// `apply_wait_fork` ever miscounts the live-child set (e.g. counting
7038 /// `fork_children.len()` instead of filtering for still-live entries).
7039 #[test]
7040 fn sc125_apply_suspend_wait_fork_installs_live_count_and_resumes_after_the_last_child() {
7041 struct WaitForkParent {
7042 log: Arc<StdMutex<Vec<&'static str>>>,
7043 }
7044 impl CompiledProcess for WaitForkParent {
7045 fn resume(
7046 &mut self,
7047 _s: SimHandle,
7048 arg: ResumeArg,
7049 _ops: &dyn SchedulerOps,
7050 ) -> Suspend {
7051 match arg {
7052 ResumeArg::AllForkChildrenDone => {
7053 self.log.lock().unwrap().push("resumed");
7054 Suspend::Done
7055 }
7056 other => panic!("WaitForkParent: unexpected {other:?}"),
7057 }
7058 }
7059 }
7060
7061 let log = Arc::new(StdMutex::new(Vec::new()));
7062 let mut engine = new_engine();
7063 // Registered directly via `ProcessTable::register` (not
7064 // `register_process`, which would also queue an unrelated `Start`
7065 // activation this test never dispatches -- this test drives `proc`
7066 // entirely through direct `apply_suspend` calls, standing in for a
7067 // real process already mid-way through its own `fork ... join_none;
7068 // ...; wait fork;` sequence).
7069 let parent = engine
7070 .table
7071 .register(Box::new(WaitForkParent { log: log.clone() }));
7072 let mut mint =
7073 |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7074 let children = engine.driver.handle_fork(
7075 &mut engine.table,
7076 parent,
7077 JoinKind::None,
7078 &[spawn_from(parent), spawn_from(parent)],
7079 &mut mint,
7080 );
7081 assert_eq!(children.len(), 2);
7082
7083 engine.apply_suspend(parent, Suspend::WaitFork);
7084 assert_eq!(
7085 engine.table.get(parent).unwrap().wait_fork_remaining,
7086 Some(2),
7087 "apply_wait_fork must install the true live-child count"
7088 );
7089
7090 engine.apply_suspend(children[0], Suspend::Done);
7091 assert!(!engine.test_process_exists(children[0]));
7092 assert!(
7093 log.lock().unwrap().is_empty(),
7094 "must not resume the parent after only 1 of 2 live children"
7095 );
7096 assert_eq!(
7097 engine.table.get(parent).unwrap().wait_fork_remaining,
7098 Some(1)
7099 );
7100
7101 engine.apply_suspend(children[1], Suspend::Done);
7102 assert!(!engine.test_process_exists(children[1]));
7103 assert!(
7104 engine
7105 .table
7106 .get(parent)
7107 .unwrap()
7108 .wait_fork_remaining
7109 .is_none(),
7110 "wait_fork_remaining must be cleared the instant the count reaches 0, \
7111 immediately from apply_suspend itself, before the parent's own \
7112 AllForkChildrenDone activation is ever dispatched below"
7113 );
7114 // The last completion's `Suspend::Done` arm only *enqueues*
7115 // `parent`'s `AllForkChildrenDone` activation onto `Region::Active`
7116 // (the same real queue `ForkChildDone` notifications use) -- it does
7117 // not call `resume()` synchronously, so an actual dispatch pass is
7118 // needed to observe it, exactly like `fork_join_all_actually_
7119 // resumes_the_parent_when_its_one_child_completes` above needs
7120 // `run_until` for the identical reason.
7121 let ready = engine.drain_and_sort(Region::Active);
7122 engine.dispatch_ready_set(Region::Active, ready);
7123 assert_eq!(
7124 log.lock().unwrap().as_slice(),
7125 &["resumed"],
7126 "the parent must resume exactly once, only after the 2nd (last) child"
7127 );
7128 }
7129
7130 /// **Mutation-testing addition (B-sched, SC-125).** `apply_wait_fork`'s
7131 /// own doc comment names "the common 'every child already finished
7132 /// before `wait fork;` itself ran' case" -- this proves it directly, for
7133 /// a *mixed* population (one child already done and reclaimed, one still
7134 /// live) rather than the "0 live" or "all live" cases the other two
7135 /// tests here already cover. `live` must be computed by filtering
7136 /// `fork_children` down to those still present in `table` -- counting
7137 /// every spawned child regardless of liveness (e.g. `children.len()`)
7138 /// would install a `wait_fork_remaining` one too high here, and the
7139 /// parent would never resume: its one remaining live child's own
7140 /// completion would decrement to 1, not 0, and `on_child_done` would
7141 /// never report `AllForkChildrenDone`.
7142 #[test]
7143 fn sc125_apply_wait_fork_counts_only_still_live_children_not_every_spawned_child() {
7144 struct WaitForkParent {
7145 log: Arc<StdMutex<Vec<&'static str>>>,
7146 }
7147 impl CompiledProcess for WaitForkParent {
7148 fn resume(
7149 &mut self,
7150 _s: SimHandle,
7151 arg: ResumeArg,
7152 _ops: &dyn SchedulerOps,
7153 ) -> Suspend {
7154 match arg {
7155 ResumeArg::AllForkChildrenDone => {
7156 self.log.lock().unwrap().push("resumed");
7157 Suspend::Done
7158 }
7159 other => panic!("WaitForkParent: unexpected {other:?}"),
7160 }
7161 }
7162 }
7163
7164 let log = Arc::new(StdMutex::new(Vec::new()));
7165 let mut engine = new_engine();
7166 let parent = engine
7167 .table
7168 .register(Box::new(WaitForkParent { log: log.clone() }));
7169 let mut mint =
7170 |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7171 let children = engine.driver.handle_fork(
7172 &mut engine.table,
7173 parent,
7174 JoinKind::None,
7175 &[spawn_from(parent), spawn_from(parent)],
7176 &mut mint,
7177 );
7178 assert_eq!(children.len(), 2);
7179
7180 // `children[0]` finishes -- and is reclaimed -- *before* `wait
7181 // fork;` itself ever runs. `parent`'s own `fork_children` still
7182 // lists both ids (nothing prunes that list on completion), but only
7183 // `children[1]` is still a live table entry.
7184 engine.apply_suspend(children[0], Suspend::Done);
7185 assert!(!engine.test_process_exists(children[0]));
7186 assert!(engine.test_process_exists(children[1]));
7187
7188 engine.apply_suspend(parent, Suspend::WaitFork);
7189 assert_eq!(
7190 engine.table.get(parent).unwrap().wait_fork_remaining,
7191 Some(1),
7192 "apply_wait_fork must count only the one still-live child, not both \
7193 spawned children"
7194 );
7195
7196 engine.apply_suspend(children[1], Suspend::Done);
7197 assert!(!engine.test_process_exists(children[1]));
7198 assert!(
7199 engine
7200 .table
7201 .get(parent)
7202 .unwrap()
7203 .wait_fork_remaining
7204 .is_none(),
7205 "wait_fork_remaining must reach 0 (and clear) the instant this one live \
7206 child's completion is reported -- not require a second, nonexistent \
7207 completion"
7208 );
7209 let ready = engine.drain_and_sort(Region::Active);
7210 engine.dispatch_ready_set(Region::Active, ready);
7211 assert_eq!(
7212 log.lock().unwrap().as_slice(),
7213 &["resumed"],
7214 "the parent must actually resume once its one live child completes, \
7215 even though one of its two originally-spawned children finished \
7216 earlier"
7217 );
7218 }
7219
7220 /// Proves `Suspend::WaitOrder(order)`'s own scheduler-side
7221 /// *installation* (`Engine::apply_wait_order`) through a real
7222 /// `apply_suspend` call, complementing `sched::resume::tests`'s own
7223 /// direct `on_child_done` proofs for the ordering/violation logic
7224 /// itself.
7225 #[test]
7226 fn sc125_apply_suspend_wait_order_resumes_only_after_the_exact_declared_order() {
7227 struct WaitOrderParent {
7228 log: Arc<StdMutex<Vec<&'static str>>>,
7229 }
7230 impl CompiledProcess for WaitOrderParent {
7231 fn resume(
7232 &mut self,
7233 _s: SimHandle,
7234 arg: ResumeArg,
7235 _ops: &dyn SchedulerOps,
7236 ) -> Suspend {
7237 match arg {
7238 ResumeArg::WaitOrderSatisfied => {
7239 self.log.lock().unwrap().push("resumed");
7240 Suspend::Done
7241 }
7242 other => panic!("WaitOrderParent: unexpected {other:?}"),
7243 }
7244 }
7245 }
7246
7247 let log = Arc::new(StdMutex::new(Vec::new()));
7248 let mut engine = new_engine();
7249 // See the identical `ProcessTable::register` note on
7250 // `sc125_apply_suspend_wait_fork_installs_live_count_and_resumes_
7251 // after_the_last_child` above.
7252 let parent = engine
7253 .table
7254 .register(Box::new(WaitOrderParent { log: log.clone() }));
7255 let mut mint =
7256 |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7257 let children = engine.driver.handle_fork(
7258 &mut engine.table,
7259 parent,
7260 JoinKind::None,
7261 &[spawn_from(parent), spawn_from(parent), spawn_from(parent)],
7262 &mut mint,
7263 );
7264 assert_eq!(children.len(), 3);
7265 let order = children.clone();
7266
7267 engine.apply_suspend(parent, Suspend::WaitOrder(order));
7268 assert!(engine.table.get(parent).unwrap().wait_order.is_some());
7269
7270 engine.apply_suspend(children[0], Suspend::Done);
7271 engine.apply_suspend(children[1], Suspend::Done);
7272 assert!(
7273 log.lock().unwrap().is_empty(),
7274 "must not resume before the 3rd, in-order completion"
7275 );
7276
7277 engine.apply_suspend(children[2], Suspend::Done);
7278 // The 3rd completion's `Suspend::Done` arm only *enqueues* the
7279 // parent's `WaitOrderSatisfied` activation onto `Region::Active` --
7280 // see the identical note on `sc125_apply_suspend_wait_fork_
7281 // installs_live_count_and_resumes_after_the_last_child` above.
7282 let ready = engine.drain_and_sort(Region::Active);
7283 engine.dispatch_ready_set(Region::Active, ready);
7284 assert_eq!(log.lock().unwrap().as_slice(), &["resumed"]);
7285 }
7286
7287 // --- Classes plan Task 13 (LRM 9.7): the `process` class ---
7288 //
7289 // Every test drives the real `Engine` dispatch loop (`run_until`) with
7290 // scripted processes that call the same `SchedulerOps` entry points the
7291 // compiled `process` methods reach (`process_track` / `process_request`
7292 // / `process_state`), so the Kill/Suspend/Resume/Await/status mechanics
7293 // are pinned without any codegen. ProcessIds are the registration order
7294 // (0, 1, 2, ...), asserted on every `register_process` return.
7295
7296 type ProcLog = Arc<StdMutex<Vec<String>>>;
7297
7298 /// A process whose every `resume()` is one call of a closure.
7299 type ScriptFn = Box<dyn FnMut(ResumeArg, &dyn SchedulerOps) -> Suspend + Send>;
7300 struct Script(ScriptFn);
7301 impl CompiledProcess for Script {
7302 fn resume(&mut self, _s: SimHandle, arg: ResumeArg, ops: &dyn SchedulerOps) -> Suspend {
7303 (self.0)(arg, ops)
7304 }
7305 }
7306
7307 fn script(
7308 f: impl FnMut(ResumeArg, &dyn SchedulerOps) -> Suspend + Send + 'static,
7309 ) -> Box<dyn CompiledProcess> {
7310 Box::new(Script(Box::new(f)))
7311 }
7312
7313 fn new_log() -> ProcLog {
7314 Arc::new(StdMutex::new(Vec::new()))
7315 }
7316
7317 fn lines(log: &ProcLog) -> Vec<String> {
7318 log.lock().unwrap().clone()
7319 }
7320
7321 fn state_of(ops: &dyn SchedulerOps, target: ProcessId) -> u8 {
7322 ops.process_state(target) as u8
7323 }
7324
7325 fn register_expecting(engine: &mut Engine, proc: Box<dyn CompiledProcess>, want: u32) {
7326 let id = engine.register_process(proc, ProcessKind::Initial, None, 0);
7327 assert_eq!(id, want, "tests hard-code ProcessIds as registration order");
7328 }
7329
7330 /// LRM 9.7.3: `kill()` terminates the target and ALL its descendants
7331 /// before it returns, and `status()` reads KILLED in the very segment
7332 /// that called `kill()`. The victim's pending timer must never fire.
7333 #[test]
7334 fn process_kill_terminates_target_and_descendants_and_reads_killed() {
7335 let mut engine = new_engine();
7336 let log = new_log();
7337
7338 let l0 = log.clone();
7339 register_expecting(
7340 &mut engine,
7341 script(move |arg, ops| match arg {
7342 ResumeArg::Start => {
7343 ops.process_track(0);
7344 Suspend::Delay(100)
7345 }
7346 other => {
7347 l0.lock().unwrap().push(format!("victim resumed {other:?}"));
7348 Suspend::Done
7349 }
7350 }),
7351 0,
7352 );
7353 let (l1, mut step) = (log.clone(), 0);
7354 register_expecting(
7355 &mut engine,
7356 script(move |arg, ops| {
7357 step += 1;
7358 match (step, arg) {
7359 (1, ResumeArg::Start) => Suspend::Delay(10),
7360 (2, ResumeArg::TimeElapsed) => {
7361 let before = state_of(ops, 0);
7362 ops.process_request(1, ProcessRequest::Kill(0));
7363 let after = state_of(ops, 0);
7364 l1.lock().unwrap().push(format!(
7365 "t={} before={before} after={after}",
7366 ops.now().ticks
7367 ));
7368 Suspend::ByRequest
7369 }
7370 (3, ResumeArg::TimeElapsed) => {
7371 l1.lock()
7372 .unwrap()
7373 .push(format!("resumed state={}", state_of(ops, 0)));
7374 Suspend::Done
7375 }
7376 other => panic!("killer: unexpected {other:?}"),
7377 }
7378 }),
7379 1,
7380 );
7381
7382 // victim (0) -> child (2) -> grandchild (3), built directly.
7383 let mut mint =
7384 |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7385 let kids = engine.driver.handle_fork(
7386 &mut engine.table,
7387 0,
7388 JoinKind::None,
7389 &[spawn_from(0)],
7390 &mut mint,
7391 );
7392 let grand = engine.driver.handle_fork(
7393 &mut engine.table,
7394 kids[0],
7395 JoinKind::None,
7396 &[spawn_from(kids[0])],
7397 &mut mint,
7398 );
7399 assert_eq!((kids[0], grand[0]), (2, 3));
7400
7401 assert!(matches!(engine.run_until(None), RunResult::Completed));
7402 assert_eq!(
7403 lines(&log),
7404 // The victim is WAITING on its delay (2); the overlay turns it
7405 // KILLED (4) at once.
7406 ["t=10 before=2 after=4", "resumed state=4"],
7407 "kill must be visible to status() in the same segment, and the \
7408 victim's Delay(100) must never resume it"
7409 );
7410 for id in [0, 2, 3] {
7411 assert!(!engine.test_process_exists(id), "process {id} must be gone");
7412 }
7413 assert_eq!(engine.effective_process_state(0), ProcessState::Killed);
7414 assert_eq!(
7415 engine.effective_process_state(3),
7416 ProcessState::Killed,
7417 "an untracked descendant killed with its ancestor reads KILLED too"
7418 );
7419 }
7420
7421 /// LRM 9.7.3: `kill()` on a process that already ended still terminates
7422 /// its live descendants, and the target keeps reading FINISHED.
7423 #[test]
7424 fn process_kill_of_a_finished_process_still_kills_its_live_descendants() {
7425 let mut engine = new_engine();
7426 register_expecting(
7427 &mut engine,
7428 script(|_, ops| {
7429 ops.process_track(0);
7430 Suspend::Done
7431 }),
7432 0,
7433 );
7434 let mut step = 0;
7435 register_expecting(
7436 &mut engine,
7437 script(move |_, ops| {
7438 step += 1;
7439 match step {
7440 1 => Suspend::Delay(10),
7441 2 => {
7442 ops.process_request(1, ProcessRequest::Kill(0));
7443 Suspend::ByRequest
7444 }
7445 _ => Suspend::Done,
7446 }
7447 }),
7448 1,
7449 );
7450 let mut mint =
7451 |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7452 let kids = engine.driver.handle_fork(
7453 &mut engine.table,
7454 0,
7455 JoinKind::None,
7456 &[spawn_from(0)],
7457 &mut mint,
7458 );
7459 assert!(engine.test_process_exists(kids[0]));
7460 assert!(matches!(engine.run_until(None), RunResult::Completed));
7461 assert!(
7462 !engine.test_process_exists(kids[0]),
7463 "the live descendant of an already-finished process must die"
7464 );
7465 assert_eq!(engine.effective_process_state(0), ProcessState::Finished);
7466 }
7467
7468 /// LRM 9.7.1: a process may kill itself; it ends there (`process_request`
7469 /// reports it so the compiled caller can stop) and reads KILLED.
7470 #[test]
7471 fn process_self_kill_ends_the_caller_and_reports_it() {
7472 let mut engine = new_engine();
7473 let log = new_log();
7474 let l0 = log.clone();
7475 register_expecting(
7476 &mut engine,
7477 script(move |_, ops| {
7478 ops.process_track(0);
7479 let ends_me = ops.process_request(0, ProcessRequest::Kill(0));
7480 l0.lock().unwrap().push(format!("ends_requester={ends_me}"));
7481 Suspend::Done
7482 }),
7483 0,
7484 );
7485 assert!(matches!(engine.run_until(None), RunResult::Completed));
7486 assert_eq!(lines(&log), ["ends_requester=true"]);
7487 assert!(!engine.test_process_exists(0));
7488 assert_eq!(engine.effective_process_state(0), ProcessState::Killed);
7489 }
7490
7491 /// Killing the children a `wait fork` is blocked on releases the parent
7492 /// exactly once, only after the last one is gone.
7493 #[test]
7494 fn process_kill_of_fork_children_releases_the_wait_fork_parent() {
7495 let mut engine = new_engine();
7496 let log = new_log();
7497 let l0 = log.clone();
7498 register_expecting(
7499 &mut engine,
7500 script(move |arg, ops| match arg {
7501 ResumeArg::Start => Suspend::WaitFork,
7502 ResumeArg::AllForkChildrenDone => {
7503 l0.lock()
7504 .unwrap()
7505 .push(format!("joined@{}", ops.now().ticks));
7506 Suspend::Done
7507 }
7508 other => panic!("parent: unexpected {other:?}"),
7509 }),
7510 0,
7511 );
7512 let mut step = 0;
7513 register_expecting(
7514 &mut engine,
7515 script(move |_, ops| {
7516 step += 1;
7517 match step {
7518 1 => Suspend::Delay(10),
7519 2 => {
7520 ops.process_request(1, ProcessRequest::Kill(2));
7521 ops.process_request(1, ProcessRequest::Kill(3));
7522 Suspend::ByRequest
7523 }
7524 _ => Suspend::Done,
7525 }
7526 }),
7527 1,
7528 );
7529 let mut mint =
7530 |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7531 let kids = engine.driver.handle_fork(
7532 &mut engine.table,
7533 0,
7534 JoinKind::None,
7535 &[spawn_from(0), spawn_from(0)],
7536 &mut mint,
7537 );
7538 assert_eq!(kids, vec![2, 3]);
7539 assert!(matches!(engine.run_until(None), RunResult::Completed));
7540 assert_eq!(lines(&log), ["joined@10"]);
7541 }
7542
7543 /// LRM 9.7: `status()` is RUNNING for the caller, WAITING while the
7544 /// target is blocked, FINISHED once it ended.
7545 #[test]
7546 fn process_status_tracks_running_waiting_finished() {
7547 let mut engine = new_engine();
7548 let log = new_log();
7549 let l0 = log.clone();
7550 let mut step = 0;
7551 register_expecting(
7552 &mut engine,
7553 script(move |_, ops| {
7554 step += 1;
7555 match step {
7556 1 => {
7557 ops.process_track(0);
7558 l0.lock()
7559 .unwrap()
7560 .push(format!("self={}", state_of(ops, 0)));
7561 Suspend::Delay(10)
7562 }
7563 _ => Suspend::Done,
7564 }
7565 }),
7566 0,
7567 );
7568 let l1 = log.clone();
7569 let mut step = 0;
7570 register_expecting(
7571 &mut engine,
7572 script(move |_, ops| {
7573 step += 1;
7574 match step {
7575 1 => Suspend::Delay(5),
7576 2 => {
7577 l1.lock().unwrap().push(format!("@5={}", state_of(ops, 0)));
7578 Suspend::Delay(20)
7579 }
7580 _ => {
7581 l1.lock().unwrap().push(format!("@25={}", state_of(ops, 0)));
7582 Suspend::Done
7583 }
7584 }
7585 }),
7586 1,
7587 );
7588 assert!(matches!(engine.run_until(None), RunResult::Completed));
7589 assert_eq!(lines(&log), ["self=1", "@5=2", "@25=0"]);
7590 }
7591
7592 /// LRM 9.7: `await()` blocks until the target ends; a target that
7593 /// already ended, or the caller itself, blocks nothing.
7594 #[test]
7595 fn process_await_blocks_until_the_target_ends() {
7596 let mut engine = new_engine();
7597 let log = new_log();
7598 let mut step = 0;
7599 register_expecting(
7600 &mut engine,
7601 script(move |_, ops| {
7602 step += 1;
7603 if step == 1 {
7604 ops.process_track(0);
7605 Suspend::Delay(30)
7606 } else {
7607 Suspend::Done
7608 }
7609 }),
7610 0,
7611 );
7612 let (l1, mut step) = (log.clone(), 0);
7613 register_expecting(
7614 &mut engine,
7615 script(move |_, ops| {
7616 step += 1;
7617 match step {
7618 1 => Suspend::Delay(5),
7619 2 => {
7620 ops.process_request(1, ProcessRequest::Await(0));
7621 l1.lock().unwrap().push("awaiting@5".into());
7622 Suspend::ByRequest
7623 }
7624 _ => {
7625 l1.lock()
7626 .unwrap()
7627 .push(format!("awaited@{}", ops.now().ticks));
7628 Suspend::Done
7629 }
7630 }
7631 }),
7632 1,
7633 );
7634 let (l2, mut step) = (log.clone(), 0);
7635 register_expecting(
7636 &mut engine,
7637 script(move |_, ops| {
7638 step += 1;
7639 match step {
7640 1 => Suspend::Delay(50),
7641 2 => {
7642 ops.process_request(2, ProcessRequest::Await(0));
7643 Suspend::ByRequest
7644 }
7645 _ => {
7646 l2.lock().unwrap().push(format!("late@{}", ops.now().ticks));
7647 Suspend::Done
7648 }
7649 }
7650 }),
7651 2,
7652 );
7653 let (l3, mut step) = (log.clone(), 0);
7654 register_expecting(
7655 &mut engine,
7656 script(move |_, ops| {
7657 step += 1;
7658 if step == 1 {
7659 ops.process_request(3, ProcessRequest::Await(3));
7660 Suspend::ByRequest
7661 } else {
7662 l3.lock().unwrap().push(format!("self@{}", ops.now().ticks));
7663 Suspend::Done
7664 }
7665 }),
7666 3,
7667 );
7668 assert!(matches!(engine.run_until(None), RunResult::Completed));
7669 assert_eq!(
7670 lines(&log),
7671 ["self@0", "awaiting@5", "awaited@30", "late@50"],
7672 "await wakes exactly at the target's end; an ended target / self \
7673 never blocks"
7674 );
7675 }
7676
7677 /// LRM 9.7.4 / 9.7.5: a suspended process stops advancing; `resume()`
7678 /// continues it; `resume()` on a process that is not suspended is a
7679 /// no-op.
7680 #[test]
7681 fn process_suspend_resume_periodic_process() {
7682 let mut engine = new_engine();
7683 let log = new_log();
7684 let (l0, mut ticks) = (log.clone(), 0);
7685 register_expecting(
7686 &mut engine,
7687 script(move |arg, ops| match arg {
7688 ResumeArg::Start => {
7689 ops.process_track(0);
7690 Suspend::Delay(10)
7691 }
7692 _ => {
7693 ticks += 1;
7694 l0.lock().unwrap().push(format!("tick@{}", ops.now().ticks));
7695 if ticks == 4 {
7696 Suspend::Done
7697 } else {
7698 Suspend::Delay(10)
7699 }
7700 }
7701 }),
7702 0,
7703 );
7704 let (l1, mut step) = (log.clone(), 0);
7705 register_expecting(
7706 &mut engine,
7707 script(move |_, ops| {
7708 step += 1;
7709 match step {
7710 1 => Suspend::Delay(5),
7711 2 => {
7712 // Not suspended: no effect.
7713 ops.process_request(1, ProcessRequest::Resume(0));
7714 Suspend::ByRequest
7715 }
7716 3 => Suspend::Delay(10),
7717 4 => {
7718 ops.process_request(1, ProcessRequest::Suspend(0));
7719 l1.lock()
7720 .unwrap()
7721 .push(format!("@15 state={}", state_of(ops, 0)));
7722 Suspend::ByRequest
7723 }
7724 5 => Suspend::Delay(30),
7725 6 => {
7726 l1.lock()
7727 .unwrap()
7728 .push(format!("@45 state={}", state_of(ops, 0)));
7729 ops.process_request(1, ProcessRequest::Resume(0));
7730 l1.lock()
7731 .unwrap()
7732 .push(format!("resumed state={}", state_of(ops, 0)));
7733 Suspend::ByRequest
7734 }
7735 _ => Suspend::Done,
7736 }
7737 }),
7738 1,
7739 );
7740 assert!(matches!(engine.run_until(None), RunResult::Completed));
7741 assert_eq!(
7742 lines(&log),
7743 [
7744 "tick@10",
7745 "@15 state=3",
7746 "@45 state=3",
7747 "resumed state=2",
7748 "tick@45",
7749 "tick@55",
7750 "tick@65",
7751 ],
7752 "the tick due at 20 is held while suspended and replayed at the \
7753 resume (45); then the 10-unit period continues"
7754 );
7755 }
7756
7757 /// A process may suspend itself (`ByRequest` parks it) and another
7758 /// process resumes it.
7759 #[test]
7760 fn process_self_suspend_is_released_by_another_process() {
7761 let mut engine = new_engine();
7762 let log = new_log();
7763 let (l0, mut step) = (log.clone(), 0);
7764 register_expecting(
7765 &mut engine,
7766 script(move |_, ops| {
7767 step += 1;
7768 if step == 1 {
7769 ops.process_track(0);
7770 ops.process_request(0, ProcessRequest::Suspend(0));
7771 l0.lock().unwrap().push("p0 suspending".into());
7772 Suspend::ByRequest
7773 } else {
7774 l0.lock()
7775 .unwrap()
7776 .push(format!("p0 resumed@{}", ops.now().ticks));
7777 Suspend::Done
7778 }
7779 }),
7780 0,
7781 );
7782 let (l1, mut step) = (log.clone(), 0);
7783 register_expecting(
7784 &mut engine,
7785 script(move |_, ops| {
7786 step += 1;
7787 match step {
7788 1 => Suspend::Delay(20),
7789 2 => {
7790 l1.lock()
7791 .unwrap()
7792 .push(format!("p0 state={}", state_of(ops, 0)));
7793 ops.process_request(1, ProcessRequest::Resume(0));
7794 Suspend::ByRequest
7795 }
7796 _ => Suspend::Done,
7797 }
7798 }),
7799 1,
7800 );
7801 assert!(matches!(engine.run_until(None), RunResult::Completed));
7802 assert_eq!(
7803 lines(&log),
7804 ["p0 suspending", "p0 state=3", "p0 resumed@20"]
7805 );
7806 }
7807
7808 fn new_semaphore(keys: i64, template: u32) -> u64 {
7809 let h = crate::objects::alloc(
7810 crate::netlist::ClassTemplateId(template),
7811 semaphore::SEMAPHORE_FIELD_WORDS,
7812 );
7813 assert!(semaphore::init(h, keys));
7814 h
7815 }
7816
7817 /// Classes plan Task 17 (LRM 15.3.3/15.3.4): a `get` that must wait yields
7818 /// (`SyncBlock` + `ByRequest`) and reads WAITING until the `put` that
7819 /// grants it queues `SyncWake`, which resumes it at the putter's time.
7820 #[test]
7821 fn semaphore_get_blocks_until_a_put_grants_it() {
7822 let h = new_semaphore(0, 17_100);
7823 let mut engine = new_engine();
7824 let log = new_log();
7825 let (l0, mut step) = (log.clone(), 0);
7826 register_expecting(
7827 &mut engine,
7828 script(move |_, ops| {
7829 step += 1;
7830 if step == 1 {
7831 let blocked = semaphore::get(h, 0, 1, &mut |r| {
7832 ops.process_request(0, r);
7833 });
7834 assert!(blocked, "an empty semaphore blocks");
7835 Suspend::ByRequest
7836 } else {
7837 l0.lock().unwrap().push(format!("got@{}", ops.now().ticks));
7838 Suspend::Done
7839 }
7840 }),
7841 0,
7842 );
7843 let (l1, mut step) = (log.clone(), 0);
7844 register_expecting(
7845 &mut engine,
7846 script(move |_, ops| {
7847 step += 1;
7848 match step {
7849 1 => Suspend::Delay(7),
7850 _ => {
7851 l1.lock()
7852 .unwrap()
7853 .push(format!("before put: state={}", state_of(ops, 0)));
7854 assert!(semaphore::put(h, 1, &mut |r| {
7855 ops.process_request(1, r);
7856 }));
7857 Suspend::Done
7858 }
7859 }
7860 }),
7861 1,
7862 );
7863 assert!(matches!(engine.run_until(None), RunResult::Completed));
7864 assert_eq!(lines(&log), ["before put: state=2", "got@7"]);
7865 assert_eq!(semaphore::keys(h), Some(0));
7866 }
7867
7868 /// A process killed while blocked leaves the queue; a waiter behind it
7869 /// that the freed head was holding back is released at the kill.
7870 #[test]
7871 fn killing_a_blocked_get_releases_the_waiter_behind_it() {
7872 let h = new_semaphore(1, 17_101);
7873 let mut engine = new_engine();
7874 let log = new_log();
7875 let mut step = 0;
7876 register_expecting(
7877 &mut engine,
7878 script(move |_, ops| {
7879 step += 1;
7880 if step == 1 {
7881 assert!(semaphore::get(h, 0, 2, &mut |r| {
7882 ops.process_request(0, r);
7883 }));
7884 Suspend::ByRequest
7885 } else {
7886 panic!("the killed waiter must never resume");
7887 }
7888 }),
7889 0,
7890 );
7891 let (l1, mut step) = (log.clone(), 0);
7892 register_expecting(
7893 &mut engine,
7894 script(move |_, ops| {
7895 step += 1;
7896 if step == 1 {
7897 assert!(
7898 semaphore::get(h, 1, 1, &mut |r| {
7899 ops.process_request(1, r);
7900 }),
7901 "FIFO: queued behind the get(2) although a key is free"
7902 );
7903 Suspend::ByRequest
7904 } else {
7905 l1.lock()
7906 .unwrap()
7907 .push(format!("p1 got@{}", ops.now().ticks));
7908 Suspend::Done
7909 }
7910 }),
7911 1,
7912 );
7913 let (l2, mut step) = (log.clone(), 0);
7914 register_expecting(
7915 &mut engine,
7916 script(move |_, ops| {
7917 step += 1;
7918 match step {
7919 1 => Suspend::Delay(5),
7920 2 => {
7921 l2.lock()
7922 .unwrap()
7923 .push(format!("waiters before kill: {}", semaphore::waiters(h)));
7924 ops.process_request(2, ProcessRequest::Kill(0));
7925 Suspend::ByRequest
7926 }
7927 _ => {
7928 l2.lock()
7929 .unwrap()
7930 .push(format!("waiters after kill: {}", semaphore::waiters(h)));
7931 Suspend::Done
7932 }
7933 }
7934 }),
7935 2,
7936 );
7937 assert!(matches!(engine.run_until(None), RunResult::Completed));
7938 assert_eq!(
7939 lines(&log),
7940 [
7941 "waiters before kill: 2",
7942 // The killer continues at once; the released waiter is
7943 // activated, so it runs after the killer's segment.
7944 "waiters after kill: 0",
7945 "p1 got@5",
7946 ]
7947 );
7948 assert_eq!(semaphore::keys(h), Some(0));
7949 }
7950
7951 /// A process suspended with `p.suspend()` while blocked in `get` is
7952 /// granted its keys but stays parked until `p.resume()`.
7953 #[test]
7954 fn a_granted_get_of_a_suspended_process_waits_for_resume() {
7955 let h = new_semaphore(0, 17_102);
7956 let mut engine = new_engine();
7957 let log = new_log();
7958 let (l0, mut step) = (log.clone(), 0);
7959 register_expecting(
7960 &mut engine,
7961 script(move |_, ops| {
7962 step += 1;
7963 if step == 1 {
7964 ops.process_track(0);
7965 assert!(semaphore::get(h, 0, 1, &mut |r| {
7966 ops.process_request(0, r);
7967 }));
7968 Suspend::ByRequest
7969 } else {
7970 l0.lock().unwrap().push(format!("got@{}", ops.now().ticks));
7971 Suspend::Done
7972 }
7973 }),
7974 0,
7975 );
7976 let mut step = 0;
7977 register_expecting(
7978 &mut engine,
7979 script(move |_, ops| {
7980 step += 1;
7981 match step {
7982 1 => Suspend::Delay(1),
7983 2 => {
7984 ops.process_request(1, ProcessRequest::Suspend(0));
7985 Suspend::ByRequest
7986 }
7987 3 => Suspend::Delay(1),
7988 4 => {
7989 semaphore::put(h, 1, &mut |r| {
7990 ops.process_request(1, r);
7991 });
7992 Suspend::Delay(8)
7993 }
7994 5 => {
7995 ops.process_request(1, ProcessRequest::Resume(0));
7996 Suspend::ByRequest
7997 }
7998 _ => Suspend::Done,
7999 }
8000 }),
8001 1,
8002 );
8003 assert!(matches!(engine.run_until(None), RunResult::Completed));
8004 assert_eq!(lines(&log), ["got@10"]);
8005 }
8006}