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    // Cleared the moment the pool drains, so the flag never outlives the run it
330    // describes — it would otherwise stay set after an exclusive scenario
331    // finished and read as "someone still owns the pool".
332    let mut exclusive_active = false;
333    let mut outcomes: Vec<ScenarioOutcome> = Vec::new();
334    let grace = Duration::from_secs(2);
335
336    while outcomes.len() < total {
337        if active.is_empty() {
338            exclusive_active = false;
339        }
340        // Fill free slots.
341        while active.len() < config.jobs.max(1) {
342            // Peeked, not popped: an exclusive scenario that cannot start yet
343            // must stay at the head. Popping it to inspect it and pushing it
344            // back would reorder the queue, and a scenario that keeps losing its
345            // place is the starvation this ordering exists to prevent.
346            let Some((_, next)) = queue.front() else {
347                break;
348            };
349            // Exclusivity is symmetric, and both directions are needed: an
350            // exclusive scenario waits for the pool to drain, and nothing joins
351            // a pool an exclusive scenario already owns. Enforcing only the
352            // first would let the very next fill iteration start a neighbour
353            // beside it.
354            if (next.exclusive || exclusive_active) && !active.is_empty() {
355                break;
356            }
357            let Some((index, spec)) = queue.pop_front() else {
358                break;
359            };
360            exclusive_active = spec.exclusive;
361            if cancel.is_cancelled() {
362                gate.finish_scenario(
363                    &Event::ScenarioFinished {
364                        scenario: Arc::clone(&spec.name),
365                        file: Arc::clone(&spec.file),
366                        status: Status::Skipped,
367                        timestamp_ms: None,
368                        worker: None,
369                        phase: None,
370                    },
371                    &spec.file,
372                    &spec.name,
373                );
374                outcomes.push(ScenarioOutcome {
375                    file: spec.file,
376                    name: spec.name,
377                    line: spec.line,
378                    status: Status::Skipped,
379                    steps: Vec::new(),
380                    fault: None,
381                    artifact_slug: None,
382                });
383                continue;
384            }
385            let initial_deadline = Instant::now() + config.default_batch_budget + grace;
386            let child = cancel.child_token();
387            active.insert(index, (initial_deadline, child.clone()));
388            let scenario_events =
389                gate.scenario_sink(Arc::clone(&spec.file), Arc::clone(&spec.name));
390            spawn_scenario(
391                index,
392                spec,
393                Arc::clone(engines),
394                Arc::clone(store),
395                config.clone(),
396                scenario_events,
397                child,
398                tx.clone(),
399            );
400        }
401        if active.is_empty() {
402            continue; // only skipped scenarios remained in the queue
403        }
404
405        // Wait for progress, bounded by the earliest active deadline.
406        let now = Instant::now();
407        let next_deadline = active
408            .values()
409            .map(|(deadline, _)| *deadline)
410            .min()
411            .unwrap_or(now + grace);
412        let wait = next_deadline
413            .saturating_duration_since(now)
414            .max(Duration::from_millis(20));
415        match rx.recv_timeout(wait) {
416            Ok(Msg::BatchBegin { scenario, deadline }) => {
417                if let Some(entry) = active.get_mut(&scenario) {
418                    entry.0 = deadline + grace;
419                }
420            }
421            Ok(Msg::Done { scenario, outcome }) => {
422                if active.remove(&scenario).is_some() {
423                    gate.finish_scenario(
424                        &Event::ScenarioFinished {
425                            scenario: Arc::clone(&outcome.name),
426                            file: Arc::clone(&outcome.file),
427                            status: outcome.status,
428                            timestamp_ms: None,
429                            worker: None,
430                            phase: None,
431                        },
432                        &outcome.file,
433                        &outcome.name,
434                    );
435                    outcomes.push(outcome);
436                }
437                // else: a previously-abandoned thread finished late — ignored.
438            }
439            Err(mpsc::RecvTimeoutError::Timeout) => {}
440            Err(mpsc::RecvTimeoutError::Disconnected) => break,
441        }
442        // Sweep expired deadlines on *every* turn of the loop — a steady
443        // stream of messages from healthy scenarios must not keep a hung
444        // one alive past its budget.
445        sweep_expired(&mut active, &mut outcomes, &gate, &identities);
446    }
447
448    let passed = outcomes
449        .iter()
450        .filter(|o| matches!(o.status, Status::Passed | Status::Warned))
451        .count();
452    let failed = outcomes
453        .iter()
454        .filter(|o| o.status == Status::Failed)
455        .count();
456    let skipped = outcomes
457        .iter()
458        .filter(|o| o.status == Status::Skipped)
459        .count();
460    let cancelled = cancel.is_cancelled();
461    gate.emit_run_level(&Event::RunFinished {
462        passed,
463        failed,
464        skipped,
465        cancelled,
466    });
467    // The tail is written — only now does the gate shut. Any scenario thread
468    // still detached out there (an abandoned one, cooperatively cancelled but
469    // not yet reaped, ADR-0007) can no longer reach the sink at all.
470    gate.close_run();
471    RunSummary {
472        outcomes,
473        passed,
474        failed,
475        skipped,
476        cancelled,
477    }
478}
479
480/// Abandon every scenario whose deadline has passed: cancel its child token,
481/// record a `System` fault, and detach the thread (ADR-0007 — the process
482/// reaps it at exit; the cancelled token is the thread's cooperative signal
483/// to stop). Cooperation is not guaranteed by every engine, so `gate` is the
484/// actual backstop: `finish_scenario` closes this scenario's identity right
485/// here, before the detached thread can notice its token and try to keep
486/// appending to the record.
487fn sweep_expired(
488    active: &mut BTreeMap<usize, (Instant, CancellationToken)>,
489    outcomes: &mut Vec<ScenarioOutcome>,
490    gate: &RecordGate,
491    identities: &[(Arc<str>, Arc<str>, usize)],
492) {
493    let now = Instant::now();
494    let expired: Vec<usize> = active
495        .iter()
496        .filter(|(_, (deadline, _))| *deadline <= now)
497        .map(|(index, _)| *index)
498        .collect();
499    for index in expired {
500        if let Some((_, token)) = active.remove(&index) {
501            token.cancel();
502        }
503        // `active` keys are spec indices and `identities` is built 1:1 from
504        // the same specs — the lookup cannot miss.
505        let (file, name, line) = &identities[index];
506        let outcome = ScenarioOutcome {
507            file: Arc::clone(file),
508            name: Arc::clone(name),
509            line: *line,
510            status: Status::Failed,
511            steps: Vec::new(),
512            fault: Some(Fault::System(
513                "batch budget exceeded — scenario thread abandoned (ADR-0007)".to_owned(),
514            )),
515            artifact_slug: None,
516        };
517        gate.finish_scenario(
518            &Event::ScenarioFinished {
519                scenario: Arc::clone(&outcome.name),
520                file: Arc::clone(&outcome.file),
521                status: Status::Failed,
522                timestamp_ms: None,
523                worker: None,
524                phase: None,
525            },
526            &outcome.file,
527            &outcome.name,
528        );
529        outcomes.push(outcome);
530    }
531}
532
533#[allow(clippy::too_many_arguments)]
534fn spawn_scenario(
535    index: usize,
536    spec: ScenarioSpec,
537    engines: Arc<Vec<Box<dyn EngineFactory>>>,
538    store: Arc<Mutex<GlobalStore>>,
539    config: RunConfig,
540    events: EventSink,
541    cancel: CancellationToken,
542    tx: mpsc::Sender<Msg>,
543) {
544    std::thread::spawn(move || {
545        let identity = (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line);
546        let heartbeat_tx = tx.clone();
547        // A panicking engine or prepare closure must never look like a hang:
548        // contain it, report a System fault under the real identity, and let
549        // the dispatcher move on immediately instead of waiting out the budget.
550        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
551            run_scenario(
552                spec,
553                &engines,
554                &store,
555                &config,
556                &events,
557                &cancel,
558                |budget| {
559                    let _ = heartbeat_tx.send(Msg::BatchBegin {
560                        scenario: index,
561                        deadline: Instant::now() + budget,
562                    });
563                },
564            )
565        }));
566        let outcome = result.unwrap_or_else(|panic| {
567            let message = panic
568                .downcast_ref::<&str>()
569                .map(ToString::to_string)
570                .or_else(|| panic.downcast_ref::<String>().cloned())
571                .unwrap_or_else(|| "opaque panic payload".to_owned());
572            ScenarioOutcome {
573                file: identity.0,
574                name: identity.1,
575                line: identity.2,
576                status: Status::Failed,
577                steps: Vec::new(),
578                fault: Some(Fault::System(format!(
579                    "scenario thread panicked: {message}"
580                ))),
581                artifact_slug: None,
582            }
583        });
584        let _ = tx.send(Msg::Done {
585            scenario: index,
586            outcome,
587        });
588    });
589}
590
591// One cohesive listing of the scenario lifecycle; splitting hides the order.
592#[allow(clippy::too_many_lines)]
593fn run_scenario(
594    spec: ScenarioSpec,
595    engines: &[Box<dyn EngineFactory>],
596    store: &Mutex<GlobalStore>,
597    config: &RunConfig,
598    events: &EventSink,
599    cancel: &CancellationToken,
600    heartbeat: impl Fn(Duration),
601) -> ScenarioOutcome {
602    let (file, name, line) = (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line);
603    let outcome = move |status, steps, fault, artifact_slug| ScenarioOutcome {
604        file: Arc::clone(&file),
605        name: Arc::clone(&name),
606        line,
607        status,
608        steps,
609        fault,
610        artifact_slug,
611    };
612
613    events.emit(&Event::ScenarioStarted {
614        scenario: Arc::clone(&spec.name),
615        file: Arc::clone(&spec.file),
616        timestamp_ms: None,
617        worker: None,
618        phase: None,
619    });
620
621    // Prepare against a snapshot of the shared globals (lower-time reads).
622    let snapshot = match store.lock() {
623        Ok(guard) => guard.clone(),
624        Err(_) => {
625            return outcome(
626                Status::Failed,
627                Vec::new(),
628                Some(Fault::System("global store lock poisoned".to_owned())),
629                None,
630            );
631        }
632    };
633    let mut world = World::new(snapshot);
634    let prepared = match (spec.prepare)(&world) {
635        Ok(prepared) => prepared,
636        Err(diags) => {
637            let detail = diags
638                .iter()
639                .map(|d| d.message.clone())
640                .collect::<Vec<_>>()
641                .join("; ");
642            return outcome(Status::Failed, Vec::new(), Some(Fault::User(detail)), None);
643        }
644    };
645
646    let mut sessions: Vec<(String, Box<dyn crate::engine::EngineSession>)> = Vec::new();
647    let mut steps: Vec<StepOutcome> = Vec::new();
648    let mut fault: Option<Fault> = None;
649    let mut failed = false;
650
651    let mut interrupted = false;
652    let mut processed = 0usize;
653    for batch in &prepared.batches {
654        if failed {
655            break;
656        }
657        if cancel.is_cancelled() {
658            // Batches remain — the scenario did not run to completion and must
659            // not report `Passed` (a cancelled run would otherwise exit 0).
660            interrupted = true;
661            break;
662        }
663        let engine_id = batch.engine.as_str().to_owned();
664        if !sessions.iter().any(|(id, _)| *id == engine_id) {
665            let Some(factory) = engines.iter().find(|f| f.id() == engine_id) else {
666                fault = Some(Fault::System(format!(
667                    "no engine registered for `{engine_id}`"
668                )));
669                failed = true;
670                break;
671            };
672            let ctx = ScenarioCtx {
673                run_id: Arc::clone(&config.run_id),
674                scenario: Arc::clone(&spec.name),
675                artifact: prepared.artifact.clone(),
676                secrets: Arc::clone(&config.secrets),
677                secret_bindings: Arc::new(prepared.secret_bindings.clone()),
678                http: config.http,
679                file_root: spec.file_root.clone(),
680            };
681            match factory.open(&ctx) {
682                Ok(session) => sessions.push((engine_id.clone(), session)),
683                Err(err) => {
684                    fault = Some(Fault::System(format!(
685                        "cannot open engine `{engine_id}`: {err}"
686                    )));
687                    failed = true;
688                    break;
689                }
690            }
691        }
692        let Some((_, session)) = sessions.iter_mut().find(|(id, _)| *id == engine_id) else {
693            break; // unreachable: just ensured above
694        };
695
696        let budget = session
697            .batch_budget(batch)
698            .unwrap_or(config.default_batch_budget);
699        heartbeat(budget);
700        events.emit(&Event::BatchStarted {
701            scenario: Arc::clone(&spec.name),
702            engine: Arc::from(engine_id.as_str()),
703            steps: batch.steps.len(),
704        });
705
706        let result = session.run_batch(batch, &mut world, events, cancel);
707        let all_optional = batch.steps.iter().all(|s| s.optional);
708        for mut step_outcome in result.steps {
709            if step_outcome.status == Status::Failed && all_optional {
710                step_outcome.status = Status::Warned;
711            }
712            steps.push(step_outcome);
713        }
714        if let Some(err) = result.error {
715            if all_optional {
716                // `optional:` — warn and continue (segmentation isolates it).
717                // The batch WAS dispatched and its steps already carry real
718                // outcomes: count it, or the unreached-steps loop below would
719                // re-report it as Skipped on top of them (ADR-0008).
720                processed += 1;
721                continue;
722            }
723            match err.class {
724                crate::error::EngineErrorClass::AssertFailed => {}
725                crate::error::EngineErrorClass::UserInput => {
726                    fault = Some(Fault::User(err.message.clone()));
727                }
728                crate::error::EngineErrorClass::Infra | crate::error::EngineErrorClass::Setup => {
729                    fault = Some(Fault::System(err.message.clone()));
730                }
731            }
732            failed = true;
733        }
734        processed += 1;
735    }
736
737    // Every authored step gets an outcome: batches never dispatched (earlier
738    // failure or cancellation) report their steps as Skipped instead of
739    // silently vanishing from console, record, and JUnit alike.
740    let unreached_reason = if interrupted {
741        "not run (run cancelled)"
742    } else {
743        "not run (an earlier step failed)"
744    };
745    for batch in prepared.batches.iter().skip(processed) {
746        for step in &batch.steps {
747            events.emit(&Event::StepFinished {
748                scenario: Arc::clone(&spec.name),
749                engine: Arc::from(batch.engine.as_str()),
750                step: step.step.clone(),
751                status: Status::Skipped,
752                attempts: 0,
753                duration_ms: 0,
754                captures: Vec::new(),
755                // A step that never ran still says where it would have run
756                // from: "not run" is exactly when a reader is reconstructing
757                // what the suite was about to do.
758                fragment: step.fragment.clone(),
759                detail: Some(unreached_reason.to_owned()),
760                attempt_details: Vec::new(),
761            });
762            steps.push(StepOutcome {
763                step: step.step.clone(),
764                status: Status::Skipped,
765                attempts: 0,
766                duration: std::time::Duration::ZERO,
767                detail: Some(unreached_reason.to_owned()),
768                attempt_details: Vec::new(),
769                reproduce_hint: None,
770                fragment: step.fragment.clone(),
771            });
772        }
773    }
774
775    for (engine_id, session) in sessions.iter_mut().rev() {
776        if let Err(err) = session.finish()
777            && fault.is_none()
778        {
779            // Teardown failure on an otherwise-clean scenario is a real infra
780            // signal (never silently swallowed); a scenario that already
781            // failed keeps its primary fault.
782            fault = Some(Fault::System(format!(
783                "engine `{engine_id}` teardown failed: {}",
784                err.message
785            )));
786        }
787    }
788
789    // Merge global promotions back through the store lock (§12) — the write
790    // set only. Writing the whole world back would clobber keys another
791    // scenario promoted after this one took its snapshot (lost update).
792    // Poison recovery is sound here: store inserts are plain map writes with
793    // no cross-key invariant a panicked holder could have torn — dropping the
794    // write set instead would silently lose `saveAs: global` promotions from
795    // a scenario that reports Passed.
796    {
797        let mut guard = store
798            .lock()
799            .unwrap_or_else(std::sync::PoisonError::into_inner);
800        for (key, value) in world.promotions() {
801            guard.insert(key, value.clone());
802        }
803    }
804
805    let status = if failed || steps.iter().any(|s| s.status == Status::Failed) {
806        Status::Failed
807    } else if interrupted {
808        Status::Skipped
809    } else {
810        Status::Passed
811    };
812    let artifact_slug = prepared
813        .artifact
814        .as_ref()
815        .map(|artifact| Arc::clone(&artifact.slug));
816    outcome(status, steps, fault, artifact_slug)
817}