1use 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
40pub const DEFAULT_CAPACITY: usize = 256;
42
43pub const OUTPUT_CHUNK: usize = 64 * 1024;
45
46const TICK: Duration = Duration::from_millis(10);
48
49const DRAIN: Duration = Duration::from_millis(250);
52
53#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct StepInfo {
56 pub index: usize,
58 pub id: String,
59 pub name: String,
60}
61
62#[derive(Clone, Debug, Eq, PartialEq)]
64pub enum ExecutionEvent {
65 RunStarted {
67 entries: Vec<(String, String)>,
68 steps: Vec<StepInfo>,
69 },
70 StepStarted(StepInfo),
71 StepCompleted(StepInfo),
72 StepFailed {
75 step: StepInfo,
76 exit_code: u32,
77 interrupted: bool,
78 },
79 StepSkipped(StepInfo),
81 Output {
84 offset: u64,
85 bytes: Vec<u8>,
86 },
87 Progress {
89 ended: usize,
90 total: usize,
91 },
92 Warning(String),
94 Completed(Finished),
97 Interrupted(Finished),
99}
100
101#[derive(Clone, Debug, Eq, PartialEq)]
103pub enum HistoryPlace {
104 Default,
106 In(PathBuf),
108 Off,
110}
111
112#[derive(Clone, Debug)]
114pub struct Options {
115 pub rows: u16,
117 pub cols: u16,
118 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
134pub struct Execution {
137 shared: Arc<Shared>,
138}
139
140struct Shared {
141 session: Mutex<Session>,
142 plan: Plan,
143 status: Option<PathBuf>,
145 finished: OnceLock<Finished>,
147 history: Mutex<Book>,
148 closed: AtomicBool,
150}
151
152struct Book {
154 place: HistoryPlace,
155 pending: Option<history::Pending>,
156 recorded: bool,
157}
158
159impl Execution {
160 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 pub fn write(&self, bytes: &[u8]) {
238 self.shared.session.lock().unwrap().write(bytes);
239 }
240
241 pub fn resize(&self, rows: u16, cols: u16) {
243 self.shared.session.lock().unwrap().resize(rows, cols);
244 }
245
246 pub fn stop(&self) {
250 self.shared.session.lock().unwrap().kill();
251 }
252
253 pub fn stop_requested(&self) -> bool {
255 self.shared.session.lock().unwrap().stop_requested()
256 }
257
258 pub fn survivors(&self) -> usize {
261 self.shared.session.lock().unwrap().survivors()
262 }
263
264 pub fn is_finished(&self) -> bool {
267 self.shared.finished.get().is_some()
268 }
269
270 pub fn finished(&self) -> Option<Finished> {
272 self.shared.finished.get().cloned()
273 }
274
275 pub fn transcript(&self) -> Vec<u8> {
278 let transcript = self.shared.session.lock().unwrap().transcript();
279 transcript.lock().unwrap().clone()
280 }
281
282 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 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
347fn 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 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 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
423fn 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
444struct StepFile {
446 path: Option<PathBuf>,
447 read: usize,
449 ended: usize,
450}
451
452impl StepFile {
453 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 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
513struct Outlet {
516 sender: SyncSender<ExecutionEvent>,
517 connected: bool,
518 sent: usize,
520 queue: VecDeque<(usize, ExecutionEvent)>,
523 transcript: Arc<Mutex<Vec<u8>>>,
524}
525
526impl Outlet {
527 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 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 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 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#[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 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
745impl 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 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 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 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 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 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 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 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 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}