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;