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/// Hard upper bound — protects FD / RAM even on huge hosts (G-AUD-19/23).
70pub use crate::constants::HARD_CAP;
71/// Async I/O may oversubscribe cores (SSH waits on RTT, not CPU).
72pub use crate::constants::IO_OVERSUBSCRIBE;
73/// Minimum concurrency (always at least one in-flight op).
74pub use crate::constants::MIN_CONCURRENCY;
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!(
108 max_concurrency = capped,
109 "installed process concurrency limit"
110 );
111}
112
113/// Install global fail-fast policy (G-O1). Default: false (partial success).
114pub fn install_fail_fast(enabled: bool) {
115 FAIL_FAST.store(enabled, Ordering::Relaxed);
116 if enabled {
117 tracing::debug!("installed fail-fast multi-host policy");
118 }
119}
120
121/// Whether multi-host fan-out should stop admission after the first unit failure.
122#[must_use]
123pub fn fail_fast_enabled() -> bool {
124 FAIL_FAST.load(Ordering::Relaxed)
125}
126
127/// Install max concurrent SCP files per host session (G-O4). `1` = serial (default).
128pub fn install_scp_file_concurrency(n: usize) {
129 let capped = n.clamp(MIN_CONCURRENCY, HARD_CAP);
130 let _ = SCP_FILE_CONCURRENCY.set(capped);
131 tracing::debug!(
132 scp_file_concurrency = capped,
133 "installed scp file concurrency"
134 );
135}
136
137/// Effective SCP per-session file concurrency (default 1).
138#[must_use]
139pub fn scp_file_concurrency() -> usize {
140 SCP_FILE_CONCURRENCY
141 .get()
142 .copied()
143 .unwrap_or(MIN_CONCURRENCY)
144}
145
146/// Effective concurrency for this process.
147///
148/// Order: installed process limit (CLI) → auto formula.
149#[must_use]
150pub fn effective_limit() -> usize {
151 if let Some(&n) = PROCESS_LIMIT.get() {
152 return n;
153 }
154 resolve_limit(None)
155}
156
157/// Resolve a limit from optional CLI override without installing it.
158///
159/// Pre-parse bootstrap uses [`auto_limit`]; post-parse installs CLI via
160/// `install_process_limit`. Env is **not** a config store (G-ERR-14 / G-UNSAFE-14).
161#[must_use]
162pub fn resolve_limit(cli_override: Option<usize>) -> usize {
163 if let Some(n) = cli_override {
164 return n.clamp(MIN_CONCURRENCY, HARD_CAP);
165 }
166 auto_limit()
167}
168
169/// Auto formula: CPUs × oversubscribe vs free-RAM budget, clamped.
170///
171/// When free RAM is unknown (non-Linux /proc path), the CPU budget is further
172/// clamped by [`NON_LINUX_CPU_CAP`] so low-RAM macOS/Windows hosts do not
173/// oversubscribe solely from `cpus × IO_OVERSUBSCRIBE` (G-PAR-25).
174#[must_use]
175pub fn auto_limit() -> usize {
176 let cpus = std::thread::available_parallelism()
177 .map(|n| n.get())
178 .unwrap_or(2);
179 let cpu_budget = cpus.saturating_mul(IO_OVERSUBSCRIBE).max(MIN_CONCURRENCY);
180
181 let ram_budget = match free_ram_bytes() {
182 Some(free) => {
183 let usable = free.saturating_mul(RAM_SAFETY_NUM) / RAM_SAFETY_DEN;
184 // G-CLOSE-02: avoid truncating `as usize` on RAM budget math.
185 let tasks = usize::try_from(usable / RAM_PER_TASK_BYTES.max(1)).unwrap_or(usize::MAX);
186 tasks.max(MIN_CONCURRENCY)
187 }
188 // No MemAvailable: do not trust unbounded CPU×IO alone on low-RAM hosts.
189 None => cpu_budget.clamp(MIN_CONCURRENCY, NON_LINUX_CPU_CAP),
190 };
191
192 cpu_budget.min(ram_budget).clamp(MIN_CONCURRENCY, HARD_CAP)
193}
194
195/// Tokio worker thread count for `main` (before clap).
196///
197/// Workers track concurrency budget but stay modest for cold-start: at least 2,
198/// at most `min(effective, available_parallelism, 16)`.
199#[must_use]
200pub fn worker_threads() -> usize {
201 let cpus = std::thread::available_parallelism()
202 .map(|n| n.get())
203 .unwrap_or(2);
204 let budget = resolve_limit(None);
205 budget.min(cpus).clamp(2, 16)
206}
207
208/// Blocking-pool size for rare `spawn_blocking` (crypto edge / sync FS).
209#[must_use]
210pub fn max_blocking_threads() -> usize {
211 resolve_limit(None).clamp(2, 32)
212}
213
214/// Reads free RAM (Linux `MemAvailable`; other OS → `None` → CPU-only formula).
215#[must_use]
216pub fn free_ram_bytes() -> Option<u64> {
217 #[cfg(target_os = "linux")]
218 {
219 let text = std::fs::read_to_string("/proc/meminfo").ok()?;
220 for line in text.lines() {
221 if let Some(rest) = line.strip_prefix("MemAvailable:") {
222 let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
223 return Some(kb.saturating_mul(1024));
224 }
225 }
226 None
227 }
228 #[cfg(not(target_os = "linux"))]
229 {
230 None
231 }
232}
233
234/// Shared admission gate for a fan-out scope.
235#[must_use]
236pub fn semaphore(limit: usize) -> Arc<Semaphore> {
237 Arc::new(Semaphore::new(limit.clamp(MIN_CONCURRENCY, HARD_CAP)))
238}
239
240/// Acquire one owned permit (for `spawn`ed tasks).
241///
242/// Product code never calls [`Semaphore::close`]; a closed semaphore is a
243/// programming fault. We still **must not panic** on product paths (G-SEC-03):
244/// recover by admitting through an ephemeral open semaphore of capacity 1 so
245/// one-shot work can finish and surface a normal error elsewhere if needed.
246pub async fn acquire_owned(sem: &Arc<Semaphore>) -> OwnedSemaphorePermit {
247 match Arc::clone(sem).acquire_owned().await {
248 Ok(p) => p,
249 Err(_) => {
250 tracing::error!(
251 "concurrency semaphore was closed unexpectedly; admitting via ephemeral permit (G-SEC-03)"
252 );
253 // Fresh open semaphore: `acquire_owned` only fails if closed, which
254 // a just-created semaphore is not. Loop with yield if the runtime
255 // ever reports otherwise (defensive; avoids expect/unwrap).
256 loop {
257 let emergency = Arc::new(Semaphore::new(1));
258 if let Ok(p) = emergency.acquire_owned().await {
259 return p;
260 }
261 tokio::task::yield_now().await;
262 }
263 }
264 }
265}
266
267/// Peak in-flight observed since process start (test helper).
268#[must_use]
269pub fn peak_in_flight() -> usize {
270 PEAK_IN_FLIGHT.load(Ordering::Relaxed)
271}
272
273/// Reset peak counters (tests only).
274pub fn reset_peak_counters() {
275 PEAK_IN_FLIGHT.store(0, Ordering::Relaxed);
276 CURRENT_IN_FLIGHT.store(0, Ordering::Relaxed);
277}
278
279fn track_enter() {
280 let cur = CURRENT_IN_FLIGHT.fetch_add(1, Ordering::Relaxed) + 1;
281 PEAK_IN_FLIGHT.fetch_max(cur, Ordering::Relaxed);
282}
283
284fn track_leave() {
285 CURRENT_IN_FLIGHT.fetch_sub(1, Ordering::Relaxed);
286}
287
288/// RAII counter so panicking tasks still release the in-flight slot.
289struct InFlightGuard;
290impl Drop for InFlightGuard {
291 fn drop(&mut self) {
292 track_leave();
293 }
294}
295
296/// Result of one fan-out unit (preserves input order index).
297#[derive(Debug)]
298pub struct IndexedResult<R> {
299 /// Original position in the input collection.
300 pub index: usize,
301 /// Task outcome (`Ok` work result, `Err` join panic/cancel).
302 pub outcome: Result<R, JoinError>,
303}
304
305/// Bounded map over independent I/O units.
306///
307/// Admission: `Semaphore` + `acquire_owned` **before** `JoinSet::spawn`, interleaved
308/// with `join_next` so completed tasks free permits (no deadlock).
309///
310/// # Panic index (G-PAR-24)
311///
312/// Input indices are tracked by Tokio [`TaskId`] outside the task payload so a
313/// panicking unit still reports the correct `IndexedResult::index` (not
314/// `usize::MAX`). Callers that need to re-raise panics still use
315/// [`JoinError::is_panic`].
316///
317/// # Cancel safety
318///
319/// Not cancel-safe as a whole: dropping the future aborts the `JoinSet` (pending
320/// tasks cancelled). Individual unit futures should tolerate cancel if they hold
321/// remote resources (SSH disconnect on drop of client).
322///
323/// # Cooperative cancel (G-PAR-39 / G-PAR-44)
324///
325/// - [`crate::signals::should_stop`]: **stops admission** (no new `spawn_one`);
326/// in-flight units drain cooperatively (units should poll `should_stop`).
327/// - [`crate::signals::is_force_exit`]: **`JoinSet::abort_all`** then drain
328/// (same pattern as tunnel forwards). Aborted units surface as
329/// [`JoinError::is_cancelled`].
330/// - Units never admitted are omitted from the result vec (callers treat partial
331/// batch as cancelled remainder).
332pub async fn map_bounded<T, R, F, Fut>(
333 items: Vec<T>,
334 limit: usize,
335 work: F,
336) -> Vec<IndexedResult<R>>
337where
338 T: Send + 'static,
339 R: Send + 'static,
340 F: Fn(T) -> Fut + Send + Sync + 'static,
341 Fut: Future<Output = R> + Send + 'static,
342{
343 map_bounded_with(items, limit, work, |_r| false).await
344}
345
346/// Bounded fan-out with optional per-result fail-fast (G-O1).
347///
348/// When `is_failure(&result)` returns true **and** [`fail_fast_enabled`],
349/// admission stops (same as cooperative cancel). In-flight units drain;
350/// never-admitted input indices are **not** present in the returned vec
351/// (callers should pad skipped hosts for agent JSON).
352///
353/// Also respects [`crate::signals::should_stop`] / force abort (G-PAR-39).
354pub async fn map_bounded_with<T, R, F, Fut, P>(
355 items: Vec<T>,
356 limit: usize,
357 work: F,
358 is_failure: P,
359) -> Vec<IndexedResult<R>>
360where
361 T: Send + 'static,
362 R: Send + 'static,
363 F: Fn(T) -> Fut + Send + Sync + 'static,
364 Fut: Future<Output = R> + Send + 'static,
365 P: Fn(&R) -> bool + Send + Sync + 'static,
366{
367 let limit = limit.clamp(MIN_CONCURRENCY, HARD_CAP);
368 let sem = semaphore(limit);
369 let work = Arc::new(work);
370 let is_failure = Arc::new(is_failure);
371 let mut set: JoinSet<R> = JoinSet::new();
372 let mut task_index: HashMap<TaskId, usize> = HashMap::new();
373 let mut iter = items.into_iter().enumerate();
374 let mut results: Vec<IndexedResult<R>> = Vec::new();
375 let mut admit = true;
376
377 while set.len() < limit {
378 if crate::signals::should_stop() {
379 admit = false;
380 tracing::debug!("fan-out: stop admission (should_stop) during seed");
381 break;
382 }
383 let Some((index, item)) = iter.next() else {
384 break;
385 };
386 spawn_one(&mut set, &mut task_index, &sem, &work, index, item).await;
387 }
388
389 loop {
390 if set.is_empty() {
391 if !admit || crate::signals::should_stop() {
392 break;
393 }
394 if let Some((index, item)) = iter.next() {
395 spawn_one(&mut set, &mut task_index, &sem, &work, index, item).await;
396 continue;
397 }
398 break;
399 }
400
401 tokio::select! {
402 joined = set.join_next_with_id() => {
403 match joined {
404 Some(j) => {
405 let before = results.len();
406 push_joined(&mut results, &mut task_index, j);
407 // G-O1: stop admission on first unit failure when enabled.
408 if fail_fast_enabled() {
409 if let Some(last) = results.get(before..) {
410 for r in last {
411 if let Ok(ref val) = r.outcome {
412 if is_failure(val) && admit {
413 admit = false;
414 tracing::debug!(
415 index = r.index,
416 "fan-out: fail-fast stop admission"
417 );
418 }
419 }
420 }
421 }
422 }
423 }
424 None => break,
425 }
426
427 if crate::signals::is_force_exit() {
428 tracing::debug!(remaining = set.len(), "fan-out: force_exit abort_all");
429 set.abort_all();
430 while let Some(j) = set.join_next_with_id().await {
431 push_joined(&mut results, &mut task_index, j);
432 }
433 break;
434 }
435
436 if crate::signals::should_stop() {
437 if admit {
438 admit = false;
439 tracing::debug!("fan-out: stop admission (should_stop); draining");
440 }
441 continue;
442 }
443
444 if admit {
445 if let Some((index, item)) = iter.next() {
446 spawn_one(&mut set, &mut task_index, &sem, &work, index, item).await;
447 }
448 }
449 }
450 _ = tokio::time::sleep(std::time::Duration::from_millis(
451 crate::constants::FAN_OUT_SIGNAL_POLL_INTERVAL_MS,
452 )) => {
453 if crate::signals::is_force_exit() {
454 tracing::debug!(remaining = set.len(), "fan-out: force_exit abort_all (timer)");
455 set.abort_all();
456 while let Some(j) = set.join_next_with_id().await {
457 push_joined(&mut results, &mut task_index, j);
458 }
459 break;
460 }
461 if admit && crate::signals::should_stop() {
462 admit = false;
463 tracing::debug!("fan-out: stop admission (should_stop via timer)");
464 }
465 }
466 }
467 }
468
469 results.sort_by_key(|r| r.index);
470 results
471}
472
473fn push_joined<R>(
474 results: &mut Vec<IndexedResult<R>>,
475 task_index: &mut HashMap<TaskId, usize>,
476 joined: Result<(TaskId, R), JoinError>,
477) {
478 match joined {
479 Ok((id, value)) => {
480 let index = task_index.remove(&id).unwrap_or(usize::MAX);
481 results.push(IndexedResult {
482 index,
483 outcome: Ok(value),
484 });
485 }
486 Err(e) => {
487 let index = task_index.remove(&e.id()).unwrap_or(usize::MAX);
488 results.push(IndexedResult {
489 index,
490 outcome: Err(e),
491 });
492 }
493 }
494}
495
496async fn spawn_one<T, R, F, Fut>(
497 set: &mut JoinSet<R>,
498 task_index: &mut HashMap<TaskId, usize>,
499 sem: &Arc<Semaphore>,
500 work: &Arc<F>,
501 index: usize,
502 item: T,
503) where
504 T: Send + 'static,
505 R: Send + 'static,
506 F: Fn(T) -> Fut + Send + Sync + 'static,
507 Fut: Future<Output = R> + Send + 'static,
508{
509 use tracing::Instrument;
510
511 let permit = acquire_owned(sem).await;
512 let available = sem.available_permits();
513 tracing::debug!(index, available_permits = available, "fan-out admit");
514 let span = tracing::info_span!("fan_out_unit", index, available_permits = available);
515 let work = Arc::clone(work);
516 let abort = set.spawn(
517 async move {
518 track_enter();
519 let _inflight = InFlightGuard;
520 let _permit = permit;
521 work(item).await
522 }
523 .instrument(span),
524 );
525 task_index.insert(abort.id(), index);
526}
527
528/// Convenience: map and unwrap join panics via `resume_unwind`.
529///
530/// Prefer [`map_bounded`] / [`map_bounded_with`] when partial failure must be reported.
531pub async fn map_bounded_ok<T, R, F, Fut>(items: Vec<T>, limit: usize, work: F) -> Vec<R>
532where
533 T: Send + 'static,
534 R: Send + 'static,
535 F: Fn(T) -> Fut + Send + Sync + 'static,
536 Fut: Future<Output = R> + Send + 'static,
537{
538 let mut out = Vec::with_capacity(items.len());
539 for r in map_bounded(items, limit, work).await {
540 match r.outcome {
541 Ok(v) => out.push(v),
542 Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
543 Err(_) => {
544 // cancelled — skip
545 }
546 }
547 }
548 out
549}
550
551#[cfg(test)]
552#[path = "concurrency_tests.rs"]
553mod tests;