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}