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}