Skip to main content

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}