Skip to main content

xlsynth_driver/
prover.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Process-based scheduler for executing ProverPlan trees.
4//!
5//! - Executes leaf ProverTasks as external processes using their driver
6//!   cmdlines.
7//! - Respects a global concurrency limit ("cores").
8//! - Group semantics:
9//!   - First: resolve on first child completion (success or failure); cancel
10//!     siblings.
11//!   - Any:   resolve true on first successful child; cancel siblings; resolve
12//!     false if all children complete and none succeeded.
13//!   - All:   resolve when all children succeed; if any fails, resolve failure
14//!     and cancel siblings.
15//!
16//! Implementation detail: strictly uses processes; no threads. Unix-only.
17
18use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
19use std::io;
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::time::{Duration, Instant};
23
24use crate::prover_config::{GroupKind, ProverPlan, ProverTask, ToDriverCommand};
25use crate::report_cli_error::report_cli_error_and_exit;
26use crate::toolchain_config::ToolchainConfig;
27
28use log::{debug, info, trace, warn};
29use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGTERM};
30use signal_hook::low_level as siglow;
31use std::os::unix::process::CommandExt;
32use std::process::Stdio;
33
34use serde::Serialize;
35
36#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)]
37pub enum IndefiniteReason {
38    GroupCriteriaAlreadyMet,
39    IndefiniteChildren,
40    Timeout,
41    Cleanup,
42}
43
44#[derive(Copy, Clone, Debug, PartialEq, Eq)]
45pub enum TaskOutcome {
46    Normal { success: bool },
47    Indefinite { reason: IndefiniteReason },
48}
49
50impl TaskOutcome {
51    fn normal(success: bool) -> Self {
52        TaskOutcome::Normal { success }
53    }
54
55    fn indefinite(reason: IndefiniteReason) -> Self {
56        TaskOutcome::Indefinite { reason }
57    }
58
59    fn success() -> Self {
60        TaskOutcome::normal(true)
61    }
62
63    fn failed() -> Self {
64        TaskOutcome::normal(false)
65    }
66
67    #[allow(dead_code)]
68    fn timeout() -> Self {
69        TaskOutcome::indefinite(IndefiniteReason::Timeout)
70    }
71
72    fn is_success(&self) -> bool {
73        match self {
74            TaskOutcome::Normal { success } => *success,
75            _ => false,
76        }
77    }
78
79    fn is_failed(&self) -> bool {
80        match self {
81            TaskOutcome::Normal { success } => !*success,
82            _ => false,
83        }
84    }
85
86    fn is_indefinite(&self) -> bool {
87        match self {
88            TaskOutcome::Indefinite { .. } => true,
89            _ => false,
90        }
91    }
92}
93
94impl serde::Serialize for TaskOutcome {
95    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
96    where
97        S: serde::Serializer,
98    {
99        let label: &str = match self {
100            TaskOutcome::Normal { success: true } => "Success",
101            TaskOutcome::Normal { success: false } => "Failed",
102            TaskOutcome::Indefinite { reason } => match reason {
103                IndefiniteReason::GroupCriteriaAlreadyMet => "GroupCriteriaAlreadyMet",
104                IndefiniteReason::IndefiniteChildren => "IndefiniteChildren",
105                IndefiniteReason::Timeout => "Timeout",
106                IndefiniteReason::Cleanup => "Cleanup",
107            },
108        };
109        serializer.serialize_str(label)
110    }
111}
112
113#[derive(Copy, Clone, Debug)]
114enum TaskState {
115    NotStarted,
116    Running { pid: i32 },
117    Completed { outcome: TaskOutcome },
118}
119
120#[derive(Debug)]
121struct TaskNode {
122    task: ProverTask,
123    timeout_ms: Option<u64>,
124    task_id: Option<String>,
125    state: TaskState,
126    // Captured outputs after task completion; None until completed.
127    completed_stdout: Option<String>,
128    completed_stderr: Option<String>,
129}
130
131#[derive(Debug)]
132struct GroupNode {
133    kind: GroupKind,
134    children: Vec<NodeId>,
135    outcome: Option<TaskOutcome>,
136    // When true, resolved groups do not cancel or prune sibling subtrees; all
137    // descendants are allowed to finish naturally.
138    keep_running_till_finish: bool,
139    // For GroupKind::First when keep_running_till_finish=true, remember the
140    // first child's outcome, then finalize only after all children complete.
141    first_winner_outcome: Option<TaskOutcome>,
142}
143
144#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
145struct NodeId(usize);
146
147#[derive(Debug)]
148enum NodeInner {
149    Task(TaskNode),
150    Group(GroupNode),
151}
152
153#[derive(Debug)]
154struct Node {
155    id: NodeId,
156    parent: Option<NodeId>,
157    inner: NodeInner,
158}
159
160// Cancellation is scoped per run via an Arc<AtomicBool> passed into the
161// scheduler.
162
163#[derive(Debug)]
164/// Invariants:
165/// - `nodes` indexed by `NodeId.0` (stable for lifetime).
166/// - `root` is a valid index into `nodes`.
167/// - `running ⊆ task_ids`; contains only tasks in `TaskState::Running`.
168/// - `pid_to_node` maps PIDs of spawned, unreaped children to their task; may
169///   include canceled-but-not-yet-reaped tasks.
170/// - `round_robin` contains each task at most once; no terminated tasks.
171/// - `max_procs >= 1`.
172/// - `json_files` holds a temp file for a task between spawn and reap.
173pub struct Scheduler {
174    nodes: Vec<Node>,
175    root: NodeId,
176    // For quick scanning of tasks to (re)fill slots.
177    task_ids: Vec<NodeId>,
178    // Running task ids set for fast membership.
179    running: HashSet<NodeId>,
180    // pid -> task node mapping for efficient waitpid handling.
181    pid_to_node: HashMap<i32, NodeId>,
182    // Queue to iterate scheduling in a fair-ish manner.
183    round_robin: VecDeque<NodeId>,
184    // Concurrency limit.
185    max_procs: usize,
186    // Temp JSON files for task results, held to ensure lifetime until read.
187    json_files: HashMap<NodeId, tempfile::NamedTempFile>,
188    // Temp stdout/stderr files and cmdline for each running task.
189    outputs: HashMap<NodeId, (tempfile::NamedTempFile, tempfile::NamedTempFile, String)>,
190    // Persistent record of cmdlines for tasks (even after outputs are dropped).
191    cmdlines: HashMap<NodeId, String>,
192    // Run-scoped cancellation flag, toggled by CLI signal handlers or callers.
193    cancel_flag: Arc<AtomicBool>,
194    deadlines: BTreeMap<Instant, Vec<NodeId>>,
195}
196
197#[derive(Debug, Serialize)]
198#[serde(untagged)]
199pub enum ProverReportNode {
200    Task {
201        task_id: Option<String>,
202        cmdline: Option<String>,
203        outcome: Option<TaskOutcome>,
204        stdout: Option<String>,
205        stderr: Option<String>,
206    },
207    Group {
208        kind: GroupKind,
209        outcome: Option<TaskOutcome>,
210        tasks: Vec<ProverReportNode>,
211    },
212}
213
214impl ProverReportNode {
215    pub fn find_task_node<'a>(&'a self, task_id: &str) -> Option<&'a ProverReportNode> {
216        match self {
217            ProverReportNode::Task { task_id: tid, .. } => {
218                if let Some(t) = tid {
219                    if t == task_id {
220                        return Some(self);
221                    }
222                }
223                None
224            }
225            ProverReportNode::Group { tasks, .. } => {
226                for t in tasks {
227                    if let Some(found) = t.find_task_node(task_id) {
228                        return Some(found);
229                    }
230                }
231                None
232            }
233        }
234    }
235}
236
237#[derive(Serialize)]
238pub struct ProverReport {
239    pub success: bool,
240    pub plan: ProverReportNode,
241}
242
243impl Scheduler {
244    /// Postconditions:
245    /// - `max_procs` is clamped to at least 1.
246    /// - All nodes built; `task_ids` and `round_robin` contain every task once.
247    /// - `running`, `pid_to_node`, and `json_files` are empty.
248    /// - `root` indexes a node inside `nodes`.
249    pub fn new(plan: ProverPlan, max_procs: usize, cancel_flag: Arc<AtomicBool>) -> Scheduler {
250        log::info!("prover: new scheduler");
251        let mut builder = PlanBuilder::default();
252        let root = builder.build_plan(None, plan);
253        let nodes = builder.nodes;
254        // Populate task index and rr queue.
255        let mut task_ids = Vec::new();
256        let mut round_robin = VecDeque::new();
257        for n in &nodes {
258            if matches!(n.inner, NodeInner::Task(_)) {
259                task_ids.push(n.id);
260                round_robin.push_back(n.id);
261            }
262        }
263        let mut sched = Scheduler {
264            nodes,
265            root,
266            task_ids,
267            running: HashSet::new(),
268            pid_to_node: HashMap::new(),
269            round_robin,
270            max_procs: max_procs.max(1),
271            json_files: HashMap::new(),
272            outputs: HashMap::new(),
273            cmdlines: HashMap::new(),
274            cancel_flag,
275            deadlines: BTreeMap::new(),
276        };
277        // Precompute planned cmdlines for all tasks so short-circuited tasks still
278        // report one.
279        for n in &sched.nodes {
280            if let NodeInner::Task(t) = &n.inner {
281                let cmd = t.task.to_command();
282                let program = cmd.get_program().to_string_lossy().into_owned();
283                let args = cmd
284                    .get_args()
285                    .map(|s| s.to_string_lossy().into_owned())
286                    .collect::<Vec<_>>();
287                let cmdline = if args.is_empty() {
288                    program.clone()
289                } else {
290                    format!("{} {}", program, args.join(" "))
291                };
292                sched.cmdlines.insert(n.id, cmdline);
293            }
294        }
295        info!(
296            "prover: scheduler initialized: tasks={}, max_procs={}",
297            sched.task_ids.len(),
298            sched.max_procs
299        );
300        sched
301    }
302
303    /// Behavior:
304    /// - Schedules up to `max_procs` concurrent tasks.
305    /// - Returns when the root plan resolves or no running tasks remain.
306    /// - On cancellation, cancels all tasks and returns `Ok(false)`.
307    /// - Maintains `running.len() <= max_procs` at all times.
308    pub fn run(&mut self) -> io::Result<bool> {
309        loop {
310            // Check for cancellation request.
311            if self.cancel_flag.load(Ordering::Relaxed) {
312                warn!("prover: cancellation requested; cleaning up and exiting");
313                let _ = self.cleanup_tasks();
314                return Ok(false);
315            }
316
317            // Check for timeouts.
318            self.check_and_handle_timeouts()?;
319
320            // If the root is already resolved, finish immediately.
321            if let Some(root_outcome) = self.node_outcome(self.root) {
322                self.cleanup_tasks()?;
323                return Ok(root_outcome.is_success());
324            }
325
326            // Fill available slots next.
327            if self.running.len() < self.max_procs {
328                self.fill_slots()?;
329            }
330
331            if self.running.is_empty() {
332                // No running processes; if root not resolved and no runnable left, consider
333                // failure.
334                panic!("prover: no running tasks remain and root unresolved; should not happen");
335            }
336
337            // Block until any child process changes state; handle one completion at a time.
338            if let Err(_e) = self.wait_for_one_child_and_handle() {
339                return Err(io::Error::new(io::ErrorKind::Other, "waitpid failed"));
340            }
341        }
342    }
343
344    fn check_and_handle_timeouts(&mut self) -> io::Result<()> {
345        while let Some(entry) = self.deadlines.first_entry() {
346            let now = Instant::now();
347            if now < *entry.key() {
348                break;
349            }
350            let tids = entry.get().iter().copied().collect::<Vec<_>>();
351            entry.remove();
352            for tid in tids {
353                log::info!("timeout task={:?}", tid);
354                self.cancel_subtree(tid, IndefiniteReason::Timeout)?;
355                self.on_child_plan_finished(tid)?;
356            }
357        }
358        Ok(())
359    }
360
361    /// Returns true iff the node is a `Task` in `NotStarted`.
362    /// Note: Group cancellation/resolution removes descendant tasks from
363    /// queues, so ancestor state is enforced by queue pruning rather than
364    /// checked here.
365    fn is_task_runnable(&self, task_id: NodeId) -> bool {
366        let node = &self.nodes[task_id.0];
367        match &node.inner {
368            NodeInner::Task(t) => matches!(t.state, TaskState::NotStarted),
369            _ => false,
370        }
371    }
372
373    /// Returns true iff the task is `Completed`.
374    fn is_task_completed(&self, task_id: NodeId) -> bool {
375        let node = &self.nodes[task_id.0];
376        match &node.inner {
377            NodeInner::Task(t) => matches!(t.state, TaskState::Completed { .. }),
378            _ => false,
379        }
380    }
381
382    // Removed is_task_success and is_task_failed; prefer using
383    // node_outcome(...).is_success()
384
385    fn is_task_indefinite(&self, task_id: NodeId) -> bool {
386        let node = &self.nodes[task_id.0];
387        match &node.inner {
388            NodeInner::Task(t) => matches!(
389                t.state,
390                TaskState::Completed {
391                    outcome: TaskOutcome::Indefinite { .. },
392                }
393            ),
394            _ => false,
395        }
396    }
397
398    /// Postconditions:
399    /// - Schedules runnable tasks in round-robin order until `running ==
400    ///   max_procs` or no more runnable tasks exist at this moment.
401    /// - Each task is spawned at most once.
402    fn fill_slots(&mut self) -> io::Result<()> {
403        let len = self.round_robin.len();
404        for _ in 0..len {
405            if let Some(tid) = self.round_robin.pop_front() {
406                if self.is_task_runnable(tid) {
407                    self.spawn_task(tid)?;
408                } else {
409                    // This happens only when inconsistent state is left by the cleanup.
410                    assert!(!self.is_task_completed(tid));
411                    self.round_robin.push_back(tid);
412                }
413                if self.running.len() >= self.max_procs {
414                    break;
415                }
416            } else {
417                break;
418            }
419        }
420        trace!(
421            "prover: scheduling iteration complete; running={}/{}",
422            self.running.len(),
423            self.max_procs
424        );
425        Ok(())
426    }
427
428    /// Precondition: `task_id` is a `Task` in `NotStarted`.
429    /// Postconditions:
430    /// - Child process spawned; becomes its own process group leader.
431    /// - Task state becomes `Running { pid }`.
432    /// - `running` contains `task_id`; `pid_to_node[pid] == task_id`.
433    /// - A temp JSON file is created and tracked in `json_files`.
434    fn spawn_task(&mut self, task_id: NodeId) -> io::Result<()> {
435        let node = &mut self.nodes[task_id.0];
436        // Build command and tempfiles; only insert into maps after successful spawn.
437        let (mut cmd, stdout_tmp, stderr_tmp, json_tmp, json_path, timeout_ms, cmdline) =
438            match &mut node.inner {
439                NodeInner::Task(t) => match t.state {
440                    TaskState::NotStarted => {
441                        let mut cmd = t.task.to_command();
442                        // Create a temp path for JSON results and pass it to the child.
443                        let json_tmp = tempfile::Builder::new().suffix(".json").tempfile()?;
444                        let json_path = json_tmp.path().to_path_buf();
445                        cmd.arg("--output_json").arg(&json_path);
446                        let json_path = json_path.display().to_string();
447
448                        // Create temp files to capture stdout/stderr; pass fds to child.
449                        let stdout_tmp = tempfile::Builder::new().suffix(".stdout").tempfile()?;
450                        let stderr_tmp = tempfile::Builder::new().suffix(".stderr").tempfile()?;
451                        let stdout_file = stdout_tmp.as_file().try_clone()?;
452                        let stderr_file = stderr_tmp.as_file().try_clone()?;
453                        cmd.stdout(Stdio::from(stdout_file));
454                        cmd.stderr(Stdio::from(stderr_file));
455
456                        // Build a human-readable cmdline snapshot.
457                        let program = cmd.get_program().to_string_lossy().into_owned();
458                        let args = cmd
459                            .get_args()
460                            .map(|s| s.to_string_lossy().into_owned())
461                            .collect::<Vec<_>>();
462                        let cmdline = if args.is_empty() {
463                            program.clone()
464                        } else {
465                            format!("{} {}", program, args.join(" "))
466                        };
467
468                        // Ensure child is leader of its own process group so we can kill the
469                        // subtree.
470                        unsafe {
471                            cmd.pre_exec(|| {
472                                let rc = libc::setpgid(0, 0);
473                                if rc != 0 {
474                                    return Err(io::Error::new(
475                                        io::ErrorKind::Other,
476                                        "Failed to setpgid",
477                                    ));
478                                }
479                                Ok(())
480                            })
481                        };
482
483                        let timeout_ms = t.timeout_ms;
484
485                        (
486                            cmd, stdout_tmp, stderr_tmp, json_tmp, json_path, timeout_ms, cmdline,
487                        )
488                    }
489                    _ => return Ok(()),
490                },
491                _ => return Ok(()),
492            };
493
494        let child = cmd.spawn()?;
495        let pid_i32 = child.id() as i32;
496
497        // Now that spawn succeeded, store tempfiles and cmdline.
498        self.outputs
499            .insert(task_id, (stdout_tmp, stderr_tmp, cmdline.clone()));
500        self.json_files.insert(task_id, json_tmp);
501        if let Some(timeout_ms) = timeout_ms {
502            let deadline = Instant::now() + Duration::from_millis(timeout_ms);
503            self.deadlines.entry(deadline).or_default().push(task_id);
504        }
505
506        if let NodeInner::Task(t) = &mut self.nodes[task_id.0].inner {
507            t.state = TaskState::Running { pid: pid_i32 };
508            debug!(
509                "prover: spawned task, cmdline=\"{}\" pid={} json_out={}",
510                self.cmdlines.get(&task_id).unwrap(),
511                pid_i32,
512                json_path
513            );
514        }
515        self.running.insert(task_id);
516        self.pid_to_node.insert(pid_i32, task_id);
517        Ok(())
518    }
519
520    /// Ensures none of `tids` remain in `round_robin` nor `task_ids`.
521    fn remove_tasks_from_queues(&mut self, tids: &[NodeId]) {
522        use std::collections::HashSet;
523        let to_remove: HashSet<NodeId> = tids.iter().copied().collect();
524        // Rebuild round-robin without removed tasks.
525        let mut new_rr = VecDeque::with_capacity(self.round_robin.len());
526        while let Some(id) = self.round_robin.pop_front() {
527            if !to_remove.contains(&id) {
528                new_rr.push_back(id);
529            }
530        }
531        self.round_robin = new_rr;
532        // Filter task_ids.
533        self.task_ids.retain(|id| !to_remove.contains(id));
534    }
535
536    /// Convenience wrapper around `remove_tasks_from_queues`.
537    fn remove_task_from_queues(&mut self, tid: NodeId) {
538        self.remove_tasks_from_queues(&[tid]);
539    }
540
541    /// Appends all descendant task ids of `id` into `out`. No duplicates.
542    fn collect_subtree_tasks_rec(&self, id: NodeId, out: &mut Vec<NodeId>) {
543        match &self.nodes[id.0].inner {
544            NodeInner::Task(_) => out.push(id),
545            NodeInner::Group(g) => {
546                for &cid in &g.children {
547                    self.collect_subtree_tasks_rec(cid, out);
548                }
549            }
550        }
551    }
552
553    /// Removes all descendant tasks of `id` from scheduling queues only.
554    fn prune_subtree_tasks(&mut self, id: NodeId) {
555        let mut tids = Vec::new();
556        self.collect_subtree_tasks_rec(id, &mut tids);
557        self.remove_tasks_from_queues(&tids);
558        trace!("prover: pruned {} tasks from queues", tids.len());
559    }
560
561    /// Reaps and processes at most one child exit.
562    /// Postconditions on a reaped (non-canceled) task:
563    /// - Task state -> `Completed { outcome }`.
564    /// - Removed from `running`, scheduling queues, and `json_files`.
565    /// - Group resolution is bubbled to ancestors as needed.
566    /// Note: For canceled tasks, result does not propagate.
567    fn wait_for_one_child_and_handle(&mut self) -> io::Result<()> {
568        // Poll only our known children; never use waitpid(-1) to avoid reaping
569        // unrelated children of the enclosing test process.
570        let pids: Vec<i32> = self.pid_to_node.keys().copied().collect();
571        let mut handled_any = false;
572
573        for pid in pids {
574            let mut status: libc::c_int = 0;
575            let rc = unsafe { libc::waitpid(pid, &mut status as *mut libc::c_int, libc::WNOHANG) };
576            if rc == 0 {
577                continue; // still running
578            }
579            if rc < 0 {
580                let err = io::Error::last_os_error();
581                if let Some(raw) = err.raw_os_error() {
582                    if raw == libc::EINTR {
583                        continue;
584                    }
585                    if raw == libc::ECHILD {
586                        // Child already reaped elsewhere; clean up bookkeeping.
587                        if let Some(tid) = self.pid_to_node.remove(&pid) {
588                            self.remove_task_bookkeeping(tid);
589                            self.on_child_plan_finished(tid)?;
590                        }
591                        continue;
592                    }
593                }
594                return Err(io::Error::new(io::ErrorKind::Other, "waitpid failed"));
595            }
596
597            // rc > 0: child state available; complete bookkeeping for this pid.
598            let term_signal = if libc::WIFSIGNALED(status) {
599                Some(libc::WTERMSIG(status))
600            } else {
601                None
602            };
603
604            if let Some(tid) = self.pid_to_node.remove(&pid) {
605                // If the task was already indefinite, do not convert it to normal or bubble up.
606                let was_indefinite = self.is_task_indefinite(tid);
607                if was_indefinite {
608                    debug!("prover: reaped indefinite task pid={}", pid);
609                    handled_any = true;
610                    continue;
611                }
612
613                if let Some(sig) = term_signal {
614                    warn!(
615                        "prover: task terminated by signal {} pid={} cmdline=\"{}\"",
616                        sig,
617                        pid,
618                        self.cmdlines.get(&tid).unwrap()
619                    );
620                }
621
622                let success = self.read_success_from_json(tid);
623
624                // Read captured stdout/stderr for this task; store for final report printing.
625                let mut captured_stdout: Option<String> = None;
626                let mut captured_stderr: Option<String> = None;
627                if let Some((stdout_tmp, stderr_tmp, _cmdline)) = self.outputs.remove(&tid) {
628                    let read_lossy = |p: &std::path::Path| -> String {
629                        match std::fs::read(p) {
630                            Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
631                            Err(_) => String::new(),
632                        }
633                    };
634                    captured_stdout = Some(read_lossy(stdout_tmp.path()));
635                    captured_stderr = Some(read_lossy(stderr_tmp.path()));
636                }
637
638                if let NodeInner::Task(t) = &mut self.nodes[tid.0].inner {
639                    t.state = TaskState::Completed {
640                        outcome: TaskOutcome::normal(success),
641                    };
642                    t.completed_stdout = captured_stdout;
643                    t.completed_stderr = captured_stderr;
644                    debug!(
645                        "prover: task completed, cmdline=\"{}\" pid={} success={}",
646                        self.cmdlines.get(&tid).unwrap(),
647                        pid,
648                        success
649                    );
650                } else {
651                    panic!("not a task node");
652                }
653                self.remove_task_bookkeeping(tid);
654                self.on_child_plan_finished(tid)?;
655                handled_any = true;
656                break; // handle one completion per call (preserve prior pacing)
657            }
658        }
659
660        if !handled_any {
661            // Back off briefly if no child changed state.
662            let mut ts = libc::timespec {
663                tv_sec: 0,
664                tv_nsec: 100 * 1000000,
665            };
666            unsafe { libc::nanosleep(&ts, &mut ts) };
667        }
668
669        Ok(())
670    }
671
672    /// Attempts to read a task's logical success from its JSON output.
673    /// Returns `false` if no file or parse error; does not mutate state.
674    fn read_success_from_json(&self, tid: NodeId) -> bool {
675        let path = match self.json_files.get(&tid) {
676            Some(tf) => tf.path(),
677            None => return false,
678        };
679        let data = match std::fs::read_to_string(path) {
680            Ok(s) => s,
681            Err(_) => return false,
682        };
683        let v: serde_json::Value = match serde_json::from_str(&data) {
684            Ok(v) => v,
685            Err(_) => return false,
686        };
687
688        v.get("success").and_then(|b| b.as_bool()).unwrap_or(false)
689    }
690
691    /// Bubbles child completion into ancestor groups, possibly resolving them,
692    /// canceling sibling subtrees, and pruning queues.
693    fn on_child_plan_finished(&mut self, node_id: NodeId) -> io::Result<()> {
694        // Climb up towards root, resolving groups as needed.
695        let mut cur_id = node_id;
696        let mut parent = self.nodes[cur_id.0].parent;
697        while let Some(pid) = parent {
698            self.resolve_group_child(pid, cur_id)?;
699            cur_id = pid;
700            parent = self.nodes[pid.0].parent;
701        }
702        Ok(())
703    }
704
705    fn get_group_node(&self, id: NodeId) -> &GroupNode {
706        match &self.nodes[id.0].inner {
707            NodeInner::Group(g) => g,
708            _ => panic!("not a group node"),
709        }
710    }
711
712    fn get_group_node_mut(&mut self, id: NodeId) -> &mut GroupNode {
713        match &mut self.nodes[id.0].inner {
714            NodeInner::Group(g) => g,
715            _ => panic!("not a group node"),
716        }
717    }
718
719    /// Precondition: `child_id` is a direct child of `gid` whose result is
720    /// known. Behavior: Inspects current child outcomes, possibly resolves
721    /// the group, cancels siblings according to `kind`, and prunes queues.
722    fn resolve_group_child(&mut self, gid: NodeId, child_id: NodeId) -> io::Result<()> {
723        let (kind, idx, num_children, finished, success, failed, indefinite, keep_running) = {
724            let g = self.get_group_node(gid);
725            if g.outcome.is_some() {
726                return Ok(());
727            }
728            let num_children = g.children.len();
729            let idx = g
730                .children
731                .iter()
732                .position(|&id| id == child_id)
733                .expect("child must be in group's children");
734            let mut finished = 0;
735            let mut success = 0;
736            let mut failed = 0;
737            let mut indefinite = 0;
738            for &cid in &g.children {
739                let child_outcomes = self.node_outcome(cid);
740                match child_outcomes {
741                    Some(TaskOutcome::Normal { success: true }) => {
742                        finished += 1;
743                        success += 1;
744                    }
745                    Some(TaskOutcome::Normal { success: false }) => {
746                        finished += 1;
747                        failed += 1;
748                    }
749                    Some(TaskOutcome::Indefinite { .. }) => {
750                        finished += 1;
751                        indefinite += 1;
752                    }
753                    None => {}
754                }
755            }
756            (
757                g.kind,
758                idx,
759                num_children,
760                finished,
761                success,
762                failed,
763                indefinite,
764                g.keep_running_till_finish,
765            )
766        };
767
768        // Only consider the direct child's actual resolved outcome (if any) for early
769        // resolution.
770        let child_outcome = self.node_outcome(child_id);
771
772        match kind {
773            GroupKind::First => {
774                let g = self.get_group_node_mut(gid);
775                if let Some(co) = child_outcome {
776                    if !co.is_indefinite() {
777                        g.first_winner_outcome.get_or_insert(co);
778                    }
779                }
780                if finished == num_children {
781                    if let Some(winner) = g.first_winner_outcome {
782                        info!("prover: group resolved kind=first outcome={:?}", winner);
783                        return self.finalize_group_resolution(gid, winner, None);
784                    } else {
785                        return self.finalize_group_resolution(
786                            gid,
787                            TaskOutcome::indefinite(IndefiniteReason::IndefiniteChildren),
788                            None,
789                        );
790                    }
791                }
792                if !keep_running {
793                    if let Some(co) = child_outcome {
794                        if !co.is_indefinite() {
795                            info!("prover: group resolved kind=first result={:?}", co);
796                            return self.finalize_group_resolution(gid, co, Some(idx));
797                        }
798                    }
799                }
800                return Ok(());
801            }
802            GroupKind::Any => {
803                if finished == num_children {
804                    let outcome = if success > 0 {
805                        TaskOutcome::success()
806                    } else if indefinite > 0 {
807                        TaskOutcome::indefinite(IndefiniteReason::IndefiniteChildren)
808                    } else {
809                        TaskOutcome::failed()
810                    };
811                    info!("prover: group resolved kind=any result={:?}", outcome);
812                    return self.finalize_group_resolution(gid, outcome, None);
813                }
814                if !keep_running {
815                    if let Some(co) = child_outcome {
816                        if co.is_success() {
817                            info!("prover: group resolved kind=any result=true");
818                            return self.finalize_group_resolution(
819                                gid,
820                                TaskOutcome::success(),
821                                Some(idx),
822                            );
823                        }
824                    }
825                }
826                Ok(())
827            }
828            GroupKind::All => {
829                if finished == num_children {
830                    let outcome = if failed > 0 {
831                        TaskOutcome::failed()
832                    } else if indefinite > 0 {
833                        TaskOutcome::indefinite(IndefiniteReason::IndefiniteChildren)
834                    } else {
835                        TaskOutcome::success()
836                    };
837                    info!("prover: group resolved kind=all result={:?}", outcome);
838                    return self.finalize_group_resolution(gid, outcome, None);
839                }
840                if !keep_running {
841                    if let Some(co) = child_outcome {
842                        if co.is_failed() {
843                            info!("prover: group resolved kind=all result=false");
844                            return self.finalize_group_resolution(
845                                gid,
846                                TaskOutcome::failed(),
847                                Some(idx),
848                            );
849                        }
850                    }
851                }
852                Ok(())
853            }
854        }
855    }
856
857    fn finalize_group_resolution(
858        &mut self,
859        gid: NodeId,
860        outcome: TaskOutcome,
861        cancel_siblings_of: Option<usize>,
862    ) -> io::Result<()> {
863        if let NodeInner::Group(g) = &mut self.nodes[gid.0].inner {
864            g.outcome = Some(outcome);
865        }
866        if let Some(idx) = cancel_siblings_of {
867            self.cancel_group_siblings(gid, idx)?;
868        }
869        // Group resolved; prune all tasks in this subtree from queues.
870        self.prune_subtree_tasks(gid);
871        Ok(())
872    }
873
874    /// Cancels all child subtrees of the group except `exclude_child_idx`.
875    ///
876    /// Post: No descendant of canceled siblings remains runnable; running
877    /// children are killed.
878    fn cancel_group_siblings(&mut self, gid: NodeId, exclude_child_idx: usize) -> io::Result<()> {
879        // Capture ids to cancel, then perform cancellations.
880        let cancel_ids: Vec<NodeId> = match &self.nodes[gid.0].inner {
881            NodeInner::Group(g) => g
882                .children
883                .iter()
884                .enumerate()
885                .filter_map(|(i, &cid)| {
886                    if i != exclude_child_idx {
887                        Some(cid)
888                    } else {
889                        None
890                    }
891                })
892                .collect(),
893            _ => vec![],
894        };
895        if !cancel_ids.is_empty() {
896            warn!(
897                "prover: canceling {} sibling subtree(s) for group (excluding child idx={})",
898                cancel_ids.len(),
899                exclude_child_idx
900            );
901        }
902        for cid in cancel_ids.iter().copied() {
903            self.cancel_subtree(cid, IndefiniteReason::GroupCriteriaAlreadyMet)?;
904        }
905        Ok(())
906    }
907
908    fn set_outcome(&mut self, id: NodeId, outcome: TaskOutcome) {
909        match &mut self.nodes[id.0].inner {
910            NodeInner::Task(t) => t.state = TaskState::Completed { outcome },
911            NodeInner::Group(g) => g.outcome = Some(outcome),
912        }
913    }
914
915    /// Cancels an entire subtree rooted at `id`.
916    /// Tasks:
917    /// - NotStarted -> mark `Completed { Canceled }` and remove from queues.
918    /// - Running -> send SIGKILL to process group, mark `Completed { Canceled
919    ///   }`, remove from queues; `pid_to_node` entry remains until reaped.
920    /// - Completed (Success/Failed) -> no-op bookkeeping.
921    /// Groups: mark `canceled`, cancel children, then prune their tasks from
922    /// queues.
923    fn cancel_subtree(
924        &mut self,
925        id: NodeId,
926        indefinite_reason: IndefiniteReason,
927    ) -> io::Result<()> {
928        match &self.nodes[id.0].inner {
929            NodeInner::Task(t) => {
930                let prior_state = t.state.clone();
931                if matches!(prior_state, TaskState::Completed { .. }) {
932                    return Ok(());
933                }
934                self.set_outcome(id, TaskOutcome::indefinite(indefinite_reason));
935                match prior_state {
936                    TaskState::NotStarted => {
937                        debug!("prover: canceled (not-started) task");
938                    }
939                    TaskState::Running { pid } => {
940                        unsafe {
941                            let _ = libc::kill(-pid, libc::SIGKILL);
942                        }
943                        info!("prover: canceled running task pid={}", pid);
944                    }
945                    TaskState::Completed { .. } => {
946                        // Already completed; no-op.
947                    }
948                }
949                self.remove_task_bookkeeping(id);
950            }
951            NodeInner::Group(g) => {
952                let prior_outcome = g.outcome.clone();
953                if matches!(prior_outcome, Some(..)) {
954                    return Ok(());
955                }
956                let kind = g.kind;
957                let children = g.children.clone();
958                for cid in children {
959                    self.cancel_subtree(cid, IndefiniteReason::GroupCriteriaAlreadyMet)?;
960                }
961                self.set_outcome(
962                    id,
963                    TaskOutcome::indefinite(IndefiniteReason::GroupCriteriaAlreadyMet),
964                );
965                // Prune entire group subtree tasks from queues.
966                self.prune_subtree_tasks(id);
967                warn!("prover: canceled group subtree kind={:?} ", kind);
968            }
969        }
970        Ok(())
971    }
972
973    /// Returns the outcome if the node is resolved:
974    /// - Task: `Some(outcome)` when in `Completed` (may be Success, Failed, or
975    ///   Canceled).
976    /// - Group: `Some(outcome)` when `outcome.is_some()` (may be Success,
977    ///   Failed, or Canceled).
978    /// Otherwise, `None`.
979    fn node_outcome(&self, id: NodeId) -> Option<TaskOutcome> {
980        match &self.nodes[id.0].inner {
981            NodeInner::Task(t) => match t.state {
982                TaskState::Completed { outcome } => Some(outcome),
983                _ => None,
984            },
985            NodeInner::Group(g) => g.outcome,
986        }
987    }
988
989    // Small helper to clean task-related bookkeeping.
990    /// Removes `id` from `running` and scheduling queues; drops any temp JSON
991    /// file. Does not remove any `pid_to_node` entry; that occurs on reap.
992    fn remove_task_bookkeeping(&mut self, id: NodeId) {
993        self.running.remove(&id);
994        self.remove_task_from_queues(id);
995        let _ = self.json_files.remove(&id);
996        let _ = self.outputs.remove(&id);
997    }
998
999    fn build_report_node(&self, id: NodeId) -> ProverReportNode {
1000        match &self.nodes[id.0].inner {
1001            NodeInner::Task(t) => {
1002                let task_id = t.task_id.clone();
1003                let cmdline = self.cmdlines.get(&id).cloned();
1004                let outcome = self.node_outcome(id);
1005                let stdout = t.completed_stdout.clone();
1006                let stderr = t.completed_stderr.clone();
1007                ProverReportNode::Task {
1008                    task_id,
1009                    cmdline,
1010                    outcome,
1011                    stdout,
1012                    stderr,
1013                }
1014            }
1015            NodeInner::Group(g) => {
1016                let outcome = g.outcome;
1017                let tasks = g
1018                    .children
1019                    .iter()
1020                    .map(|&cid| self.build_report_node(cid))
1021                    .collect();
1022                ProverReportNode::Group {
1023                    kind: g.kind,
1024                    outcome,
1025                    tasks,
1026                }
1027            }
1028        }
1029    }
1030
1031    // --- New cleanup helpers for unexpected OS errors or abrupt shutdowns. ---
1032    /// Best-effort teardown:
1033    /// - Sets global cancel flag.
1034    /// - Cancels the entire plan subtree.
1035    /// - Reaps all remaining children until none remain (blocking).
1036    /// After return, no child processes should remain.
1037    pub fn cleanup_tasks(&mut self) -> io::Result<()> {
1038        // Prevent any further scheduling if this instance somehow continues.
1039        self.cancel_flag.store(true, Ordering::Relaxed);
1040        info!("prover: cleanup starting (best-effort) ");
1041        let _ = self.cancel_subtree(self.root, IndefiniteReason::Cleanup);
1042
1043        // Reap only our tracked children until none remain.
1044        while !self.pid_to_node.is_empty() {
1045            let pids: Vec<i32> = self.pid_to_node.keys().copied().collect();
1046            let mut reaped_any = false;
1047
1048            for pid in pids {
1049                let mut status: libc::c_int = 0;
1050                let rc =
1051                    unsafe { libc::waitpid(pid, &mut status as *mut libc::c_int, libc::WNOHANG) };
1052                if rc == 0 {
1053                    continue;
1054                }
1055                if rc < 0 {
1056                    let err = io::Error::last_os_error();
1057                    if let Some(raw) = err.raw_os_error() {
1058                        if raw == libc::EINTR {
1059                            continue;
1060                        }
1061                        if raw == libc::ECHILD {
1062                            // Already reaped elsewhere; drop bookkeeping.
1063                            self.pid_to_node.remove(&pid);
1064                            reaped_any = true;
1065                            continue;
1066                        }
1067                    }
1068                    warn!("prover: waitpid during cleanup failed: {}", err);
1069                    // Drop from bookkeeping to avoid getting stuck.
1070                    self.pid_to_node.remove(&pid);
1071                    reaped_any = true;
1072                    continue;
1073                }
1074
1075                // rc > 0
1076                self.pid_to_node.remove(&pid);
1077                trace!("prover: reaped child pid={} during cleanup", pid);
1078                reaped_any = true;
1079            }
1080
1081            if !reaped_any {
1082                // Brief sleep to avoid busy-wait; children were SIGKILLed in cancel_subtree.
1083                let mut ts = libc::timespec {
1084                    tv_sec: 0,
1085                    tv_nsec: 100 * 1000000,
1086                };
1087                unsafe { libc::nanosleep(&ts, &mut ts) };
1088            }
1089        }
1090
1091        warn!("prover: cleanup complete");
1092        Ok(())
1093    }
1094}
1095
1096#[derive(Default)]
1097/// Invariant: `nodes[id.0].id == NodeId(id.0)` holds for all nodes.
1098struct PlanBuilder {
1099    nodes: Vec<Node>,
1100}
1101
1102impl PlanBuilder {
1103    fn push(&mut self, parent: Option<NodeId>, inner: NodeInner) -> NodeId {
1104        let id = NodeId(self.nodes.len());
1105        self.nodes.push(Node { id, parent, inner });
1106        id
1107    }
1108
1109    /// Builds the tree for `plan` under `parent`.
1110    /// Post: For `Group` nodes, `children.len()` is set to the number of tasks.
1111    fn build_plan(&mut self, parent: Option<NodeId>, plan: ProverPlan) -> NodeId {
1112        match plan {
1113            ProverPlan::Task {
1114                task,
1115                timeout_ms,
1116                task_id,
1117            } => self.push(
1118                parent,
1119                NodeInner::Task(TaskNode {
1120                    task,
1121                    timeout_ms,
1122                    task_id,
1123                    state: TaskState::NotStarted,
1124                    completed_stdout: None,
1125                    completed_stderr: None,
1126                }),
1127            ),
1128            ProverPlan::Group {
1129                kind,
1130                tasks,
1131                keep_running_till_finish,
1132            } => {
1133                let gid = self.push(
1134                    parent,
1135                    NodeInner::Group(GroupNode {
1136                        kind,
1137                        children: Vec::new(),
1138                        outcome: None,
1139                        keep_running_till_finish,
1140                        first_winner_outcome: None,
1141                    }),
1142                );
1143                let child_ids: Vec<NodeId> = tasks
1144                    .into_iter()
1145                    .map(|t| self.build_plan(Some(gid), t))
1146                    .collect();
1147                if let NodeInner::Group(g) = &mut self.nodes[gid.0].inner {
1148                    g.children = child_ids;
1149                }
1150                gid
1151            }
1152        }
1153    }
1154}
1155
1156/// Convenience entry point: run a `ProverPlan` with up to `max_procs`
1157/// concurrent processes.
1158/// On internal error, performs cleanup before returning `Err`.
1159pub fn run_prover_plan(plan: ProverPlan, max_procs: usize) -> io::Result<ProverReport> {
1160    // Create a run-scoped cancellation flag and wire OS signals to it.
1161    let cancel_flag = Arc::new(AtomicBool::new(false));
1162    let mut sig_ids = Vec::new();
1163    for sig in [SIGINT, SIGTERM, SIGHUP] {
1164        let flag = Arc::clone(&cancel_flag);
1165        // Set the flag from signal handler; store SigId so we can unregister on exit.
1166        match unsafe { siglow::register(sig, move || flag.store(true, Ordering::Relaxed)) } {
1167            Ok(id) => sig_ids.push(id),
1168            Err(e) => warn!("prover: failed to register signal {}: {}", sig, e),
1169        }
1170    }
1171
1172    let mut sched = Scheduler::new(plan, max_procs, cancel_flag);
1173    info!("prover: run starting");
1174    let run_res = sched.run();
1175
1176    // Always unregister signal handlers before returning.
1177    for id in sig_ids.drain(..) {
1178        let _ = siglow::unregister(id);
1179    }
1180
1181    let result = match run_res {
1182        Ok(v) => v,
1183        Err(e) => {
1184            let _ = sched.cleanup_tasks();
1185            return Err(e);
1186        }
1187    };
1188    let plan_node = sched.build_report_node(sched.root);
1189    Ok(ProverReport {
1190        success: result,
1191        plan: plan_node,
1192    })
1193}
1194
1195/// Best-effort: write a minimal error JSON if `--output_json` was requested.
1196/// Shape:
1197/// { "success": false, "error": { "message": <msg>, "exception": <exc>,
1198/// "stage": <stage> } }
1199fn write_error_json(
1200    output_json_path: &Option<String>,
1201    message: &str,
1202    exception: &str,
1203    stage: &str,
1204) {
1205    if let Some(path) = output_json_path {
1206        let obj = serde_json::json!({
1207            "success": false,
1208            "error": {
1209                "message": message,
1210                "exception": exception,
1211                "stage": stage,
1212            }
1213        });
1214        if let Err(write_err) = std::fs::write(path, obj.to_string()) {
1215            warn!(
1216                "prover: failed writing output_json error to {}: {}",
1217                path, write_err
1218            );
1219        }
1220    }
1221}
1222
1223/// Helper to both emit JSON (when requested) and print CLI error then exit.
1224fn fail_and_exit(
1225    output_json_path: &Option<String>,
1226    stage: &str,
1227    message: &str,
1228    exception: &str,
1229    extra_details: &[(&str, &str)],
1230) -> ! {
1231    write_error_json(output_json_path, message, exception, stage);
1232    // Assemble CLI details
1233    let mut details: Vec<(&str, &str)> = Vec::with_capacity(2 + extra_details.len());
1234    details.push(("stage", stage));
1235    details.push(("exception", exception));
1236    details.extend_from_slice(extra_details);
1237    report_cli_error_and_exit(message, Some("prover"), details)
1238}
1239
1240/// Implements the `prover` sub-command.
1241/// CLI behavior:
1242/// - Installs signal handlers that set a global cancel flag.
1243/// - Reads the plan JSON from a file or stdin.
1244/// - Parses and executes the plan with the requested concurrency.
1245/// Process exits with status 0 on success, 1 on failure.
1246pub fn handle_prover(matches: &clap::ArgMatches, _config: &Option<ToolchainConfig>) {
1247    let cores: usize = matches
1248        .get_one::<String>("cores")
1249        .map(|s| s.parse::<usize>().unwrap_or(1))
1250        .unwrap_or(1)
1251        .max(1);
1252    let plan_path = matches
1253        .get_one::<String>("plan_json_file")
1254        .expect("plan_json_file arg missing");
1255    let output_json_path = matches
1256        .get_one::<String>("output_json")
1257        .map(|s| s.to_string());
1258
1259    info!(
1260        "prover: starting with cores={} plan_source= {}",
1261        cores,
1262        if plan_path == "-" { "stdin" } else { plan_path }
1263    );
1264
1265    let plan_json = if plan_path == "-" {
1266        use std::io::Read;
1267        let mut buf = String::new();
1268        if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
1269            fail_and_exit(
1270                &output_json_path,
1271                "read-plan",
1272                "Failed to read plan from stdin",
1273                &e.to_string(),
1274                &[],
1275            );
1276        }
1277        buf
1278    } else {
1279        match std::fs::read_to_string(plan_path) {
1280            Ok(s) => s,
1281            Err(e) => {
1282                fail_and_exit(
1283                    &output_json_path,
1284                    "read-plan",
1285                    "Failed to read plan JSON file",
1286                    &e.to_string(),
1287                    &[("path", plan_path)],
1288                );
1289            }
1290        }
1291    };
1292
1293    let plan: ProverPlan = match serde_json::from_str(&plan_json) {
1294        Ok(p) => p,
1295        Err(e) => {
1296            fail_and_exit(
1297                &output_json_path,
1298                "parse-plan",
1299                "Failed to parse ProverPlan JSON",
1300                &e.to_string(),
1301                &[],
1302            );
1303        }
1304    };
1305
1306    match run_prover_plan(plan, cores) {
1307        Ok(report) => {
1308            if let Some(path) = &output_json_path {
1309                // Write full report including plan tree and captured outputs; do not print
1310                // per-task outputs.
1311                let s = serde_json::to_string(&report).unwrap();
1312                if let Err(e) = std::fs::write(path, s) {
1313                    warn!("prover: failed writing output_json to {}: {}", path, e);
1314                }
1315            } else {
1316                // No JSON requested; print all task outputs together at the end in DFS order.
1317                fn print_task_outputs(node: &ProverReportNode) {
1318                    match node {
1319                        ProverReportNode::Task {
1320                            cmdline,
1321                            stdout,
1322                            stderr,
1323                            ..
1324                        } => {
1325                            println!("{}", "-".repeat(80));
1326                            println!("{}", cmdline.as_deref().unwrap_or(""));
1327                            let out = stdout.as_deref().unwrap_or("");
1328                            let err = stderr.as_deref().unwrap_or("");
1329                            if out.is_empty() {
1330                                println!(">>>> stdout:");
1331                            } else {
1332                                println!(">>>> stdout:\n{}", out);
1333                            }
1334                            if err.is_empty() {
1335                                println!(">>>> stderr:");
1336                            } else {
1337                                println!(">>>> stderr:\n{}", err);
1338                            }
1339                            println!("{}", "-".repeat(80));
1340                        }
1341                        ProverReportNode::Group { tasks, .. } => {
1342                            for t in tasks {
1343                                print_task_outputs(t);
1344                            }
1345                        }
1346                    }
1347                }
1348                print_task_outputs(&report.plan);
1349            }
1350            if report.success {
1351                println!("Overall: success");
1352                std::process::exit(0)
1353            } else {
1354                println!("Overall: failure");
1355                std::process::exit(1)
1356            }
1357        }
1358        Err(e) => {
1359            let exc = e.to_string();
1360            // Best-effort JSON + CLI error with context, then exit.
1361            fail_and_exit(&output_json_path, "run", "prover run failed", &exc, &[]);
1362        }
1363    }
1364}