Skip to main content

pushkin_daemon/
regen.rs

1//! Eager epoch probe + sequential regeneration queue (spec §5.2): probe
2//! headers cheaply at startup, regenerate stale artifacts one at a time
3//! with a per-item timeout. Eager because lazy checks leave artifacts
4//! silently stale and never run in short-lived processes; sequential to
5//! bound resource use; per-item timeout so one stuck item can't block
6//! the queue.
7
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11/// The header line every generated file carries (spec §5.2, emitted by
12/// `pushkin-compiler::targets::header`).
13const EPOCH_MARKER: &str = "pushkin-epoch:";
14
15/// How much of a file the probe reads: the epoch header sits in the first
16/// lines by construction; reading whole trees would defeat "cheap".
17const PROBE_HEAD_BYTES: usize = 512;
18
19/// Per-item result of a regeneration pass; the queue reports every item,
20/// never silently drops one.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum RegenOutcome {
23    Regenerated,
24    TimedOut,
25    Failed(String),
26}
27
28/// Scan a generated tree for files whose `pushkin-epoch:` header lags
29/// `expected_epoch` (missing header = stale). Returns paths relative to
30/// `dir`, unordered.
31///
32/// # Errors
33/// `std::io::Error` when the tree cannot be read.
34pub fn probe_stale(dir: &Path, expected_epoch: u32) -> std::io::Result<Vec<PathBuf>> {
35    let mut stale = Vec::new();
36    let mut pending = vec![dir.to_path_buf()];
37    while let Some(current) = pending.pop() {
38        for entry in std::fs::read_dir(&current)? {
39            let entry = entry?;
40            let path = entry.path();
41            if path.is_dir() {
42                pending.push(path);
43            } else if file_epoch(&path)? != Some(expected_epoch) {
44                if let Ok(relative) = path.strip_prefix(dir) {
45                    stale.push(relative.to_path_buf());
46                }
47            }
48        }
49    }
50    Ok(stale)
51}
52
53/// The epoch stamped in a file's head, or `None` when no marker is found
54/// (headerless = stale by definition — a generated file we can't date is
55/// a generated file we can't trust).
56fn file_epoch(path: &Path) -> std::io::Result<Option<u32>> {
57    use std::io::Read;
58    let mut head = vec![0_u8; PROBE_HEAD_BYTES];
59    let mut file = std::fs::File::open(path)?;
60    let read = file.read(&mut head)?;
61    head.truncate(read);
62    let text = String::from_utf8_lossy(&head);
63    Ok(text.lines().find_map(|line| {
64        let (_, rest) = line.split_once(EPOCH_MARKER)?;
65        rest.trim().parse::<u32>().ok()
66    }))
67}
68
69/// Run `worker` over `items` strictly one at a time, capping each item at
70/// `timeout`. A timed-out item is reported as `TimedOut` and the queue
71/// moves on immediately; its worker thread is left to finish and be
72/// dropped (threads cannot be force-killed safely — the leak is bounded
73/// by the toolchain call it wraps, and the alternative is a wedged queue).
74pub fn run_queue<W>(items: &[PathBuf], timeout: Duration, worker: W) -> Vec<(PathBuf, RegenOutcome)>
75where
76    W: Fn(&Path) -> Result<(), String> + Send + Sync + 'static,
77{
78    let worker = std::sync::Arc::new(worker);
79    let mut outcomes = Vec::with_capacity(items.len());
80    for item in items {
81        outcomes.push((item.clone(), run_one(item, timeout, &worker)));
82    }
83    outcomes
84}
85
86fn run_one<W>(item: &Path, timeout: Duration, worker: &std::sync::Arc<W>) -> RegenOutcome
87where
88    W: Fn(&Path) -> Result<(), String> + Send + Sync + 'static,
89{
90    let (sender, receiver) = std::sync::mpsc::channel();
91    let worker = std::sync::Arc::clone(worker);
92    let path = item.to_path_buf();
93    // One worker thread per item, joined via the channel within `timeout`.
94    // Deliberately NOT a thread pool: sequential is the §5.2 contract.
95    std::thread::spawn(move || {
96        let _ = sender.send(worker(&path));
97    });
98    match receiver.recv_timeout(timeout) {
99        Ok(Ok(())) => RegenOutcome::Regenerated,
100        Ok(Err(message)) => RegenOutcome::Failed(message),
101        Err(_) => RegenOutcome::TimedOut,
102    }
103}