#![allow(clippy::needless_pass_by_value)]
use std::fmt;
use std::io::ErrorKind;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use std::thread;
use std::thread::available_parallelism;
type BoxedCause = Box<dyn std::error::Error + Send + Sync + 'static>;
use crossbeam::channel::{Receiver, Sender, unbounded};
type ProcFilesFunction<Config> = dyn Fn(PathBuf, &Config) -> std::io::Result<()> + Send + Sync;
#[derive(Debug)]
struct JobItem<Config> {
path: PathBuf,
cfg: Arc<Config>,
}
type JobReceiver<Config> = Receiver<Option<JobItem<Config>>>;
type JobSender<Config> = Sender<Option<JobItem<Config>>>;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum NumJobs {
#[default]
Auto,
Explicit(NonZeroUsize),
}
impl NumJobs {
#[must_use]
pub fn resolve(self) -> usize {
match self {
Self::Auto => available_parallelism().map_or(1, NonZeroUsize::get),
Self::Explicit(n) => n.get(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseNumJobsError {
Zero {
input: String,
},
NotAPositiveInteger {
input: String,
},
}
impl ParseNumJobsError {
#[must_use]
pub fn input(&self) -> &str {
match self {
Self::Zero { input } | Self::NotAPositiveInteger { input } => input,
}
}
}
impl fmt::Display for ParseNumJobsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Zero { .. } => f.write_str(
"--num-jobs must be >= 1 (use `--num-jobs 1` to force serial mode, \
or `--num-jobs auto` for the OS-reported CPU count)",
),
Self::NotAPositiveInteger { input } => write!(
f,
"--num-jobs: expected a positive integer or `auto`, got `{input}`"
),
}
}
}
impl std::error::Error for ParseNumJobsError {}
impl FromStr for NumJobs {
type Err = ParseNumJobsError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.eq_ignore_ascii_case("auto") {
return Ok(Self::Auto);
}
match s.parse::<usize>() {
Ok(n) => {
NonZeroUsize::new(n)
.map(Self::Explicit)
.ok_or_else(|| ParseNumJobsError::Zero {
input: s.to_owned(),
})
}
Err(_) => Err(ParseNumJobsError::NotAPositiveInteger {
input: s.to_owned(),
}),
}
}
}
fn consumer<Config, ProcFiles>(receiver: JobReceiver<Config>, func: Arc<ProcFiles>)
where
ProcFiles: Fn(PathBuf, &Config) -> std::io::Result<()> + Send + Sync,
{
while let Ok(Some(job)) = receiver.recv() {
let path = job.path.clone();
if let Err(err) = func(job.path, &job.cfg)
&& let Some(message) = per_file_error_message(&path, &err)
{
eprintln!("{message}");
}
}
}
fn per_file_error_message(path: &Path, err: &std::io::Error) -> Option<String> {
if err.kind() == ErrorKind::BrokenPipe {
return None;
}
Some(format!("error processing {}: {err}", path.display()))
}
fn send_file<T: 'static + Send + Sync>(
path: PathBuf,
cfg: &Arc<T>,
sender: &JobSender<T>,
) -> Result<(), ConcurrentErrors> {
sender
.send(Some(JobItem {
path,
cfg: Arc::clone(cfg),
}))
.map_err(|e| ConcurrentErrors::Sender(Box::new(e)))
}
fn explore<Config: 'static + Send + Sync>(
files_data: FilesData,
cfg: &Arc<Config>,
sender: &JobSender<Config>,
) -> Result<(), ConcurrentErrors> {
for path in files_data.paths {
if !path.is_file() {
eprintln!("Warning: not a regular file, skipping: {}", path.display());
continue;
}
send_file(path, cfg, sender)?;
}
Ok(())
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ConcurrentErrors {
Producer(String),
Sender(BoxedCause),
Receiver(String),
Thread(BoxedCause),
}
impl fmt::Display for ConcurrentErrors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Producer(msg) => write!(f, "producer thread failed: {msg}"),
Self::Sender(cause) => write!(f, "failed to send a file to a worker: {cause}"),
Self::Receiver(msg) => write!(f, "consumer thread failed: {msg}"),
Self::Thread(cause) => write!(f, "failed to spawn a worker thread: {cause}"),
}
}
}
impl std::error::Error for ConcurrentErrors {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Producer(_) | Self::Receiver(_) => None,
Self::Sender(cause) | Self::Thread(cause) => Some(cause.as_ref()),
}
}
}
#[derive(Debug)]
pub struct FilesData {
pub paths: Vec<PathBuf>,
}
pub struct ConcurrentRunner<Config> {
proc_files: Box<ProcFilesFunction<Config>>,
num_jobs: usize,
}
impl<Config: 'static + Send + Sync> ConcurrentRunner<Config> {
pub fn new<ProcFiles>(num_jobs: usize, proc_files: ProcFiles) -> Self
where
ProcFiles: 'static + Fn(PathBuf, &Config) -> std::io::Result<()> + Send + Sync,
{
let num_jobs = std::cmp::max(2, num_jobs) - 1;
Self {
proc_files: Box::new(proc_files),
num_jobs,
}
}
pub fn run(self, config: Config, files_data: FilesData) -> Result<(), ConcurrentErrors> {
let cfg = Arc::new(config);
let (sender, receiver) = unbounded();
let producer = {
let sender = sender.clone();
match thread::Builder::new()
.name(String::from("Producer"))
.spawn(move || explore(files_data, &cfg, &sender))
{
Ok(producer) => producer,
Err(e) => return Err(ConcurrentErrors::Thread(Box::new(e))),
}
};
let mut receivers = Vec::with_capacity(self.num_jobs);
let proc_files = Arc::new(self.proc_files);
for i in 0..self.num_jobs {
let receiver = receiver.clone();
let proc_files = proc_files.clone();
let t = match thread::Builder::new()
.name(format!("Consumer {i}"))
.spawn(move || {
consumer(receiver, proc_files);
}) {
Ok(receiver) => receiver,
Err(e) => return Err(ConcurrentErrors::Thread(Box::new(e))),
};
receivers.push(t);
}
let Ok(walk_result) = producer.join() else {
return Err(ConcurrentErrors::Producer(
"Child thread panicked".to_owned(),
));
};
walk_result?;
for _ in 0..self.num_jobs {
if let Err(e) = sender.send(None) {
return Err(ConcurrentErrors::Sender(Box::new(e)));
}
}
for receiver in receivers {
if receiver.join().is_err() {
return Err(ConcurrentErrors::Receiver(
"A thread used to process a file panicked".to_owned(),
));
}
}
Ok(())
}
}
#[cfg(test)]
#[path = "concurrent_files_tests.rs"]
mod tests;