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