Skip to main content

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