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