nmbrs_runtime/wrappers/while.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Loop-while wrapper. Iterates the inner op as long as the
5//! op-template's `while:` Polydat expression evaluates truthy. On
6//! each iteration the wrapper:
7//!
8//! 1. Checks the activity-global stop flag. If set, the loop
9//! returns [`OpResult::skipped`] (no further iterations).
10//! Used to drain non-daemon while loops cleanly on session
11//! stop, and as a belt-and-braces secondary signal for
12//! daemon while loops (the daemon body's tokio::select!
13//! cancels the future independently).
14//! 2. Reads the synthesised `__while` binding via the pull
15//! plan. The op-kernel synthesis appends
16//! `__while := <expr>` to the kernel's result bindings —
17//! same path metrics' value expressions take — so referenced
18//! wires are auto-externed and read from the right cells.
19//! 3. If the condition is falsy, returns [`OpResult::skipped`]
20//! (loop exit). Otherwise calls the inner dispenser; an Err
21//! propagates and breaks the loop, an Ok continues.
22//!
23//! Composition: `while:` sits outer of `result`/`metrics` /
24//! `traverse` (so the inner's result captures land before the
25//! next iteration's predicate check) and inner of `if:` /
26//! `memo` (so the conditional short-circuit fires before the
27//! loop starts, and the memo emits after the loop concludes).
28
29use std::sync::Arc;
30
31use crate::adapter::WrappingDispenser;
32use crate::adapter::{ExecutionError, OpDispenser, OpResult};
33use crate::wrapper_registry::{WrapperName, WrapperRegistration, WrapperSubject};
34
35/// Wrapper name.
36pub const NAME: WrapperName = WrapperName::new("while");
37
38/// Internal kernel-output name the wrapper pulls. The op-
39/// kernel synthesiser appends `__while := <expr>` to the
40/// kernel's result-bindings source so the expression's free
41/// identifiers get auto-externed (same path metrics take).
42pub const BINDING_NAME: &str = "__while";
43
44/// Trigger: op declares a `while:` expression.
45fn triggers(s: WrapperSubject) -> bool {
46 let Some(template) = s.op() else {
47 return false;
48 };
49 template.while_cond.is_some()
50}
51
52fn describe_assignment(s: WrapperSubject) -> Option<String> {
53 let template = s.op()?;
54 template
55 .while_cond
56 .as_ref()
57 .map(|expr| format!("while: {}", expr.trim()))
58}
59
60inventory::submit! {
61 WrapperRegistration {
62 name: NAME,
63 owned_fields: &["while"],
64 triggers,
65 requires_inner: &[],
66 forbids_outer: &[],
67 mutually_exclusive_with: &[],
68 describe_assignment,
69 levels: &[crate::wrapper_registry::WrapperLevel::Op],
70 }
71}
72
73/// Wraps an inner OpDispenser with a loop driven by a GK
74/// boolean expression. See module docs.
75pub struct WhileWrapper {
76 inner: Arc<dyn OpDispenser>,
77 /// Handle for the synthesised `__while` binding registered
78 /// into the scope fixture at init.
79 cond_handle: crate::fixture::PullHandle,
80 /// SRD-92 Step 0 — the cooperative-stop view (activity `stop_flag` +
81 /// session stop + SRD-83 `walk_stop` + SRD-82 P6 `daemon_stop`). Each
82 /// loop iteration checks it and exits cleanly when ANY arm is set.
83 /// Before Step 0 this held only the activity `stop_flag`, so a `while:`
84 /// op did NOT abort on a scenario walk-halt or a daemon-group stop.
85 stop_view: crate::session_signals::StopView,
86 /// Hard ceiling on iterations per outer dispatch. Defends
87 /// against runaway loops (a `while:` predicate that
88 /// never flips falsy) hanging the activity. The default
89 /// is intentionally large; daemons that need genuine long-
90 /// running loops are governed by the daemon stop-flag and
91 /// the activity-stop flag, not this counter.
92 iteration_ceiling: u64,
93}
94
95impl WhileWrapper {
96 /// Default iteration ceiling. Sized to allow days-long
97 /// daemon loops at modest tick rates (e.g. 100k/sec for
98 /// 11+ days) while still terminating a buggy infinite loop
99 /// in finite time during dev. Raise via
100 /// [`Self::with_iteration_ceiling`] if a legitimate workload
101 /// needs more.
102 pub const DEFAULT_ITERATION_CEILING: u64 = 100_000_000_000;
103
104 /// Wrap an inner dispenser. Registers the synthesised
105 /// `__while` binding into `fx` so the per-cycle read goes
106 /// through the canonical PullPlan path.
107 ///
108 /// Errors if the kernel doesn't know `__while` — that
109 /// means the op-kernel synthesiser didn't inject the
110 /// binding (registry bug) or the expression failed to
111 /// compile and the kernel was rebuilt without it.
112 pub fn wrap(
113 inner: Arc<dyn OpDispenser>,
114 stop_view: crate::session_signals::StopView,
115 fx: &mut crate::fixture::ScopeFixture,
116 ) -> Result<Arc<dyn OpDispenser>, String> {
117 let cond_handle = fx.register_pull(BINDING_NAME).map_err(|e| {
118 format!(
119 "while: {e} (expected synthesised binding `{BINDING_NAME}` \
120 on the op-template kernel — synthesis bug?)"
121 )
122 })?;
123 Ok(Arc::new(Self {
124 inner,
125 cond_handle,
126 stop_view,
127 iteration_ceiling: Self::DEFAULT_ITERATION_CEILING,
128 }))
129 }
130
131 /// Test-only constructor with a custom iteration ceiling.
132 #[cfg(test)]
133 pub fn with_iteration_ceiling(
134 inner: Arc<dyn OpDispenser>,
135 stop_view: crate::session_signals::StopView,
136 fx: &mut crate::fixture::ScopeFixture,
137 ceiling: u64,
138 ) -> Result<Arc<dyn OpDispenser>, String> {
139 let cond_handle = fx
140 .register_pull(BINDING_NAME)
141 .map_err(|e| format!("while: {e}"))?;
142 Ok(Arc::new(Self {
143 inner,
144 cond_handle,
145 stop_view,
146 iteration_ceiling: ceiling,
147 }))
148 }
149}
150
151/// Truthy test for the predicate value. Mirrors
152/// `ConditionalDispenser::is_truthy` — the two wrappers
153/// share a semantic: 0/false/empty-string/None is falsy,
154/// everything else is truthy.
155
156impl WrappingDispenser for WhileWrapper {}
157
158impl OpDispenser for WhileWrapper {
159 fn execute<'a>(
160 &'a self,
161 cycle: u64,
162 ctx: &'a crate::fixture::ExecCtx<'a>,
163 ) -> std::pin::Pin<
164 Box<dyn std::future::Future<Output = Result<OpResult, ExecutionError>> + Send + 'a>,
165 > {
166 Box::pin(async move {
167 let mut iterations: u64 = 0;
168 let mut last_result: Option<OpResult> = None;
169 loop {
170 if self.stop_view.stopped() {
171 // Any cooperative stop (activity / session / SRD-83
172 // walk / SRD-82 P6 daemon-group). Treat as a clean
173 // exit; any iteration metrics already landed through
174 // the inner's wrappers.
175 return Ok(last_result.unwrap_or_else(OpResult::skipped));
176 }
177 if iterations >= self.iteration_ceiling {
178 // Defensive ceiling. Surfacing as a skip
179 // (not an error) keeps the phase outcome
180 // identical to a natural predicate-flip
181 // exit; the activity log records the
182 // ceiling hit at iteration time below.
183 crate::diag!(
184 crate::observer::LogLevel::Warn,
185 "while: iteration ceiling {} hit; exiting loop",
186 self.iteration_ceiling
187 );
188 return Ok(last_result.unwrap_or_else(OpResult::skipped));
189 }
190 let cond = ctx.pulls.get(self.cond_handle);
191 if !crate::wrappers::condition::is_truthy(cond) {
192 return Ok(last_result.unwrap_or_else(OpResult::skipped));
193 }
194 match self.inner.execute(cycle, ctx).await {
195 Ok(r) => {
196 last_result = Some(r);
197 iterations += 1;
198 }
199 Err(e) => return Err(e),
200 }
201 }
202 })
203 }
204 fn inner_dispenser(&self) -> Option<&dyn OpDispenser> {
205 Some(self.inner.as_ref())
206 }
207}
208
209#[cfg(test)]
210mod tests {
211
212 // Truthiness itself is not tested here — it lives in
213 // `super::condition` and is tested there. Duplicating it was how the two
214 // implementations drifted apart in the first place.
215
216 // The `while` loop halts: given a ceiling and a stop flag, the loop exits
217 // after at most `ceiling` iterations. Models the wrapper's
218 // loop bookkeeping synchronously (no tokio) since the
219 // arithmetic is what we're exercising.
220 mod proptests {
221 use proptest::prelude::*;
222
223 proptest! {
224 #![proptest_config(ProptestConfig::with_cases(100))]
225 #[test]
226 fn loop_terminates_at_ceiling(
227 ceiling in 1u64..=10_000,
228 always_true in any::<bool>(),
229 stop_after in 0u64..=15_000,
230 ) {
231 // Simulate the loop: every iteration checks
232 // (a) stop flag, (b) ceiling, (c) predicate.
233 // Returns the iteration count actually run.
234 let mut iterations: u64 = 0;
235 let stop_flag = std::sync::atomic::AtomicBool::new(false);
236 loop {
237 if stop_flag.load(std::sync::atomic::Ordering::Acquire) { break; }
238 if iterations >= ceiling { break; }
239 if !always_true { break; }
240 iterations += 1;
241 if iterations == stop_after {
242 stop_flag.store(true, std::sync::atomic::Ordering::Release);
243 }
244 }
245 // Two invariants:
246 // (1) Loop runs at most `ceiling` iterations.
247 prop_assert!(iterations <= ceiling,
248 "iterations {} exceeded ceiling {}", iterations, ceiling);
249 // (2) If the predicate is always-false, zero
250 // iterations run regardless of ceiling.
251 if !always_true {
252 prop_assert_eq!(iterations, 0,
253 "predicate=false must yield zero iterations");
254 }
255 }
256 }
257 }
258}