Skip to main content

hydro_lang/sim/
hooks.rs

1//! Test-side API for **simulator hooks**: scripting the decisions of unsafe operators.
2//!
3//! A hook handle (see [`crate::sim_hooks`]) is created from the
4//! [`FlowBuilder`](crate::compile::builder::FlowBuilder) via
5//! [`FlowBuilder::sim_hook`](crate::compile::builder::FlowBuilder::sim_hook) and attached
6//! to one specific unsafe operator with `nondet!(/** reason */ hook = handle)`. Inside a
7//! simulation test body (under [`SimFlow::deterministic`](crate::sim::flow::SimFlow::deterministic),
8//! [`fuzz`](crate::sim::flow::SimFlow::fuzz), or
9//! [`exhaustive`](crate::sim::flow::SimFlow::exhaustive)), the handle scripts the
10//! operator's decisions.
11//!
12//! # The script is a schedule
13//!
14//! Decision calls are `async`, and the sequence of calls in the test body is a
15//! **schedule**, read in program order:
16//!
17//! - Consecutive decisions that target *different hooks of the same tick* form a
18//!   **group**: one execution of that tick will consume all of them together.
19//! - A decision that targets a different tick — or the *next execution* of the same tick
20//!   (scripting a hook that already has a decision in the current group) — starts a new
21//!   group. The `.await` on the first decision of a new group suspends the test until the
22//!   previous group's tick execution has actually happened, so the test body advances in
23//!   lockstep with the execution it describes. Decisions whose turn has already come
24//!   return immediately without suspending.
25//! - Output awaits are group barriers: an output await completes only after every
26//!   decision scripted so far has been consumed.
27//!
28//! A decision may be scripted before its data exists (`release(3)` immediately after
29//! `send_many([1, 2, 3])`): the tick simply fires at the first moment the decision can be
30//! honored in full. A decision that can *never* be honored is reported when the
31//! simulation runs out of other work, attributed to the test line that is suspended
32//! waiting on it.
33//!
34//! # Holding data on purpose
35//!
36//! A hook with buffered data and no decision is an error the simulator reports at every
37//! scheduling boundary. When buffering *is* the scenario, declare it with the `pause`
38//! family ([`BatchHook::pause`], [`BatchHook::pause_while`],
39//! [`BatchHook::pause_until_count`], [`BatchHook::auto_pause`], and the snapshot
40//! equivalents).
41
42use std::future::Future;
43use std::marker::PhantomData;
44use std::pin::Pin;
45use std::task::{Context, Poll};
46
47use serde::Serialize;
48use serde::de::DeserializeOwned;
49
50use crate::live_collections::boundedness::{Bounded, Unbounded};
51use crate::live_collections::stream::{NoOrder, Ordering, Retries, TotalOrder};
52use crate::sim::compiled::{
53    ScheduleDecision, script_ctx, script_stuck_error, script_unconsumed_description,
54};
55use crate::sim::runtime::{
56    BatchDecision, InlineOrderingDecision, ScriptDecision, SnapshotDecision,
57    TopLevelOrderingDecision, UnorderedBatchDecision,
58};
59pub use crate::sim::runtime::{BatchStatus, OrderingStatus, SnapshotStatus};
60pub use crate::sim_hooks::{BatchHook, OrderingHook, SimHook, SnapshotHook};
61
62/// A scripted decision that has been issued but not yet installed into the schedule.
63///
64/// Awaiting it suspends the test until every previously scripted tick execution the
65/// decision must come after has actually happened (see the module docs); it resolves once
66/// the decision is installed for its tick's next execution. Panics (at the `.await`'s
67/// location) if the decision can never take its place in the schedule.
68#[must_use = "a scripted decision does nothing until awaited"]
69pub struct DecisionFuture {
70    hook_id: usize,
71    /// The decision, bincode-serialized (the handle and the hook it is bound to
72    /// statically know the same decision type). `None` once installed.
73    blob: Option<Vec<u8>>,
74}
75
76impl DecisionFuture {
77    fn new(hook_id: usize, decision: &impl ScriptDecision) -> Self {
78        DecisionFuture {
79            hook_id,
80            blob: Some(bincode::serialize(decision).unwrap()),
81        }
82    }
83}
84
85impl Future for DecisionFuture {
86    type Output = ();
87
88    #[track_caller]
89    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
90        let this = self.get_mut();
91        let Some(blob) = this.blob.take() else {
92            return Poll::Ready(());
93        };
94
95        let ctx = script_ctx();
96        match ctx.try_schedule_decision(this.hook_id, blob) {
97            Ok(ScheduleDecision::Installed) => Poll::Ready(()),
98            Ok(ScheduleDecision::Wait(blob)) => {
99                this.blob = Some(blob);
100                // While the script waits, the rest of the simulation does not: the
101                // scheduler keeps freely choosing which *other* ticks run. The test body
102                // is re-polled after every scheduler step; the waker is only needed for
103                // the parked (quiescent) case.
104                ctx.push_park_waker(cx.waker());
105                Poll::Pending
106            }
107            Err(message) => panic!("{}", message),
108        }
109    }
110}
111
112/// A `pause_until` wait: resolves once the hook's pending-input status satisfies the
113/// predicate, un-pausing the hook. The status is read from the hook **on demand** at every
114/// poll (the test body is polled between every pair of scheduler steps), so the wait
115/// resolves at the first scheduling point where the predicate holds. Panics (at the
116/// `.await`'s location) if the simulation can no longer satisfy it.
117#[must_use = "the pause is only released once this future is awaited"]
118pub struct PauseUntilFuture<S, F> {
119    hook_id: usize,
120    /// What the wait is called in error messages (e.g. `pause_until_count(3)`).
121    label: String,
122    predicate: F,
123    _status: PhantomData<fn(S)>,
124}
125
126impl<S: DeserializeOwned, F: Fn(&S) -> bool + Unpin> Future for PauseUntilFuture<S, F> {
127    type Output = ();
128
129    #[track_caller]
130    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
131        let this = self.get_mut();
132        let ctx = script_ctx();
133
134        // A `pause_until` wait is a script barrier, like an output await: it must not
135        // resolve (dropping the hold) while an earlier decision group is unconsumed,
136        // or the next decision would spuriously overlap the outstanding group and the
137        // boundary scan would see the exposed hook mid-group. And if that group is
138        // stuck, it is the root cause — report it instead of blaming the predicate.
139        if let Some(stuck) = script_unconsumed_description() {
140            if ctx.is_quiescent() {
141                panic!("{}", script_stuck_error(&stuck));
142            }
143            ctx.push_park_waker(cx.waker());
144            return Poll::Pending;
145        }
146
147        let hook = ctx.control(this.hook_id);
148
149        let status: S = bincode::deserialize(&hook.borrow().status_blob())
150            .expect("internal error: hook status blob did not match the handle's status type");
151
152        if (this.predicate)(&status) {
153            // The wait is satisfied; the hook is unpaused (the ordinary missing-decision
154            // error applies from here on).
155            hook.borrow_mut().release_hold();
156            Poll::Ready(())
157        } else if ctx.is_quiescent() {
158            let hook = hook.borrow();
159            let loc = hook.location_meta().location;
160            panic!(
161                "{} can never be satisfied: the hook at {} has {} and the simulation has no more work it can do",
162                this.label,
163                loc,
164                hook.describe_pending()
165                    .unwrap_or_else(|| "no pending input".to_owned()),
166            );
167        } else {
168            ctx.push_park_waker(cx.waker());
169            Poll::Pending
170        }
171    }
172}
173
174/// RAII guard for [`BatchHook::pause_while`] / [`SnapshotHook::pause_while`]: ends the
175/// hold when dropped (even on panic), leaving a standing `auto_pause` hold in place.
176struct PauseGuard {
177    hook_id: usize,
178}
179
180impl Drop for PauseGuard {
181    fn drop(&mut self) {
182        let ctx = script_ctx();
183        let hook = ctx.control(self.hook_id);
184        hook.borrow_mut().release_hold();
185    }
186}
187
188macro_rules! pause_family {
189    ($status:ty) => {
190        /// Declares that buffering at this operator is intended: while paused, the hook is
191        /// exempt from the missing-decision error, never causes its tick to run, and — if
192        /// its tick runs anyway because *other* hooks feed it — contributes its "nothing
193        /// new" behavior each time. Scripting any decision implicitly resumes the hook.
194        ///
195        /// A pause takes its place in the script like everything else: requested while a
196        /// decision is still pending, the hold begins once that decision has been
197        /// consumed.
198        pub fn pause(&self) {
199            let ctx = script_ctx();
200            ctx.control(self.id).borrow_mut().set_hold(true);
201        }
202
203        /// Ends a [`Self::pause`] (and clears [`Self::auto_pause`] mode).
204        pub fn resume(&self) {
205            let ctx = script_ctx();
206            let hook = ctx.control(self.id);
207            let mut hook = hook.borrow_mut();
208            hook.set_auto_pause(false);
209            hook.set_hold(false);
210        }
211
212        /// Sets a standing mode where this hook only ever acts when scripted: it holds
213        /// immediately, and every scripted decision leaves a fresh hold in place behind
214        /// it.
215        ///
216        /// This deliberately opts out of the forgotten-hook protection: if the test
217        /// forgets a step, the operator silently holds its data instead of failing. The
218        /// one `auto_pause()` line at the top of a test is the reviewer-visible marker
219        /// that this hook's timing is entirely script-driven, missed steps and all.
220        pub fn auto_pause(&self) {
221            let ctx = script_ctx();
222            let hook = ctx.control(self.id);
223            let mut hook = hook.borrow_mut();
224            hook.set_auto_pause(true);
225            hook.set_hold(true);
226        }
227
228        /// Pauses the hook exactly for the duration of `body` (resuming even on panic), so
229        /// a bracketed buffering phase cannot leak a paused hook.
230        pub async fn pause_while<Fut: Future>(&self, body: Fut) -> Fut::Output {
231            self.pause();
232            let _guard = PauseGuard { hook_id: self.id };
233            body.await
234        }
235
236        /// Pauses the hook and returns a future that resolves once the hook's
237        /// pending-input status satisfies `predicate` — a synchronization point for
238        /// scripts where the right decision is not knowable upfront. The status is read
239        /// on demand at every scheduling point. After the future resolves, the hook is
240        /// unpaused; the ordinary missing-decision error applies from there on.
241        pub fn pause_until(
242            &self,
243            predicate: impl Fn(&$status) -> bool + Unpin,
244        ) -> PauseUntilFuture<$status, impl Fn(&$status) -> bool + Unpin> {
245            self.pause_until_labeled("pause_until(..)".to_owned(), predicate)
246        }
247
248        fn pause_until_labeled<F: Fn(&$status) -> bool + Unpin>(
249            &self,
250            label: String,
251            predicate: F,
252        ) -> PauseUntilFuture<$status, F> {
253            let ctx = script_ctx();
254            ctx.control(self.id).borrow_mut().set_hold(true);
255            PauseUntilFuture {
256                hook_id: self.id,
257                label,
258                predicate,
259                _status: PhantomData,
260            }
261        }
262    };
263}
264
265impl<T, O: Ordering, R: Retries> BatchHook<T, O, R> {
266    pause_family!(BatchStatus);
267
268    /// Pauses the hook and returns a future that resolves once at least `n` elements are
269    /// buffered; see [`Self::pause_until`].
270    pub fn pause_until_count(
271        &self,
272        n: usize,
273    ) -> PauseUntilFuture<BatchStatus, impl Fn(&BatchStatus) -> bool + Unpin> {
274        self.pause_until_labeled(format!("pause_until_count({})", n), move |status| {
275            status.buffered >= n
276        })
277    }
278}
279
280impl<T, R: Retries> BatchHook<T, TotalOrder, R>
281where
282    T: Serialize + DeserializeOwned + PartialEq,
283{
284    /// Scripts the next batch to be exactly the next `n` buffered elements. The tick
285    /// fires at the first moment the decision can be honored in full.
286    pub fn release(&self, n: usize) -> DecisionFuture {
287        DecisionFuture::new(self.id, &BatchDecision::<T>::Prefix(n))
288    }
289
290    /// Scripts the next batch to be exactly this sequence of values. Values must match the
291    /// buffered prefix in order: a mismatching available value panics immediately, while a
292    /// matching but incomplete prefix waits for the remaining values to arrive.
293    pub fn release_values(&self, values: impl IntoIterator<Item = T>) -> DecisionFuture {
294        DecisionFuture::new(
295            self.id,
296            &BatchDecision::Values(values.into_iter().collect()),
297        )
298    }
299
300    /// Scripts the next batch to be everything that has arrived by the time the tick
301    /// fires. Under fuzzing, the released contents co-vary with the schedule being
302    /// explored; use [`Self::release`] to name them exactly.
303    pub fn release_all(&self) -> DecisionFuture {
304        DecisionFuture::new(self.id, &BatchDecision::<T>::All)
305    }
306
307    /// Scripts the next batch to be empty, holding everything buffered. Shorthand for
308    /// [`Self::release`]`(0)`.
309    pub fn release_empty(&self) -> DecisionFuture {
310        self.release(0)
311    }
312}
313
314impl<T, R: Retries> BatchHook<T, NoOrder, R>
315where
316    T: Serialize + DeserializeOwned + PartialEq,
317{
318    /// Scripts the next batch to contain exactly this multiset of buffered values. Values
319    /// are matched independently of arrival order; duplicates request the corresponding
320    /// number of equal buffered items. The tick fires once every requested value exists.
321    pub fn release_values(&self, values: impl IntoIterator<Item = T>) -> DecisionFuture {
322        DecisionFuture::new(
323            self.id,
324            &UnorderedBatchDecision::Values(values.into_iter().collect()),
325        )
326    }
327
328    /// Scripts the next batch to be everything that has arrived by the time the tick
329    /// fires. Under fuzzing, the released contents co-vary with the schedule being
330    /// explored; use [`Self::release_values`] to name them exactly.
331    pub fn release_all(&self) -> DecisionFuture {
332        DecisionFuture::new(self.id, &UnorderedBatchDecision::<T>::All)
333    }
334
335    /// Scripts the next batch to be empty, holding everything buffered. Shorthand for
336    /// [`Self::release_values`] with no values.
337    pub fn release_empty(&self) -> DecisionFuture {
338        self.release_values([])
339    }
340}
341
342impl<T> OrderingHook<T, Unbounded>
343where
344    T: Serialize + DeserializeOwned,
345{
346    /// Scripts a top-level `assume_ordering` action to release the buffered element equal
347    /// to `value`. Exactly one element is released, preserving opportunities for ticks and
348    /// feedback to interleave with the remaining buffered input.
349    pub fn next(&self, value: T) -> DecisionFuture {
350        DecisionFuture::new(self.id, &TopLevelOrderingDecision::Next(value))
351    }
352
353    pause_family!(OrderingStatus);
354
355    /// Pauses a top-level ordering hook until at least `n` elements are buffered; see
356    /// [`Self::pause_until`].
357    pub fn pause_until_count(
358        &self,
359        n: usize,
360    ) -> PauseUntilFuture<OrderingStatus, impl Fn(&OrderingStatus) -> bool + Unpin> {
361        self.pause_until_labeled(format!("pause_until_count({})", n), move |status| {
362            status.buffered >= n
363        })
364    }
365}
366
367impl<T> OrderingHook<T, Bounded>
368where
369    T: Serialize + DeserializeOwned,
370{
371    /// Scripts an in-tick `assume_ordering` observation to consume its complete input in
372    /// exactly this order. The supplied values must be a permutation of all values received
373    /// by the operator during that tick.
374    pub fn order(&self, values: impl IntoIterator<Item = T>) -> DecisionFuture {
375        DecisionFuture::new(
376            self.id,
377            &InlineOrderingDecision::Order(values.into_iter().collect()),
378        )
379    }
380}
381
382impl<T> SnapshotHook<T> {
383    /// Scripts the next tick execution to observe the buffered version equal to `value`:
384    /// scans forward from the currently-revealed version through the buffered ones and
385    /// releases the first equal version, skipping over earlier versions.
386    ///
387    /// This is a combined assertion and release, and the recommended way to script
388    /// snapshots: a script written with positional decisions breaks silently when the
389    /// program changes how often the state updates, while `reveal(value)` names the state
390    /// it means and any mis-synchronization fails loudly at the reveal.
391    pub fn reveal(&self, value: T) -> DecisionFuture
392    where
393        T: Serialize + DeserializeOwned,
394    {
395        DecisionFuture::new(self.id, &SnapshotDecision::Reveal(value))
396    }
397
398    /// Scripts the next tick execution to observe the next buffered version.
399    pub fn reveal_next(&self) -> DecisionFuture
400    where
401        T: Serialize + DeserializeOwned,
402    {
403        DecisionFuture::new(self.id, &SnapshotDecision::<T>::RevealNext)
404    }
405
406    /// Scripts the next tick execution to observe the newest version that has arrived by
407    /// the time the tick fires. Under fuzzing, which version is newest co-varies with the
408    /// schedule being explored; use [`Self::reveal`] to name it exactly.
409    pub fn reveal_latest(&self) -> DecisionFuture
410    where
411        T: Serialize + DeserializeOwned,
412    {
413        DecisionFuture::new(self.id, &SnapshotDecision::<T>::RevealLatest)
414    }
415
416    /// Scripts the next tick execution to observe the previously revealed version again.
417    pub fn keep(&self) -> DecisionFuture
418    where
419        T: Serialize + DeserializeOwned,
420    {
421        DecisionFuture::new(self.id, &SnapshotDecision::<T>::Keep)
422    }
423
424    pause_family!(SnapshotStatus);
425
426    /// Pauses the hook and returns a future that resolves once at least `n` newer
427    /// versions are buffered; see [`Self::pause_until`].
428    pub fn pause_until_versions(
429        &self,
430        n: usize,
431    ) -> PauseUntilFuture<SnapshotStatus, impl Fn(&SnapshotStatus) -> bool + Unpin> {
432        self.pause_until_labeled(format!("pause_until_versions({})", n), move |status| {
433            status.newer_versions >= n
434        })
435    }
436}