Skip to main content

vj_sim/state/
kernel_ctx.rs

1//! Task 19 — `KernelCtx` field-ownership boundary and the compiled-kernel
2//! purity contract (ledger ST-131, ST-132; ARCHITECTURE §6.2/state.md §3).
3//!
4//! Wave A has no CODEGEN track (ENGINE's compiled-kernel `KernelCtx` type —
5//! current sim time, the rest of the suspend/resume contract — is jointly
6//! defined by ENGINE and CODEGEN, neither of which is an owning track this
7//! wave). This module therefore does **not** attempt to redefine the whole
8//! `KernelCtx`: it names only the two fields ST-131 says STATE itself owns
9//! ([`StateKernelCtxFields`]), and states + tests the STATE-side half of the
10//! ST-132 purity contract ([`PureKernel`] plus the structural scan below).
11//!
12//! **Unresolved-review-findings note (ST-146, plan review, blocking):** the
13//! review flags Task 14's `NetShape::AliasedSingleDriver` default as a
14//! *unilateral* STATE decision masquerading as ST-146's bilateral
15//! STATE/CODEGEN sign-off, since no CODEGEN track exists this wave to
16//! actually agree. That finding is about ST-146 / Task 14's net-read-codegen
17//! default, not about this task's ST-131/ST-132 scope (this module never
18//! touches `NetShape`/`NetLayout` at all), so it is out of scope here and is
19//! not re-litigated by this file. Nothing in this task claims a bilateral
20//! sign-off: [`StateKernelCtxFields`]'s doc comment below states explicitly
21//! that it names only STATE's own fields, exactly as ST-131 requires, with
22//! no joint-decision claim being made either way.
23
24use std::ffi::c_void;
25
26use vj_abi::{SimHandle, SlotId};
27
28use super::write::VcState;
29
30/// ST-131: **this struct names only the fields STATE owns.** The full
31/// `KernelCtx` a compiled kernel actually receives (current sim time, the
32/// rest of the suspend/resume contract) is jointly defined by ENGINE/CODEGEN
33/// and is out of this crate's scope — it is **not** redefined here. This is
34/// a documented sub-shape other tracks' real `KernelCtx` embeds (by value or
35/// by reference), never a claim that STATE owns or defines the whole thing.
36///
37/// - `arena_resolution_cb`: the resolution-callback vtable slot
38///   ARCHITECTURE §6.2 / state.md §3 name as jointly owned — the entry point
39///   a compiled kernel calls to resolve a net read through STATE's own
40///   multi-driver resolution (`driver::resolve_net_step`), rather than
41///   reading a slot directly. Its exact wiring to a real elaborated net is
42///   out of this wave's scope (see the plan's Deferred section, `NetKind`
43///   gap); this field only names the calling-convention slot, not a live
44///   implementation of it.
45/// - `vc_side_channel`: an opaque pointer to this crate's speculative-buffer
46///   append point (Task 8's [`VcState`]) — the mechanism a compiled kernel's
47///   write path reaches to append a `VcRecord`, without STATE handing the
48///   kernel anything more specific than an opaque handle it round-trips back
49///   through STATE's own accessor ABI.
50///
51/// `#[repr(C)]`: this struct's fields are the exact bytes a compiled-kernel
52/// calling convention embeds, so its layout must be FFI-stable — no field
53/// reordering/padding a Rust-only `#[repr(Rust)]` struct would otherwise be
54/// free to apply.
55#[repr(C)]
56pub struct StateKernelCtxFields {
57    pub arena_resolution_cb: extern "C" fn(handle: SimHandle, net: SlotId),
58    pub vc_side_channel: *mut c_void,
59}
60
61impl StateKernelCtxFields {
62    /// Builds the STATE-owned sub-shape from a live [`super::StateEngine`]'s
63    /// own speculative-buffer state, keeping the actual pointer construction
64    /// (and its safety argument) in exactly one place rather than letting
65    /// every future caller re-derive `Rc::as_ptr` casts of its own.
66    ///
67    /// `vc_side_channel` is `Rc::as_ptr(vc)` cast to an opaque `*mut c_void`
68    /// — the same [`VcState`] Task 8/9's `commit_write_at` already threads
69    /// through every write path this crate has, not a copy or a decoy
70    /// stand-in for it. The engine keeps its own `Rc<VcState>` alive for at
71    /// least as long as any `StateKernelCtxFields` built from it is in use
72    /// (the same lifetime discipline `InstanceHandle`'s own `Rc<VcState>`
73    /// clone already relies on) — this constructor takes `&std::rc::Rc<VcState>`
74    /// directly (not `&StateEngine`) so that discipline is visible at the
75    /// call site rather than hidden behind a second field access.
76    ///
77    /// `#[allow(dead_code)]` outside test builds: this wave has no CODEGEN/
78    /// ENGINE consumer yet to call this from real kernel-invocation code
79    /// (see this module's top doc comment) — it exists and is exercised by
80    /// `vc_side_channel_pointer_round_trips_to_the_same_speculative_buffer`
81    /// below, which is this task's own acceptance test for it, not dead API.
82    #[allow(dead_code)]
83    pub(crate) fn new(
84        arena_resolution_cb: extern "C" fn(handle: SimHandle, net: SlotId),
85        vc: &std::rc::Rc<VcState>,
86    ) -> Self {
87        StateKernelCtxFields {
88            arena_resolution_cb,
89            // `Rc::as_ptr` never bumps the strong count and never moves the
90            // pointee — the raw pointer stays valid for exactly as long as
91            // some `Rc<VcState>` clone (the engine's own, an `InstanceHandle`'s,
92            // or both) keeps the allocation alive, which callers of this
93            // constructor are required to uphold (documented above).
94            vc_side_channel: std::rc::Rc::as_ptr(vc) as *mut VcState as *mut c_void,
95        }
96    }
97}
98
99/// ST-132: a marker trait, no methods. A `ProcessKernelFn`/`ProcessFn`
100/// implementation that also implements `PureKernel` is asserting the
101/// contract ST-132 requires: it is derivable purely from `(base, ctx)`, with
102/// no other mutable state (no hidden thread-local/global) reachable from
103/// inside it. This crate cannot *compel* a compiled kernel to implement this
104/// trait (CODEGEN's own IR emitter, out of this wave's scope, would need a
105/// `#[forbid(...)]`-style procedural check for a compiler-level guarantee) —
106/// STATE's own enforcement mechanism this wave is the structural lint test
107/// below: proving that nothing STATE itself exposes to a kernel author
108/// *encourages* violating the contract (no STATE-owned API hands out a
109/// thread-local or global mutable handle a kernel could close over instead
110/// of going through `(base, ctx)`).
111pub trait PureKernel {}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::state::instance::{StateEngine, TemplateRegistry};
117    use crate::state::write::{SlotLocation, commit_write_at};
118    use crate::state::{FourStateValue, SlotClass};
119    use vj_abi::SimTime;
120
121    fn fresh_engine() -> StateEngine {
122        StateEngine::new(TemplateRegistry::new())
123    }
124
125    fn narrow_word(val: u64, unk: u64) -> u128 {
126        (val as u128) | ((unk as u128) << 64)
127    }
128
129    fn t(units: i64) -> SimTime {
130        SimTime {
131            sim_units: units,
132            delta: 0,
133        }
134    }
135
136    extern "C" fn dummy_resolution_cb(_handle: SimHandle, _net: SlotId) {}
137
138    /// ST-131: a compile-level proof — this struct literal fails to compile
139    /// (not merely fails an assertion) if `StateKernelCtxFields` gains a
140    /// third field, loses one of these two, or renames either one.
141    #[test]
142    fn state_kernel_ctx_fields_has_exactly_the_two_named_fields() {
143        let fields = StateKernelCtxFields {
144            arena_resolution_cb: dummy_resolution_cb,
145            vc_side_channel: std::ptr::null_mut(),
146        };
147        assert_eq!(fields.vc_side_channel, std::ptr::null_mut());
148        // The field is genuinely callable as the documented fn-pointer
149        // shape (not merely a value that happens to type-check).
150        (fields.arena_resolution_cb)(SimHandle(std::ptr::null_mut()), SlotId(0));
151    }
152
153    /// The forbidden patterns [`no_state_owned_api_exposes_a_thread_local_or_
154    /// global_mutable_handle_to_a_kernel`] scans for, factored out so the
155    /// documented-exception count check below exercises the exact same list.
156    /// Built via concatenation (not one contiguous literal) so this file's
157    /// own source text can never match itself, mirroring `accessor_impl.rs`'s
158    /// existing `forbidden_global_patterns` convention.
159    fn forbidden_mutable_global_patterns() -> Vec<String> {
160        vec![["static", " mut"].concat(), ["lazy", "_static!"].concat()]
161    }
162
163    /// `production_code`/`find_matching_brace`: this module's own copy of
164    /// the boundary-finder `accessor_impl.rs`'s tests module already
165    /// established (private to that module, so not reusable from here) —
166    /// isolates a scanned file's real, always-compiled production code from
167    /// its trailing `#[cfg(test)] mod tests { ... }` block(s), so a test's
168    /// own doc comment or fault-injection fixture text can never be mistaken
169    /// for a real declaration. See `accessor_impl.rs`'s identically-named
170    /// helpers for the full rationale (Task 10 r1/r2 review fixes) this copy
171    /// carries over unchanged.
172    fn production_code(src: &str) -> String {
173        let marker = "#[cfg(test)]";
174        let mut result = String::with_capacity(src.len());
175        let mut cursor = 0usize;
176        loop {
177            let Some(rel_idx) = src[cursor..].find(marker) else {
178                result.push_str(&src[cursor..]);
179                break;
180            };
181            let idx = cursor + rel_idx;
182            let after = &src[idx + marker.len()..];
183            let after_trimmed = after.trim_start();
184            if after_trimmed.starts_with("mod tests") {
185                let trim_offset = after.len() - after_trimmed.len();
186                let mod_tests_abs = idx + marker.len() + trim_offset;
187                let after_keyword = &src[mod_tests_abs + "mod tests".len()..];
188                let brace_abs = after_keyword
189                    .find('{')
190                    .map(|rel| mod_tests_abs + "mod tests".len() + rel);
191                let close_abs = brace_abs.and_then(|b| find_matching_brace(src, b));
192                match close_abs {
193                    Some(close_abs) => {
194                        result.push_str(&src[cursor..idx]);
195                        cursor = close_abs + 1;
196                        continue;
197                    }
198                    None => {
199                        result.push_str(&src[cursor..idx]);
200                        break;
201                    }
202                }
203            }
204            result.push_str(&src[cursor..idx + marker.len()]);
205            cursor = idx + marker.len();
206        }
207        result
208    }
209
210    fn find_matching_brace(src: &str, open_idx: usize) -> Option<usize> {
211        let bytes = src.as_bytes();
212        debug_assert_eq!(bytes.get(open_idx), Some(&b'{'));
213        let mut depth: i32 = 0;
214        let mut i = open_idx;
215        while i < bytes.len() {
216            match bytes[i] {
217                b'{' => {
218                    depth += 1;
219                    i += 1;
220                }
221                b'}' => {
222                    depth -= 1;
223                    if depth == 0 {
224                        return Some(i);
225                    }
226                    i += 1;
227                }
228                b'/' if bytes.get(i + 1) == Some(&b'/') => {
229                    while i < bytes.len() && bytes[i] != b'\n' {
230                        i += 1;
231                    }
232                }
233                b'/' if bytes.get(i + 1) == Some(&b'*') => {
234                    i += 2;
235                    let mut comment_depth = 1u32;
236                    while i < bytes.len() && comment_depth > 0 {
237                        if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
238                            comment_depth += 1;
239                            i += 2;
240                        } else if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
241                            comment_depth -= 1;
242                            i += 2;
243                        } else {
244                            i += 1;
245                        }
246                    }
247                }
248                b'"' => {
249                    i += 1;
250                    while i < bytes.len() && bytes[i] != b'"' {
251                        if bytes[i] == b'\\' {
252                            i += 1;
253                        }
254                        i += 1;
255                    }
256                    i += 1;
257                }
258                b'\'' => {
259                    const MAX_CHAR_LITERAL_LEN: usize = 12;
260                    let window_end = (i + 1 + MAX_CHAR_LITERAL_LEN).min(bytes.len());
261                    let mut j = i + 1;
262                    let mut found_close = None;
263                    while j < window_end {
264                        if bytes[j] == b'\n' {
265                            break;
266                        }
267                        if bytes[j] == b'\'' && j > i + 1 {
268                            found_close = Some(j);
269                            break;
270                        }
271                        j += 1;
272                    }
273                    match found_close {
274                        Some(close) => i = close + 1,
275                        None => i += 1,
276                    }
277                }
278                _ => {
279                    i += 1;
280                }
281            }
282        }
283        None
284    }
285
286    /// ST-132: the concrete, testable half of "no hidden mutable state
287    /// outside (base, ctx)" that lives on STATE's side of the boundary — a
288    /// source-text structural scan (same technique as Task 10's
289    /// `no_public_function_returns_a_raw_absolute_pointer...` test) of every
290    /// `.rs` file under `state/`:
291    ///
292    /// - `static mut`/`lazy_static!` must never appear in any production
293    ///   code under `state/` — zero exceptions.
294    /// - `thread_local!` must appear **exactly once** in all of `state/`'s
295    ///   production code, and that one declaration must be `write.rs`'s
296    ///   `WORKER_CACHE` — Task 8's documented, explicitly-thread-local
297    ///   speculative-buffer *cache* (a thread's own cached `WorkerSlot`/
298    ///   buffer pointer once registered via `register_worker_thread`; the
299    ///   `worker_buffers: Vec<Box<RefCell<Vec<VcRecord>>>>` registry it is
300    ///   backed by is an ordinary `VcState`/`StateEngine`-owned field, not a
301    ///   second thread-local/global). This is intentional per-thread PDES
302    ///   state, not kernel-visible mutable global state — the one allowed
303    ///   exception, named explicitly here so a *third* `thread_local!`
304    ///   anywhere else in `state/` fails this test. (The second, event_plan.rs's
305    ///   design-scope PLAN, is read-only per-design syntax data.)
306    #[test]
307    fn no_state_owned_api_exposes_a_thread_local_or_global_mutable_handle_to_a_kernel() {
308        let forbidden = forbidden_mutable_global_patterns();
309        let state_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/state");
310        let entries = std::fs::read_dir(&state_dir).expect("state/ directory is readable");
311
312        let mut thread_local_hits: Vec<String> = Vec::new();
313
314        for entry in entries {
315            let entry = entry.expect("directory entry is readable");
316            let path = entry.path();
317            if path.extension().and_then(|e| e.to_str()) != Some("rs") {
318                continue;
319            }
320            let contents = std::fs::read_to_string(&path)
321                .unwrap_or_else(|e| panic!("{} must be readable: {e}", path.display()));
322            let scanned = production_code(&contents);
323            let file_name = path.file_name().unwrap().to_string_lossy().into_owned();
324
325            for pattern in &forbidden {
326                assert!(
327                    !scanned.contains(pattern.as_str()),
328                    "{file_name}'s production code must not contain a `{pattern}` -- \
329                     no STATE-owned API may expose a hidden mutable global to a kernel"
330                );
331            }
332
333            for _ in 0..scanned.matches("thread_local!").count() {
334                thread_local_hits.push(file_name.clone());
335            }
336        }
337
338        thread_local_hits.sort();
339        assert_eq!(
340            thread_local_hits,
341            vec!["event_plan.rs".to_string(), "write.rs".to_string()],
342            "exactly two `thread_local!` declarations must exist in state/'s production \
343             code -- Task 8's documented WORKER_CACHE speculative-buffer cache in write.rs, \
344             and event_plan.rs's read-only design-scope PLAN (the design-wide event pool \
345             plan, installed per design by EventPlanScope like the other thread-local \
346             design scopes; it holds no kernel handle) -- and nothing else; \
347             found: {thread_local_hits:?}"
348        );
349    }
350
351    /// Fault injection proving [`no_state_owned_api_exposes_a_thread_local_or_
352    /// global_mutable_handle_to_a_kernel`]'s own machinery actually catches a
353    /// violation, rather than passing vacuously on every input (a "gate that
354    /// cannot fail"): a synthetic file shaped exactly like the forbidden
355    /// second-`thread_local!`/`static mut` case must be flagged by the same
356    /// `production_code` + pattern scan the real test uses.
357    #[test]
358    fn thread_local_scan_machinery_catches_a_synthetic_second_declaration() {
359        let shaped = concat!(
360            "pub struct Whatever;\n",
361            "\n",
362            "thread_local! {\n",
363            "    static SNEAKY: std::cell::RefCell<u32> = const { std::cell::RefCell::new(0) };\n",
364            "}\n",
365            "\n",
366            "#[cfg(test)]\n",
367            "mod tests {\n",
368            "    // real test code, excluded from the scan\n",
369            "}\n",
370        );
371        let scanned = production_code(shaped);
372        assert_eq!(
373            scanned.matches("thread_local!").count(),
374            1,
375            "the synthetic fixture's own injected thread_local! must survive the \
376             mod-tests-exclusion scan (it lives outside `mod tests`) so the real test's \
377             exactly-one-total-across-the-crate check would trip on it"
378        );
379
380        let forbidden = forbidden_mutable_global_patterns();
381        let static_mut_shaped = "pub static mut SNEAKY_GLOBAL: u32 = 0;\n";
382        assert!(
383            forbidden
384                .iter()
385                .any(|p| static_mut_shaped.contains(p.as_str())),
386            "a reintroduced `static mut` must be caught by the forbidden-pattern list"
387        );
388        let lazy_static_shaped = "lazy_static! { static ref SNEAKY: u32 = 0; }\n";
389        assert!(
390            forbidden
391                .iter()
392                .any(|p| lazy_static_shaped.contains(p.as_str())),
393            "a reintroduced `lazy_static!` must be caught by the forbidden-pattern list"
394        );
395    }
396
397    /// ST-131: `vc_side_channel` genuinely names the same speculative buffer
398    /// `StateEngine::drain_committed_changes` (Task 12) reads — not a decoy
399    /// or a no-op stand-in. Builds `StateKernelCtxFields` from a live
400    /// `StateEngine`, casts `vc_side_channel` back to `&VcState` (test-only —
401    /// `VcState` itself is `pub(crate)`, so this is an ordinary same-crate
402    /// access, not a special-cased accessor), appends a write through
403    /// `commit_write_at` using that recovered reference directly (bypassing
404    /// `engine.commit_write` entirely), then confirms the appended record is
405    /// visible via `engine.drain_committed_changes` after `advance_gvt` —
406    /// proving the opaque pointer round-trips to the one real buffer the
407    /// engine itself also drains from.
408    #[test]
409    fn vc_side_channel_pointer_round_trips_to_the_same_speculative_buffer() {
410        let mut engine = fresh_engine();
411        // SAFETY: local, single-owned engine; no other live borrow of the
412        // inline plane, single-threaded test.
413        let word_offset = unsafe { engine.arena.alloc_inline_word() };
414        let loc = SlotLocation {
415            class: SlotClass::Inline,
416            word_offset,
417            bit_width: 8,
418            elem_word_count: 1,
419            row_stride_words: 0,
420            num_rows: 0,
421        };
422        let cell = engine.alloc_cell(loc);
423
424        let fields = StateKernelCtxFields::new(dummy_resolution_cb, &engine.vc);
425
426        // Recover `&VcState` from the opaque pointer -- the exact cast a
427        // real compiled-kernel call site would need to perform on its own
428        // side of the ABI, done here purely to prove the pointer is genuine.
429        //
430        // SAFETY: `fields.vc_side_channel` was constructed immediately above
431        // by `StateKernelCtxFields::new` from `Rc::as_ptr(&engine.vc)`;
432        // `engine` (and therefore its `Rc<VcState>` allocation) is still
433        // alive and un-mutated for the entire body of this test, so the
434        // pointee is live and this reference does not outlive it.
435        let vc_ref: &VcState = unsafe { &*(fields.vc_side_channel as *const VcState) };
436
437        engine.set_current_time(t(5));
438        let at = t(5);
439        // Append directly through the recovered side-channel reference, not
440        // through `engine.commit_write` -- this is the whole point of the
441        // round-trip proof: the append must land in the same buffer
442        // `drain_committed_changes` below reads.
443        let result = commit_write_at(
444            &engine.arena,
445            vc_ref,
446            loc,
447            cell,
448            FourStateValue::Narrow(narrow_word(0x2A, 0)),
449            &mut (),
450        );
451        assert!(
452            result.changed,
453            "the write through the recovered side channel must register as a change"
454        );
455
456        engine.advance_gvt(at);
457        let drained = engine.drain_committed_changes(at);
458        assert_eq!(
459            drained.len(),
460            1,
461            "exactly the one record appended through the side channel must be visible"
462        );
463        assert_eq!(drained[0].cell, cell);
464        match &drained[0].new {
465            FourStateValue::Narrow(w) => assert_eq!(*w, narrow_word(0x2A, 0)),
466            FourStateValue::Wide { .. } => panic!("expected Narrow"),
467        }
468
469        // Negative control: before `advance_gvt` ran (GVT-gating, Task 12),
470        // the record must not have been visible -- otherwise this test would
471        // prove nothing about the side channel specifically, only that
472        // `drain_committed_changes` works at all.
473        let mut engine2 = fresh_engine();
474        let word_offset2 = unsafe { engine2.arena.alloc_inline_word() };
475        let loc2 = SlotLocation {
476            class: SlotClass::Inline,
477            word_offset: word_offset2,
478            bit_width: 8,
479            elem_word_count: 1,
480            row_stride_words: 0,
481            num_rows: 0,
482        };
483        let cell2 = engine2.alloc_cell(loc2);
484        let fields2 = StateKernelCtxFields::new(dummy_resolution_cb, &engine2.vc);
485        let vc_ref2: &VcState = unsafe { &*(fields2.vc_side_channel as *const VcState) };
486        engine2.set_current_time(t(10));
487        commit_write_at(
488            &engine2.arena,
489            vc_ref2,
490            loc2,
491            cell2,
492            FourStateValue::Narrow(narrow_word(0x7, 0)),
493            &mut (),
494        );
495        let too_early = engine2.drain_committed_changes(t(0));
496        assert!(
497            too_early.is_empty(),
498            "before advance_gvt reaches the write's timestamp, drain_committed_changes must not see it"
499        );
500    }
501}