Skip to main content

proef_core/
runner.rs

1//! The run orchestrator (TECH-SPEC §1, §12; ADR-0007): scenario-per-OS-thread,
2//! `--jobs`-bounded, cooperative cancellation at batch boundaries, and a
3//! heartbeat watchdog that **abandons** over-budget scenario threads (recording
4//! a `System` failure and detaching — the process reaps them at exit).
5//!
6//! Scenarios *prepare* lazily at dispatch time: `${global:key}` reads happen at
7//! lower time of the scenario (ADR-0005), so lowering + emission run inside the
8//! scenario thread against a snapshot of the shared global store; `saveAs:
9//! global` promotions merge back through the store lock when the scenario ends.
10//!
11//! Clock note: the pipeline stays clock-free (core purity); the orchestrator's
12//! monotonic-clock use is confined to budget enforcement and never enters
13//! events (durations are engine-measured).
14
15use std::collections::{BTreeMap, HashSet};
16use std::sync::mpsc;
17use std::sync::{Arc, Mutex, PoisonError};
18use std::time::{Duration, Instant};
19
20use crate::cancel::CancellationToken;
21use crate::diag::Diag;
22use crate::engine::{ArtifactRef, EngineFactory, HttpDefaults, ScenarioCtx};
23use crate::error::ExitCode;
24use crate::event::{EVENT_SCHEMA_VERSION, Event, EventSink};
25use crate::step::{Status, StepBatch, StepOutcome};
26use crate::world::{GlobalStore, World};
27
28/// Everything a scenario needs at dispatch time. Built by the CLI edge from
29/// owned/`Arc`ed data so scenario threads are `'static` (abandonable).
30pub struct ScenarioSpec {
31    /// Feature file path.
32    pub file: Arc<str>,
33    /// Scenario name (post-expansion).
34    pub name: Arc<str>,
35    /// 1-based header line.
36    pub line: usize,
37    /// Root for file bodies referenced by the scenario's entries (the feature
38    /// file's directory — hurl `context_dir` confinement, TECH-SPEC §13).
39    pub file_root: Option<std::path::PathBuf>,
40    /// Lower + emit against the live World snapshot (pure; runs in-thread).
41    pub prepare: PrepareFn,
42    /// Run this scenario with the pool to itself (ADR-0007 scheduling).
43    ///
44    /// Not "one of these at a time" but "nothing else at all": the motivating
45    /// case is a scenario asserting absolute positions (`items[0]`) against a
46    /// database no concurrent scenario may write to. A per-group concurrency
47    /// limit — the other half of the shape `cargo-nextest` settled on — bounds
48    /// members against each other and leaves the rest of the pool running, which
49    /// is the weaker guarantee and not the one that was missing.
50    pub exclusive: bool,
51}
52
53/// The dispatch-time preparation: World snapshot in, batches + artifact out.
54pub type PrepareFn = Box<dyn FnOnce(&World) -> Result<Prepared, Vec<Diag>> + Send>;
55
56/// A scenario ready to execute.
57pub struct Prepared {
58    /// Engine batches in authored order.
59    pub batches: Vec<StepBatch>,
60    /// The emitted artifact (the hurl engine's executed input, ADR-0010).
61    pub artifact: Option<ArtifactRef>,
62    /// This scenario's secrets as **engine variable name → secret name**. The
63    /// two differ when a fragment binding renamed one (ADR-0018); an inline
64    /// `${secret:X}` maps `X` to itself, so this covers every secret the
65    /// scenario needs, not just the renamed ones.
66    pub secret_bindings: std::collections::BTreeMap<String, String>,
67}
68
69/// Run-level configuration.
70#[derive(Clone)]
71pub struct RunConfig {
72    /// Injected run identifier.
73    pub run_id: Arc<str>,
74    /// Parallel scenario workers.
75    pub jobs: usize,
76    /// Watchdog budget for a batch whose engine cannot estimate one.
77    pub default_batch_budget: Duration,
78    /// Secret name → value (engines inject via their redacting mechanisms).
79    pub secrets: Arc<BTreeMap<String, String>>,
80    /// Batch-level HTTP defaults.
81    pub http: HttpDefaults,
82}
83
84/// Aggregate result of a run.
85#[derive(Debug)]
86pub struct RunSummary {
87    /// Per-scenario outcomes, in completion order.
88    pub outcomes: Vec<ScenarioOutcome>,
89    /// Scenarios that passed (warnings allowed).
90    pub passed: usize,
91    /// Scenarios that failed.
92    pub failed: usize,
93    /// Scenarios skipped (cancellation).
94    pub skipped: usize,
95    /// The run was cancelled (Ctrl-C / token) — some scenarios did not run.
96    pub cancelled: bool,
97}
98
99impl RunSummary {
100    /// The run's exit code: system faults dominate, then user faults, then
101    /// test failures (ADR-0009). A cancelled run is never `Success` — the
102    /// suite did not pass; it was interrupted (folds in as a test failure).
103    pub fn exit_code(&self) -> ExitCode {
104        self.exit_code_excluding(&[])
105    }
106
107    /// [`Self::exit_code`], treating the given `(file, name)` scenarios as
108    /// non-gating (`@quarantine`): their *test-failures* no longer count toward
109    /// the exit code, but a `System`/`User` fault still does — quarantine is for
110    /// flaky tests, not broken input or infra.
111    pub fn exit_code_excluding(&self, non_gating: &[(String, String)]) -> ExitCode {
112        let mut worst = if self.cancelled {
113            ExitCode::TestFailure
114        } else {
115            ExitCode::Success
116        };
117        for outcome in &self.outcomes {
118            let quarantined = non_gating.iter().any(|(file, name)| {
119                file.as_str() == outcome.file.as_ref() && name.as_str() == outcome.name.as_ref()
120            });
121            let code = match (&outcome.fault, outcome.status) {
122                (Some(Fault::System(_)), _) => ExitCode::SystemError,
123                (Some(Fault::User(_)), _) => ExitCode::UserError,
124                (None, Status::Failed) if !quarantined => ExitCode::TestFailure,
125                _ => ExitCode::Success,
126            };
127            worst = pick_worse(worst, code);
128        }
129        worst
130    }
131}
132
133/// Prefer system errors over user errors over test failures.
134fn pick_worse(a: ExitCode, b: ExitCode) -> ExitCode {
135    let rank = |c: ExitCode| match c {
136        ExitCode::SystemError => 3,
137        ExitCode::UserError => 2,
138        ExitCode::TestFailure => 1,
139        ExitCode::Success => 0,
140    };
141    if rank(b) > rank(a) { b } else { a }
142}
143
144/// One scenario's outcome.
145#[derive(Debug)]
146pub struct ScenarioOutcome {
147    /// Feature file path.
148    pub file: Arc<str>,
149    /// Scenario name.
150    pub name: Arc<str>,
151    /// 1-based header line.
152    pub line: usize,
153    /// Aggregate status.
154    pub status: Status,
155    /// Step outcomes, in execution order.
156    pub steps: Vec<StepOutcome>,
157    /// Non-test fault, when one occurred.
158    pub fault: Option<Fault>,
159    /// Slug of the emitted artifact, when one exists (drives the CLI's
160    /// `reproduce:` line — the emitter's naming is never re-derived).
161    pub artifact_slug: Option<Arc<str>>,
162}
163
164/// A non-test fault attributed per ADR-0009.
165#[derive(Debug)]
166pub enum Fault {
167    /// The user's input is at fault (runtime resolution, missing secret, …).
168    User(String),
169    /// The environment or proef is at fault (engine infra, abandoned budget).
170    System(String),
171}
172
173enum Msg {
174    BatchBegin {
175        scenario: usize,
176        deadline: Instant,
177    },
178    Done {
179        scenario: usize,
180        outcome: ScenarioOutcome,
181    },
182}
183
184/// A scenario's run-wide identity: `(file, scenario)`, matching the pairing
185/// `identities`/`ScenarioOutcome` already use.
186type ScenarioId = (Arc<str>, Arc<str>);
187
188/// The gate's mutable state, behind one lock (see [`RecordGate`]).
189struct GateState {
190    /// Scenario identities that have been finalized — a worker's late event
191    /// for one of these is dropped rather than reaching `inner`.
192    closed: HashSet<ScenarioId>,
193    /// Set once, after `RunFinished` is written. Once `true`, nothing reaches
194    /// `inner` again, for any identity.
195    run_closed: bool,
196}
197
198/// Wraps the run's sink so a finalized scenario's late events never reach the
199/// record. An abandoned scenario's thread is detached and observes its
200/// cancellation token only at its next batch boundary (ADR-0007), so it can
201/// still try to emit after the sweep recorded its outcome — and after the run
202/// itself was finalized. The record's tail must be the tail.
203///
204/// One gate, two write paths, one lock: [`Self::scenario_sink`] hands each
205/// worker thread a sink pre-bound to its own scenario identity (so it filters
206/// without needing to inspect every event variant for `scenario`/`file`
207/// fields — not all of them carry both); [`Self::finish_scenario`] is called
208/// directly by the dispatcher thread, from both the normal completion path
209/// and `sweep_expired`, to emit a scenario's terminal event and mark it
210/// closed. Every read *and* write of [`GateState`] — including the emit
211/// itself — happens under the same `Mutex`, so "is this identity still open"
212/// and "write the event" are one atomic step rather than a check racing a
213/// concurrent close. [`Self::close_run`] is called only after `RunFinished`
214/// has already been written, per [`Self::emit_run_level`].
215#[derive(Clone)]
216struct RecordGate {
217    inner: EventSink,
218    state: Arc<Mutex<GateState>>,
219}
220
221impl RecordGate {
222    fn new(inner: EventSink) -> Self {
223        Self {
224            inner,
225            state: Arc::new(Mutex::new(GateState {
226                closed: HashSet::new(),
227                run_closed: false,
228            })),
229        }
230    }
231
232    /// A sink bound to one scenario's identity, handed to its worker thread.
233    /// Every event passed through it is dropped once the run is closed, or
234    /// once this scenario has been finalized via [`Self::finish_scenario`] —
235    /// including when that happened on the *dispatcher* thread (the watchdog
236    /// sweep), racing ahead of this scenario's own thread. The state lock is
237    /// held across the emit itself: a check-then-act (read the flag, drop
238    /// the lock, then emit) would leave a window for a worker to be
239    /// preempted right there and still write after the tail — the emit is
240    /// part of the same critical section as the check, not a step after it.
241    fn scenario_sink(&self, file: Arc<str>, scenario: Arc<str>) -> EventSink {
242        let inner = self.inner.clone();
243        let state = Arc::clone(&self.state);
244        let id: ScenarioId = (file, scenario);
245        EventSink::new(move |event| {
246            let guard = state.lock().unwrap_or_else(PoisonError::into_inner);
247            if guard.run_closed || guard.closed.contains(&id) {
248                return;
249            }
250            inner.emit(event);
251            // `guard` drops here, after the emit — not before it.
252        })
253    }
254
255    /// Emit a scenario's terminal `ScenarioFinished` and mark it closed under
256    /// one lock acquisition, so no [`Self::scenario_sink`] call for this
257    /// identity — nor a concurrent [`Self::close_run`] — can land between the
258    /// two: the terminal event and the closing of its identity are atomic.
259    /// Called from both the dispatcher's normal completion path and
260    /// `sweep_expired` — the two places a scenario is ever finalized.
261    fn finish_scenario(&self, event: &Event, file: &Arc<str>, scenario: &Arc<str>) {
262        let mut guard = self.state.lock().unwrap_or_else(PoisonError::into_inner);
263        if !guard.run_closed {
264            self.inner.emit(event);
265        }
266        guard
267            .closed
268            .insert((Arc::clone(file), Arc::clone(scenario)));
269    }
270
271    /// Emit an event that carries no single-scenario identity (`RunStarted`,
272    /// `RunFinished`) — gated only by the run-level close.
273    fn emit_run_level(&self, event: &Event) {
274        let guard = self.state.lock().unwrap_or_else(PoisonError::into_inner);
275        if !guard.run_closed {
276            self.inner.emit(event);
277        }
278    }
279
280    /// Shut the gate for good. Call only after `RunFinished` has already
281    /// been written via [`Self::emit_run_level`] — the tail must be written
282    /// before the gate closes, never the other way around.
283    fn close_run(&self) {
284        self.state
285            .lock()
286            .unwrap_or_else(PoisonError::into_inner)
287            .run_closed = true;
288    }
289}
290
291/// Execute `specs` and return the summary. Emits the full event stream on
292/// `events` (`RunStarted` … `RunFinished` — ADR-0008).
293// One cohesive listing of the dispatch/watchdog loop; splitting hides the order.
294#[allow(clippy::too_many_lines)]
295pub fn run(
296    specs: Vec<ScenarioSpec>,
297    engines: &Arc<Vec<Box<dyn EngineFactory>>>,
298    store: &Arc<Mutex<GlobalStore>>,
299    config: &RunConfig,
300    events: &EventSink,
301    cancel: &CancellationToken,
302) -> RunSummary {
303    // Every emission in this function goes through the gate, never `events`
304    // directly (one mechanism deciding what reaches the record — see
305    // `RecordGate`), so late writes from an abandoned scenario's detached
306    // thread can never slip past it.
307    let gate = RecordGate::new(events.clone());
308    gate.emit_run_level(&Event::RunStarted {
309        schema: EVENT_SCHEMA_VERSION,
310        run_id: Arc::clone(&config.run_id),
311    });
312
313    let (tx, rx) = mpsc::channel::<Msg>();
314    // Identities survive the specs' move into the queue, so an abandoned
315    // scenario is reported as itself, never as a synthetic placeholder.
316    let identities: Vec<(Arc<str>, Arc<str>, usize)> = specs
317        .iter()
318        .map(|spec| (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line))
319        .collect();
320    let mut queue: std::collections::VecDeque<(usize, ScenarioSpec)> =
321        specs.into_iter().enumerate().collect();
322    let total = queue.len();
323    // Deadline plus the scenario's child cancellation token — retained so
324    // abandonment can cancel the detached thread's work instead of leaving it
325    // appending events forever (record hygiene; engines gain real stop points
326    // as they support cancellation).
327    let mut active: BTreeMap<usize, (Instant, CancellationToken)> = BTreeMap::new();
328    // Whether the scenario currently holding the pool asked for it alone.
329    //
330    // Also cleared when the pool drains, below. That clear is belt-and-braces
331    // rather than load-bearing — every pop reassigns the flag, and the gate's
332    // `!active.is_empty()` conjunct already ignores it while the pool is empty,
333    // so removing it changes no behaviour (checked). It stays because a stale
334    // "someone owns the pool" is a nasty thing to leave lying around for the
335    // next change to the gate, and because reading the flag mid-loop should not
336    // require reconstructing that argument.
337    let mut exclusive_active = false;
338    let mut outcomes: Vec<ScenarioOutcome> = Vec::new();
339    let grace = Duration::from_secs(2);
340
341    while outcomes.len() < total {
342        if active.is_empty() {
343            exclusive_active = false;
344        }
345        // Fill free slots.
346        while active.len() < config.jobs.max(1) {
347            // Peeked, not popped: an exclusive scenario that cannot start yet
348            // must stay at the head. Popping it to inspect it and pushing it
349            // back would reorder the queue, and a scenario that keeps losing its
350            // place is the starvation this ordering exists to prevent.
351            let Some((_, next)) = queue.front() else {
352                break;
353            };
354            // Exclusivity is symmetric, and both directions are needed: an
355            // exclusive scenario waits for the pool to drain, and nothing joins
356            // a pool an exclusive scenario already owns. Enforcing only the
357            // first would let the very next fill iteration start a neighbour
358            // beside it.
359            if (next.exclusive || exclusive_active) && !active.is_empty() {
360                break;
361            }
362            let Some((index, spec)) = queue.pop_front() else {
363                break;
364            };
365            exclusive_active = spec.exclusive;
366            if cancel.is_cancelled() {
367                gate.finish_scenario(
368                    &Event::ScenarioFinished {
369                        scenario: Arc::clone(&spec.name),
370                        file: Arc::clone(&spec.file),
371                        status: Status::Skipped,
372                        timestamp_ms: None,
373                        worker: None,
374                        phase: None,
375                    },
376                    &spec.file,
377                    &spec.name,
378                );
379                outcomes.push(ScenarioOutcome {
380                    file: spec.file,
381                    name: spec.name,
382                    line: spec.line,
383                    status: Status::Skipped,
384                    steps: Vec::new(),
385                    fault: None,
386                    artifact_slug: None,
387                });
388                continue;
389            }
390            let initial_deadline = Instant::now() + config.default_batch_budget + grace;
391            let child = cancel.child_token();
392            active.insert(index, (initial_deadline, child.clone()));
393            let scenario_events =
394                gate.scenario_sink(Arc::clone(&spec.file), Arc::clone(&spec.name));
395            spawn_scenario(
396                index,
397                spec,
398                Arc::clone(engines),
399                Arc::clone(store),
400                config.clone(),
401                scenario_events,
402                child,
403                tx.clone(),
404            );
405        }
406        if active.is_empty() {
407            continue; // only skipped scenarios remained in the queue
408        }
409
410        // Wait for progress, bounded by the earliest active deadline.
411        let now = Instant::now();
412        let next_deadline = active
413            .values()
414            .map(|(deadline, _)| *deadline)
415            .min()
416            .unwrap_or(now + grace);
417        let wait = next_deadline
418            .saturating_duration_since(now)
419            .max(Duration::from_millis(20));
420        match rx.recv_timeout(wait) {
421            Ok(Msg::BatchBegin { scenario, deadline }) => {
422                if let Some(entry) = active.get_mut(&scenario) {
423                    entry.0 = deadline + grace;
424                }
425            }
426            Ok(Msg::Done { scenario, outcome }) => {
427                if active.remove(&scenario).is_some() {
428                    gate.finish_scenario(
429                        &Event::ScenarioFinished {
430                            scenario: Arc::clone(&outcome.name),
431                            file: Arc::clone(&outcome.file),
432                            status: outcome.status,
433                            timestamp_ms: None,
434                            worker: None,
435                            phase: None,
436                        },
437                        &outcome.file,
438                        &outcome.name,
439                    );
440                    outcomes.push(outcome);
441                }
442                // else: a previously-abandoned thread finished late — ignored.
443            }
444            Err(mpsc::RecvTimeoutError::Timeout) => {}
445            Err(mpsc::RecvTimeoutError::Disconnected) => break,
446        }
447        // Sweep expired deadlines on *every* turn of the loop — a steady
448        // stream of messages from healthy scenarios must not keep a hung
449        // one alive past its budget.
450        sweep_expired(&mut active, &mut outcomes, &gate, &identities);
451    }
452
453    let passed = outcomes
454        .iter()
455        .filter(|o| matches!(o.status, Status::Passed | Status::Warned))
456        .count();
457    let failed = outcomes
458        .iter()
459        .filter(|o| o.status == Status::Failed)
460        .count();
461    let skipped = outcomes
462        .iter()
463        .filter(|o| o.status == Status::Skipped)
464        .count();
465    let cancelled = cancel.is_cancelled();
466    gate.emit_run_level(&Event::RunFinished {
467        passed,
468        failed,
469        skipped,
470        cancelled,
471    });
472    // The tail is written — only now does the gate shut. Any scenario thread
473    // still detached out there (an abandoned one, cooperatively cancelled but
474    // not yet reaped, ADR-0007) can no longer reach the sink at all.
475    gate.close_run();
476    RunSummary {
477        outcomes,
478        passed,
479        failed,
480        skipped,
481        cancelled,
482    }
483}
484
485/// Abandon every scenario whose deadline has passed: cancel its child token,
486/// record a `System` fault, and detach the thread (ADR-0007 — the process
487/// reaps it at exit; the cancelled token is the thread's cooperative signal
488/// to stop). Cooperation is not guaranteed by every engine, so `gate` is the
489/// actual backstop: `finish_scenario` closes this scenario's identity right
490/// here, before the detached thread can notice its token and try to keep
491/// appending to the record.
492fn sweep_expired(
493    active: &mut BTreeMap<usize, (Instant, CancellationToken)>,
494    outcomes: &mut Vec<ScenarioOutcome>,
495    gate: &RecordGate,
496    identities: &[(Arc<str>, Arc<str>, usize)],
497) {
498    let now = Instant::now();
499    let expired: Vec<usize> = active
500        .iter()
501        .filter(|(_, (deadline, _))| *deadline <= now)
502        .map(|(index, _)| *index)
503        .collect();
504    for index in expired {
505        if let Some((_, token)) = active.remove(&index) {
506            token.cancel();
507        }
508        // `active` keys are spec indices and `identities` is built 1:1 from
509        // the same specs — the lookup cannot miss.
510        let (file, name, line) = &identities[index];
511        let outcome = ScenarioOutcome {
512            file: Arc::clone(file),
513            name: Arc::clone(name),
514            line: *line,
515            status: Status::Failed,
516            steps: Vec::new(),
517            fault: Some(Fault::System(
518                "batch budget exceeded — scenario thread abandoned (ADR-0007)".to_owned(),
519            )),
520            artifact_slug: None,
521        };
522        gate.finish_scenario(
523            &Event::ScenarioFinished {
524                scenario: Arc::clone(&outcome.name),
525                file: Arc::clone(&outcome.file),
526                status: Status::Failed,
527                timestamp_ms: None,
528                worker: None,
529                phase: None,
530            },
531            &outcome.file,
532            &outcome.name,
533        );
534        outcomes.push(outcome);
535    }
536}
537
538#[allow(clippy::too_many_arguments)]
539fn spawn_scenario(
540    index: usize,
541    spec: ScenarioSpec,
542    engines: Arc<Vec<Box<dyn EngineFactory>>>,
543    store: Arc<Mutex<GlobalStore>>,
544    config: RunConfig,
545    events: EventSink,
546    cancel: CancellationToken,
547    tx: mpsc::Sender<Msg>,
548) {
549    std::thread::spawn(move || {
550        let identity = (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line);
551        let heartbeat_tx = tx.clone();
552        // A panicking engine or prepare closure must never look like a hang:
553        // contain it, report a System fault under the real identity, and let
554        // the dispatcher move on immediately instead of waiting out the budget.
555        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
556            run_scenario(
557                spec,
558                &engines,
559                &store,
560                &config,
561                &events,
562                &cancel,
563                |budget| {
564                    let _ = heartbeat_tx.send(Msg::BatchBegin {
565                        scenario: index,
566                        deadline: Instant::now() + budget,
567                    });
568                },
569            )
570        }));
571        let outcome = result.unwrap_or_else(|panic| {
572            let message = panic
573                .downcast_ref::<&str>()
574                .map(ToString::to_string)
575                .or_else(|| panic.downcast_ref::<String>().cloned())
576                .unwrap_or_else(|| "opaque panic payload".to_owned());
577            ScenarioOutcome {
578                file: identity.0,
579                name: identity.1,
580                line: identity.2,
581                status: Status::Failed,
582                steps: Vec::new(),
583                fault: Some(Fault::System(format!(
584                    "scenario thread panicked: {message}"
585                ))),
586                artifact_slug: None,
587            }
588        });
589        let _ = tx.send(Msg::Done {
590            scenario: index,
591            outcome,
592        });
593    });
594}
595
596// One cohesive listing of the scenario lifecycle; splitting hides the order.
597#[allow(clippy::too_many_lines)]
598fn run_scenario(
599    spec: ScenarioSpec,
600    engines: &[Box<dyn EngineFactory>],
601    store: &Mutex<GlobalStore>,
602    config: &RunConfig,
603    events: &EventSink,
604    cancel: &CancellationToken,
605    heartbeat: impl Fn(Duration),
606) -> ScenarioOutcome {
607    let (file, name, line) = (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line);
608    let outcome = move |status, steps, fault, artifact_slug| ScenarioOutcome {
609        file: Arc::clone(&file),
610        name: Arc::clone(&name),
611        line,
612        status,
613        steps,
614        fault,
615        artifact_slug,
616    };
617
618    events.emit(&Event::ScenarioStarted {
619        scenario: Arc::clone(&spec.name),
620        file: Arc::clone(&spec.file),
621        timestamp_ms: None,
622        worker: None,
623        phase: None,
624    });
625
626    // Prepare against a snapshot of the shared globals (lower-time reads).
627    let snapshot = match store.lock() {
628        Ok(guard) => guard.clone(),
629        Err(_) => {
630            return outcome(
631                Status::Failed,
632                Vec::new(),
633                Some(Fault::System("global store lock poisoned".to_owned())),
634                None,
635            );
636        }
637    };
638    let mut world = World::new(snapshot);
639    let prepared = match (spec.prepare)(&world) {
640        Ok(prepared) => prepared,
641        Err(diags) => {
642            let detail = diags
643                .iter()
644                .map(|d| d.message.clone())
645                .collect::<Vec<_>>()
646                .join("; ");
647            return outcome(Status::Failed, Vec::new(), Some(Fault::User(detail)), None);
648        }
649    };
650
651    let mut sessions: Vec<(String, Box<dyn crate::engine::EngineSession>)> = Vec::new();
652    let mut steps: Vec<StepOutcome> = Vec::new();
653    let mut fault: Option<Fault> = None;
654    let mut failed = false;
655
656    let mut interrupted = false;
657    let mut processed = 0usize;
658    for batch in &prepared.batches {
659        if failed {
660            break;
661        }
662        if cancel.is_cancelled() {
663            // Batches remain — the scenario did not run to completion and must
664            // not report `Passed` (a cancelled run would otherwise exit 0).
665            interrupted = true;
666            break;
667        }
668        let engine_id = batch.engine.as_str().to_owned();
669        if !sessions.iter().any(|(id, _)| *id == engine_id) {
670            let Some(factory) = engines.iter().find(|f| f.id() == engine_id) else {
671                fault = Some(Fault::System(format!(
672                    "no engine registered for `{engine_id}`"
673                )));
674                failed = true;
675                break;
676            };
677            let ctx = ScenarioCtx {
678                run_id: Arc::clone(&config.run_id),
679                scenario: Arc::clone(&spec.name),
680                artifact: prepared.artifact.clone(),
681                secrets: Arc::clone(&config.secrets),
682                secret_bindings: Arc::new(prepared.secret_bindings.clone()),
683                http: config.http,
684                file_root: spec.file_root.clone(),
685            };
686            match factory.open(&ctx) {
687                Ok(session) => sessions.push((engine_id.clone(), session)),
688                Err(err) => {
689                    fault = Some(Fault::System(format!(
690                        "cannot open engine `{engine_id}`: {err}"
691                    )));
692                    failed = true;
693                    break;
694                }
695            }
696        }
697        let Some((_, session)) = sessions.iter_mut().find(|(id, _)| *id == engine_id) else {
698            break; // unreachable: just ensured above
699        };
700
701        let budget = session
702            .batch_budget(batch)
703            .unwrap_or(config.default_batch_budget);
704        heartbeat(budget);
705        events.emit(&Event::BatchStarted {
706            scenario: Arc::clone(&spec.name),
707            engine: Arc::from(engine_id.as_str()),
708            steps: batch.steps.len(),
709        });
710
711        let result = session.run_batch(batch, &mut world, events, cancel);
712        let all_optional = batch.steps.iter().all(|s| s.optional);
713        for mut step_outcome in result.steps {
714            if step_outcome.status == Status::Failed && all_optional {
715                step_outcome.status = Status::Warned;
716            }
717            steps.push(step_outcome);
718        }
719        if let Some(err) = result.error {
720            if all_optional {
721                // `optional:` — warn and continue (segmentation isolates it).
722                // The batch WAS dispatched and its steps already carry real
723                // outcomes: count it, or the unreached-steps loop below would
724                // re-report it as Skipped on top of them (ADR-0008).
725                processed += 1;
726                continue;
727            }
728            match err.class {
729                crate::error::EngineErrorClass::AssertFailed => {}
730                crate::error::EngineErrorClass::UserInput => {
731                    fault = Some(Fault::User(err.message.clone()));
732                }
733                crate::error::EngineErrorClass::Infra | crate::error::EngineErrorClass::Setup => {
734                    fault = Some(Fault::System(err.message.clone()));
735                }
736            }
737            failed = true;
738        }
739        processed += 1;
740    }
741
742    // Every authored step gets an outcome: batches never dispatched (earlier
743    // failure or cancellation) report their steps as Skipped instead of
744    // silently vanishing from console, record, and JUnit alike.
745    let unreached_reason = if interrupted {
746        "not run (run cancelled)"
747    } else {
748        "not run (an earlier step failed)"
749    };
750    for batch in prepared.batches.iter().skip(processed) {
751        for step in &batch.steps {
752            events.emit(&Event::StepFinished {
753                scenario: Arc::clone(&spec.name),
754                engine: Arc::from(batch.engine.as_str()),
755                step: step.step.clone(),
756                status: Status::Skipped,
757                attempts: 0,
758                duration_ms: 0,
759                captures: Vec::new(),
760                // A step that never ran still says where it would have run
761                // from: "not run" is exactly when a reader is reconstructing
762                // what the suite was about to do.
763                fragment: step.fragment.clone(),
764                detail: Some(unreached_reason.to_owned()),
765                attempt_details: Vec::new(),
766            });
767            steps.push(StepOutcome {
768                step: step.step.clone(),
769                status: Status::Skipped,
770                attempts: 0,
771                duration: std::time::Duration::ZERO,
772                detail: Some(unreached_reason.to_owned()),
773                attempt_details: Vec::new(),
774                reproduce_hint: None,
775                fragment: step.fragment.clone(),
776            });
777        }
778    }
779
780    for (engine_id, session) in sessions.iter_mut().rev() {
781        if let Err(err) = session.finish()
782            && fault.is_none()
783        {
784            // Teardown failure on an otherwise-clean scenario is a real infra
785            // signal (never silently swallowed); a scenario that already
786            // failed keeps its primary fault.
787            fault = Some(Fault::System(format!(
788                "engine `{engine_id}` teardown failed: {}",
789                err.message
790            )));
791        }
792    }
793
794    // Merge global promotions back through the store lock (§12) — the write
795    // set only. Writing the whole world back would clobber keys another
796    // scenario promoted after this one took its snapshot (lost update).
797    // Poison recovery is sound here: store inserts are plain map writes with
798    // no cross-key invariant a panicked holder could have torn — dropping the
799    // write set instead would silently lose `saveAs: global` promotions from
800    // a scenario that reports Passed.
801    {
802        let mut guard = store
803            .lock()
804            .unwrap_or_else(std::sync::PoisonError::into_inner);
805        for (key, value) in world.promotions() {
806            guard.insert(key, value.clone());
807        }
808    }
809
810    let status = if failed || steps.iter().any(|s| s.status == Status::Failed) {
811        Status::Failed
812    } else if interrupted {
813        Status::Skipped
814    } else {
815        Status::Passed
816    };
817    let artifact_slug = prepared
818        .artifact
819        .as_ref()
820        .map(|artifact| Arc::clone(&artifact.slug));
821    outcome(status, steps, fault, artifact_slug)
822}