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;