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