Skip to main content

aft/inspect/
dispatch.rs

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