Skip to main content

only_engine/runtime/
mod.rs

1use anstyle::{AnsiColor as TermAnsiColor, Style as TermStyle};
2use std::collections::VecDeque;
3use std::fs::{File, OpenOptions, remove_file};
4use std::io::{self, Read, Seek, SeekFrom, Write};
5use std::path::PathBuf;
6use std::process::ExitCode;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::mpsc;
9use std::thread;
10
11use only_semantic::{ShellKind, ShellSelection};
12
13use crate::error::{command_block_failed, command_block_start_failed, command_failed};
14use crate::interpolate::{interpolate, interpolate_with_parts};
15use crate::process::{
16    OutputChunk, OutputStream, StdinAccess, TerminalBackend, TerminalContext,
17    begin_terminal_invocation,
18};
19use crate::shell::{run_command, run_command_inherit};
20use crate::{EngineError, ExecutionNode, ExecutionPlan, ExecutionStep, PlanParam};
21
22#[cfg(unix)]
23mod unix;
24#[cfg(windows)]
25mod windows;
26
27#[cfg(unix)]
28use unix as platform;
29#[cfg(windows)]
30use windows as platform;
31
32/// Runs a pre-built execution plan.
33///
34/// Args:
35/// plan: Dependency-expanded execution plan.
36///
37/// Returns:
38/// Success when all execution nodes complete successfully.
39pub fn run_plan(plan: &ExecutionPlan) -> Result<ExitCode, EngineError> {
40    run_plan_with_options(plan, RuntimeOptions::default())
41}
42
43/// Runtime options that affect only the task host, not command semantics.
44///
45/// Args:
46/// None.
47///
48/// Returns:
49/// Host display options used while executing an already-built plan.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub struct RuntimeOptions {
52    pub quiet: bool,
53    pub max_parallelism: Option<usize>,
54}
55
56/// Runs a pre-built execution plan with host display options.
57///
58/// Args:
59/// plan: Dependency-expanded execution plan.
60/// options: Host-side display options.
61///
62/// Returns:
63/// Success when all execution nodes complete successfully.
64pub fn run_plan_with_options(
65    plan: &ExecutionPlan,
66    options: RuntimeOptions,
67) -> Result<ExitCode, EngineError> {
68    let total_tasks = plan.nodes.len();
69    if total_tasks == 0 {
70        return Ok(ExitCode::SUCCESS);
71    }
72    if plan.successors.len() != total_tasks {
73        return Err(EngineError::Runtime(
74            "execution plan has invalid dependency edges".to_string(),
75        ));
76    }
77
78    let mut indegree = vec![0usize; total_tasks];
79    for successors in &plan.successors {
80        for &successor in successors {
81            if successor >= total_tasks {
82                return Err(EngineError::Runtime(
83                    "execution plan has invalid dependency edge".to_string(),
84                ));
85            }
86            indegree[successor] += 1;
87        }
88    }
89    let mut ready = (0..total_tasks)
90        .filter(|&index| indegree[index] == 0)
91        .collect::<VecDeque<_>>();
92    let limit = options.max_parallelism.unwrap_or(usize::MAX).max(1);
93    let (event_tx, event_rx) = mpsc::channel::<ExecutionEvent>();
94    let mut buffers = (0..total_tasks)
95        .map(|_| TaskOutputBuffer::default())
96        .collect::<Vec<_>>();
97    let mut progress_printed = vec![false; total_tasks];
98    let mut finished = vec![false; total_tasks];
99    let mut results = (0..total_tasks)
100        .map(|_| None)
101        .collect::<Vec<Option<TaskResult>>>();
102    let mut task_errors = (0..total_tasks)
103        .map(|_| None)
104        .collect::<Vec<Option<EngineError>>>();
105    let mut current_index = 0usize;
106    let mut finished_count = 0usize;
107    let mut active_count = 0usize;
108    let mut failure_seen = false;
109    let terminal = begin_terminal_invocation()?;
110    // Windows cannot both capture a task's output and give it a real console.
111    // Progress bars ask the child's own stderr for a width, which only answers
112    // when the child holds a console handle, so an attached Windows terminal
113    // trades ordered presentation for direct output.
114    let passthrough = cfg!(windows) && terminal.backend == TerminalBackend::Pty;
115
116    thread::scope(|scope| {
117        let mut handles = Vec::new();
118        while finished_count < total_tasks && (!failure_seen || active_count > 0) {
119            while !failure_seen && active_count < limit {
120                let Some(index) = ready.pop_front() else {
121                    break;
122                };
123                let node = plan.nodes[index].clone();
124                let working_dir = plan.working_dir.clone();
125                let shell = plan.shell.clone();
126                let event_tx = event_tx.clone();
127                let terminal = terminal.clone();
128                // Only the selected root may own the caller's stdin. Dependency
129                // tasks stay isolated even when the graph happens to run serially.
130                let stdin = if plan.successors[index].is_empty()
131                    && active_count == 0
132                    && (limit == 1 || ready.is_empty())
133                {
134                    StdinAccess::Owned
135                } else {
136                    StdinAccess::Closed
137                };
138                let direct = passthrough || stdin == StdinAccess::Owned;
139                handles.push(scope.spawn(move || {
140                    event_tx
141                        .send(ExecutionEvent::Started { task_index: index })
142                        .map_err(|_| {
143                            EngineError::Runtime("failed to start task presentation".to_string())
144                        })?;
145                    if direct {
146                        let (error, result) = match run_node_inherit(
147                            &node,
148                            &working_dir,
149                            shell.as_ref(),
150                            &terminal,
151                            stdin,
152                        ) {
153                            Ok(result) => result,
154                            Err(error) => (Some(error), None),
155                        };
156                        event_tx
157                            .send(ExecutionEvent::Finished {
158                                task_index: index,
159                                error,
160                                result,
161                            })
162                            .map_err(|_| {
163                                EngineError::Runtime("failed to finalize task output".to_string())
164                            })
165                    } else {
166                        run_node(
167                            index,
168                            &node,
169                            &working_dir,
170                            shell.as_ref(),
171                            terminal,
172                            event_tx,
173                        )
174                    }
175                }));
176                active_count += 1;
177            }
178            let event = event_rx.recv().map_err(|_| {
179                EngineError::Runtime("execution event channel closed unexpectedly".to_string())
180            })?;
181            match event {
182                ExecutionEvent::Started { task_index } => {
183                    // A passthrough task writes straight to the terminal, so its
184                    // marker has to land when it starts rather than when its turn
185                    // in the declaration order arrives.
186                    if passthrough || task_index == current_index {
187                        print_task_progress(
188                            task_index,
189                            total_tasks,
190                            plan,
191                            &mut progress_printed,
192                            options.quiet,
193                        )?;
194                    }
195                }
196                ExecutionEvent::Output { task_index, chunk } => {
197                    if task_index == current_index {
198                        print_task_progress(
199                            task_index,
200                            total_tasks,
201                            plan,
202                            &mut progress_printed,
203                            options.quiet,
204                        )?;
205                        print_output_chunk(&chunk)?;
206                    } else {
207                        buffers[task_index].push(chunk)?;
208                    }
209                }
210                ExecutionEvent::Finished {
211                    task_index,
212                    error,
213                    result,
214                } => {
215                    active_count -= 1;
216                    finished[task_index] = true;
217                    task_errors[task_index] = error;
218                    results[task_index] = result;
219                    finished_count += 1;
220                    if task_errors[task_index].is_some() {
221                        failure_seen = true;
222                    } else if !failure_seen {
223                        for &successor in &plan.successors[task_index] {
224                            indegree[successor] -= 1;
225                            if indegree[successor] == 0 {
226                                ready.push_back(successor);
227                            }
228                        }
229                    }
230                }
231            }
232
233            while current_index < total_tasks && finished[current_index] {
234                print_task_progress(
235                    current_index,
236                    total_tasks,
237                    plan,
238                    &mut progress_printed,
239                    options.quiet,
240                )?;
241                buffers[current_index].flush()?;
242                let task_error = task_errors[current_index].take();
243                match results[current_index].take() {
244                    Some(TaskResult::Pass(message)) if !options.quiet => {
245                        print_task_message(&message, false)?
246                    }
247                    Some(TaskResult::Fail(message)) => {
248                        print_task_message(&message, true)?;
249                    }
250                    Some(TaskResult::Pass(_)) | None => {}
251                }
252                task_errors[current_index] = task_error;
253                current_index += 1;
254            }
255
256            if current_index < total_tasks && !buffers[current_index].is_empty() {
257                print_task_progress(
258                    current_index,
259                    total_tasks,
260                    plan,
261                    &mut progress_printed,
262                    options.quiet,
263                )?;
264                buffers[current_index].flush()?;
265            }
266        }
267        drop(event_tx);
268        for handle in handles {
269            if let Err(payload) = handle.join() {
270                std::panic::resume_unwind(payload);
271            }
272        }
273        Ok::<(), EngineError>(())
274    })?;
275
276    task_errors
277        .into_iter()
278        .find_map(|error| error)
279        .map_or(Ok(ExitCode::SUCCESS), Err)
280}
281
282fn run_node(
283    task_index: usize,
284    node: &ExecutionNode,
285    working_dir: &std::path::Path,
286    default_shell: Option<&ShellKind>,
287    terminal: TerminalContext,
288    event_tx: mpsc::Sender<ExecutionEvent>,
289) -> Result<(), EngineError> {
290    let (output_tx, output_rx) = mpsc::channel::<OutputChunk>();
291    let forwarder = thread::spawn({
292        let event_tx = event_tx.clone();
293        move || -> Result<(), EngineError> {
294            while let Ok(chunk) = output_rx.recv() {
295                event_tx
296                    .send(ExecutionEvent::Output { task_index, chunk })
297                    .map_err(|_| {
298                        EngineError::Runtime("failed to forward task output".to_string())
299                    })?;
300            }
301            Ok(())
302        }
303    });
304
305    let total_steps = node.steps.len();
306    let mut task_error = None;
307
308    for (index, step) in node.steps.iter().enumerate() {
309        let rendered = match interpolate_step(step, &node.params) {
310            Ok(rendered) => rendered,
311            Err(error) => {
312                task_error = Some(error);
313                break;
314            }
315        };
316
317        let shell = select_shell(node, default_shell);
318        let status = match run_command(&rendered, working_dir, &shell, output_tx.clone(), &terminal)
319        {
320            Ok(status) => status,
321            Err(error) => {
322                task_error = Some(if step.is_block() {
323                    command_block_start_failed(shell.kind.as_str(), error)
324                } else {
325                    error
326                });
327                break;
328            }
329        };
330
331        if let Some(reason) = status.failure_reason() {
332            task_error = Some(if step.is_block() {
333                command_block_failed(&node.name, index + 1, total_steps, reason)
334            } else {
335                command_failed(&node.name, index + 1, total_steps, &rendered, reason)
336            });
337            break;
338        }
339    }
340
341    drop(output_tx);
342    match forwarder.join() {
343        Ok(Ok(())) => {}
344        Ok(Err(error)) => {
345            if task_error.is_none() {
346                task_error = Some(error);
347            }
348        }
349        Err(_) => {
350            if task_error.is_none() {
351                task_error = Some(EngineError::Runtime(
352                    "task output forwarder thread panicked".to_string(),
353                ));
354            }
355        }
356    }
357
358    let result = match task_result_message(node, task_error.is_none()) {
359        Ok(message) => message.map(|message| {
360            if task_error.is_none() {
361                TaskResult::Pass(message)
362            } else {
363                TaskResult::Fail(message)
364            }
365        }),
366        Err(error) => {
367            if task_error.is_none() {
368                task_error = Some(error);
369            }
370            None
371        }
372    };
373
374    event_tx
375        .send(ExecutionEvent::Finished {
376            task_index,
377            error: task_error,
378            result,
379        })
380        .map_err(|_| EngineError::Runtime("failed to finalize task output".to_string()))?;
381    Ok(())
382}
383
384fn run_node_inherit(
385    node: &ExecutionNode,
386    working_dir: &std::path::Path,
387    default_shell: Option<&ShellKind>,
388    terminal: &TerminalContext,
389    stdin: StdinAccess,
390) -> Result<(Option<EngineError>, Option<TaskResult>), EngineError> {
391    let total_steps = node.steps.len();
392    let execution = (|| {
393        for (index, step) in node.steps.iter().enumerate() {
394            let rendered = interpolate_step(step, &node.params)?;
395            let shell = select_shell(node, default_shell);
396            let status = run_command_inherit(&rendered, working_dir, &shell, terminal, stdin)
397                .map_err(|error| {
398                    if step.is_block() {
399                        command_block_start_failed(shell.kind.as_str(), error)
400                    } else {
401                        error
402                    }
403                })?;
404
405            if let Some(reason) = status.failure_reason() {
406                return Err(if step.is_block() {
407                    command_block_failed(&node.name, index + 1, total_steps, reason)
408                } else {
409                    command_failed(&node.name, index + 1, total_steps, &rendered, reason)
410                });
411            }
412        }
413        Ok::<(), EngineError>(())
414    })();
415
416    match execution {
417        Ok(()) => Ok((None, task_result_message(node, true)?.map(TaskResult::Pass))),
418        Err(error) => Ok((
419            Some(error),
420            task_result_message(node, false)?.map(TaskResult::Fail),
421        )),
422    }
423}
424
425fn interpolate_step(step: &ExecutionStep, params: &[PlanParam]) -> Result<String, EngineError> {
426    match step {
427        ExecutionStep::Command {
428            source,
429            interpolations,
430        }
431        | ExecutionStep::CommandBlock {
432            source,
433            interpolations,
434            ..
435        } => interpolate_with_parts(source, interpolations, params),
436    }
437}
438
439fn select_shell(node: &ExecutionNode, default_shell: Option<&ShellKind>) -> ShellSelection {
440    node.shell.clone().unwrap_or_else(|| {
441        ShellSelection::required(default_shell.cloned().unwrap_or(ShellKind::Deno))
442    })
443}
444
445#[derive(Debug)]
446enum ExecutionEvent {
447    Started {
448        task_index: usize,
449    },
450    Output {
451        task_index: usize,
452        chunk: OutputChunk,
453    },
454    Finished {
455        task_index: usize,
456        error: Option<EngineError>,
457        result: Option<TaskResult>,
458    },
459}
460
461fn print_task_progress(
462    task_index: usize,
463    total_tasks: usize,
464    plan: &ExecutionPlan,
465    progress_printed: &mut [bool],
466    quiet: bool,
467) -> Result<(), EngineError> {
468    if quiet || progress_printed[task_index] {
469        return Ok(());
470    }
471
472    let rendered = format!(
473        "{}\n",
474        render_task_progress(task_index + 1, total_tasks, &plan.nodes[task_index].name)
475    );
476    write_stderr(rendered.as_bytes())?;
477    progress_printed[task_index] = true;
478    Ok(())
479}
480
481#[derive(Debug)]
482enum TaskResult {
483    Pass(String),
484    Fail(String),
485}
486
487fn task_result_message(node: &ExecutionNode, success: bool) -> Result<Option<String>, EngineError> {
488    let source = if success {
489        node.pass.as_deref()
490    } else {
491        node.fail.as_deref()
492    };
493    source
494        .map(|text| interpolate(text, &node.result_params))
495        .transpose()
496}
497
498fn print_task_message(message: &str, failure: bool) -> Result<(), EngineError> {
499    let style = TermStyle::new()
500        .fg_color(Some(if failure {
501            TermAnsiColor::BrightRed.into()
502        } else {
503            TermAnsiColor::BrightGreen.into()
504        }))
505        .bold();
506    let rendered = format!("{}{}{}\n", style.render(), message, style.render_reset());
507    write_stderr(rendered.as_bytes())
508}
509
510const OUTPUT_MEMORY_LIMIT: usize = 1024 * 1024;
511static NEXT_BUFFER_ID: AtomicU64 = AtomicU64::new(0);
512
513#[derive(Default)]
514struct TaskOutputBuffer {
515    chunks: Vec<OutputChunk>,
516    memory_bytes: usize,
517    spill: Option<SpillFile>,
518}
519
520impl TaskOutputBuffer {
521    fn is_empty(&self) -> bool {
522        self.chunks.is_empty() && self.spill.is_none()
523    }
524
525    fn push(&mut self, chunk: OutputChunk) -> Result<(), EngineError> {
526        if let Some(spill) = &mut self.spill {
527            return spill.write_chunk(&chunk);
528        }
529
530        if self.memory_bytes + chunk.bytes.len() <= OUTPUT_MEMORY_LIMIT {
531            self.memory_bytes += chunk.bytes.len();
532            self.chunks.push(chunk);
533            return Ok(());
534        }
535
536        let mut spill = SpillFile::create()?;
537        for buffered in self.chunks.drain(..) {
538            spill.write_chunk(&buffered)?;
539        }
540        self.memory_bytes = 0;
541        spill.write_chunk(&chunk)?;
542        self.spill = Some(spill);
543        Ok(())
544    }
545
546    fn flush(&mut self) -> Result<(), EngineError> {
547        if let Some(spill) = self.spill.take() {
548            spill.replay()?;
549        } else {
550            for chunk in self.chunks.drain(..) {
551                print_output_chunk(&chunk)?;
552            }
553        }
554        self.memory_bytes = 0;
555        Ok(())
556    }
557}
558
559struct SpillFile {
560    file: File,
561    path: PathBuf,
562}
563
564impl SpillFile {
565    fn create() -> Result<Self, EngineError> {
566        let id = NEXT_BUFFER_ID.fetch_add(1, Ordering::Relaxed);
567        let path =
568            std::env::temp_dir().join(format!("only-output-{}-{id}.tmp", std::process::id()));
569        let file = OpenOptions::new()
570            .create_new(true)
571            .read(true)
572            .write(true)
573            .open(&path)
574            .map_err(|error| {
575                EngineError::Runtime(format!("failed to create output buffer: {error}"))
576            })?;
577        Ok(Self { file, path })
578    }
579
580    fn write_chunk(&mut self, chunk: &OutputChunk) -> Result<(), EngineError> {
581        self.file
582            .seek(SeekFrom::End(0))
583            .and_then(|_| {
584                self.file.write_all(&[match chunk.stream {
585                    OutputStream::Stdout => 0,
586                    OutputStream::Stderr => 1,
587                }])
588            })
589            .and_then(|_| {
590                self.file
591                    .write_all(&(chunk.bytes.len() as u64).to_le_bytes())
592            })
593            .and_then(|_| self.file.write_all(&chunk.bytes))
594            .map_err(|error| EngineError::Runtime(format!("failed to buffer task output: {error}")))
595    }
596
597    fn replay(mut self) -> Result<(), EngineError> {
598        self.file.seek(SeekFrom::Start(0)).map_err(|error| {
599            EngineError::Runtime(format!("failed to rewind output buffer: {error}"))
600        })?;
601        loop {
602            let mut stream = [0u8; 1];
603            match self.file.read_exact(&mut stream) {
604                Ok(()) => {}
605                Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break,
606                Err(error) => {
607                    return Err(EngineError::Runtime(format!(
608                        "failed to read output buffer: {error}"
609                    )));
610                }
611            }
612            let mut length = [0u8; 8];
613            self.file.read_exact(&mut length).map_err(|error| {
614                EngineError::Runtime(format!("failed to read output buffer: {error}"))
615            })?;
616            let mut bytes = vec![0u8; u64::from_le_bytes(length) as usize];
617            self.file.read_exact(&mut bytes).map_err(|error| {
618                EngineError::Runtime(format!("failed to read output buffer: {error}"))
619            })?;
620            print_output_chunk(&OutputChunk {
621                stream: if stream[0] == 0 {
622                    OutputStream::Stdout
623                } else {
624                    OutputStream::Stderr
625                },
626                bytes,
627            })?;
628        }
629        Ok(())
630    }
631}
632
633impl Drop for SpillFile {
634    fn drop(&mut self) {
635        let _ = remove_file(&self.path);
636    }
637}
638
639fn print_output_chunk(chunk: &OutputChunk) -> Result<(), EngineError> {
640    match chunk.stream {
641        OutputStream::Stdout => write_stdout(&chunk.bytes),
642        OutputStream::Stderr => write_stderr(&chunk.bytes),
643    }
644}
645
646fn write_stdout(content: &[u8]) -> Result<(), EngineError> {
647    platform::write_stdout(content)
648}
649
650fn write_stderr(content: &[u8]) -> Result<(), EngineError> {
651    platform::write_stderr(content)
652}
653
654fn render_task_progress(task_index: usize, total_tasks: usize, task_name: &str) -> String {
655    let label_style = TermStyle::new()
656        .fg_color(Some(TermAnsiColor::BrightGreen.into()))
657        .bold();
658    let task_style = TermStyle::new()
659        .fg_color(Some(TermAnsiColor::BrightCyan.into()))
660        .bold();
661
662    format!(
663        "{}[{}/{}]{} {}{}{}",
664        label_style.render(),
665        task_index,
666        total_tasks,
667        label_style.render_reset(),
668        task_style.render(),
669        task_name,
670        task_style.render_reset()
671    )
672}