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