Skip to main content

big_code_analysis/
concurrent_files.rs

1#![allow(clippy::needless_pass_by_value)]
2
3use std::fmt;
4use std::io::ErrorKind;
5use std::num::NonZeroUsize;
6use std::path::{Path, PathBuf};
7use std::str::FromStr;
8use std::sync::Arc;
9use std::thread;
10use std::thread::available_parallelism;
11
12/// A boxed, thread-safe error cause carried by [`ConcurrentErrors`].
13///
14/// The runner moves results across thread boundaries (the producer /
15/// consumer join seam in [`ConcurrentRunner::run`]), so any carried
16/// source must be `Send`; it is also `'static` so it can outlive the
17/// worker threads that produced it and so [`ConcurrentErrors`] stays
18/// non-generic over the user's `Config`.
19type BoxedCause = Box<dyn std::error::Error + Send + Sync + 'static>;
20
21use crossbeam::channel::{Receiver, Sender, unbounded};
22
23type ProcFilesFunction<Config> = dyn Fn(PathBuf, &Config) -> std::io::Result<()> + Send + Sync;
24
25#[derive(Debug)]
26struct JobItem<Config> {
27    path: PathBuf,
28    cfg: Arc<Config>,
29}
30
31type JobReceiver<Config> = Receiver<Option<JobItem<Config>>>;
32type JobSender<Config> = Sender<Option<JobItem<Config>>>;
33
34/// Parsed worker-count selector for [`ConcurrentRunner`], shared by the
35/// `bca` CLI and the `bca-web` server so both binaries resolve the same
36/// `<N|auto>` contract.
37///
38/// `Auto` is the default and resolves to the OS-reported effective CPU
39/// count via [`std::thread::available_parallelism`], which honors Linux
40/// cgroup CPU quotas, cgroup v2 `cpu.max`, and `sched_setaffinity`
41/// cpusets. On macOS / Windows it falls back to the OS CPU count.
42/// `Explicit(n)` is an integer override; [`NumJobs::from_str`] rejects
43/// `0` with a clear error message rather than silently degrading to
44/// serial mode.
45///
46/// This type is intentionally clap-agnostic — it is a plain [`FromStr`]
47/// the binaries wire into clap via `value_parser` / `value_name`, so the
48/// core library gains no clap dependency.
49#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
50pub enum NumJobs {
51    /// Use the OS-reported effective CPU count (cgroup / cpuset aware).
52    #[default]
53    Auto,
54    /// Use an explicit, non-zero worker count.
55    Explicit(NonZeroUsize),
56}
57
58impl NumJobs {
59    /// Resolve to the worker count handed to [`ConcurrentRunner`].
60    ///
61    /// `Auto` falls back to `1` if [`available_parallelism`] errors —
62    /// keeping the caller alive even in unusual sandboxes where the
63    /// syscall fails. The returned value is always `>= 1`.
64    #[must_use]
65    pub fn resolve(self) -> usize {
66        match self {
67            Self::Auto => available_parallelism().map_or(1, NonZeroUsize::get),
68            Self::Explicit(n) => n.get(),
69        }
70    }
71}
72
73/// Error returned by [`NumJobs::from_str`] when the input is neither
74/// `auto` nor a usable positive integer.
75///
76/// Named (rather than a bare `String`) so callers can match the failure
77/// mode and recover the rejected input via [`ParseNumJobsError::input`],
78/// matching the typed-error convention of
79/// [`ParseMetricError`](crate::ParseMetricError) /
80/// [`ParseLangError`](crate::ParseLangError).
81///
82/// Deliberately exhaustive: `usize::from_str` collapses every malformed
83/// integer (negative, overflowing, non-numeric) into the single
84/// [`NotAPositiveInteger`](Self::NotAPositiveInteger) arm, so the only
85/// other distinct mode is the in-range-but-zero rejection. A new mode
86/// would be a deliberate change, and callers benefit from matching both
87/// arms without a wildcard.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum ParseNumJobsError {
90    /// The input parsed as `0`, below the `>= 1` worker-count floor.
91    Zero {
92        /// The rejected input, verbatim (e.g. `"0"`).
93        input: String,
94    },
95    /// The input was not `auto` and did not parse as a positive integer
96    /// (non-numeric, negative, or overflowing).
97    NotAPositiveInteger {
98        /// The rejected input, verbatim.
99        input: String,
100    },
101}
102
103impl ParseNumJobsError {
104    /// The rejected input that failed to parse as a [`NumJobs`] value.
105    ///
106    /// Lets callers recover the offending string programmatically rather
107    /// than scraping it out of the [`Display`](fmt::Display) output.
108    #[must_use]
109    pub fn input(&self) -> &str {
110        match self {
111            Self::Zero { input } | Self::NotAPositiveInteger { input } => input,
112        }
113    }
114}
115
116impl fmt::Display for ParseNumJobsError {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        match self {
119            Self::Zero { .. } => f.write_str(
120                "--num-jobs must be >= 1 (use `--num-jobs 1` to force serial mode, \
121                 or `--num-jobs auto` for the OS-reported CPU count)",
122            ),
123            Self::NotAPositiveInteger { input } => write!(
124                f,
125                "--num-jobs: expected a positive integer or `auto`, got `{input}`"
126            ),
127        }
128    }
129}
130
131impl std::error::Error for ParseNumJobsError {}
132
133impl FromStr for NumJobs {
134    type Err = ParseNumJobsError;
135
136    fn from_str(s: &str) -> Result<Self, Self::Err> {
137        if s.eq_ignore_ascii_case("auto") {
138            return Ok(Self::Auto);
139        }
140        match s.parse::<usize>() {
141            Ok(n) => {
142                NonZeroUsize::new(n)
143                    .map(Self::Explicit)
144                    .ok_or_else(|| ParseNumJobsError::Zero {
145                        input: s.to_owned(),
146                    })
147            }
148            Err(_) => Err(ParseNumJobsError::NotAPositiveInteger {
149                input: s.to_owned(),
150            }),
151        }
152    }
153}
154
155fn consumer<Config, ProcFiles>(receiver: JobReceiver<Config>, func: Arc<ProcFiles>)
156where
157    ProcFiles: Fn(PathBuf, &Config) -> std::io::Result<()> + Send + Sync,
158{
159    // `Ok(None)` is the poison-pill terminating the consumer loop;
160    // `Err(_)` means the channel was closed (sender dropped).
161    while let Ok(Some(job)) = receiver.recv() {
162        let path = job.path.clone();
163
164        if let Err(err) = func(job.path, &job.cfg)
165            && let Some(message) = per_file_error_message(&path, &err)
166        {
167            eprintln!("{message}");
168        }
169    }
170}
171
172/// Format a per-file processing error for stderr, or return `None` when it
173/// should be swallowed silently.
174///
175/// `BrokenPipe` is swallowed to match the CLI's `write_stdout_or_die` policy
176/// (`big-code-analysis-cli/src/lib.rs`): a closed downstream pipe (`| head`,
177/// `| less`, …) is the routine case, not a failure. Every other error is
178/// `Display`-formatted so internal type structure does not leak into
179/// user-facing diagnostics (a Debug `Os { code, kind, .. }` struct).
180fn per_file_error_message(path: &Path, err: &std::io::Error) -> Option<String> {
181    if err.kind() == ErrorKind::BrokenPipe {
182        return None;
183    }
184    Some(format!("error processing {}: {err}", path.display()))
185}
186
187fn send_file<T: 'static + Send + Sync>(
188    path: PathBuf,
189    cfg: &Arc<T>,
190    sender: &JobSender<T>,
191) -> Result<(), ConcurrentErrors> {
192    sender
193        .send(Some(JobItem {
194            path,
195            cfg: Arc::clone(cfg),
196        }))
197        .map_err(|e| ConcurrentErrors::Sender(Box::new(e)))
198}
199
200/// Producer body: dispatch each resolved file in `files_data.paths`
201/// to the consumer pool.
202///
203/// `paths` is a **terminal file list** — already resolved, anchored,
204/// and filtered by the caller (the `big-code-analysis-cli` walk seam
205/// resolves it via its gitignore-aware `expand_seed_paths`). This
206/// function therefore performs no directory traversal and no glob
207/// filtering of its own: it skips entries that are missing or are not
208/// regular files (warning to stderr) and sends the rest. For the
209/// `big-code-analysis-cli` caller this skip is a safety net only —
210/// `expand_seed_paths` already yields existing regular files — but it
211/// keeps this public entry point robust for a direct library caller
212/// that hands in an arbitrary path. Re-walking or re-filtering here
213/// would re-introduce the emitted-path-form dependence that #488/#489
214/// removed (see #495).
215fn explore<Config: 'static + Send + Sync>(
216    files_data: FilesData,
217    cfg: &Arc<Config>,
218    sender: &JobSender<Config>,
219) -> Result<(), ConcurrentErrors> {
220    for path in files_data.paths {
221        if !path.is_file() {
222            eprintln!("Warning: not a regular file, skipping: {}", path.display());
223            continue;
224        }
225        send_file(path, cfg, sender)?;
226    }
227
228    Ok(())
229}
230
231/// Series of errors that might happen when processing files concurrently.
232///
233/// Marked `#[non_exhaustive]` so future failure modes can be added
234/// without a SemVer break. Variants whose construction site has a
235/// concrete underlying [`std::error::Error`] (`Sender`, `Thread`)
236/// carry it as a boxed source and surface it through
237/// [`std::error::Error::source`]; variants whose only available
238/// information is a thread-panic payload (`Producer`, `Receiver`)
239/// carry a message and return `None` from `source`, because a join
240/// failure yields a `Box<dyn Any + Send>`, not an `Error`.
241#[derive(Debug)]
242#[non_exhaustive]
243pub enum ConcurrentErrors {
244    /// Producer side error.
245    ///
246    /// The producer thread panicked and joining it failed. The panic
247    /// payload is not a [`std::error::Error`], so this variant carries
248    /// only a message and has no [`source`](std::error::Error::source).
249    Producer(String),
250    /// Sender side error.
251    ///
252    /// An item (or the poison-pill) could not be placed on the channel
253    /// because every receiver was dropped. Carries the originating
254    /// channel send error as its [`source`](std::error::Error::source).
255    Sender(BoxedCause),
256    /// Receiver side error.
257    ///
258    /// A consumer thread panicked and joining it failed. The panic
259    /// payload is not a [`std::error::Error`], so this variant carries
260    /// only a message and has no [`source`](std::error::Error::source).
261    Receiver(String),
262    /// Thread side error.
263    ///
264    /// A worker thread (producer or consumer) could not be spawned.
265    /// Carries the originating [`std::io::Error`] as its
266    /// [`source`](std::error::Error::source).
267    Thread(BoxedCause),
268}
269
270impl fmt::Display for ConcurrentErrors {
271    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272        match self {
273            Self::Producer(msg) => write!(f, "producer thread failed: {msg}"),
274            Self::Sender(cause) => write!(f, "failed to send a file to a worker: {cause}"),
275            Self::Receiver(msg) => write!(f, "consumer thread failed: {msg}"),
276            Self::Thread(cause) => write!(f, "failed to spawn a worker thread: {cause}"),
277        }
278    }
279}
280
281impl std::error::Error for ConcurrentErrors {
282    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
283        match self {
284            // Producer / Receiver originate from a thread-join panic
285            // payload (`Box<dyn Any + Send>`), which is not an Error.
286            Self::Producer(_) | Self::Receiver(_) => None,
287            Self::Sender(cause) | Self::Thread(cause) => Some(cause.as_ref()),
288        }
289    }
290}
291
292/// A resolved, terminal file list for [`ConcurrentRunner`].
293///
294/// Each entry in `paths` is processed as a single regular file: the
295/// runner does **not** walk directories and does **not** apply any
296/// include/exclude filtering. Callers are responsible for resolving,
297/// anchoring, and filtering the file set before constructing this
298/// struct (the `big-code-analysis-cli` walk does so via its
299/// gitignore-aware, walk-root-anchored `expand_seed_paths`). This is
300/// the single filtering seam: there is no second, emitted-path-form
301/// matcher in the library that could re-inherit the path-form
302/// dependence #488/#489 removed (see #495).
303#[derive(Debug)]
304pub struct FilesData {
305    /// The resolved files to process. Each path is treated as a
306    /// terminal regular file; directories and non-existent paths are
307    /// skipped with a warning.
308    pub paths: Vec<PathBuf>,
309}
310
311/// A runner to process files concurrently.
312pub struct ConcurrentRunner<Config> {
313    proc_files: Box<ProcFilesFunction<Config>>,
314    num_jobs: usize,
315}
316
317impl<Config: 'static + Send + Sync> ConcurrentRunner<Config> {
318    /// Creates a new `ConcurrentRunner`.
319    ///
320    /// * `num_jobs` - Total worker budget (one producer thread plus the
321    ///   consumer threads). [`run`](Self::run) always spawns a single
322    ///   producer thread, so the number of consumer threads it spawns is
323    ///   `max(2, num_jobs) - 1`. This means `0`, `1`, and `2` all collapse
324    ///   to a single consumer (no panic, no linear scaling below 2);
325    ///   values of `3` or more yield `num_jobs - 1` consumers.
326    /// * `proc_files` - Function that processes each file in the list.
327    pub fn new<ProcFiles>(num_jobs: usize, proc_files: ProcFiles) -> Self
328    where
329        ProcFiles: 'static + Fn(PathBuf, &Config) -> std::io::Result<()> + Send + Sync,
330    {
331        let num_jobs = std::cmp::max(2, num_jobs) - 1;
332        Self {
333            proc_files: Box::new(proc_files),
334            num_jobs,
335        }
336    }
337
338    /// Runs the producer-consumer pool over the terminal file list in
339    /// `files_data`. Each path is dispatched to a worker as a single
340    /// regular file; this runner performs no directory traversal or
341    /// glob filtering (the caller resolves and filters the file set —
342    /// see [`FilesData`]).
343    ///
344    /// * `config` - Information used to process a file.
345    /// * `files_data` - The resolved, terminal file list to process.
346    ///
347    /// # Errors
348    ///
349    /// Returns [`ConcurrentErrors::Thread`] when any worker thread
350    /// (the single producer OR one of the `num_jobs` consumers)
351    /// cannot be spawned via [`std::thread::Builder::spawn`];
352    /// [`ConcurrentErrors::Producer`] when the producer thread
353    /// panics and join fails;
354    /// [`ConcurrentErrors::Sender`] when a worker cannot place an
355    /// item (or the post-dispatch `None` poison-pill) on the channel;
356    /// [`ConcurrentErrors::Receiver`] when a consumer thread panics
357    /// and its join fails. Per-file processing errors raised by the
358    /// user-supplied callback are surfaced through the callback
359    /// itself, not through this `Result`.
360    pub fn run(self, config: Config, files_data: FilesData) -> Result<(), ConcurrentErrors> {
361        let cfg = Arc::new(config);
362
363        let (sender, receiver) = unbounded();
364
365        let producer = {
366            let sender = sender.clone();
367
368            match thread::Builder::new()
369                .name(String::from("Producer"))
370                .spawn(move || explore(files_data, &cfg, &sender))
371            {
372                Ok(producer) => producer,
373                Err(e) => return Err(ConcurrentErrors::Thread(Box::new(e))),
374            }
375        };
376
377        let mut receivers = Vec::with_capacity(self.num_jobs);
378        let proc_files = Arc::new(self.proc_files);
379        for i in 0..self.num_jobs {
380            let receiver = receiver.clone();
381            let proc_files = proc_files.clone();
382
383            let t = match thread::Builder::new()
384                .name(format!("Consumer {i}"))
385                .spawn(move || {
386                    consumer(receiver, proc_files);
387                }) {
388                Ok(receiver) => receiver,
389                Err(e) => return Err(ConcurrentErrors::Thread(Box::new(e))),
390            };
391
392            receivers.push(t);
393        }
394
395        let Ok(walk_result) = producer.join() else {
396            return Err(ConcurrentErrors::Producer(
397                "Child thread panicked".to_owned(),
398            ));
399        };
400        walk_result?;
401
402        // Poison the receiver, now that the producer is finished.
403        for _ in 0..self.num_jobs {
404            if let Err(e) = sender.send(None) {
405                return Err(ConcurrentErrors::Sender(Box::new(e)));
406            }
407        }
408
409        for receiver in receivers {
410            if receiver.join().is_err() {
411                return Err(ConcurrentErrors::Receiver(
412                    "A thread used to process a file panicked".to_owned(),
413                ));
414            }
415        }
416
417        Ok(())
418    }
419}
420
421#[cfg(test)]
422#[path = "concurrent_files_tests.rs"]
423mod tests;