Skip to main content

ssh_cli/
concurrency.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! Bounded concurrency for multi-host SSH fan-out (Rules Rust — paralelismo).
5//!
6//! # Workload classification
7//!
8//! **I/O-bound** (SSH/TCP, SCP streams, tunnel accepts/forwards). Network RTT
9//! dominates; CPU is secondary (crypto inside russh is already async on Tokio).
10//! **Not** CPU-bound ML/batch — do **not** pull Rayon into product paths.
11//! **Heavy-memory** singletons stay on `OnceLock` / atomics elsewhere.
12//! **Subprocess / systemd-run MemoryMax:** N/A (no child fan-out of heavy
13//! workers; this binary *is* the one-shot process).
14//!
15//! # Where parallelism lives
16//!
17//! | Surface | Gate | Saturates |
18//! |---------|------|-----------|
19//! | `health-check|exec|scp --all` / `--hosts` | [`map_bounded`] + `Semaphore` | sockets, remote auth, RAM/session |
20//! | `scp` multi-file single-host | **1 session**, serial files (G-PAR-47) | one TCP + auth |
21//! | `scp` multi-host × multi-file | `map_bounded` per host + serial files | sessions (not files) |
22//! | Tunnel local accepts → channel forwards | `JoinSet` + `Semaphore` | FDs, SSH channels |
23//! | Tokio runtime workers | capped from concurrency budget | scheduler threads |
24//!
25//! Host lists are built only via [`crate::vps::resolve_host_jobs`] (G-PAR-31).
26//! Sequential paths (local TOML CRUD, locale, completions, secrets key ops)
27//! are **intentionally** serial: work is tiny vs coordination overhead — see
28//! module docs on each command handler (G-PAR-28).
29//!
30//! Fan-out units get `tracing` span `fan_out_unit` (G-PAR-52) + `available_permits`
31//! debug on admit (G-PAR-40).
32//!
33//! # Permit formula
34//!
35//! ```text
36//! permits = clamp(
37//!   min(
38//!     available_parallelism() * IO_OVERSUBSCRIBE,
39//!     (MemAvailable * SAFETY_NUM / SAFETY_DEN) / RAM_PER_TASK_BYTES
40//!   ),
41//!   MIN_CONCURRENCY ..= HARD_CAP
42//! )
43//! ```
44//!
45//! - **IO_OVERSUBSCRIBE = 4** — async I/O may exceed cores without CPU thrash.
46//! - **SAFETY_NUM/DEN = 1/2** — 50% of free RAM reserved for OS / peer tools.
47//! - **RAM_PER_TASK_BYTES = 16 MiB** — ballpark for one authenticated russh
48//!   session + capture buffers (revalidate with `/usr/bin/time -v` after major
49//!   dependency bumps; Maximum resident set size of a single `health-check`).
50//! - **Non-Linux / no MemAvailable:** CPU budget capped at **8** so `cpus×4`
51//!   cannot alone open too many sessions on low-RAM macOS/Windows (G-PAR-25).
52//! - Override: CLI `--max-concurrency=N` > auto formula (no env-as-store; G-UNSAFE-14) >
53//!   auto formula. `N=0` is rejected at clap parse.
54//!
55//! # Cancel / panic
56//!
57//! Permits are held as `OwnedSemaphorePermit` and dropped on task end (RAII),
58//! including panic unwind of the task future. Callers must still handle
59//! [`tokio::task::JoinError::is_panic`].
60
61use std::collections::HashMap;
62use std::future::Future;
63use std::sync::atomic::{AtomicUsize, Ordering};
64use std::sync::{Arc, OnceLock};
65
66use tokio::sync::{OwnedSemaphorePermit, Semaphore};
67use tokio::task::{Id as TaskId, JoinError, JoinSet};
68
69/// Minimum concurrency (always at least one in-flight op).
70pub use crate::constants::MIN_CONCURRENCY;
71/// Hard upper bound — protects FD / RAM even on huge hosts (G-AUD-19/23).
72pub use crate::constants::HARD_CAP;
73/// Async I/O may oversubscribe cores (SSH waits on RTT, not CPU).
74pub use crate::constants::IO_OVERSUBSCRIBE;
75/// When free RAM cannot be read (non-Linux), cap CPU×IO budget conservatively
76/// so low-RAM hosts do not open `cpus×4` sessions blindly (G-PAR-25).
77pub use crate::constants::NON_LINUX_CPU_CAP;
78/// Documented per-session RAM budget (bytes). See module docs + [`crate::constants::RAM_PER_TASK_BYTES`].
79pub use crate::constants::RAM_PER_TASK_BYTES;
80/// Keep half of free RAM for the OS and sibling processes.
81const RAM_SAFETY_NUM: u64 = 1;
82const RAM_SAFETY_DEN: u64 = 2;
83
84// G-UNSAFE-14: concurrency is CLI `--max-concurrency` + auto formula only
85// (ENV_MAX_CONCURRENCY env store removed).
86
87/// Process-wide limit set after CLI parse (or defaults from auto formula).
88static PROCESS_LIMIT: OnceLock<usize> = OnceLock::new();
89
90/// G-O1: stop admitting new fan-out units after the first unit failure.
91static FAIL_FAST: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
92
93/// G-O4: max concurrent SCP file transfers on one session (default 1 = serial).
94static SCP_FILE_CONCURRENCY: OnceLock<usize> = OnceLock::new();
95
96/// Peak in-flight tasks observed by [`map_bounded`] (tests / diagnostics).
97static PEAK_IN_FLIGHT: AtomicUsize = AtomicUsize::new(0);
98static CURRENT_IN_FLIGHT: AtomicUsize = AtomicUsize::new(0);
99
100/// Install the process concurrency limit once (CLI `--max-concurrency` or auto).
101///
102/// Subsequent calls are ignored (`OnceLock`). Prefer calling from `dispatch`
103/// before any multi-host fan-out.
104pub fn install_process_limit(limit: usize) {
105    let capped = limit.clamp(MIN_CONCURRENCY, HARD_CAP);
106    let _ = PROCESS_LIMIT.set(capped);
107    tracing::debug!(max_concurrency = capped, "installed process concurrency limit");
108}
109
110/// Install global fail-fast policy (G-O1). Default: false (partial success).
111pub fn install_fail_fast(enabled: bool) {
112    FAIL_FAST.store(enabled, Ordering::Relaxed);
113    if enabled {
114        tracing::debug!("installed fail-fast multi-host policy");
115    }
116}
117
118/// Whether multi-host fan-out should stop admission after the first unit failure.
119#[must_use]
120pub fn fail_fast_enabled() -> bool {
121    FAIL_FAST.load(Ordering::Relaxed)
122}
123
124/// Install max concurrent SCP files per host session (G-O4). `1` = serial (default).
125pub fn install_scp_file_concurrency(n: usize) {
126    let capped = n.clamp(MIN_CONCURRENCY, HARD_CAP);
127    let _ = SCP_FILE_CONCURRENCY.set(capped);
128    tracing::debug!(scp_file_concurrency = capped, "installed scp file concurrency");
129}
130
131/// Effective SCP per-session file concurrency (default 1).
132#[must_use]
133pub fn scp_file_concurrency() -> usize {
134    SCP_FILE_CONCURRENCY.get().copied().unwrap_or(MIN_CONCURRENCY)
135}
136
137/// Effective concurrency for this process.
138///
139/// Order: installed process limit (CLI) → auto formula.
140#[must_use]
141pub fn effective_limit() -> usize {
142    if let Some(&n) = PROCESS_LIMIT.get() {
143        return n;
144    }
145    resolve_limit(None)
146}
147
148/// Resolve a limit from optional CLI override without installing it.
149///
150/// Pre-parse bootstrap uses [`auto_limit`]; post-parse installs CLI via
151/// `install_process_limit`. Env is **not** a config store (G-ERR-14 / G-UNSAFE-14).
152#[must_use]
153pub fn resolve_limit(cli_override: Option<usize>) -> usize {
154    if let Some(n) = cli_override {
155        return n.clamp(MIN_CONCURRENCY, HARD_CAP);
156    }
157    auto_limit()
158}
159
160/// Auto formula: CPUs × oversubscribe vs free-RAM budget, clamped.
161///
162/// When free RAM is unknown (non-Linux /proc path), the CPU budget is further
163/// clamped by [`NON_LINUX_CPU_CAP`] so low-RAM macOS/Windows hosts do not
164/// oversubscribe solely from `cpus × IO_OVERSUBSCRIBE` (G-PAR-25).
165#[must_use]
166pub fn auto_limit() -> usize {
167    let cpus = std::thread::available_parallelism()
168        .map(|n| n.get())
169        .unwrap_or(2);
170    let cpu_budget = cpus.saturating_mul(IO_OVERSUBSCRIBE).max(MIN_CONCURRENCY);
171
172    let ram_budget = match free_ram_bytes() {
173        Some(free) => {
174            let usable = free.saturating_mul(RAM_SAFETY_NUM) / RAM_SAFETY_DEN;
175            // G-CLOSE-02: avoid truncating `as usize` on RAM budget math.
176            let tasks = usize::try_from(usable / RAM_PER_TASK_BYTES.max(1)).unwrap_or(usize::MAX);
177            tasks.max(MIN_CONCURRENCY)
178        }
179        // No MemAvailable: do not trust unbounded CPU×IO alone on low-RAM hosts.
180        None => cpu_budget.clamp(MIN_CONCURRENCY, NON_LINUX_CPU_CAP),
181    };
182
183    cpu_budget.min(ram_budget).clamp(MIN_CONCURRENCY, HARD_CAP)
184}
185
186/// Tokio worker thread count for `main` (before clap).
187///
188/// Workers track concurrency budget but stay modest for cold-start: at least 2,
189/// at most `min(effective, available_parallelism, 16)`.
190#[must_use]
191pub fn worker_threads() -> usize {
192    let cpus = std::thread::available_parallelism()
193        .map(|n| n.get())
194        .unwrap_or(2);
195    let budget = resolve_limit(None);
196    budget.min(cpus).clamp(2, 16)
197}
198
199/// Blocking-pool size for rare `spawn_blocking` (crypto edge / sync FS).
200#[must_use]
201pub fn max_blocking_threads() -> usize {
202    resolve_limit(None).clamp(2, 32)
203}
204
205/// Reads free RAM (Linux `MemAvailable`; other OS → `None` → CPU-only formula).
206#[must_use]
207pub fn free_ram_bytes() -> Option<u64> {
208    #[cfg(target_os = "linux")]
209    {
210        let text = std::fs::read_to_string("/proc/meminfo").ok()?;
211        for line in text.lines() {
212            if let Some(rest) = line.strip_prefix("MemAvailable:") {
213                let kb: u64 = rest
214                    .split_whitespace()
215                    .next()?
216                    .parse()
217                    .ok()?;
218                return Some(kb.saturating_mul(1024));
219            }
220        }
221        None
222    }
223    #[cfg(not(target_os = "linux"))]
224    {
225        None
226    }
227}
228
229/// Shared admission gate for a fan-out scope.
230#[must_use]
231pub fn semaphore(limit: usize) -> Arc<Semaphore> {
232    Arc::new(Semaphore::new(limit.clamp(MIN_CONCURRENCY, HARD_CAP)))
233}
234
235/// Acquire one owned permit (for `spawn`ed tasks).
236///
237/// Product code never calls [`Semaphore::close`]; a closed semaphore is a
238/// programming fault. We still **must not panic** on product paths (G-SEC-03):
239/// recover by admitting through an ephemeral open semaphore of capacity 1 so
240/// one-shot work can finish and surface a normal error elsewhere if needed.
241pub async fn acquire_owned(sem: &Arc<Semaphore>) -> OwnedSemaphorePermit {
242    match Arc::clone(sem).acquire_owned().await {
243        Ok(p) => p,
244        Err(_) => {
245            tracing::error!(
246                "concurrency semaphore was closed unexpectedly; admitting via ephemeral permit (G-SEC-03)"
247            );
248            // Fresh open semaphore: `acquire_owned` only fails if closed, which
249            // a just-created semaphore is not. Loop with yield if the runtime
250            // ever reports otherwise (defensive; avoids expect/unwrap).
251            loop {
252                let emergency = Arc::new(Semaphore::new(1));
253                if let Ok(p) = emergency.acquire_owned().await {
254                    return p;
255                }
256                tokio::task::yield_now().await;
257            }
258        }
259    }
260}
261
262/// Peak in-flight observed since process start (test helper).
263#[must_use]
264pub fn peak_in_flight() -> usize {
265    PEAK_IN_FLIGHT.load(Ordering::Relaxed)
266}
267
268/// Reset peak counters (tests only).
269pub fn reset_peak_counters() {
270    PEAK_IN_FLIGHT.store(0, Ordering::Relaxed);
271    CURRENT_IN_FLIGHT.store(0, Ordering::Relaxed);
272}
273
274fn track_enter() {
275    let cur = CURRENT_IN_FLIGHT.fetch_add(1, Ordering::Relaxed) + 1;
276    PEAK_IN_FLIGHT.fetch_max(cur, Ordering::Relaxed);
277}
278
279fn track_leave() {
280    CURRENT_IN_FLIGHT.fetch_sub(1, Ordering::Relaxed);
281}
282
283/// RAII counter so panicking tasks still release the in-flight slot.
284struct InFlightGuard;
285impl Drop for InFlightGuard {
286    fn drop(&mut self) {
287        track_leave();
288    }
289}
290
291/// Result of one fan-out unit (preserves input order index).
292#[derive(Debug)]
293pub struct IndexedResult<R> {
294    /// Original position in the input collection.
295    pub index: usize,
296    /// Task outcome (`Ok` work result, `Err` join panic/cancel).
297    pub outcome: Result<R, JoinError>,
298}
299
300/// Bounded map over independent I/O units.
301///
302/// Admission: `Semaphore` + `acquire_owned` **before** `JoinSet::spawn`, interleaved
303/// with `join_next` so completed tasks free permits (no deadlock).
304///
305/// # Panic index (G-PAR-24)
306///
307/// Input indices are tracked by Tokio [`TaskId`] outside the task payload so a
308/// panicking unit still reports the correct `IndexedResult::index` (not
309/// `usize::MAX`). Callers that need to re-raise panics still use
310/// [`JoinError::is_panic`].
311///
312/// # Cancel safety
313///
314/// Not cancel-safe as a whole: dropping the future aborts the `JoinSet` (pending
315/// tasks cancelled). Individual unit futures should tolerate cancel if they hold
316/// remote resources (SSH disconnect on drop of client).
317///
318/// # Cooperative cancel (G-PAR-39 / G-PAR-44)
319///
320/// - [`crate::signals::should_stop`]: **stops admission** (no new `spawn_one`);
321///   in-flight units drain cooperatively (units should poll `should_stop`).
322/// - [`crate::signals::is_force_exit`]: **`JoinSet::abort_all`** then drain
323///   (same pattern as tunnel forwards). Aborted units surface as
324///   [`JoinError::is_cancelled`].
325/// - Units never admitted are omitted from the result vec (callers treat partial
326///   batch as cancelled remainder).
327pub async fn map_bounded<T, R, F, Fut>(
328    items: Vec<T>,
329    limit: usize,
330    work: F,
331) -> Vec<IndexedResult<R>>
332where
333    T: Send + 'static,
334    R: Send + 'static,
335    F: Fn(T) -> Fut + Send + Sync + 'static,
336    Fut: Future<Output = R> + Send + 'static,
337{
338    map_bounded_with(items, limit, work, |_r| false).await
339}
340
341/// Bounded fan-out with optional per-result fail-fast (G-O1).
342///
343/// When `is_failure(&result)` returns true **and** [`fail_fast_enabled`],
344/// admission stops (same as cooperative cancel). In-flight units drain;
345/// never-admitted input indices are **not** present in the returned vec
346/// (callers should pad skipped hosts for agent JSON).
347///
348/// Also respects [`crate::signals::should_stop`] / force abort (G-PAR-39).
349pub async fn map_bounded_with<T, R, F, Fut, P>(
350    items: Vec<T>,
351    limit: usize,
352    work: F,
353    is_failure: P,
354) -> Vec<IndexedResult<R>>
355where
356    T: Send + 'static,
357    R: Send + 'static,
358    F: Fn(T) -> Fut + Send + Sync + 'static,
359    Fut: Future<Output = R> + Send + 'static,
360    P: Fn(&R) -> bool + Send + Sync + 'static,
361{
362    let limit = limit.clamp(MIN_CONCURRENCY, HARD_CAP);
363    let sem = semaphore(limit);
364    let work = Arc::new(work);
365    let is_failure = Arc::new(is_failure);
366    let mut set: JoinSet<R> = JoinSet::new();
367    let mut task_index: HashMap<TaskId, usize> = HashMap::new();
368    let mut iter = items.into_iter().enumerate();
369    let mut results: Vec<IndexedResult<R>> = Vec::new();
370    let mut admit = true;
371
372    while set.len() < limit {
373        if crate::signals::should_stop() {
374            admit = false;
375            tracing::debug!("fan-out: stop admission (should_stop) during seed");
376            break;
377        }
378        let Some((index, item)) = iter.next() else {
379            break;
380        };
381        spawn_one(&mut set, &mut task_index, &sem, &work, index, item).await;
382    }
383
384    loop {
385        if set.is_empty() {
386            if !admit || crate::signals::should_stop() {
387                break;
388            }
389            if let Some((index, item)) = iter.next() {
390                spawn_one(&mut set, &mut task_index, &sem, &work, index, item).await;
391                continue;
392            }
393            break;
394        }
395
396        tokio::select! {
397            joined = set.join_next_with_id() => {
398                match joined {
399                    Some(j) => {
400                        let before = results.len();
401                        push_joined(&mut results, &mut task_index, j);
402                        // G-O1: stop admission on first unit failure when enabled.
403                        if fail_fast_enabled() {
404                            if let Some(last) = results.get(before..) {
405                                for r in last {
406                                    if let Ok(ref val) = r.outcome {
407                                        if is_failure(val) && admit {
408                                            admit = false;
409                                            tracing::debug!(
410                                                index = r.index,
411                                                "fan-out: fail-fast stop admission"
412                                            );
413                                        }
414                                    }
415                                }
416                            }
417                        }
418                    }
419                    None => break,
420                }
421
422                if crate::signals::is_force_exit() {
423                    tracing::debug!(remaining = set.len(), "fan-out: force_exit abort_all");
424                    set.abort_all();
425                    while let Some(j) = set.join_next_with_id().await {
426                        push_joined(&mut results, &mut task_index, j);
427                    }
428                    break;
429                }
430
431                if crate::signals::should_stop() {
432                    if admit {
433                        admit = false;
434                        tracing::debug!("fan-out: stop admission (should_stop); draining");
435                    }
436                    continue;
437                }
438
439                if admit {
440                    if let Some((index, item)) = iter.next() {
441                        spawn_one(&mut set, &mut task_index, &sem, &work, index, item).await;
442                    }
443                }
444            }
445            _ = tokio::time::sleep(std::time::Duration::from_millis(
446                crate::constants::FAN_OUT_SIGNAL_POLL_INTERVAL_MS,
447            )) => {
448                if crate::signals::is_force_exit() {
449                    tracing::debug!(remaining = set.len(), "fan-out: force_exit abort_all (timer)");
450                    set.abort_all();
451                    while let Some(j) = set.join_next_with_id().await {
452                        push_joined(&mut results, &mut task_index, j);
453                    }
454                    break;
455                }
456                if admit && crate::signals::should_stop() {
457                    admit = false;
458                    tracing::debug!("fan-out: stop admission (should_stop via timer)");
459                }
460            }
461        }
462    }
463
464    results.sort_by_key(|r| r.index);
465    results
466}
467
468
469fn push_joined<R>(
470    results: &mut Vec<IndexedResult<R>>,
471    task_index: &mut HashMap<TaskId, usize>,
472    joined: Result<(TaskId, R), JoinError>,
473) {
474    match joined {
475        Ok((id, value)) => {
476            let index = task_index.remove(&id).unwrap_or(usize::MAX);
477            results.push(IndexedResult {
478                index,
479                outcome: Ok(value),
480            });
481        }
482        Err(e) => {
483            let index = task_index.remove(&e.id()).unwrap_or(usize::MAX);
484            results.push(IndexedResult {
485                index,
486                outcome: Err(e),
487            });
488        }
489    }
490}
491
492async fn spawn_one<T, R, F, Fut>(
493    set: &mut JoinSet<R>,
494    task_index: &mut HashMap<TaskId, usize>,
495    sem: &Arc<Semaphore>,
496    work: &Arc<F>,
497    index: usize,
498    item: T,
499) where
500    T: Send + 'static,
501    R: Send + 'static,
502    F: Fn(T) -> Fut + Send + Sync + 'static,
503    Fut: Future<Output = R> + Send + 'static,
504{
505    use tracing::Instrument;
506
507    let permit = acquire_owned(sem).await;
508    let available = sem.available_permits();
509    tracing::debug!(index, available_permits = available, "fan-out admit");
510    let span = tracing::info_span!("fan_out_unit", index, available_permits = available);
511    let work = Arc::clone(work);
512    let abort = set.spawn(
513        async move {
514            track_enter();
515            let _inflight = InFlightGuard;
516            let _permit = permit;
517            work(item).await
518        }
519        .instrument(span),
520    );
521    task_index.insert(abort.id(), index);
522}
523
524/// Convenience: map and unwrap join panics via `resume_unwind`.
525///
526/// Prefer [`map_bounded`] / [`map_bounded_with`] when partial failure must be reported.
527pub async fn map_bounded_ok<T, R, F, Fut>(items: Vec<T>, limit: usize, work: F) -> Vec<R>
528where
529    T: Send + 'static,
530    R: Send + 'static,
531    F: Fn(T) -> Fut + Send + Sync + 'static,
532    Fut: Future<Output = R> + Send + 'static,
533{
534    let mut out = Vec::with_capacity(items.len());
535    for r in map_bounded(items, limit, work).await {
536        match r.outcome {
537            Ok(v) => out.push(v),
538            Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
539            Err(_) => {
540                // cancelled — skip
541            }
542        }
543    }
544    out
545}
546
547
548#[cfg(test)]
549#[path = "concurrency_tests.rs"]
550mod tests;