hydro_lang/sim/compiled.rs
1//! Interfaces for compiled Hydro simulators and concrete simulation instances.
2//!
3//! # Quiescence and observation soundness
4//!
5//! The scheduler distinguishes two kinds of simulation work:
6//! - **Deterministic work**: running the top-level async dataflows, which simply propagate
7//! whatever data is already in flight. This makes no `nondet!` decisions, so running it can
8//! never change which executions are explored.
9//! - **Nondeterministic work**: running ticks and observations, whose behavior depends on
10//! decisions drawn from the bolero driver (batch boundaries, snapshot versions, message
11//! orderings). Each decision forks the space of possible executions.
12//!
13//! The simulation is **quiescent** when neither kind of work can make progress without new
14//! external input. Test-side observations (the methods on [`SimReceiver`] /
15//! [`SimClusterReceiver`]) interact with the scheduler while waiting, and the key soundness
16//! question is: *when is it okay for an observation to let nondeterministic work run?*
17//!
18//! **Waiting for a message is always sound.** If the message eventually arrives, the work
19//! that ran was necessary to produce it (schedules that run *extra* work are also valid
20//! executions and are explored separately). If the simulation instead quiesces without
21//! producing the message, the assertion fails and the instance ends, so nothing can observe
22//! the overrun. This is why [`SimReceiver::next`], [`SimReceiver::collect_n`], and the
23//! `assert_yields*` prefix checks are safe to use in the middle of a test.
24//!
25//! **Observing the *absence* of a message is dangerous.** Proving that "no more messages can
26//! arrive" requires driving the simulation all the way to quiescence, running *all* pending
27//! nondeterministic work. A later assertion may have needed to observe a state where that
28//! work had not yet run — e.g., `assert_yields_only([1, 2])` followed by reading a counter
29//! must be able to see the counter *before* the ticks that count `1` and `2` have fired.
30//! Forcing quiescence at the first assertion would make some executions unobservable, and
31//! extra messages produced by the forced work could surface at a *later* assertion,
32//! misattributing the failure. Absence-observing APIs therefore proceed in phases:
33//!
34//! 1. **Settle** (see `SettlePauseGuard::poll_settle`): the scheduler runs only deterministic work, pausing
35//! just before nondeterministic work. If the simulation reaches quiescence this way, the
36//! end-of-stream check is *free* — no decision was forced, no execution was cut off — and
37//! the test simply continues.
38//! 2. If nondeterministic work is pending, the check would overrun. What happens next depends
39//! on the API and engine:
40//! - The assertion APIs ([`SimReceiver::assert_no_more`], `assert_yields_only*`,
41//! `collect_n_only`) under [`CompiledSim::exhaustive`] **fork** the search on a bolero
42//! decision: one instance performs the check and then ends (via a discard panic, like
43//! `sim::continue_if!`), while sibling instances skip the check entirely and continue. The
44//! exhaustive driver enumerates the checking instance *first*, so a failing check is
45//! found before any instance runs past it — with a decision trace that leads exactly to
46//! the failing assertion. Since nothing after the check runs in the checking instance,
47//! the overrun it performs is unobservable, and the continuing instances never quiesce,
48//! so every downstream state remains reachable.
49//! - Otherwise (fuzz / RNG / replay engines, or the drain-everything APIs
50//! [`SimReceiver::try_next`], [`SimReceiver::collect`], and `collect_sorted` in every
51//! mode), the pending work runs and the instance is **tainted**
52//! (`QuiescenceState::tainted`). Reads of the now-quiescent state remain sound (they
53//! observe a fully-drained simulation that can no longer advance), so tests may drain
54//! multiple output ports at the end. But once new input is sent, the instance is
55//! **poisoned** (`QuiescenceState::poisoned`): any further receive panics (see
56//! `guard_not_poisoned`), because a failure observed after the forced overrun could
57//! have been caused by it and attributed to the wrong assertion.
58//!
59//! NOTE: This module runs inside bolero's `catch_unwind` scope, which silently
60//! swallows panics. Internal invariant checks should use `abort_assert!`
61//! rather than `panic!`/`assert!`.
62//!
63//! TODO(mingwei): Panics inside the tick DFIR (generated code in the dylib) are
64//! also caught by bolero's `catch_unwind`. Consider a mechanism to detect and
65//! propagate those as well.
66
67/// Like `assert!`, but calls `std::process::abort()` instead of `panic!()`.
68/// Use for internal invariants that must not be silently caught by bolero.
69macro_rules! abort_assert {
70 ($cond:expr, $($arg:tt)*) => {
71 if !$cond {
72 eprintln!("Simulator internal error: {}", format!($($arg)*));
73 std::process::abort();
74 }
75 };
76}
77
78use core::{fmt, panic};
79use std::cell::{Cell, RefCell};
80use std::collections::{HashMap, VecDeque};
81use std::fmt::Debug;
82use std::panic::RefUnwindSafe;
83use std::path::Path;
84use std::pin::{Pin, pin};
85use std::rc::Rc;
86use std::task::{Poll, ready};
87
88use bytes::Bytes;
89use colored::Colorize;
90use dfir_rs::scheduled::context::DfirErased;
91use dfir_rs::util::unsync::mpsc::{Receiver as UnsyncReceiver, Sender as UnsyncSender};
92use futures::StreamExt;
93use libloading::Library;
94use tokio::sync::{Mutex, Notify};
95
96use super::runtime::{
97 Hooks, InlineHooks, ObservationHooks, ScriptTarget, ScriptedHookControl, ScriptedHookRegistry,
98 ScriptedInlineHooks, ScriptedObservationHooks, ScriptedTickHooks, SimLocation,
99};
100use super::{SimClusterReceiver, SimClusterSender, SimReceiver, SimSender};
101use crate::compile::builder::ExternalPortId;
102use crate::compile::trybuild::generate::BuiltArtifact;
103use crate::live_collections::stream::{ExactlyOnce, NoOrder, Ordering, Retries, TotalOrder};
104use crate::location::dynamic::LocationId;
105use crate::sim::graph::{SimExternalPort, SimExternalPortRegistry};
106use crate::sim::runtime::{
107 InlineHook, ObservationHook, ScriptedObservationHook, ScriptedTickInputHook, TickInputHook,
108};
109
110struct QuiescenceState {
111 /// Set to true when the scheduler reaches quiescence; reset to false when new input is sent.
112 quiescent: Cell<bool>,
113 /// Notified when the scheduler reaches quiescence (wakes receivers waiting for data).
114 quiescence_notify: Notify,
115 /// Notified when new input is sent, signaling the scheduler to resume.
116 resume_notify: Notify,
117 /// When nonzero, the scheduler must not start nondeterministic work (ticks /
118 /// observations): once only such work remains, it sets `nondet_pending` and pauses until
119 /// resumed. Used by receivers to query whether the simulation can quiesce
120 /// deterministically. This is a count (not a bool) because multiple settling futures can
121 /// be in flight at once (e.g. `select!`/`join!` between two receiver awaits): the
122 /// scheduler must stay paused until *every* one of them has finished settling.
123 pause_nondet: Cell<usize>,
124 /// Set while the scheduler is paused because nondeterministic work is ready to run but
125 /// `pause_nondet` is set.
126 nondet_pending: Cell<bool>,
127 /// Wakers for test-side tasks waiting for the scheduler to settle (either quiesce or set
128 /// `nondet_pending`) while `pause_nondet` is set. Also used by scripting futures that
129 /// need to be woken when the scheduler parks.
130 settle_wakers: RefCell<Vec<std::task::Waker>>,
131 /// Set when an observation *forced* the simulation to quiesce (running pending
132 /// nondeterministic work) outside of exhaustive mode's forking. Further observations of
133 /// the quiescent state remain sound, but once new input is sent (see `poisoned`), later
134 /// observations could misattribute failures caused by the forced overrun.
135 tainted: Cell<bool>,
136 /// Set when new input is sent after `tainted`; all further receives panic.
137 poisoned: Cell<bool>,
138}
139
140impl QuiescenceState {
141 /// Signal that new input has been sent, waking the scheduler if it was quiescent.
142 fn resume(&self) {
143 if self.tainted.get() {
144 self.poisoned.set(true);
145 }
146 self.quiescent.set(false);
147 // `notify_one` (rather than `notify_waiters`) stores a permit if the scheduler driver
148 // is not currently parked on [`Self::resumed`], so a resume that fires before the
149 // driver parks (e.g. input sent while the driver is polling the thunk) is not lost.
150 self.resume_notify.notify_one();
151 }
152
153 /// Whether the scheduler is currently quiescent (no more progress possible without input).
154 fn is_quiescent(&self) -> bool {
155 self.quiescent.get()
156 }
157
158 /// Returns a future that completes when the scheduler next reaches quiescence.
159 fn notified(&self) -> tokio::sync::futures::Notified<'_> {
160 self.quiescence_notify.notified()
161 }
162
163 /// Wakes test-side tasks waiting for the scheduler to settle.
164 fn wake_settled(&self) {
165 for waker in self.settle_wakers.borrow_mut().drain(..) {
166 waker.wake();
167 }
168 }
169
170 /// Enter quiescence, waking receivers waiting for data (their streams end). The scheduler
171 /// driver is responsible for parking until [`Self::resume`] is called with new input.
172 fn enter_quiescence(&self) {
173 self.quiescent.set(true);
174 self.quiescence_notify.notify_waiters();
175 self.wake_settled();
176 }
177
178 /// Completes when new input arrives (via [`Self::resume`]).
179 async fn resumed(&self) {
180 self.resume_notify.notified().await;
181 }
182
183 /// Registers a waker to be woken the next time the scheduler parks (quiescence or
184 /// settle-pause). Used by scripting futures: while the scheduler is running, the test
185 /// body is re-polled after every step anyway, so a waker is only needed for the parked
186 /// cases. Duplicate registrations are harmless.
187 fn push_park_waker(&self, waker: &std::task::Waker) {
188 self.settle_wakers.borrow_mut().push(waker.clone());
189 }
190}
191
192/// The **current group** of scripted decisions: consecutive decision calls in the test body
193/// that target different hooks of the same tick form a group, describing one execution of
194/// that tick. At most one group's decisions are ever installed at a time; the first decision
195/// call of the *next* group suspends until the current group's tick execution has consumed
196/// every installed decision.
197pub(crate) struct CurrentGroup {
198 /// The scheduler action the group's decisions apply to.
199 target: ScriptTarget,
200 /// The hook IDs with an installed decision in this group.
201 members: Vec<usize>,
202 /// Set when the scheduler starts a step. Until then, consecutive decisions for different
203 /// hooks of this tick may join the group in the same poll of the test body.
204 sealed: bool,
205}
206
207/// Coordinates the script protocol between test-side hook handles and the scheduler.
208#[derive(Default)]
209pub(crate) struct ScriptCoordinator {
210 /// `Some` means exactly one decision group is outstanding. The scheduler clears it only
211 /// after that group's tick executes, so the test cannot replace an unconsumed group.
212 current: Option<CurrentGroup>,
213 /// Set by the scheduler at each quiescence: `true` when the outstanding group is stuck
214 /// even though every queued decision is satisfiable, because none of them can trigger
215 /// the tick (and no unscripted input on the tick can trigger it either — otherwise the
216 /// tick would be runnable and the simulation would not be quiescent). Selects the
217 /// stuck-script error style rendered at the suspended test-side await; `false` means
218 /// some decision is waiting on input that can never arrive.
219 stuck_cannot_trigger: bool,
220}
221
222impl ScriptCoordinator {
223 /// Describes the not-yet-consumed decisions of the current group, one per line
224 /// (without a trailing newline), for error messages. `None` when no group is
225 /// outstanding or every decision is consumed.
226 fn describe_unconsumed(&self, hooks: &ScriptedHookRegistry) -> Option<String> {
227 let group = self.current.as_ref()?;
228 let mut out = String::new();
229 for id in &group.members {
230 let hook = hooks.get(id).unwrap().borrow();
231 if let Some(decision) = hook.describe_decision() {
232 use std::fmt::Write;
233 if !out.is_empty() {
234 out.push('\n');
235 }
236 write!(
237 out,
238 " {} is waiting on the hook at {}, which has {}",
239 decision,
240 hook.location_meta().location,
241 hook.describe_pending()
242 .as_deref()
243 .unwrap_or("no pending input"),
244 )
245 .unwrap();
246 }
247 }
248 (!out.is_empty()).then_some(out)
249 }
250}
251
252/// The per-instance scripting context, resolved through the task-local sim connections.
253///
254/// The three `Rc`s are genuinely distinct (not one shared allocation) because they have
255/// different owners and lifetimes: the hook registry only materializes when the dylib is
256/// launched (it is part of the `DylibResult`), while the coordinator and quiescence
257/// state live in the pre-launch `SimConnections` and are independently shared with
258/// receivers and test-side handles (quiescence is also used by non-scripting paths).
259/// This struct is the bundle of all three, assembled by-clone at resolution time.
260pub(crate) struct ScriptCtx {
261 hooks: Rc<ScriptedHookRegistry>,
262 coordinator: Rc<RefCell<ScriptCoordinator>>,
263 quiescence: Rc<QuiescenceState>,
264}
265
266/// The result of attempting to schedule one decision; see
267/// [`ScriptCtx::try_schedule_decision`].
268pub(crate) enum ScheduleDecision {
269 /// The decision was installed into the current group.
270 Installed,
271 /// The previous group has not been consumed yet; the decision blob is handed back and
272 /// the caller should retry after the scheduler makes progress.
273 Wait(Vec<u8>),
274}
275
276const UNBOUND_HOOK_ERROR: &str = "this sim hook handle is not bound to any operator in the simulated flow; \
277 attach it with `nondet!(... hook = handle)` at the operator it should control";
278
279impl ScriptCtx {
280 /// Resolves a hook handle's scripted hook. Panics if the handle was never bound to an
281 /// operator.
282 #[track_caller]
283 pub(crate) fn control(&self, hook_id: usize) -> Rc<RefCell<dyn ScriptedHookControl>> {
284 self.hooks
285 .get(&hook_id)
286 .cloned()
287 .unwrap_or_else(|| panic!("{}", UNBOUND_HOOK_ERROR))
288 }
289
290 /// Whether the simulation is currently quiescent (no more progress possible).
291 pub(crate) fn is_quiescent(&self) -> bool {
292 self.quiescence.is_quiescent()
293 }
294
295 /// See [`QuiescenceState::push_park_waker`].
296 pub(crate) fn push_park_waker(&self, waker: &std::task::Waker) {
297 self.quiescence.push_park_waker(waker);
298 }
299
300 /// Attempts to install a decision (bincode-serialized; the handle and hook statically
301 /// know the matching type) for `hook_id` under the group protocol: join the current
302 /// group if this decision belongs to it, open a new group if the previous one has
303 /// been consumed, or hand the decision back to be retried once the previous group's
304 /// tick execution has happened.
305 pub(crate) fn try_schedule_decision(
306 &self,
307 hook_id: usize,
308 decision_blob: Vec<u8>,
309 ) -> Result<ScheduleDecision, String> {
310 let hook = self.control(hook_id);
311 let target = hook.borrow().target();
312
313 let mut coordinator = self.coordinator.borrow_mut();
314
315 enum Action {
316 Join,
317 NewGroup,
318 Wait,
319 }
320
321 let action = match &coordinator.current {
322 None => Action::NewGroup,
323 Some(group)
324 if !group.sealed
325 && matches!(target, ScriptTarget::Tick { .. })
326 && group.target == target
327 && !group.members.contains(&hook_id) =>
328 {
329 Action::Join
330 }
331 Some(_) => Action::Wait,
332 };
333
334 match action {
335 Action::Join => {
336 coordinator.current.as_mut().unwrap().members.push(hook_id);
337 }
338 Action::NewGroup => {
339 coordinator.current = Some(CurrentGroup {
340 target,
341 members: vec![hook_id],
342 sealed: false,
343 });
344 }
345 Action::Wait => {
346 // The previous group's execution hasn't happened yet; hand the decision
347 // back to be retried. The waiting hook stays subject to the boundary scan:
348 // buffered input held across this wait must be declared with an explicit
349 // pause (the waiting decision names a *later* execution).
350 if self.quiescence.is_quiescent() {
351 let stuck = coordinator.describe_unconsumed(&self.hooks);
352 let stuck = stuck.as_deref().unwrap_or(" (unknown decision)");
353 let header = if coordinator.stuck_cannot_trigger {
354 "a previously scripted decision group can never run: none of its tick's hooks can trigger it (no scripted decision triggers, and no unscripted input has data)"
355 } else {
356 "a previously scripted decision can never be satisfied (the simulation has no more work it can do)"
357 };
358 return Err(format!("cannot script this decision: {header}:\n{stuck}"));
359 }
360 return Ok(ScheduleDecision::Wait(decision_blob));
361 }
362 }
363 drop(coordinator);
364
365 hook.borrow_mut().install_decision(&decision_blob);
366 // Installing a decision can make a tick runnable; wake the scheduler if parked.
367 self.quiescence.resume();
368 Ok(ScheduleDecision::Installed)
369 }
370}
371
372/// Resolves the per-instance scripting context. Panics if called outside a simulation.
373pub(crate) fn script_ctx() -> ScriptCtx {
374 CURRENT_SIM_CONNECTIONS.with(|connections| {
375 let connections = connections.borrow();
376 ScriptCtx {
377 hooks: connections.scripted_hooks.clone(),
378 coordinator: connections.script_coordinator.clone(),
379 quiescence: connections.quiescence.clone(),
380 }
381 })
382}
383
384/// Renders the stuck-script error for a quiescent simulation with an outstanding group.
385/// Two distinct failure styles: a decision that is *unsatisfiable* (waiting on input that
386/// can never arrive), vs decisions that are all satisfiable but *cannot trigger* their
387/// tick (none of them triggers, and no unscripted input on the tick has data).
388fn render_stuck_script_error(cannot_trigger: bool, stuck: &str) -> String {
389 if cannot_trigger {
390 format!(
391 "the simulation has stopped, but scripted decisions are still pending: none of the tick's hooks can trigger it (no scripted decision triggers, and no unscripted input has data):\n{stuck}\nhelp: script a decision that triggers the tick, or drive an unscripted input, so the tick can run"
392 )
393 } else {
394 format!("a scripted decision can never be satisfied:\n{stuck}")
395 }
396}
397
398/// Renders the stuck-script error for the current instance (see
399/// [`render_stuck_script_error`]); the scheduler classified the failure style when it
400/// reached quiescence.
401pub(crate) fn script_stuck_error(stuck: &str) -> String {
402 let cannot_trigger = CURRENT_SIM_CONNECTIONS.with(|connections| {
403 let connections = connections.borrow();
404 let coordinator = connections.script_coordinator.borrow();
405 coordinator.stuck_cannot_trigger
406 });
407 render_stuck_script_error(cannot_trigger, stuck)
408}
409
410/// If a scripted group is outstanding, returns a description of its decisions (used by
411/// output awaits and `pause_until` waits, which are script barriers: they must not
412/// resolve until every decision scripted so far has run).
413pub(crate) fn script_unconsumed_description() -> Option<String> {
414 CURRENT_SIM_CONNECTIONS.with(|connections| {
415 let connections = connections.borrow();
416 let coordinator = connections.script_coordinator.borrow();
417 coordinator.current.as_ref()?;
418 Some(
419 coordinator
420 .describe_unconsumed(&connections.scripted_hooks)
421 .unwrap_or_else(|| " (unknown decision)".to_owned()),
422 )
423 })
424}
425
426/// Tracks a pending "settle" pause request to the scheduler (see
427/// [`QuiescenceState::pause_nondet`]), releasing it if the requesting future is dropped
428/// mid-settle (e.g. by `select!`) so the scheduler is not left paused forever. Pause
429/// requests are counted, so concurrent settling futures each hold their own request.
430struct SettlePauseGuard {
431 quiescence: Rc<QuiescenceState>,
432 active: bool,
433}
434
435impl SettlePauseGuard {
436 fn new(quiescence: Rc<QuiescenceState>) -> Self {
437 SettlePauseGuard {
438 quiescence,
439 active: false,
440 }
441 }
442
443 fn acquire(&mut self) {
444 abort_assert!(!self.active, "settle pause acquired twice");
445 self.quiescence
446 .pause_nondet
447 .set(self.quiescence.pause_nondet.get() + 1);
448 self.active = true;
449 }
450
451 fn release(&mut self) {
452 abort_assert!(self.active, "settle pause released without being acquired");
453 self.active = false;
454 self.quiescence
455 .pause_nondet
456 .set(self.quiescence.pause_nondet.get() - 1);
457 }
458
459 /// Polls the "settle" handshake with the scheduler: deterministic (non-tick) work is
460 /// allowed to run, but the scheduler pauses instead of starting nondeterministic work
461 /// (ticks / observations). Resolves to `true` if the simulation reached quiescence
462 /// deterministically, or `false` if nondeterministic work is pending (in which case the
463 /// scheduler is resumed).
464 fn poll_settle(&mut self, cx: &mut std::task::Context<'_>) -> Poll<bool> {
465 let quiescence = self.quiescence.clone();
466 if !self.active {
467 if quiescence.is_quiescent() {
468 return Poll::Ready(true);
469 }
470 self.acquire();
471 }
472
473 if quiescence.is_quiescent() {
474 self.release();
475 Poll::Ready(true)
476 } else if quiescence.nondet_pending.get() {
477 self.release();
478 // `notify_one` (permit-based): the driver only parks *between* thunk polls, so it
479 // is not parked right now — the permit ensures this resume is not lost.
480 quiescence.resume_notify.notify_one();
481 Poll::Ready(false)
482 } else {
483 // This may push a duplicate waker if we are re-polled without an intervening
484 // `wake_settled` (e.g. a `join!` sibling waking the shared task), but duplicates
485 // are harmless (waking is idempotent) and are cleared at the next `wake_settled`,
486 // so deduplicating here isn't worth the scan on every poll.
487 quiescence
488 .settle_wakers
489 .borrow_mut()
490 .push(cx.waker().clone());
491 Poll::Pending
492 }
493 }
494}
495
496impl Drop for SettlePauseGuard {
497 fn drop(&mut self) {
498 if self.active {
499 self.release();
500 // Resume the scheduler in case this was the last pause request (otherwise it
501 // would stay parked forever with nobody left to resume it). `notify_one`
502 // (permit-based) so the resume is not lost if the driver has not parked yet. If
503 // other settlers still hold requests, this wakeup is spurious but harmless: the
504 // scheduler re-checks `pause_nondet > 0` before starting any nondeterministic
505 // work, so it immediately re-parks without running anything.
506 self.quiescence.resume_notify.notify_one();
507 }
508 }
509}
510
511/// Panics if the simulation has been poisoned: an earlier observation forced the simulation
512/// to quiesce (running pending nondeterministic work), and new input has been sent since, so
513/// further observations could misattribute failures caused by the forced overrun.
514fn guard_not_poisoned(quiescence: &QuiescenceState) {
515 if quiescence.poisoned.get() {
516 panic!(
517 "cannot receive more simulator output: an earlier observation (such as `try_next`, `collect`, or a quiescence assertion outside exhaustive mode) forced the simulation to quiesce by running pending nondeterministic work, and new input has been sent since. Failures observed now could be misattributed, so either restructure the test to make quiescence-forcing observations its last step, or insert an explicit `sim::quiesce().await` phase barrier before sending more input."
518 );
519 }
520}
521
522/// Runs the simulation to quiescence, as an explicit *phase barrier* between rounds of a
523/// multi-phase test.
524///
525/// All pending nondeterministic work (ticks / observations) is forced to run until no more
526/// progress is possible without new input. This deliberately narrows the explored executions:
527/// inputs sent after the barrier will never interleave with work from before it, modeling
528/// scenarios where new stimuli (such as timer ticks) arrive long after the system settles.
529/// Pair such tests with a separate barrier-free test if interleaved executions should also be
530/// explored.
531///
532/// Because the barrier is explicit, observations after it are *intended* to see the fully
533/// settled state, so — unlike [`SimReceiver::try_next`] / [`SimReceiver::collect`] forcing
534/// quiescence implicitly — it does not restrict what the test may do afterwards: receives
535/// after the barrier observe only buffered output (plus whatever later input produces), and
536/// failures cannot be misattributed across it.
537pub async fn quiesce() {
538 let quiescence =
539 CURRENT_SIM_CONNECTIONS.with(|connections| connections.borrow().quiescence.clone());
540 guard_not_poisoned(&quiescence);
541
542 let mut notified_fut = pin!(None);
543 std::future::poll_fn(|cx| {
544 if quiescence.is_quiescent() {
545 // A stuck scripted decision makes this a *dirty* quiescence: report it here
546 // rather than letting the barrier silently pass.
547 if let Some(stuck) = script_unconsumed_description() {
548 panic!("{}", script_stuck_error(&stuck));
549 }
550 return Poll::Ready(());
551 }
552 // Registered before the scheduler can run (single-threaded), so the quiescence
553 // notification cannot be missed.
554 if notified_fut.is_none() {
555 notified_fut.set(Some(quiescence.notified()));
556 }
557 let () = ready!(notified_fut.as_mut().as_pin_mut().unwrap().poll(cx));
558 Poll::Ready(())
559 })
560 .await;
561
562 // The barrier subsumes any quiescence forced by earlier observations in this phase:
563 // everything before it has fully settled, and the test has explicitly opted into
564 // observing only post-quiescence states from here on.
565 quiescence.tainted.set(false);
566}
567
568/// Receives the next message from `receiver` while trying not to overrun the simulation:
569/// first the simulation *settles* (deterministic work runs, but the scheduler pauses before
570/// nondeterministic work). If a message arrives, it is returned; if the simulation settles to
571/// quiescence, returns `None` without having run any nondeterministic work. Otherwise the
572/// scheduler is resumed and pending nondeterministic work runs until a message arrives or the
573/// simulation quiesces; quiescing this way *taints* the simulation (see
574/// [`QuiescenceState::tainted`]).
575async fn try_next_bytes(
576 receiver: &Mutex<UnsyncReceiver<Bytes>>,
577 quiescence: &Rc<QuiescenceState>,
578) -> Option<Bytes> {
579 guard_not_poisoned(quiescence);
580
581 let mut receiver_stream = receiver.lock().await;
582 let mut settle_guard = SettlePauseGuard::new(quiescence.clone());
583 // `Some` once the settle phase has concluded that nondeterministic work is pending and
584 // we have started forcing it to run.
585 let mut notified_fut = pin!(None);
586
587 std::future::poll_fn(|cx| {
588 // **Scripted-decision barrier**: an output await completes only after every
589 // decision scripted so far has been consumed, so every point where the test body
590 // resumes is a clean synchronization point (the script written so far has fully
591 // happened). If the simulation runs out of work while a scripted decision is still
592 // waiting, that decision can never be honored — panic instead of yielding output
593 // or end-of-stream, so a stuck script cannot masquerade as a completed one.
594 if let Some(stuck) = script_unconsumed_description() {
595 if quiescence.is_quiescent() {
596 panic!("{}", script_stuck_error(&stuck));
597 }
598 quiescence.push_park_waker(cx.waker());
599 return Poll::Pending;
600 }
601
602 // A message may become available at any point (including from deterministic work
603 // while settling), so always check the stream first.
604 match receiver_stream.poll_next_unpin(cx) {
605 Poll::Ready(Some(bytes)) => return Poll::Ready(Some(bytes)),
606 Poll::Ready(None) => return Poll::Ready(None),
607 Poll::Pending => {}
608 }
609
610 if notified_fut.is_none() {
611 match settle_guard.poll_settle(cx) {
612 // Deterministically quiescent: no more messages, and nothing was overrun.
613 Poll::Ready(true) => return Poll::Ready(None),
614 // Nondeterministic work is pending; start forcing it to run. The `Notified`
615 // is created here and polled (registered) below in this same synchronous
616 // poll — before the scheduler can run — and the simulation is not currently
617 // quiescent, so the quiescence notification cannot be missed.
618 Poll::Ready(false) => notified_fut.set(Some(quiescence.notified())),
619 Poll::Pending => return Poll::Pending,
620 }
621 }
622
623 // Let the scheduler run nondeterministic work until a message arrives or the
624 // simulation quiesces. Note that merely entering this phase does not taint: if a
625 // message arrives (the `Some` exit at the top), waiting was sound for the same
626 // reason as `SimReceiver::next` — the work that ran was needed to produce it. Only
627 // *observing quiescence* after forcing the pending work taints, since that is the
628 // overrun a later observation could misattribute.
629 let () = ready!(notified_fut.as_mut().as_pin_mut().unwrap().poll(cx));
630 quiescence.tainted.set(true);
631 Poll::Ready(None)
632 })
633 .await
634}
635
636struct SimConnections {
637 input_senders: HashMap<SimExternalPort, UnsyncSender<Bytes>>,
638 output_receivers: HashMap<SimExternalPort, Rc<Mutex<UnsyncReceiver<Bytes>>>>,
639 cluster_input_senders: HashMap<SimExternalPort, HashMap<u32, UnsyncSender<Bytes>>>,
640 cluster_output_receivers:
641 HashMap<SimExternalPort, HashMap<u32, Rc<Mutex<UnsyncReceiver<Bytes>>>>>,
642 external_registered: HashMap<ExternalPortId, SimExternalPort>,
643 quiescence: Rc<QuiescenceState>,
644 /// Every scripted hook (shared with the scheduler's tick lists), keyed by handle ID.
645 scripted_hooks: Rc<ScriptedHookRegistry>,
646 /// Coordinates the decision-group protocol between hook handles and the scheduler.
647 script_coordinator: Rc<RefCell<ScriptCoordinator>>,
648 log: bool,
649 /// Whether this instance is being executed by the exhaustive engine (see
650 /// [`CompiledSim::exhaustive`]), which affects how `assert_yields_only` explores
651 /// quiescence checks.
652 exhaustive: bool,
653}
654
655/// Implementation detail of [`crate::sim::continue_if!`](crate::continue_if); do not call directly.
656///
657/// If `condition` is false, aborts the current simulation instance by panicking with a special
658/// payload ([`bolero::generator::bolero_generator::any::Error`]) that bolero recognizes as an
659/// "invalid input" marker: the instance is discarded (not treated as a test failure, and never
660/// recorded as a reproducer) and exploration moves on to the next instance. If logging is
661/// enabled for the current instance, the failed assumption is logged first.
662#[doc(hidden)]
663#[track_caller]
664pub fn continue_if_impl(condition: bool, message: fmt::Arguments<'_>) {
665 if condition {
666 return;
667 }
668
669 let log = CURRENT_SIM_CONNECTIONS
670 .try_with(|connections| connections.borrow().log)
671 .unwrap_or(true);
672 if log {
673 eprintln!(
674 "{}",
675 render_continue_if_failure(std::panic::Location::caller(), message)
676 );
677 }
678
679 // Panics with `bolero_generator::any::Error`, which bolero's engines treat as an invalid
680 // input rather than a test failure. Both this function and bolero's `assume` are
681 // `#[track_caller]`, so the recorded location is the user's `continue_if!` call site.
682 bolero::generator::bolero_generator::any::assume(false, "simulation assumption failed");
683}
684
685/// Renders the log message for a failed assumption, echoing the source line with a caret
686/// pointing at the `continue_if!` call site, in the same style as the other simulator logs.
687fn render_continue_if_failure(
688 location: &std::panic::Location<'_>,
689 message: fmt::Arguments<'_>,
690) -> String {
691 use std::fmt::Write;
692
693 // `Location::file()` is relative to the directory the crate was compiled from (e.g. the
694 // workspace root), which may not match the current working directory (e.g. the crate
695 // root when running `cargo test`), so walk up from the current directory to find it.
696 let source_line = std::env::current_dir()
697 .ok()
698 .and_then(|cwd| {
699 cwd.ancestors()
700 .find_map(|base| std::fs::read_to_string(base.join(location.file())).ok())
701 })
702 .and_then(|content| {
703 content
704 .lines()
705 .nth((location.line() as usize).saturating_sub(1))
706 .map(|line| line.to_owned())
707 })
708 .unwrap_or_default();
709
710 let caret_indent = " ".repeat((location.column() as usize).saturating_sub(1));
711
712 let mut out = String::new();
713 let _ = writeln!(
714 out,
715 "\n{}",
716 "Condition failed (discarding simulation instance):"
717 .color(colored::Color::Yellow)
718 .bold()
719 );
720 let _ = writeln!(out, "{} {}", "-->".color(colored::Color::Blue), location);
721 let _ = writeln!(out, " {}{}", "|".color(colored::Color::Blue), source_line);
722 let _ = write!(
723 out,
724 " {}{}{}",
725 "|".color(colored::Color::Blue),
726 caret_indent,
727 format!("^ {}", message).color(colored::Color::Yellow)
728 );
729 out
730}
731
732tokio::task_local! {
733 static CURRENT_SIM_CONNECTIONS: RefCell<SimConnections>;
734}
735
736/// A handle to a compiled Hydro simulation, which can be instantiated and run.
737pub struct CompiledSim {
738 pub(super) _path: BuiltArtifact,
739 pub(super) lib: Library,
740 pub(super) externals_port_registry: SimExternalPortRegistry,
741 pub(super) unit_test_fuzz_iterations: usize,
742}
743
744#[sealed::sealed]
745/// A trait implemented by closures that can instantiate a compiled simulation.
746///
747/// This is needed to ensure [`RefUnwindSafe`] so instances can be created during fuzzing.
748pub trait Instantiator<'a>: RefUnwindSafe + Fn() -> CompiledSimInstance<'a> {}
749#[sealed::sealed]
750impl<'a, T: RefUnwindSafe + Fn() -> CompiledSimInstance<'a>> Instantiator<'a> for T {}
751
752fn null_handler(_args: fmt::Arguments<'_>) {}
753
754fn println_handler(args: fmt::Arguments<'_>) {
755 println!("{}", args);
756}
757
758fn eprintln_handler(args: fmt::Arguments<'_>) {
759 eprintln!("{}", args);
760}
761
762/// Creates a simulation instance, returning:
763/// - A list of async DFIRs to run (all process / cluster logic outside a tick)
764/// - A list of tick DFIRs to run (where the &'static str is for the tick location id)
765/// - A mapping of hooks for non-deterministic decisions at tick-input boundaries
766/// - A mapping of inline hooks for non-deterministic decisions inside ticks
767type SimLoaded<'a> = libloading::Symbol<
768 'a,
769 unsafe extern "Rust" fn(
770 should_color: bool,
771 external_out: &mut HashMap<usize, UnsyncReceiver<Bytes>>,
772 external_in: &mut HashMap<usize, UnsyncSender<Bytes>>,
773 cluster_external_out: &mut HashMap<usize, HashMap<u32, UnsyncReceiver<Bytes>>>,
774 cluster_external_in: &mut HashMap<usize, HashMap<u32, UnsyncSender<Bytes>>>,
775 println_handler: fn(fmt::Arguments<'_>),
776 eprintln_handler: fn(fmt::Arguments<'_>),
777 ) -> (
778 Vec<(LocationId, Option<u32>, DfirErased)>,
779 Vec<(LocationId, Option<u32>, DfirErased)>,
780 Hooks,
781 ObservationHooks,
782 InlineHooks,
783 ScriptedTickHooks,
784 ScriptedObservationHooks,
785 ScriptedInlineHooks,
786 ScriptedHookRegistry,
787 ),
788>;
789
790impl CompiledSim {
791 /// Executes the given closure with a single instance of the compiled simulation.
792 pub fn with_instance<T>(&self, thunk: impl FnOnce(CompiledSimInstance<'_>) -> T) -> T {
793 self.with_instantiator(|instantiator| thunk(instantiator()), true)
794 }
795
796 /// Executes the given closure with an [`Instantiator`], which can be called to create
797 /// independent instances of the simulation. This is useful for fuzzing, where we need to
798 /// re-execute the simulation several times with different decisions.
799 ///
800 /// The `always_log` parameter controls whether to log tick executions and stream releases. If
801 /// it is `true`, logging will always be enabled. If it is `false`, logging will only be
802 /// enabled if the `HYDRO_SIM_LOG` environment variable is set to `1`.
803 pub fn with_instantiator<T>(
804 &self,
805 thunk: impl FnOnce(&dyn Instantiator<'_>) -> T,
806 always_log: bool,
807 ) -> T {
808 let func: SimLoaded<'_> = unsafe { self.lib.get(b"__hydro_runtime").unwrap() };
809 let log = always_log || std::env::var("HYDRO_SIM_LOG").is_ok_and(|v| v == "1");
810 thunk(
811 &(|| CompiledSimInstance {
812 func: func.clone(),
813 externals_port_registry: self.externals_port_registry.clone(),
814 dylib_result: None,
815 log,
816 exhaustive: false,
817 deterministic: false,
818 }),
819 )
820 }
821
822 /// Uses a fuzzing strategy to explore possible executions of the simulation. The provided
823 /// closure will be repeatedly executed with instances of the Hydro program where the
824 /// batching boundaries, order of messages, and retries are varied.
825 ///
826 /// During development, you should run the test that invokes this function with the `cargo sim`
827 /// command, which will use `libfuzzer` to intelligently explore the execution space. If a
828 /// failure is found, a minimized test case will be produced in a `sim-failures` directory.
829 /// When running the test with `cargo test` (such as in CI), if a reproducer is found it will
830 /// be executed, and if no reproducer is found a small number of random executions will be
831 /// performed.
832 pub fn fuzz(&self, mut thunk: impl AsyncFnMut() + RefUnwindSafe) {
833 let caller_fn = crate::compile::ir::backtrace::Backtrace::get_backtrace(0)
834 .elements()
835 .into_iter()
836 .find(|e| {
837 !e.fn_name.starts_with("hydro_lang::sim::compiled")
838 && !e.fn_name.starts_with("hydro_lang::sim::flow")
839 && !e.fn_name.starts_with("fuzz<")
840 && !e.fn_name.starts_with("<hydro_lang::sim")
841 })
842 .unwrap();
843
844 let caller_path = Path::new(&caller_fn.filename.unwrap()).to_path_buf();
845 let repro_folder = caller_path.parent().unwrap().join("sim-failures");
846
847 let caller_fuzz_repro_path = repro_folder
848 .join(caller_fn.fn_name.replace("::", "__"))
849 .with_extension("bin");
850
851 if std::env::var("BOLERO_FUZZER").is_ok() {
852 let corpus_dir = std::env::current_dir().unwrap().join(".fuzz-corpus");
853 std::fs::create_dir_all(&corpus_dir).unwrap();
854 let libfuzzer_args = format!(
855 "{} {} -artifact_prefix={}/ -handle_abrt=0",
856 corpus_dir.to_str().unwrap(),
857 corpus_dir.to_str().unwrap(),
858 corpus_dir.to_str().unwrap(),
859 );
860
861 std::fs::create_dir_all(&repro_folder).unwrap();
862
863 if !std::env::var("HYDRO_NO_FAILURE_OUTPUT").is_ok_and(|v| v == "1") {
864 unsafe {
865 std::env::set_var(
866 "BOLERO_FAILURE_OUTPUT",
867 caller_fuzz_repro_path.to_str().unwrap(),
868 );
869 }
870 }
871
872 unsafe {
873 std::env::set_var("BOLERO_LIBFUZZER_ARGS", libfuzzer_args);
874 }
875
876 self.with_instantiator(
877 |instantiator| {
878 bolero::test(bolero::TargetLocation {
879 package_name: "",
880 manifest_dir: "",
881 module_path: "",
882 file: "",
883 line: 0,
884 item_path: "<unknown>::__bolero_item_path__",
885 test_name: None,
886 })
887 .run_with_replay(move |is_replay| {
888 let mut instance = instantiator();
889
890 if instance.log {
891 eprintln!(
892 "{}",
893 "\n==== New Simulation Instance ===="
894 .color(colored::Color::Cyan)
895 .bold()
896 );
897 }
898
899 if is_replay {
900 instance.log = true;
901 }
902
903 tokio::runtime::Builder::new_current_thread()
904 .build()
905 .unwrap()
906 .block_on(async { instance.run(&mut thunk).await })
907 })
908 },
909 false,
910 );
911 } else if let Ok(existing_bytes) = std::fs::read(&caller_fuzz_repro_path) {
912 self.fuzz_repro(existing_bytes, async |compiled| {
913 compiled.run_with_scheduler(thunk()).await
914 });
915 } else {
916 eprintln!(
917 "Running a fuzz test without `cargo sim` and no reproducer found at {}, using {} iterations with random inputs.",
918 caller_fuzz_repro_path.display(),
919 self.unit_test_fuzz_iterations,
920 );
921 self.with_instantiator(
922 |instantiator| {
923 bolero::test(bolero::TargetLocation {
924 package_name: "",
925 manifest_dir: "",
926 module_path: "",
927 file: ".",
928 line: 0,
929 item_path: "<unknown>::__bolero_item_path__",
930 test_name: None,
931 })
932 .with_iterations(self.unit_test_fuzz_iterations)
933 .run_with_replay(move |is_replay| {
934 let mut instance = instantiator();
935
936 if instance.log {
937 eprintln!(
938 "{}",
939 "\n==== New Simulation Instance ===="
940 .color(colored::Color::Cyan)
941 .bold()
942 );
943 }
944
945 if is_replay {
946 instance.log = true;
947 }
948
949 tokio::runtime::Builder::new_current_thread()
950 .build()
951 .unwrap()
952 .block_on(async { instance.run(&mut thunk).await })
953 })
954 },
955 false,
956 );
957 }
958 }
959
960 /// Executes the given closure with a single instance of the compiled simulation, using the
961 /// provided bytes as the source of fuzzing decisions. This can be used to manually reproduce a
962 /// failure found during fuzzing.
963 pub fn fuzz_repro<'a>(
964 &'a self,
965 bytes: Vec<u8>,
966 thunk: impl AsyncFnOnce(CompiledSimInstance<'_>) + RefUnwindSafe,
967 ) {
968 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
969 self.with_instance(|instance| {
970 bolero::bolero_engine::any::scope::with(
971 Box::new(bolero::bolero_engine::driver::object::Object(
972 bolero::bolero_engine::driver::bytes::Driver::new(
973 bytes,
974 &Default::default(),
975 ),
976 )),
977 || {
978 tokio::runtime::Builder::new_current_thread()
979 .build()
980 .unwrap()
981 .block_on(async { instance.run_without_launching(thunk).await })
982 },
983 )
984 })
985 }));
986
987 if let Err(payload) = result {
988 if payload
989 .downcast_ref::<bolero::generator::bolero_generator::any::Error>()
990 .is_some()
991 {
992 // A `continue_if!` failed (or the driver ran out of entropy) while replaying the
993 // recorded bytes. Instances that fail an assumption are never recorded as
994 // failures, so this means the reproducer is stale or does not correspond to
995 // this program.
996 panic!(
997 "simulation assumption failed while replaying recorded fuzz decisions; the reproducer may be stale or may not correspond to this program"
998 );
999 }
1000 std::panic::resume_unwind(payload);
1001 }
1002 }
1003
1004 /// Exhaustively searches all possible executions of the simulation. The provided
1005 /// closure will be repeatedly executed with instances of the Hydro program where the
1006 /// batching boundaries, order of messages, and retries are varied.
1007 ///
1008 /// Exhaustive searching is feasible when the inputs to the Hydro program are finite and there
1009 /// are no dataflow loops that generate infinite messages. Exhaustive searching provides a
1010 /// stronger guarantee of correctness than fuzzing, but may take a long time to complete.
1011 /// Because no fuzzer is involved, you can run exhaustive tests with `cargo test`.
1012 ///
1013 /// Returns the number of distinct executions explored.
1014 pub fn exhaustive(&self, mut thunk: impl AsyncFnMut() + RefUnwindSafe) -> usize {
1015 if std::env::var("BOLERO_FUZZER").is_ok() {
1016 eprintln!(
1017 "Cannot run exhaustive tests with a fuzzer. Please use `cargo test` instead of `cargo sim`."
1018 );
1019 std::process::abort();
1020 }
1021
1022 let mut count = 0;
1023 let count_mut = &mut count;
1024
1025 let _span = tracing::debug_span!(target: "hydro_build", "sim_exhaustive").entered();
1026
1027 self.with_instantiator(
1028 |instantiator| {
1029 bolero::test(bolero::TargetLocation {
1030 package_name: "",
1031 manifest_dir: "",
1032 module_path: "",
1033 file: "",
1034 line: 0,
1035 item_path: "<unknown>::__bolero_item_path__",
1036 test_name: None,
1037 })
1038 .exhaustive()
1039 .run_with_replay(move |is_replay| {
1040 *count_mut += 1;
1041
1042 let mut instance = instantiator();
1043 instance.exhaustive = true;
1044 if instance.log {
1045 eprintln!(
1046 "{}",
1047 "\n==== New Simulation Instance ===="
1048 .color(colored::Color::Cyan)
1049 .bold()
1050 );
1051 }
1052
1053 if is_replay {
1054 instance.log = true;
1055 }
1056
1057 tokio::runtime::Builder::new_current_thread()
1058 .build()
1059 .unwrap()
1060 .block_on(async { instance.run(&mut thunk).await })
1061 })
1062 },
1063 false,
1064 );
1065
1066 count
1067 }
1068
1069 /// Runs the test body against exactly **one** execution of the program, with no fuzzer
1070 /// involved anywhere: if it passes once, it passes always, on every machine.
1071 ///
1072 /// Every source of variation must be pinned: inputs are already scripted (via
1073 /// `sim_input`), and every unsafe operator that receives data must be bound to a sim
1074 /// hook (see [`crate::sim_hooks`]) and scripted — encountering an unhooked operator
1075 /// with meaningful input panics, naming the operator. The scheduler needs no
1076 /// tie-breaking policy because at most one tick is ever runnable: scripted decisions
1077 /// activate one group at a time, so the *script* is the schedule.
1078 pub fn deterministic(&self, thunk: impl AsyncFnOnce() + RefUnwindSafe) {
1079 self.with_instance(|mut instance| {
1080 instance.deterministic = true;
1081
1082 // Deliberately do not install a Bolero entropy scope. Deterministic execution
1083 // must never draw entropy; Bolero's unset thread-local scope makes any accidental
1084 // draw fail immediately with `no scope set`.
1085 tokio::runtime::Builder::new_current_thread()
1086 .build()
1087 .unwrap()
1088 .block_on(instance.run(thunk));
1089 })
1090 }
1091}
1092
1093// This must be a tuple because it is referenced from generated code in `graph.rs`.
1094type DylibResult = (
1095 Vec<(LocationId, Option<u32>, DfirErased)>,
1096 Vec<(LocationId, Option<u32>, DfirErased)>,
1097 Hooks,
1098 ObservationHooks,
1099 InlineHooks,
1100 ScriptedTickHooks,
1101 ScriptedObservationHooks,
1102 ScriptedInlineHooks,
1103 ScriptedHookRegistry,
1104);
1105
1106/// A single instance of a compiled Hydro simulation, which provides methods to interactively
1107/// execute the simulation, feed inputs, and receive outputs.
1108pub struct CompiledSimInstance<'a> {
1109 func: SimLoaded<'a>,
1110 externals_port_registry: SimExternalPortRegistry,
1111 dylib_result: Option<DylibResult>,
1112 log: bool,
1113 exhaustive: bool,
1114 deterministic: bool,
1115}
1116
1117impl<'a> CompiledSimInstance<'a> {
1118 async fn run(self, thunk: impl AsyncFnOnce() + RefUnwindSafe) {
1119 self.run_without_launching(async |instance| {
1120 instance.run_with_scheduler(thunk()).await;
1121 })
1122 .await;
1123 }
1124
1125 async fn run_without_launching(
1126 mut self,
1127 thunk: impl AsyncFnOnce(CompiledSimInstance<'_>) + RefUnwindSafe,
1128 ) {
1129 let mut external_out: HashMap<usize, UnsyncReceiver<Bytes>> = HashMap::new();
1130 let mut external_in: HashMap<usize, UnsyncSender<Bytes>> = HashMap::new();
1131 let mut cluster_external_out: HashMap<usize, HashMap<u32, UnsyncReceiver<Bytes>>> =
1132 HashMap::new();
1133 let mut cluster_external_in: HashMap<usize, HashMap<u32, UnsyncSender<Bytes>>> =
1134 HashMap::new();
1135
1136 let mut dylib_result = unsafe {
1137 (self.func)(
1138 colored::control::SHOULD_COLORIZE.should_colorize(),
1139 &mut external_out,
1140 &mut external_in,
1141 &mut cluster_external_out,
1142 &mut cluster_external_in,
1143 if self.log {
1144 println_handler
1145 } else {
1146 null_handler
1147 },
1148 if self.log {
1149 eprintln_handler
1150 } else {
1151 null_handler
1152 },
1153 )
1154 };
1155
1156 let registered = &self.externals_port_registry.registered;
1157
1158 let quiescence = Rc::new(QuiescenceState {
1159 quiescent: Cell::new(false),
1160 quiescence_notify: Notify::new(),
1161 resume_notify: Notify::new(),
1162 pause_nondet: Cell::new(0),
1163 nondet_pending: Cell::new(false),
1164 settle_wakers: RefCell::new(vec![]),
1165 tainted: Cell::new(false),
1166 poisoned: Cell::new(false),
1167 });
1168
1169 let mut input_senders = HashMap::new();
1170 let mut output_receivers = HashMap::new();
1171 let mut cluster_input_senders = HashMap::new();
1172 let mut cluster_output_receivers = HashMap::new();
1173
1174 #[expect(
1175 clippy::disallowed_methods,
1176 reason = "inserts into maps also unordered"
1177 )]
1178 for sim_port in registered.values() {
1179 let usize_key = sim_port.into_inner();
1180 if let Some(sender) = external_in.remove(&usize_key) {
1181 input_senders.insert(*sim_port, sender);
1182 }
1183 if let Some(receiver) = external_out.remove(&usize_key) {
1184 output_receivers.insert(*sim_port, Rc::new(Mutex::new(receiver)));
1185 }
1186 if let Some(senders) = cluster_external_in.remove(&usize_key) {
1187 cluster_input_senders.insert(*sim_port, senders);
1188 }
1189 if let Some(receivers) = cluster_external_out.remove(&usize_key) {
1190 cluster_output_receivers.insert(
1191 *sim_port,
1192 receivers
1193 .into_iter()
1194 .map(|(member, r)| (member, Rc::new(Mutex::new(r))))
1195 .collect(),
1196 );
1197 }
1198 }
1199
1200 let scripted_hooks = Rc::new(std::mem::take(&mut dylib_result.8));
1201 self.dylib_result = Some(dylib_result);
1202
1203 CURRENT_SIM_CONNECTIONS
1204 .scope(
1205 RefCell::new(SimConnections {
1206 input_senders,
1207 output_receivers,
1208 cluster_input_senders,
1209 cluster_output_receivers,
1210 external_registered: self.externals_port_registry.registered.clone(),
1211 quiescence: quiescence.clone(),
1212 scripted_hooks,
1213 script_coordinator: Rc::new(RefCell::new(ScriptCoordinator::default())),
1214 log: self.log,
1215 exhaustive: self.exhaustive,
1216 }),
1217 async move {
1218 thunk(self).await;
1219 },
1220 )
1221 .await;
1222 }
1223
1224 /// Runs the simulation scheduler alongside the given future, until the future completes.
1225 ///
1226 /// The future always gets to run first; whenever it is blocked (e.g. waiting to receive
1227 /// simulation outputs), the scheduler runs a single step to completion. Steps are atomic
1228 /// with respect to the future: it is re-polled between every pair of scheduler steps, but
1229 /// never while a step is in flight. The [`LaunchedSim`] state struct lives across steps,
1230 /// in this function's frame.
1231 async fn run_with_scheduler(self, thunk: impl Future<Output = ()>) {
1232 self.run_with_scheduler_and_maybe_logger::<std::io::Empty>(None, thunk)
1233 .await;
1234 }
1235
1236 /// Runs the simulation scheduler alongside the given future, until the future completes,
1237 /// reporting the simulation trace to the given logger.
1238 ///
1239 /// The future always gets to run first; whenever it is blocked (e.g. waiting to receive
1240 /// simulation outputs), the scheduler runs a single step to completion. Steps are atomic
1241 /// with respect to the future: it is re-polled between every pair of scheduler steps, but
1242 /// never while a step is in flight.
1243 pub async fn run_with_scheduler_and_logger<W: std::io::Write>(
1244 self,
1245 log_writer: W,
1246 thunk: impl Future<Output = ()>,
1247 ) {
1248 self.run_with_scheduler_and_maybe_logger(Some(log_writer), thunk)
1249 .await;
1250 }
1251
1252 async fn run_with_scheduler_and_maybe_logger<W: std::io::Write>(
1253 self,
1254 log_override: Option<W>,
1255 thunk: impl Future<Output = ()>,
1256 ) {
1257 let mut sim = self.start(log_override);
1258 let mut thunk_fut = pin!(thunk);
1259 let mut thunk_complete = false;
1260 loop {
1261 // The thunk always gets to run first until it completes. Completion is itself a
1262 // script barrier: after the body returns, keep stepping until every decision it
1263 // installed has been consumed (or report a decision that can never be honored).
1264 if !thunk_complete && futures::poll!(thunk_fut.as_mut()).is_ready() {
1265 thunk_complete = true;
1266 }
1267
1268 if thunk_complete {
1269 let Some(stuck) = script_unconsumed_description() else {
1270 break;
1271 };
1272 if sim.quiescence.is_quiescent() {
1273 panic!("{}", script_stuck_error(&stuck));
1274 }
1275 sim.step().await;
1276 continue;
1277 }
1278
1279 if sim.quiescence.is_quiescent() || sim.quiescence.nondet_pending.get() {
1280 // The scheduler is parked: either no step can make progress until the thunk
1281 // sends new input (quiescent), or nondeterministic work is ready but a
1282 // settling test-side observation has paused the scheduler (nondet_pending).
1283 // Park until either the thunk is woken independently or the scheduler is
1284 // resumed. (`resumed()` is permit-based, so a resume that fired while polling
1285 // the thunk above is not lost.)
1286 tokio::select! {
1287 biased;
1288 () = &mut thunk_fut => break,
1289 () = sim.quiescence.resumed() => {}
1290 }
1291 sim.quiescence.nondet_pending.set(false);
1292 } else {
1293 // Run a single scheduler step to completion. This is awaited directly (not
1294 // raced against the thunk), so a step is atomic: the thunk is never polled
1295 // while a step is in flight, and a step is never cancelled mid-execution.
1296 sim.step().await;
1297 }
1298 }
1299 }
1300
1301 /// Consumes this instance and constructs the [`LaunchedSim`] state struct, which is
1302 /// advanced incrementally via [`LaunchedSim::step`].
1303 fn start<W: std::io::Write>(mut self, log_override: Option<W>) -> LaunchedSim<W> {
1304 let (
1305 async_dfirs,
1306 tick_dfirs,
1307 mut hooks,
1308 mut observation_hooks,
1309 mut inline_hooks,
1310 mut scripted_hooks,
1311 mut scripted_observation_hooks,
1312 mut scripted_inline_hooks,
1313 _registry,
1314 ) = self.dylib_result.take().unwrap();
1315
1316 // The generated code keys hooks and tick DFIRs by the same locations, so we can
1317 // move each tick's / observation's hooks out of the maps and attach them
1318 // directly. This lets the scheduler's hot paths avoid keyed lookups entirely.
1319 let not_ready_ticks = tick_dfirs
1320 .into_iter()
1321 .map(|(location, cluster_id, dfir)| {
1322 let key = SimLocation {
1323 location,
1324 cluster_id,
1325 };
1326 let LocationId::Tick {
1327 tick: _,
1328 parent_location,
1329 } = &key.location
1330 else {
1331 unreachable!("tick DFIRs are always keyed by a tick location")
1332 };
1333 let parent_location = (**parent_location).clone();
1334 let tick = SimTick {
1335 parent_location,
1336 cluster_id,
1337 dfir,
1338 hooks: hooks.remove(&key).unwrap_or_default(),
1339 scripted_hooks: scripted_hooks.remove(&key).unwrap_or_default(),
1340 inline_hooks: inline_hooks.remove(&key).unwrap_or_default(),
1341 scripted_inline_hooks: scripted_inline_hooks.remove(&key).unwrap_or_default(),
1342 location: key.location,
1343 };
1344 abort_assert!(
1345 !(tick.hooks.is_empty() && tick.scripted_hooks.is_empty()),
1346 "every tick DFIR must have at least one hook"
1347 );
1348 tick
1349 })
1350 .collect();
1351
1352 let (quiescence, script_coordinator) = CURRENT_SIM_CONNECTIONS.with(|connections| {
1353 let connections = connections.borrow();
1354 (
1355 connections.quiescence.clone(),
1356 connections.script_coordinator.clone(),
1357 )
1358 });
1359
1360 let not_ready_observations = async_dfirs
1361 .iter()
1362 .flat_map(|(location, cluster_id, _)| {
1363 let key = SimLocation {
1364 location: location.clone(),
1365 cluster_id: *cluster_id,
1366 };
1367 let cluster_id = *cluster_id;
1368 let unscripted = observation_hooks
1369 .remove(&key)
1370 .unwrap_or_default()
1371 .into_iter()
1372 .map(|hook| ObservationSlot::Unscripted { hook });
1373 let scripted = scripted_observation_hooks
1374 .remove(&key)
1375 .unwrap_or_default()
1376 .into_iter()
1377 .map(|hook| {
1378 let ScriptTarget::Observation { hook_id, .. } = hook.borrow().target()
1379 else {
1380 unreachable!("observation-registered scripted hook had a tick target")
1381 };
1382 ObservationSlot::Scripted { hook_id, hook }
1383 });
1384 unscripted.chain(scripted).map(move |hook| SimObservation {
1385 location: key.location.clone(),
1386 cluster_id,
1387 hook,
1388 })
1389 })
1390 .collect();
1391
1392 debug_assert!(
1393 hooks.is_empty()
1394 && observation_hooks.is_empty()
1395 && inline_hooks.is_empty()
1396 && scripted_hooks.is_empty()
1397 && scripted_observation_hooks.is_empty()
1398 && scripted_inline_hooks.is_empty(),
1399 "all hooks should belong to either a tick DFIR or a top-level location"
1400 );
1401
1402 LaunchedSim {
1403 async_dfirs,
1404 possibly_ready_ticks: vec![],
1405 not_ready_ticks,
1406 current_scripted_tick: None,
1407 current_scripted_observation: None,
1408 script_coordinator,
1409 possibly_ready_observations: vec![],
1410 not_ready_observations,
1411 log: if self.log {
1412 if let Some(w) = log_override {
1413 LogKind::Custom(w)
1414 } else {
1415 LogKind::Stderr
1416 }
1417 } else {
1418 LogKind::Null
1419 },
1420 quiescence,
1421 deterministic: self.deterministic,
1422 }
1423 }
1424}
1425
1426impl<T, O: Ordering, R: Retries> Clone for SimReceiver<T, O, R> {
1427 fn clone(&self) -> Self {
1428 *self
1429 }
1430}
1431
1432impl<T, O: Ordering, R: Retries> Copy for SimReceiver<T, O, R> {}
1433
1434/// How a [`QuiescenceCheckFuture`] resolves the "did the stream end?" check of
1435/// `assert_no_more`. Decided once the simulation has settled (run out of deterministic
1436/// work).
1437#[derive(Clone, Copy)]
1438enum QuiescenceBranch {
1439 /// Skip the check and continue the test. Only taken in exhaustive mode, where a
1440 /// sibling instance performs the check instead.
1441 Continue,
1442 /// Perform the check, then end this simulation instance (exhaustive mode), letting
1443 /// sibling instances continue past this point without forcing quiescence.
1444 CheckThenEnd,
1445 /// Perform the check and keep running. Taken when the simulation is already quiescent
1446 /// (the check is free) and in non-exhaustive modes.
1447 CheckAndKeepRunning,
1448}
1449
1450/// Decides how to run the quiescence check when the simulation has pending nondeterministic
1451/// work (ticks / observations) that the check would force to run.
1452fn decide_quiescence_branch() -> QuiescenceBranch {
1453 let (exhaustive, log) = CURRENT_SIM_CONNECTIONS.with(|connections| {
1454 let connections = connections.borrow();
1455 (connections.exhaustive, connections.log)
1456 });
1457
1458 if !exhaustive {
1459 return QuiescenceBranch::CheckAndKeepRunning;
1460 }
1461
1462 // In exhaustive mode, fork the search on a bolero decision. The exhaustive driver
1463 // enumerates `false` first, so the instance that performs the quiescence check is
1464 // explored *before* any instance that continues past this assertion. This ensures that
1465 // if the stream has extra output, the failure is attributed to this assertion (with a
1466 // decision trace leading exactly to the check) rather than leaking the extra messages
1467 // into a later assertion.
1468 let continue_without_check: bool = bolero::any();
1469 if continue_without_check {
1470 if log {
1471 eprintln!(
1472 "\n{}",
1473 "Continuing past quiescence assertion without checking (checked by an earlier instance)"
1474 .color(colored::Color::Cyan)
1475 .bold()
1476 );
1477 }
1478 QuiescenceBranch::Continue
1479 } else {
1480 if log {
1481 eprintln!(
1482 "\n{}",
1483 "Checking that no more messages arrive (this instance will end after the check)"
1484 .color(colored::Color::Cyan)
1485 .bold()
1486 );
1487 }
1488 QuiescenceBranch::CheckThenEnd
1489 }
1490}
1491
1492/// Ends the current simulation instance after a passing quiescence check, by panicking with
1493/// [`bolero::generator::bolero_generator::any::Error`], which bolero's engines treat as an
1494/// invalid input rather than a test failure. The instance has verified everything up to and
1495/// including the quiescence check; sibling instances continue past the check instead.
1496fn end_instance_after_quiescence_check() -> ! {
1497 bolero::generator::bolero_generator::any::assume(
1498 false,
1499 "simulation instance ended after quiescence check",
1500 );
1501 unreachable!()
1502}
1503
1504pin_project_lite::pin_project! {
1505 // The "and then the stream ends" half of `assert_no_more` (and thus of
1506 // `assert_yields_only*` / `collect_n_only`). First lets the simulation *settle* (see
1507 // `poll_settle`): if it settles to quiescence, the check is free and the test simply
1508 // continues. Otherwise, in exhaustive mode the search forks into a checking instance and
1509 // continuing instances (see `SimReceiver::assert_no_more` and
1510 // `decide_quiescence_branch`); in non-exhaustive modes the check runs, forcing the
1511 // pending work (which taints the simulation, via `try_next_bytes`).
1512 //
1513 // See [`FutureTrackingCaller`] for why `poll` is `#[track_caller]`.
1514 struct QuiescenceCheckFuture<F: Future<Output = ()>> {
1515 #[pin]
1516 check: F,
1517 settle: SettlePauseGuard,
1518 branch: Option<QuiescenceBranch>,
1519 }
1520}
1521
1522impl<F: Future<Output = ()>> QuiescenceCheckFuture<F> {
1523 fn new(check: F) -> Self {
1524 QuiescenceCheckFuture {
1525 check,
1526 settle: SettlePauseGuard::new(
1527 CURRENT_SIM_CONNECTIONS.with(|connections| connections.borrow().quiescence.clone()),
1528 ),
1529 branch: None,
1530 }
1531 }
1532}
1533
1534impl<F: Future<Output = ()>> Future for QuiescenceCheckFuture<F> {
1535 type Output = ();
1536
1537 #[track_caller]
1538 fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
1539 let this = self.as_mut().project();
1540
1541 if this.branch.is_none() {
1542 *this.branch = Some(if ready!(this.settle.poll_settle(cx)) {
1543 // Settled to quiescence deterministically, so the check is free.
1544 QuiescenceBranch::CheckAndKeepRunning
1545 } else {
1546 // The check would force nondeterministic work to run.
1547 decide_quiescence_branch()
1548 });
1549 }
1550
1551 match this.branch.unwrap() {
1552 QuiescenceBranch::Continue => Poll::Ready(()),
1553 QuiescenceBranch::CheckAndKeepRunning => this.check.poll(cx),
1554 QuiescenceBranch::CheckThenEnd => {
1555 ready!(this.check.poll(cx));
1556 end_instance_after_quiescence_check()
1557 }
1558 }
1559 }
1560}
1561
1562impl<T, O: Ordering, R: Retries> SimReceiver<T, O, R> {
1563 fn connections(&self) -> (Rc<Mutex<UnsyncReceiver<Bytes>>>, Rc<QuiescenceState>) {
1564 CURRENT_SIM_CONNECTIONS.with(|connections| {
1565 let connections = connections.borrow();
1566 let port = connections.external_registered.get(&self.0).unwrap();
1567 (
1568 connections.output_receivers.get(port).unwrap().clone(),
1569 connections.quiescence.clone(),
1570 )
1571 })
1572 }
1573
1574 /// See [`try_next_bytes`].
1575 async fn try_next_impl(&self) -> Option<T> {
1576 let (receiver, quiescence) = self.connections();
1577 try_next_bytes(&receiver, &quiescence)
1578 .await
1579 .map(|bytes| (self.2)(&bytes))
1580 }
1581
1582 /// Asserts that the stream has ended and no more messages can possibly arrive.
1583 ///
1584 /// If the check cannot be answered without running pending nondeterministic work (such
1585 /// as ticks with buffered inputs):
1586 /// - Under [`CompiledSim::exhaustive`], the search forks: one instance performs the
1587 /// check and ends there, while sibling instances skip the check and continue.
1588 /// - In other modes, the pending work runs; afterwards, sending more input and then
1589 /// attempting to receive output will panic.
1590 pub fn assert_no_more(self) -> impl Future<Output = ()>
1591 where
1592 T: Debug,
1593 {
1594 QuiescenceCheckFuture::new(FutureTrackingCaller {
1595 future: async move {
1596 if let Some(next) = self.try_next_impl().await {
1597 return Err(format!(
1598 "Stream yielded unexpected message: {:?}, expected termination",
1599 next
1600 ));
1601 }
1602 Ok(())
1603 },
1604 })
1605 }
1606}
1607
1608impl<T> SimReceiver<T, TotalOrder, ExactlyOnce> {
1609 /// Receives the next message from the simulation output stream, waiting (and letting the
1610 /// scheduler run any pending simulation work) until one is available. If the simulation
1611 /// becomes quiescent without producing a message, the test fails.
1612 ///
1613 /// This is safe to use in the middle of a test; to observe the *absence* of a message,
1614 /// use [`Self::try_next`] or [`Self::assert_no_more`].
1615 pub fn next(&self) -> impl use<'_, T> + Future<Output = T> {
1616 // Waiting for a message never "overruns" the simulation, even though the scheduler
1617 // may run nondeterministic ticks while we wait: if a message arrives, some pending
1618 // work was necessary to produce it (schedules that run *extra* work are also valid
1619 // executions, explored separately), and if the simulation quiesces instead, the test
1620 // fails right here — so no later observation can be affected by the overrun (the
1621 // taint set by `try_next_impl` is unobservable). See the module docs for the full
1622 // soundness reasoning.
1623 FutureTrackingCaller {
1624 future: async move {
1625 self.try_next_impl().await.ok_or_else(|| {
1626 "Stream ended (simulation quiescent), but another message was expected"
1627 .to_owned()
1628 })
1629 },
1630 }
1631 }
1632
1633 /// Receives the next message from the simulation output stream, or returns `None` if no
1634 /// more messages can possibly arrive.
1635 ///
1636 /// If answering requires forcing pending nondeterministic work to run, then afterwards,
1637 /// sending more input and then attempting to receive output will panic. Prefer
1638 /// [`Self::next`] (or [`Self::assert_no_more`]) when possible.
1639 pub async fn try_next(&self) -> Option<T> {
1640 self.try_next_impl().await
1641 }
1642
1643 /// Receives the next `n` messages from the simulation output stream, waiting (and letting
1644 /// the scheduler run any pending simulation work) until they are available. If the
1645 /// simulation becomes quiescent before `n` messages arrive, the test fails.
1646 ///
1647 /// Like [`Self::next`], this is safe to use in the middle of a test. It does not check
1648 /// that the stream ends afterwards; use [`Self::collect_n_only`] for that.
1649 pub fn collect_n<C: Default + Extend<T>>(
1650 &self,
1651 n: usize,
1652 ) -> impl use<'_, T, C> + Future<Output = C> {
1653 FutureTrackingCaller {
1654 future: async move {
1655 let mut out = C::default();
1656 for i in 0..n {
1657 // Like `next`, waiting for each message is safe mid-test; the taint on a
1658 // forced `None` is unobservable because the test fails below.
1659 if let Some(v) = self.try_next_impl().await {
1660 out.extend([v]);
1661 } else {
1662 return Err(format!(
1663 "Stream ended (simulation quiescent) after {} messages, but {} were expected",
1664 i, n
1665 ));
1666 }
1667 }
1668 Ok(out)
1669 },
1670 }
1671 }
1672
1673 /// Receives the next `n` messages (like [`Self::collect_n`]) and then asserts that the
1674 /// stream ends (like [`Self::assert_no_more`], forking the search in exhaustive mode).
1675 pub async fn collect_n_only<C: Default + Extend<T>>(self, n: usize) -> C
1676 where
1677 T: Debug,
1678 {
1679 let out = self.collect_n(n).await;
1680 self.assert_no_more().await;
1681 out
1682 }
1683
1684 /// Collects all remaining messages from the simulation output stream into a collection,
1685 /// waiting until no more messages can possibly arrive.
1686 ///
1687 /// If this has to force pending nondeterministic work to run, it should be the last
1688 /// observation of the test: afterwards, sending more input and then attempting to
1689 /// receive output will panic. When the number of expected messages is known, prefer
1690 /// [`Self::collect_n`] / [`Self::collect_n_only`].
1691 pub async fn collect<C: Default + Extend<T>>(self) -> C {
1692 let mut out = C::default();
1693 while let Some(v) = self.try_next_impl().await {
1694 out.extend([v]);
1695 }
1696 out
1697 }
1698
1699 /// Asserts that the stream yields exactly the expected sequence of messages, in order.
1700 /// This does not check that the stream ends, use [`Self::assert_yields_only`] for that.
1701 ///
1702 /// Like [`Self::next`], this is safe to use in the middle of a test.
1703 pub fn assert_yields<T2: Debug, I: IntoIterator<Item = T2>>(
1704 &self,
1705 expected: I,
1706 ) -> impl use<'_, T, T2, I> + Future<Output = ()>
1707 where
1708 T: Debug + PartialEq<T2>,
1709 {
1710 FutureTrackingCaller {
1711 future: async {
1712 let mut expected: VecDeque<T2> = expected.into_iter().collect();
1713
1714 while !expected.is_empty() {
1715 // Like `next`, waiting for each expected message is safe mid-test; the
1716 // taint on a forced `None` is unobservable because the test fails below.
1717 if let Some(next) = self.try_next_impl().await {
1718 let next_expected = expected.pop_front().unwrap();
1719 if next != next_expected {
1720 return Err(format!(
1721 "Stream yielded unexpected message: {:?}, expected: {:?}",
1722 next, next_expected
1723 ));
1724 }
1725 } else {
1726 return Err(format!(
1727 "Stream ended early, still expected: {:?}",
1728 expected
1729 ));
1730 }
1731 }
1732
1733 Ok(())
1734 },
1735 }
1736 }
1737
1738 /// Asserts that the stream yields only the expected sequence of messages, in order,
1739 /// and then ends (like [`Self::assert_no_more`], forking the search in exhaustive mode).
1740 pub fn assert_yields_only<T2: Debug, I: IntoIterator<Item = T2>>(
1741 &self,
1742 expected: I,
1743 ) -> impl use<'_, T, T2, I> + Future<Output = ()>
1744 where
1745 T: Debug + PartialEq<T2>,
1746 {
1747 ChainedFuture {
1748 first: self.assert_yields(expected),
1749 second: self.assert_no_more(),
1750 first_done: false,
1751 }
1752 }
1753}
1754
1755pin_project_lite::pin_project! {
1756 // A future that tracks the location of the `.await` call for better panic messages.
1757 //
1758 // `#[track_caller]` is important for us to create assertion methods because it makes
1759 // the panic backtrace show up at that method (instead of inside the call tree within
1760 // that method). This is e.g. what `Option::unwrap` uses. Unfortunately, `#[track_caller]`
1761 // does not work correctly for async methods (or `dyn Future` either), so we have to
1762 // create these concrete future types that (1) have `#[track_caller]` on their `poll()`
1763 // method and (2) have the `panic!` triggered in their `poll()` method (or in a directly
1764 // nested concrete future).
1765 struct FutureTrackingCaller<F> {
1766 #[pin]
1767 future: F,
1768 }
1769}
1770
1771impl<T, F: Future<Output = Result<T, String>>> Future for FutureTrackingCaller<F> {
1772 type Output = T;
1773
1774 #[track_caller]
1775 fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
1776 match ready!(self.as_mut().project().future.poll(cx)) {
1777 Ok(v) => Poll::Ready(v),
1778 Err(e) => panic!("{}", e),
1779 }
1780 }
1781}
1782
1783pin_project_lite::pin_project! {
1784 // A future that first awaits the first future, then the second, propagating caller info.
1785 //
1786 // See [`FutureTrackingCaller`] for context.
1787 struct ChainedFuture<F1: Future<Output = ()>, F2: Future<Output = ()>> {
1788 #[pin]
1789 first: F1,
1790 #[pin]
1791 second: F2,
1792 first_done: bool,
1793 }
1794}
1795
1796impl<F1: Future<Output = ()>, F2: Future<Output = ()>> Future for ChainedFuture<F1, F2> {
1797 type Output = ();
1798
1799 #[track_caller]
1800 fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
1801 if !self.first_done {
1802 ready!(self.as_mut().project().first.poll(cx));
1803 *self.as_mut().project().first_done = true;
1804 }
1805
1806 self.as_mut().project().second.poll(cx)
1807 }
1808}
1809
1810impl<T> SimReceiver<T, NoOrder, ExactlyOnce> {
1811 /// Receives the next `n` messages, sorted, and then asserts that the stream ends (like
1812 /// [`SimReceiver::assert_no_more`], forking the search in exhaustive mode). If the
1813 /// simulation becomes quiescent before `n` messages arrive, the test fails.
1814 ///
1815 /// Unlike [`collect_n`](SimReceiver::collect_n) on ordered streams, there is no variant
1816 /// of this API that skips the end-of-stream check. On an unordered stream, the set of
1817 /// messages that arrives *first* is not well-defined, so observing a strict prefix of
1818 /// the output would be sensitive to arrival orders that the simulator does not explore
1819 /// (delivery into the port is FIFO, with no ordering hook); sorting normalizes the
1820 /// permutation of the received messages, but not the choice of *subset*. The quiescence
1821 /// check makes the observation sound: it proves the `n` messages are *all* the messages
1822 /// the program can produce from the input so far, a set which does not depend on
1823 /// arrival order.
1824 pub async fn collect_n_sorted_only<C: Default + Extend<T> + AsMut<[T]>>(self, n: usize) -> C
1825 where
1826 T: Debug + Ord,
1827 {
1828 let out = FutureTrackingCaller {
1829 future: async move {
1830 let mut out = C::default();
1831 for i in 0..n {
1832 // Like `next`, waiting for each message is safe mid-test; the taint on a
1833 // forced `None` is unobservable because the test fails below.
1834 if let Some(v) = self.try_next_impl().await {
1835 out.extend([v]);
1836 } else {
1837 return Err(format!(
1838 "Stream ended (simulation quiescent) after {} messages, but {} were expected",
1839 i, n
1840 ));
1841 }
1842 }
1843 out.as_mut().sort();
1844 Ok(out)
1845 },
1846 }
1847 .await;
1848 self.assert_no_more().await;
1849 out
1850 }
1851
1852 /// Receives the next message, and then asserts that the stream ends (like
1853 /// [`SimReceiver::assert_no_more`], forking the search in exhaustive mode). If the
1854 /// simulation becomes quiescent without producing a message, the test fails.
1855 ///
1856 /// This is a shortcut for [`Self::collect_n_sorted_only`] with `n = 1`. Unlike
1857 /// [`next`](SimReceiver::next) on ordered streams, there is no variant that skips the
1858 /// end-of-stream check, because on an unordered stream *which* message arrives first is
1859 /// not well-defined; the check proves the message is the *only* one the program can
1860 /// produce from the input so far.
1861 pub async fn next_only(self) -> T
1862 where
1863 T: Debug + Ord,
1864 {
1865 let mut out: Vec<T> = self.collect_n_sorted_only(1).await;
1866 out.remove(0)
1867 }
1868
1869 /// Collects all remaining messages from the simulation output stream into a collection,
1870 /// sorting them. This will wait until no more messages can possibly arrive.
1871 ///
1872 /// If this has to force pending nondeterministic work to run, it should be the last
1873 /// observation of the test; see [`collect`](SimReceiver::collect).
1874 pub async fn collect_sorted<C: Default + Extend<T> + AsMut<[T]>>(self) -> C
1875 where
1876 T: Ord,
1877 {
1878 let mut collected = C::default();
1879 while let Some(v) = self.try_next_impl().await {
1880 collected.extend([v]);
1881 }
1882 collected.as_mut().sort();
1883 collected
1884 }
1885
1886 /// Asserts that the stream yields exactly the expected sequence of messages, in some order.
1887 /// This does not check that the stream ends, use [`Self::assert_yields_only_unordered`] for that.
1888 ///
1889 /// Like [`SimReceiver::next`], this is safe to use in the middle of a test.
1890 pub fn assert_yields_unordered<T2: Debug, I: IntoIterator<Item = T2>>(
1891 &self,
1892 expected: I,
1893 ) -> impl use<'_, T, T2, I> + Future<Output = ()>
1894 where
1895 T: Debug + PartialEq<T2>,
1896 {
1897 FutureTrackingCaller {
1898 future: async {
1899 let mut expected: Vec<T2> = expected.into_iter().collect();
1900
1901 while !expected.is_empty() {
1902 // Like `next`, waiting for each expected message is safe mid-test; the
1903 // taint on a forced `None` is unobservable because the test fails below.
1904 if let Some(next) = self.try_next_impl().await {
1905 let idx = expected.iter().enumerate().find(|(_, e)| &next == *e);
1906 if let Some((i, _)) = idx {
1907 expected.swap_remove(i);
1908 } else {
1909 return Err(format!("Stream yielded unexpected message: {:?}", next));
1910 }
1911 } else {
1912 return Err(format!(
1913 "Stream ended early, still expected: {:?}",
1914 expected
1915 ));
1916 }
1917 }
1918
1919 Ok(())
1920 },
1921 }
1922 }
1923
1924 /// Asserts that the stream yields only the expected sequence of messages, in some order,
1925 /// and then ends (like [`Self::assert_no_more`], forking the search in exhaustive mode).
1926 pub fn assert_yields_only_unordered<T2: Debug, I: IntoIterator<Item = T2>>(
1927 &self,
1928 expected: I,
1929 ) -> impl use<'_, T, T2, I> + Future<Output = ()>
1930 where
1931 T: Debug + PartialEq<T2>,
1932 {
1933 ChainedFuture {
1934 first: self.assert_yields_unordered(expected),
1935 second: self.assert_no_more(),
1936 first_done: false,
1937 }
1938 }
1939}
1940
1941impl<T, O: Ordering, R: Retries> SimSender<T, O, R> {
1942 fn with_sink<Out>(&self, thunk: impl FnOnce(&dyn Fn(T)) -> Out) -> Out {
1943 let (sender, quiescence) = CURRENT_SIM_CONNECTIONS.with(|connections| {
1944 let connections = connections.borrow();
1945 (
1946 connections
1947 .input_senders
1948 .get(connections.external_registered.get(&self.0).unwrap())
1949 .unwrap()
1950 .clone(),
1951 connections.quiescence.clone(),
1952 )
1953 });
1954
1955 let encode = self.2;
1956 thunk(&move |t| {
1957 sender.try_send(encode(&t).into()).unwrap();
1958 quiescence.resume();
1959 })
1960 }
1961}
1962
1963impl<T, O: Ordering> SimSender<T, O, ExactlyOnce> {
1964 /// Sends several messages to the simulation input. The messages will be asynchronously
1965 /// processed as part of the simulation, in non-deterministic order.
1966 pub fn send_many_unordered<I: IntoIterator<Item = T>>(&self, iter: I) {
1967 self.with_sink(|send| {
1968 for t in iter {
1969 send(t);
1970 }
1971 })
1972 }
1973}
1974
1975impl<T> SimSender<T, TotalOrder, ExactlyOnce> {
1976 /// Sends a message to the simulation input. The message will be asynchronously processed
1977 /// as part of the simulation.
1978 pub fn send(&self, t: T) {
1979 self.with_sink(|send| send(t));
1980 }
1981
1982 /// Sends several messages to the simulation input. The messages will be asynchronously
1983 /// processed as part of the simulation.
1984 pub fn send_many<I: IntoIterator<Item = T>>(&self, iter: I) {
1985 self.with_sink(|send| {
1986 for t in iter {
1987 send(t);
1988 }
1989 })
1990 }
1991}
1992
1993impl<T, O: Ordering, R: Retries> Clone for SimClusterReceiver<T, O, R> {
1994 fn clone(&self) -> Self {
1995 *self
1996 }
1997}
1998
1999impl<T, O: Ordering, R: Retries> Copy for SimClusterReceiver<T, O, R> {}
2000
2001impl<T, O: Ordering, R: Retries> SimClusterReceiver<T, O, R> {
2002 fn member_connections(
2003 &self,
2004 member_id: u32,
2005 ) -> (Rc<Mutex<UnsyncReceiver<Bytes>>>, Rc<QuiescenceState>) {
2006 CURRENT_SIM_CONNECTIONS.with(|connections| {
2007 let connections = connections.borrow();
2008 let port = connections.external_registered.get(&self.0).unwrap();
2009 let receivers = connections.cluster_output_receivers.get(port).unwrap();
2010 (
2011 receivers[&member_id].clone(),
2012 connections.quiescence.clone(),
2013 )
2014 })
2015 }
2016
2017 /// See [`try_next_bytes`].
2018 async fn try_next_impl(&self, member_id: u32) -> Option<T> {
2019 let (receiver, quiescence) = self.member_connections(member_id);
2020 try_next_bytes(&receiver, &quiescence)
2021 .await
2022 .map(|bytes| (self.2)(&bytes))
2023 }
2024
2025 /// Asserts that the stream from a specific cluster member has ended and no more messages
2026 /// can possibly arrive.
2027 ///
2028 /// If the check cannot be answered without running pending nondeterministic work (such
2029 /// as ticks with buffered inputs):
2030 /// - Under [`CompiledSim::exhaustive`], the search forks: one instance performs the
2031 /// check and ends there, while sibling instances skip the check and continue.
2032 /// - In other modes, the pending work runs; afterwards, sending more input and then
2033 /// attempting to receive output will panic.
2034 pub fn assert_no_more(self, member_id: u32) -> impl Future<Output = ()>
2035 where
2036 T: Debug,
2037 {
2038 QuiescenceCheckFuture::new(FutureTrackingCaller {
2039 future: async move {
2040 if let Some(next) = self.try_next_impl(member_id).await {
2041 return Err(format!(
2042 "Stream yielded unexpected message: {:?}, expected termination",
2043 next
2044 ));
2045 }
2046 Ok(())
2047 },
2048 })
2049 }
2050}
2051
2052impl<T> SimClusterReceiver<T, TotalOrder, ExactlyOnce> {
2053 /// Receives the next value from a specific cluster member, waiting (and letting the
2054 /// scheduler run any pending simulation work) until one is available. If the simulation
2055 /// becomes quiescent without producing a value, the test fails.
2056 ///
2057 /// This is safe to use in the middle of a test; to observe the *absence* of a value,
2058 /// use [`Self::try_next`].
2059 pub fn next(&self, member_id: u32) -> impl use<'_, T> + Future<Output = T> {
2060 // See `SimReceiver::next` for why waiting for a value never "overruns" the
2061 // simulation.
2062 FutureTrackingCaller {
2063 future: async move {
2064 self.try_next_impl(member_id).await.ok_or_else(|| {
2065 "Stream ended (simulation quiescent), but another message was expected"
2066 .to_owned()
2067 })
2068 },
2069 }
2070 }
2071
2072 /// Receives the next value from a specific cluster member, or returns `None` if no more
2073 /// values can possibly arrive.
2074 ///
2075 /// If answering requires forcing pending nondeterministic work to run, then afterwards,
2076 /// sending more input and then attempting to receive output will panic. Prefer
2077 /// [`Self::next`] when possible.
2078 pub async fn try_next(&self, member_id: u32) -> Option<T> {
2079 self.try_next_impl(member_id).await
2080 }
2081
2082 /// Collects all remaining values from a specific cluster member into a collection,
2083 /// waiting until no more values can possibly arrive.
2084 ///
2085 /// If this has to force pending nondeterministic work to run, it should be the last
2086 /// observation of the test; see [`SimReceiver::collect`].
2087 pub async fn collect<C: Default + Extend<T>>(self, member_id: u32) -> C {
2088 let mut out = C::default();
2089 while let Some(v) = self.try_next_impl(member_id).await {
2090 out.extend([v]);
2091 }
2092 out
2093 }
2094}
2095
2096impl<T> SimClusterReceiver<T, NoOrder, ExactlyOnce> {
2097 /// Receives the next `n` values from a specific cluster member, sorted, and then
2098 /// asserts that the stream ends (like [`Self::assert_no_more`], forking the search in
2099 /// exhaustive mode). If the simulation becomes quiescent before `n` values arrive, the
2100 /// test fails.
2101 ///
2102 /// There is no variant of this API that skips the end-of-stream check; see
2103 /// [`SimReceiver::collect_n_sorted_only`] for why observing a strict prefix of an
2104 /// unordered stream would be unsound.
2105 pub async fn collect_n_sorted_only<C: Default + Extend<T> + AsMut<[T]>>(
2106 self,
2107 member_id: u32,
2108 n: usize,
2109 ) -> C
2110 where
2111 T: Debug + Ord,
2112 {
2113 let out = FutureTrackingCaller {
2114 future: async move {
2115 let mut out = C::default();
2116 for i in 0..n {
2117 // Like `SimReceiver::next`, waiting for each message is safe mid-test;
2118 // the taint on a forced `None` is unobservable because the test fails
2119 // below.
2120 if let Some(v) = self.try_next_impl(member_id).await {
2121 out.extend([v]);
2122 } else {
2123 return Err(format!(
2124 "Stream ended (simulation quiescent) after {} messages, but {} were expected",
2125 i, n
2126 ));
2127 }
2128 }
2129 out.as_mut().sort();
2130 Ok(out)
2131 },
2132 }
2133 .await;
2134 self.assert_no_more(member_id).await;
2135 out
2136 }
2137
2138 /// Receives the next value from a specific cluster member, and then asserts that the
2139 /// stream ends (like [`Self::assert_no_more`], forking the search in exhaustive mode).
2140 /// If the simulation becomes quiescent without producing a value, the test fails.
2141 ///
2142 /// This is a shortcut for [`Self::collect_n_sorted_only`] with `n = 1`; see
2143 /// [`SimReceiver::next_only`] for why there is no variant that skips the end-of-stream
2144 /// check.
2145 pub async fn next_only(self, member_id: u32) -> T
2146 where
2147 T: Debug + Ord,
2148 {
2149 let mut out: Vec<T> = self.collect_n_sorted_only(member_id, 1).await;
2150 out.remove(0)
2151 }
2152
2153 /// Collects all remaining values from a specific cluster member, sorted, waiting until no
2154 /// more values can possibly arrive.
2155 ///
2156 /// If this has to force pending nondeterministic work to run, it should be the last
2157 /// observation of the test; see [`SimReceiver::collect`].
2158 pub async fn collect_sorted<C: Default + Extend<T> + AsMut<[T]>>(self, member_id: u32) -> C
2159 where
2160 T: Ord,
2161 {
2162 let mut collected = C::default();
2163 while let Some(v) = self.try_next_impl(member_id).await {
2164 collected.extend([v]);
2165 }
2166 collected.as_mut().sort();
2167 collected
2168 }
2169}
2170
2171impl<T, O: Ordering, R: Retries> SimClusterSender<T, O, R> {
2172 fn with_sink<Out>(&self, thunk: impl FnOnce(&dyn Fn(u32, T)) -> Out) -> Out {
2173 let (senders, quiescence) = CURRENT_SIM_CONNECTIONS.with(|connections| {
2174 let connections = connections.borrow();
2175 (
2176 connections
2177 .cluster_input_senders
2178 .get(connections.external_registered.get(&self.0).unwrap())
2179 .unwrap()
2180 .clone(),
2181 connections.quiescence.clone(),
2182 )
2183 });
2184
2185 let encode = self.2;
2186 thunk(&move |member_id: u32, t: T| {
2187 senders[&member_id].try_send(encode(&t).into()).unwrap();
2188 quiescence.resume();
2189 })
2190 }
2191}
2192
2193impl<T, O: Ordering> SimClusterSender<T, O, ExactlyOnce> {
2194 /// Sends multiple values to specific cluster members. The messages will be asynchronously
2195 /// processed as part of the simulation, in non-deterministic order.
2196 pub fn send_many_unordered<I: IntoIterator<Item = (u32, T)>>(&self, iter: I) {
2197 self.with_sink(|send| {
2198 for (member_id, t) in iter {
2199 send(member_id, t);
2200 }
2201 })
2202 }
2203}
2204
2205impl<T> SimClusterSender<T, TotalOrder, ExactlyOnce> {
2206 /// Sends a value to a specific cluster member.
2207 pub fn send(&self, member_id: u32, t: T) {
2208 self.with_sink(|send| send(member_id, t));
2209 }
2210
2211 /// Sends multiple values to specific cluster members.
2212 pub fn send_many<I: IntoIterator<Item = (u32, T)>>(&self, iter: I) {
2213 self.with_sink(|send| {
2214 for (member_id, t) in iter {
2215 send(member_id, t);
2216 }
2217 })
2218 }
2219}
2220
2221enum LogKind<W: std::io::Write> {
2222 Null,
2223 Stderr,
2224 Custom(W),
2225}
2226
2227// via https://www.reddit.com/r/rust/comments/t69sld/is_there_a_way_to_allow_either_stdfmtwrite_or/
2228impl<W: std::io::Write> std::fmt::Write for LogKind<W> {
2229 fn write_str(&mut self, s: &str) -> Result<(), std::fmt::Error> {
2230 match self {
2231 LogKind::Null => Ok(()),
2232 LogKind::Stderr => {
2233 eprint!("{}", s);
2234 Ok(())
2235 }
2236 LogKind::Custom(w) => w.write_all(s.as_bytes()).map_err(|_| std::fmt::Error),
2237 }
2238 }
2239}
2240
2241/// A tick-scoped DFIR together with the hooks that feed it data.
2242struct SimTick {
2243 /// The tick's location, used to match this tick to an outstanding script group.
2244 location: LocationId,
2245 /// The location of the process/cluster the tick lives on, used to match this tick
2246 /// against the async DFIR that produces its input data.
2247 parent_location: LocationId,
2248 /// The cluster member ID, if the tick lives on a cluster.
2249 cluster_id: Option<u32>,
2250 /// The tick DFIR, executed once per tick.
2251 dfir: DfirErased,
2252 /// Hooks (e.g. from `batch`) resolved *before* the tick runs, deciding what data to
2253 /// release into it.
2254 hooks: Vec<Box<dyn TickInputHook>>,
2255 /// Scripted hooks (bound to test-side handles), also resolved before the tick runs.
2256 /// Kept separate from `hooks` so the scheduler can apply the script-specific rules
2257 /// (the boundary scan and `blocks_tick`), and shared (`Rc`) with the per-instance
2258 /// registry that test-side handles resolve through (see [`ScriptedRuntimeHook`]).
2259 scripted_hooks: Vec<Rc<RefCell<dyn ScriptedTickInputHook>>>,
2260 /// Hooks (e.g. from `assume_ordering` inside the tick) resolved *while* the tick DFIR
2261 /// is running, via a `tokio::select!` loop, for operators that block on ordering
2262 /// decisions mid-tick.
2263 inline_hooks: Vec<Box<dyn InlineHook>>,
2264 scripted_inline_hooks: Vec<Rc<RefCell<dyn crate::sim::runtime::ScriptedInlineHook>>>,
2265}
2266
2267impl SimTick {
2268 /// Whether the scheduler can execute this tick right now.
2269 fn can_run(&self) -> bool {
2270 // No scripted hook may have a queued decision that is not yet honorable
2271 // (such a decision names this tick's *next* execution, so the tick must wait
2272 // until it can be honored in full)...
2273 !self
2274 .scripted_hooks
2275 .iter()
2276 .any(|hook| hook.borrow().blocks_tick())
2277 // ...and at least one hook must be able to trigger the tick.
2278 && (self.hooks.iter().any(|hook| hook.can_trigger_tick())
2279 || self
2280 .scripted_hooks
2281 .iter()
2282 .any(|hook| hook.borrow().can_trigger_tick()))
2283 }
2284}
2285
2286/// A single top-level hook (e.g. from `assume_ordering` on a non-tick stream) that needs
2287/// scheduling decisions, but has no tick DFIR to execute. The scheduler just resolves the
2288/// hook.
2289///
2290/// Each top-level hook is its own observation ("its own virtual tick"), even when several
2291/// hooks live at the same location: unlike a tick's hooks, which one atomic tick
2292/// execution consumes together, co-located top-level hooks are causally independent
2293/// operators, so resolving them jointly would only couple their decisions. Grouping them
2294/// would both add redundant schedules (releasing jointly is equivalent to releasing in
2295/// consecutive steps, which is explored anyway) and *lose* schedules for hook kinds whose
2296/// decisions always release when resolved (a fold could never stay silent while a
2297/// co-located sibling acts). With one hook per observation, "act" and "stay silent" are
2298/// expressed purely by the scheduler picking or not picking the observation, and a picked
2299/// observation always makes a nontrivial decision.
2300struct SimObservation {
2301 /// The top-level location, used to match this observation against the async DFIR that
2302 /// produces its input data (and, for a scripted hook, against an outstanding script
2303 /// group).
2304 location: LocationId,
2305 /// The cluster member ID, if the location is a cluster.
2306 cluster_id: Option<u32>,
2307 /// The hook resolved when the scheduler selects this observation.
2308 hook: ObservationSlot,
2309}
2310
2311/// The single hook of a [`SimObservation`]: either an ordinary autonomous hook, or a
2312/// scripted hook (bound to a test-side handle), tagged with its hook ID so a script group
2313/// can be matched to exactly this observation.
2314enum ObservationSlot {
2315 /// An ordinary autonomous hook, owned by the scheduler.
2316 Unscripted { hook: Box<dyn ObservationHook> },
2317 /// A hook bound to a test-side handle, shared (`Rc`) with the per-instance registry.
2318 Scripted {
2319 /// The bound handle's ID, used to match a script group to this observation.
2320 hook_id: usize,
2321 hook: Rc<RefCell<dyn ScriptedObservationHook>>,
2322 },
2323}
2324
2325impl SimObservation {
2326 /// Whether the scheduler can resolve this observation's hook right now.
2327 fn can_run(&self) -> bool {
2328 match &self.hook {
2329 // Running an observation *is* releasing, so any pending input makes an
2330 // unscripted observation runnable.
2331 ObservationSlot::Unscripted { hook } => hook.has_pending_input(),
2332 ObservationSlot::Scripted { hook, .. } => hook.borrow().can_fire(),
2333 }
2334 }
2335}
2336
2337/// A running simulation, which manages the async DFIRs, tick DFIRs, and hook-based
2338/// scheduling decisions for non-deterministic operators like `batch` and `assume_ordering`.
2339///
2340/// This struct holds all simulator state across scheduler steps. Each [`Self::step`] performs
2341/// one of three kinds of work:
2342/// - **Async DFIRs**: long-running top-level dataflows (one per process/cluster member) that
2343/// produce data consumed by ticks and observations.
2344/// - **Ticks**: tick-scoped DFIRs that execute a single tick. Before running, their associated
2345/// hooks (e.g. from `batch`) are resolved to decide what data to release into the tick.
2346/// - **Observations**: top-level locations that have hooks (e.g. from `assume_ordering` on a
2347/// non-tick stream) needing decisions, but no tick DFIR to execute. The scheduler just
2348/// resolves their hooks.
2349struct LaunchedSim<W: std::io::Write> {
2350 /// Top-level async DFIRs, one per process/cluster member. These run continuously and
2351 /// produce data that feeds into ticks and observations.
2352 async_dfirs: Vec<(LocationId, Option<u32>, DfirErased)>,
2353 /// Ticks whose parent async DFIR has made progress, so they may be ready to run.
2354 /// The scheduler further filters these by checking whether their hooks have pending decisions.
2355 possibly_ready_ticks: Vec<SimTick>,
2356 /// Ticks whose parent async DFIR has not yet made progress since they were last checked.
2357 not_ready_ticks: Vec<SimTick>,
2358 /// The tick owned by the one sealed, outstanding scripted decision group. It is kept
2359 /// outside the ordinary ready lists until it executes and consumes that group.
2360 current_scripted_tick: Option<SimTick>,
2361 current_scripted_observation: Option<SimObservation>,
2362 /// Coordinates the decision group shared with test-side hook handles.
2363 script_coordinator: Rc<RefCell<ScriptCoordinator>>,
2364 /// Observations whose async DFIR has made progress, so their hooks may have decisions
2365 /// to resolve.
2366 possibly_ready_observations: Vec<SimObservation>,
2367 /// Observations whose async DFIR has not yet made progress since they were last checked.
2368 not_ready_observations: Vec<SimObservation>,
2369 log: LogKind<W>,
2370 /// Represents quiescence state of the simulation.
2371 quiescence: Rc<QuiescenceState>,
2372 /// When true, this simulation runs in deterministic mode: no fuzzer entropy is ever
2373 /// drawn, every unsafe operator with meaningful input must be scripted, and at most
2374 /// one tick is ever runnable (see `SimFlow::deterministic`).
2375 deterministic: bool,
2376}
2377
2378impl<W: std::io::Write> LaunchedSim<W> {
2379 /// Runs a single step of the simulation scheduler.
2380 ///
2381 /// A step first advances all async DFIRs; if none of them made progress, it instead runs
2382 /// one ready tick or resolves one ready observation. If nothing at all can make progress,
2383 /// the simulation is quiescent: this signals waiting receivers and returns; the driver is
2384 /// responsible for parking until new external input arrives (see
2385 /// [`QuiescenceState::resumed`]).
2386 ///
2387 /// This future is always awaited to completion by the driver, so a step is atomic: user
2388 /// code never runs (and never observes intermediate state) while a step is in flight.
2389 async fn step(&mut self) {
2390 // A group remains joinable only while the test body is in the same synchronous poll
2391 // that created it. Starting any scheduler step seals it and moves its tick out of
2392 // the ordinary lists exactly once; `Some(current)` then means that tick exclusively
2393 // owns the one outstanding group until it executes.
2394 let outstanding_target = {
2395 let mut coordinator = self.script_coordinator.borrow_mut();
2396 coordinator.current.as_mut().map(|group| {
2397 group.sealed = true;
2398 group.target.clone()
2399 })
2400 };
2401 match outstanding_target {
2402 Some(ScriptTarget::Tick {
2403 location:
2404 SimLocation {
2405 location: group_location,
2406 cluster_id: group_cluster_id,
2407 },
2408 }) => {
2409 abort_assert!(
2410 self.current_scripted_observation.is_none(),
2411 "scripted observation remained active for a tick group"
2412 );
2413 if self.current_scripted_tick.is_none() {
2414 let matches_group = |tick: &SimTick| {
2415 tick.location == group_location && tick.cluster_id == group_cluster_id
2416 };
2417 self.current_scripted_tick = self
2418 .possibly_ready_ticks
2419 .iter()
2420 .position(matches_group)
2421 .map(|index| self.possibly_ready_ticks.swap_remove(index))
2422 .or_else(|| {
2423 self.not_ready_ticks
2424 .iter()
2425 .position(matches_group)
2426 .map(|index| self.not_ready_ticks.swap_remove(index))
2427 });
2428 }
2429 let tick = self.current_scripted_tick.as_ref().unwrap();
2430 abort_assert!(
2431 tick.location == group_location && tick.cluster_id == group_cluster_id,
2432 "outstanding scripted group changed before its tick executed"
2433 );
2434 }
2435 Some(ScriptTarget::Observation {
2436 location:
2437 SimLocation {
2438 location: group_location,
2439 cluster_id: group_cluster_id,
2440 },
2441 hook_id,
2442 }) => {
2443 abort_assert!(
2444 self.current_scripted_tick.is_none(),
2445 "scripted tick remained active for an observation group"
2446 );
2447 if self.current_scripted_observation.is_none() {
2448 let matches_group = |observation: &SimObservation| {
2449 observation.location == group_location
2450 && observation.cluster_id == group_cluster_id
2451 && matches!(observation.hook, ObservationSlot::Scripted { hook_id: id, .. } if id == hook_id)
2452 };
2453 self.current_scripted_observation = self
2454 .possibly_ready_observations
2455 .iter()
2456 .position(matches_group)
2457 .map(|index| self.possibly_ready_observations.swap_remove(index))
2458 .or_else(|| {
2459 self.not_ready_observations
2460 .iter()
2461 .position(matches_group)
2462 .map(|index| self.not_ready_observations.swap_remove(index))
2463 });
2464 }
2465 abort_assert!(
2466 self.current_scripted_observation.is_some(),
2467 "outstanding scripted group did not match an observation"
2468 );
2469 }
2470 None => abort_assert!(
2471 self.current_scripted_tick.is_none() && self.current_scripted_observation.is_none(),
2472 "scripted action remained active without an outstanding group"
2473 ),
2474 }
2475
2476 let mut any_made_progress = false;
2477 for (loc, c_id, dfir) in &mut self.async_dfirs {
2478 if dfir.run_tick().await {
2479 any_made_progress = true;
2480
2481 // This async DFIR may have produced new data, so the ticks and observations
2482 // it feeds may now be ready.
2483 self.possibly_ready_ticks
2484 .extend(self.not_ready_ticks.extract_if(.., |tick| {
2485 tick.parent_location == *loc && tick.cluster_id == *c_id
2486 }));
2487 self.possibly_ready_observations.extend(
2488 self.not_ready_observations
2489 .extract_if(.., |obs| obs.location == *loc && obs.cluster_id == *c_id),
2490 );
2491 }
2492 }
2493
2494 if any_made_progress {
2495 return;
2496 }
2497
2498 // The **boundary scan**: the async dataflows have stopped making progress and we
2499 // are about to consider running ticks — the first moment where a missing scripted
2500 // decision could influence what happens next. Check ticks exposed by async progress,
2501 // plus the active scripted tick (which lives outside the ordinary ready lists).
2502 for tick in self
2503 .possibly_ready_ticks
2504 .iter()
2505 .chain(self.current_scripted_tick.iter())
2506 {
2507 for hook in &tick.scripted_hooks {
2508 if let Err(message) = hook.borrow().boundary_check() {
2509 panic!("{}", message);
2510 }
2511 }
2512 }
2513
2514 for observation in self
2515 .possibly_ready_observations
2516 .iter()
2517 .chain(self.current_scripted_observation.iter())
2518 {
2519 if let ObservationSlot::Scripted { hook, .. } = &observation.hook
2520 && let Err(message) = hook.borrow().boundary_check()
2521 {
2522 panic!("{}", message);
2523 }
2524 }
2525
2526 // A fully scripted tick needs at least one decision that can eventually trigger
2527 // it. There is exactly one outstanding group, so only its owned tick can contain
2528 // a newly installed group in which no decision can trigger.
2529 if let Some(tick) = &self.current_scripted_tick
2530 && tick.hooks.is_empty()
2531 {
2532 let has_pending_decision = tick
2533 .scripted_hooks
2534 .iter()
2535 .any(|hook| hook.borrow().has_decision());
2536 let any_pending_decision_can_eventually_trigger =
2537 tick.scripted_hooks.iter().any(|hook| {
2538 let hook = hook.borrow();
2539 // A decision that is not yet honorable may become honorable and
2540 // trigger once more data arrives, so it does not fail this check.
2541 hook.has_decision() && (hook.blocks_tick() || hook.can_trigger_tick())
2542 });
2543
2544 if has_pending_decision && !any_pending_decision_can_eventually_trigger {
2545 let mut details = String::new();
2546 for hook in &tick.scripted_hooks {
2547 let hook = hook.borrow();
2548 if let Some(decision) = hook.describe_decision() {
2549 let loc = ScriptedHookControl::location_meta(&*hook).location;
2550 use std::fmt::Write;
2551 write!(details, "\n {} on the hook at {}", decision, loc).unwrap();
2552 }
2553 }
2554 panic!(
2555 "none of the scripted decisions in this group can trigger their tick, so the tick can never run; at least one decision in the group must trigger it:{}",
2556 details
2557 );
2558 }
2559 }
2560
2561 use bolero::generator::*;
2562
2563 // Send anything that can't make a scheduling decision back to the not-ready lists.
2564 self.not_ready_ticks.extend(
2565 self.possibly_ready_ticks
2566 .extract_if(.., |tick| !tick.can_run()),
2567 );
2568 self.not_ready_observations.extend(
2569 self.possibly_ready_observations
2570 .extract_if(.., |obs| !obs.can_run()),
2571 );
2572
2573 let scripted_tick_runnable = self
2574 .current_scripted_tick
2575 .as_ref()
2576 .is_some_and(SimTick::can_run);
2577 let scripted_observation_runnable = self
2578 .current_scripted_observation
2579 .as_ref()
2580 .is_some_and(SimObservation::can_run);
2581
2582 if self.possibly_ready_ticks.is_empty()
2583 && !scripted_tick_runnable
2584 && !scripted_observation_runnable
2585 && self.possibly_ready_observations.is_empty()
2586 {
2587 // Classify why the outstanding scripted group (if any) is stuck, so the
2588 // suspended test-side await renders the right error: `true` when every
2589 // queued decision is satisfiable but none can trigger the tick — given
2590 // quiescence, no unscripted input on the tick can trigger it either, or the
2591 // tick would be runnable.
2592 self.script_coordinator.borrow_mut().stuck_cannot_trigger =
2593 self.current_scripted_tick.as_ref().is_some_and(|tick| {
2594 let mut queued = tick
2595 .scripted_hooks
2596 .iter()
2597 .filter(|hook| hook.borrow().has_decision())
2598 .peekable();
2599 queued.peek().is_some() && queued.all(|hook| !hook.borrow().blocks_tick())
2600 });
2601
2602 // Signal quiescence, waking receivers waiting for data (their streams end). The
2603 // driver is responsible for parking until new input arrives.
2604 self.quiescence.enter_quiescence();
2605 } else if self.quiescence.pause_nondet.get() > 0 {
2606 // The test is querying whether the simulation can quiesce without
2607 // nondeterministic work (see `SettlePauseGuard::poll_settle`). Report that
2608 // ticks/observations are pending and pause; the driver parks until the test
2609 // decides how to proceed.
2610 self.quiescence.nondet_pending.set(true);
2611 self.quiescence.wake_settled();
2612 } else {
2613 let ordinary_tick_count = self.possibly_ready_ticks.len();
2614 let scripted_tick_index = ordinary_tick_count;
2615 let observation_start = scripted_tick_index + usize::from(scripted_tick_runnable);
2616 let scripted_observation_index =
2617 observation_start + self.possibly_ready_observations.len();
2618 let candidate_count =
2619 scripted_observation_index + usize::from(scripted_observation_runnable);
2620 let next_tick_or_obs = if self.deterministic {
2621 for tick in self.possibly_ready_ticks.iter().chain(
2622 self.current_scripted_tick
2623 .iter()
2624 .filter(|_| scripted_tick_runnable),
2625 ) {
2626 for hook in &tick.hooks {
2627 if !hook.only_one_possible_decision() {
2628 panic!(
2629 "{}",
2630 crate::sim::runtime::render_unhooked_nondet_error(
2631 hook.location_meta()
2632 )
2633 );
2634 }
2635 }
2636 }
2637 for obs in &self.possibly_ready_observations {
2638 if let ObservationSlot::Unscripted { hook } = &obs.hook
2639 && !hook.only_one_possible_decision()
2640 {
2641 panic!(
2642 "{}",
2643 crate::sim::runtime::render_unhooked_nondet_error(hook.location_meta())
2644 );
2645 }
2646 }
2647 if candidate_count > 1 {
2648 // Each action on its own may be free of choices, but the order in
2649 // which they run is not determined, and it can be observable.
2650 panic!(
2651 "deterministic simulation reached a state with more than one runnable tick/observation; the order in which they run is not deterministic\nhelp: script the involved operators so the schedule is explicit, or run under `fuzz` / `exhaustive` instead"
2652 );
2653 }
2654 0
2655 } else {
2656 (0..candidate_count).any()
2657 };
2658
2659 if next_tick_or_obs < observation_start {
2660 let is_scripted_tick = next_tick_or_obs == scripted_tick_index;
2661 let mut tick = if is_scripted_tick {
2662 self.current_scripted_tick.take().unwrap()
2663 } else {
2664 self.possibly_ready_ticks.remove(next_tick_or_obs)
2665 };
2666
2667 match &mut self.log {
2668 LogKind::Null => {}
2669 LogKind::Stderr => {
2670 if let Some(cid) = &tick.cluster_id {
2671 eprintln!(
2672 "\n{}",
2673 format!("Running Tick (Cluster Member {})", cid)
2674 .color(colored::Color::Magenta)
2675 .bold()
2676 )
2677 } else {
2678 eprintln!("\n{}", "Running Tick".color(colored::Color::Magenta).bold())
2679 }
2680 }
2681 LogKind::Custom(writer) => {
2682 writeln!(
2683 writer,
2684 "\n{}",
2685 "Running Tick".color(colored::Color::Magenta).bold()
2686 )
2687 .unwrap();
2688 }
2689 }
2690
2691 let mut asterisk_indenter = |_line_no, write: &mut dyn std::fmt::Write| {
2692 write.write_str(&"*".color(colored::Color::Magenta).bold())?;
2693 write.write_str(" ")
2694 };
2695
2696 let mut tick_decision_writer = (!matches!(self.log, LogKind::Null)).then(|| {
2697 indenter::indented(&mut self.log).with_format(indenter::Format::Custom {
2698 inserter: &mut asterisk_indenter,
2699 })
2700 });
2701
2702 run_hooks(
2703 tick_decision_writer.as_mut(),
2704 &mut tick.hooks,
2705 &tick.scripted_hooks,
2706 );
2707
2708 let run_tick_future = tick.dfir.run_tick();
2709 if !tick.inline_hooks.is_empty() || !tick.scripted_inline_hooks.is_empty() {
2710 let mut run_tick_future_pinned = pin!(run_tick_future);
2711 let deterministic = self.deterministic;
2712
2713 loop {
2714 tokio::select! {
2715 biased;
2716 r = &mut run_tick_future_pinned => {
2717 abort_assert!(r, "runnable tick's DFIR run_tick() returned false");
2718 break;
2719 }
2720 _ = async {} => {
2721 for hook in &tick.scripted_inline_hooks {
2722 if hook.borrow().has_pending_input() {
2723 let run = hook.borrow_mut().run_decision(
2724 tick_decision_writer
2725 .as_mut()
2726 .map(|w| w as &mut dyn std::fmt::Write),
2727 );
2728 // The error is reported here, on the host side of
2729 // the dylib boundary (unwinding across it aborts).
2730 if let Err(message) = run {
2731 panic!("{}", message);
2732 }
2733 }
2734 }
2735 if !tick.inline_hooks.is_empty() {
2736 bolero_generator::any::scope::borrow_with(|driver| {
2737 for hook in tick.inline_hooks.iter_mut() {
2738 if hook.has_pending_input() {
2739 // In deterministic mode there is no fuzzer
2740 // to decide for this operator; it may only
2741 // proceed when exactly one outcome is
2742 // possible.
2743 if deterministic && !hook.only_one_possible_decision() {
2744 panic!(
2745 "{}",
2746 crate::sim::runtime::render_unhooked_nondet_error(
2747 hook.location_meta()
2748 )
2749 );
2750 }
2751 hook.autonomous_decision(driver);
2752 hook.release_decision(
2753 tick_decision_writer
2754 .as_mut()
2755 .map(|w| w as &mut dyn std::fmt::Write),
2756 );
2757 }
2758 }
2759 });
2760 }
2761 }
2762 }
2763 }
2764 } else {
2765 let made_progress = run_tick_future.await;
2766 abort_assert!(
2767 made_progress,
2768 "runnable tick's DFIR run_tick() returned false"
2769 );
2770 }
2771
2772 if is_scripted_tick {
2773 for hook in &tick.scripted_inline_hooks {
2774 abort_assert!(
2775 !hook.borrow().has_decision(),
2776 "tick completed without consuming a scripted inline decision"
2777 );
2778 }
2779 let group = self.script_coordinator.borrow_mut().current.take();
2780 abort_assert!(
2781 group.is_some(),
2782 "scripted tick executed without an outstanding group"
2783 );
2784 }
2785 self.possibly_ready_ticks.push(tick);
2786 } else {
2787 let is_scripted_observation = next_tick_or_obs == scripted_observation_index;
2788 let observation = if is_scripted_observation {
2789 self.current_scripted_observation.as_mut().unwrap()
2790 } else {
2791 &mut self.possibly_ready_observations[next_tick_or_obs - observation_start]
2792 };
2793 let log_writer = (!matches!(self.log, LogKind::Null)).then_some(&mut self.log);
2794 match &mut observation.hook {
2795 ObservationSlot::Unscripted { hook } => {
2796 run_observation_hook(log_writer, &mut **hook);
2797 }
2798 ObservationSlot::Scripted { hook, .. } => {
2799 abort_assert!(
2800 hook.borrow().can_fire(),
2801 "scripted observation ran without a releasing decision"
2802 );
2803 hook.borrow_mut()
2804 .run_decision(log_writer.map(|w| w as &mut dyn std::fmt::Write));
2805 }
2806 }
2807 if is_scripted_observation {
2808 let group = self.script_coordinator.borrow_mut().current.take();
2809 abort_assert!(group.is_some(), "scripted observation ran without a group");
2810 let observation = self.current_scripted_observation.take().unwrap();
2811 self.possibly_ready_observations.push(observation);
2812 }
2813 }
2814 }
2815 }
2816}
2817
2818fn run_hooks<W: std::fmt::Write>(
2819 mut tick_decision_writer: Option<&mut W>,
2820 hooks: &mut [Box<dyn TickInputHook>],
2821 scripted_hooks: &[Rc<RefCell<dyn ScriptedTickInputHook>>],
2822) {
2823 // Scripted hooks own and release their decisions without entropy. Run them completely
2824 // before considering regular hooks; only regular hooks need a Bolero driver.
2825 let mut made_triggering_decision = false;
2826 for hook in scripted_hooks {
2827 let mut hook = hook.borrow_mut();
2828 // Whether a scripted decision triggers is known before running it.
2829 made_triggering_decision |= hook.can_trigger_tick();
2830 hook.run_decision(
2831 tick_decision_writer
2832 .as_deref_mut()
2833 .map(|w| w as &mut dyn std::fmt::Write),
2834 );
2835 }
2836
2837 if !hooks.is_empty() {
2838 let mut decided = vec![false; hooks.len()];
2839 let mut remaining_decision_count = hooks.len();
2840 bolero::generator::bolero_generator::any::scope::borrow_with(|driver| {
2841 // First, resolve every hook that faces no choice (its decision consumes no
2842 // entropy). Doing this before the second pass lets the final undecided hook
2843 // be forced to trigger when no earlier hook made a triggering decision.
2844 for (hook, decided) in hooks.iter_mut().zip(decided.iter_mut()) {
2845 if hook.only_one_possible_decision() {
2846 // The no-choice decision can still trigger the tick (the passthrough
2847 // singleton always releases the latest value), so its result counts.
2848 made_triggering_decision |= hook.autonomous_decision(driver, false);
2849 *decided = true;
2850 remaining_decision_count -= 1;
2851 }
2852 }
2853
2854 for (hook, decided) in hooks.iter_mut().zip(decided.iter()) {
2855 if !decided {
2856 made_triggering_decision |= hook.autonomous_decision(
2857 driver,
2858 !made_triggering_decision && remaining_decision_count == 1,
2859 );
2860 remaining_decision_count -= 1;
2861 }
2862
2863 hook.release_decision(
2864 tick_decision_writer
2865 .as_deref_mut()
2866 .map(|w| w as &mut dyn std::fmt::Write),
2867 );
2868 }
2869 });
2870 }
2871
2872 abort_assert!(
2873 made_triggering_decision,
2874 "runnable tick had no hook make a triggering decision"
2875 );
2876}
2877
2878/// Resolves a single unscripted observation hook. The observation was only scheduled
2879/// because it has pending input (running an observation *is* releasing), so its
2880/// autonomous decision must stage a release — running an observation without releasing
2881/// would be a wasted schedule step the exploration must not contain.
2882fn run_observation_hook<W: std::fmt::Write>(
2883 writer: Option<&mut W>,
2884 hook: &mut dyn ObservationHook,
2885) {
2886 bolero::generator::bolero_generator::any::scope::borrow_with(|driver| {
2887 hook.autonomous_decision(driver);
2888 });
2889 // `release_decision` panics if the autonomous decision staged nothing, so a
2890 // contract violation cannot pass silently.
2891 hook.release_decision(writer.map(|w| w as &mut dyn std::fmt::Write));
2892}