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}