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