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}