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