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