Skip to main content

helixforge_engine/
execution.rs

1//! A run of a plan as a stream of events, for any interface to show.
2//!
3//! [`Execution::start`] runs a plan's program in a pseudo-terminal and
4//! returns a handle with a bounded channel of [`ExecutionEvent`]s. The handle
5//! is what an interface acts through: keyboard input, the terminal's size,
6//! stopping the run and writing its history record. The events are what it
7//! shows: the run and each step starting and ending, the terminal's output as
8//! it arrives, progress, warnings, and how the run ended, with its checks.
9//!
10//! A consumer that falls behind never holds the run up. Output waits in the
11//! run's transcript until the channel has room and then arrives in larger
12//! pieces, each saying where in the transcript it starts; the other events
13//! wait in order behind it. Nothing is dropped. A consumer that goes away
14//! (drops the receiver) leaves the run going and its record still written.
15//!
16//! The order is the run's: `RunStarted` first, a step's `StepStarted` before
17//! its end, and `Completed` or `Interrupted` after the output that came
18//! before the program ended. Step events come from the program's step file
19//! and output from the terminal, read side by side every few milliseconds,
20//! so a step's first or last bytes can fall on the other side of its event.
21//! Output can follow the end when processes the run left behind still write.
22
23use crate::{
24    EngineError, Finished, Outcome, Plan, StepState, check_requirements, history, pty::Session,
25    shell_program_with, status_file,
26};
27use serde::{Serialize, Serializer};
28use std::{
29    collections::VecDeque,
30    io,
31    path::{Path, PathBuf},
32    sync::{
33        Arc, Mutex, OnceLock,
34        atomic::{AtomicBool, Ordering},
35        mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError},
36    },
37    time::{Duration, Instant},
38};
39
40/// How many events the channel holds before the rest wait for room.
41pub const DEFAULT_CAPACITY: usize = 256;
42
43/// The most output one event carries.
44pub const OUTPUT_CHUNK: usize = 64 * 1024;
45
46/// How often the run is looked at when nothing wakes the monitor sooner.
47const TICK: Duration = Duration::from_millis(10);
48
49/// How long the end of a run waits for output still on its way from the
50/// terminal, when something the run started keeps it open.
51const DRAIN: Duration = Duration::from_millis(250);
52
53/// One step of the plan, as the events name it.
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct StepInfo {
56    /// Its position in the plan, from 0.
57    pub index: usize,
58    pub id: String,
59    pub name: String,
60}
61
62/// Something that happened in a run.
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub enum ExecutionEvent {
65    /// Always the first event: the entries and the steps, in order.
66    RunStarted {
67        entries: Vec<(String, String)>,
68        steps: Vec<StepInfo>,
69    },
70    StepStarted(StepInfo),
71    StepCompleted(StepInfo),
72    /// The step ended with a non-zero status; `interrupted` when a signal
73    /// ended it, which also ends the run.
74    StepFailed {
75        step: StepInfo,
76        exit_code: u32,
77        interrupted: bool,
78    },
79    /// Not run, because a step it needs did not finish.
80    StepSkipped(StepInfo),
81    /// Terminal output: the transcript's bytes from `offset` on, exactly as
82    /// the program wrote them, escape sequences included.
83    Output {
84        offset: u64,
85        bytes: Vec<u8>,
86    },
87    /// After each step ends, however it ended: how many have, of all.
88    Progress {
89        ended: usize,
90        total: usize,
91    },
92    /// Something the user should know that does not stop the run.
93    Warning(String),
94    /// The run ended by itself: it succeeded, failed or could not start.
95    /// The checks of the steps that finished are done.
96    Completed(Finished),
97    /// The run was stopped, by request or by a signal; nothing is checked.
98    Interrupted(Finished),
99}
100
101/// Where a run's history record goes.
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub enum HistoryPlace {
104    /// The user's history, as every interface keeps it.
105    Default,
106    /// The history in this state directory.
107    In(PathBuf),
108    /// No record, for tests and previews.
109    Off,
110}
111
112/// How to start a run.
113#[derive(Clone, Debug)]
114pub struct Options {
115    /// The terminal's first size; [`Execution::resize`] changes it.
116    pub rows: u16,
117    pub cols: u16,
118    /// How many events the channel holds; at least 1.
119    pub capacity: usize,
120    pub history: HistoryPlace,
121}
122
123impl Default for Options {
124    fn default() -> Self {
125        Self {
126            rows: 24,
127            cols: 80,
128            capacity: DEFAULT_CAPACITY,
129            history: HistoryPlace::Default,
130        }
131    }
132}
133
134/// A running plan. Every method takes `&self`, and the handle can be shared
135/// between threads.
136pub struct Execution {
137    shared: Arc<Shared>,
138}
139
140struct Shared {
141    session: Mutex<Session>,
142    plan: Plan,
143    /// Where the program writes each step's outcome.
144    status: Option<PathBuf>,
145    /// The finished run, once the monitor has seen the program end.
146    finished: OnceLock<Finished>,
147    history: Mutex<Book>,
148    /// Set when the handle is dropped: the monitor stops.
149    closed: AtomicBool,
150}
151
152/// The run's history record, from its marker to the record.
153struct Book {
154    place: HistoryPlace,
155    pending: Option<history::Pending>,
156    recorded: bool,
157}
158
159impl Execution {
160    /// Check the plan's requirements and start its program, with its
161    /// history marker. Errors are what keeps it from starting.
162    pub fn start(
163        plan: &Plan,
164        lib_dir: &Path,
165        this: &Path,
166        options: Options,
167    ) -> Result<(Self, Receiver<ExecutionEvent>), EngineError> {
168        check_requirements(plan)?;
169        tracing::info!(steps = ?plan.entries(), "run started");
170        let status = status_file();
171        let (bell, ring) = mpsc::sync_channel(1);
172        let session = Session::spawn_sized(
173            &shell_program_with(plan, lib_dir, this, status.as_deref()),
174            lib_dir,
175            options.rows,
176            options.cols,
177            Some(bell),
178        )
179        .inspect_err(|_| {
180            if let Some(status) = &status {
181                let _ = std::fs::remove_file(status);
182            }
183        })?;
184        let entries = plan.entries();
185        let mut first = vec![ExecutionEvent::RunStarted {
186            entries: entries.clone(),
187            steps: (0..plan.steps.len())
188                .map(|index| step_info(plan, index))
189                .collect(),
190        }];
191        let begun = match &options.history {
192            HistoryPlace::Default => Some(history::begin(&entries)),
193            HistoryPlace::In(directory) => Some(history::begin_in(directory, &entries)),
194            HistoryPlace::Off => None,
195        };
196        let pending = match begun {
197            Some(Ok(pending)) => Some(pending),
198            Some(Err(error)) => {
199                tracing::warn!(%error, "the run start was not recorded");
200                first.push(ExecutionEvent::Warning(format!(
201                    "The start of this run was not recorded in the history: {error}"
202                )));
203                None
204            }
205            None => None,
206        };
207        if status.is_none() {
208            first.push(ExecutionEvent::Warning(
209                "No file for the steps' outcomes could be made, so the steps are not followed one by one".into(),
210            ));
211        }
212        let shared = Arc::new(Shared {
213            session: Mutex::new(session),
214            plan: plan.clone(),
215            status,
216            finished: OnceLock::new(),
217            history: Mutex::new(Book {
218                place: options.history,
219                pending,
220                recorded: false,
221            }),
222            closed: AtomicBool::new(false),
223        });
224        let (sender, receiver) = mpsc::sync_channel(options.capacity.max(1));
225        let monitored = shared.clone();
226        std::thread::Builder::new()
227            .name("helixforge-run".into())
228            .spawn(move || monitor(&monitored, sender, &ring, first))
229            .map_err(EngineError::Spawn)?;
230        Ok((Self { shared }, receiver))
231    }
232
233    /// Send keyboard input to the program, as a terminal would: `\r` for
234    /// Enter, `\x03` for Ctrl-C (which the program sees, unlike [`stop`]).
235    ///
236    /// [`stop`]: Execution::stop
237    pub fn write(&self, bytes: &[u8]) {
238        self.shared.session.lock().unwrap().write(bytes);
239    }
240
241    /// Give the terminal a new size; the program is told when it changes.
242    pub fn resize(&self, rows: u16, cols: u16) {
243        self.shared.session.lock().unwrap().resize(rows, cols);
244    }
245
246    /// Stop the run. The first request hangs up every process in the run's
247    /// process group, as a closing terminal does; a later one, while any of
248    /// them is still running, kills them.
249    pub fn stop(&self) {
250        self.shared.session.lock().unwrap().kill();
251    }
252
253    /// Whether a stop was requested.
254    pub fn stop_requested(&self) -> bool {
255        self.shared.session.lock().unwrap().stop_requested()
256    }
257
258    /// How many processes of the run are still alive, such as ones that
259    /// ignored the hangup.
260    pub fn survivors(&self) -> usize {
261        self.shared.session.lock().unwrap().survivors()
262    }
263
264    /// Whether the run has ended and been checked: true from just before
265    /// its `Completed` or `Interrupted` event is sent.
266    pub fn is_finished(&self) -> bool {
267        self.shared.finished.get().is_some()
268    }
269
270    /// How the run ended, once it has.
271    pub fn finished(&self) -> Option<Finished> {
272        self.shared.finished.get().cloned()
273    }
274
275    /// Everything the program has written so far, as the `Output` events
276    /// carry it.
277    pub fn transcript(&self) -> Vec<u8> {
278        let transcript = self.shared.session.lock().unwrap().transcript();
279        transcript.lock().unwrap().clone()
280    }
281
282    /// Write the run's history record, with a saved log if there is one.
283    /// Only the first call once the run has ended writes it; dropping the
284    /// handle writes it without a log if no call did. Until then the
285    /// history's marker keeps the outcome, in case HelixForge stops first.
286    pub fn record(&self, log: Option<PathBuf>) -> io::Result<()> {
287        let Some(finished) = self.shared.finished.get() else {
288            return Ok(());
289        };
290        let left = self.survivors();
291        let mut book = self.shared.history.lock().unwrap();
292        if book.recorded {
293            return Ok(());
294        }
295        book.recorded = true;
296        tracing::info!(outcome = finished.describe(), "run finished");
297        let mut result = finished.describe();
298        if left > 0 {
299            result.push_str(&format!("; {left} process(es) left running"));
300        }
301        let entries = self.shared.plan.entries();
302        let written = match (book.pending.take(), &book.place) {
303            (_, HistoryPlace::Off) => return Ok(()),
304            (Some(pending), _) => pending.finish(finished.succeeded(), &result, log),
305            (None, HistoryPlace::Default) => {
306                history::record(&entries, finished.succeeded(), &result, log)
307            }
308            (None, HistoryPlace::In(directory)) => {
309                history::record_in(directory, &entries, finished.succeeded(), &result, log)
310            }
311        };
312        written.map(|_| ()).inspect_err(|error| {
313            tracing::warn!(%error, "the run was not recorded");
314        })
315    }
316}
317
318impl Drop for Execution {
319    fn drop(&mut self) {
320        // A run that ended just now is still checked and recorded.
321        if self.shared.finished.get().is_none() && self.shared.session.lock().unwrap().is_finished()
322        {
323            conclude(&self.shared);
324        }
325        let _ = self.record(None);
326        self.shared.closed.store(true, Ordering::Release);
327    }
328}
329
330impl Drop for Shared {
331    fn drop(&mut self) {
332        if let Some(status) = &self.status {
333            let _ = std::fs::remove_file(status);
334        }
335    }
336}
337
338fn step_info(plan: &Plan, index: usize) -> StepInfo {
339    let step = &plan.steps[index];
340    StepInfo {
341        index,
342        id: step.id().to_string(),
343        name: step.name().to_string(),
344    }
345}
346
347/// Follows the run until it has ended and everything is sent, or until the
348/// handle is dropped.
349fn monitor(
350    shared: &Shared,
351    sender: SyncSender<ExecutionEvent>,
352    ring: &Receiver<()>,
353    first: Vec<ExecutionEvent>,
354) {
355    let transcript = shared.session.lock().unwrap().transcript();
356    let mut outlet = Outlet {
357        sender,
358        connected: true,
359        sent: 0,
360        queue: VecDeque::new(),
361        transcript,
362    };
363    // Before any output, however much the program has written already.
364    for event in first {
365        outlet.next(event);
366    }
367    let mut steps = StepFile {
368        path: shared.status.clone(),
369        read: 0,
370        ended: 0,
371    };
372    let mut exited: Option<Instant> = None;
373    let mut announced = false;
374    loop {
375        if let Err(RecvTimeoutError::Disconnected) = ring.recv_timeout(TICK) {
376            // The terminal is closed; nothing rings any more.
377            std::thread::sleep(TICK);
378        }
379        if shared.closed.load(Ordering::Acquire) {
380            return;
381        }
382        for (event, ends) in steps.read(&shared.plan) {
383            if ends {
384                outlet.after_output_so_far(event);
385            } else {
386                outlet.next(event);
387            }
388        }
389        let (ended, reading) = {
390            let session = shared.session.lock().unwrap();
391            (session.is_finished(), session.reading())
392        };
393        if ended && !announced {
394            let since = *exited.get_or_insert_with(Instant::now);
395            if reading && since.elapsed() < DRAIN {
396                outlet.pump();
397                continue;
398            }
399            for (event, _) in steps.read(&shared.plan) {
400                outlet.after_output_so_far(event);
401            }
402            let finished = conclude(shared);
403            let left = shared.session.lock().unwrap().survivors();
404            if left > 0 {
405                outlet.after_output_so_far(ExecutionEvent::Warning(format!(
406                    "{left} process(es) of this run are still running"
407                )));
408            }
409            outlet.after_output_so_far(if finished.outcome == Outcome::Interrupted {
410                ExecutionEvent::Interrupted(finished)
411            } else {
412                ExecutionEvent::Completed(finished)
413            });
414            announced = true;
415        }
416        outlet.pump();
417        if announced && !reading && outlet.is_empty() {
418            return;
419        }
420    }
421}
422
423/// Check the ended run and keep its outcome in the history's marker, once
424/// however many ask.
425fn conclude(shared: &Shared) -> Finished {
426    shared
427        .finished
428        .get_or_init(|| {
429            let outcome = shared
430                .session
431                .lock()
432                .unwrap()
433                .outcome()
434                .unwrap_or_else(|| Outcome::CouldNotStart("unknown".into()));
435            let finished = Finished::from_run(&shared.plan, outcome, shared.status.as_deref());
436            if let Some(pending) = &mut shared.history.lock().unwrap().pending {
437                let _ = pending.ended(finished.succeeded(), &finished.describe());
438            }
439            finished
440        })
441        .clone()
442}
443
444/// The step lines the run's program has written so far.
445struct StepFile {
446    path: Option<PathBuf>,
447    /// How much of the file has been read, in whole lines.
448    read: usize,
449    ended: usize,
450}
451
452impl StepFile {
453    /// The events of the lines added since the last read, each with whether
454    /// it ends a step (and so comes after the output before it).
455    fn read(&mut self, plan: &Plan) -> Vec<(ExecutionEvent, bool)> {
456        let Some(text) = self
457            .path
458            .as_deref()
459            .and_then(|path| std::fs::read(path).ok())
460        else {
461            return Vec::new();
462        };
463        let Some(new) = text.get(self.read..) else {
464            return Vec::new();
465        };
466        // Only whole lines: the program may be writing the next one.
467        let Some(whole) = new.iter().rposition(|&byte| byte == b'\n') else {
468            return Vec::new();
469        };
470        self.read += whole + 1;
471        let mut events = Vec::new();
472        for line in String::from_utf8_lossy(&new[..whole]).lines() {
473            let mut words = line.split_whitespace();
474            let (Some(index), Some(state), Some(code)) = (words.next(), words.next(), words.next())
475            else {
476                continue;
477            };
478            let (Ok(index), Ok(code)) = (index.parse::<usize>(), code.parse::<u32>()) else {
479                continue;
480            };
481            if index >= plan.steps.len() {
482                continue;
483            }
484            let step = step_info(plan, index);
485            let event = match state {
486                "started" => {
487                    events.push((ExecutionEvent::StepStarted(step), false));
488                    continue;
489                }
490                "done" => ExecutionEvent::StepCompleted(step),
491                "failed" | "interrupted" => ExecutionEvent::StepFailed {
492                    step,
493                    exit_code: code,
494                    interrupted: state == "interrupted",
495                },
496                "skipped" => ExecutionEvent::StepSkipped(step),
497                _ => continue,
498            };
499            self.ended += 1;
500            events.push((event, true));
501            events.push((
502                ExecutionEvent::Progress {
503                    ended: self.ended,
504                    total: plan.steps.len(),
505                },
506                true,
507            ));
508        }
509        events
510    }
511}
512
513/// The sending end: output from the transcript, other events in order
514/// behind the output that came before them, all without waiting.
515struct Outlet {
516    sender: SyncSender<ExecutionEvent>,
517    connected: bool,
518    /// How much of the transcript has been sent.
519    sent: usize,
520    /// Events waiting for room, each after the transcript's first so many
521    /// bytes.
522    queue: VecDeque<(usize, ExecutionEvent)>,
523    transcript: Arc<Mutex<Vec<u8>>>,
524}
525
526impl Outlet {
527    /// Queue an event to follow the output so far.
528    fn after_output_so_far(&mut self, event: ExecutionEvent) {
529        let length = self.transcript.lock().unwrap().len();
530        self.queue.push_back((length, event));
531    }
532
533    /// Queue an event to follow only the output already promised before it.
534    fn next(&mut self, event: ExecutionEvent) {
535        let after = self.queue.back().map_or(self.sent, |(after, _)| *after);
536        self.queue.push_back((after.max(self.sent), event));
537    }
538
539    fn is_empty(&self) -> bool {
540        !self.connected
541            || (self.queue.is_empty() && self.sent >= self.transcript.lock().unwrap().len())
542    }
543
544    /// Send what there is room for, in order.
545    fn pump(&mut self) {
546        while self.connected {
547            let limit = match self.queue.front() {
548                Some((after, _)) if self.sent >= *after => {
549                    let (after, event) = self.queue.pop_front().unwrap();
550                    if let Some(event) = self.offer(event) {
551                        self.queue.push_front((after, event));
552                        return;
553                    }
554                    continue;
555                }
556                Some((after, _)) => *after,
557                None => usize::MAX,
558            };
559            let bytes = {
560                let transcript = self.transcript.lock().unwrap();
561                let end = transcript.len().min(limit).min(self.sent + OUTPUT_CHUNK);
562                if end <= self.sent {
563                    return;
564                }
565                transcript[self.sent..end].to_vec()
566            };
567            let length = bytes.len();
568            let event = ExecutionEvent::Output {
569                offset: self.sent as u64,
570                bytes,
571            };
572            if self.offer(event).is_some() {
573                return;
574            }
575            self.sent += length;
576        }
577    }
578
579    /// Send an event if there is room; give it back if not.
580    fn offer(&mut self, event: ExecutionEvent) -> Option<ExecutionEvent> {
581        match self.sender.try_send(event) {
582            Ok(()) => None,
583            Err(TrySendError::Full(event)) => Some(event),
584            Err(TrySendError::Disconnected(_)) => {
585                self.connected = false;
586                self.queue.clear();
587                None
588            }
589        }
590    }
591}
592
593// ----- the events as JSON, for an interface in another language -----
594
595#[derive(Serialize)]
596#[serde(tag = "event", rename_all = "snake_case")]
597enum Wire<'a> {
598    RunStarted {
599        entries: Vec<WireEntry<'a>>,
600        steps: Vec<WireStep<'a>>,
601    },
602    StepStarted {
603        step: WireStep<'a>,
604    },
605    StepCompleted {
606        step: WireStep<'a>,
607    },
608    StepFailed {
609        step: WireStep<'a>,
610        exit_code: u32,
611        interrupted: bool,
612    },
613    StepSkipped {
614        step: WireStep<'a>,
615    },
616    Output {
617        offset: u64,
618        bytes: &'a [u8],
619    },
620    Progress {
621        ended: usize,
622        total: usize,
623    },
624    Warning {
625        message: &'a str,
626    },
627    Completed {
628        #[serde(flatten)]
629        result: WireResult<'a>,
630    },
631    Interrupted {
632        #[serde(flatten)]
633        result: WireResult<'a>,
634    },
635}
636
637#[derive(Serialize)]
638struct WireEntry<'a> {
639    id: &'a str,
640    name: &'a str,
641}
642
643#[derive(Serialize)]
644struct WireStep<'a> {
645    index: usize,
646    id: &'a str,
647    name: &'a str,
648}
649
650impl<'a> From<&'a StepInfo> for WireStep<'a> {
651    fn from(step: &'a StepInfo) -> Self {
652        Self {
653            index: step.index,
654            id: &step.id,
655            name: &step.name,
656        }
657    }
658}
659
660#[derive(Serialize)]
661struct WireResult<'a> {
662    outcome: WireOutcome<'a>,
663    /// As the history records it.
664    description: String,
665    succeeded: bool,
666    verified: bool,
667    steps: Vec<WireStepResult<'a>>,
668    checks: Vec<WireCheck<'a>>,
669}
670
671#[derive(Serialize)]
672#[serde(tag = "kind", rename_all = "snake_case")]
673enum WireOutcome<'a> {
674    Success,
675    Failed { exit_code: u32 },
676    Interrupted,
677    CouldNotStart { reason: &'a str },
678}
679
680#[derive(Serialize)]
681struct WireStepResult<'a> {
682    index: usize,
683    id: &'a str,
684    name: &'a str,
685    state: &'static str,
686    #[serde(skip_serializing_if = "Option::is_none")]
687    exit_code: Option<u32>,
688}
689
690#[derive(Serialize)]
691struct WireCheck<'a> {
692    name: &'a str,
693    passed: bool,
694    detail: &'a str,
695}
696
697impl<'a> From<&'a Finished> for WireResult<'a> {
698    fn from(finished: &'a Finished) -> Self {
699        Self {
700            outcome: match &finished.outcome {
701                Outcome::Success => WireOutcome::Success,
702                Outcome::Failed { exit_code } => WireOutcome::Failed {
703                    exit_code: *exit_code,
704                },
705                Outcome::Interrupted => WireOutcome::Interrupted,
706                Outcome::CouldNotStart(reason) => WireOutcome::CouldNotStart { reason },
707            },
708            description: finished.describe(),
709            succeeded: finished.succeeded(),
710            verified: finished.verified(),
711            steps: finished
712                .steps
713                .iter()
714                .enumerate()
715                .map(|(index, step)| {
716                    let (state, exit_code) = match &step.state {
717                        StepState::Done => ("done", None),
718                        StepState::Failed(code) => ("failed", Some(*code)),
719                        StepState::Interrupted => ("interrupted", None),
720                        StepState::Skipped => ("skipped", None),
721                        StepState::NotRun => ("not_run", None),
722                    };
723                    WireStepResult {
724                        index,
725                        id: &step.id,
726                        name: &step.name,
727                        state,
728                        exit_code,
729                    }
730                })
731                .collect(),
732            checks: finished
733                .checks
734                .iter()
735                .map(|check| WireCheck {
736                    name: &check.name,
737                    passed: check.passed,
738                    detail: &check.detail,
739                })
740                .collect(),
741        }
742    }
743}
744
745/// The JSON form documented in docs/EVENTS.md: an object whose `event`
746/// names the kind, in snake case.
747impl Serialize for ExecutionEvent {
748    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
749        let wire = match self {
750            ExecutionEvent::RunStarted { entries, steps } => Wire::RunStarted {
751                entries: entries
752                    .iter()
753                    .map(|(id, name)| WireEntry { id, name })
754                    .collect(),
755                steps: steps.iter().map(WireStep::from).collect(),
756            },
757            ExecutionEvent::StepStarted(step) => Wire::StepStarted { step: step.into() },
758            ExecutionEvent::StepCompleted(step) => Wire::StepCompleted { step: step.into() },
759            ExecutionEvent::StepFailed {
760                step,
761                exit_code,
762                interrupted,
763            } => Wire::StepFailed {
764                step: step.into(),
765                exit_code: *exit_code,
766                interrupted: *interrupted,
767            },
768            ExecutionEvent::StepSkipped(step) => Wire::StepSkipped { step: step.into() },
769            ExecutionEvent::Output { offset, bytes } => Wire::Output {
770                offset: *offset,
771                bytes,
772            },
773            ExecutionEvent::Progress { ended, total } => Wire::Progress {
774                ended: *ended,
775                total: *total,
776            },
777            ExecutionEvent::Warning(message) => Wire::Warning { message },
778            ExecutionEvent::Completed(finished) => Wire::Completed {
779                result: finished.into(),
780            },
781            ExecutionEvent::Interrupted(finished) => Wire::Interrupted {
782                result: finished.into(),
783            },
784        };
785        wire.serialize(serializer)
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use crate::{Check, Step, StepResult};
793
794    fn command(id: &str, line: &str) -> Step {
795        Step::Command {
796            id: id.into(),
797            name: id.to_uppercase(),
798            line: line.into(),
799        }
800    }
801
802    fn plan(steps: Vec<Step>) -> Plan {
803        Plan {
804            steps,
805            ..Plan::default()
806        }
807    }
808
809    fn quiet() -> Options {
810        Options {
811            history: HistoryPlace::Off,
812            ..Options::default()
813        }
814    }
815
816    fn start(plan: &Plan, options: Options) -> (Execution, Receiver<ExecutionEvent>) {
817        Execution::start(plan, Path::new("/lib"), Path::new("/bin/hf"), options).unwrap()
818    }
819
820    fn is_end(event: &ExecutionEvent) -> bool {
821        matches!(
822            event,
823            ExecutionEvent::Completed(_) | ExecutionEvent::Interrupted(_)
824        )
825    }
826
827    /// Every event up to and including the run's end.
828    fn until_end(events: &Receiver<ExecutionEvent>) -> Vec<ExecutionEvent> {
829        let mut seen = Vec::new();
830        loop {
831            let event = events
832                .recv_timeout(Duration::from_secs(30))
833                .expect("the run should end");
834            let end = is_end(&event);
835            seen.push(event);
836            if end {
837                return seen;
838            }
839        }
840    }
841
842    /// The output the events carry, each piece starting where the last ended.
843    fn assemble(events: &[ExecutionEvent]) -> Vec<u8> {
844        let mut output = Vec::new();
845        for event in events {
846            if let ExecutionEvent::Output { offset, bytes } = event {
847                assert_eq!(*offset as usize, output.len(), "no gap and no overlap");
848                assert!(!bytes.is_empty() && bytes.len() <= OUTPUT_CHUNK);
849                output.extend_from_slice(bytes);
850            }
851        }
852        output
853    }
854
855    /// Wait for the transcript to show `text`.
856    fn wait_for(execution: &Execution, text: &str) {
857        let start = Instant::now();
858        while !String::from_utf8_lossy(&execution.transcript()).contains(text) {
859            assert!(
860                start.elapsed() < Duration::from_secs(10),
861                "waiting for {text:?} in {:?}",
862                String::from_utf8_lossy(&execution.transcript())
863            );
864            std::thread::sleep(Duration::from_millis(10));
865        }
866    }
867
868    fn position(events: &[ExecutionEvent], wanted: &ExecutionEvent) -> usize {
869        events
870            .iter()
871            .position(|event| event == wanted)
872            .unwrap_or_else(|| panic!("no {wanted:?} in {events:#?}"))
873    }
874
875    #[test]
876    fn a_run_is_told_in_order_and_recorded() {
877        let state = tempfile::tempdir().unwrap();
878        let plan = plan(vec![
879            command("one", "echo first"),
880            command("two", "echo second"),
881        ]);
882        let (execution, events) = start(
883            &plan,
884            Options {
885                history: HistoryPlace::In(state.path().to_path_buf()),
886                ..Options::default()
887            },
888        );
889        let seen = until_end(&events);
890        let steps: Vec<StepInfo> = (0..2).map(|index| step_info(&plan, index)).collect();
891        assert_eq!(
892            seen[0],
893            ExecutionEvent::RunStarted {
894                entries: plan.entries(),
895                steps: steps.clone(),
896            }
897        );
898        let order = [
899            ExecutionEvent::StepStarted(steps[0].clone()),
900            ExecutionEvent::StepCompleted(steps[0].clone()),
901            ExecutionEvent::Progress { ended: 1, total: 2 },
902            ExecutionEvent::StepStarted(steps[1].clone()),
903            ExecutionEvent::StepCompleted(steps[1].clone()),
904            ExecutionEvent::Progress { ended: 2, total: 2 },
905        ]
906        .map(|event| position(&seen, &event));
907        assert!(order.is_sorted(), "{seen:#?}");
908        let ExecutionEvent::Completed(finished) = seen.last().unwrap() else {
909            panic!("the run should complete: {seen:#?}");
910        };
911        assert_eq!(finished.outcome, Outcome::Success);
912        assert!(execution.is_finished());
913        assert_eq!(execution.finished().as_ref(), Some(finished));
914        let output = assemble(&seen);
915        assert_eq!(output, execution.transcript());
916        let text = String::from_utf8_lossy(&output);
917        assert!(text.contains("==> ONE") && text.contains("first"));
918        assert!(text.contains("==> TWO") && text.contains("second"));
919        // The marker has the outcome until the record is written.
920        assert!(history::read_in(state.path(), 10).unwrap().is_empty());
921        execution.record(None).unwrap();
922        execution.record(None).unwrap();
923        let records = history::read_in(state.path(), 10).unwrap();
924        assert_eq!(records.len(), 1, "recorded once");
925        assert!(records[0].success);
926        assert_eq!(records[0].entries, ["one", "two"]);
927    }
928
929    #[test]
930    fn the_first_events_come_before_output_the_program_already_wrote() {
931        let (sender, receiver) = mpsc::sync_channel(8);
932        let mut outlet = Outlet {
933            sender,
934            connected: true,
935            sent: 0,
936            queue: VecDeque::new(),
937            transcript: Arc::new(Mutex::new(b"early".to_vec())),
938        };
939        let first = ExecutionEvent::Warning("first".into());
940        outlet.next(first.clone());
941        outlet.pump();
942        assert_eq!(receiver.try_recv().unwrap(), first);
943        assert_eq!(
944            receiver.try_recv().unwrap(),
945            ExecutionEvent::Output {
946                offset: 0,
947                bytes: b"early".to_vec()
948            }
949        );
950    }
951
952    #[test]
953    fn a_slow_consumer_never_holds_the_run_up() {
954        let plan = plan(vec![command(
955            "lots",
956            "head -c 3000000 /dev/zero | tr '\\0' x",
957        )]);
958        let (execution, events) = start(
959            &plan,
960            Options {
961                capacity: 2,
962                ..quiet()
963            },
964        );
965        // Nothing is received until the run has ended.
966        let start = Instant::now();
967        while !execution.is_finished() {
968            assert!(start.elapsed() < Duration::from_secs(30), "the run waited");
969            std::thread::sleep(Duration::from_millis(20));
970        }
971        std::thread::sleep(Duration::from_millis(50));
972        let seen = until_end(&events);
973        let output = assemble(&seen);
974        assert_eq!(output, execution.transcript());
975        assert_eq!(
976            output.iter().filter(|&&byte| byte == b'x').count(),
977            3_000_000
978        );
979        assert!(matches!(seen.last(), Some(ExecutionEvent::Completed(_))));
980    }
981
982    #[test]
983    fn large_output_arrives_whole_in_bounded_pieces() {
984        let plan = plan(vec![command(
985            "lots",
986            "head -c 12000000 /dev/zero | tr '\\0' x; echo; echo the end",
987        )]);
988        let (execution, events) = start(&plan, quiet());
989        let seen = until_end(&events);
990        let output = assemble(&seen);
991        assert_eq!(output, execution.transcript());
992        assert_eq!(
993            output.iter().filter(|&&byte| byte == b'x').count(),
994            12_000_000
995        );
996        assert!(String::from_utf8_lossy(&output).ends_with("the end\r\n"));
997        let pieces = seen
998            .iter()
999            .filter(|event| matches!(event, ExecutionEvent::Output { .. }))
1000            .count();
1001        assert!(pieces >= 12_000_000 / OUTPUT_CHUNK, "{pieces} pieces");
1002    }
1003
1004    #[test]
1005    fn a_failed_step_is_told_and_what_needs_it_skipped() {
1006        let mut plan = plan(vec![
1007            command("broken", "echo trying; exit 3"),
1008            command("after", "echo never"),
1009            command("apart", "echo apart"),
1010        ]);
1011        plan.depends = vec![vec![], vec![0], vec![]];
1012        plan.keep_going = true;
1013        let (execution, events) = start(&plan, quiet());
1014        let seen = until_end(&events);
1015        let step = |index| step_info(&plan, index);
1016        let order = [
1017            ExecutionEvent::StepStarted(step(0)),
1018            ExecutionEvent::StepFailed {
1019                step: step(0),
1020                exit_code: 3,
1021                interrupted: false,
1022            },
1023            ExecutionEvent::StepSkipped(step(1)),
1024            ExecutionEvent::StepStarted(step(2)),
1025            ExecutionEvent::StepCompleted(step(2)),
1026            ExecutionEvent::Progress { ended: 3, total: 3 },
1027        ]
1028        .map(|event| position(&seen, &event));
1029        assert!(order.is_sorted(), "{seen:#?}");
1030        assert!(!seen.contains(&ExecutionEvent::StepStarted(step(1))));
1031        let ExecutionEvent::Completed(finished) = seen.last().unwrap() else {
1032            panic!("a failed run still completes: {seen:#?}");
1033        };
1034        assert_eq!(finished.outcome, Outcome::Failed { exit_code: 3 });
1035        let states: Vec<StepState> = finished.steps.iter().map(|s| s.state.clone()).collect();
1036        assert_eq!(
1037            states,
1038            [StepState::Failed(3), StepState::Skipped, StepState::Done]
1039        );
1040        assert!(!String::from_utf8_lossy(&execution.transcript()).contains("never"));
1041    }
1042
1043    #[test]
1044    fn stopping_hangs_up_the_group_and_a_second_stop_kills_what_stayed() {
1045        let directory = tempfile::tempdir().unwrap();
1046        let pid_file = directory.path().join("pid");
1047        let plan = plan(vec![command(
1048            "stubborn",
1049            &format!(
1050                "(trap '' HUP; sh -c 'echo $$ > {}; exec sleep 300') & echo ready; wait",
1051                pid_file.display()
1052            ),
1053        )]);
1054        let (execution, events) = start(&plan, quiet());
1055        wait_for(&execution, "ready");
1056        while std::fs::read_to_string(&pid_file).map_or(true, |text| text.trim().is_empty()) {
1057            std::thread::sleep(Duration::from_millis(10));
1058        }
1059        assert!(!execution.stop_requested());
1060        execution.stop();
1061        let seen = until_end(&events);
1062        let ExecutionEvent::Interrupted(finished) = seen.last().unwrap() else {
1063            panic!("a stopped run is interrupted: {seen:#?}");
1064        };
1065        assert_eq!(finished.outcome, Outcome::Interrupted);
1066        assert!(finished.checks.is_empty());
1067        assert!(execution.stop_requested());
1068        assert!(
1069            seen.iter().any(|event| matches!(
1070                event,
1071                ExecutionEvent::Warning(message) if message.contains("1 process(es) of this run are still running")
1072            )),
1073            "{seen:#?}"
1074        );
1075        assert_eq!(execution.survivors(), 1);
1076        execution.stop();
1077        let start = Instant::now();
1078        while execution.survivors() > 0 {
1079            assert!(
1080                start.elapsed() < Duration::from_secs(5),
1081                "the second stop kills"
1082            );
1083            std::thread::sleep(Duration::from_millis(20));
1084        }
1085    }
1086
1087    #[test]
1088    fn the_terminal_size_reaches_the_program_and_follows_a_resize() {
1089        let plan = plan(vec![command("size", "stty size; read -r line; stty size")]);
1090        let (execution, events) = start(
1091            &plan,
1092            Options {
1093                rows: 30,
1094                cols: 100,
1095                ..quiet()
1096            },
1097        );
1098        wait_for(&execution, "30 100");
1099        execution.resize(40, 120);
1100        execution.write(b"\r");
1101        let seen = until_end(&events);
1102        assert!(matches!(seen.last(), Some(ExecutionEvent::Completed(_))));
1103        assert!(String::from_utf8_lossy(&assemble(&seen)).contains("40 120"));
1104    }
1105
1106    #[test]
1107    fn a_hidden_prompt_takes_input_without_showing_it() {
1108        // As sudo asks for a password: echo off, read, echo on.
1109        let plan = plan(vec![command(
1110            "secret",
1111            "stty -echo; printf 'Password: '; read -r secret; stty echo; echo; echo \"length ${#secret}\"",
1112        )]);
1113        let (execution, events) = start(&plan, quiet());
1114        wait_for(&execution, "Password: ");
1115        execution.write(b"hunter2\r");
1116        let seen = until_end(&events);
1117        let text = String::from_utf8_lossy(&assemble(&seen)).into_owned();
1118        assert!(text.contains("length 7"), "{text}");
1119        assert!(!text.contains("hunter2"), "{text}");
1120    }
1121
1122    #[test]
1123    fn a_consumer_that_goes_away_leaves_the_run_and_its_record() {
1124        let state = tempfile::tempdir().unwrap();
1125        let plan = plan(vec![command("one", "echo done")]);
1126        let (execution, events) = start(
1127            &plan,
1128            Options {
1129                history: HistoryPlace::In(state.path().to_path_buf()),
1130                ..Options::default()
1131            },
1132        );
1133        drop(events);
1134        let start = Instant::now();
1135        while !execution.is_finished() {
1136            assert!(start.elapsed() < Duration::from_secs(10));
1137            std::thread::sleep(Duration::from_millis(10));
1138        }
1139        // Dropping the handle writes the record.
1140        drop(execution);
1141        let records = history::read_in(state.path(), 10).unwrap();
1142        assert_eq!(records.len(), 1);
1143        assert!(records[0].success);
1144    }
1145
1146    #[test]
1147    fn dropping_the_handle_hangs_up_a_run_in_progress() {
1148        let directory = tempfile::tempdir().unwrap();
1149        let pid_file = directory.path().join("pid");
1150        let plan = plan(vec![command(
1151            "long",
1152            &format!("sh -c 'echo $$ > {}; exec sleep 300'", pid_file.display()),
1153        )]);
1154        let (execution, events) = start(&plan, quiet());
1155        let start = Instant::now();
1156        while std::fs::read_to_string(&pid_file).map_or(true, |text| text.trim().is_empty()) {
1157            assert!(start.elapsed() < Duration::from_secs(10));
1158            std::thread::sleep(Duration::from_millis(10));
1159        }
1160        let child = std::fs::read_to_string(&pid_file)
1161            .unwrap()
1162            .trim()
1163            .to_string();
1164        drop(execution);
1165        // The receiver closes once the run is gone.
1166        let start = Instant::now();
1167        while events.recv_timeout(Duration::from_secs(10)).is_ok() {
1168            assert!(start.elapsed() < Duration::from_secs(10));
1169        }
1170        let start = Instant::now();
1171        while std::fs::read_to_string(format!("/proc/{child}/stat")).is_ok_and(|stat| {
1172            !stat
1173                .rsplit_once(')')
1174                .unwrap()
1175                .1
1176                .trim_start()
1177                .starts_with('Z')
1178        }) {
1179            assert!(
1180                start.elapsed() < Duration::from_secs(10),
1181                "the run kept going"
1182            );
1183            std::thread::sleep(Duration::from_millis(20));
1184        }
1185    }
1186
1187    #[test]
1188    fn missing_requirements_stop_a_run_before_it_starts() {
1189        let mut plan = plan(vec![command("one", "echo hi")]);
1190        plan.requirements = vec!["helixforge-no-such-tool".into()];
1191        let error = Execution::start(&plan, Path::new("/"), Path::new("/"), quiet())
1192            .err()
1193            .unwrap();
1194        assert!(matches!(error, EngineError::MissingExecutable(_)));
1195    }
1196
1197    #[test]
1198    fn the_handle_can_be_shared_between_threads() {
1199        fn shared<T: Send + Sync>() {}
1200        shared::<Execution>();
1201        fn sent<T: Send>() {}
1202        sent::<Receiver<ExecutionEvent>>();
1203    }
1204
1205    #[test]
1206    fn events_have_a_json_form() {
1207        let step = StepInfo {
1208            index: 0,
1209            id: "ripgrep".into(),
1210            name: "Ripgrep".into(),
1211        };
1212        let json = |event: &ExecutionEvent| serde_json::to_value(event).unwrap();
1213        assert_eq!(
1214            json(&ExecutionEvent::RunStarted {
1215                entries: vec![("ripgrep".into(), "Ripgrep".into())],
1216                steps: vec![step.clone()],
1217            }),
1218            serde_json::json!({
1219                "event": "run_started",
1220                "entries": [{"id": "ripgrep", "name": "Ripgrep"}],
1221                "steps": [{"index": 0, "id": "ripgrep", "name": "Ripgrep"}],
1222            })
1223        );
1224        assert_eq!(
1225            json(&ExecutionEvent::StepFailed {
1226                step: step.clone(),
1227                exit_code: 2,
1228                interrupted: false,
1229            }),
1230            serde_json::json!({
1231                "event": "step_failed",
1232                "step": {"index": 0, "id": "ripgrep", "name": "Ripgrep"},
1233                "exit_code": 2,
1234                "interrupted": false,
1235            })
1236        );
1237        assert_eq!(
1238            json(&ExecutionEvent::Output {
1239                offset: 5,
1240                bytes: b"hi\r\n".to_vec(),
1241            }),
1242            serde_json::json!({"event": "output", "offset": 5, "bytes": [104, 105, 13, 10]})
1243        );
1244        assert_eq!(
1245            json(&ExecutionEvent::Progress { ended: 1, total: 3 }),
1246            serde_json::json!({"event": "progress", "ended": 1, "total": 3})
1247        );
1248        assert_eq!(
1249            json(&ExecutionEvent::Warning("careful".into())),
1250            serde_json::json!({"event": "warning", "message": "careful"})
1251        );
1252        let finished = Finished {
1253            outcome: Outcome::Failed { exit_code: 1 },
1254            checks: vec![Check {
1255                name: "Ripgrep".into(),
1256                passed: false,
1257                detail: "rg is not installed".into(),
1258            }],
1259            steps: vec![StepResult {
1260                id: "ripgrep".into(),
1261                name: "Ripgrep".into(),
1262                state: StepState::Failed(1),
1263            }],
1264        };
1265        assert_eq!(
1266            json(&ExecutionEvent::Completed(finished.clone())),
1267            serde_json::json!({
1268                "event": "completed",
1269                "outcome": {"kind": "failed", "exit_code": 1},
1270                "description": finished.describe(),
1271                "succeeded": false,
1272                "verified": false,
1273                "steps": [{"index": 0, "id": "ripgrep", "name": "Ripgrep", "state": "failed", "exit_code": 1}],
1274                "checks": [{"name": "Ripgrep", "passed": false, "detail": "rg is not installed"}],
1275            })
1276        );
1277        let stopped = Finished {
1278            outcome: Outcome::Interrupted,
1279            checks: Vec::new(),
1280            steps: vec![StepResult {
1281                state: StepState::NotRun,
1282                ..finished.steps[0].clone()
1283            }],
1284        };
1285        let value = json(&ExecutionEvent::Interrupted(stopped));
1286        assert_eq!(value["event"], "interrupted");
1287        assert_eq!(value["outcome"], serde_json::json!({"kind": "interrupted"}));
1288        assert_eq!(value["steps"][0]["state"], "not_run");
1289    }
1290}