sv_sim_sim/sched/finals.rs
1//! `final` blocks: one dedicated post-simulation pass (Task 14,
2//! `docs/superpowers/plans/2026-09-23-wave-A-sched.md`; ledger SC-077, SC-078).
3//!
4//! **Scope:** `final` blocks (`ProceduralBlockSymbol` kind `Final`) run
5//! exactly once, in one dedicated pass strictly *after* `run_until` (Task 21)
6//! returns — never folded into the 5-region timestep loop (SC-077) — and in
7//! the same stable, canonical `registration_order` used everywhere else in
8//! this crate, since the LRM leaves inter-block final order unspecified
9//! (SC-078). [`run_finals`] carries no `Region`/`SchedTime` "current
10//! timestep" parameter anywhere in its signature: that is what structurally
11//! prevents it from being invoked *as part of* a region drain, all of which
12//! take a `Region` (`process_table::form_ready_set`, `RegionQueues::push`).
13//!
14//! **Deviation from the plan's literal signature** (recorded in
15//! `docs/CONTRACTS.md`'s r3 section, item 7): the plan names `run_finals`'s
16//! second parameter `table: &mut ProcessTable`, but Task 12's already-shipped
17//! `ResumeDriver::start` (`resume.rs`) takes a **shared** `&ProcessTable` (the
18//! SC-061 fix — see `resume.rs`'s module doc), so a `&mut` borrow here would
19//! not even compile against the real `start`. `run_finals` performs no table
20//! mutation of its own (registration already happened before this pass runs),
21//! so the minimal, additive fix is `table: &ProcessTable`.
22
23use sv_sim_abi::SimHandle;
24
25use super::{ProcessId, ProcessTable, ResumeDriver, SchedulerOps};
26
27/// One registered `final` block candidate (scheduler.md §8; SC-077, SC-078).
28/// `registration_order` is the same crate-wide, monotonic, stable ordering
29/// space `ProcessDescriptor::registration_order` uses (Task 11) — SC-078's
30/// "one logical order, everywhere" invariant, not a second, `final`-local
31/// numbering.
32#[derive(Clone, Copy, Debug)]
33pub struct FinalProcess {
34 pub proc: ProcessId,
35 pub registration_order: u64,
36}
37
38/// Runs every registered `final` block exactly once, in `registration_order`
39/// (stable, ascending — SC-078), sorted in place by this call itself (a
40/// caller must not have to pre-sort `finals`). No `Region`/`SchedTime`
41/// parameter anywhere in this signature (SC-077 — structurally cannot be
42/// folded into the 5-region timestep loop, unlike every region-drain
43/// primitive in this crate).
44///
45/// Each final process is started via [`ResumeDriver::start`] (SC-059's
46/// "`resume(Start)` exactly once"), `.expect()`-ing Task 12's
47/// `Result<Suspend, ResumeError>`: a final process id came from this same
48/// `finals: Vec<FinalProcess>`, itself only ever populated from real
49/// `ProcessTable::register` calls, so `UnknownProcess`/`AlreadyStarted` here
50/// would mean this function's own caller passed a bogus or duplicate entry —
51/// a programmer error worth failing loudly on, not a runtime condition this
52/// function's signature needs to expose to its own caller.
53pub fn run_finals(
54 finals: &mut [FinalProcess],
55 table: &ProcessTable,
56 state: SimHandle,
57 ops: &dyn SchedulerOps,
58 driver: &ResumeDriver,
59) {
60 finals.sort_by_key(|f| f.registration_order);
61 for f in finals.iter() {
62 // A `final` block is a thread too (37.44): created as it starts and
63 // deleted when it returns (it cannot suspend).
64 super::fire_thread_event(
65 ops,
66 super::ThreadEvent::Start {
67 pid: f.proc,
68 parent: None,
69 spawn: None,
70 },
71 );
72 driver
73 .start(table, f.proc, state, ops)
74 .expect("registered final process must be startable");
75 super::fire_thread_event(
76 ops,
77 super::ThreadEvent::End {
78 pid: f.proc,
79 killed: false,
80 },
81 );
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88 use crate::sched::{
89 CompiledProcess, PendingActivation, RegionQueues, ResumeArg, SchedTime, Suspend,
90 form_ready_set,
91 };
92 use crate::state::{FourStateValue, StateCellId};
93 use std::sync::{Arc, Mutex};
94 use sv_sim_abi::Region;
95
96 /// A no-op `SchedulerOps` double — no test in this file inspects any of
97 /// its call arguments, only the resulting `resume()` call log.
98 struct NoopOps;
99 impl SchedulerOps for NoopOps {
100 fn now(&self) -> SchedTime {
101 SchedTime { ticks: 0 }
102 }
103 fn current_region(&self) -> Region {
104 Region::Active
105 }
106 fn stage_nba(&self, _cell: StateCellId, _value: FourStateValue, _source: ProcessId) {}
107 fn commit_blocking_write(&self, _cell: StateCellId, _value: FourStateValue) {}
108 fn request_finish(&self, _code: i32) {}
109 fn defer_io(&self, _at: SchedTime, _region: Region, _cb: super::super::DeferredIoCallback) {
110 }
111 fn register_region_callback(
112 &self,
113 _region: Region,
114 _at: SchedTime,
115 _cb: Box<dyn super::super::RegionCallback>,
116 ) {
117 }
118 }
119
120 fn null_state() -> SimHandle {
121 SimHandle(std::ptr::null_mut())
122 }
123
124 /// Records `(tag, was_a_start_call)` into a shared log every time it is
125 /// resumed. `tag` lets a test identify *which* logical process ran
126 /// without needing to know its dynamically-assigned `ProcessId` — used
127 /// below to check `run_finals`'s actual dispatch order.
128 struct LoggingProcess {
129 tag: u64,
130 log: Arc<Mutex<Vec<(u64, bool)>>>,
131 }
132 impl CompiledProcess for LoggingProcess {
133 fn resume(
134 &mut self,
135 _state: SimHandle,
136 arg: ResumeArg,
137 _ops: &dyn SchedulerOps,
138 ) -> Suspend {
139 self.log
140 .lock()
141 .unwrap()
142 .push((self.tag, matches!(arg, ResumeArg::Start)));
143 Suspend::Done
144 }
145 }
146
147 fn act(
148 id: u64,
149 proc: ProcessId,
150 ticks: u64,
151 reg_order: u64,
152 arg: ResumeArg,
153 ) -> PendingActivation {
154 PendingActivation {
155 id,
156 proc,
157 arg,
158 reconv_iter: 0,
159 logical_order: (SchedTime { ticks }, Region::Active, reg_order),
160 }
161 }
162
163 /// SC-077 — structural half: `run_finals` takes no `Region`/`SchedTime`
164 /// parameter, so it cannot be called from any region-drain call site
165 /// (they all take `Region` — `form_ready_set`, `RegionQueues::push`).
166 /// Behavioral half: register 3 "final" candidates that are never pushed
167 /// into `RegionQueues`, run 2 unrelated Active-region
168 /// `form_ready_set`/dispatch cycles that never mention them, and assert
169 /// the final processes' own call log stays empty until `run_finals` is
170 /// explicitly invoked. Fails if anything in the ordinary dispatch path
171 /// (region push/drain, `ResumeDriver::start`/`resume_for`) implicitly
172 /// reaches a registered-but-unqueued "final" process.
173 #[test]
174 fn finals_do_not_run_during_the_timestep_loop() {
175 let final_log = Arc::new(Mutex::new(Vec::new()));
176 let loop_log = Arc::new(Mutex::new(Vec::new()));
177 let mut table = ProcessTable::new();
178 let driver = ResumeDriver;
179 let ops = NoopOps;
180
181 // 3 final candidates: registered, but never pushed into RegionQueues.
182 let mut finals = Vec::new();
183 for tag in 0..3u64 {
184 let proc = table.register(Box::new(LoggingProcess {
185 tag,
186 log: Arc::clone(&final_log),
187 }));
188 finals.push(FinalProcess {
189 proc,
190 registration_order: tag,
191 });
192 }
193
194 // 2 unrelated processes driven through the ordinary Active-region
195 // ready-set/dispatch path.
196 let loop_proc_a = table.register(Box::new(LoggingProcess {
197 tag: 100,
198 log: Arc::clone(&loop_log),
199 }));
200 let loop_proc_b = table.register(Box::new(LoggingProcess {
201 tag: 101,
202 log: Arc::clone(&loop_log),
203 }));
204
205 let mut queues = RegionQueues::new();
206
207 // Cycle 1: both loop processes' initial Start activation.
208 queues
209 .push(Region::Active, act(0, loop_proc_a, 0, 0, ResumeArg::Start))
210 .unwrap();
211 queues
212 .push(Region::Active, act(1, loop_proc_b, 0, 1, ResumeArg::Start))
213 .unwrap();
214 let ready = form_ready_set(&mut queues, Region::Active).unwrap();
215 assert_eq!(ready.len(), 2);
216 for a in &ready {
217 driver.start(&table, a.proc, null_state(), &ops).unwrap();
218 }
219
220 assert!(
221 final_log.lock().unwrap().is_empty(),
222 "final processes must not have been touched by cycle 1 of the \
223 ordinary timestep loop"
224 );
225
226 // Cycle 2: both loop processes re-activated later in the same
227 // (fictitious) run.
228 queues
229 .push(
230 Region::Active,
231 act(2, loop_proc_a, 5, 0, ResumeArg::TimeElapsed),
232 )
233 .unwrap();
234 queues
235 .push(
236 Region::Active,
237 act(3, loop_proc_b, 5, 1, ResumeArg::TimeElapsed),
238 )
239 .unwrap();
240 let ready = form_ready_set(&mut queues, Region::Active).unwrap();
241 assert_eq!(ready.len(), 2);
242 for a in &ready {
243 driver
244 .resume_for(&table, a.proc, ResumeArg::TimeElapsed, null_state(), &ops)
245 .unwrap();
246 }
247
248 assert!(
249 final_log.lock().unwrap().is_empty(),
250 "final processes must still be untouched after 2 full \
251 Active-region form_ready_set/dispatch cycles that never \
252 mentioned them"
253 );
254 assert_eq!(
255 loop_log.lock().unwrap().len(),
256 4,
257 "sanity: the 2 unrelated processes really were dispatched twice each"
258 );
259
260 // Only now, via the explicit dedicated pass, do the final processes run.
261 run_finals(&mut finals, &table, null_state(), &ops, &driver);
262 let final_log = final_log.lock().unwrap();
263 assert_eq!(
264 final_log.len(),
265 3,
266 "run_finals must start every registered final process exactly once"
267 );
268 assert!(
269 final_log.iter().all(|(_, is_start)| *is_start),
270 "every final process must be reached via resume(Start), not some \
271 other ResumeArg"
272 );
273 }
274
275 /// SC-078 — 4 `FinalProcess` entries built in a shuffled `Vec` order but
276 /// with fixed `registration_order` values `[3,1,0,2]`; `run_finals` must
277 /// dispatch them `0,1,2,3` regardless of the input `Vec`'s own order.
278 /// Run twice, each from a fresh shuffled input, and assert both runs
279 /// produce the identical call sequence (determinism across "runs").
280 /// Fails if `run_finals` trusts the input `Vec`'s order instead of
281 /// sorting by `registration_order`.
282 #[test]
283 fn finals_run_in_stable_registration_order_across_repeated_runs() {
284 fn run_once(orders_in_input_order: [u64; 4]) -> Vec<u64> {
285 let log = Arc::new(Mutex::new(Vec::new()));
286 let mut table = ProcessTable::new();
287 let driver = ResumeDriver;
288 let ops = NoopOps;
289
290 let mut finals = Vec::new();
291 for ®istration_order in orders_in_input_order.iter() {
292 let proc = table.register(Box::new(LoggingProcess {
293 tag: registration_order,
294 log: Arc::clone(&log),
295 }));
296 finals.push(FinalProcess {
297 proc,
298 registration_order,
299 });
300 }
301
302 run_finals(&mut finals, &table, null_state(), &ops, &driver);
303 log.lock().unwrap().iter().map(|(tag, _)| *tag).collect()
304 }
305
306 let run1 = run_once([3, 1, 0, 2]);
307 assert_eq!(
308 run1,
309 vec![0, 1, 2, 3],
310 "run_finals must dispatch strictly by ascending registration_order"
311 );
312
313 let run2 = run_once([2, 0, 3, 1]);
314 assert_eq!(
315 run2,
316 vec![0, 1, 2, 3],
317 "a second run from a differently-shuffled input must produce the \
318 identical dispatch order (determinism across runs)"
319 );
320 }
321
322 /// SC-078 — two `FinalProcess` groups standing in for two different
323 /// hierarchical module instances, tagged with disjoint `registration_order`
324 /// ranges (`[5, 2]` and `[4, 1]`) and interleaved into one `finals: Vec`.
325 /// The merged run order must be purely numeric — `1, 2, 4, 5` — proving no
326 /// per-instance grouping leaks into the dispatch order (the LRM leaves
327 /// inter-block order unspecified only *within* the registration-order
328 /// space, and this crate's own policy is that there is exactly one such
329 /// space, never one per instance).
330 #[test]
331 fn finals_across_different_module_instances_still_use_one_global_order() {
332 let log = Arc::new(Mutex::new(Vec::new()));
333 let mut table = ProcessTable::new();
334 let driver = ResumeDriver;
335 let ops = NoopOps;
336
337 // Instance A's final blocks: registration_order 5, then 2.
338 // Instance B's final blocks: registration_order 4, then 1.
339 // Interleaved into one Vec in an order that matches neither instance's
340 // own internal order nor the final numeric order.
341 let instance_a = [5u64, 2u64];
342 let instance_b = [4u64, 1u64];
343 let interleaved: Vec<u64> =
344 vec![instance_a[0], instance_b[0], instance_a[1], instance_b[1]];
345
346 let mut finals = Vec::new();
347 for ®istration_order in &interleaved {
348 let proc = table.register(Box::new(LoggingProcess {
349 tag: registration_order,
350 log: Arc::clone(&log),
351 }));
352 finals.push(FinalProcess {
353 proc,
354 registration_order,
355 });
356 }
357
358 run_finals(&mut finals, &table, null_state(), &ops, &driver);
359 let order: Vec<u64> = log.lock().unwrap().iter().map(|(tag, _)| *tag).collect();
360 assert_eq!(
361 order,
362 vec![1, 2, 4, 5],
363 "the merged run order must be purely numeric by registration_order \
364 -- no per-instance grouping may leak in"
365 );
366 }
367}