main
Drew Owens 0.9.12 source 97dcc9e 19d ago
//! Progress blocks for slow verbs.
//!
//! Two halves of one contract:
//!   * a slow verb's own response ends with a TERMINAL frame — `percent:100`
//!     plus the real `elapsedSec`, so the caller learns what the operation
//!     actually costs on this machine;
//!   * `swx_progress` returns `active[]` — the live frames of everything in
//!     flight — so an AI (or a UI) can poll ~1/s and drive a bar while a
//!     90-second interference check runs.
//!
//! A job registers on creation and deregisters on drop, so an early return or a
//! panic can never leave a phantom job pinned in `active[]`.

use serde_json::{json, Value};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;

struct Entry {
    phase: &'static str,
    step_label: String,
    estimated_sec: u64,
    started: Instant,
    /// What the verb has done so far, for `swx_progress` (0.9.12): a sweep's
    /// stage and its counts.
    detail: Value,
}

#[derive(Default)]
pub struct Jobs {
    seq: AtomicU64,
    live: Mutex<Vec<(u64, Entry)>>,
}

impl Jobs {
    pub fn new() -> Arc<Self> {
        Arc::new(Self::default())
    }

    /// Register a job for the life of the returned guard.
    pub fn start(
        self: &Arc<Self>,
        phase: &'static str,
        step_label: impl Into<String>,
        estimated_sec: u64,
    ) -> JobGuard {
        let id = self.seq.fetch_add(1, Ordering::SeqCst) + 1;
        let started = Instant::now();
        let entry = Entry { phase, step_label: step_label.into(), estimated_sec, started, detail: Value::Null };
        self.live.lock().unwrap_or_else(|e| e.into_inner()).push((id, entry));
        JobGuard { jobs: Arc::clone(self), id, started }
    }

    /// Live frames for `swx_progress`.
    pub fn active(&self) -> Vec<Value> {
        let live = self.live.lock().unwrap_or_else(|e| e.into_inner());
        live.iter()
            .map(|(_, e)| {
                let elapsed = round1(e.started.elapsed().as_secs_f64());
                json!({
                    "phase": e.phase,
                    "stepLabel": e.step_label,
                    "percent": Value::Null,
                    "elapsedSec": elapsed,
                    "estimatedSec": e.estimated_sec,
                    "etaSec": round1((e.estimated_sec as f64 - elapsed).max(0.0)),
                    "confidence": "estimate",
                    "startKind": "warm",
                    "detail": e.detail,
                })
            })
            .collect()
    }
}

pub struct JobGuard {
    jobs: Arc<Jobs>,
    id: u64,
    started: Instant,
}

impl JobGuard {
    /// Say what the verb is doing now — shown by `swx_progress` while it runs.
    pub fn update(&self, step_label: impl Into<String>, detail: Value) {
        let mut live = self.jobs.live.lock().unwrap_or_else(|e| e.into_inner());
        if let Some((_, e)) = live.iter_mut().find(|(id, _)| *id == self.id) {
            e.step_label = step_label.into();
            e.detail = detail;
        }
    }

    /// The terminal frame that rides on the verb's own response.
    pub fn finished(&self) -> Value {
        let live = self.jobs.live.lock().unwrap_or_else(|e| e.into_inner());
        let (phase, label, est) = live
            .iter()
            .find(|(id, _)| *id == self.id)
            .map(|(_, e)| (e.phase, e.step_label.clone(), e.estimated_sec))
            .unwrap_or(("unknown", String::new(), 0));
        json!({
            "phase": phase,
            "stepLabel": label,
            "percent": 100,
            "elapsedSec": round1(self.started.elapsed().as_secs_f64()),
            "estimatedSec": est,
            "etaSec": 0,
            "confidence": "estimate",
            "startKind": "warm",
        })
    }
}

impl Drop for JobGuard {
    fn drop(&mut self) {
        let mut live = self.jobs.live.lock().unwrap_or_else(|e| e.into_inner());
        live.retain(|(id, _)| *id != self.id);
    }
}

fn round1(v: f64) -> f64 {
    (v * 10.0).round() / 10.0
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_job_is_active_only_while_it_lives() {
        let jobs = Jobs::new();
        assert!(jobs.active().is_empty());
        {
            let g = jobs.start("export", "Exporting STEP", 45);
            let active = jobs.active();
            assert_eq!(active.len(), 1);
            assert_eq!(active[0]["phase"], "export");
            assert_eq!(active[0]["percent"], Value::Null);
            assert_eq!(g.finished()["percent"], 100);
            assert_eq!(g.finished()["etaSec"], 0);
        }
        assert!(jobs.active().is_empty(), "the guard must deregister on drop");
    }

    #[test]
    fn finished_reports_the_label_and_estimate() {
        let jobs = Jobs::new();
        let g = jobs.start("bom", "Walking assembly components", 60);
        let f = g.finished();
        assert_eq!(f["stepLabel"], "Walking assembly components");
        assert_eq!(f["estimatedSec"], 60);
    }
}