Skip to main content

aft/inspect/
dispatch.rs

1use std::sync::atomic::{AtomicUsize, Ordering};
2use std::sync::{Arc, LazyLock};
3use std::thread;
4use std::time::Instant;
5
6use crossbeam_channel::{unbounded, Receiver, Sender};
7
8use super::job::{InspectCategory, InspectJob, InspectResult};
9
10pub type InspectWorker = Arc<dyn Fn(InspectJob) -> InspectResult + Send + Sync + 'static>;
11
12#[derive(Clone)]
13pub struct DispatchHandles {
14    pub request_tx: Sender<InspectJob>,
15    pub result_rx: Receiver<InspectResult>,
16    pub pool: Arc<rayon::ThreadPool>,
17}
18
19/// Number of live workers in the process-wide inspect pool. The pool's start
20/// and exit handlers maintain this counter for platform-independent diagnostics.
21static INSPECT_THREAD_COUNT: AtomicUsize = AtomicUsize::new(0);
22
23/// The inspect pool is process-wide because roots are independently evictable
24/// while their scans still share the daemon process. Inspect mixes file IO and
25/// parsing; `min(8, available_parallelism)` is the measured point where more
26/// workers stop improving the supported repositories while leaving cores for
27/// interactive lanes.
28///
29/// `ColdBuildLimiter` remains separate: it admits concurrent heavy operations
30/// (including Tier-2 scans), while this pool bounds the threads those admitted
31/// operations can use. The pool is intentionally process-lifetime; dropping a
32/// root's `InspectManager` only releases that root's handle to this pool.
33static INSPECT_POOL: LazyLock<Arc<rayon::ThreadPool>> = LazyLock::new(|| {
34    Arc::new(
35        rayon::ThreadPoolBuilder::new()
36            .num_threads(default_pool_size())
37            .thread_name(|index| format!("aft-inspect-{index}"))
38            // Rayon defaults workers to ~2MB stacks (vs the main thread's 8MB).
39            // The duplicates scanner walks the AST recursively, and deep trees
40            // (minified bundles, generated code, long chains) previously
41            // overflowed a 2MB worker stack and SIGABRT'd the whole bridge.
42            // Match the main thread's 8MB so the bounded recursion in
43            // collect_fragments (MAX_FRAGMENT_DEPTH) has comfortable headroom.
44            .stack_size(8 * 1024 * 1024)
45            .start_handler(|_| {
46                INSPECT_THREAD_COUNT.fetch_add(1, Ordering::SeqCst);
47            })
48            .exit_handler(|_| {
49                INSPECT_THREAD_COUNT.fetch_sub(1, Ordering::SeqCst);
50            })
51            .build()
52            .expect("inspect worker pool must build"),
53    )
54});
55
56pub fn start_dispatch_loop(worker: InspectWorker) -> DispatchHandles {
57    let (request_tx, request_rx) = unbounded::<InspectJob>();
58    let (result_tx, result_rx) = unbounded::<InspectResult>();
59    let pool = inspect_pool();
60
61    let loop_pool = Arc::clone(&pool);
62    thread::spawn(move || dispatch_loop(request_rx, result_tx, loop_pool, worker));
63
64    DispatchHandles {
65        request_tx,
66        result_rx,
67        pool,
68    }
69}
70
71fn inspect_pool() -> Arc<rayon::ThreadPool> {
72    Arc::clone(&INSPECT_POOL)
73}
74
75#[doc(hidden)]
76pub fn inspect_pool_size_for_test() -> usize {
77    inspect_pool().current_num_threads()
78}
79
80#[doc(hidden)]
81pub fn inspect_pool_thread_count_for_test() -> usize {
82    INSPECT_THREAD_COUNT.load(Ordering::SeqCst)
83}
84
85pub fn default_worker() -> InspectWorker {
86    Arc::new(dispatch_category)
87}
88
89fn dispatch_loop(
90    request_rx: Receiver<InspectJob>,
91    result_tx: Sender<InspectResult>,
92    pool: Arc<rayon::ThreadPool>,
93    worker: InspectWorker,
94) {
95    while let Ok(job) = request_rx.recv() {
96        let tx = result_tx.clone();
97        let worker = Arc::clone(&worker);
98        pool.spawn_fifo(move || {
99            let result = worker(job);
100            let _ = tx.send(result);
101        });
102    }
103}
104
105fn dispatch_category(job: InspectJob) -> InspectResult {
106    use crate::inspect::scanners;
107
108    match job.category {
109        InspectCategory::Todos => scanners::todos::run_todos_scan(&job),
110        InspectCategory::Metrics => scanners::metrics::run_metrics_scan(&job),
111        InspectCategory::DeadCode => scanners::dead_code::run_dead_code_scan(&job),
112        InspectCategory::UnusedExports => scanners::unused_exports::run_unused_exports_scan(&job),
113        InspectCategory::Duplicates => scanners::duplicates::run_duplicates_scan(&job),
114        InspectCategory::Cycles => scanners::cycles::run_cycles_scan(&job),
115        InspectCategory::Diagnostics => {
116            // Diagnostics are backed by the AppContext LSP manager and run via
117            // the serial LSP/status lane in `handle_inspect` — never through
118            // this rayon worker pool. Reaching this arm means a caller routed
119            // Diagnostics into the worker path incorrectly; surface that as a
120            // routing bug instead of a misleading "pending" status.
121            let started = Instant::now();
122            InspectResult::failed(
123                &job,
124                "diagnostics must run on the main thread (run_diagnostics_category), \
125                 not the rayon inspect worker pool",
126                started.elapsed(),
127            )
128        }
129        other => {
130            let started = Instant::now();
131            InspectResult::failed(
132                &job,
133                format!("inspect category '{other}' is not active in v0.33"),
134                started.elapsed(),
135            )
136        }
137    }
138}
139
140fn default_pool_size() -> usize {
141    // Dev-only override for reproducing thread-regime-dependent behaviour
142    // (glibc allocates arenas per contending thread, so pool width changes
143    // fragmentation). Not a tuning surface and deliberately undocumented.
144    resolve_pool_size(std::env::var("AFT_INSPECT_POOL_THREADS").ok().as_deref())
145}
146
147/// Split from the env read so the parsing and clamping are testable without
148/// mutating process-global state: `INSPECT_POOL` is a `LazyLock`, and any
149/// concurrent test that builds an `InspectManager` would otherwise capture
150/// whatever width the env happened to hold.
151fn resolve_pool_size(override_value: Option<&str>) -> usize {
152    if let Some(threads) = override_value.and_then(|value| value.parse::<usize>().ok()) {
153        return threads.clamp(1, 512);
154    }
155
156    std::thread::available_parallelism()
157        .map(|parallelism| parallelism.get())
158        .unwrap_or(1)
159        .min(8)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::resolve_pool_size;
165
166    #[test]
167    fn pool_thread_override_wins_and_clamps() {
168        assert_eq!(resolve_pool_size(Some("17")), 17);
169        assert_eq!(resolve_pool_size(Some("0")), 1);
170        assert_eq!(resolve_pool_size(Some("100000")), 512);
171    }
172
173    #[test]
174    fn pool_thread_override_ignores_absent_and_unparseable_values() {
175        let derived = resolve_pool_size(None);
176
177        assert_eq!(resolve_pool_size(Some("wide")), derived);
178        assert_eq!(resolve_pool_size(Some("-4")), derived);
179        assert_eq!(resolve_pool_size(Some("")), derived);
180    }
181}