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