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;
16use std::sync::mpsc;
17use std::sync::{Arc, Mutex};
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}
54
55/// Run-level configuration.
56#[derive(Clone)]
57pub struct RunConfig {
58    /// Injected run identifier.
59    pub run_id: Arc<str>,
60    /// Parallel scenario workers.
61    pub jobs: usize,
62    /// Watchdog budget for a batch whose engine cannot estimate one.
63    pub default_batch_budget: Duration,
64    /// Secret name → value (engines inject via their redacting mechanisms).
65    pub secrets: Arc<BTreeMap<String, String>>,
66    /// Batch-level HTTP defaults.
67    pub http: HttpDefaults,
68}
69
70/// Aggregate result of a run.
71#[derive(Debug)]
72pub struct RunSummary {
73    /// Per-scenario outcomes, in completion order.
74    pub outcomes: Vec<ScenarioOutcome>,
75    /// Scenarios that passed (warnings allowed).
76    pub passed: usize,
77    /// Scenarios that failed.
78    pub failed: usize,
79    /// Scenarios skipped (cancellation).
80    pub skipped: usize,
81    /// The run was cancelled (Ctrl-C / token) — some scenarios did not run.
82    pub cancelled: bool,
83}
84
85impl RunSummary {
86    /// The run's exit code: system faults dominate, then user faults, then
87    /// test failures (ADR-0009). A cancelled run is never `Success` — the
88    /// suite did not pass; it was interrupted (folds in as a test failure).
89    pub fn exit_code(&self) -> ExitCode {
90        self.exit_code_excluding(&[])
91    }
92
93    /// [`Self::exit_code`], treating the given `(file, name)` scenarios as
94    /// non-gating (`@quarantine`): their *test-failures* no longer count toward
95    /// the exit code, but a `System`/`User` fault still does — quarantine is for
96    /// flaky tests, not broken input or infra.
97    pub fn exit_code_excluding(&self, non_gating: &[(String, String)]) -> ExitCode {
98        let mut worst = if self.cancelled {
99            ExitCode::TestFailure
100        } else {
101            ExitCode::Success
102        };
103        for outcome in &self.outcomes {
104            let quarantined = non_gating.iter().any(|(file, name)| {
105                file.as_str() == outcome.file.as_ref() && name.as_str() == outcome.name.as_ref()
106            });
107            let code = match (&outcome.fault, outcome.status) {
108                (Some(Fault::System(_)), _) => ExitCode::SystemError,
109                (Some(Fault::User(_)), _) => ExitCode::UserError,
110                (None, Status::Failed) if !quarantined => ExitCode::TestFailure,
111                _ => ExitCode::Success,
112            };
113            worst = pick_worse(worst, code);
114        }
115        worst
116    }
117}
118
119/// Prefer system errors over user errors over test failures.
120fn pick_worse(a: ExitCode, b: ExitCode) -> ExitCode {
121    let rank = |c: ExitCode| match c {
122        ExitCode::SystemError => 3,
123        ExitCode::UserError => 2,
124        ExitCode::TestFailure => 1,
125        ExitCode::Success => 0,
126    };
127    if rank(b) > rank(a) { b } else { a }
128}
129
130/// One scenario's outcome.
131#[derive(Debug)]
132pub struct ScenarioOutcome {
133    /// Feature file path.
134    pub file: Arc<str>,
135    /// Scenario name.
136    pub name: Arc<str>,
137    /// 1-based header line.
138    pub line: usize,
139    /// Aggregate status.
140    pub status: Status,
141    /// Step outcomes, in execution order.
142    pub steps: Vec<StepOutcome>,
143    /// Non-test fault, when one occurred.
144    pub fault: Option<Fault>,
145    /// Slug of the emitted artifact, when one exists (drives the CLI's
146    /// `reproduce:` line — the emitter's naming is never re-derived).
147    pub artifact_slug: Option<Arc<str>>,
148}
149
150/// A non-test fault attributed per ADR-0009.
151#[derive(Debug)]
152pub enum Fault {
153    /// The user's input is at fault (runtime resolution, missing secret, …).
154    User(String),
155    /// The environment or proef is at fault (engine infra, abandoned budget).
156    System(String),
157}
158
159enum Msg {
160    BatchBegin {
161        scenario: usize,
162        deadline: Instant,
163    },
164    Done {
165        scenario: usize,
166        outcome: ScenarioOutcome,
167    },
168}
169
170/// Execute `specs` and return the summary. Emits the full event stream on
171/// `events` (`RunStarted` … `RunFinished` — ADR-0008).
172// One cohesive listing of the dispatch/watchdog loop; splitting hides the order.
173#[allow(clippy::too_many_lines)]
174pub fn run(
175    specs: Vec<ScenarioSpec>,
176    engines: &Arc<Vec<Box<dyn EngineFactory>>>,
177    store: &Arc<Mutex<GlobalStore>>,
178    config: &RunConfig,
179    events: &EventSink,
180    cancel: &CancellationToken,
181) -> RunSummary {
182    events.emit(&Event::RunStarted {
183        schema: EVENT_SCHEMA_VERSION,
184        run_id: Arc::clone(&config.run_id),
185    });
186
187    let (tx, rx) = mpsc::channel::<Msg>();
188    // Identities survive the specs' move into the queue, so an abandoned
189    // scenario is reported as itself, never as a synthetic placeholder.
190    let identities: Vec<(Arc<str>, Arc<str>, usize)> = specs
191        .iter()
192        .map(|spec| (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line))
193        .collect();
194    let mut queue: std::collections::VecDeque<(usize, ScenarioSpec)> =
195        specs.into_iter().enumerate().collect();
196    let total = queue.len();
197    // Deadline plus the scenario's child cancellation token — retained so
198    // abandonment can cancel the detached thread's work instead of leaving it
199    // appending events forever (record hygiene; engines gain real stop points
200    // as they support cancellation).
201    let mut active: BTreeMap<usize, (Instant, CancellationToken)> = BTreeMap::new();
202    let mut outcomes: Vec<ScenarioOutcome> = Vec::new();
203    let grace = Duration::from_secs(2);
204
205    while outcomes.len() < total {
206        // Fill free slots.
207        while active.len() < config.jobs.max(1) {
208            let Some((index, spec)) = queue.pop_front() else {
209                break;
210            };
211            if cancel.is_cancelled() {
212                events.emit(&Event::ScenarioFinished {
213                    scenario: Arc::clone(&spec.name),
214                    file: Arc::clone(&spec.file),
215                    status: Status::Skipped,
216                    timestamp_ms: None,
217                    worker: None,
218                });
219                outcomes.push(ScenarioOutcome {
220                    file: spec.file,
221                    name: spec.name,
222                    line: spec.line,
223                    status: Status::Skipped,
224                    steps: Vec::new(),
225                    fault: None,
226                    artifact_slug: None,
227                });
228                continue;
229            }
230            let initial_deadline = Instant::now() + config.default_batch_budget + grace;
231            let child = cancel.child_token();
232            active.insert(index, (initial_deadline, child.clone()));
233            spawn_scenario(
234                index,
235                spec,
236                Arc::clone(engines),
237                Arc::clone(store),
238                config.clone(),
239                events.clone(),
240                child,
241                tx.clone(),
242            );
243        }
244        if active.is_empty() {
245            continue; // only skipped scenarios remained in the queue
246        }
247
248        // Wait for progress, bounded by the earliest active deadline.
249        let now = Instant::now();
250        let next_deadline = active
251            .values()
252            .map(|(deadline, _)| *deadline)
253            .min()
254            .unwrap_or(now + grace);
255        let wait = next_deadline
256            .saturating_duration_since(now)
257            .max(Duration::from_millis(20));
258        match rx.recv_timeout(wait) {
259            Ok(Msg::BatchBegin { scenario, deadline }) => {
260                if let Some(entry) = active.get_mut(&scenario) {
261                    entry.0 = deadline + grace;
262                }
263            }
264            Ok(Msg::Done { scenario, outcome }) => {
265                if active.remove(&scenario).is_some() {
266                    events.emit(&Event::ScenarioFinished {
267                        scenario: Arc::clone(&outcome.name),
268                        file: Arc::clone(&outcome.file),
269                        status: outcome.status,
270                        timestamp_ms: None,
271                        worker: None,
272                    });
273                    outcomes.push(outcome);
274                }
275                // else: a previously-abandoned thread finished late — ignored.
276            }
277            Err(mpsc::RecvTimeoutError::Timeout) => {}
278            Err(mpsc::RecvTimeoutError::Disconnected) => break,
279        }
280        // Sweep expired deadlines on *every* turn of the loop — a steady
281        // stream of messages from healthy scenarios must not keep a hung
282        // one alive past its budget.
283        sweep_expired(&mut active, &mut outcomes, events, &identities);
284    }
285
286    let passed = outcomes
287        .iter()
288        .filter(|o| matches!(o.status, Status::Passed | Status::Warned))
289        .count();
290    let failed = outcomes
291        .iter()
292        .filter(|o| o.status == Status::Failed)
293        .count();
294    let skipped = outcomes
295        .iter()
296        .filter(|o| o.status == Status::Skipped)
297        .count();
298    let cancelled = cancel.is_cancelled();
299    events.emit(&Event::RunFinished {
300        passed,
301        failed,
302        skipped,
303        cancelled,
304    });
305    RunSummary {
306        outcomes,
307        passed,
308        failed,
309        skipped,
310        cancelled,
311    }
312}
313
314/// Abandon every scenario whose deadline has passed: cancel its child token,
315/// record a `System` fault, and detach the thread (ADR-0007 — the process
316/// reaps it at exit; the cancelled token is the thread's signal to stop
317/// appending to the record).
318fn sweep_expired(
319    active: &mut BTreeMap<usize, (Instant, CancellationToken)>,
320    outcomes: &mut Vec<ScenarioOutcome>,
321    events: &EventSink,
322    identities: &[(Arc<str>, Arc<str>, usize)],
323) {
324    let now = Instant::now();
325    let expired: Vec<usize> = active
326        .iter()
327        .filter(|(_, (deadline, _))| *deadline <= now)
328        .map(|(index, _)| *index)
329        .collect();
330    for index in expired {
331        if let Some((_, token)) = active.remove(&index) {
332            token.cancel();
333        }
334        // `active` keys are spec indices and `identities` is built 1:1 from
335        // the same specs — the lookup cannot miss.
336        let (file, name, line) = &identities[index];
337        let outcome = ScenarioOutcome {
338            file: Arc::clone(file),
339            name: Arc::clone(name),
340            line: *line,
341            status: Status::Failed,
342            steps: Vec::new(),
343            fault: Some(Fault::System(
344                "batch budget exceeded — scenario thread abandoned (ADR-0007)".to_owned(),
345            )),
346            artifact_slug: None,
347        };
348        events.emit(&Event::ScenarioFinished {
349            scenario: Arc::clone(&outcome.name),
350            file: Arc::clone(&outcome.file),
351            status: Status::Failed,
352            timestamp_ms: None,
353            worker: None,
354        });
355        outcomes.push(outcome);
356    }
357}
358
359#[allow(clippy::too_many_arguments)]
360fn spawn_scenario(
361    index: usize,
362    spec: ScenarioSpec,
363    engines: Arc<Vec<Box<dyn EngineFactory>>>,
364    store: Arc<Mutex<GlobalStore>>,
365    config: RunConfig,
366    events: EventSink,
367    cancel: CancellationToken,
368    tx: mpsc::Sender<Msg>,
369) {
370    std::thread::spawn(move || {
371        let identity = (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line);
372        let heartbeat_tx = tx.clone();
373        // A panicking engine or prepare closure must never look like a hang:
374        // contain it, report a System fault under the real identity, and let
375        // the dispatcher move on immediately instead of waiting out the budget.
376        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
377            run_scenario(
378                spec,
379                &engines,
380                &store,
381                &config,
382                &events,
383                &cancel,
384                |budget| {
385                    let _ = heartbeat_tx.send(Msg::BatchBegin {
386                        scenario: index,
387                        deadline: Instant::now() + budget,
388                    });
389                },
390            )
391        }));
392        let outcome = result.unwrap_or_else(|panic| {
393            let message = panic
394                .downcast_ref::<&str>()
395                .map(ToString::to_string)
396                .or_else(|| panic.downcast_ref::<String>().cloned())
397                .unwrap_or_else(|| "opaque panic payload".to_owned());
398            ScenarioOutcome {
399                file: identity.0,
400                name: identity.1,
401                line: identity.2,
402                status: Status::Failed,
403                steps: Vec::new(),
404                fault: Some(Fault::System(format!(
405                    "scenario thread panicked: {message}"
406                ))),
407                artifact_slug: None,
408            }
409        });
410        let _ = tx.send(Msg::Done {
411            scenario: index,
412            outcome,
413        });
414    });
415}
416
417// One cohesive listing of the scenario lifecycle; splitting hides the order.
418#[allow(clippy::too_many_lines)]
419fn run_scenario(
420    spec: ScenarioSpec,
421    engines: &[Box<dyn EngineFactory>],
422    store: &Mutex<GlobalStore>,
423    config: &RunConfig,
424    events: &EventSink,
425    cancel: &CancellationToken,
426    heartbeat: impl Fn(Duration),
427) -> ScenarioOutcome {
428    let (file, name, line) = (Arc::clone(&spec.file), Arc::clone(&spec.name), spec.line);
429    let outcome = move |status, steps, fault, artifact_slug| ScenarioOutcome {
430        file: Arc::clone(&file),
431        name: Arc::clone(&name),
432        line,
433        status,
434        steps,
435        fault,
436        artifact_slug,
437    };
438
439    events.emit(&Event::ScenarioStarted {
440        scenario: Arc::clone(&spec.name),
441        file: Arc::clone(&spec.file),
442        timestamp_ms: None,
443        worker: None,
444    });
445
446    // Prepare against a snapshot of the shared globals (lower-time reads).
447    let snapshot = match store.lock() {
448        Ok(guard) => guard.clone(),
449        Err(_) => {
450            return outcome(
451                Status::Failed,
452                Vec::new(),
453                Some(Fault::System("global store lock poisoned".to_owned())),
454                None,
455            );
456        }
457    };
458    let mut world = World::new(snapshot);
459    let prepared = match (spec.prepare)(&world) {
460        Ok(prepared) => prepared,
461        Err(diags) => {
462            let detail = diags
463                .iter()
464                .map(|d| d.message.clone())
465                .collect::<Vec<_>>()
466                .join("; ");
467            return outcome(Status::Failed, Vec::new(), Some(Fault::User(detail)), None);
468        }
469    };
470
471    let mut sessions: Vec<(String, Box<dyn crate::engine::EngineSession>)> = Vec::new();
472    let mut steps: Vec<StepOutcome> = Vec::new();
473    let mut fault: Option<Fault> = None;
474    let mut failed = false;
475
476    let mut interrupted = false;
477    let mut processed = 0usize;
478    for batch in &prepared.batches {
479        if failed {
480            break;
481        }
482        if cancel.is_cancelled() {
483            // Batches remain — the scenario did not run to completion and must
484            // not report `Passed` (a cancelled run would otherwise exit 0).
485            interrupted = true;
486            break;
487        }
488        let engine_id = batch.engine.as_str().to_owned();
489        if !sessions.iter().any(|(id, _)| *id == engine_id) {
490            let Some(factory) = engines.iter().find(|f| f.id() == engine_id) else {
491                fault = Some(Fault::System(format!(
492                    "no engine registered for `{engine_id}`"
493                )));
494                failed = true;
495                break;
496            };
497            let ctx = ScenarioCtx {
498                run_id: Arc::clone(&config.run_id),
499                scenario: Arc::clone(&spec.name),
500                artifact: prepared.artifact.clone(),
501                secrets: Arc::clone(&config.secrets),
502                http: config.http,
503                file_root: spec.file_root.clone(),
504            };
505            match factory.open(&ctx) {
506                Ok(session) => sessions.push((engine_id.clone(), session)),
507                Err(err) => {
508                    fault = Some(Fault::System(format!(
509                        "cannot open engine `{engine_id}`: {err}"
510                    )));
511                    failed = true;
512                    break;
513                }
514            }
515        }
516        let Some((_, session)) = sessions.iter_mut().find(|(id, _)| *id == engine_id) else {
517            break; // unreachable: just ensured above
518        };
519
520        let budget = session
521            .batch_budget(batch)
522            .unwrap_or(config.default_batch_budget);
523        heartbeat(budget);
524        events.emit(&Event::BatchStarted {
525            scenario: Arc::clone(&spec.name),
526            engine: Arc::from(engine_id.as_str()),
527            steps: batch.steps.len(),
528        });
529
530        let result = session.run_batch(batch, &mut world, events, cancel);
531        let all_optional = batch.steps.iter().all(|s| s.optional);
532        for mut step_outcome in result.steps {
533            if step_outcome.status == Status::Failed && all_optional {
534                step_outcome.status = Status::Warned;
535            }
536            steps.push(step_outcome);
537        }
538        if let Some(err) = result.error {
539            if all_optional {
540                // `optional:` — warn and continue (segmentation isolates it).
541                // The batch WAS dispatched and its steps already carry real
542                // outcomes: count it, or the unreached-steps loop below would
543                // re-report it as Skipped on top of them (ADR-0008).
544                processed += 1;
545                continue;
546            }
547            match err.class {
548                crate::error::EngineErrorClass::AssertFailed => {}
549                crate::error::EngineErrorClass::UserInput => {
550                    fault = Some(Fault::User(err.message.clone()));
551                }
552                crate::error::EngineErrorClass::Infra | crate::error::EngineErrorClass::Setup => {
553                    fault = Some(Fault::System(err.message.clone()));
554                }
555            }
556            failed = true;
557        }
558        processed += 1;
559    }
560
561    // Every authored step gets an outcome: batches never dispatched (earlier
562    // failure or cancellation) report their steps as Skipped instead of
563    // silently vanishing from console, record, and JUnit alike.
564    let unreached_reason = if interrupted {
565        "not run (run cancelled)"
566    } else {
567        "not run (an earlier step failed)"
568    };
569    for batch in prepared.batches.iter().skip(processed) {
570        for step in &batch.steps {
571            events.emit(&Event::StepFinished {
572                scenario: Arc::clone(&spec.name),
573                engine: Arc::from(batch.engine.as_str()),
574                step: step.step.clone(),
575                status: Status::Skipped,
576                attempts: 0,
577                duration_ms: 0,
578                captures: Vec::new(),
579                detail: Some(unreached_reason.to_owned()),
580                attempt_details: Vec::new(),
581            });
582            steps.push(StepOutcome {
583                step: step.step.clone(),
584                status: Status::Skipped,
585                attempts: 0,
586                duration: std::time::Duration::ZERO,
587                detail: Some(unreached_reason.to_owned()),
588                attempt_details: Vec::new(),
589                reproduce_hint: None,
590            });
591        }
592    }
593
594    for (engine_id, session) in sessions.iter_mut().rev() {
595        if let Err(err) = session.finish()
596            && fault.is_none()
597        {
598            // Teardown failure on an otherwise-clean scenario is a real infra
599            // signal (never silently swallowed); a scenario that already
600            // failed keeps its primary fault.
601            fault = Some(Fault::System(format!(
602                "engine `{engine_id}` teardown failed: {}",
603                err.message
604            )));
605        }
606    }
607
608    // Merge global promotions back through the store lock (§12) — the write
609    // set only. Writing the whole world back would clobber keys another
610    // scenario promoted after this one took its snapshot (lost update).
611    // Poison recovery is sound here: store inserts are plain map writes with
612    // no cross-key invariant a panicked holder could have torn — dropping the
613    // write set instead would silently lose `saveAs: global` promotions from
614    // a scenario that reports Passed.
615    {
616        let mut guard = store
617            .lock()
618            .unwrap_or_else(std::sync::PoisonError::into_inner);
619        for (key, value) in world.promotions() {
620            guard.insert(key, value.clone());
621        }
622    }
623
624    let status = if failed || steps.iter().any(|s| s.status == Status::Failed) {
625        Status::Failed
626    } else if interrupted {
627        Status::Skipped
628    } else {
629        Status::Passed
630    };
631    let artifact_slug = prepared
632        .artifact
633        .as_ref()
634        .map(|artifact| Arc::clone(&artifact.slug));
635    outcome(status, steps, fault, artifact_slug)
636}