use mandible_extract::{FillResult, ResolvedTool, Runner};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{mpsc, Arc};
const MAX_WARMED_NODES: usize = 4096;
pub struct WarmedNode {
pub path: Vec<String>,
pub result: FillResult,
}
pub struct Warmer {
pool: rayon::ThreadPool,
cancelled: Arc<AtomicBool>,
submitted: AtomicUsize,
generation: Arc<AtomicUsize>,
tx: Sender<WarmedNode>,
rx: Receiver<WarmedNode>,
}
impl Warmer {
pub fn new() -> Warmer {
let threads = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.saturating_mul(4)
.clamp(4, 32);
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.thread_name(|i| format!("mandible-warm-{i}"))
.build()
.expect("failed to build the background warming thread pool");
let (tx, rx) = mpsc::channel();
Warmer {
pool,
cancelled: Arc::new(AtomicBool::new(false)),
submitted: AtomicUsize::new(0),
generation: Arc::new(AtomicUsize::new(0)),
tx,
rx,
}
}
pub fn submit(
&self,
runner: Arc<Runner>,
tool: ResolvedTool,
path: Vec<String>,
existing: mandible_core::CommandNode,
) -> bool {
if self.cancelled.load(Ordering::Relaxed) {
return false;
}
if self.submitted.fetch_add(1, Ordering::Relaxed) >= MAX_WARMED_NODES {
return false;
}
let cancelled = Arc::clone(&self.cancelled);
let generation = Arc::clone(&self.generation);
let submitted_under = generation.load(Ordering::Relaxed);
let tx = self.tx.clone();
self.pool.spawn(move || {
let stale = |g: &AtomicUsize| g.load(Ordering::Relaxed) != submitted_under;
if cancelled.load(Ordering::Relaxed) || stale(&generation) {
return;
}
let result = runner.fill_node(&tool, &path, existing);
if cancelled.load(Ordering::Relaxed) || stale(&generation) {
return;
}
let _ = tx.send(WarmedNode { path, result });
});
true
}
pub fn warm_children(
&self,
runner: &Arc<Runner>,
tool: &ResolvedTool,
node: &mandible_core::CommandNode,
path: &[String],
) -> Vec<Vec<String>> {
let mut queued = Vec::new();
for child in &node.subcommands {
if child.children_filled {
continue;
}
let mut child_path = path.to_vec();
child_path.push(child.name.clone());
if !self.submit(
Arc::clone(runner),
tool.clone(),
child_path.clone(),
child.clone(),
) {
break;
}
queued.push(child_path);
}
queued
}
pub fn drain(&self) -> Vec<WarmedNode> {
self.rx.try_iter().collect()
}
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Relaxed);
}
pub fn reset(&self) {
self.generation.fetch_add(1, Ordering::Relaxed);
self.submitted.store(0, Ordering::Relaxed);
while self.rx.try_recv().is_ok() {}
}
}
impl Default for Warmer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use mandible_core::{CommandNode, Provenance, Source};
fn warmed(path: &[&str]) -> WarmedNode {
WarmedNode {
path: path.iter().map(|s| s.to_string()).collect(),
result: FillResult {
node: CommandNode::new(
path.last().copied().unwrap_or("root"),
Provenance::single(Source::HelpText),
),
tier_statuses: Vec::new(),
elapsed: std::time::Duration::ZERO,
},
}
}
#[test]
fn reset_replenishes_the_warming_budget() {
let warmer = Warmer::new();
warmer.submitted.store(MAX_WARMED_NODES, Ordering::Relaxed);
warmer.reset();
assert_eq!(warmer.submitted.load(Ordering::Relaxed), 0);
}
#[test]
fn reset_discards_undrained_results_from_the_old_generation() {
let warmer = Warmer::new();
warmer.tx.send(warmed(&["git", "rebase"])).unwrap();
warmer.tx.send(warmed(&["git", "add"])).unwrap();
warmer.reset();
assert!(
warmer.drain().is_empty(),
"stale fills must not be spliced into the replacement tree"
);
}
#[test]
fn a_job_submitted_before_a_reset_is_stale_afterwards() {
let warmer = Warmer::new();
let submitted_under = warmer.generation.load(Ordering::Relaxed);
warmer.reset();
assert_ne!(
warmer.generation.load(Ordering::Relaxed),
submitted_under,
"in-flight jobs detect abandonment by this counter changing"
);
}
#[test]
fn drain_returns_results_when_nothing_was_reset() {
let warmer = Warmer::new();
warmer.tx.send(warmed(&["git", "rebase"])).unwrap();
assert_eq!(warmer.drain().len(), 1);
}
}