char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somdeslop(task): unify watchset + glob machinerya29d09f

main
33.2 KiB881 linesraw
1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::process::ExitCode;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, mpsc};
6use std::time::{Duration, Instant};
7
8use avril_runtime::config::{Error as ConfigError, ErrorKind, Span};
9use avril_runtime::package_config::{PackageConfig, Task};
10use avril_subprocess::Scope;
11use avril_task::{
12    Relevance, WatchContext, WatchEvent, WatchSet, WatchState, external_roots, forget_directories,
13    known_directories, remember_directories, resolution_paths, resolved_input_paths, shell_quote,
14};
15
16use crate::cli::ParseError;
17
18type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
19type TaskResult<T> = std::result::Result<T, TaskError>;
20
21#[derive(Debug, thiserror::Error)]
22enum TaskError {
23    #[error("{0}")]
24    Message(String),
25    #[error(transparent)]
26    Io(#[from] std::io::Error),
27}
28
29impl From<String> for TaskError {
30    fn from(message: String) -> Self {
31        Self::Message(message)
32    }
33}
34
35struct Loaded {
36    config: PackageConfig,
37    graph: avril_task::Graph,
38    source: String,
39    path: PathBuf,
40    root: PathBuf,
41}
42
43fn load(cwd: &Path) -> Result<Loaded> {
44    let path = cwd
45        .ancestors()
46        .map(|directory| directory.join("avril.conf"))
47        .find(|candidate| candidate.is_file())
48        .ok_or_else(|| {
49            format!(
50                "No avril.conf found in {} or any parent directory",
51                cwd.display()
52            )
53        })?;
54    load_file(path)
55}
56
57fn load_file(path: PathBuf) -> Result<Loaded> {
58    let source = std::fs::read_to_string(&path)?;
59    let base = url::Url::from_file_path(&path).map_err(|_| "Config paths must be absolute")?;
60    let config = PackageConfig::parse(&source, &base)
61        .map_err(|error| error.render(&path.to_string_lossy(), &source))?;
62    let root = path.parent().unwrap().to_path_buf();
63    let graph = to_graph(&config.tasks);
64    Ok(Loaded {
65        config,
66        graph,
67        source,
68        path,
69        root,
70    })
71}
72
73fn list(tasks: &BTreeMap<String, Task>) -> String {
74    if tasks.is_empty() {
75        return "No tasks are defined in avril.conf".into();
76    }
77    let mut text = String::from("Available tasks:");
78    for (name, task) in tasks {
79        text.push_str(&format!("\n- {name}"));
80        if let Some(description) = &task.description {
81            text.push_str(&format!("\n    {description}"));
82        } else if let Some(command) = &task.command {
83            text.push_str(&format!("\n    {}", command.display()));
84        } else {
85            let dependencies: Vec<_> = task
86                .dependencies
87                .iter()
88                .map(|dependency| dependency.name.as_str())
89                .collect();
90            text.push_str(&format!("\n    dependencies: {}", dependencies.join(", ")));
91        }
92    }
93    text
94}
95
96pub struct Args {
97    watch: bool,
98    name: Option<String>,
99    extra: Vec<String>,
100}
101
102/// `avril task` argument parsing failed.
103#[derive(Debug, thiserror::Error)]
104pub enum TaskParseError {
105    #[error(transparent)]
106    Generic(#[from] ParseError),
107}
108
109fn task_usage() -> TaskParseError {
110    ParseError::Usage(
111        crate::cli::command("task")
112            .map(|command| command.usage())
113            .unwrap_or_else(crate::cli::usage),
114    )
115    .into()
116}
117
118pub fn parse(args: &[String]) -> std::result::Result<Args, TaskParseError> {
119    let (watch, rest) = match args.first().map(String::as_str) {
120        Some("--watch") => (true, &args[1..]),
121        _ => (false, args),
122    };
123    let Some(name) = rest.first() else {
124        if watch {
125            return Err(task_usage());
126        }
127        return Ok(Args {
128            watch,
129            name: None,
130            extra: Vec::new(),
131        });
132    };
133    if name.starts_with('-') {
134        return Err(task_usage());
135    }
136    Ok(Args {
137        watch,
138        name: Some(name.clone()),
139        extra: rest[1..].to_vec(),
140    })
141}
142
143pub fn run(args: Args) -> Result<ExitCode> {
144    let cwd = std::env::current_dir()?.canonicalize()?;
145    let Args { watch, name, extra } = args;
146    let Some(name) = name else {
147        let loaded = load(&cwd)?;
148        println!("{}", list(&loaded.config.tasks));
149        return Ok(ExitCode::SUCCESS);
150    };
151    let loaded = load(&cwd)?;
152    if !loaded.config.tasks.contains_key(&name) {
153        return Err(format!("Unknown task {name:?}\n\n{}", list(&loaded.config.tasks)).into());
154    }
155    let mut extra = extra.as_slice();
156    if extra.first().is_some_and(|arg| arg == "--") {
157        extra = &extra[1..];
158    }
159    let host = avril_host::Host::new()?;
160    let handle = host.handle();
161    let _enter = handle.enter();
162    if watch {
163        return watch_run(&cwd, loaded, &name, extra);
164    }
165    match execute_graph(&loaded, &cwd, &name, extra, None, false) {
166        Ok(code) => Ok(ExitCode::from(code)),
167        Err(error) => Err(error.into()),
168    }
169}
170
171const DEBOUNCE: Duration = Duration::from_millis(50);
172
173static SHUTDOWN: AtomicBool = AtomicBool::new(false);
174
175#[cfg(unix)]
176fn install_shutdown_handler() -> Result<()> {
177    extern "C" fn handler(_signal: libc::c_int) {
178        SHUTDOWN.store(true, Ordering::SeqCst);
179    }
180    for signal in [libc::SIGINT, libc::SIGTERM] {
181        if unsafe { libc::signal(signal, handler as *const () as libc::sighandler_t) }
182            == libc::SIG_ERR
183        {
184            return Err(std::io::Error::last_os_error().into());
185        }
186    }
187    Ok(())
188}
189
190#[cfg(not(unix))]
191fn install_shutdown_handler() -> Result<()> {
192    ctrlc::set_handler(|| SHUTDOWN.store(true, Ordering::SeqCst))?;
193    Ok(())
194}
195
196fn create_watcher(
197    loaded: &Loaded,
198    watch_set: &WatchSet,
199    extra: &[PathBuf],
200    sender: mpsc::Sender<notify::Result<notify::Event>>,
201) -> Result<notify::RecommendedWatcher> {
202    use notify::Watcher;
203    let config = notify::Config::default().with_follow_symlinks(false);
204    let mut watcher = notify::RecommendedWatcher::new(
205        move |result| {
206            let _ = sender.send(result);
207        },
208        config,
209    )?;
210    watcher.watch(&loaded.root, notify::RecursiveMode::Recursive)?;
211    for parent in external_roots(&loaded.root, watch_set, extra) {
212        watcher.watch(&parent, notify::RecursiveMode::NonRecursive)?;
213    }
214    Ok(watcher)
215}
216
217/// One watched run: its thread (while still tracked) and the control
218/// owning its process groups. A generation whose thread cannot be joined
219/// yet is retired with its handle rather than joined unconditionally.
220struct Generation {
221    handle: Option<std::thread::JoinHandle<TaskResult<u8>>>,
222    control: Arc<Scope>,
223    forced: bool,
224}
225
226impl Generation {
227    /// Escalates INT, TERM, KILL against a generation's groups. Returns whether
228    /// everything settled: the run thread (if still tracked) finished and no
229    /// group has live members left. Settling is bounded even after KILL, so a
230    /// member stuck in uninterruptible sleep cannot hang the watcher.
231    fn stop(&mut self, name: &str) -> bool {
232        if !self.control.terminate(|| {
233            self.handle
234                .as_ref()
235                .is_none_or(|handle| handle.is_finished())
236        }) {
237            eprintln!(
238                "Task {name}: some processes did not exit after SIGKILL; leaving them behind"
239            );
240            return false;
241        }
242        if let Some(handle) = self.handle.take() {
243            let _ = handle.join();
244        }
245        true
246    }
247}
248
249fn watch_run(cwd: &Path, loaded: Loaded, name: &str, extra: &[String]) -> Result<ExitCode> {
250    if cfg!(not(any(
251        target_os = "linux",
252        target_os = "android",
253        target_os = "macos",
254        windows
255    ))) {
256        return Err("Task --watch is not supported on this platform".into());
257    }
258    let watch_set = loaded
259        .graph
260        .watch_set(name, &loaded.root)
261        .map_err(|error| render_task_error(&loaded, error))?;
262    if watch_set.patterns.is_empty() {
263        return Err(format!(
264            "Task {name:?} cannot be watched: neither it nor its dependencies declare 'files'"
265        )
266        .into());
267    }
268    install_shutdown_handler()?;
269    let mut loaded = Arc::new(loaded);
270    let mut watch_set = watch_set;
271    let (sender, receiver) = mpsc::channel();
272    let mut config_paths = resolution_paths(&loaded.path);
273    let mut input_paths = resolved_input_paths(&loaded.root, &watch_set);
274    let linked: Vec<PathBuf> = config_paths.iter().chain(&input_paths).cloned().collect();
275    let mut _watcher = create_watcher(&loaded, &watch_set, &linked, sender.clone())?;
276    eprintln!("Task {name}: watching for changes");
277    let mut baseline = WatchState::capture(
278        &loaded.graph,
279        &loaded.root,
280        &loaded.source,
281        name,
282        extra,
283        &watch_set,
284    );
285
286    macro_rules! start_run {
287        ($force:expr) => {{
288            let control = Arc::new(Scope::new(
289                &std::env::current_exe()?,
290                vec![
291                    ("SIGINT".into(), Duration::from_secs(1)),
292                    ("SIGTERM".into(), Duration::from_secs(1)),
293                ],
294            )?);
295            let run_runtime = tokio::runtime::Handle::current();
296            let run_loaded = loaded.clone();
297            let run_cwd = cwd.to_path_buf();
298            let run_name = name.to_owned();
299            let run_extra = extra.to_vec();
300            let run_control = control.clone();
301            let force = $force;
302            let handle = std::thread::spawn(move || {
303                let _enter = run_runtime.enter();
304                execute_graph(
305                    &run_loaded,
306                    &run_cwd,
307                    &run_name,
308                    &run_extra,
309                    Some(run_control),
310                    force,
311                )
312            });
313            Some(Generation {
314                handle: Some(handle),
315                control,
316                forced: force,
317            })
318        }};
319    }
320
321    let mut active = start_run!(false);
322    // Generations whose thread finished but whose process groups may
323    // still hold background processes; cleaned up before the next run
324    // and at shutdown, not silently dropped.
325    let mut retired: Vec<Generation> = Vec::new();
326    // A forced (cache-bypassing) refresh was launched but not yet seen to
327    // complete uncancelled; the obligation carries over to the next run.
328    let mut force_pending = false;
329    let mut pending = false;
330    let mut rewatch_needed = false;
331    // When the next watcher-repair retry is due, if one is scheduled.
332    // Set only by failed installations, consumed when it fires, and
333    // cleared by any successful one: it cannot bypass a fresh event's
334    // debounce or spin against a permanently broken config.
335    let mut rewatch_retry_at: Option<Instant> = None;
336    let mut installed_external = external_roots(&loaded.root, &watch_set, &linked);
337    let mut known_dirs = known_directories(&loaded.root);
338    let mut last_event = Instant::now();
339
340    // Coverage repair (config symlink target, external topology) is
341    // independent of whether the config parses and of content debounce:
342    // it only (re)installs watches by path.
343    macro_rules! repair_coverage {
344        () => {{
345            // One installation, against the candidate config paths: a
346            // successful rebuild with stale paths must not be able to
347            // erase a failed candidate repair's obligation.
348            let candidate_config = resolution_paths(&loaded.path);
349            if candidate_config != config_paths || rewatch_needed {
350                let linked: Vec<PathBuf> = candidate_config
351                    .iter()
352                    .chain(&input_paths)
353                    .cloned()
354                    .collect();
355                match create_watcher(&loaded, &watch_set, &linked, sender.clone()) {
356                    Ok(fresh_watcher) => {
357                        _watcher = fresh_watcher;
358                        config_paths = candidate_config;
359                        installed_external = external_roots(&loaded.root, &watch_set, &linked);
360                        rewatch_needed = false;
361                        rewatch_retry_at = None;
362                    }
363                    Err(error) => {
364                        rewatch_retry_at = Some(Instant::now() + Duration::from_secs(1));
365                        eprintln!("Task {name}: cannot rewatch: {error}")
366                    }
367                }
368            }
369        }};
370    }
371
372    loop {
373        if SHUTDOWN.load(Ordering::SeqCst) {
374            if let Some(mut generation) = active.take()
375                && !generation.stop(name)
376            {
377                retired.push(generation);
378            }
379            retired.retain_mut(|generation| !generation.stop(name));
380            for generation in &retired {
381                generation.control.force_close();
382            }
383            return Ok(ExitCode::SUCCESS);
384        }
385
386        // Drain in bounded batches so an event flood cannot starve the
387        // shutdown check above.
388        for _ in 0..1024 {
389            match receiver.try_recv() {
390                Ok(Ok(event)) => {
391                    remember_directories(&loaded.root, &mut known_dirs, &event.paths);
392                    let relevance = if event.need_rescan() {
393                        // Content may have been missed and directory watches may be
394                        // gone with it: rebuild the watcher, not just the state.
395                        Some(Relevance { evaluate: true, rewatch: true })
396                    } else if matches!(event.kind, notify::EventKind::Access(_)) {
397                        None
398                    } else {
399                        watch_set.classify(
400                            WatchContext {
401                                root: &loaded.root,
402                                config_path: &loaded.path,
403                                config_paths: &config_paths,
404                                input_paths: &input_paths,
405                                known_dirs: &known_dirs,
406                            },
407                            WatchEvent {
408                                paths: &event.paths,
409                                structural: matches!(
410                                    event.kind,
411                                    notify::EventKind::Create(_)
412                                        | notify::EventKind::Remove(_)
413                                        | notify::EventKind::Modify(
414                                            notify::event::ModifyKind::Name(_)
415                                        )
416                                        | notify::EventKind::Any
417                                        | notify::EventKind::Other
418                                ),
419                                has_directory: event.paths.iter().any(|path| path.is_dir()),
420                                is_folder_event: matches!(
421                                    event.kind,
422                                    notify::EventKind::Create(notify::event::CreateKind::Folder)
423                                        | notify::EventKind::Remove(
424                                            notify::event::RemoveKind::Folder
425                                        )
426                                ),
427                            },
428                        )
429                    };
430                    if let Some(relevance) = relevance {
431                        pending |= relevance.evaluate;
432                        rewatch_needed |= relevance.rewatch;
433                        last_event = Instant::now();
434                    }
435                    let gone: &[PathBuf] = match &event.kind {
436                        notify::EventKind::Remove(_) => &event.paths,
437                        notify::EventKind::Modify(notify::event::ModifyKind::Name(
438                            notify::event::RenameMode::From,
439                        )) => &event.paths,
440                        notify::EventKind::Modify(notify::event::ModifyKind::Name(
441                            notify::event::RenameMode::Both,
442                        )) => &event.paths[..1.min(event.paths.len())],
443                        _ => &[],
444                    };
445                    forget_directories(&loaded.root, &mut known_dirs, gone);
446                }
447                Ok(Err(error)) => {
448                    eprintln!("Task {name}: filesystem watcher error: {error}");
449                    pending = true;
450                    rewatch_needed = true;
451                    last_event = Instant::now();
452                }
453                Err(mpsc::TryRecvError::Empty) => break,
454                Err(mpsc::TryRecvError::Disconnected) => {
455                    return Err("Filesystem watcher stopped".into());
456                }
457            }
458        }
459
460        if active.as_ref().is_some_and(|generation| {
461            generation
462                .handle
463                .as_ref()
464                .is_some_and(|handle| handle.is_finished())
465        }) {
466            let mut generation = active.take().unwrap();
467            let forced = generation.forced;
468            let handle = generation.handle.take().unwrap();
469            match handle.join() {
470                Ok(Ok(0)) => {
471                    if forced {
472                        force_pending = false;
473                    }
474                }
475                Ok(Ok(code)) => {
476                    eprintln!("Task {name} exited with status {code}; watching for changes")
477                }
478                Ok(Err(error)) => eprintln!("{error}"),
479                Err(_) => eprintln!("Task {name}: task thread panicked"),
480            }
481            retired.push(generation);
482        }
483
484        let retry_due = rewatch_retry_at.is_some_and(|deadline| Instant::now() >= deadline);
485        let debouncing = pending && last_event.elapsed() < DEBOUNCE;
486        if retry_due {
487            rewatch_retry_at = None;
488        }
489        if retry_due && debouncing {
490            // A due repair must not let unfinished edits skip their quiet
491            // period: repair coverage now, evaluate content later.
492            if rewatch_needed {
493                repair_coverage!();
494            }
495        } else if (pending && !debouncing) || retry_due {
496            pending = false;
497            repair_coverage!();
498            // Evaluate against a freshly loaded config before touching a
499            // running task: an unchanged state (a touch, a same-content
500            // save) leaves it and the cache exactly as they were.
501            let mut committed = true;
502            match load_file(loaded.path.clone()) {
503                Ok(fresh) => match fresh
504                    .graph
505                    .watch_set(name, &fresh.root)
506                    .map_err(|error| render_task_error(&fresh, error))
507                {
508                    Ok(fresh_set) if !fresh_set.patterns.is_empty() => {
509                        if fresh.source != loaded.source || fresh_set != watch_set {
510                            // Commit nothing until the new set is really
511                            // being watched.
512                            let fresh_config = resolution_paths(&fresh.path);
513                            let fresh_inputs = resolved_input_paths(&fresh.root, &fresh_set);
514                            let fresh_extra: Vec<PathBuf> =
515                                fresh_config.iter().chain(&fresh_inputs).cloned().collect();
516                            match create_watcher(&fresh, &fresh_set, &fresh_extra, sender.clone()) {
517                                Ok(fresh_watcher) => {
518                                    loaded = Arc::new(fresh);
519                                    watch_set = fresh_set;
520                                    config_paths = fresh_config;
521                                    input_paths = fresh_inputs;
522                                    _watcher = fresh_watcher;
523                                    installed_external =
524                                        external_roots(&loaded.root, &watch_set, &fresh_extra);
525                                    rewatch_retry_at = None;
526                                }
527                                Err(error) => {
528                                    committed = false;
529                                    rewatch_retry_at =
530                                        Some(Instant::now() + Duration::from_secs(1));
531                                    eprintln!("Task {name}: cannot rewatch: {error}")
532                                }
533                            }
534                        }
535                    }
536                    Ok(_) => {
537                        committed = false;
538                        eprintln!(
539                            "Task {name:?} cannot be watched: neither it nor its dependencies declare 'files'"
540                        );
541                    }
542                    Err(error) => {
543                        committed = false;
544                        eprintln!("{error}")
545                    }
546                },
547                Err(error) => {
548                    committed = false;
549                    eprintln!("Task {name}: cannot reload avril.conf: {error}")
550                }
551            }
552
553            if committed {
554                known_dirs = known_directories(&loaded.root);
555                config_paths = resolution_paths(&loaded.path);
556                input_paths = resolved_input_paths(&loaded.root, &watch_set);
557                let linked: Vec<PathBuf> =
558                    config_paths.iter().chain(&input_paths).cloned().collect();
559                // Directory topology outside the package (an env file's
560                // parent appearing or being replaced) changes coverage
561                // without changing the config; reconcile it here.
562                let desired_external = external_roots(&loaded.root, &watch_set, &linked);
563                if rewatch_needed || desired_external != installed_external {
564                    match create_watcher(&loaded, &watch_set, &linked, sender.clone()) {
565                        Ok(fresh_watcher) => {
566                            _watcher = fresh_watcher;
567                            installed_external = desired_external;
568                            rewatch_needed = false;
569                            rewatch_retry_at = None;
570                        }
571                        Err(error) => {
572                            rewatch_needed = true;
573                            rewatch_retry_at = Some(Instant::now() + Duration::from_secs(1));
574                            eprintln!("Task {name}: cannot rewatch: {error}")
575                        }
576                    }
577                }
578
579                let state = WatchState::capture(
580                    &loaded.graph,
581                    &loaded.root,
582                    &loaded.source,
583                    name,
584                    extra,
585                    &watch_set,
586                );
587                if state != baseline {
588                    let force = force_pending || state.metadata_changed(&baseline);
589                    baseline = state;
590                    if let Some(mut generation) = active.take() {
591                        eprintln!("Task {name}: restarting");
592                        if !generation.stop(name) {
593                            retired.push(generation);
594                        }
595                    }
596                    retired.retain_mut(|generation| !generation.stop(name));
597                    if SHUTDOWN.load(Ordering::SeqCst) {
598                        continue;
599                    }
600                    if force {
601                        force_pending = true;
602                    }
603                    active = start_run!(force);
604                }
605            }
606        }
607
608        std::thread::sleep(Duration::from_millis(5));
609    }
610}
611
612// ------------------------------------------------------------- lib driver
613
614struct CliReporter;
615
616impl avril_task::Reporter for CliReporter {
617    fn cached(&self, name: &str) {
618        eprintln!("Task {name} (cached)");
619    }
620    fn unmatched_output(&self, name: &str, pattern: &str) {
621        eprintln!("Task {name}: output pattern {pattern:?} matched nothing");
622    }
623    fn cache_error(&self, name: &str, message: &str) {
624        eprintln!("Task {name}: cannot record cached outputs: {message}");
625    }
626}
627
628fn to_graph(tasks: &BTreeMap<String, Task>) -> avril_task::Graph {
629    avril_task::Graph {
630        tasks: tasks
631            .iter()
632            .map(|(name, task)| {
633                (
634                    name.clone(),
635                    avril_task::Task {
636                        description: task.description.clone(),
637                        action: match &task.command {
638                            None => avril_task::Action::None,
639                            Some(avril_runtime::package_config::TaskCommand::Shell(command)) => {
640                                avril_task::Action::Shell(command.clone())
641                            }
642                            Some(avril_runtime::package_config::TaskCommand::Argv(argv)) => {
643                                avril_task::Action::Argv(argv.clone())
644                            }
645                        },
646                        dependencies: task
647                            .dependencies
648                            .iter()
649                            .map(|dependency| dependency.name.clone())
650                            .collect(),
651                        parallel: task.parallel,
652                        env_file: task.env_file.as_ref().map(|file| PathBuf::from(&file.path)),
653                        files: task.files.as_ref().map(|patterns| {
654                            patterns
655                                .iter()
656                                .map(|pattern| pattern.pattern.clone())
657                                .collect()
658                        }),
659                        output: task
660                            .output
661                            .iter()
662                            .map(|pattern| pattern.pattern.clone())
663                            .collect(),
664                        env: task.env.clone(),
665                    },
666                )
667            })
668            .collect(),
669    }
670}
671
672/// Renders a lib/task error against the config, using the span of the
673/// task, dependency, or pattern it concerns when one is known.
674fn render_task_error(loaded: &Loaded, error: avril_task::Error) -> String {
675    let message = error.to_string();
676    let span = match &error {
677        avril_task::Error::UnknownTask(name) => loaded
678            .config
679            .tasks
680            .values()
681            .flat_map(|task| &task.dependencies)
682            .find(|dependency| &dependency.name == name)
683            .map(|dependency| dependency.span.clone())
684            .or_else(|| loaded.config.tasks.get(name).map(|task| task.span.clone())),
685        avril_task::Error::Cycle(cycle) => {
686            let names: Vec<_> = cycle.split(" -> ").collect();
687            names
688                .iter()
689                .rev()
690                .nth(1)
691                .and_then(|name| loaded.config.tasks.get(*name))
692                .and_then(|task| {
693                    task.dependencies
694                        .iter()
695                        .find(|dependency| Some(dependency.name.as_str()) == names.last().copied())
696                })
697                .map(|dependency| dependency.span.clone())
698        }
699        avril_task::Error::Env(avril_task::EnvError::Load { path, .. }) => loaded
700            .config
701            .tasks
702            .values()
703            .filter_map(|task| task.env_file.as_ref())
704            .find(|file| loaded.root.join(&file.path) == *path)
705            .map(|file| file.span.clone()),
706        avril_task::Error::UnmatchedFiles { task, pattern }
707        | avril_task::Error::Pattern { task, pattern, .. } => loaded
708            .config
709            .tasks
710            .get(task)
711            .and_then(|task| task.files.as_ref())
712            .and_then(|patterns| {
713                patterns
714                    .iter()
715                    .find(|candidate| &candidate.pattern == pattern)
716                    .map(|candidate| candidate.span.clone())
717            }),
718        _ => None,
719    };
720    match span {
721        Some(span) => render_error(loaded, span, message),
722        None => message,
723    }
724}
725
726fn execute_graph(
727    loaded: &Loaded,
728    cwd: &Path,
729    name: &str,
730    extra: &[String],
731    control: Option<Arc<Scope>>,
732    force: bool,
733) -> TaskResult<u8> {
734    let cancelled = avril_task::CancelToken::new();
735    let mut options = avril_task::RunOptions::new(loaded.root.clone());
736    options.cache = Some(avril_task::Cache::new(
737        crate::cli::default_cache(cwd).join("task-cache"),
738    ));
739    options.force = force;
740    options.extra_args = extra.to_vec();
741    options.reporter = Box::new(CliReporter);
742    options.cancelled = cancelled.clone();
743    let runtime = tokio::runtime::Handle::current();
744    let mut execution = avril_task::Execution::new(loaded.graph.clone(), name, options)
745        .map_err(|error| render_task_error(loaded, error))?;
746    loop {
747        if control
748            .as_ref()
749            .is_some_and(|control| control.is_cancelled())
750        {
751            cancelled.cancel();
752        }
753        match execution
754            .step()
755            .map_err(|error| render_task_error(loaded, error))?
756        {
757            avril_task::Step::Done(report) => return Ok(report.code),
758            avril_task::Step::Ready(batch) => {
759                if batch.is_empty() {
760                    return Err(TaskError::Message("Task execution stalled".into()));
761                }
762                let results = std::thread::scope(|scope| {
763                    let handles: Vec<_> = batch
764                        .iter()
765                        .map(|ready| {
766                            let control = control.clone();
767                            let runtime = runtime.clone();
768                            scope.spawn(move || run_ready(loaded, ready, cwd, runtime, control))
769                        })
770                        .collect();
771                    handles
772                        .into_iter()
773                        .map(|handle| handle.join().map_err(|_| "Task thread panicked".to_owned()))
774                        .collect::<Vec<_>>()
775                });
776                for (ready, result) in batch.iter().zip(results) {
777                    let code = result??;
778                    execution.finish(&ready.name, code);
779                }
780            }
781        }
782    }
783}
784
785fn run_ready(
786    loaded: &Loaded,
787    ready: &avril_task::ReadyTask,
788    cwd: &Path,
789    runtime: tokio::runtime::Handle,
790    control: Option<Arc<Scope>>,
791) -> TaskResult<u8> {
792    let name = &ready.name;
793    let directory = loaded.root.as_path();
794    let mut options = match &ready.action {
795        avril_task::Action::Shell(command) => {
796            let mut full = command.clone();
797            for arg in &ready.args {
798                full.push(' ');
799                full.push_str(&shell_quote(arg));
800            }
801            let (shell, flag) = if cfg!(windows) {
802                ("cmd", "/C")
803            } else {
804                ("sh", "-c")
805            };
806            let mut options = avril_subprocess::Options::new(shell, vec![flag.into(), full]);
807            #[cfg(unix)]
808            {
809                options.command = shell.into();
810            }
811            options
812        }
813        avril_task::Action::Argv(argv) => {
814            let Some((program, args)) = argv.split_first() else {
815                return Err(format!("Task {name:?}: command array cannot be empty").into());
816            };
817            let mut args: Vec<String> = args.to_vec();
818            args.extend(ready.args.iter().cloned());
819            avril_subprocess::Options::new(program, args)
820        }
821        avril_task::Action::Callback(_) => {
822            return Err(
823                format!("Task {name:?}: function tasks require the avril:task module").into(),
824            );
825        }
826        avril_task::Action::None => return Ok(0),
827    };
828    eprintln!("Task {name} {}", ready.display());
829    options.cwd = Some(directory.to_owned());
830    options.termination.scope = "child".into();
831    // Like `--env-file`, file values are defaults: the inherited process
832    // environment wins when it already defines a name.
833    options.env = ready
834        .env
835        .iter()
836        .filter(|(key, _)| std::env::var_os(key).is_none())
837        .map(|(key, value)| (key.clone(), value.clone()))
838        .collect();
839    let mut inherited: BTreeMap<_, _> = std::env::vars_os().collect();
840    if !inherited.contains_key(std::ffi::OsStr::new("INIT_CWD"))
841        && !ready.env.contains_key("INIT_CWD")
842    {
843        inherited.insert("INIT_CWD".into(), cwd.as_os_str().to_owned());
844    }
845    #[cfg(unix)]
846    if matches!(ready.action, avril_task::Action::Shell(_))
847        && !inherited.contains_key(std::ffi::OsStr::new("PATH"))
848        && !ready.env.contains_key("PATH")
849    {
850        options.command = "/bin/sh".into();
851    }
852    if SHUTDOWN.load(Ordering::SeqCst)
853        && let Some(control) = &control
854    {
855        control.cancel();
856    }
857    let process = avril_subprocess::Launcher {
858        runtime: runtime.clone(),
859        caps: avril_caps::Capabilities::new(avril_caps::Initial::new(false), directory.to_owned()),
860        owners: Vec::new(),
861    }
862    .spawn(options, inherited, control.as_deref(), |_, _, _| {
863        unreachable!("task stdio is inherited")
864    })
865    .map_err(|error| format!("Task {name:?}: {error}"))?;
866    let status = runtime
867        .block_on(process.wait(true, None))
868        .map_err(|error| format!("Task {name:?}: {error}"))?
869        .ok_or_else(|| format!("Task {name:?}: Task exited without a status"))?;
870    Ok(status.code.unwrap_or(1) as u8)
871}
872
873fn render_error(loaded: &Loaded, span: Span, message: String) -> String {
874    ConfigError {
875        kind: ErrorKind::Syntax,
876        message,
877        span,
878        previous: None,
879    }
880    .render(&loaded.path.to_string_lossy(), &loaded.source)
881}