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;