vj_sim/sched/process_table.rs
1//! Process table & descriptor storage — Task 11 ("Ready-set unit of work &
2//! `ProcessTable`", `docs/superpowers/plans/2026-09-23-wave-A-sched.md`;
3//! ledger SC-057, SC-058, SC-063).
4//!
5//! `ProcessDescriptor` (per registered process), `ProcessTable::register`/
6//! `get`, and the `Mutex`/`AtomicBool` interior-mutability shape were shipped
7//! by an earlier commit (`555998c`/`3c69852`, scoped to `["SC-061"]` — making
8//! Task 12's `ResumeDriver::resume_for` dispatch `resume()` calls from
9//! multiple OS threads while still guaranteeing mutual exclusion per process,
10//! via `ProcessDescriptor::compiled`'s own `Mutex`, never an external `&mut`
11//! borrow of the whole table; see `resume.rs`'s module doc). This commit adds
12//! the rest of Task 11: `ProcessTable` is now genuinely `slotmap::SlotMap`-
13//! backed (SC-063's literal requirement, not just behaviorally equivalent to
14//! it — a `Vec` did not satisfy it no matter how correct its behavior was),
15//! with `register`'s/`get`'s public signatures unchanged so `resume.rs`
16//! required no edit; `WorkUnit` (SC-057); and `form_ready_set` (SC-058, with
17//! one documented signature deviation — see its own doc comment — since
18//! Task 10's `QueuedGate` does not exist yet in this tree).
19
20use std::sync::Mutex;
21use std::sync::atomic::AtomicBool;
22
23use vj_abi::Region;
24
25use super::{
26 CompiledProcess, JoinKind, PendingActivation, ProcessId, RegionQueuePushError, RegionQueues,
27 ResumeArg,
28};
29use crate::process::WaitSpec;
30
31/// One resumable process's scheduler-owned bookkeeping (scheduler.md §5; ledger
32/// SC-063's named shape, plus the fork/join fields Task 11/D1a add and the
33/// `Mutex`/`AtomicBool` review fix Task 11 records for SC-061 — see the plan's
34/// Task 11 "Review fix (SC-061 concurrency, Task 12)" note).
35pub struct ProcessDescriptor {
36 /// The compiled kernel this descriptor drives. `Mutex`-wrapped (not a bare
37 /// `Box`) so `ResumeDriver` can reach it through a *shared* `&ProcessTable`
38 /// and still get exclusive `&mut` access for the duration of one `resume()`
39 /// call — the actual SC-061 guarantee (`Mutex<T>: Sync` since
40 /// `CompiledProcess: Send`).
41 pub compiled: Mutex<Box<dyn CompiledProcess>>,
42 pub queued_this_pass: AtomicBool,
43 /// `ResumeDriver::start`'s once-only CAS (SC-059, Task 12, `resume.rs`).
44 /// Not written by this file.
45 pub started: AtomicBool,
46 pub wait_state: Option<WaitSpec>,
47 pub fork_parent: Option<ProcessId>,
48 /// The name(s) a `disable <label>;` anywhere may single this live
49 /// process out by (LRM 9.6.2) -- set once, at `Suspend::ForkJoin`
50 /// handling time, from the spawning `SpawnSpec::disable_name` this
51 /// process's own fork branch carried (`sched::control::Engine`'s own
52 /// `Suspend::ForkJoin` arm, its one writer); empty for a process no
53 /// label could ever single out (an unnamed branch of an unnamed fork,
54 /// or a process not spawned by a fork at all). `Engine::
55 /// collect_disable_named` is its one reader.
56 pub disable_names: Vec<String>,
57 /// Every live-or-recently-live immediate child of this process, across
58 /// ALL of its forks (LRM 9.6.1: `wait fork` waits for every immediate
59 /// child, 9.6.3: `disable fork` ends every descendant), oldest fork
60 /// first. `handle_fork` prunes entries already reclaimed.
61 pub fork_children: Vec<ProcessId>,
62 /// How many forks this process has executed: the generation of its
63 /// MOST RECENT fork. The join bookkeeping below (`fork_join_kind`,
64 /// `fork_remaining`, `fork_any_signaled`) belongs to that fork only; a
65 /// child of an earlier fork (`fork_group` != this) never touches it.
66 pub fork_gen: u64,
67 /// On a fork child: the parent's `fork_gen` at the moment this child was
68 /// spawned, i.e. which fork of the parent it belongs to.
69 pub fork_group: u64,
70 pub registration_order: u64,
71 pub fork_join_kind: Option<JoinKind>,
72 pub fork_remaining: u32,
73 pub fork_any_signaled: bool,
74 /// Task 9 (B-sched wave, SC-125): `Some(n)` while this process is
75 /// suspended via `Suspend::WaitFork`, counting how many of its own
76 /// `fork_children` were still live (not yet `Suspend::Done` + reclaimed)
77 /// at the moment `Engine::apply_suspend` installed this wait
78 /// (`apply_wait_fork`, `sched/control.rs`). Independent of
79 /// `fork_join_kind`/`fork_remaining` above (a real `wait fork;` is
80 /// always preceded by a `fork ... join_none`, which leaves
81 /// `fork_join_kind` permanently at `Some(JoinKind::None)` -- see
82 /// `handle_fork`'s own doc comment -- so there is no real overlap
83 /// between the two mechanisms). `None` when not currently waiting;
84 /// cleared back to `None` by `ResumeDriver::on_child_done` the instant
85 /// it reaches 0 and the parent has been notified via
86 /// `ResumeArg::AllForkChildrenDone`.
87 pub wait_fork_remaining: Option<u32>,
88 /// Task 9 (SC-125): `Some((order, next_index))` while this process is
89 /// suspended via `Suspend::WaitOrder(order)` (`apply_wait_order`,
90 /// `sched/control.rs`) -- `next_index` is the index into `order` of the
91 /// next completion this parent expects. `None` when not currently
92 /// waiting on an ordered join.
93 pub wait_order: Option<(Vec<ProcessId>, usize)>,
94 /// Task 9 (SC-125): latched `true` the first time a `wait_order`-
95 /// suspended parent observes an out-of-order completion
96 /// (`ResumeDriver::on_child_done`). Once set, no later completion can
97 /// ever satisfy this parent's wait -- every subsequent `on_child_done`
98 /// call against it returns `Ok(None)`, never re-fires the violation and
99 /// never silently accepts a reordering. The violation itself is
100 /// surfaced exactly once, as `Err(ResumeError::WaitOrderViolated(child))`
101 /// from the `on_child_done` call that first set this flag.
102 pub wait_order_violated: bool,
103 /// Task 9 (A2-part2, CG-102): this process's own `ProcessSideTable::
104 /// region_id`, threaded from `SchedulerControl::register_process`'s new
105 /// parameter (`sched/control.rs`'s own doc comment) -- defaults to `0`
106 /// for a process registered directly through `ProcessTable::register`
107 /// (fork children, and every Task 11/12-owned unit test in this file/
108 /// `finals.rs`/`resume.rs`, none of which has any notion of `region_id`
109 /// -- see `set_region_id`'s own doc comment for why this field's
110 /// initial value is set here rather than by widening `register`'s own
111 /// signature), which is also v1's own real, correct value for every one
112 /// of those processes (ARCHITECTURE §4 item 1: a single conservative
113 /// partition).
114 pub region_id: u32,
115 /// Classes plan Task 13 (LRM 9.7): `true` between `p.suspend()` and
116 /// `p.resume()`. A suspended process's activations are parked (see
117 /// `parked`) instead of dispatched.
118 pub suspended: bool,
119 /// The activation argument that arrived while `suspended`; replayed by
120 /// `p.resume()`. Later arrivals coalesce into it (the last one wins).
121 pub parked: Option<ResumeArg>,
122 /// `Some(t)` while this process is blocked in `t.await()`.
123 pub awaiting: Option<ProcessId>,
124 /// Classes plan Task 17 (LRM 15.3): `true` while this process is blocked
125 /// in a synchronisation-object call (`semaphore.get`) that the matching
126 /// `put` has not yet released ([`super::ProcessRequest::SyncBlock`]/
127 /// `SyncWake`).
128 pub sync_blocked: bool,
129 /// `true` while the process is yielded on a wait (`#`, `@`, `wait`,
130 /// `join`, ...) -- `process::WAITING`. Maintained only once some `process`
131 /// handle exists (`Engine::any_tracked`), so programs that never use the
132 /// class pay nothing.
133 pub waiting: bool,
134 /// The event wait this process is blocked in (`Suspend::WaitEvents`),
135 /// kept while `waiting` so `p.suspend()` can desensitise it (LRM 9.7.4)
136 /// and `p.resume()` resensitise it (LRM 9.7.5). Maintained under the
137 /// same gate as `waiting`.
138 pub wait_set: Option<super::EventWaitSet>,
139 /// `true` while `wait_set`'s dynamic sensitivity is removed because the
140 /// process is suspended; `resume()` reinstalls it.
141 pub desensitized: bool,
142 /// Whether this process runs in the Reactive region (LRM 17.7.3: a
143 /// checker's procedures, continuous assignments and output formal
144 /// connections are "scheduled in the Reactive region, similarly to
145 /// programs"). Every wake-up of such a process -- a sensitivity match,
146 /// including the one a write made by another Reactive process causes --
147 /// lands in the Reactive queue instead of Active. `false` for every
148 /// design process.
149 pub reactive: bool,
150 /// A program `initial` thread or a fork descendant of one (LRM 24.3).
151 pub program_thread: bool,
152}
153
154impl ProcessDescriptor {
155 fn new(compiled: Box<dyn CompiledProcess>, registration_order: u64) -> Self {
156 ProcessDescriptor {
157 compiled: Mutex::new(compiled),
158 queued_this_pass: AtomicBool::new(false),
159 started: AtomicBool::new(false),
160 wait_state: None,
161 fork_parent: None,
162 disable_names: Vec::new(),
163 fork_children: Vec::new(),
164 fork_gen: 0,
165 fork_group: 0,
166 registration_order,
167 fork_join_kind: None,
168 fork_remaining: 0,
169 fork_any_signaled: false,
170 wait_fork_remaining: None,
171 wait_order: None,
172 wait_order_violated: false,
173 region_id: 0,
174 suspended: false,
175 parked: None,
176 awaiting: None,
177 sync_blocked: false,
178 waiting: false,
179 wait_set: None,
180 desensitized: false,
181 reactive: false,
182 program_thread: false,
183 }
184 }
185}
186
187slotmap::new_key_type! {
188 /// The `SlotMap`'s own internal key type (SC-063's literal "ProcessTable
189 /// is a `SlotMap<ProcessId, ProcessDescriptor{...}>`" requirement). Never
190 /// exposed outside this module — `ProcessId` (a plain, stable, densely-
191 /// assigned `u32`) remains the only process identity every other module
192 /// (`resume.rs`, `queues.rs`, `sched/mod.rs`'s own types) sees, via the
193 /// side index below.
194 pub struct ProcessKey;
195}
196
197/// Process storage, genuinely `slotmap::SlotMap`-backed (SC-063). `ProcessId`
198/// (`u32`) stays the crate-visible identity: v1 never frees/reuses a slot
199/// (fork children get fresh ids from the same monotonic sequence; nothing
200/// calls `SlotMap::remove`), so `order_to_key[id as usize]` is a stable,
201/// densely-assigned `ProcessId -> ProcessKey` side index — the same "assigned
202/// order equals registration order, for the lifetime of a run" invariant the
203/// previous `Vec`-backed slice already relied on, preserved exactly across
204/// this migration.
205#[derive(Default)]
206pub struct ProcessTable {
207 slots: slotmap::SlotMap<ProcessKey, ProcessDescriptor>,
208 order_to_key: Vec<ProcessKey>,
209}
210
211impl ProcessTable {
212 pub fn new() -> Self {
213 ProcessTable {
214 slots: slotmap::SlotMap::with_key(),
215 order_to_key: Vec::new(),
216 }
217 }
218
219 /// Registers a new process, assigning it the next `ProcessId` and
220 /// `registration_order` (monotonic, never reused — v1 never frees a slot,
221 /// matching Task 11's documented assumption that `ProcessId` assignment
222 /// order equals `registration_order` for the lifetime of a run).
223 /// Signature unchanged by the slotmap migration — `resume.rs` (Task 12)
224 /// keeps compiling with no edit of its own.
225 pub fn register(&mut self, compiled: Box<dyn CompiledProcess>) -> ProcessId {
226 let id = self.order_to_key.len() as ProcessId;
227 let key = self
228 .slots
229 .insert(ProcessDescriptor::new(compiled, id as u64));
230 self.order_to_key.push(key);
231 id
232 }
233
234 /// How many `ProcessId`s were ever assigned: ids are dense, `0..count()`.
235 pub fn count(&self) -> usize {
236 self.order_to_key.len()
237 }
238
239 /// Shared-reference lookup (Task 11's review fix) — lets `ResumeDriver`
240 /// reach a descriptor's `Mutex`/`AtomicBool` fields through a shared
241 /// `&ProcessTable`, never requiring `&mut` access to the whole table.
242 /// Signature unchanged by the slotmap migration.
243 pub fn get(&self, id: ProcessId) -> Option<&ProcessDescriptor> {
244 let key = *self.order_to_key.get(id as usize)?;
245 self.slots.get(key)
246 }
247
248 /// Mutable lookup — **added by Task 12** (a minimal, same-track,
249 /// additive extension to this Task-11-owned file; recorded in
250 /// `docs/CONTRACTS.md`/this commit's deviations, not a rewrite of
251 /// anything Task 11 shipped). `handle_fork`/`on_child_done` mutate
252 /// `fork_children`/`fork_parent`/`fork_join_kind`/`fork_remaining`/
253 /// `fork_any_signaled` directly — plain, non-`Mutex` fields per Task
254 /// 11/D1a — which needs exclusive access to the whole table via
255 /// `&mut ProcessTable`. This does not reopen SC-061: Task 12's review
256 /// fix note (resume.rs) already establishes this wave never calls
257 /// `handle_fork`/`on_child_done` concurrently with `resume_for`/`start`
258 /// (v1's default `SequentialController` dispatches everything from one
259 /// thread; only the SC-061 stress test deliberately drives `resume_for`
260 /// from many, and never touches fork state).
261 pub fn get_mut(&mut self, id: ProcessId) -> Option<&mut ProcessDescriptor> {
262 let key = *self.order_to_key.get(id as usize)?;
263 self.slots.get_mut(key)
264 }
265
266 /// **Task 9 (A2-part2, CG-102).** Sets `id`'s own `region_id`, called by
267 /// `SchedulerControl::register_process` (`sched/control.rs`) right after
268 /// `register` -- a small, additive, same-track extension of this Task-
269 /// 11-owned file (mirroring Task 12's own `get_mut` precedent, recorded
270 /// there identically), rather than widening `register`'s own signature:
271 /// `register`'s direct callers (`finals.rs`, `resume.rs`, this file's
272 /// own unit tests, fork-child minting) have no notion of `region_id` at
273 /// all and are unrelated to CG-102's scheduler-dispatch scope, so
274 /// leaving `register`'s arity untouched keeps this a minimal, targeted
275 /// addition instead of churning every one of those call sites for a
276 /// field they'd only ever pass `0` for anyway (`ProcessDescriptor::new`
277 /// already defaults `region_id` to that same real v1 value).
278 pub fn set_region_id(&mut self, id: ProcessId, region_id: u32) {
279 if let Some(desc) = self.get_mut(id) {
280 desc.region_id = region_id;
281 }
282 }
283
284 /// Marks `id` as a Reactive-region process ([`ProcessDescriptor::reactive`]).
285 pub fn set_reactive(&mut self, id: ProcessId) {
286 if let Some(desc) = self.get_mut(id) {
287 desc.reactive = true;
288 }
289 }
290
291 /// Whether any live fork child still belongs to a program thread
292 /// (LRM 24.3).
293 pub fn any_live_program_child(&self) -> bool {
294 self.slots
295 .values()
296 .any(|d| d.program_thread && d.fork_parent.is_some())
297 }
298
299 /// Whether `id` runs in the Reactive region. `false` for an unknown id.
300 pub fn is_reactive(&self, id: ProcessId) -> bool {
301 self.get(id).is_some_and(|d| d.reactive)
302 }
303
304 /// **Task 9 (A2-part2, CG-102).** `region_id(&self, id: ProcessId) ->
305 /// u32` -- the plan's own literal getter shape. Returns `0` (v1's own
306 /// real, correct single-conservative-partition value, not a sentinel)
307 /// for an unknown `id` rather than panicking or returning `Option`: a
308 /// caller reaching this (e.g. `dispatch_ready_set`) only ever holds
309 /// `ProcessId`s this same `Engine` minted, so an unknown id here would
310 /// already indicate a bug in the caller's own bookkeeping, not a
311 /// legitimate "no region_id set" state this getter needs to
312 /// distinguish.
313 pub fn region_id(&self, id: ProcessId) -> u32 {
314 self.get(id).map(|d| d.region_id).unwrap_or(0)
315 }
316
317 /// **Task 7 (B-sched wave, ledger SC-062): the `SlotMap::remove`
318 /// wrapper this file's own module doc comment already promised** ("...
319 /// stable... until eventually calls `SlotMap::remove` -- a forward
320 /// reference to work that was never done" -- now done). Removes `id`'s
321 /// descriptor and returns it; a later `get`/`get_mut` for the same `id`
322 /// returns `None`.
323 ///
324 /// **Deviation from the plan's literal text (recorded here and in
325 /// `docs/CONTRACTS.md`):** the plan additionally describes tombstoning
326 /// `id`'s own `order_to_key` side-index slot. That is unnecessary and
327 /// deliberately not done: `slotmap::SlotMap::remove` already bumps the
328 /// removed slot's internal generation, so the *same* `ProcessKey`
329 /// value still sitting in `order_to_key[id]` no longer resolves via
330 /// `self.slots.get`/`get_mut` -- this is the entire reason Task 11
331 /// required a genuine `SlotMap` (SC-063) rather than a `Vec` in the
332 /// first place. Adding a second, hand-maintained tombstone layer on
333 /// top of that would only be able to drift out of sync with the
334 /// `SlotMap`'s own source of truth; leaving `order_to_key` untouched
335 /// keeps exactly one source of truth for liveness, and a stale/reused
336 /// `ProcessId` still fails loudly via `get`'s existing `Option` return,
337 /// exactly as the plan's own acceptance requires.
338 pub fn remove(&mut self, id: ProcessId) -> Option<ProcessDescriptor> {
339 let key = *self.order_to_key.get(id as usize)?;
340 self.slots.remove(key)
341 }
342}
343
344/// The scheduler's work-item type: wraps exactly one `resume()` invocation
345/// (SC-057 — "the unit of parallel/schedulable work is one process
346/// activation... within one activation the compiled kernel runs to
347/// completion on one thread and never yields mid-statement"). Distinct from
348/// `determinism::WorkItem` (which additionally carries the ordering key) —
349/// `WorkUnit` is the pre-ordering, "what to run" half.
350#[derive(Clone, Copy, Debug)]
351pub struct WorkUnit {
352 pub proc: ProcessId,
353 pub arg: ResumeArg,
354}
355
356/// Collects every activation ready at one `(SchedTime, Region)` into a single
357/// ready-set object before dispatch (SC-058). Drains `region`'s queue (Task
358/// 4's `RegionQueues`) and hands back the un-sorted `Vec` — Task 2's
359/// `sort_ready_set` is applied by the caller, kept separate so this
360/// function's contract is purely "collect what's ready".
361///
362/// `Region::Nba` has no queue of its own (Task 4/6 — NBA is a staging log,
363/// not a `RegionQueues` member), so it is rejected with the same typed
364/// `RegionQueuePushError::NbaHasNoQueue` `RegionQueues::push` already uses,
365/// exhaustive over all 7 `Region` variants with no wildcard arm — never a
366/// silently-returned empty `Vec`, which a caller could not distinguish from
367/// "genuinely nothing ready this pass".
368///
369/// **Deviation from the plan's literal signature** (recorded in
370/// `docs/CONTRACTS.md` and this task's commit): the plan names a third
371/// parameter, `gate: &QueuedGate`, to reset each drained activation's dedup
372/// flag as it's pulled. `QueuedGate` is Task 10's own type
373/// (`docs/superpowers/plans/2026-09-23-wave-A-sched.md` Task 10, ledger
374/// SC-055/SC-056) and does not exist in this tree as of this commit — Task
375/// 10 has not been implemented yet, and SC-055/SC-056 are not in this task's
376/// assigned ledger scope (`["SC-057","SC-058","SC-063"]`). SC-058's own
377/// literal acceptance text ("all processes ready at the same (time,region)
378/// are collected into one ready-set object before dispatch") says nothing
379/// about a dedup-flag reset, so this task implements exactly that, self-
380/// contained. Task 10, when it lands, is expected to extend this function's
381/// signature (or wrap it) to also reset each drained activation's dedup
382/// entry — either via its own `QueuedGate` or via the `queued_this_pass`
383/// `AtomicBool` already present on each activation's `ProcessDescriptor`
384/// (`ProcessTable::get(act.proc)`), which this task deliberately leaves
385/// untouched rather than inventing Task 10's own type ahead of its task.
386pub fn form_ready_set(
387 queues: &mut RegionQueues,
388 region: Region,
389) -> Result<Vec<PendingActivation>, RegionQueuePushError> {
390 match region {
391 Region::Active => Ok(queues.drain_active()),
392 Region::Inactive => Ok(queues.drain_inactive()),
393 Region::Reactive => Ok(queues.drain_reactive_once()),
394 Region::Postponed => Ok(queues.drain_postponed_once()),
395 Region::Nba => Err(RegionQueuePushError::NbaHasNoQueue),
396 // sva T1 (§1.4, review fix, blocking finding 2): `Region::Preponed`
397 // is queue-less for the identical reason `Region::Nba` is -- a live
398 // SVA thread/snapshot push is never dispatched through
399 // `RegionQueues`/`ProcessTable` as a `PendingActivation` at all.
400 Region::Preponed => Err(RegionQueuePushError::PreponedHasNoQueue),
401 // sva T2 (review fix, blocking finding 2), Observed's half: equally
402 // queue-less -- a live SVA thread is owned by the automaton engine's
403 // own thread table (a later task), never dispatched here either.
404 Region::Observed => Err(RegionQueuePushError::ObservedHasNoQueue),
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::sched::{ResumeArg, SchedulerOps, Suspend};
412 use std::sync::atomic::Ordering;
413 use vj_abi::SimHandle;
414
415 struct NoopProcess;
416 impl CompiledProcess for NoopProcess {
417 fn resume(
418 &mut self,
419 _state: SimHandle,
420 _arg: ResumeArg,
421 _ops: &dyn SchedulerOps,
422 ) -> Suspend {
423 Suspend::Done
424 }
425 }
426
427 #[test]
428 fn register_assigns_ids_in_call_order() {
429 let mut table = ProcessTable::new();
430 let a = table.register(Box::new(NoopProcess));
431 let b = table.register(Box::new(NoopProcess));
432 let c = table.register(Box::new(NoopProcess));
433 assert_eq!([a, b, c], [0, 1, 2]);
434 assert_eq!(table.get(a).unwrap().registration_order, 0);
435 assert_eq!(table.get(b).unwrap().registration_order, 1);
436 assert_eq!(table.get(c).unwrap().registration_order, 2);
437 }
438
439 #[test]
440 fn get_returns_none_for_an_unregistered_id() {
441 let mut table = ProcessTable::new();
442 let only = table.register(Box::new(NoopProcess));
443 assert!(table.get(only).is_some());
444 assert!(table.get(only + 1).is_none());
445 }
446
447 #[test]
448 fn new_descriptor_starts_unqueued_not_started_and_with_no_fork_state() {
449 let mut table = ProcessTable::new();
450 let id = table.register(Box::new(NoopProcess));
451 let desc = table.get(id).unwrap();
452 assert!(!desc.queued_this_pass.load(Ordering::Relaxed));
453 assert!(!desc.started.load(Ordering::Relaxed));
454 assert!(desc.wait_state.is_none());
455 assert!(desc.fork_parent.is_none());
456 assert!(desc.fork_children.is_empty());
457 assert!(desc.fork_join_kind.is_none());
458 assert_eq!(desc.fork_remaining, 0);
459 assert!(!desc.fork_any_signaled);
460 // Task 9 (SC-125): a freshly registered process is not mid-`wait
461 // fork`/`wait_order` either.
462 assert!(desc.wait_fork_remaining.is_none());
463 assert!(desc.wait_order.is_none());
464 assert!(!desc.wait_order_violated);
465 // `compiled` is reachable only through the lock, never as a bare
466 // `Box` — proves it is `Mutex<Box<dyn CompiledProcess>>`.
467 let _guard = desc.compiled.lock().unwrap();
468 }
469
470 // --- Task 11 remaining work (SC-057, SC-058, SC-063) ---
471
472 /// SC-063 — the slotmap migration's own regression proof beyond re-running
473 /// the tests above: register 5 processes, assert the returned `ProcessId`s
474 /// are still usable as stable, densely-assigned keys matching the
475 /// pre-migration `Vec`-index behavior exactly. Fails if the
476 /// `ProcessId <-> ProcessKey` side-index has an off-by-one or reordering
477 /// bug the `Vec`-backed version couldn't have had.
478 #[test]
479 fn slotmap_migration_preserves_ids_and_registration_order() {
480 let mut table = ProcessTable::new();
481 let ids: Vec<ProcessId> = (0..5)
482 .map(|_| table.register(Box::new(NoopProcess)))
483 .collect();
484 assert_eq!(ids, vec![0, 1, 2, 3, 4]);
485 for id in ids {
486 assert_eq!(
487 table.get(id).unwrap().registration_order,
488 id as u64,
489 "ProcessId {id} must map to the descriptor registered at that order"
490 );
491 }
492 }
493
494 /// SC-063, review fix — a behavior-preserving migration alone doesn't
495 /// prove the literal type requirement, since a `Vec` and a fresh
496 /// `SlotMap` are behaviorally indistinguishable from outside when
497 /// nothing is ever removed. Source-text scan (no shell plumbing, pure
498 /// Rust `std::fs::read_to_string`), and — unlike the first version of
499 /// this test — genuinely non-tautological:
500 ///
501 /// - It strips every `#[cfg(test)]` body before scanning, via the
502 /// shared `determinism::scan_support::strip_cfg_test_blocks` (the
503 /// same technique, reused rather than reimplemented, that Task 3's
504 /// `dt006_no_thread_spawn_or_rollback_code_outside_determinism_module`
505 /// and Task 22's `notify_change` scan already use, for the identical
506 /// reason). Without this, the scan reads its *own* assertion's string
507 /// literal back and is trivially true regardless of `ProcessTable`'s
508 /// real storage.
509 /// - It searches for the field declaration's exact type signature
510 /// (`"slots: slotmap::SlotMap<ProcessKey"`), not the generic phrase
511 /// `"slotmap::SlotMap"`. That phrase also appears, correctly, in this
512 /// module's own doc comments describing the migration (this doc
513 /// comment included) — a scan for just the phrase would keep passing
514 /// even after `slots` regressed to `Vec<ProcessDescriptor>`, as long
515 /// as the prose describing the (no-longer-true) migration was left
516 /// stale, which is exactly the kind of edit that "happens to keep all
517 /// other tests green".
518 ///
519 /// `scan_is_genuinely_slotmap_backed` below is shared with
520 /// `slotmap_scan_rejects_a_reverted_vec_even_with_stale_doc_comment_mentions`,
521 /// which proves this check actually fails on a reverted file — the
522 /// thing the original version of this test could never do.
523 fn scan_is_genuinely_slotmap_backed(src: &str) -> bool {
524 let production_only = crate::determinism::scan_support::strip_cfg_test_blocks(src);
525 production_only.contains("slots: slotmap::SlotMap<ProcessKey")
526 }
527
528 #[test]
529 fn process_table_is_genuinely_slotmap_backed() {
530 let src = std::fs::read_to_string(concat!(
531 env!("CARGO_MANIFEST_DIR"),
532 "/src/sched/process_table.rs"
533 ))
534 .expect("must be able to read process_table.rs's own source");
535 assert!(
536 scan_is_genuinely_slotmap_backed(&src),
537 "ProcessTable's `slots` field must be genuinely SlotMap-backed (SC-063), \
538 not just behaviorally equivalent to one -- scanning production code \
539 only (test bodies stripped) for the field's actual declared type"
540 );
541 }
542
543 /// Review-fix regression proof: confirms the check above is not
544 /// tautological by feeding `scan_is_genuinely_slotmap_backed` a
545 /// synthetic "reverted to `Vec`" source that still contains the
546 /// generic phrase `"slotmap::SlotMap"` both in a stale doc comment and
547 /// inside a `#[cfg(test)]` body (exactly the shape that made the
548 /// pre-fix version of this gate unable to ever fail) and asserts the
549 /// real check correctly rejects it.
550 #[test]
551 fn slotmap_scan_rejects_a_reverted_vec_even_with_stale_doc_comment_mentions() {
552 let reverted_src = concat!(
553 "//! Process storage, genuinely `slotmap::SlotMap`-backed (SC-063).\n",
554 "pub struct ProcessTable {\n",
555 " slots: Vec<ProcessDescriptor>,\n",
556 "}\n",
557 "#[cfg(test)]\n",
558 "mod tests {\n",
559 " #[test]\n",
560 " fn t() {\n",
561 " assert!(\"x\".contains(\"slotmap::SlotMap\"));\n",
562 " }\n",
563 "}\n",
564 );
565 // Sanity check on the fixture itself: the generic phrase IS present
566 // (in both the doc comment and the #[cfg(test)] body) -- this is
567 // exactly what made the pre-fix gate tautologically true.
568 assert!(reverted_src.contains("slotmap::SlotMap"));
569 assert!(
570 !scan_is_genuinely_slotmap_backed(reverted_src),
571 "a source with `slots: Vec<...>` must be rejected even though the \
572 generic phrase \"slotmap::SlotMap\" still appears in a doc comment \
573 and in a #[cfg(test)] body"
574 );
575 }
576
577 /// Positive control for the same helper, kept separate from the
578 /// filesystem-reading test above: guards against the marker string
579 /// itself silently drifting out of sync with the real field
580 /// declaration on line 92 (e.g. a whitespace/formatting change) by
581 /// checking it against that exact declaration text in isolation.
582 #[test]
583 fn slotmap_scan_accepts_the_actual_current_field_declaration() {
584 assert!(scan_is_genuinely_slotmap_backed(
585 "slots: slotmap::SlotMap<ProcessKey, ProcessDescriptor>,"
586 ));
587 }
588
589 /// SC-058 — push 4 activations at the same `(SchedTime, Region::Active)`
590 /// via `RegionQueues::push` (Task 4); calling `form_ready_set` must
591 /// collect all of them into one `Vec` (not 4 separate calls/objects).
592 #[test]
593 fn form_ready_set_collects_all_activations_at_one_time_region_as_one_object() {
594 use crate::sched::{PendingActivation, RegionQueues, ResumeArg, SchedTime};
595 use vj_abi::Region;
596
597 let mut queues = RegionQueues::new();
598 for i in 0..4u64 {
599 queues
600 .push(
601 Region::Active,
602 PendingActivation {
603 id: i,
604 proc: i as u32,
605 arg: ResumeArg::Start,
606 reconv_iter: 0,
607 logical_order: (SchedTime { ticks: 5 }, Region::Active, i),
608 },
609 )
610 .unwrap();
611 }
612
613 let ready = form_ready_set(&mut queues, Region::Active).unwrap();
614 assert_eq!(
615 ready.len(),
616 4,
617 "all processes ready at the same (time, region) must be collected \
618 into one ready-set object before dispatch"
619 );
620 }
621
622 /// SC-058 negative half — `Region::Nba` has no queue of its own (it is
623 /// the NBA staging log, Task 6), so `form_ready_set` must reject it with
624 /// the same typed error `RegionQueues::push` already uses, never a
625 /// silent empty `Vec` that could be confused with "genuinely nothing
626 /// ready this pass".
627 #[test]
628 fn form_ready_set_rejects_nba_with_a_typed_error_not_a_silent_empty_vec() {
629 use crate::sched::{RegionQueuePushError, RegionQueues};
630 use vj_abi::Region;
631
632 let mut queues = RegionQueues::new();
633 let result = form_ready_set(&mut queues, Region::Nba);
634 assert_eq!(result.unwrap_err(), RegionQueuePushError::NbaHasNoQueue);
635 }
636
637 /// sva T1 (§1.4, review fix, blocking finding 2): the identical negative
638 /// half for `Region::Preponed` -- a pure snapshot push with no ready
639 /// processes of its own, never dispatched through `form_ready_set`.
640 #[test]
641 fn form_ready_set_preponed_returns_named_error() {
642 use crate::sched::{RegionQueuePushError, RegionQueues};
643 use vj_abi::Region;
644
645 let mut queues = RegionQueues::new();
646 let result = form_ready_set(&mut queues, Region::Preponed);
647 assert_eq!(
648 result.unwrap_err(),
649 RegionQueuePushError::PreponedHasNoQueue
650 );
651 }
652
653 /// sva T2 (review fix, blocking finding 2), Observed's half: the
654 /// identical negative half for `Region::Observed` -- a live SVA thread
655 /// owned by the automaton engine's own thread table, never dispatched
656 /// through `form_ready_set`.
657 #[test]
658 fn form_ready_set_observed_returns_named_error() {
659 use crate::sched::{RegionQueuePushError, RegionQueues};
660 use vj_abi::Region;
661
662 let mut queues = RegionQueues::new();
663 let result = form_ready_set(&mut queues, Region::Observed);
664 assert_eq!(
665 result.unwrap_err(),
666 RegionQueuePushError::ObservedHasNoQueue
667 );
668 }
669
670 /// SC-057 — a type-level test: `WorkUnit` has no field representing a
671 /// partial statement offset (only `proc`+`arg`); combined with
672 /// `CompiledProcess::resume`'s own signature (unchanged: `&mut self,
673 /// state, arg, ops) -> Suspend`), there is no API in this module that
674 /// could invoke half of a `resume()` call. Checked by `WorkUnit`'s field
675 /// list via exhaustive destructuring (a third field added later without
676 /// updating this test would fail to compile).
677 #[test]
678 fn work_unit_wraps_exactly_one_resume_invocation_no_mid_statement_split() {
679 use crate::sched::ResumeArg;
680
681 let unit = WorkUnit {
682 proc: 7,
683 arg: ResumeArg::Start,
684 };
685 let WorkUnit { proc, arg } = unit;
686 assert_eq!(proc, 7);
687 assert!(matches!(arg, ResumeArg::Start));
688 }
689
690 /// Task 9 (A2-part2, CG-102): a freshly registered process defaults to
691 /// `region_id == 0` (v1's own real value, not a placeholder -- see
692 /// `ProcessDescriptor::region_id`'s own doc comment), and `set_region_id`
693 /// genuinely stores whatever value it is given, read back unchanged by
694 /// `region_id` -- fails if either getter/setter ignores its argument.
695 #[test]
696 fn region_id_defaults_to_zero_and_set_region_id_is_read_back_by_region_id() {
697 let mut table = ProcessTable::new();
698 let id = table.register(Box::new(NoopProcess));
699 assert_eq!(table.region_id(id), 0);
700
701 table.set_region_id(id, 9);
702 assert_eq!(
703 table.region_id(id),
704 9,
705 "set_region_id/region_id must round-trip a real value, not stay pinned at 0"
706 );
707 }
708
709 /// `region_id` on an unregistered id returns the same real v1 default
710 /// (`0`) rather than panicking -- see its own doc comment.
711 #[test]
712 fn region_id_of_an_unregistered_id_is_zero_not_a_panic() {
713 let table = ProcessTable::new();
714 assert_eq!(table.region_id(42), 0);
715 }
716
717 // --- Task 7 (B-sched wave, ledger SC-062) ---
718
719 /// The literal SC-062 acceptance clause this task closes: "the child's
720 /// slot is reclaimed" -- `remove` must drop the descriptor from the
721 /// `SlotMap` itself (not merely mark it dead some other way), proven by
722 /// a `get` afterward returning `None`. Fails if `remove` is a no-op, or
723 /// if it only clears some other field while leaving the slot live.
724 #[test]
725 fn remove_drops_the_descriptor_and_a_later_get_returns_none() {
726 let mut table = ProcessTable::new();
727 let a = table.register(Box::new(NoopProcess));
728 let b = table.register(Box::new(NoopProcess));
729
730 assert!(table.get(a).is_some());
731 let removed = table.remove(a);
732 assert!(
733 removed.is_some(),
734 "remove must return the removed descriptor, not None, for a live id"
735 );
736 assert!(
737 table.get(a).is_none(),
738 "a later get() for a removed ProcessId must return None -- the slot \
739 itself must be gone from the SlotMap, not just logically ignored"
740 );
741 // A sibling process registered before the removed one must be
742 // completely unaffected -- proves `remove` does not disturb the
743 // rest of the table (e.g. by shifting indices the way a `Vec::
744 // remove` would).
745 assert!(
746 table.get(b).is_some(),
747 "removing one process must not affect an unrelated, still-live one"
748 );
749 }
750
751 /// A second `remove` of the same, already-removed id is a harmless
752 /// `None`, never a panic -- `SlotMap::remove`'s own generational-key
753 /// semantics already guarantee this, but this pins it as this crate's
754 /// own contract too (never a silent double-free-shaped bug surfacing
755 /// as a panic instead of a typed `None`).
756 #[test]
757 fn removing_an_already_removed_id_returns_none_not_a_panic() {
758 let mut table = ProcessTable::new();
759 let a = table.register(Box::new(NoopProcess));
760 assert!(table.remove(a).is_some());
761 assert!(table.remove(a).is_none());
762 }
763
764 /// `remove` of an id that was never registered at all returns `None`,
765 /// never a panic -- mirrors `get`'s own established posture for an
766 /// out-of-range id.
767 #[test]
768 fn removing_an_unregistered_id_returns_none_not_a_panic() {
769 let mut table = ProcessTable::new();
770 assert!(table.remove(42).is_none());
771 }
772}